Commit 4abbdebf authored by Andy-bubu's avatar Andy-bubu

fix: align application switcher with Xinyuan standard

parent 1596daf0
......@@ -10,6 +10,7 @@
- 共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。
- DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;分页容量继续保持 20/50/100,并使用 HeroUI Autocomplete compound API。
- 文件上传采用 HeroUI Pro DropZone 的 `File[]` 协议;页面边界继续只取既有单文件值。
- 应用切换直接组合 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;分组只读取现有 `SiteContext` 的 `/info -> site.group`,应用仍合并 `/application/info` 与当前用户权限数据,不引入硬编码分组或应用。
## 依赖来源
......
......@@ -12,6 +12,7 @@
- [x] Login:问题整改几何、字段节奏、验证码、filled/autofill、浅/深色
- [x] Shell:Sidebar/Navbar/退出/应用切换/主题/offcanvas(代码结构)
- [x] 应用切换:同步 `model_analysis` Modal 几何、动态分组/计数、其他/全部回退、两列 Card 与 identity well(代码结构)
- [x] 驾驶舱:Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip(代码结构)
- [x] 案例管理:Tabs、筛选工具栏、DataGrid、Pagination、ActionBar、EmptyState(代码结构)
- [x] 弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
......@@ -23,7 +24,8 @@
- [x] Login 1280x720 light/dark
- [x] Login 768x1024 light/dark
- [x] Login 390x844 light/dark
- [ ] 打开 Sidebar、用户退出菜单、应用切换、Dropdown/ListBox/DatePicker
- [x] 应用切换 1440x900、1280x720、768x1024、390x844 light/dark:尺寸、两列/单列、动态分组、全部应用回退、唯一当前态、Body 滚动、Escape 与焦点回归
- [ ] 打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
- [ ] 验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip
- [x] Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
......@@ -38,4 +40,4 @@
- [x] HeroUI/React Aria 依赖树唯一
- [x] HeroUI Pro 必需子路径通过 ESM 实际导入
未完成项只剩登录后真实浏览器状态。不得使用伪造 token、网络数据或测试数字替代真实验收。
应用切换目标页打开态已验收;`model_analysis` 隔离会话未登录,无法对其打开态做同屏像素测量。其余未勾选项仍需使用真实业务状态验收,不得使用伪造 token、网络数据或测试数字替代。
......@@ -13,7 +13,7 @@
## 页面规则
- Shell 使用 HeroUI Pro offcanvas Sidebar 与 Navbar;页面和 Navbar 使用 48/32/16px 对称 gutter。
- 应用切换使用 HeroUI Modal,应用卡片在小屏单列、`sm` 及以上固定两列,与“问题整改”保持一致。
- 应用切换严格使用 `model_analysis` 的 HeroUI Modal 基准:46rem 宽、42rem 最大高度、28px inset、Body 内滚动,小屏单列且 `sm` 及以上固定两列;分组读取 `/info -> site.group`,按真实配置顺序展示分组与数量,未分组应用进入“其他应用”,无分组配置时使用“全部应用”。
- 结构化筛选使用 HeroUI Surface,内部控件使用 `secondary`;关键词搜索位于列表工具栏左侧。
- 列表使用 HeroUI Pro DataGrid 原生选择、排序、固定列、ActionBar、EmptyState 与 compound Pagination。
- 详情和内容型表单使用鑫元 4xl(1088px)Modal:HeroUI `Modal.Container size="lg"` 保留官方交互与响应式结构,`Modal.Dialog` 通过共享布局类限制为 `sm:max-w-[68rem]`;不得把 HeroUI 不支持的 `4xl` 直接传给 Container。查看态只有一个 `Surface secondary` 字段组,编辑态直接放置 `secondary` 控件。
......
# 鑫元 UI 审计
审计日期:2026-08-19。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。唯一实现基准为本机已确认的“问题整改”应用 `/Users/liuchenlong/Desktop/job/problem_rectification`。
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现基准为“问题整改”,应用切换专项基准为 `model_analysis` 当前 HeroUI 实现。
## 安装与运行基线
- 官方安装命令已在项目根目录执行成功,`@heroui-pro/react` 解析为本项目 `node_modules` 内的 `1.0.0-beta.8`,不是符号链接或跨项目 `file:` 依赖。
- 项目使用 npm,保留 `package-lock.json`;旧 `pnpm-lock.yaml` 已删除。
- 当前迁移实例运行于 `http://localhost:8001/risk_case_library/`。
- “问题整改”基准运行于 `http://localhost:8000/problem_rectification_new/`。
- `HEAD` 原始业务基线通过独立临时工作树运行于 `http://localhost:8004/risk_case_library/`。原始路由、鉴权、验证码、查询、保存、导入导出和分页协议以该实例及 `git show HEAD` 为准。
- 当前迁移实例运行于 `http://localhost:8000/risk_case_library/`。
- “问题整改”基准运行于 `http://localhost:8001/problem_rectification_new/`。
- 应用切换专项基准 `model_analysis` 运行于 `http://localhost:8002/model_analysis/`;该隔离浏览器会话停留在登录页,因此没有把未登录状态冒充为打开态对照。
## 业务契约
......@@ -24,7 +24,7 @@
| --- | --- | --- |
| Login | HeroUI;结构、尺寸、字段节奏与问题整改一致;可访问性告警已消除 | 四视口浅/深色对照通过 |
| Shell | HeroUI Pro Sidebar/Navbar,offcanvas、主题、用户退出与应用切换保留既有业务回调 | 代码与共享基准一致;登录后浏览器状态待真实凭证 |
| 应用切换 | HeroUI Modal compound API,桌面两列应用卡、Surface + Avatar identity well、当前态 | 登录后打开态待真实凭证 |
| 应用切换 | HeroUI Modal compound API;46rem/42rem/28px 几何;读取 `/info -> site.group` 动态分组、真实计数与未分组回退;两列 Card、Surface + Avatar identity、唯一当前态 | 目标应用已在四视口浅/深色真实打开验收;动态分组和无配置回退均由真实会话覆盖,专项基准打开态因未登录仍不可复测 |
| 驾驶舱 | HeroUI Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltipContent | 构建通过;真实图表 Tooltip 待登录验收 |
| 案例管理 | HeroUI Surface/Tabs/TextField/Autocomplete、Pro DataGrid/ActionBar/EmptyState | 默认 20、20/50/100 分页口径已保护;交互待登录验收 |
| 弹窗 | HeroUI Modal/Alert/DropZone,Container 使用官方 `lg`、Dialog 映射鑫元 4xl/1088px,查看态使用单一 secondary Surface | 新建弹窗宽度已按问题整改基准修正;其他打开态待登录验收 |
......@@ -35,4 +35,4 @@
- 不改变 API URL、请求参数、响应映射、路由、权限、校验、请求时机、保存回调、登录退出或业务状态流。
- 不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
- 静态、构建与登录页浏览器门槛已完成;缺少真实登录凭证,因此 Sidebar、退出菜单、应用切换、业务列表、弹窗、KPI 和图表的登录后验收仍处于阻塞状态,不能标记全量迁移完成。
- 应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
......@@ -381,6 +381,21 @@
gap: var(--rc-space-2);
}
.xy-app-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
}
.xy-app-identity-well,
.xy-app-identity-avatar {
border-radius: var(--rc-radius-control);
}
.xy-app-identity-avatar {
background: transparent;
}
.rc-navbar-title {
min-width: 0;
margin: 0;
......
import { getApplications } from '@/services/DataService';
import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import {
APP_DISPLAY_NAME,
APP_LOGIN_SUBTITLE,
APP_ROUTE_NAME,
} from '@/constants/app';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
......@@ -95,6 +97,18 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
);
});
if (!mergedMap.has(APP_ROUTE_NAME)) {
mergedMap.set(
APP_ROUTE_NAME,
normalizeApplication({
name: APP_ROUTE_NAME,
title: APP_DISPLAY_NAME,
description: APP_LOGIN_SUBTITLE,
logo: xinyuanLogo,
}),
);
}
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
......@@ -125,6 +139,57 @@ function unwrapApplications(response) {
return [];
}
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 (typeof window === 'undefined') {
return false;
......@@ -179,11 +244,11 @@ function ApplicationCard({ application }) {
>
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
className="xy-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden"
variant="tertiary"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
className="xy-app-identity-avatar size-full"
color="accent"
variant="soft"
>
......@@ -194,12 +259,15 @@ function ApplicationCard({ application }) {
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5" title={application.title}>
<span className="block truncate text-sm font-medium leading-5" title={application.title}>
{application.title}
</span>
<span className="block truncate" title={application.description}>
<Description
className="mt-0.5 block truncate leading-4"
title={application.description}
>
{application.description || application.name || '应用'}
</span>
</Description>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
......@@ -214,10 +282,10 @@ function LoadingSkeleton() {
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9 shrink-0 rounded-[var(--rc-radius-control)]" />
<Skeleton className="size-9 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-2/5 rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
......@@ -229,6 +297,7 @@ function LoadingSkeleton() {
export default function ApplicationSwitcherDialog({
open,
onClose,
siteInfo,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
......@@ -253,6 +322,10 @@ export default function ApplicationSwitcherDialog({
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
useEffect(() => {
if (fallbackApplications.length === 0) {
......@@ -323,26 +396,30 @@ export default function ApplicationSwitcherDialog({
}}
>
<Modal.Container
className="w-full sm:w-full"
className="w-full p-4 sm:w-full sm:p-4"
placement="center"
scroll="inside"
size="lg"
>
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
aria-label="应用切换"
className="xy-app-switcher-dialog p-7"
>
<Modal.CloseTrigger aria-label="关闭应用切换" />
<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>选择你可访问的应用</Description>
</Modal.Header>
<Modal.Body>
<Modal.Body style={{ marginTop: '0.75rem' }}>
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert status="warning">
<Alert.Indicator>
<WarningOutlined className="size-5" />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
......@@ -352,12 +429,26 @@ 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}
/>
<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}
/>
))}
</div>
</section>
))}
</div>
) : (
......
......@@ -32,6 +32,7 @@
- 桌面端侧栏展开时显示在应用标题右侧;侧栏收起后整体 offcanvas,不保留图标轨或应用入口
- 移动端显示在顶部菜单栏右侧
- 点击后打开应用切换弹框,优先调用 `/application/info` 读取当前账号可见应用
- 按 `/info -> site.group` 的后台配置顺序展示非空应用分组和真实数量;未被配置引用的可访问应用归入“其他应用”,未配置分组时展示“全部应用”
- 应用列表加载失败时,回退展示当前登录用户信息中的 `applications`
- 点击应用卡片后跳转到对应应用根路径
- `驾驶舱`
......
......@@ -1337,6 +1337,7 @@ export default function ResourceBookingPage() {
)}
<Modal.Root isOpen={isCreateOpen} onOpenChange={setIsCreateOpen}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className={XINYUAN_4XL_DIALOG_CLASS}>
......@@ -1546,6 +1547,7 @@ export default function ResourceBookingPage() {
</Modal.Root>
<Modal.Root isOpen={isDetailOpen} onOpenChange={handleDetailOpenChange}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className={XINYUAN_4XL_DIALOG_CLASS}>
......@@ -1821,6 +1823,7 @@ export default function ResourceBookingPage() {
</Modal.Root>
<Modal.Root isOpen={isBatchEditOpen} onOpenChange={setIsBatchEditOpen}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className={XINYUAN_4XL_DIALOG_CLASS}>
......@@ -2005,6 +2008,7 @@ export default function ResourceBookingPage() {
</Modal.Root>
<Modal.Root isOpen={isBatchDeleteOpen} onOpenChange={setIsBatchDeleteOpen}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop>
<Modal.Container placement="center" size="md">
<Modal.Dialog>
......@@ -2045,6 +2049,7 @@ export default function ResourceBookingPage() {
</Modal.Root>
<Modal.Root isOpen={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop>
<Modal.Container placement="center" size="md">
<Modal.Dialog>
......@@ -2085,6 +2090,7 @@ export default function ResourceBookingPage() {
</Modal.Root>
<Modal.Root isOpen={isImportOpen} onOpenChange={setIsImportOpen}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className={XINYUAN_4XL_DIALOG_CLASS}>
......
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