Commit 59f021c1 authored by Andy-bubu's avatar Andy-bubu

feat: align application switcher with Xinyuan baseline

parent 6a36f6ce
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
- 登录采用居中认证 Card。花形 Logo 约 36px,标题约 32px/600;不重复显示“登录”标题。 - 登录采用居中认证 Card。花形 Logo 约 36px,标题约 32px/600;不重复显示“登录”标题。
- 账号、密码、验证码可见高度统一为 40px,字段组可见间距 24px,提交按钮上方保留 24px。 - 账号、密码、验证码可见高度统一为 40px,字段组可见间距 24px,提交按钮上方保留 24px。
- 登录字段有值或自动填充时使用 `#fbf1f1`,Hover 使用 `#faeaea`,Focus 使用鑫元红描边和浅红焦点环。 - 登录字段有值或自动填充时使用 `#fbf1f1`,Hover 使用 `#faeaea`,Focus 使用鑫元红描边和浅红焦点环。
- 应用切换器固定为居中的紧凑三列列表,标题“应用切换”、副标题“选择你可访问的应用”;最多一个“当前”标签,不显示数量、权限标签、外链图标或统计内容。 - 应用切换器固定为居中的紧凑两列 HeroUI Modal,移动端一列;标题“应用切换”、副标题“选择你可访问的应用”。分组与成员顺序读取站点 `site.group`,组标题显示真实应用数量,空组隐藏,未分组可访问项归入“其他应用”,无配置时回退“全部应用”;最多一个“当前”标签,不显示权限标签或外链装饰。
## 4. 页面、筛选与 Tab ## 4. 页面、筛选与 Tab
......
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
- 多选:共享 Autocomplete composition 在 `Autocomplete.Value` 内使用 HeroUI `TagGroup + Tag + Tag.RemoveButton`;根 `selectedKeys` 是唯一选择状态,移除操作回写同一个 `onSelectionChange`,业务页面只负责值协议映射。 - 多选:共享 Autocomplete composition 在 `Autocomplete.Value` 内使用 HeroUI `TagGroup + Tag + Tag.RemoveButton`;根 `selectedKeys` 是唯一选择状态,移除操作回写同一个 `onSelectionChange`,业务页面只负责值协议映射。
- 列表:Pro DataGrid 原生 selection/sort/fixed columns + HeroUI Pagination + Pro ActionBar/EmptyState。 - 列表:Pro DataGrid 原生 selection/sort/fixed columns + HeroUI Pagination + Pro ActionBar/EmptyState。
- 弹层:HeroUI Modal/AlertDialog/Dropdown/Tooltip,根主题覆盖 body Portal。 - 弹层:HeroUI Modal/AlertDialog/Dropdown/Tooltip,根主题覆盖 body Portal。
- 应用切换:共享 `ApplicationSwitcherDialog` 直接组合 HeroUI Modal/Card/Surface/Avatar/Chip 与 Pro EmptyState;`SiteContext.siteInfo.group` 只负责动态分组与顺序,`UserContext.applications` 和 `/application/info` 继续负责访问集合与详情。授权域 `audit_manager` 规范化为唯一当前项,并导航到部署基路径 `/audit_manager_new/`。
- 上传:Pro DropZone,页面边界接收真实 File。 - 上传:Pro DropZone,页面边界接收真实 File。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip。 - KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip。
- 报告视图:一级 HeroUI Tabs 切换 current/history,二级内容宽度 Tabs 以稳定项目 ID 切换报告快照;当前项目只挂载工具栏和 DataGrid,不重复 Tabs 已表达的项目标题,整体进度由 KPI 承担。项目与控制点仍携带原数组索引进入现有更新/确认回调。 - 报告视图:一级 HeroUI Tabs 切换 current/history,二级内容宽度 Tabs 以稳定项目 ID 切换报告快照;当前项目只挂载工具栏和 DataGrid,不重复 Tabs 已表达的项目标题,整体进度由 KPI 承担。项目与控制点仍携带原数组索引进入现有更新/确认回调。
...@@ -37,6 +38,7 @@ ...@@ -37,6 +38,7 @@
- `theme.css` 提供 HeroUI 核心颜色、半径、阴影、字段和 dark token,必须与技能资产逐字节一致。 - `theme.css` 提供 HeroUI 核心颜色、半径、阴影、字段和 dark token,必须与技能资产逐字节一致。
- `tokens.css` 只能添加 `--rc-*` 别名、布局尺寸、业务语义 chip/chart tokens,不能重定义核心变量。 - `tokens.css` 只能添加 `--rc-*` 别名、布局尺寸、业务语义 chip/chart tokens,不能重定义核心变量。
- 共享 Shell/layout CSS 只负责几何和技能批准的跨应用模式。 - 共享 Shell/layout CSS 只负责几何和技能批准的跨应用模式。
- 应用切换 CSS 仅允许 `46rem/42rem` 弹窗几何和 36px identity well 的共享 bridge class;Card、Surface、Avatar、Chip 与交互状态均由 HeroUI variant/compound API 管理。
- 页面 className 只负责 flex、grid、宽度、间距、对齐和响应式;禁止外观、状态、slot/BEM 和伪元素覆盖。 - 页面 className 只负责 flex、grid、宽度、间距、对齐和响应式;禁止外观、状态、slot/BEM 和伪元素覆盖。
## 业务边界 ## 业务边界
......
# UI 迁移清单 # UI 迁移清单
状态日期:2026-08-21。`完成` 仅表示代码、业务保护、双应用浏览器对照和扫描门槛全部通过。 状态日期:2026-08-26。`完成` 仅表示代码、业务保护、双应用浏览器对照和扫描门槛全部通过。
| 模块 | 路由/入口 | 代码迁移 | 浏览器对照 | 业务保护 | | 模块 | 路由/入口 | 代码迁移 | 浏览器对照 | 业务保护 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
| Login | `/login` | 完成 | 四视口双主题及 filled 完成 | 完成 | | Login | `/login` | 完成 | 四视口双主题及 filled 完成 | 完成 |
| AppShell/Navbar/Sidebar | 鉴权路由 | 完成 | 缺有效登录态 | 代码复核完成 | | AppShell/Navbar/Sidebar | 鉴权路由 | 完成 | 缺有效登录态 | 代码复核完成 |
| 用户菜单/退出 | Sidebar footer | 完成 | 缺有效登录态 | 回调保留 | | 用户菜单/退出 | Sidebar footer | 完成 | 缺有效登录态 | 回调保留 |
| 应用切换 | Shell Modal | 完成 | 缺有效登录态 | 数据/导航保留 | | 应用切换 | Shell Modal | 分组/数量/唯一当前态完成 | 2026-08-26 双应用均停在真实登录门禁 | `/info` 分组、应用接口与部署导航保留 |
| 全应用弹窗尺寸 | HeroUI Modal / AlertDialog | 完成 | 待有效登录态复验 | md 480 / lg 640 / 3xl 960 / 应用切换 736 | | 全应用弹窗尺寸 | HeroUI Modal / AlertDialog | 完成 | 待有效登录态复验 | md 480 / lg 640 / 3xl 960 / 应用切换 736 |
| 稽核项目 | `/audit_process` | 完成 | 缺有效登录态 | 代码复核完成 | | 稽核项目 | `/audit_process` | 完成 | 缺有效登录态 | 代码复核完成 |
| 多选字段 | 稽核项目表单 | 完成 | 缺有效登录态/打开态复验 | 框内 Tag;`_id[]` 与请求时机保留 | | 多选字段 | 稽核项目表单 | 完成 | 缺有效登录态/打开态复验 | 框内 Tag;`_id[]` 与请求时机保留 |
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
- [x] 并列报告项目整合为内容宽度二级 HeroUI Tabs,单项目时隐藏轨道;搜索只过滤当前项目列表。 - [x] 并列报告项目整合为内容宽度二级 HeroUI Tabs,单项目时隐藏轨道;搜索只过滤当前项目列表。
- [x] 当前报告搜索位于二级 Tabs 下方的标准工具栏,移动端独占一行;项目区不重复 Tabs 标题和 KPI 进度。 - [x] 当前报告搜索位于二级 Tabs 下方的标准工具栏,移动端独占一行;项目区不重复 Tabs 标题和 KPI 进度。
- [x] 报告附件内容使用紧凑 HeroUI Chip/Link,DataGrid 单元格内无 Card/Surface 或页面私有外观。 - [x] 报告附件内容使用紧凑 HeroUI Chip/Link,DataGrid 单元格内无 Card/Surface 或页面私有外观。
- [x] 应用切换按站点配置动态分组,移动一列/桌面两列,使用 HeroUI 原生 Card/Surface/Avatar 并保持唯一当前态。
- [ ] 四视口、浅色/暗色、目标/基准同时打开完成(登录完成,鉴权页面待有效登录态)。 - [ ] 四视口、浅色/暗色、目标/基准同时打开完成(登录完成,鉴权页面待有效登录态)。
- [ ] 所有可达 popup、Modal、Tooltip、Dropdown、Sidebar 状态检查完成。 - [ ] 所有可达 popup、Modal、Tooltip、Dropdown、Sidebar 状态检查完成。
- [x] `npm run build` 与 `git diff --check` 通过;`audit_project.sh` 退出码 0,并报告非活动遗留。 - [x] `npm run build` 与 `git diff --check` 通过;`audit_project.sh` 退出码 0,并报告非活动遗留。
......
...@@ -16,7 +16,10 @@ ...@@ -16,7 +16,10 @@
- HeroUI Pro Navbar 高 64px;`Sidebar.Trigger` 使用 Pro 原生图形。 - HeroUI Pro Navbar 高 64px;`Sidebar.Trigger` 使用 Pro 原生图形。
- Navbar 与页面内容采用对称 48px/32px/16px gutter;主题操作在最右。 - Navbar 与页面内容采用对称 48px/32px/16px gutter;主题操作在最右。
- 用户行由 `Dropdown.Trigger` 打开真实菜单,退出调用目标应用现有服务。 - 用户行由 `Dropdown.Trigger` 打开真实菜单,退出调用目标应用现有服务。
- 应用切换使用 HeroUI Modal,桌面宽约 46rem、高不超过 42rem,一列移动/两列桌面;最多一个“当前”。 - 应用切换使用 HeroUI Modal,标题固定为“应用切换”,副标题固定为“选择你可访问的应用”;弹窗宽 `min(46rem, 100vw - 2rem)`、高不超过 `min(42rem, 100dvh - 2rem)`,28px 内边距,只有 Modal Body 滚动。
- 应用按 `/info` 的 `site.group` 与组内成员顺序动态分区,隐藏无可访问应用的分组,未被配置引用的可访问应用进入“其他应用”;无分组配置时显示“全部应用”。分组标题右侧显示真实可访问数量,禁止硬编码分组或应用。
- 每个分组在移动端一列、`sm` 及以上严格两列,组间 24px、标题到卡片 12px、卡片间 12px;应用项使用 HeroUI `Card variant="secondary"`、`Surface variant="tertiary"` 与 `Avatar variant="soft"`,identity well 固定 36px。
- 应用列表合并 `/application/info` 与当前用户授权应用;接口失败回退用户授权信息。授权域 `audit_manager` 映射到部署路径 `/audit_manager_new/`,整个弹窗最多一个 accent“当前” Chip。
## 登录 ## 登录
......
# 监察稽核管理 UI 审计 # 监察稽核管理 UI 审计
审计日期:2026-08-21 审计日期:2026-08-26
当前分支:`codex/ui-ux-xinyuan` 当前分支:`codex/ui-ux-xinyuan`
唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification` 唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
...@@ -49,7 +49,9 @@ ...@@ -49,7 +49,9 @@
- 登录已同步基准的居中 Card、双主题 Logo、字段节奏、验证码 Button、filled/autofill 与响应式几何。 - 登录已同步基准的居中 Card、双主题 Logo、字段节奏、验证码 Button、filled/autofill 与响应式几何。
- 鉴权 Shell 使用共享 `AppShell`、Pro Navbar、Pro Sidebar offcanvas、用户 Dropdown、真实退出回调和应用切换 Modal。 - 鉴权 Shell 使用共享 `AppShell`、Pro Navbar、Pro Sidebar offcanvas、用户 Dropdown、真实退出回调和应用切换 Modal。
- 应用切换使用基准两列结构、L2 Card 与 L3 Surface + Avatar identity well。 - 应用切换已按 `model_analysis` 基准改为动态分组结构:读取 `SiteContext.siteInfo.group` 保留后端分组及成员顺序,隐藏空组,将未配置的可访问应用归入“其他应用”,并显示真实组内数量。
- 应用项使用严格一列移动/两列桌面结构、L2 HeroUI Card 与 L3 Surface + Avatar identity well;弹窗保持 46rem/42rem、28px padding、Body 单独滚动及唯一“当前” Chip。
- 应用数据仍来自既有 `/application/info` 与 `userInfo.applications` 回退。仅将授权域 `audit_manager` 规范化为当前应用并导航至既有 `/audit_manager_new/` 部署基路径,未修改接口、权限或登录状态流。
- 活动列表使用 Pro DataGrid、Pagination、ActionBar、EmptyState;选择、排序、固定列及分页业务回调保留。 - 活动列表使用 Pro DataGrid、Pagination、ActionBar、EmptyState;选择、排序、固定列及分页业务回调保留。
- 活动 DataGrid 操作列统一将 `tertiary` Eye 查看按钮放在首位:项目控制点与报告控制点打开当前行的只读 HeroUI Modal,历史报告进入原详情路由;编辑、确认、删除权限和回调不变。 - 活动 DataGrid 操作列统一将 `tertiary` Eye 查看按钮放在首位:项目控制点与报告控制点打开当前行的只读 HeroUI Modal,历史报告进入原详情路由;编辑、确认、删除权限和回调不变。
- 报告视图“进行中 / 历史报告”Tabs 已移除 `flex-1` 伸展,根节点和 ListContainer 按基准使用内容宽度;同行导出、填报链接和提交操作继续由外层布局靠右。 - 报告视图“进行中 / 历史报告”Tabs 已移除 `flex-1` 伸展,根节点和 ListContainer 按基准使用内容宽度;同行导出、填报链接和提交操作继续由外层布局靠右。
...@@ -77,6 +79,9 @@ ...@@ -77,6 +79,9 @@
## 验收证据与剩余边界 ## 验收证据与剩余边界
- `npm run build` 与 `git diff --check` 通过。 - `npm run build` 与 `git diff --check` 通过。
- 2026-08-26 应用切换变更后重新执行生产构建,Webpack 编译通过;目标独立开发服务运行于 `http://localhost:8011/audit_manager_new/`,`model_analysis` 基准运行于 `http://localhost:8005/model_analysis/`,两者 HTTP 200。
- 使用全新 Playwright 会话分别进入目标 `/audit_process` 和基准 `/models/overview`,两者都由真实鉴权重定向至各自登录页,登录页 console 均为 0 error / 0 warning。现存带 `browser-only-token` 的 QA 会话未用于验收,也未向目标注入用户、token 或业务数据。
- 因当前没有有效真实登录凭据,本次无法打开目标应用切换 Modal,动态分组、四视口双主题、Body 滚动、关闭交互与唯一当前态的浏览器对照仍待补齐;代码对照、生产构建、HeroUI Pro ESM 子路径导入和静态扫描已通过,不据此声明浏览器验收完成。
- 2026-08-21 重新打开 `http://localhost:8001/audit_manager_new/`,应用正常跳转真实登录页,当前开发预览 console 为 0 error / 0 warning。 - 2026-08-21 重新打开 `http://localhost:8001/audit_manager_new/`,应用正常跳转真实登录页,当前开发预览 console 为 0 error / 0 warning。
- 目标与“问题整改”登录页在 1440x900、1280x720、768x1024、390x844 的浅色/暗色几何逐项一致,所有视口横向溢出为 0,console 为 0 error / 0 warning。 - 目标与“问题整改”登录页在 1440x900、1280x720、768x1024、390x844 的浅色/暗色几何逐项一致,所有视口横向溢出为 0,console 为 0 error / 0 warning。
- 已填写态三个字段均为 40px,目标与基准的 filled Surface 计算值一致,未提交测试登录请求。 - 已填写态三个字段均为 40px,目标与基准的 filled Surface 计算值一致,未提交测试登录请求。
......
import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { APP_BASE_PATH, APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert, Alert,
...@@ -13,152 +15,165 @@ import { ...@@ -13,152 +15,165 @@ import {
} from '@heroui/react'; } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) { const APPLICATION_PERMISSION_NAME = 'audit_manager';
if (!Array.isArray(items)) {
return []; function unwrapApplications(response) {
} const candidates = [
response,
return items response?.data,
.map((item) => { response?.applications,
if (typeof item === 'string') { response?.apps,
return item; response?.records,
} response?.rows,
response?.list,
return item?.display_name || item?.title || item?.name || ''; response?.result,
}) ];
.filter(Boolean);
return candidates.find(Array.isArray) || [];
} }
function resolveApplicationUrl(application) { function isCurrentApplicationName(name) {
if (application?.name) { return name === APPLICATION_PERMISSION_NAME || name === APP_ROUTE_NAME;
return `/${application.name}`;
}
return '#';
} }
function normalizeApplication(application, fallback = {}) { function normalizeApplication(application = {}) {
const name = application?.name || fallback?.name || ''; const sourceName = application.name || '';
const current = isCurrentApplicationName(sourceName);
const name = current ? APPLICATION_PERMISSION_NAME : sourceName;
return { return {
name, name,
title: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用', title: current ? APP_DISPLAY_NAME : application.title || name || '未命名应用',
description: description:
application?.description || application.description ||
fallback?.description || application.subtitle ||
fallback?.title || application.title ||
application?.title ||
'进入对应应用继续处理业务', '进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null, logo: current ? xinyuanLogo : application.logo || application.icon || null,
url: resolveApplicationUrl({ order: Number(application.order || 0),
name, url: current
}), ? APP_BASE_PATH
access: application?.access || fallback?.access || '', : application.url || application.path || (name ? `/${name}` : '#'),
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(application?.roles?.length ? application.roles : fallback?.roles),
groups: normalizeLabelList(application?.groups?.length ? application.groups : fallback?.groups),
}; };
} }
function mergeApplications(fetchedApplications, fallbackApplications) { function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map( const currentFallback = fallbackApplications.find((application) =>
fallbackApplications.map((application) => [application.name, application]), isCurrentApplicationName(application?.name),
); ) || {
name: APPLICATION_PERMISSION_NAME,
const normalizedFetched = fetchedApplications title: APP_DISPLAY_NAME,
.map((application) => };
normalizeApplication(application, fallbackMap.get(application?.name)), const merged = new Map();
)
.filter((application) => application.name); [...fallbackApplications, ...fetchedApplications, currentFallback]
.map(normalizeApplication)
const mergedMap = new Map( .filter((application) => application.name)
normalizedFetched.map((application) => [application.name, application]), .forEach((application) => {
); merged.set(application.name, {
...merged.get(application.name),
fallbackApplications.forEach((application) => { ...application,
if (!application?.name || mergedMap.has(application.name)) { });
return; });
}
mergedMap.set(application.name, normalizeApplication(application, application));
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN'); return Array.from(merged.values()).sort((left, right) => {
return left.order - right.order || left.title.localeCompare(right.title, 'zh-CN');
}); });
} }
function isCurrentApplication(application) { function buildApplicationSections(applications, applicationGroups) {
if (typeof window === 'undefined') { if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return false; return [{ key: 'all', label: '全部应用', applications }];
} }
const currentPath = window.location.pathname; const applicationMap = new Map(
const applicationRoot = application?.name ? `/${application.name}` : ''; 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) => {
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 (application?.url?.startsWith('http')) { if (ungroupedApplications.length > 0) {
try { sections.push({
const url = new URL(application.url); key: 'ungrouped',
return url.pathname === currentPath; label: '其他应用',
} catch (error) { applications: ungroupedApplications,
return false; });
}
} }
return ( return sections.length > 0
application?.url === currentPath || ? sections
(!!applicationRoot && : [{ key: 'all', label: '全部应用', applications }];
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
} }
function ApplicationCard({ application, current }) { function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined; const target = /^https?:\/\//i.test(application.url) ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A'; const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return ( return (
<Card className="h-full" variant="secondary"> <Card className="h-full" variant="secondary">
<Card.Content> <Card.Content>
<a <a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
aria-current={current ? 'page' : undefined} aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline" className="flex min-w-0 items-center gap-3 no-underline"
href={application.url}
rel={target ? 'noreferrer' : undefined}
target={target}
> >
<Surface <Surface
aria-hidden="true" aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]" className="rc-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden"
variant="tertiary" variant="tertiary"
> >
<Avatar <Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent" className="rc-app-identity-avatar size-full"
color="accent" color="accent"
variant="soft" variant="soft"
> >
{application.logo ? ( {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} ) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback> <Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar> </Avatar>
</Surface> </Surface>
<span className="min-w-0 flex-1"> <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">
{application.title} {application.title}
</span> </span>
<span className="block truncate text-sm text-muted" title={application.description}> <Description className="mt-0.5 block truncate leading-4">
{application.description || application.name || '应用'} {application.description}
</span> </Description>
</span> </span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null} {current ? (
<Chip color="accent" size="sm">
当前
</Chip>
) : null}
</a> </a>
</Card.Content> </Card.Content>
</Card> </Card>
...@@ -167,7 +182,7 @@ function ApplicationCard({ application, current }) { ...@@ -167,7 +182,7 @@ function ApplicationCard({ application, current }) {
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用"> <div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary"> <Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3"> <Card.Content className="flex items-center gap-3">
...@@ -186,84 +201,53 @@ function LoadingSkeleton() { ...@@ -186,84 +201,53 @@ function LoadingSkeleton() {
export default function ApplicationSwitcherDialog({ export default function ApplicationSwitcherDialog({
open, open,
onClose, onClose,
siteInfo,
userInfo, userInfo,
}) { }) {
const fallbackApplications = useMemo(() => { const fallbackApplications = useMemo(
if (!Array.isArray(userInfo?.applications)) { () => (Array.isArray(userInfo?.applications) ? userInfo.applications : []),
return []; [userInfo],
} );
const [applications, setApplications] = useState(() =>
return userInfo.applications mergeApplications([], fallbackApplications),
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [status, setStatus] = useState('idle');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || ''; return (
applications.find(
(application) => application.name === APPLICATION_PERMISSION_NAME,
)?.name ||
applications.find((application) => application.name === APP_ROUTE_NAME)?.name ||
''
);
}, [applications]); }, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
useEffect(() => { useEffect(() => {
if (fallbackApplications.length === 0) { if (!open) return undefined;
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false; let cancelled = false;
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
const loadApplications = async () => { setErrorMessage('');
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage(''); getApplications()
.then((response) => {
try { if (cancelled) return;
const response = await getApplications(); setApplications(
const fetchedApplications = Array.isArray(response) ? response : []; mergeApplications(unwrapApplications(response), fallbackApplications),
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
); );
setApplications(mergedApplications);
setStatus('success'); setStatus('success');
} catch (error) { })
if (cancelled) { .catch(() => {
return; if (cancelled) return;
} setApplications(mergeApplications([], fallbackApplications));
setApplications(fallbackApplications);
setStatus('error'); setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。'); setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
} });
};
loadApplications();
return () => { return () => {
cancelled = true; cancelled = true;
...@@ -275,52 +259,67 @@ export default function ApplicationSwitcherDialog({ ...@@ -275,52 +259,67 @@ export default function ApplicationSwitcherDialog({
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop <Modal.Backdrop
isOpen={open} isOpen={open}
variant="blur"
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) onClose?.(); if (!isOpen) onClose?.();
}} }}
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 <Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]" aria-label="应用切换"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }} className="rc-app-switcher-dialog p-7"
> >
<Modal.CloseTrigger aria-label="关闭"> <Modal.CloseTrigger
<Close size={16} /> aria-label="关闭"
</Modal.CloseTrigger> style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header> <Modal.Header>
<div className="min-w-0"> <Modal.Heading>应用切换</Modal.Heading>
<Modal.Heading>应用切换</Modal.Heading> <Description>选择你可访问的应用</Description>
<Description>
选择你可访问的应用
</Description>
</div>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body style={{ marginTop: '0.75rem' }}>
{status === 'error' && errorMessage ? ( {status === 'error' && errorMessage ? (
<Alert status="warning"> <Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator> <Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content> <ExclamationTriangleIcon size={20} />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
</Alert> </Alert>
) : null} ) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : applications.length > 0 ? ( ) : applications.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="space-y-6">
{applications.map((application) => ( {applicationSections.map((section) => (
<ApplicationCard <section key={section.key}>
key={application.name} <div className="mb-3 flex items-center justify-between gap-3">
application={application} <h3 className="text-sm font-medium leading-5">
current={application.name === currentApplicationName} {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> </div>
) : ( ) : (
<EmptyState size="md"> <EmptyState size="md">
<EmptyState.Header> <EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title> <EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description> <EmptyState.Description>
当前账号还没有分配其它应用权限。
</EmptyState.Description>
</EmptyState.Header> </EmptyState.Header>
</EmptyState> </EmptyState>
)} )}
......
...@@ -1397,6 +1397,22 @@ ...@@ -1397,6 +1397,22 @@
line-height: 1rem; line-height: 1rem;
} }
.rc-app-switcher-dialog {
min-width: 0;
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
}
.rc-app-identity-well,
.rc-app-identity-avatar {
border-radius: var(--rc-radius-control);
}
.rc-app-identity-avatar {
background: transparent;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.rc-mobile-sidebar.app-sidebar-glass { .rc-mobile-sidebar.app-sidebar-glass {
width: 100%; width: 100%;
......
...@@ -3,6 +3,7 @@ import { FileText, Project } from '@/components/AppIcons'; ...@@ -3,6 +3,7 @@ import { FileText, Project } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { APP_DISPLAY_NAME, getAppRouteTitle } from '@/constants/app'; import { APP_DISPLAY_NAME, getAppRouteTitle } from '@/constants/app';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi'; import { Outlet, useLocation } from 'umi';
...@@ -20,6 +21,7 @@ const MENU_GROUPS = [ ...@@ -20,6 +21,7 @@ const MENU_GROUPS = [
export default function AuditManagementLayout() { export default function AuditManagementLayout() {
const location = useLocation(); const location = useLocation();
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const currentTitle = useMemo( const currentTitle = useMemo(
...@@ -49,6 +51,7 @@ export default function AuditManagementLayout() { ...@@ -49,6 +51,7 @@ export default function AuditManagementLayout() {
<ApplicationSwitcherDialog <ApplicationSwitcherDialog
open={isApplicationDialogOpen} open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)} onClose={() => setIsApplicationDialogOpen(false)}
siteInfo={siteInfo}
userInfo={userInfo} userInfo={userInfo}
/> />
</> </>
......
...@@ -9,6 +9,8 @@ ...@@ -9,6 +9,8 @@
应用部署基路径为 `/audit_manager_new/`。主应用导航使用 HeroUI Pro Sidebar,桌面端采用 offcanvas 折叠,移动端使用 `Sidebar.Mobile`。 应用部署基路径为 `/audit_manager_new/`。主应用导航使用 HeroUI Pro Sidebar,桌面端采用 offcanvas 折叠,移动端使用 `Sidebar.Mobile`。
侧栏应用切换入口打开 HeroUI Modal。弹窗按站点 `/info` 配置的 `site.group` 顺序展示当前用户可访问应用,并通过既有 `/application/info` 补全应用信息;加载失败时回退当前用户授权列表。选择其它应用沿用其既有 URL,选择当前授权域 `audit_manager` 返回部署基路径 `/audit_manager_new/`,不改变权限或登录状态。
## 2. 权限 ## 2. 权限
- 应用授权域为 `audit_manager` - 应用授权域为 `audit_manager`
......
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