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

fix: align application switcher with Xinyuan baseline

parent d04092d9
......@@ -9,7 +9,7 @@
字段后缀图标由 `src/components/AppIcons.jsx` 的 `FIELD_SUFFIX_ICON_STYLE` 统一提供语义颜色。`XinyuanAutocomplete` 和 `FormDatePicker` 直接把该样式绑定到 Ant 图标根节点,并在共享组件内部固定 16px 图标与 DatePicker 24px Trigger 几何;页面不得覆盖颜色、位置或内部 slot。
应用切换由 `ApplicationSwitcherDialog.jsx` 保留目标应用的数据归一化、当前项识别和跳转逻辑,但视觉结构严格继承“问题整改”:46rem/42rem Modal、手机一列、`sm` 起固定两列、12px gap,真实 Card 和 Loading Skeleton 使用同一网格,不设置桌面三列分支。
应用切换由 `ApplicationSwitcherDialog.jsx` 保留目标应用的 `/application/info`、用户应用合并、当前项识别和跳转逻辑,并从 `SiteContext` 使用 `/info -> site.group` 动态生成有权限的非空分组。结构严格继承 `model_analysis`:46rem/42rem Modal、28px inset、24px/12px 分组节奏、手机一列、`sm` 起固定两列、36px identity well;未归组应用进入“其他应用”,无分组配置时使用“全部应用”。
## 直接导入规则
......
......@@ -14,7 +14,7 @@
- [x] Login:迁移 HeroUI 原生控件、验证码、filled/autofill 和响应式结构
- [x] Shell:迁移品牌、offcanvas、用户菜单、主题和 base-aware 导航
- [x] 应用切换:迁移 HeroUI Modal、真实应用和 36px identity well
- [x] 应用切换:同步 `model_analysis` HeroUI Modal、`site.group` 动态分组、真实数量、“其他应用”/“全部应用”回退和 36px identity well
- [x] 驾驶舱:移除 ECharts/手写 KPI,改用 HeroUI Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip
- [x] 党风廉政 Tabs、结构化筛选与透明工具栏 SearchField;无刷新/查询按钮并保持自动筛选
- [x] 党风廉政 DataGrid、选择、ActionBar、固定 10 条 Pagination
......@@ -45,3 +45,13 @@
- [ ] 控制台无 runtime、slot、provider、重复 Context 错误
运行态验收说明:2026-08-19 本地开发服务已启动,但当前执行环境未暴露可连接的应用内浏览器或 Chrome 实例,以上三项浏览器门禁尚未执行,不能以静态检查替代。
## 2026-08-26 应用切换专项验收
- [x] 与 `model_analysis` 同时打开并在 1440x900、1280x720、768x1024、390x844 测量:目标与基准均为桌面/平板 736px、390 视口 358px,24px 圆角、28px inset、24px 关闭按钮和 36px identity well。
- [x] `sm` 起严格两列、移动端一列,所有网格横纵 gap 12px;Body 独立滚动且长列表不增加第三列。
- [x] 动态分组保持配置和组内顺序、过滤空组与无权限应用,显示真实数量;未归组应用进入“其他应用”,无配置时使用单个“全部应用”。
- [x] 浅色和深色的 Modal/Card/Surface 均由 HeroUI 语义 Surface 提供,无白色硬编码背景;“当前”仅出现一次。
- [x] Escape、关闭按钮、焦点约束、dialog accessible name 与 `aria-current` 通过。
- [x] 应用切换自身无控制台错误或告警。
- [ ] 390px 从 Mobile Sidebar 入口打开时仍会触发既有 Sidebar Dialog title 告警,与本次应用切换组件无关,未扩大范围修改。
......@@ -34,7 +34,7 @@
- 业务列表固定每页 10 条,不提供页容量选择器。
- Dashboard/KPI 外层圆角 24px,DataGrid 20px,字段 12px,Modal 24px。
- 例行成功反馈使用顶部居中的自动消失 Toast;需要处理的错误保留上下文 Alert。
- 应用切换 Modal 固定采用手机一列、`sm` 起两列的应用 Card 网格,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表在 Modal Body 内滚动。
- 应用切换严格继承 `model_analysis`:从 `/info -> site.group` 动态生成分组,保持配置顺序并显示真实数量;未归组应用进入“其他应用”,无配置时使用“全部应用”。Modal 固定 46rem/42rem、28px inset、手机一列、`sm` 起两列,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表仅在 Modal Body 内滚动。
## 字段后缀图标
......
......@@ -56,7 +56,7 @@
- 已通过官方 `hpsetup` 在当前仓库独立安装 `@heroui-pro/react@1.0.0-beta.8`;`package.json`、lockfile 和安装目录均不再依赖相邻项目、绝对路径或软链接。
- HeroUI 基础样式入口已更正为 `@heroui/styles/css`。此前包根 JavaScript 入口导致 Tabs、Input、Button、Dropdown 和 DataGrid 缺失官方结构样式,是本次截图中纵向 Tabs、裸控件和尺寸异常的共同根因。
- Catalyst、Headless UI、旧 Select/Table 兼容层与多余 pnpm lockfile 已从活动源码移除。
- Login、Shell、应用切换、党风廉政筛选/DataGrid/Modal/DropZone 已完成原生 HeroUI/HeroUI Pro 结构迁移;与“问题整改”的浏览器逐项对照仍受浏览器不可用阻断。
- Login、Shell、党风廉政筛选/DataGrid/Modal/DropZone 已完成原生 HeroUI/HeroUI Pro 结构迁移。应用切换进一步同步 `model_analysis` 当前基准:保留 `/application/info` 与用户权限合并,读取 `/info -> site.group` 动态分组,显示真实组数并处理“其他应用”/“全部应用”回退。
- 党风廉政筛选对齐问题整改基线:Surface 内前三项等宽排列,溢出条件通过 Disclosure 展开;移除刷新和查询按钮,字段变化后自动请求,关键词采用约 300ms 延迟。
- 驾驶舱已移除 ECharts,KPI 使用 HeroUI Pro `KPI` compound API,图表使用 Pro `PieChart`、`BarChart`、`ComposedChart` 与 `ChartTooltipContent`。
- `src/xinyuan-portable.css` 已删除;页面目录不存在私有 CSS/Less/SCSS。`theme.css` 与 Skill 权威副本一致,`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 与确认基准逐字节一致。
......@@ -64,7 +64,8 @@
- 2026-08-19 最终静态门禁:`npm run build`、`git diff --check`、鑫元审计脚本均成功;旧组件、ECharts、页面私有视觉类、跨项目依赖、本机路径和授权值均无有效源码命中。
- HeroUI Pro 的 CSS、入口文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 导入均可用。
- 依赖树的应用与 Pro 主路径统一使用 HeroUI 3.2.2 / React Aria Components 1.19.0;Adobe Spectrum 的间接依赖仍包含 React Aria Components 1.20.0,不进入应用直接组件边界。
- 开发服务在 2026-08-19 启动于 `http://localhost:8003/incorrupt_party_build/`。当前执行环境没有可连接浏览器,因此桌面/平板/移动端、深浅主题、打开态弹层和控制台检查仍待浏览器门禁执行。
- 2026-08-26 修改前开发服务以 `npm run dev` 启动于 `http://localhost:8003/incorrupt_party_build/`,base/publicPath 为 `/incorrupt_party_build/`;未登录状态跳转 `/login?redirect=%2F`,控制台无错误或警告。应用切换改动后的四视口、深浅主题和打开态结果记录在迁移清单中。
- 应用切换已与 `model_analysis` 在四个规定视口逐项测量一致,深浅主题均通过;桌面入口控制台无错误或告警。移动端打开 Sidebar 时 HeroUI Mobile Sidebar 仍产生既有 Dialog title 告警,应用切换自身具备 accessible name/Heading,未产生新增告警。
## 原始业务状态记录
......
import { useLayoutEffect, useState } from 'react';
import { useContext, useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { SiteContext } from '@/wrapper/Site';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
......@@ -27,6 +28,7 @@ export default function AppShell({
title,
...sidebarProps
}) {
const siteInfo = useContext(SiteContext);
const [theme, setTheme] = useState(getInitialTheme);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
......@@ -67,9 +69,10 @@ export default function AppShell({
</div>
</Sidebar.Main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
isOpen={isApplicationDialogOpen}
siteInfo={siteInfo}
userInfo={sidebarProps.userInfo}
onOpenChange={setIsApplicationDialogOpen}
/>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
......
import { Close } from '@/components/AppIcons';
import { Modal } from '@heroui/react';
import clsx from 'clsx';
......@@ -11,14 +10,16 @@ const DIALOG_WIDTH_CLASS = {
};
export default function XinyuanModal({
bodyClassName,
children,
dialogClassName,
footer,
isOpen,
onOpenChange,
size = 'lg',
isDismissable,
title,
description,
size = 'lg',
dialogClassName,
bodyClassName,
children,
footer,
}) {
const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg';
......@@ -26,26 +27,32 @@ export default function XinyuanModal({
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isDismissable={isDismissable}
isOpen={isOpen}
onOpenChange={onOpenChange}
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',
'xy-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}>{children}</Modal.Body>
{footer ? <Modal.Footer>{footer}</Modal.Footer> : null}
<Modal.Body className={bodyClassName} style={{ marginTop: '0.75rem' }}>
{children}
</Modal.Body>
{footer ? <Modal.Footer style={{ marginTop: '1.5rem' }}>{footer}</Modal.Footer> : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
......
......@@ -650,6 +650,25 @@
align-items: stretch;
}
.xy-modal-dialog {
min-width: 0;
}
.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-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
......
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import XinyuanModal from '@/components/XinyuanModal';
import { APP_NAME, APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import { getApplications } from '@/services/DataService';
import {
Close as XMarkIcon,
ExclamationTriangleIcon,
} from '@/components/AppIcons';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
Skeleton,
Surface,
} from '@heroui/react';
......@@ -23,6 +21,7 @@ const CURRENT_APPLICATION = {
name: APP_ROUTE_NAME,
title: APP_DISPLAY_NAME,
description: APP_LOGIN_SUBTITLE,
logo: xyLogoRed,
};
function normalizeLabelList(items) {
......@@ -71,8 +70,9 @@ function normalizeApplication(application, fallback = {}) {
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo:
application?.logo ||
logo: isCurrentSystem
? xyLogoRed
: application?.logo ||
application?.logo_url ||
application?.icon ||
fallback?.logo ||
......@@ -149,6 +149,57 @@ function unwrapApplications(response) {
return candidates.find(Array.isArray) || [];
}
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;
......@@ -202,13 +253,9 @@ function ApplicationCard({ application }) {
rel={target ? 'noreferrer' : undefined}
target={target}
>
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
>
<Surface aria-hidden="true" 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"
>
......@@ -219,12 +266,12 @@ function ApplicationCard({ application }) {
</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>
......@@ -252,8 +299,9 @@ function LoadingSkeleton() {
}
export default function ApplicationSwitcherDialog({
open,
onClose,
isOpen,
onOpenChange,
siteInfo,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
......@@ -279,6 +327,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) {
......@@ -295,7 +347,7 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
if (!isOpen) {
return;
}
......@@ -336,40 +388,17 @@ export default function ApplicationSwitcherDialog({
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
}, [fallbackApplications, isOpen]);
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container
className="w-full sm:w-full"
placement="center"
scroll="inside"
size="lg"
<XinyuanModal
description="选择你可访问的应用"
dialogClassName="xy-app-switcher-dialog"
isOpen={isOpen}
size="xl"
title="应用切换"
onOpenChange={onOpenChange}
>
<Modal.Dialog
className="app-switcher-dialog max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭应用切换">
<XMarkIcon size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0">
<Modal.Heading>应用切换</Modal.Heading>
<Description>
选择你可访问的应用
</Description>
</div>
</Modal.Header>
<Modal.Body className="[scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator>
......@@ -384,14 +413,24 @@ 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}
/>
))}
</div>
</section>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
......@@ -402,10 +441,6 @@ export default function ApplicationSwitcherDialog({
</EmptyState.Header>
</EmptyState>
)}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
</XinyuanModal>
);
}
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