Commit f8a83c57 authored by Andy-bubu's avatar Andy-bubu

feat: align application switcher with Xinyuan baseline

parent 239971a9
......@@ -14,6 +14,11 @@ Hover/Focus/Selected/Disabled。`src/pages` 下没有页面私有 CSS/Less/SCSS
- `AppShell`:根主题、Pro Sidebar Provider、Navbar 和 base-aware 页面滚动容器。
- `DashboardSidebar` / `DashboardNavbar`:导航、offcanvas、应用切换、用户菜单与主题动作。
移动 Sidebar 直接组合 Pro Sheet,并在真实 `Sheet.Dialog` 上提供可访问名称,继续复用
`useSidebar` 的开关状态和同一份 Sidebar 内容。
- `ApplicationSwitcherDialog`:直接组合 HeroUI Modal/Card/Surface/Avatar 与 Pro EmptyState;
从既有 `/application/info` 和用户应用合并可访问集合,再按 `SiteContext` 的
`/info -> site.group` 配置生成有序分组,不拥有接口、权限、会话或跳转逻辑。
- `DataTable`:Pro DataGrid 原生选择、排序、固定列、内部滚动、EmptyState、ActionBar。
- `XinyuanAutocomplete` / `FilterSelect` / `ReferenceSearchSelect`:当前 HeroUI
`selectedKey(s)`、`onSelectionChange`、`inputValue` 合同。
......
......@@ -24,8 +24,11 @@
- [x] 居中登录 Card、鑫元 Logo、标题、统一字段高度和 24px 节奏
- [x] 登录账号/密码、真实验证码与 token 提交,密码加密、失败刷新、失败反馈和 redirect
- [x] HeroUI Pro Sidebar 真 offcanvas,移动面板与基准同为 80vw
- [x] 移动 Sidebar 使用官方 Sheet 结构,真实 Dialog 具备可访问名称且控制台无警告
- [x] Pro Navbar/Sidebar.Trigger 与 48/32/16px 对称边距
- [x] HeroUI Modal 应用切换,一列/两列和唯一当前状态
- [x] 应用切换同步 `model_analysis` HeroUI Modal 的 46rem/42rem、28px inset、Body 滚动
- [x] `/info -> site.group` 动态分组、真实数量、空组过滤、“其他应用/全部应用”回退
- [x] 应用卡移动一列/`sm+` 两列、36px identity well 和唯一当前状态
- [x] Dropdown 用户菜单、真实退出、token 清理和 base-aware 跳转
- [x] 浅深色主题切换、刷新持久化与深色 Portal
......@@ -52,7 +55,8 @@
- [x] 768x1024 浅色/深色目标与基准对照
- [x] 390x844 浅色/深色目标与基准对照
- [ ] 验证码恢复后的首次加载、点击刷新、失败重载及四视口浅深色对照(本轮浏览器实例不可用,待重验)
- [x] 登录、退出、Sidebar、Navbar、应用切换和用户菜单
- [x] 应用切换四视口浅/深色与 `model_analysis` 打开态重新验收
- [x] 登录、退出、Sidebar、Navbar 和用户菜单
- [x] Tabs、筛选展开/收起、Dropdown/ListBox、DatePicker、Tooltip
- [x] 三处活动 Tabs 均显式使用 HeroUI `primary` 胶囊轨道,按文字收缩且窄屏由官方容器滚动
- [ ] 页面数据集、详情阶段与动态表单分组的切换业务回调未变,本轮浏览器对照待补
......
......@@ -24,8 +24,11 @@ Inter 与系统回退字体之间切换。
- HeroUI Pro Navbar 高 64px,直接使用 `Sidebar.Trigger`;Navbar 与页面共享桌面/平板/
手机 48/32/16px 对称 gutter。
- Sidebar 用户入口必须使用 `Dropdown.Trigger`,退出调用既有 auth service 和 base-aware 路由。
- 应用切换使用 HeroUI Modal:46rem/42rem 几何,移动一列、`sm` 起始终两列;每个 Logo
使用 36px `Surface tertiary + Avatar soft` identity well,只允许一个当前应用。
- 应用切换以 `model_analysis` 为唯一基准,使用 HeroUI Modal:46rem/42rem 几何、28px
inset、24px 官方关闭控件,移动一列、`sm` 起始终两列;每个 Logo 使用 36px
`Surface tertiary + Avatar soft` identity well,只允许一个当前应用。
- 应用分组读取 `/info -> site.group`,按配置和账号可访问应用的真实顺序匹配,隐藏空组,
显示真实数量,未引用应用进入“其他应用”,无分组配置时使用“全部应用”。
- 根主题写入 `xinyuan_color_theme`,所有 Portal、Sidebar.Mobile、ActionBar 和图表 Tooltip
继承同一根主题。
......
......@@ -7,7 +7,8 @@
- 分支:`codex/ui-ux-xinyuan-migration`
- 唯一实现基准:`problem_rectification`
- 应用基路径:`/audit_project_manager_new/`
- 目标验收地址:`http://127.0.0.1:8014/audit_project_manager_new/`
- 本轮启动命令:`npm run dev -- --port 8014`(Umi 自动选择实际空闲端口 `8005`)
- 目标验收地址:`http://127.0.0.1:8005/audit_project_manager_new/`
- 基准验收地址:`http://127.0.0.1:8006/problem_rectification_new/problem_rectification`
迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、
......@@ -64,6 +65,9 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
- 登录:居中 Card、鑫元 Logo、统一字段高度、24px 字段节奏、filled/autofill 与真实验证码加载/刷新/提交。
- Shell:HeroUI Pro Sidebar/Navbar、offcanvas 真折叠、应用切换、主题、用户菜单和真实退出。
- 应用切换已按 `model_analysis` 基准同步 HeroUI Modal 结构和固定几何;保留既有
`/application/info`、用户应用合并、鉴权和跳转,新增消费已有 `/info -> site.group` 配置,
按真实权限和后端顺序显示非空分组、数量及“其他应用/全部应用”回退。
- 页面:HeroUI Tabs、Surface、Autocomplete、DatePicker、Disclosure、DropZone。
- 列表:HeroUI Pro DataGrid 原生选择/排序/固定列/内部横向滚动、Pagination、ActionBar、
EmptyState;固定每页 10 条,无页容量控件。
......@@ -103,7 +107,8 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
- 移动 Sidebar:目标与基准均为 HeroUI Pro 官方 `80vw`,390px 视口下为 `312x844`,
Sheet/Dialog/Sidebar 内容同宽且无空白条;桌面折叠后为零宽。
- 应用切换:移动端 358px 单列,桌面 736px 两列,只有一个“当前”。
- 应用切换:移动端 358px 单列,桌面 736px 两列,只有一个“当前”;本次动态分组同步后的
四视口浅/深色打开态将重新验收,不复用旧平铺版本结论。
- Modal:移动端 4xl 为 `358x812`,桌面 4xl 为 1088px;查看态 1 个 secondary Surface、
0 个输入,编辑态 0 个组 Surface、7 个官方控件,取消后恢复原查看态。
- DataGrid:滚动层 `clientWidth=1096`、`scrollWidth=1624`、最大滚动 528px;首列与操作列
......@@ -114,6 +119,23 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
- Checkbox 选中/半选、ActionBar、Pagination、Tabs、筛选展开态、Dropdown、用户菜单、
退出和删除取消均通过。目标控制台最终为 `0 errors / 0 warnings`。
### 2026-08-26 应用切换同步复核
- 以 `model_analysis` 当前运行实现为唯一基准,目标和基准使用同一浏览器会话数据,在
`1440x900`、`1280x720`、`768x1024`、`390x844` 的浅色与深色模式逐项对照。
- 两者桌面/平板弹窗均为 736px 宽,390px 视口均为 `358px` 宽且左右各 16px;计算圆角
`24px`、padding `28px`、关闭控件 `24px`,关闭控件与顶部/右侧均相距 28px。
- `sm` 起两列计算宽度均为 `334px 334px`,390px 为单列 `302px`;浅深色均无页面横向
溢出,深色 Dialog/Card/identity well 计算色值与基准逐项一致。
- 动态分组场景显示“演示 3 个应用”和“其他应用 1 个应用”,空分组被过滤且当前状态唯一;
无分组配置回退为“全部应用”。17 个应用的移动端长列表中,Modal Body 为
`clientHeight=548`、`scrollHeight=1508`,只有 Body 滚动,全部 identity well 为 36x36px。
- Escape 可关闭弹窗并把焦点还给触发按钮;目标应用切换本身控制台为 0 error / 0 warning。
验收响应只存在于临时 Playwright 会话,未写入源码、文档数据或后端。
- 移动端入口将 HeroUI Pro `Sidebar.Mobile` 的等价 Sheet compound 结构显式展开,只为在真实
`Sheet.Dialog` 上提供“审计项目管理导航”可访问名称;390x844 下 Sidebar 仍为 312x844,
开关状态、遮罩、位置、内容和应用切换入口未变,控制台复验为 0 error / 0 warning。
### 2026-08-21 筛选模块复核
- 活动路由的筛选入口统一收敛到 `RecordWorkbench`:关键词使用列表工具栏的 HeroUI
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
......@@ -147,18 +148,45 @@ function SidebarContents({
);
}
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) {
return null;
}
return (
<Sheet.Root
isOpen={isMobileOpen}
placement={side}
onOpenChange={setMobileOpen}
>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile"
>
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
<AccessibleMobileSidebar {...props} />
</>
);
}
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
......@@ -13,6 +18,23 @@ import {
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const CURRENT_APPLICATION_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g, '');
function unwrapApplications(response) {
const candidates = [
response,
response?.data,
response?.applications,
response?.apps,
response?.records,
response?.rows,
response?.list,
response?.result,
];
return candidates.find(Array.isArray) || [];
}
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
......@@ -39,20 +61,27 @@ function resolveApplicationUrl(application) {
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === CURRENT_APPLICATION_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,
url: resolveApplicationUrl({
name,
}),
logo: current
? xinyuanLogo
: application?.logo ||
application?.icon ||
fallback?.logo ||
fallback?.icon ||
null,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
......@@ -85,6 +114,15 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
mergedMap.set(application.name, normalizeApplication(application, application));
});
const currentApplication = mergedMap.get(CURRENT_APPLICATION_NAME);
mergedMap.set(
CURRENT_APPLICATION_NAME,
normalizeApplication(
{ name: CURRENT_APPLICATION_NAME },
currentApplication || { title: APPLICATION_DISPLAY_NAME },
),
);
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
......@@ -95,7 +133,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 === CURRENT_APPLICATION_NAME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
......@@ -134,29 +227,35 @@ function ApplicationCard({ application, current }) {
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
>
<Surface
aria-hidden="true"
className="rc-application-identity-well"
variant="tertiary"
>
<Surface aria-hidden="true" variant="tertiary" className="rc-application-identity-well">
<Avatar
className="rc-application-identity-avatar"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
<Avatar.Image
alt=""
className="object-contain p-1.5"
src={application.logo}
/>
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}>
<span
className="block truncate text-sm font-medium leading-5"
title={application.title}
>
{application.title}
</span>
<span className="block truncate text-sm text-muted" 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>
......@@ -186,6 +285,7 @@ function LoadingSkeleton() {
export default function ApplicationSwitcherDialog({
open,
onClose,
siteInfo,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
......@@ -203,27 +303,29 @@ 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 || CURRENT_APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
setApplications((previous) =>
mergeApplications(previous, fallbackApplications),
);
}, [fallbackApplications]);
useEffect(() => {
......@@ -239,7 +341,7 @@ export default function ApplicationSwitcherDialog({
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
const fetchedApplications = unwrapApplications(response);
if (cancelled) {
return;
......@@ -257,7 +359,7 @@ export default function ApplicationSwitcherDialog({
return;
}
setApplications(fallbackApplications);
setApplications(mergeApplications([], fallbackApplications));
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
......@@ -280,23 +382,24 @@ export default function ApplicationSwitcherDialog({
}}
variant="blur"
>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Container 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="max-h-[min(42rem,calc(100dvh-2rem))] p-7"
style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<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>
<div className="text-sm text-muted">选择你可访问的应用</div>
</Modal.Header>
<Modal.Body>
<Modal.Body style={{ marginTop: '0.75rem' }}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
......@@ -307,13 +410,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>
) : (
......
......@@ -16,6 +16,11 @@
配置中的 `audit_indicator`、`risk_indicator`、`audit_process`、`audit_report`
没有菜单入口,属于复制配置残留,不注册为本应用路由。
侧栏“切换应用”打开 HeroUI Modal。可访问应用仍由既有 `/application/info` 与当前用户
`applications` 合并,分组读取 `/info -> site.group` 的 `{ label, applications }` 配置;
按后端分组及成员顺序过滤无权限和空分组,未被配置引用的可访问应用归入“其他应用”,
无分组配置时显示“全部应用”。应用点击仍使用既有名称到根路径的跳转规则。
侧栏将“驾驶舱”作为独立入口;“审计项目管理”和“往期项目归档”归入可展开的“开展审计工作”,“项目表单”归入可展开的“表单配置及维护”。两个分组默认展开,并支持手动收起。模块主题色与 `problem_rectification` 保持一致,使用品牌红 `#b42318` 统一导航选中态、主按钮、复选框、当前视图和焦点反馈。
## 2. 项目列表与详情
......
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