Commit 1da38cbc authored by Andy-bubu's avatar Andy-bubu

feat: align application switcher with Xinyuan baseline

parent 6345aa52
......@@ -68,7 +68,8 @@ Umi route /ai_chat/
- 退出仍删除 `current_user`,不更改路由 base。
- 会话 CRUD、置顶顺序、当前会话选择和请求参数不变。
- AI 请求 messages/context、知识库检索、MCP、附件和导出协议不变。
- 应用列表仍来自现有接口并沿用当前应用合并与跳转规则。
- 应用列表仍来自现有 `/api/application/info` 接口并沿用当前应用合并与跳转规则;`/api/info` 的 `site.group` 仅负责显示分组、顺序与真实数量。站点配置失败回退“全部应用”,未被配置引用的授权应用进入“其他应用”,不改变权限或跳转协议。
- 应用切换数据只在弹窗打开时读取;两个接口并行且独立降级。应用列表失败沿用账号 `applications` 回退和原警告,站点配置失败只移除分组信息,不阻断授权应用展示。
## 不适用范围
......
......@@ -30,7 +30,7 @@
- [x] HeroUI Pro Navbar、Sidebar.Trigger 与 48/32/16px 对称边距
- [x] 主题切换及持久化
- [x] Sidebar 用户 Dropdown 与真实退出登录
- [x] 应用切换 HeroUI Modal、两列应用 Card 和 identity well
- [x] 应用切换 HeroUI Modal、两列应用 Card、36px identity well,以及 `site.group` 动态分组、真实数量和“全部应用/其他应用”回退
- [x] 移动端 Sidebar.Mobile
## AI 与业务模块
......@@ -58,6 +58,7 @@
- [x] 登录、filled/autofill、失败刷新验证码
- [x] Sidebar 开关、移动 offcanvas、用户菜单、退出
- [x] 应用切换打开态、图片/失败/兜底状态
- [x] 应用切换与 `model_analysis` 同数据同视口复验:动态分组/顺序/计数、空组过滤、其他/全部回退、长列表 Body 滚动、四视口双主题及弹窗控制台通过
- [~] 会话菜单、删除确认、业务配置弹窗和上传打开态:配置弹窗/上传通过;空会话导致行菜单待真实记录复验
- [x] Tooltip、焦点、Escape、Console、页面溢出
- [x] 长消息滚动时输入框坐标稳定;390×844 移动端无页面横向溢出
......
......@@ -38,7 +38,7 @@ Inter 字体文件独立存放于 `public/fonts/`,并由 `.umirc.ts` 注入稳
- Navbar 高 64px,使用 HeroUI Pro `Sidebar.Trigger` 默认图形。
- Navbar 与页面内容共享对称 gutter:桌面 48px、平板 32px、手机 16px。
- Sidebar 用户整行通过 `Dropdown.Trigger` 打开菜单,`退出登录` 调用现有清理流程。
- 应用切换为 HeroUI Modal,46rem/42rem 上限,一列手机/两列 `sm` 以上,应用 identity well 为 36px `Surface tertiary + Avatar soft`。
- 应用切换为 HeroUI Modal,46rem/42rem 上限、28px 内容内边距,一列手机/两列 `sm` 以上,应用 identity well 为 36px `Surface tertiary + Avatar soft`。分组必须来自 `/api/info` 的 `site.group`,保持配置顺序并显示真实 `{count} 个应用`;无配置使用“全部应用”,未被配置引用的授权应用使用“其他应用”。
## Surface 与主题
......
......@@ -38,7 +38,8 @@
- 退出:真实点击用户 Dropdown 的“退出登录”后,`current_user` 为 `null` 并回到登录界面。
- 主题:切换后 `xinyuan_color_theme=dark`,刷新后仍为 dark;Portal 弹窗计算为暗色主题背景与前景。
- Sidebar:桌面折叠后内容可见宽度为 0,主区域回到 x=0;移动打开宽 312px,Escape 关闭。
- 应用切换:实际 `/api/application/info` 返回 3 个可访问应用;桌面 736px、两列、仅 1 个当前状态;手机 358px、单列;Escape 关闭。
- 应用切换:实际 `/api/application/info` 返回 3 个可访问应用;弹窗并行读取 `/api/info` 的 `site.group`,按后端分组与组内顺序展示真实数量,未配置分组回退“全部应用”,未被分组引用的授权应用回退“其他应用”;桌面 736px、两列、仅 1 个当前状态;手机 358px、单列;Escape 关闭。
- 应用切换专项复验:与 `model_analysis` 在 1280×720 同屏数据下均为 736×484、28px inset、24px radius,Card 内容均为 38px;1440×900、1280×720、768×1024、390×844 的浅/深色共 8 组均无横向溢出,`sm` 起严格两列、手机一列,弹窗自身控制台 0 error / 0 warning。长列表保持 Header 固定且仅 Body 滚动;空分组过滤、站点配置失败、全部应用和其他应用回退均通过内存 fixture 验证,fixture 未写入源码或业务数据。
- 弹窗:审计提示词和 Excel 配置均使用共享 `4xl` HeroUI Modal 尺寸,最大宽度 1088px;手机宽度为视口减 32px,Footer 操作纵向满宽排列。DropZone 打开态、浅/深色和 Escape 已验证。
- Modal 尺寸实测:目标在 1440×900 为 `x=176 / width=1088`,在 3072×1200 仍为 `width=1088`,在 390×844 为 `x=16 / width=358`;“问题整改”基准在 1280×720 为 `width=1088`,在 390×844 同为 `x=16 / width=358`。目标弹窗 `scrollWidth === clientWidth`,页面无横向溢出,控制台 0 error / 0 warning。
- 控制台:生产验收会话为 0 error / 0 warning;八种主题/视口组合均无页面横向溢出。
......@@ -69,7 +70,7 @@
| AI 对话 | 普通问答、知识库问答、审计检查清单、附件和工具调用 | 消息结构、请求上下文、流式行为、错误回退不变 |
| 发票助手 | 由应用配置启用的对话分支 | 上传、识别、确认、下载和状态流不变 |
| 审计知识库 | Excel 导入、提示词配置、风险点结果和导出 | Excel 解析、保存、查询和导出逻辑不变 |
| 应用切换 | 调用系统信息与应用列表接口后跳转 | 数据解包、当前应用兜底、权限列表和跳转回调不变 |
| 应用切换 | 调用系统信息与应用列表接口后跳转 | `site.group` 只控制展示分组;数据解包、当前应用兜底、权限列表和跳转回调不变 |
| 退出登录 | 删除 `current_user` 并重新显示登录 | 真实清理行为不变,迁移到 Sidebar 用户菜单入口 |
`Access` 页面和已注释 Chat 路由当前不可达,记录为非活动代码;迁移完成前仍执行旧组件引用扫描,避免活动入口间接引用旧视觉组件。
......
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { getApplications, getSystemInfo } from '@/services/DataService';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
......@@ -41,13 +41,26 @@ function normalizeApplication(application, fallback = {}) {
}
function extractApplications(response) {
if (Array.isArray(response)) return response;
if (Array.isArray(response?.records)) return response.records;
if (Array.isArray(response?.applications)) return response.applications;
if (Array.isArray(response?.data)) return response.data;
const candidates = [
response,
response?.data,
response?.applications,
response?.apps,
response?.records,
response?.rows,
response?.list,
response?.result,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
return [];
}
function extractSiteInfo(response) {
return response?.site || response?.data?.site || {};
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
......@@ -76,6 +89,49 @@ 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) => {
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;
......@@ -121,15 +177,18 @@ function ApplicationCard({ application, current }) {
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<Card.Title className="break-words" title={application.title}>
<span
className="block truncate text-sm font-medium leading-5"
title={application.title}
>
{application.title}
</Card.Title>
<Card.Description
className="truncate"
</span>
<Description
className="mt-0.5 block truncate leading-4"
title={application.description}
>
{application.description || application.name || '应用'}
</Card.Description>
</Description>
</span>
{current ? (
<Chip color="accent" size="sm">
......@@ -168,6 +227,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [siteInfo, setSiteInfo] = useState({});
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
......@@ -176,34 +236,48 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
() => applications.find(isCurrentApplication)?.name || '',
[applications],
);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
useEffect(() => {
if (!open) return;
let cancelled = false;
const loadApplications = async () => {
const loadDialogData = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const [applicationsResult, systemInfoResult] = await Promise.allSettled([
getApplications(),
getSystemInfo(),
]);
if (cancelled) return;
if (systemInfoResult.status === 'fulfilled') {
setSiteInfo(extractSiteInfo(systemInfoResult.value));
} else {
setSiteInfo({});
}
if (applicationsResult.status === 'rejected') {
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
return;
}
setApplications(
mergeApplications(
extractApplications(response),
extractApplications(applicationsResult.value),
fallbackApplications,
),
);
setStatus('success');
} catch {
if (cancelled) return;
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
loadDialogData();
return () => {
cancelled = true;
};
......@@ -226,19 +300,22 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
size="lg"
>
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
aria-label="应用切换"
className="min-w-0 max-h-[min(42rem,calc(100dvh-2rem))] p-7"
style={{
width: 'min(46rem, calc(100vw - 2rem))',
maxWidth: '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>
<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>
......@@ -252,15 +329,31 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<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">
{applications.map((application) => (
{section.applications.map((application) => (
<ApplicationCard
application={application}
current={application.name === currentApplicationName}
current={
application.name === currentApplicationName
}
key={application.name}
/>
))}
</div>
</section>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
......
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