Commit 15a1e978 authored by Andy-bubu's avatar Andy-bubu

feat: 对齐应用切换弹窗设计规范

parent 73208caf
# UI 架构
更新日期:2026-08-20
更新日期:2026-08-26
## 目标边界
......@@ -76,7 +76,7 @@ Umi routes
- Autocomplete 统一在共享 HeroUI-native 组合中使用当前 `react-stately` Select 的受控 `value/onChange` 契约:单选值为 ID 或 `null`,多选值为 ID 数组;官方 ClearButton、触发器显示和选项勾选均由该值驱动。远程用户字段只在业务边界输出原有 ID、`''`、ID 数组或 `[]`。`selectedKeys/onSelectionChange` 仅用于 DataGrid 等集合选择,不用于 Autocomplete 根。
- 根 `html` 同时携带 class 与 `data-theme`,Portal 从根继承主题。
- 查看态详情使用单一 L2 Surface;编辑态 controls 直接位于 Overlay 并使用 `secondary`。
- 应用切换保留目标的应用 API、归一化和导航 callback,只替换为批准的 Modal/identity composition。
- 应用切换保留目标的应用 API、归一化和导航 callback;`ApplicationSwitcherDialog` 解包现有响应形态,按 `siteInfo.group` 以应用 name 匹配真实可访问应用,保持后端组序/组内序,隐藏空组并将未引用应用归入“其他应用”,无配置时回退“全部应用”。外层使用批准的 `XinyuanModal`/HeroUI Modal compound composition。
- 移动导航复用 HeroUI Pro Sidebar Provider 的 `isMobileOpen/setMobileOpen` 状态,并以同包 Sheet compound 建立具名 Dialog,规避当前 Sidebar.Mobile 未向 Dialog 根透传可访问名称的问题。
## 删除顺序
......
# 鑫元 UI 迁移清单
更新日期:2026-08-20
更新日期:2026-08-26
状态说明:`[ ]` 待处理,`[x]` 已完成,`[-]` 不适用或受外部条件阻塞。
......@@ -32,7 +32,7 @@
- [x] Navbar 使用 Pro 默认 Sidebar.Trigger。
- [x] 页面与 Navbar 对齐 48/32/16px 对称 gutter。
- [x] 用户入口使用 Dropdown.Trigger,真实退出可用且 base-aware。
- [x] 应用切换使用 HeroUI Modal、固定两列和 36px identity well。
- [x] 应用切换使用 HeroUI Modal、动态站点分组、移动单列/sm 起固定两列和 36px identity well。
- [x] 主题在刷新和路由切换后保持,全部 Portal 跟随根主题。
## 资源统计
......@@ -92,6 +92,7 @@
- 目标与问题整改在四个规定视口、浅色和深色下同时打开比较;两端八组视口均无页面级横向溢出。
- 已打开并操作用户 Dropdown、应用切换 Modal、移动 Sheet、筛选 Disclosure、枚举与远程用户 Autocomplete、DatePicker、项目/周进度/导入 Modal、删除 AlertDialog、DataGrid、Pagination、ActionBar 和图表 Tooltip。
- 应用切换专项复验:1440x900 与 `model_analysis` 几何均为 736x484、24px 圆角、28px inset、24px 关闭按钮偏移 28px;390x844 为 x=16、width=358、单列且页面横向溢出 0;浅色/深色 Portal 控制台均 0 error/0 warning。
- 选择器专项回归:项目负责人和项目协作人均通过连续双选、重新打开保留勾选、单项取消、触发器即时回显及官方 ClearButton 全部清空;筛选状态/负责人及表单枚举通过选择回显、清除和实时查询。桌面浅色与 390x844 深色均通过,移动弹层无横向溢出。
- 退出菜单实际跳转到 `/project_manager_xy/login`;取消周进度与删除确认不会触发保存/删除请求。
- 最后一次目标页面重载与退出验收均为 0 error、0 warning。
......
# 部门任务管理鑫元设计系统
更新日期:2026-08-20
更新日期:2026-08-26
本项目直接继承鑫元 HeroUI 设计系统,以“问题整改”运行结果为唯一结构、尺寸、间距和交互基准。业务页面不得建立第二套视觉语言。
......@@ -43,7 +43,7 @@
## 组件合同
- 登录:约 28rem 卡片、36px Logo、卡内品牌、24px 字段组节奏、40px 字段、46px提交按钮。
- 应用切换:46rem/42rem,手机一列、sm 起固定两列,36px `Surface tertiary + Avatar soft` identity well。
- 应用切换:46rem/42rem,24px 圆角与 28px inset,手机一列、sm 起固定两列,36px `Surface tertiary + Avatar soft` identity well;分组来自 `site.group`,按后端顺序渲染并将未引用应用归入“其他应用”。
- 筛选:1-2 个字段不加容器;3 个以上使用 `Surface default rounded-3xl p-6`,前三项常用字段,其余放 HeroUI Disclosure。
- 列表工具栏:搜索在左、列分组紧随、业务操作在右、主操作最右;手机搜索先独占一行。
- DataGrid:原生选择/排序/固定列/滚动;20px 外圆角;固定每页 10 条;Pagination 与 DataGrid 间距 8px。
......
# 鑫元 UI 迁移审计
更新日期:2026-08-20
更新日期:2026-08-26
## 范围与基准
......@@ -48,7 +48,7 @@
| 鉴权 | `Auth.jsx` 拉取用户并清理登录态 | 仅修正 base-aware 导航,不改调用与时机 |
| Sidebar | HeroUI Pro Sidebar,offcanvas 已启用 | 统一实际基准几何、单组标签、默认 Trigger、用户 Dropdown.Trigger |
| Navbar | HeroUI Pro Navbar | 对齐 48/32/16px gutter 和真实 Sidebar.Trigger |
| 应用切换 | 旧 Dialog 包装 + 三列 ItemCard | 直接 HeroUI Modal,固定两列、36px identity well |
| 应用切换 | 平铺应用列表,未读取站点分组 | 基于 `GET /info -> site.group` 的 HeroUI Modal 分组,移动单列、sm 起固定两列、36px identity well |
| 主题 | 根 `.light/.dark` + localStorage | 保留键与行为,改用权威 theme.css 驱动全部 Portal |
| 退出 | 现有 `userLogout` + clearUser | 保留真实回调,修正 base-aware 登录跳转 |
......@@ -78,6 +78,7 @@
- Dropdown、Tooltip、Popover、Modal、DatePicker、ActionBar 均需在 body Portal 下复核根主题。
- Dropdown、Tooltip、Modal、Autocomplete、DatePicker、ActionBar 和图表 Tooltip 均通过根 `.light/.dark` 与 `data-theme` 驱动。
- 已验证远程用户 Autocomplete 打开/加载/选择、DatePicker Calendar、DataGrid Checkbox、Pagination、ActionBar、应用切换和用户菜单。
- 应用切换已同步 `model_analysis` 基准:按站点配置保持分组和组内顺序,空组隐藏,未分组应用归入“其他应用”,无配置回退“全部应用”;真实浏览器验证了分组数量、唯一当前 Chip、关闭/焦点与响应式几何。
- 390px 页首 Tabs 产生的 max-content 溢出已通过显式单列收缩边界修复;业务操作在手机视口内保持可达。
- Pro `Sidebar.Mobile` 当前版本未把可访问名称传给 Dialog 根,已改用同包 Sheet compound 与 Sidebar 状态组合,移动抽屉控制台零警告。
......
import { Close } from '@/components/AppIcons';
import { Modal } from '@heroui/react';
import clsx from 'clsx';
......@@ -13,6 +12,7 @@ const DIALOG_WIDTH_CLASS = {
export default function XinyuanModal({
isOpen,
onOpenChange,
isDismissable,
title,
description,
size = 'lg',
......@@ -26,29 +26,36 @@ export default function XinyuanModal({
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={isOpen} onOpenChange={onOpenChange} variant="blur">
<Modal.Backdrop
isDismissable={isDismissable}
isOpen={isOpen}
variant="blur"
onOpenChange={onOpenChange}
>
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
aria-label={title}
className={clsx(
DIALOG_WIDTH_CLASS[size] && 'p-7',
'rc-modal-dialog p-7',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
{description ? (
<div className="text-sm text-muted">{description}</div>
) : null}
</Modal.Header>
<Modal.Body className={bodyClassName}>
<Modal.Body className={bodyClassName} style={{ marginTop: '0.75rem' }}>
{children}
</Modal.Body>
{footer ? (
<Modal.Footer>{footer}</Modal.Footer>
<Modal.Footer style={{ marginTop: '1.5rem' }}>{footer}</Modal.Footer>
) : null}
</Modal.Dialog>
</Modal.Container>
......
export const APPLICATION_DISPLAY_NAME = '部门任务管理';
export const APPLICATION_BASE_PATH = '/project_manager_xy/';
export const APPLICATION_ROUTE_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g, '');
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
......
......@@ -526,6 +526,12 @@
background: transparent;
}
.rc-application-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
......
import { getApplications } from '@/services/DataService';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import XinyuanModal from '@/components/XinyuanModal';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import xyLogoRed from '@/assets/xy-logo-red.png';
import { APPLICATION_ROUTE_NAME } from '@/config/application';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
......@@ -7,12 +10,26 @@ import {
Card,
Chip,
Description,
Modal,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
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 [];
......@@ -49,7 +66,10 @@ function normalizeApplication(application, fallback = {}) {
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
logo:
application?.logo ||
fallback?.logo ||
(name === APPLICATION_ROUTE_NAME ? xyLogoRed : null),
url: resolveApplicationUrl({
name,
}),
......@@ -95,6 +115,52 @@ 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;
......@@ -134,11 +200,7 @@ 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 grid size-9 shrink-0 place-items-center overflow-hidden"
variant="tertiary"
>
<Surface aria-hidden="true" className="rc-application-identity-well grid size-9 shrink-0 place-items-center overflow-hidden" variant="tertiary">
<Avatar
className="rc-application-identity-avatar size-full"
color="accent"
......@@ -151,12 +213,12 @@ function ApplicationCard({ application, current }) {
</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 +248,7 @@ function LoadingSkeleton() {
export default function ApplicationSwitcherDialog({
open,
onClose,
siteInfo,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
......@@ -211,6 +274,10 @@ export default function ApplicationSwitcherDialog({
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || '';
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
useEffect(() => {
if (fallbackApplications.length === 0) {
......@@ -239,7 +306,7 @@ export default function ApplicationSwitcherDialog({
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
const fetchedApplications = unwrapApplications(response);
if (cancelled) {
return;
......@@ -271,32 +338,16 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications, open]);
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
<XinyuanModal
description="选择你可访问的应用"
dialogClassName="rc-application-switcher-dialog"
isOpen={open}
size="xl"
title="应用切换"
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0">
<Modal.Heading>应用切换</Modal.Heading>
<Description>
选择你可访问的应用
</Description>
</div>
</Modal.Header>
<Modal.Body>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
......@@ -307,8 +358,15 @@ export default function ApplicationSwitcherDialog({
{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
key={application.name}
application={application}
......@@ -316,6 +374,9 @@ export default function ApplicationSwitcherDialog({
/>
))}
</div>
</section>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
......@@ -324,10 +385,6 @@ export default function ApplicationSwitcherDialog({
</EmptyState.Header>
</EmptyState>
)}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
</XinyuanModal>
);
}
......@@ -14,7 +14,7 @@
## 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