Commit 473e68e6 authored by Andy-bubu's avatar Andy-bubu

feat: 同步应用切换动态分组规范

parent 7b1c7ebb
......@@ -19,7 +19,8 @@
- 页面继续拥有查询条件、选择记录、弹窗开关、表单草稿和请求时机。
- HeroUI 组件拥有打开态、焦点、键盘导航、选中态和 Portal 可访问性。
- 移动导航使用 HeroUI Pro Sheet 与 Sidebar compound 内容,并在打开应用切换前关闭移动 Sheet;Dialog 使用明确的可访问名称。
- 应用切换 Modal 的卡片列表和加载骨架共用响应式网格:移动端一列、`sm` 起固定两列、间距 12px,长列表在 Modal Body 内滚动。
- 应用切换 Modal 从 `SiteContext` 消费 `/info -> site.group`,仅在页面编排层把现有可访问应用按动态配置分组;请求、权限、应用合并、当前应用识别和导航仍由原有业务边界负责。空组/无权限成员被过滤,重复配置只展示一次,未分组应用进入 `其他应用`,无配置时使用 `全部应用`。
- 应用切换 Modal 的卡片列表和加载骨架共用响应式网格:移动端一列、`sm` 起固定两列、间距 12px;专用共享布局类只负责 46rem 宽度、42rem 最大高度和视口安全边距,长列表仅在 Modal Body 内滚动,HeroUI 自身负责 Overlay 外观、圆角、阴影和交互。
- 数据转换仍在现有 service/util 边界完成;UI 迁移不改变接口协议。
- Modal 是独立 Overlay/L1 平面;查看态字段组使用 `Surface secondary`/L2。详情中的关联记录是 L2 内的静态 L3 子区域,使用 HeroUI `Surface tertiary` 包裹共享 `DataTable`,并通过 HeroUI Pro DataGrid 官方 `secondary` variant 区分表头和 L3 背景;页面不得覆盖 DataGrid 的 BEM、slot、背景或圆角。
- 业务列表页面标题由 Shell 的 HeroUI Pro Navbar 统一呈现,`RecordWorkbench` 内容区不再重复渲染同名 `<h2>`,以避免标题重复并让搜索/工具栏保持首屏优先级。
......@@ -33,4 +34,4 @@
## 验收证据
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入 `docs/xinyuan-ui-audit.md`。目标服务保持运行于 `http://localhost:8002/integrity_risk_prevention_new/`;基准服务运行于 `http://localhost:8006/problem_rectification_new/`。业务 API、参数、数据结构、路由、权限、校验、分页口径、保存回调、登录退出和请求时机均未改动;变更集中在 HeroUI 组件结构、共享布局、动作归位、响应式 gutter 和无效 DOM 属性清理。
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入 `docs/xinyuan-ui-audit.md`。本轮目标服务运行于 `http://localhost:8000/integrity_risk_prevention_new/`,应用切换专项基准运行于 `http://localhost:8004/model_analysis/`。业务 API、参数、数据结构、路由、权限、校验、分页口径、保存回调、登录退出和请求时机均未改动;变更集中在 HeroUI 组件结构、动态分组编排和共享布局几何。
......@@ -7,7 +7,8 @@
- [x] 记录未修改应用的原始启动状态。
- [x] 同步权威 theme、Tokens、Logo 与 Shell 基线。
- [x] Shell 使用 HeroUI Pro Sidebar/Navbar,支持 offcanvas、主题切换、用户菜单与退出。
- [x] 应用切换使用 HeroUI Modal 和移动一列、`sm` 起固定两列应用卡片与加载骨架。
- [x] 应用切换使用 HeroUI Modal,并同步 `model_analysis` 的 46rem/42rem/28px 几何、Body 内滚动、移动一列及 `sm` 起固定两列应用卡片与加载骨架。
- [x] 应用切换按 `site.group` 动态分组,保留配置顺序和真实数量,并覆盖空组过滤、无权限过滤、重复成员去重、`其他应用` 及 `全部应用` 回退。
- [x] 登录页使用 HeroUI Card/TextField/Input/Button/Alert。
- [x] 所有活动列表使用 DataGrid、ActionBar、EmptyState、Pagination。
- [x] 全局 DataGrid populated/loading/empty 高度链与宽表内部滚动复核完成;长文本统一最多两行 + HeroUI Tooltip。
......@@ -30,4 +31,4 @@
- [x] `pnpm run build`、`git diff --check`、Skill audit 已执行;audit 的 pnpm store/CJS 子路径警告已人工复核为工具误报。
- [x] Umi watcher 已排除浏览器验收/构建产物;开发态 `umi.css`/Inter 字体 HMR 循环已复测并记录。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;`recharts` 仅保留原依赖且无源码 import。应用切换遵循问题整改基准和鑫元规范,移动端一列、`sm` 起固定两列。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;`recharts` 仅保留原依赖且无源码 import。应用切换遵循 `model_analysis` 专项基准和鑫元规范,使用动态分组、移动一列及 `sm` 起固定两列。
......@@ -36,7 +36,8 @@
- 批量动作只通过 `@heroui-pro/react/action-bar` 的浮动 ActionBar 呈现;普通工具栏保留导入、全量导出和新建,主操作在最右侧。
- 共享 DataGrid 以真实列最小可读宽度之和与当前 viewport 比较决定内部滚动和固定列,不以列数阈值决定宽表。
- 应用切换遵循问题整改基准:移动端一列、`sm` 起固定两列,卡片列表与加载骨架均使用 12px 间距;桌面不扩展为三列。
- 应用切换严格遵循 `model_analysis` 专项基准:HeroUI Modal 宽度为 `min(46rem, 100vw - 2rem)`、最大高度为 `min(42rem, 100dvh - 2rem)`、28px inset,仅 Body 滚动;移动端一列、`sm` 起固定两列,卡片列表与加载骨架均使用 12px 间距,桌面不扩展为三列。
- 应用切换分组必须读取 `GET /info -> site.group` 的 `{ label, applications }` 动态配置,按用户真实可访问应用匹配并保持分组和组内顺序;空组和无权限应用不展示,未分组应用进入 `其他应用`,无分组配置时展示单个 `全部应用`,每组右侧显示真实 `{count} 个应用`。禁止写死真实组名、成员或数量。
## 布局约定
......
......@@ -2,7 +2,8 @@
## 基线与范围
- 唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
- 整体唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
- 应用切换专项唯一实现基准:`/Users/liuchenlong/Desktop/job/model_analysis`
- 目标分支:`ui/xinyuan-heroui-migration`
- 目标应用:廉洁风险防控
- 原始启动:2026-08-21,目标服务实际监听 `http://localhost:8002/integrity_risk_prevention_new/`(Umi 未采用请求的 8005 参数);基准服务 `http://localhost:8006/problem_rectification_new/`。
......@@ -53,6 +54,18 @@
- 生产构建浏览器会话实测 6 个应用、两列、12px 间距、唯一当前态,控制台 0 error/0 warning。浏览器临时响应仅用于验收,没有写入应用源码或业务数据。
- 工具栏主操作复核(2026-08-21):展示表页面将 `导入廉洁风险防控表(过程表)`、`导出廉洁风险防控表(展示表)`、`导出廉洁风险防控` 精简为 `导入过程表`、`导出展示表`、`导出防控措施`;1280x720 实测四个按钮均单行排列,`新建记录` 保持最右侧。过程表和措施库同步使用 `导出过程表`、`导入防控措施`、`导出措施描述`,仅调整可见文案,不改变 action key、请求、回调和业务状态。
### 应用切换动态分组专项复核(2026-08-26)
- 审计发现旧实现仅渲染无标题的两列平铺网格,虽然两列几何已对齐,但没有消费 `GET /info -> site.group`,也没有分组标题、真实数量及 `其他应用` / `全部应用` 回退,不符合 `model_analysis` 专项基准。
- 当前实现保留既有 `/application/info`、`userInfo.applications` 合并、权限结果、当前应用识别与跳转语义,仅按 `site.group` 的 `{ label, applications }` 配置组织可访问应用;保持分组与组内顺序,过滤空组、无权限应用和重复成员,并把未被配置引用的应用追加到 `其他应用`。没有分组配置时仅显示 `全部应用`。
- 结构使用 HeroUI `Modal`、`Card secondary`、`Surface tertiary`、`Avatar soft`、`Chip accent`、`Alert warning` 与 HeroUI Pro `EmptyState`;弹窗专用布局为 `min(46rem, 100vw - 2rem)`、最大高度 `min(42rem, 100dvh - 2rem)`、28px inset、Body 内滚动、移动一列及 `sm` 起固定两列。布局 CSS 不设置组件背景、颜色、描边、圆角、阴影或交互状态。
- 本轮浏览器 fixture 只用于验证分组几何、Logo fallback、长列表滚动和响应式,不写入源码或业务数据。目标和 `model_analysis` 在 1440x900、1280x720、768x1024、390x844 的浅/深色计算值逐项一致:前三个视口 Dialog 为 736px、网格为 `334px 334px`,390px 视口 Dialog 为 358px、左右各 16px、网格为单列 302px;全部视口均为 24px 圆角、28px padding、24px 关闭控件、12px gap、36px identity well 和 0 页面溢出。
- 390x844 下目标与基准的 Modal Body 均为 `clientHeight=548px`、`scrollHeight=732px`、`overflow-y=auto`,确认长列表只在 Body 内滚动。动态分组 fixture 实测顺序为“核心应用 / 业务管理 / 协同应用 / 其他应用”,数量为 `2 / 2 / 1 / 2`,空组和无权限项不渲染,重复配置只展示一次且 `aria-current` 唯一;无分组 fixture 实测仅展示“全部应用”。
- Escape、官方关闭按钮和 Backdrop 均可关闭,Dialog 可访问名称为“应用切换”,打开后焦点进入 Dialog。移除旧的自定义 Close 子节点后,干净正常态会话控制台为 0 error / 0 warning;人为 404 Logo fixture 仅用于故障 fallback 验收,不计入正常态控制台。
- 截图:目标 `/tmp/integrity-app-switcher-grouped-{1440x900,1280x720,768x1024,390x844}-{light,dark}.png`;基准 `/tmp/model-analysis-app-switcher-grouped-{1440x900,1280x720,768x1024,390x844}-{light,dark}.png`;最终移动深色 `/tmp/integrity-app-switcher-final-390x844-dark.png`。
- 最终命令:`npm run build` PASS;`git diff --check` PASS;权威 `theme.css` byte-for-byte PASS;`pnpm list @heroui-pro/react @heroui/react @heroui/styles react react-dom react-aria-components @react-aria/utils --depth 1` PASS;Pro CSS 导出解析和六个 JS 子路径 ESM import PASS;package.json/package-lock/pnpm-lock 的 Pro 声明均为 `1.0.0-beta.8`。旧组件、可见原生控件、硬编码视觉、内部 BEM/slot、`file:`、绝对本机路径和相邻 `node_modules` 扫描无命中。
- Skill 审计脚本 PASS 构建性检查,但仍有三项静态误报:把当前仓库 pnpm `.pnpm` 解析路径识别为机器依赖、以 CommonJS 方式检查 ESM Pro 子路径、以及没有跨文件追踪共享 `ApplicationIdentityWell` 中的 `Surface tertiary + Avatar soft`。实际依赖声明、ESM 导入和浏览器 36px identity 计算值均已单独通过。`npm ls` 还会把 pnpm 布局及 Umi 18 的旧 peer 声明标记为 invalid/extraneous;项目实际 `pnpm list` 显示 HeroUI 3.2.4、Pro 1.0.0-beta.8、React/React DOM 19.2.8、React Aria Components 1.20.0 为同一运行图。
### 弹窗 Surface 与圆角复核(2026-08-21)
- 全量扫描 `Modal.Dialog`、`XinyuanModal` 和弹窗内 `Surface` 后,确认 Modal 面板继续由 HeroUI Overlay 提供 L1 背景与 24px 圆角;所有弹窗内结构性 `Surface secondary` 统一为 16px 圆角。
......
export const APPLICATION_DISPLAY_NAME = '廉洁风险防控';
export const APPLICATION_BASE_PATH = '/integrity_risk_prevention_new/';
export const APPLICATION_ROUTE_NAME = 'integrity_risk_prevention_new';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
......
......@@ -654,6 +654,12 @@
.rc-dialog-panel--4xl { --rc-dialog-width: 68rem; }
.rc-dialog-panel--5xl { --rc-dialog-width: 76rem; }
.rc-application-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
}
@media (max-width: 640px) {
.rc-dialog-panel {
width: calc(100vw - 2rem);
......
import { getApplications } from '@/services/DataService';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import ApplicationIdentityWell from '@/components/ApplicationIdentityWell';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_DISPLAY_NAME,
APPLICATION_ROUTE_NAME,
} from '@/config/application';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
......@@ -38,17 +43,20 @@ function resolveApplicationUrl(application) {
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === APPLICATION_ROUTE_NAME;
return {
name,
title: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
title: current
? APPLICATION_DISPLAY_NAME
: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
logo: current ? xinyuanLogo : application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
......@@ -63,7 +71,14 @@ function normalizeApplication(application, fallback = {}) {
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
[
...fallbackApplications,
{
name: APPLICATION_ROUTE_NAME,
title: APPLICATION_DISPLAY_NAME,
logo: xinyuanLogo,
},
].map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
......@@ -76,7 +91,7 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
fallbackMap.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
......@@ -94,7 +109,62 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [{ key: 'all', label: '全部应用', applications }];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
if (groupedApplicationNames.has(name)) {
return null;
}
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (application?.name === APPLICATION_ROUTE_NAME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
......@@ -135,10 +205,10 @@ function ApplicationCard({ application, current }) {
>
<ApplicationIdentityWell initial={initial} logo={application.logo} />
<span className="min-w-0 flex-1">
<span className="block min-w-0 truncate font-medium leading-5 text-foreground">
<span className="block min-w-0 truncate text-sm font-medium leading-5">
{application.title}
</span>
<Description className="block min-w-0 truncate">
<Description className="mt-0.5 block min-w-0 truncate leading-4">
{application.description || application.name || '应用'}
</Description>
</span>
......@@ -170,6 +240,7 @@ function LoadingSkeleton() {
export default function ApplicationSwitcherDialog({
open,
onClose,
siteInfo,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
......@@ -187,14 +258,20 @@ export default function ApplicationSwitcherDialog({
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [applications, setApplications] = useState(() =>
mergeApplications([], fallbackApplications),
);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || '';
return applications.find(isCurrentApplication)?.name || APPLICATION_ROUTE_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
useEffect(() => {
if (fallbackApplications.length === 0) {
......@@ -241,7 +318,7 @@ export default function ApplicationSwitcherDialog({
return;
}
setApplications(fallbackApplications);
setApplications(mergeApplications([], fallbackApplications));
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
......@@ -264,20 +341,20 @@ export default function ApplicationSwitcherDialog({
}}
variant="blur"
>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-dialog-panel rc-dialog-panel--xl max-h-[min(42rem,calc(100dvh-2rem))]">
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog
aria-label="应用切换"
className="rc-application-switcher-dialog p-7"
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<div className="min-w-0">
<Modal.Heading>应用切换</Modal.Heading>
<Description>
选择你可访问的应用
</Description>
</div>
<Modal.Heading>应用切换</Modal.Heading>
<Description className="leading-5">选择你可访问的应用</Description>
</Modal.Header>
<Modal.Body>
<Modal.Body className="mt-3">
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
......@@ -288,13 +365,25 @@ export default function ApplicationSwitcherDialog({
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
<div className="space-y-6">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5">
{section.label}
</h3>
<Description>{section.applications.length} 个应用</Description>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
))}
</div>
</section>
))}
</div>
) : (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment