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

feat: align model analysis with Xinyuan HeroUI baseline

parent 5b9d1940
# UI 架构 # UI 架构
视觉基准:`/Users/liuchenlong/Desktop/job/audit_project_manager` 当前实现。所有页面文字沿用
该应用的层级:Navbar 20px/600、页面分区 18px/700、模块标题 16px/600、正文 14px/400、
次要说明 13px/400、表头和状态 Chip 12px/500。
## 层次 ## 层次
1. `.umirc.ts` + `public/fonts/inter.css`:常驻 Inter 400/500/600 拉丁字符字体入口,不参与 `umi.css` 热替换。 1. `.umirc.ts` + `public/fonts/inter.css`:常驻 Inter 400/500/600 拉丁字符字体入口,不参与 `umi.css` 热替换。
2. `src/tailwind.css`:Tailwind、HeroUI、HeroUI Pro CSS 入口;使用 `source(none)` 和显式源码目录,排除 Umi 生成目录。 2. `src/tailwind.css`:Tailwind、HeroUI、HeroUI Pro CSS 入口;使用 `source(none)` 和显式源码目录,排除 Umi 生成目录。
3. `src/design-system/theme.css`:不可修改的权威 HeroUI 主题。 3. `src/design-system/theme.css`:不可修改的权威 HeroUI 主题。
4. `src/design-system/tokens.css`:Skill 批准的鑫元扩展 Token。 4. `src/design-system/tokens.css`:Skill 批准的鑫元扩展 Token。
5. `src/design-system/xinyuan.css`:登录与 Shell 几何、应用切换宽高、KPI 布局。 5. `src/design-system/shell.css`:基准 Shell、列表、分页、KPI 和响应式几何。
6. 页面 JSX:仅业务组合和布局,不包含私有组件外观。 6. 页面 JSX:仅业务组合和布局,不包含私有组件外观。
## 字体稳定性 ## 字体稳定性
...@@ -16,11 +20,11 @@ ...@@ -16,11 +20,11 @@
## 组件边界 ## 组件边界
- `Layout.jsx`:HeroUI Pro Sidebar/Navbar、主题切换、应用切换、退出;移动端 `Sidebar.Mobile` 使用 HeroUI Pro `Sheet.Heading` 提供不可见但可访问的 Dialog 标题。 - `Layout.jsx`:通过 `AppShell` 组合 HeroUI Pro Sidebar/Navbar、应用切换和真实退出;内容区固定浅色,Sidebar 固定深色,移动端使用 HeroUI Pro Sheet。页面层级面包屑由 Layout 根据路由注入 Navbar,父级可返回、当前级为深色,页面正文不再重复显示面包屑。
- `Layout.jsx`:从通知 service 读取真实未读数,监听跨页面状态事件并每 60 秒刷新;Navbar 只负责铃铛展示和路由跳转。 - `Layout.jsx`:从通知 service 读取真实未读数,监听跨页面状态事件并每 60 秒刷新;Navbar 只负责铃铛展示和路由跳转。
- `notifications/notificationService.js`:复用 `DataService` 的平台通知请求,负责 300ms 搜索参数、未读状态、已读事件、动态 action 和 base-aware 链接解析。 - `notifications/notificationService.js`:复用 `DataService` 的平台通知请求,负责 300ms 搜索参数、未读状态、已读事件、动态 action 和 base-aware 链接解析。
- `notifications/NotificationListPage.jsx`:HeroUI 原生消息/待办列表、搜索、状态 Tabs、Pagination、EmptyState 与 Toast,不持有模型监控业务状态。 - `notifications/NotificationListPage.jsx`:HeroUI 原生消息/待办列表、搜索、状态 Tabs、Pagination、EmptyState 与 Toast,不持有模型监控业务状态。
- `ApplicationSwitcherDialog.jsx`:HeroUI Modal/Card/Surface/Avatar;从 `SiteContext` 的动态 `site.group` 构建应用分组,不在 UI 层写死分组数据。 - `ApplicationSwitcherDialog.jsx`:HeroUI Modal、ItemCardGroup、ItemCard、Surface/Avatar;从 `SiteContext` 的动态 `site.group` 构建应用分组,不在 UI 层写死分组数据。
- `XinyuanModal.jsx`:HeroUI `Modal.Root > Backdrop > Container > Dialog` 的业务无关组合,集中实现 320–1216px 设计系统宽度梯度和 28px 标准内容 inset;扩展尺寸由官方 `lg` Container 加共享最大宽度实现,不向 HeroUI v3 传递无效的 `xl/4xl` size。圆角、背景、阴影和交互状态仍由 HeroUI 官方 Modal 样式负责。 - `XinyuanModal.jsx`:HeroUI `Modal.Root > Backdrop > Container > Dialog` 的业务无关组合,集中实现 320–1216px 设计系统宽度梯度和 28px 标准内容 inset;扩展尺寸由官方 `lg` Container 加共享最大宽度实现,不向 HeroUI v3 传递无效的 `xl/4xl` size。圆角、背景、阴影和交互状态仍由 HeroUI 官方 Modal 样式负责。
- `ModelMonitorShared.jsx`:业务中性的 SearchField、DataGrid、Pagination、ActionBar、状态 Chip、动态字段组合。 - `ModelMonitorShared.jsx`:业务中性的 SearchField、DataGrid、Pagination、ActionBar、状态 Chip、动态字段组合。
- `XinyuanAutocomplete.jsx`:HeroUI Autocomplete/SearchField/ListBox 组合,暴露当前 HeroUI 值契约,不兼容旧 Select props。 - `XinyuanAutocomplete.jsx`:HeroUI Autocomplete/SearchField/ListBox 组合,暴露当前 HeroUI 值契约,不兼容旧 Select props。
......
...@@ -2,18 +2,21 @@ ...@@ -2,18 +2,21 @@
## 已完成 ## 已完成
- [x] 2026-09-02 复核并同步审计项目管理基准:固定浅色页面、深色 offcanvas Sidebar、共享 Navbar、分组应用切换 Modal、共享图标出口
- [x] 官方 HeroUI Pro 安装成功,完整包和六个要求子路径可导入 - [x] 官方 HeroUI Pro 安装成功,完整包和六个要求子路径可导入
- [x] 单一 `pnpm-lock.yaml`,无 `file:`、vendor、本机绝对路径或跨项目依赖 - [x] 单一 `pnpm-lock.yaml`,无 `file:`、vendor、本机绝对路径或跨项目依赖
- [x] 权威 `theme.css` 与 `tokens.css` 逐字节一致 - [x] 权威 `theme.css`、`tokens.css` 与共享 `shell.css` 已同步基准
- [x] 登录、Sidebar、Navbar、应用切换和退出结构迁移 - [x] 登录、Sidebar、Navbar、应用切换和退出结构迁移
- [x] SearchField、Autocomplete、DatePicker、Checkbox、Tabs 使用 HeroUI compound API - [x] SearchField、Autocomplete、DatePicker、Checkbox、Tabs 使用 HeroUI compound API
- [x] 模型、批次、批次数据、转发审批列表迁移为 DataGrid - [x] 模型、批次、批次数据、转发审批列表迁移为 DataGrid
- [x] DataGrid 原生选择、真实溢出固定列、横向滚动、单一空态 - [x] DataGrid 原生选择、真实溢出固定列、横向滚动、单一空态
- [x] Pagination 固定 10 条、最多五页窗口、无页大小选择 - [x] 模型、模型批次和批次数据 Pagination 使用 HeroUI 标准组合:列表有数据即显示,默认 20 条/页,支持 20/50/100 条切换,最多五页窗口;空列表隐藏分页
- [x] ActionBar 固定定位并对齐活动内容区 - [x] ActionBar 固定定位并对齐活动内容区
- [x] 模型分析按跨应用白名单只接入消息通知,未引入 `shortTitle`、View 权限或材料中心文件功能 - [x] 模型分析按跨应用白名单只接入消息通知,未引入 `shortTitle`、View 权限或材料中心文件功能
- [x] 新增 `/notifications`,支持消息/待办、全部/未读、300ms 搜索、固定 10 条分页、单条/全部已读、动态 action 和关联跳转 - [x] 新增 `/notifications`,支持消息/待办、全部/未读、300ms 搜索、固定 10 条分页、单条/全部已读、动态 action 和关联跳转
- [x] Navbar 使用 HeroUI Badge.Anchor + Badge + Button + Tooltip,Bell 位于主题按钮左侧,Sidebar 无通知入口 - [x] Navbar 使用 HeroUI Badge.Anchor + Badge + Button + Tooltip,Bell 位于主题按钮左侧,Sidebar 无通知入口
- [x] 路由层级面包屑统一放入 Navbar;模型批次数据使用“全部模型 > 模型批次 > 批次数据”,父级可返回对应列表
- [x] 弹窗使用 HeroUI Modal/AlertDialog;全部业务 Modal 接入共享 320–1216px 尺寸梯度,审批详情使用单一 secondary Surface - [x] 弹窗使用 HeroUI Modal/AlertDialog;全部业务 Modal 接入共享 320–1216px 尺寸梯度,审批详情使用单一 secondary Surface
- [x] 批量同步弹窗使用 HeroUI Pro Stepper/ListView、HeroUI ScrollShadow 和正确 Checkbox anatomy;移除手写灰色模型/批次分组及重复 Surface - [x] 批量同步弹窗使用 HeroUI Pro Stepper/ListView、HeroUI ScrollShadow 和正确 Checkbox anatomy;移除手写灰色模型/批次分组及重复 Surface
- [x] KPI 使用 HeroUI Pro compound API 与 16px grid gap - [x] KPI 使用 HeroUI Pro compound API 与 16px grid gap
......
# 模型分析鑫元设计系统 # 模型分析鑫元设计系统
本项目的唯一视觉与交互基准为已确认的“问题整改”应用,业务行为以 `record-api.md`、`subjectsDefinition.md` 和 `model-monitor.business-flow.md` 为准。 本项目的唯一视觉与交互基准为已确认的“审计项目管理”应用:
`/Users/liuchenlong/Desktop/job/audit_project_manager`。业务行为以 `record-api.md`、
`subjectsDefinition.md` 和 `model-monitor.business-flow.md` 为准。
## 基础约束 ## 基础约束
- 页面和共享组件直接使用 `@heroui/react`、`@heroui-pro/react` 及官方子路径。 - 页面和共享组件直接使用 `@heroui/react`、`@heroui-pro/react` 及官方子路径。
- 权威 `theme.css` 原样加载,SHA-256 为 `a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`;`tokens.css` 仅提供 Skill 批准的扩展 Token。 - 权威 `theme.css` 原样加载,SHA-256 为 `a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`;`tokens.css` 仅提供 Skill 批准的扩展 Token。
- Inter 400/500/600 拉丁子集由 `.umirc.ts` 生成的常驻 `public/fonts/inter.css` link 加载;不得在 `tailwind.css` 中导入字体 CSS,避免 Umi HMR 替换 `umi.css` 时造成英文、数字和符号字体回退抖动。 - Inter 400/500/600 拉丁子集由 `.umirc.ts` 生成的常驻 `public/fonts/inter.css` link 加载;不得在 `tailwind.css` 中导入字体 CSS,避免 Umi HMR 替换 `umi.css` 时造成英文、数字和符号字体回退抖动。
- 统一文字层级:正文 14px/400,次要说明 13px/400,模块标题 16px/600,页面分区标题
18px/700,Navbar 标题 20px/600,表头和状态 Chip 12px/500;对应 Token 为
`--rc-font-normal/medium/semibold/bold` 与 `--rc-text-sm/section/module`。
- 页面 `className` 只承担 flex、grid、尺寸、间距、对齐、截断和响应式布局,不定义组件颜色、边框、圆角、阴影或状态;次要文字使用 HeroUI `Description`,必填星号使用 `Label isRequired`。 - 页面 `className` 只承担 flex、grid、尺寸、间距、对齐、截断和响应式布局,不定义组件颜色、边框、圆角、阴影或状态;次要文字使用 HeroUI `Description`,必填星号使用 `Label isRequired`。
- 文字命令按钮不加装饰图标;真实图标操作使用 HeroUI Button、Tooltip 与可访问名称。 - 文字命令按钮不加装饰图标;真实图标操作使用 HeroUI Button、Tooltip 与可访问名称。
...@@ -18,6 +23,7 @@ ...@@ -18,6 +23,7 @@
- Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 位于主题按钮左侧;未读 0 隐藏、1-99 原值、100+ 显示 `99+`,Sidebar 不重复展示通知入口。 - Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 位于主题按钮左侧;未读 0 隐藏、1-99 原值、100+ 显示 `99+`,Sidebar 不重复展示通知入口。
- Navbar Bell 与主题按钮进入同一 `rc-navbar-actions` 动作组,间距固定 8px;两者静止态均使用 HeroUI `--default` 中性 Surface,Hover 使用 `--default-hover`。 - Navbar Bell 与主题按钮进入同一 `rc-navbar-actions` 动作组,间距固定 8px;两者静止态均使用 HeroUI `--default` 中性 Surface,Hover 使用 `--default-hover`。
- Navbar 与页面共用 48/32/16px 对称 gutter。 - Navbar 与页面共用 48/32/16px 对称 gutter。
- 页面层级导航统一放在 Navbar 标题位置:父级使用灰色文字并可点击返回,层级之间使用灰色右箭头,当前页面使用深色文字;正文不重复渲染小面包屑。进入模型批次数据时沿用“全部模型 > 模型批次 > 批次数据”,其中“模型批次”返回当前模型的批次列表。
- 登录页使用居中 28rem Card、36px Logo、40px 字段、24px 字段节奏和 46px 提交按钮;验证码及原鉴权协议保留。 - 登录页使用居中 28rem Card、36px Logo、40px 字段、24px 字段节奏和 46px 提交按钮;验证码及原鉴权协议保留。
- 应用切换为 HeroUI Modal,宽 46rem、高 42rem,移动一列、`sm` 起两列,36px identity well。 - 应用切换为 HeroUI Modal,宽 46rem、高 42rem,移动一列、`sm` 起两列,36px identity well。
...@@ -25,7 +31,7 @@ ...@@ -25,7 +31,7 @@
- 筛选:HeroUI Surface、SearchField、Autocomplete、DatePicker、Disclosure。 - 筛选:HeroUI Surface、SearchField、Autocomplete、DatePicker、Disclosure。
- 列表:HeroUI Pro DataGrid,原生选择/排序/固定列/横向滚动;空态使用 `renderEmptyState`。 - 列表:HeroUI Pro DataGrid,原生选择/排序/固定列/横向滚动;空态使用 `renderEmptyState`。
- 分页:HeroUI compound Pagination,固定 10 条,最多五个连续页码,无页大小选择器。 - 分页:HeroUI compound Pagination 与 HeroUI Autocomplete 页容量选择器组合;业务列表默认 20 条/页,支持 20/50/100 条切换,最多五个连续页码,有数据才显示。
- 批量操作:HeroUI Pro ActionBar 固定于视口并居中于活动内容区。 - 批量操作:HeroUI Pro ActionBar 固定于视口并居中于活动内容区。
- 弹窗:HeroUI Modal/AlertDialog;统一通过 HeroUI 原生 `XinyuanModal` compound 组合解析尺寸梯度:`xs` 320px、`sm` 384px、`md` 480px、`lg` 640px、`xl` 736px、`2xl` 840px、`3xl` 960px、`4xl` 1088px、`5xl` 1216px。确认弹窗使用 `md`,短单列表单使用 `lg`,模型归档双列表单使用 `2xl`,内容型详情/转发/批量同步使用 `4xl`;查看态仅一个 secondary Surface。禁止将 HeroUI v3 不支持的 `xl` 至 `5xl` 直接传给 `Modal.Container`。 - 弹窗:HeroUI Modal/AlertDialog;统一通过 HeroUI 原生 `XinyuanModal` compound 组合解析尺寸梯度:`xs` 320px、`sm` 384px、`md` 480px、`lg` 640px、`xl` 736px、`2xl` 840px、`3xl` 960px、`4xl` 1088px、`5xl` 1216px。确认弹窗使用 `md`,短单列表单使用 `lg`,模型归档双列表单使用 `2xl`,内容型详情/转发/批量同步使用 `4xl`;查看态仅一个 secondary Surface。禁止将 HeroUI v3 不支持的 `xl` 至 `5xl` 直接传给 `Modal.Container`。
- 普通 Modal 保持 HeroUI 官方 24px 圆角和 Overlay shadow,统一使用 28px panel inset;Modal 内承担真实 L2 边界的 `Surface secondary` 使用基准项目一致的 `rounded-2xl`(16px),不得保留大面积直角灰色块。关闭按钮直接使用无子节点的 `Modal.CloseTrigger`,由 HeroUI 渲染原生 16px CloseIcon,禁止替换为受字体基线影响的第三方图标;关闭按钮、标题、正文和 Footer 对齐同一内容边界,标题到正文 12px、正文到 Footer 24px。批量同步使用紧凑 HeroUI Pro Stepper,年份工具区使用正确 Checkbox compound anatomy,批次选择使用单一 HeroUI Pro ListView `primary` 原生多选和 HeroUI ScrollShadow,不使用页面自制灰色分组块或重复 Surface。 - 普通 Modal 保持 HeroUI 官方 24px 圆角和 Overlay shadow,统一使用 28px panel inset;Modal 内承担真实 L2 边界的 `Surface secondary` 使用基准项目一致的 `rounded-2xl`(16px),不得保留大面积直角灰色块。关闭按钮直接使用无子节点的 `Modal.CloseTrigger`,由 HeroUI 渲染原生 16px CloseIcon,禁止替换为受字体基线影响的第三方图标;关闭按钮、标题、正文和 Footer 对齐同一内容边界,标题到正文 12px、正文到 Footer 24px。批量同步使用紧凑 HeroUI Pro Stepper,年份工具区使用正确 Checkbox compound anatomy,批次选择使用单一 HeroUI Pro ListView `primary` 原生多选和 HeroUI ScrollShadow,不使用页面自制灰色分组块或重复 Surface。
......
# 模型分析 UI 审计 # 模型分析 UI 审计
审计日期:2026-08-27 审计日期:2026-09-02
实现基准:已确认的“问题整改”应用。 实现基准:`/Users/liuchenlong/Desktop/job/audit_project_manager` 当前实现。
本轮迁移:共享主题、tokens、shell、Sidebar/Navbar、应用切换 Modal、图标出口和受控 Autocomplete
已按审计项目管理基准同步;模型分析业务菜单、通知 service、路由和所有模型监控回调保持原样。
## Typography 对齐复核(2026-09-03)
- 以 `audit_project_manager` 当前运行实现为唯一字号、字色、字重基准;未复制其业务代码。
- `tokens.css` 明确提供 400/500/600/700 字重及 14/16/18px 层级 Token,`shell.css` 的页面分区和
模块标题通过 Token 解析,避免 `--rc-font-bold` 缺失导致标题回退为普通字重。
- 审批详情、填写详情标题使用页面分区 18px/700;批次填写摘要使用模块标题 16px/600;数据值显式
使用 `text-foreground`,说明和加载态使用 `text-muted`。API、数据结构、保存回调和请求时机未改动。
## 安装硬门槛 ## 安装硬门槛
...@@ -35,7 +46,7 @@ ...@@ -35,7 +46,7 @@
- 登录继续调用 `getCaptcha()`、`userLogin()`,保持 AES 密码、`captcha/token/type`、localStorage 键和 redirect。 - 登录继续调用 `getCaptcha()`、`userLogin()`,保持 AES 密码、`captcha/token/type`、localStorage 键和 redirect。
- Auth wrapper 继续通过 `getUserInfo()` 合并用户;退出继续调用 `userLogout(username)`,最终清理登录态并进入 `/login`。 - Auth wrapper 继续通过 `getUserInfo()` 合并用户;退出继续调用 `userLogout(username)`,最终清理登录态并进入 `/login`。
- 模型同步、批次同步、动态 Subject、隐藏记录、转发、填写和两级审批的 service、参数、校验、调用顺序与状态条件未修改。 - 模型同步、批次同步、动态 Subject、隐藏记录、转发、填写和两级审批的 service、参数、校验、调用顺序与状态条件未修改。
- 批次数据每页固定 10 条;Pagination 最多显示五个连续页码,无页大小选择器。 - 模型、模型批次和批次数据列表有数据时显示标准 Pagination:左侧为总条数/页数摘要和 20/50/100 条页容量选择器,右侧为最多五个连续页码;空列表隐藏分页。
- 仓库没有生成测试数字、浏览器 fixture 或伪造业务数据;验收 route 响应只存在于 Playwright 会话内存。 - 仓库没有生成测试数字、浏览器 fixture 或伪造业务数据;验收 route 响应只存在于 Playwright 会话内存。
## 跨应用功能审计 ## 跨应用功能审计
...@@ -55,7 +66,7 @@ ...@@ -55,7 +66,7 @@
- 批量同步弹窗此前将 Checkbox 标签放在 `Checkbox.Content` 外部,并用 `Surface tertiary + Checkbox` 手写模型分组列表,造成方框/文案错位和陌生灰色块。现已改为正确 Checkbox compound 层级、紧凑 HeroUI Pro Stepper、单一 ListView `primary` 原生多选与 ScrollShadow;来源模型信息进入批次行说明,保留原年份筛选、跨年份选择、已同步禁用和提交回调。 - 批量同步弹窗此前将 Checkbox 标签放在 `Checkbox.Content` 外部,并用 `Surface tertiary + Checkbox` 手写模型分组列表,造成方框/文案错位和陌生灰色块。现已改为正确 Checkbox compound 层级、紧凑 HeroUI Pro Stepper、单一 ListView `primary` 原生多选与 ScrollShadow;来源模型信息进入批次行说明,保留原年份筛选、跨年份选择、已同步禁用和提交回调。
- 核心列表均使用 DataGrid;空态由 `renderEmptyState` 单一渲染。 - 核心列表均使用 DataGrid;空态由 `renderEmptyState` 单一渲染。
- 页面视觉色、背景、描边、圆角、阴影及 HeroUI 内部状态覆盖扫描为零;次要文字直接使用 HeroUI `Description`,必填状态由 `Label isRequired` 管理。 - 页面视觉色、背景、描边、圆角、阴影及 HeroUI 内部状态覆盖扫描为零;次要文字直接使用 HeroUI `Description`,必填状态由 `Label isRequired` 管理。
- `xinyuan.css` 仅保留批准的登录基准、Shell 几何、应用切换宽高和 KPI 布局;组件外观由权威主题及官方 variant 管理。 - `shell.css` 已同步“审计项目管理”基准的共享 Shell、列表、分页、KPI 和响应式几何;组件外观由权威主题及官方 variant 管理。
- Navbar 通知与主题按钮复用批准的共享动作组:36x36px、8px 间距、HeroUI 中性 Surface;移动端 Sidebar Dialog 通过 HeroUI Pro `Sheet.Heading` 获得可访问名称,复验控制台无警告。 - Navbar 通知与主题按钮复用批准的共享动作组:36x36px、8px 间距、HeroUI 中性 Surface;移动端 Sidebar Dialog 通过 HeroUI Pro `Sheet.Heading` 获得可访问名称,复验控制台无警告。
- 当前业务需求没有活动图表;旧 ECharts 页面已移除,因此没有伪造图表或业务数据。 - 当前业务需求没有活动图表;旧 ECharts 页面已移除,因此没有伪造图表或业务数据。
...@@ -68,9 +79,9 @@ ...@@ -68,9 +79,9 @@
## 浏览器证据 ## 浏览器证据
- 目标:`http://localhost:8004/model_analysis/` - 目标:`http://localhost:8001/model_analysis/`
- 基准:`http://localhost:8005/problem_rectification_new/` - 基准:`http://localhost:8000/audit_project_manager_new/`
- 使用独立 Playwright 会话同时打开目标通知页和问题整改通知页,在 1440x900、1280x720、768x1024、390x844 的浅色与深色完成同视口对照。基准真实登录受验证码接口拒绝,因此仅在浏览器内存中拦截鉴权/通知请求用于结构比较,未写入仓库或充当业务数据。 - 使用独立 Playwright 会话同时打开目标通知页和审计项目管理通知页,在 1440x900、1280x720、768x1024、390x844 的浅色与深色完成同视口对照。基准真实登录受验证码接口拒绝,因此仅在浏览器内存中拦截鉴权/通知请求用于结构比较,未写入仓库或充当业务数据。
- 桌面通知页的一级 Tabs、搜索、状态 Tabs、Card 和 Pagination 几何与基准一致;目标继续执行当前合同要求的 48/32/16px Shell gutter。所有视口 `scrollWidth === clientWidth`。 - 桌面通知页的一级 Tabs、搜索、状态 Tabs、Card 和 Pagination 几何与基准一致;目标继续执行当前合同要求的 48/32/16px Shell gutter。所有视口 `scrollWidth === clientWidth`。
- Navbar Bell/主题按钮均为 36x36px、间距 8px;未读 0/1/99/100 分别验证为隐藏/`1`/`99`/`99+`,Badge 可访问名称保留真实计数。 - Navbar Bell/主题按钮均为 36x36px、间距 8px;未读 0/1/99/100 分别验证为隐藏/`1`/`99`/`99+`,Badge 可访问名称保留真实计数。
- 验证消息/待办、全部/未读、300ms 搜索、单条已读、全部已读、动态 action、站内去 base 跳转、外链新窗口、HTML 纯文本、Pagination、EmptyState 与 Toast;移动端 offcanvas 可由 Escape 关闭,Dialog 标题为“模型分析导航”。最终目标控制台 0 error / 0 warning。 - 验证消息/待办、全部/未读、300ms 搜索、单条已读、全部已读、动态 action、站内去 base 跳转、外链新窗口、HTML 纯文本、Pagination、EmptyState 与 Toast;移动端 offcanvas 可由 Escape 关闭,Dialog 标题为“模型分析导航”。最终目标控制台 0 error / 0 warning。
......
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "6.1.0",
"echarts-for-react": "3.0.6",
"embla-carousel": "^8.6.0", "embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
......
...@@ -71,6 +71,12 @@ importers: ...@@ -71,6 +71,12 @@ importers:
dayjs: dayjs:
specifier: ^1.11.20 specifier: ^1.11.20
version: 1.11.23 version: 1.11.23
echarts:
specifier: 6.1.0
version: 6.1.0
echarts-for-react:
specifier: 3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.7)
embla-carousel: embla-carousel:
specifier: ^8.6.0 specifier: ^8.6.0
version: 8.6.0 version: 8.6.0
...@@ -112,7 +118,7 @@ importers: ...@@ -112,7 +118,7 @@ importers:
version: 3.49.0(react@19.2.7) version: 3.49.0(react@19.2.7)
recharts: recharts:
specifier: 3.10.1 specifier: 3.10.1
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.8)(react@19.2.7)(redux@5.0.1) version: 3.10.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.8)(react@19.2.7)(redux@4.2.1)
remark-breaks: remark-breaks:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
...@@ -3722,6 +3728,15 @@ packages: ...@@ -3722,6 +3728,15 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
...@@ -6734,18 +6749,6 @@ packages: ...@@ -6734,18 +6749,6 @@ packages:
redux: redux:
optional: true optional: true
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.14.0: react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
...@@ -7235,6 +7238,9 @@ packages: ...@@ -7235,6 +7238,9 @@ packages:
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0: slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
...@@ -7639,6 +7645,9 @@ packages: ...@@ -7639,6 +7645,9 @@ packages:
tslib@1.14.1: tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
...@@ -8050,6 +8059,9 @@ packages: ...@@ -8050,6 +8059,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4: zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
...@@ -9225,14 +9237,14 @@ snapshots: ...@@ -9225,14 +9237,14 @@ snapshots:
'@loadable/component@5.15.2(react@18.3.1)': '@loadable/component@5.15.2(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.23.6 '@babel/runtime': 7.29.7
hoist-non-react-statics: 3.3.2 hoist-non-react-statics: 3.3.2
react: 18.3.1 react: 18.3.1
react-is: 16.13.1 react-is: 16.13.1
'@loadable/component@5.15.2(react@19.2.7)': '@loadable/component@5.15.2(react@19.2.7)':
dependencies: dependencies:
'@babel/runtime': 7.23.6 '@babel/runtime': 7.29.7
hoist-non-react-statics: 3.3.2 hoist-non-react-statics: 3.3.2
react: 19.2.7 react: 19.2.7
react-is: 16.13.1 react-is: 16.13.1
...@@ -9560,7 +9572,7 @@ snapshots: ...@@ -9560,7 +9572,7 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': '@reduxjs/toolkit@2.12.0(react-redux@8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(redux@4.2.1))(react@19.2.7)':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
...@@ -9570,7 +9582,7 @@ snapshots: ...@@ -9570,7 +9582,7 @@ snapshots:
reselect: 5.2.0 reselect: 5.2.0
optionalDependencies: optionalDependencies:
react: 19.2.7 react: 19.2.7
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.7)(redux@5.0.1) react-redux: 8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(redux@4.2.1)
'@shikijs/core@4.4.3': '@shikijs/core@4.4.3':
dependencies: dependencies:
...@@ -10558,7 +10570,7 @@ snapshots: ...@@ -10558,7 +10570,7 @@ snapshots:
'@umijs/history@5.3.1': '@umijs/history@5.3.1':
dependencies: dependencies:
'@babel/runtime': 7.23.6 '@babel/runtime': 7.29.7
query-string: 6.14.1 query-string: 6.14.1
'@umijs/lint@4.6.51(eslint@8.35.0(supports-color@8.1.1))(stylelint@14.8.2(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': '@umijs/lint@4.6.51(eslint@8.35.0(supports-color@8.1.1))(stylelint@14.8.2(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)':
...@@ -12377,6 +12389,18 @@ snapshots: ...@@ -12377,6 +12389,18 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 19.2.7
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.411: {} electron-to-chromium@1.5.411: {}
...@@ -13378,7 +13402,7 @@ snapshots: ...@@ -13378,7 +13402,7 @@ snapshots:
history@5.3.0: history@5.3.0:
dependencies: dependencies:
'@babel/runtime': 7.23.6 '@babel/runtime': 7.29.7
hmac-drbg@1.0.1: hmac-drbg@1.0.1:
dependencies: dependencies:
...@@ -16025,7 +16049,7 @@ snapshots: ...@@ -16025,7 +16049,7 @@ snapshots:
react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@babel/runtime': 7.23.6 '@babel/runtime': 7.29.7
invariant: 2.2.4 invariant: 2.2.4
prop-types: 15.8.1 prop-types: 15.8.1
react: 18.3.1 react: 18.3.1
...@@ -16035,7 +16059,7 @@ snapshots: ...@@ -16035,7 +16059,7 @@ snapshots:
react-helmet-async@1.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): react-helmet-async@1.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies: dependencies:
'@babel/runtime': 7.23.6 '@babel/runtime': 7.29.7
invariant: 2.2.4 invariant: 2.2.4
prop-types: 15.8.1 prop-types: 15.8.1
react: 19.2.7 react: 19.2.7
...@@ -16116,15 +16140,6 @@ snapshots: ...@@ -16116,15 +16140,6 @@ snapshots:
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
redux: 4.2.1 redux: 4.2.1
react-redux@9.3.0(@types/react@19.2.18)(react@19.2.7)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.18
redux: 5.0.1
react-refresh@0.14.0: {} react-refresh@0.14.0: {}
react-refresh@0.14.2: {} react-refresh@0.14.2: {}
...@@ -16251,9 +16266,9 @@ snapshots: ...@@ -16251,9 +16266,9 @@ snapshots:
real-require@0.1.0: {} real-require@0.1.0: {}
recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.8)(react@19.2.7)(redux@5.0.1): recharts@3.10.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.8)(react@19.2.7)(redux@4.2.1):
dependencies: dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.7)(redux@5.0.1))(react@19.2.7) '@reduxjs/toolkit': 2.12.0(react-redux@8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(redux@4.2.1))(react@19.2.7)
clsx: 2.1.1 clsx: 2.1.1
decimal.js-light: 2.5.1 decimal.js-light: 2.5.1
es-toolkit: 1.51.0 es-toolkit: 1.51.0
...@@ -16262,13 +16277,15 @@ snapshots: ...@@ -16262,13 +16277,15 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
react-is: 19.2.8 react-is: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.7)(redux@5.0.1) react-redux: 8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(redux@4.2.1)
reselect: 5.2.0 reselect: 5.2.0
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7)
victory-vendor: 37.3.6 victory-vendor: 37.3.6
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
- '@types/react-dom'
- react-native
- redux - redux
redent@3.0.0: redent@3.0.0:
...@@ -16722,6 +16739,8 @@ snapshots: ...@@ -16722,6 +16739,8 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {} slash@3.0.0: {}
slash@4.0.0: {} slash@4.0.0: {}
...@@ -17201,6 +17220,8 @@ snapshots: ...@@ -17201,6 +17220,8 @@ snapshots:
tslib@1.14.1: {} tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3): tsutils@3.21.0(typescript@5.9.3):
...@@ -17697,4 +17718,8 @@ snapshots: ...@@ -17697,4 +17718,8 @@ snapshots:
zod@3.25.76: {} zod@3.25.76: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {} zwitch@2.0.4: {}
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { applyRootTheme } from '@/constants/app';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
applyRootTheme(); if (typeof document !== 'undefined') {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
document.documentElement.dataset.theme = 'light';
document.documentElement.style.colorScheme = 'light';
}
export async function getInitialState() { export async function getInitialState() {
return { return {
......
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import ExclamationCircleOutlined from '@ant-design/icons/ExclamationCircleOutlined';
import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import ProjectOutlined from '@ant-design/icons/ProjectOutlined';
import ReloadOutlined from '@ant-design/icons/ReloadOutlined';
import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import ShareAltOutlined from '@ant-design/icons/ShareAltOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UpOutlined from '@ant-design/icons/UpOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
<Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />
);
};
}
export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined);
export const Edit = withSize(EditOutlined);
export const ExclamationCircle = withSize(ExclamationCircleOutlined);
export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
style={iconStyle(size, style)}
className={['anticon', props.className].filter(Boolean).join(' ')}
>
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable="false"
aria-hidden="true"
>
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
export const More = withSize(MoreOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Share = withSize(ShareAltOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowDownIcon = ChevronDown;
export const ArrowUpIcon = withSize(UpOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
export const CalendarDaysIcon = Calendar;
export const ChartBarSquareIcon = BarChart;
export const ChartPieIcon = withSize(PieChartOutlined);
export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const ExclamationCircleIcon = ExclamationCircle;
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const TableCellsIcon = withSize(OrderedListOutlined);
export const UserGroupIcon = Team;
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
breadcrumbs,
...sidebarProps
}) {
return (
<Sidebar.Provider
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
breadcrumbs={breadcrumbs}
title={title}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, ChevronRight, SidebarPanel } from './AppIcons';
import { history } from 'umi';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
title,
breadcrumbs = [],
}) {
const { isMobile, isOpen } = useSidebar();
const count = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">
{breadcrumbs.length ? (
<span className="flex min-w-0 items-center gap-2">
{breadcrumbs.map((item, index) => (
<span className="inline-flex min-w-0 items-center gap-2" key={`${item.label}-${index}`}>
{index > 0 ? <ChevronRight aria-hidden="true" className="app-navbar-breadcrumb-muted shrink-0" size={16} /> : null}
{item.to ? (
<a
className="app-navbar-breadcrumb-muted min-w-0 truncate"
href={item.to}
onClick={(event) => {
event.preventDefault();
history.push(item.to);
}}
>
{item.label}
</a>
) : (
<span className="app-navbar-breadcrumb-current min-w-0 truncate">{item.label}</span>
)}
</span>
))}
</span>
) : title}
</h1>
<Navbar.Spacer />
{onOpenNotifications ? (
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{count > 0 ? <Badge aria-label={`${count}条未读通知`} color="danger" size="sm">{count > 99 ? '99+' : count}</Badge> : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
</div>
) : null}
</Navbar.Header>
</Navbar>
);
}
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
return { displayName, username: userInfo?.username || userInfo?.email || '已登录', initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U' };
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Dropdown.Trigger aria-label="打开用户菜单" className="app-sidebar-user-trigger rc-sidebar-user-trigger">
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? <Avatar.Image alt={user.displayName} src={userInfo.avatar} /> : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span className="app-sidebar-user-copy rc-sidebar-user-copy" data-sidebar="label"><span className="app-sidebar-user-name">{user.displayName}</span><span className="app-sidebar-user-description">{user.username}</span></span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => key === 'logout' && onLogout?.()}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger"><div className="rc-sidebar-user-menu-item"><Label>退出登录</Label><LogOut size={14} /></div></Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return <Sidebar.MenuItem href={item.path} isCurrent={item.isCurrent ? item.isCurrent(pathname) : pathname === item.path} key={item.path} textValue={item.name} tooltip={item.name}><Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon><Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel></Sidebar.MenuItem>;
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => item.isCurrent ? item.isCurrent(pathname) : pathname === item.path);
const GroupIcon = group.icon;
return <Sidebar.Group key={group.title}><Disclosure defaultExpanded={hasCurrentItem}><Disclosure.Heading><Sidebar.GroupLabel><Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">{GroupIcon ? <GroupIcon size={16} /> : null}<span className="min-w-0 flex-1 truncate">{group.title}</span><Disclosure.Indicator><ChevronDown size={14} /></Disclosure.Indicator></Disclosure.Trigger></Sidebar.GroupLabel></Disclosure.Heading><Disclosure.Content><Disclosure.Body>{renderMenu(group)}</Disclosure.Body></Disclosure.Content></Disclosure></Sidebar.Group>;
};
return <>
<Sidebar.Header><div className="app-sidebar-brand rc-sidebar-brand"><div className="app-sidebar-brand-mark rc-sidebar-brand-mark"><img alt="鑫元" className="rc-sidebar-logo" src={xyLogoWhite} /></div><div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label"><span className="app-sidebar-title rc-sidebar-title">{APP_DISPLAY_NAME}</span></div><div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label"><Button isIconOnly aria-label="切换应用" className="app-sidebar-app-trigger rc-sidebar-app-trigger" size="sm" variant="ghost" onPress={onOpenApplicationSwitcher}><AppGrid size={16} /></Button></div></div></Sidebar.Header>
<Sidebar.Content>{menuGroups.map((group) => group.showLabel === false ? <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group> : renderGroup(group))}</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
</>;
}
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={`${APP_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><AccessibleMobileSidebar {...props} /></>;
}
export default function SidebarPanelIcon() {
return (
<span aria-hidden="true" className="anticon" role="img">
<svg aria-hidden="true" fill="none" focusable="false" height="16" viewBox="0 0 16 16" width="16">
<path clipRule="evenodd" d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z" fill="currentColor" fillRule="evenodd" />
</svg>
</span>
);
}
import { CheckOutlined, DownOutlined, SearchOutlined } from '@ant-design/icons'; import { Check, ChevronDown, Search } from '@/components/AppIcons';
import { import {
Autocomplete, Autocomplete,
Label, Label,
...@@ -23,22 +23,35 @@ function toSelectionKey(value) { ...@@ -23,22 +23,35 @@ function toSelectionKey(value) {
export default function XinyuanAutocomplete({ export default function XinyuanAutocomplete({
'aria-label': ariaLabel, 'aria-label': ariaLabel,
allowsEmptyCollection = false,
className, className,
emptyText = '暂无可选项', emptyText = '暂无可选项',
errorText = '',
filter,
fullWidth = true,
inputValue, inputValue,
isDisabled = false, isDisabled = false,
isClearable = false,
isLoading = false, isLoading = false,
isOpen,
isRequired = false, isRequired = false,
label, label,
onInputChange, onInputChange,
onClear,
onOpenChange,
onSelectionChange, onSelectionChange,
options = [], options = [],
placeholder = '请选择', placeholder = '请选择',
placement = 'bottom start',
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
selectedKey = '', selectedKey = '',
selectedKeys, selectedKeys,
selectionMode = 'single', selectionMode = 'single',
valueText, valueText,
showSearch = true,
shouldFilter = true,
variant = 'secondary', variant = 'secondary',
}) { }) {
const normalizedOptions = options.map((option) => { const normalizedOptions = options.map((option) => {
...@@ -50,14 +63,14 @@ export default function XinyuanAutocomplete({ ...@@ -50,14 +63,14 @@ export default function XinyuanAutocomplete({
...option, ...option,
id: optionValue === '' ? EMPTY_KEY : toSelectionKey(optionValue), id: optionValue === '' ? EMPTY_KEY : toSelectionKey(optionValue),
isDisabled: Boolean(option.isDisabled ?? option.disabled), isDisabled: Boolean(option.isDisabled ?? option.disabled),
label: String(option.label), label: String(option.label ?? option.textValue ?? optionValue ?? ''),
}; };
}); });
const normalizedKey = selectedKey === '' || selectedKey == null const normalizedKey = selectedKey === '' || selectedKey == null
? EMPTY_KEY ? EMPTY_KEY
: toSelectionKey(selectedKey); : toSelectionKey(selectedKey);
const selectedKeySet = selectionMode === 'multiple' const selectedKeySet = selectionMode === 'multiple'
? new Set(selectedKeys || []) ? new Set(selectedKeys === 'all' ? [] : Array.from(selectedKeys || []))
: new Set([normalizedKey]); : new Set([normalizedKey]);
const selectedKeyStringSet = new Set( const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey), Array.from(selectedKeySet).map(toSelectionKey),
...@@ -108,12 +121,16 @@ export default function XinyuanAutocomplete({ ...@@ -108,12 +121,16 @@ export default function XinyuanAutocomplete({
<Autocomplete <Autocomplete
{...rootSelectionProps} {...rootSelectionProps}
aria-label={ariaLabel || label || placeholder} aria-label={ariaLabel || label || placeholder}
allowsEmptyCollection={allowsEmptyCollection}
className={className} className={className}
fullWidth fullWidth={fullWidth}
isDisabled={isDisabled} isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired} isRequired={isRequired}
selectionMode={selectionMode} selectionMode={selectionMode}
variant={variant} variant={variant}
onClear={onClear}
onOpenChange={onOpenChange}
onSelectionChange={selectionMode === 'multiple' onSelectionChange={selectionMode === 'multiple'
? onSelectionChange ? onSelectionChange
: (key) => { : (key) => {
...@@ -127,32 +144,34 @@ export default function XinyuanAutocomplete({ ...@@ -127,32 +144,34 @@ export default function XinyuanAutocomplete({
{resolvedValueText} {resolvedValueText}
</span> </span>
</Autocomplete.Value> </Autocomplete.Value>
{isClearable ? <Autocomplete.ClearButton aria-label={`清除${ariaLabel || placeholder}`} /> : null}
<Autocomplete.Indicator> <Autocomplete.Indicator>
<DownOutlined className="size-4" style={FIELD_SUFFIX_ICON_STYLE} /> <ChevronDown className="size-4" style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator> </Autocomplete.Indicator>
</Autocomplete.Trigger> </Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start"> <Autocomplete.Popover placement={placement}>
<Autocomplete.Filter <Autocomplete.Filter
filter={defaultFilter} filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue} inputValue={inputValue}
onInputChange={onInputChange} onInputChange={onInputChange}
> >
<SearchField aria-label={searchPlaceholder} fullWidth variant="secondary"> {popoverHeader}
{showSearch ? <SearchField aria-label={searchPlaceholder} fullWidth variant={variant}>
<SearchField.Group> <SearchField.Group>
<SearchField.SearchIcon> <SearchField.SearchIcon>
<SearchOutlined className="size-4" /> <Search className="size-4" />
</SearchField.SearchIcon> </SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} /> <SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索" /> <SearchField.ClearButton aria-label="清空搜索" />
</SearchField.Group> </SearchField.Group>
</SearchField> </SearchField> : null}
<ListBox <ListBox
{...listBoxSelectionProps} {...listBoxSelectionProps}
aria-label={ariaLabel || label || placeholder} aria-label={ariaLabel || label || placeholder}
items={isLoading ? [] : normalizedOptions} items={isLoading || errorText ? [] : normalizedOptions}
renderEmptyState={() => ( renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4"> <div className="grid min-h-20 place-items-center px-3 py-4">
{isLoading ? <Spinner size="sm" /> : emptyText} {isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div> </div>
)} )}
> >
...@@ -170,13 +189,13 @@ export default function XinyuanAutocomplete({ ...@@ -170,13 +189,13 @@ export default function XinyuanAutocomplete({
? { onPress: () => handleMultipleOptionPress(option) } ? { onPress: () => handleMultipleOptionPress(option) }
: {})} : {})}
> >
<Label className="truncate">{option.label}</Label> {renderOption ? renderOption(option) : <Label className="truncate" title={option.label}>{option.label}</Label>}
{selectionMode === 'multiple' ? ( {selectionMode === 'multiple' ? (
<span <span
aria-hidden="true" aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground" className="ml-auto inline-flex w-5 justify-center text-foreground"
> >
{isSelected ? <CheckOutlined className="size-3.5" /> : null} {isSelected ? <Check className="size-3.5" /> : null}
</span> </span>
) : ( ) : (
<ListBox.ItemIndicator /> <ListBox.ItemIndicator />
......
import { Close } from '@/components/AppIcons';
import { Modal } from '@heroui/react'; import { Modal } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = { const DIALOG_WIDTH_CLASS = {
md: 'sm:max-w-[30rem]',
xl: 'sm:max-w-[46rem]', xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]', '2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]', '3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]', '4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]', '5xl': 'sm:max-w-[76rem]',
}; };
const STANDARD_PANEL_PADDING_SIZES = new Set(['xl', '2xl', '3xl', '4xl', '5xl']);
export default function XinyuanModal({ export default function XinyuanModal({
isOpen, isOpen,
...@@ -26,31 +29,31 @@ export default function XinyuanModal({ ...@@ -26,31 +29,31 @@ export default function XinyuanModal({
return ( return (
<Modal.Root> <Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop <Modal.Backdrop isDismissable={isDismissable} isOpen={isOpen} onOpenChange={onOpenChange} variant="blur">
isDismissable={isDismissable}
isOpen={isOpen}
variant="blur"
onOpenChange={onOpenChange}
>
<Modal.Container placement="center" scroll="inside" size={modalSize}> <Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog <Modal.Dialog
aria-label={title}
className={clsx( className={clsx(
'xy-modal-dialog p-7', STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size], DIALOG_WIDTH_CLASS[size],
dialogClassName, dialogClassName,
)} )}
> >
<Modal.CloseTrigger <Modal.CloseTrigger aria-label="关闭">
aria-label="关闭" <Close size={16} />
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }} </Modal.CloseTrigger>
/>
<Modal.Header> <Modal.Header>
<Modal.Heading>{title}</Modal.Heading> <Modal.Heading>{title}</Modal.Heading>
{description ? <div className="text-sm text-muted">{description}</div> : null}
</Modal.Header> </Modal.Header>
<Modal.Body className={bodyClassName} style={{ marginTop: '0.75rem' }}>{children}</Modal.Body> <Modal.Body className={bodyClassName}>
{footer ? <Modal.Footer style={{ marginTop: '1.5rem' }}>{footer}</Modal.Footer> : null} {description ? (
<div className="text-sm text-muted">{description}</div>
) : null}
{children}
</Modal.Body>
{footer ? (
<Modal.Footer>{footer}</Modal.Footer>
) : null}
</Modal.Dialog> </Modal.Dialog>
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
......
export const APP_DISPLAY_NAME = '模型分析'; export const APP_DISPLAY_NAME = '模型分析';
export const APP_ROUTE_NAME = 'model_analysis'; export const APP_ROUTE_NAME = 'model_analysis';
export const APP_BASE_PATH = '/model_analysis'; export const APP_BASE_PATH = '/model_analysis';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export function getStoredTheme() {
if (typeof window === 'undefined') return 'light';
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light';
}
export function applyRootTheme(theme = getStoredTheme()) {
if (typeof document === 'undefined') return theme;
const normalizedTheme = theme === 'dark' ? 'dark' : 'light';
const root = document.documentElement;
root.classList.toggle('dark', normalizedTheme === 'dark');
root.classList.toggle('light', normalizedTheme === 'light');
root.dataset.theme = normalizedTheme;
root.style.colorScheme = normalizedTheme;
return normalizedTheme;
}
export function getAppUrl(path = '/') { export function getAppUrl(path = '/') {
const normalizedPath = path.startsWith('/') ? path : `/${path}`; const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${APP_BASE_PATH}${normalizedPath}`; return `${APP_BASE_PATH}${normalizedPath}`;
......
This diff is collapsed.
...@@ -11,38 +11,56 @@ ...@@ -11,38 +11,56 @@
[data-theme="light"], [data-theme="light"],
[data-theme="default"] { [data-theme="default"] {
/* Theme Colors (Light Mode) */ /* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03); /* Xinyuan Fund brand red. HeroUI's accent is the primary action color. */
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03); --primary: #DA241A;
--border: oklch(90.00% 0.0045 27.03); --primary-hover: #C91F17;
--danger: oklch(45.32% 0.1661 27.03); --primary-pressed: #B51B14;
--primary-foreground: #FFFFFF;
--primary-50: #FCE9E7;
--primary-100: #F9D0CC;
--primary-200: #F3A29A;
--primary-300: #EC756D;
--primary-400: #E44A3F;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #941711;
--primary-900: #73120D;
--primary-950: #4A0C08;
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: #B51B14;
--danger-hover: #941711;
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(94.00% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(21.03% 0.0059 29.20);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(100.00% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0090 27.03); --field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: #DA241A;
--foreground: oklch(21.03% 0.0045 27.03); --foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0090 27.03); --muted: oklch(55.17% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0045 27.03); --scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0045 27.03); --segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0045 27.03); --segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0045 27.03); --separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0022 27.03); --surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0045 27.03); --surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0036 27.03); --surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03); --surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0036 27.03); --surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03); --surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1599 45.11); --warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.11); --warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */ /* Border Radius */
--radius: 0.5rem; --radius: 0.5rem;
...@@ -51,42 +69,107 @@ ...@@ -51,42 +69,107 @@
/* Font Family */ /* Font Family */
/* Make sure to load Inter font in your app */ /* Make sure to load Inter font in your app */
--font-sans: var(--font-inter); --font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/* Coordinated financial palette: brand reds -> warm neutrals -> gray blues. */
--xinyuan-chart-1: #DA241A;
--xinyuan-chart-2: #B83A32;
--xinyuan-chart-3: #C95A52;
--xinyuan-chart-4: #D47A70;
--xinyuan-chart-5: #A65A45;
--xinyuan-chart-6: #B87958;
--xinyuan-chart-7: #C99772;
--xinyuan-chart-8: #6F6674;
--xinyuan-chart-9: #807783;
--xinyuan-chart-10: #938B96;
--xinyuan-chart-11: #4F6380;
--xinyuan-chart-12: #61758F;
--xinyuan-chart-13: #74879A;
--xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #B0A5AD;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
} }
.dark, .dark,
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* Theme Colors (Dark Mode) */ /* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03); --primary: #DA241A;
--border: oklch(28.00% 0.0045 27.03); --primary-hover: #C91F17;
--danger: oklch(62.00% 0.1600 27.03); --primary-pressed: #B51B14;
--primary-foreground: #FFFFFF;
--primary-50: #FCE9E7;
--primary-100: #F9D0CC;
--primary-200: #F3A29A;
--primary-300: #EC756D;
--primary-400: #E44A3F;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #941711;
--primary-900: #73120D;
--primary-950: #4A0C08;
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: #B51B14;
--danger-hover: #941711;
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(27.40% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(99.11% 0 0);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(21.03% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0090 27.03); --field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: #DA241A;
--foreground: oklch(99.11% 0.0045 27.03); --foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0090 27.03); --muted: oklch(70.50% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0045 27.03); --scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0045 27.03); --segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0045 27.03); --segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0045 27.03); --separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0090 27.03); --surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0045 27.03); --surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0067 27.03); --surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03); --surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0067 27.03); --surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03); --surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1400 49.12); --warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.12); --warning-foreground: oklch(21.03% 0.0059 49.38);
} }
...@@ -2,9 +2,7 @@ ...@@ -2,9 +2,7 @@
.light, .light,
.default, .default,
[data-theme='light'], [data-theme='light'],
[data-theme='default'], [data-theme='default'] {
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */ /* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent); --rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white); --rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
...@@ -27,13 +25,28 @@ ...@@ -27,13 +25,28 @@
var(--background) var(--background)
); );
--rc-text-muted: var(--muted); --rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans); --rc-font-sans: var(--font-sans);
--rc-font-normal: 400;
--rc-font-medium: 500; --rc-font-medium: 500;
--rc-font-semibold: 600; --rc-font-semibold: 600;
--rc-font-bold: 700;
--rc-line-normal: 1.5; --rc-line-normal: 1.5;
--rc-text-xs: 0.75rem; --rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem; --rc-text-sm: 0.875rem;
--rc-text-section: 1.125rem;
--rc-text-module: 1rem;
--rc-space-1: 0.25rem; --rc-space-1: 0.25rem;
--rc-space-2: 0.5rem; --rc-space-2: 0.5rem;
...@@ -45,6 +58,7 @@ ...@@ -45,6 +58,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */ /* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem; --rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter); --rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter); --rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter); --rc-page-inline-end: var(--rc-shell-inline-gutter);
...@@ -82,14 +96,14 @@ ...@@ -82,14 +96,14 @@
0 4px 12px rgb(15 23 42 / 2%); 0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */ /* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255); --rc-metric-tone-info: #4F6380;
--rc-metric-tone-success: oklch(54% 0.13 145); --rc-metric-tone-success: #61758F;
--rc-metric-tone-teal: oklch(55% 0.1 205); --rc-metric-tone-teal: #74879A;
--rc-metric-tone-danger: oklch(52% 0.17 25); --rc-metric-tone-danger: #DA241A;
--rc-metric-tone-warning: oklch(62% 0.12 75); --rc-metric-tone-warning: #B87958;
--rc-metric-tone-violet: oklch(56% 0.12 300); --rc-metric-tone-violet: #6F6674;
--rc-metric-tone-indigo: oklch(53% 0.13 275); --rc-metric-tone-indigo: #807783;
--rc-metric-tone-coral: oklch(60% 0.12 48); --rc-metric-tone-coral: #C95A52;
--rc-metric-tone-neutral: var(--muted); --rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix( --rc-metric-tone-info-soft: color-mix(
in oklab, in oklab,
...@@ -138,40 +152,28 @@ ...@@ -138,40 +152,28 @@
); );
/* Data visualization only. Do not reuse UI status colors as a series palette. */ /* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03); --rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: oklch(56% 0.135 255); --rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: oklch(64% 0.11 220); --rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: oklch(56% 0.115 280); --rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: oklch(60% 0.095 200); --rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: oklch(70% 0.12 80); --rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: oklch(60% 0.1 295); --rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: oklch(57% 0.035 250); --rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1); --rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03); --rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-2); --rc-chart-positive: var(--rc-chart-7);
--rc-chart-comparison: var(--rc-chart-4); --rc-chart-comparison: var(--rc-chart-5);
--rc-chart-neutral: var(--rc-chart-8); --rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem; --rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: 1rem; --rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
} }
:root {
--rc-login-control-height: 2.5rem;
--rc-login-submit-height: 2.875rem;
--rc-login-captcha-image-max-height: 2.25rem;
}
html,
body,
#root {
background: var(--rc-bg-canvas);
}
.login-page {
display: grid;
min-height: 100dvh;
place-items: center;
padding: 2rem 1rem;
background: var(--rc-bg-canvas);
}
.login-shell {
width: min(calc(100vw - 2rem), var(--rc-login-panel-width));
}
.login-card.card {
border: 0;
border-radius: 0.75rem;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-card);
}
.login-card > .card__content,
.login-card [data-slot='content'] {
padding: 2.25rem;
}
.login-card-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.875rem;
margin-bottom: 2rem;
}
.login-card-brand img {
width: 2.25rem;
height: 2.25rem;
object-fit: contain;
}
.login-card-brand strong {
color: var(--rc-text-primary);
font-size: 2rem;
font-weight: var(--rc-login-brand-weight);
line-height: 1.15;
}
.login-form {
display: grid;
gap: 1.25rem;
}
.login-field .label {
margin-bottom: 0;
color: var(--rc-text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.login-field :is(.input, .input--secondary, .login-input, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control']) {
min-height: var(--rc-login-control-height);
}
.login-input[data-filled='true'],
.login-input[data-filled='true']:hover,
.login-input[data-filled='true']:focus,
input.login-input:not(:placeholder-shown),
input.login-input:-webkit-autofill {
--input-bg: #fbf1f1;
--input-bg-hover: #faeaea;
--input-bg-focus: #fbf1f1;
background: #fbf1f1 !important;
box-shadow: inset 0 0 0 1000px #fbf1f1 !important;
-webkit-box-shadow: inset 0 0 0 1000px #fbf1f1 !important;
}
.login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 0.75rem;
}
.login-captcha-button.button {
width: 100%;
height: var(--rc-login-control-height);
padding: 0.125rem 0.375rem;
overflow: hidden;
}
.login-captcha-button img {
width: auto;
height: 100%;
max-width: 100%;
max-height: var(--rc-login-captcha-image-max-height);
}
.login-captcha-button svg {
width: auto;
height: 120%;
max-width: none;
max-height: none;
flex: none;
}
.login-captcha-button .captcha-image {
min-width: 0;
overflow: hidden;
}
.login-error {
margin-top: 1rem;
}
.login-submit-button.button {
width: 100%;
height: var(--rc-login-submit-height);
margin-top: 0.875rem;
}
.xy-app-shell.sidebar__provider {
position: fixed;
inset: 0;
width: 100%;
height: 100dvh;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--rc-bg-canvas);
}
.xy-app-shell .sidebar__offcanvas-wrapper {
--sidebar-width: 15rem;
}
.xy-sidebar.sidebar,
.xy-sidebar.sidebar__mobile {
color: var(--foreground);
background: var(--rc-bg-canvas);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
.xy-sidebar-brand {
display: flex;
height: 52px;
min-width: 0;
align-items: center;
gap: 10px;
padding: 8px 4px;
}
.xy-sidebar-logo {
width: 27px;
height: 27px;
flex: 0 0 27px;
object-fit: contain;
}
.xy-sidebar-logo-dark {
display: none;
}
.dark .xy-sidebar-logo-light {
display: none;
}
.dark .xy-sidebar-logo-dark {
display: block;
}
.xy-sidebar-title {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-icon-button.button {
display: inline-grid;
width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex: 0 0 var(--rc-navbar-icon-button-size);
place-items: center;
padding: 0;
}
.xy-icon-button .anticon {
display: grid;
width: 1rem;
height: 1rem;
margin: 0;
place-items: center;
line-height: 0;
}
.xy-icon-button .anticon > svg {
display: block;
width: 1rem;
height: 1rem;
margin: 0;
}
.xy-data-grid-action .anticon,
.xy-data-grid-action .anticon > svg {
display: grid;
width: 1rem;
height: 1rem;
margin: 0;
place-items: center;
line-height: 0;
}
.xy-data-grid-action .anticon > svg {
display: block;
}
.app-sidebar-app-trigger.button {
width: var(--rc-sidebar-app-trigger-size);
height: var(--rc-sidebar-app-trigger-size);
min-width: var(--rc-sidebar-app-trigger-size);
max-width: var(--rc-sidebar-app-trigger-size);
flex: none;
line-height: 1;
}
.app-sidebar-app-trigger .anticon {
display: grid;
width: var(--rc-sidebar-app-trigger-icon-size);
height: var(--rc-sidebar-app-trigger-icon-size);
place-items: center;
line-height: 1;
}
.app-sidebar-app-trigger .anticon > svg {
display: block;
width: 100%;
height: 100%;
}
.xy-sidebar-user-trigger {
display: flex;
width: 100%;
height: 48px;
min-width: 0;
align-items: center;
gap: 10px;
padding: 6px 8px;
cursor: pointer;
text-align: left;
}
.xy-sidebar-user-avatar {
width: 32px;
height: 32px;
flex: 0 0 32px;
}
.app-sidebar-user-name {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.125rem;
}
.app-sidebar-user-description {
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
}
.rc-sidebar-user-menu-item {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--rc-space-2);
}
.xy-app-main.sidebar__main {
--rc-shell-inline-gutter: 3rem;
min-width: 0;
overflow: hidden;
background: var(--rc-bg-canvas);
}
.xy-navbar.navbar {
height: 64px;
border: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.rc-navbar {
background: transparent;
}
.xy-modal-dialog {
min-width: 0;
}
.xy-navbar .navbar__header {
height: 64px;
gap: 12px;
padding-inline: var(--rc-shell-inline-gutter);
}
.rc-navbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.rc-navbar-icon-button.button {
background: var(--default);
}
.rc-navbar-icon-button.button:hover,
.rc-navbar-icon-button.button[data-hovered='true'] {
background: var(--default-hover);
}
.xy-navbar .sidebar__trigger,
.xy-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size);
min-width: var(--rc-navbar-trigger-size);
margin-inline-start: 0;
padding: 0;
}
.xy-navbar-title {
min-width: 0;
overflow: hidden;
font-size: var(--rc-navbar-title-size);
font-weight: var(--rc-navbar-title-weight);
line-height: var(--rc-navbar-title-line-height);
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-shell-scroll {
height: calc(100dvh - 64px);
overflow: auto;
}
.rc-app-page {
width: 100%;
padding-block: var(--rc-page-padding-block-start) var(--rc-page-padding-block-end);
padding-inline: var(--rc-shell-inline-gutter);
overflow-x: hidden;
}
.xy-action-bar {
inset-inline-start: 15rem;
}
.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-kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--rc-related-item-gap);
}
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
.rc-metric-icon-surface {
display: grid;
width: 3rem;
height: 3rem;
flex: none;
place-items: center;
line-height: 1;
}
.rc-metric-icon-surface .anticon,
.rc-metric-icon-surface .anticon > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
}
.rc-metric-icon-surface--brand {
background: var(--rc-metric-tone-info-soft);
color: var(--rc-metric-tone-info);
}
.rc-metric-icon-surface--success {
background: var(--rc-metric-tone-success-soft);
color: var(--rc-metric-tone-success);
}
.rc-metric-icon-surface--teal {
background: var(--rc-metric-tone-teal-soft);
color: var(--rc-metric-tone-teal);
}
.rc-metric-icon-surface--danger {
background: var(--rc-metric-tone-danger-soft);
color: var(--rc-metric-tone-danger);
}
@media (max-width: 1023px) {
.xy-app-main.sidebar__main {
--rc-shell-inline-gutter: 2rem;
}
.xy-action-bar {
inset-inline-start: 0;
}
.rc-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.xy-app-main.sidebar__main {
--rc-shell-inline-gutter: 1rem;
}
.login-card > .card__content,
.login-card [data-slot='content'] {
padding: 1.75rem;
}
.login-card-brand strong {
font-size: 1.75rem;
}
.xy-navbar .sidebar__trigger,
.xy-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
}
.rc-kpi-grid {
grid-template-columns: 1fr;
}
}
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import xinyuanLogoWhite from '@/assets/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME } from '@/constants/app';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
...@@ -77,18 +78,22 @@ export default function LoginPage() { ...@@ -77,18 +78,22 @@ export default function LoginPage() {
}; };
return ( return (
<main className="login-page"> <main className="login-page rc-login-page">
<section className="login-shell"> <section className="login-shell rc-login-shell">
<Card className="login-card"> <Card className="login-card rc-login-card">
<Card.Content> <Card.Content>
<div className="login-card-brand"> <div className="login-brand login-card-brand rc-login-brand">
<img alt="鑫元基金" src={xinyuanLogo} /> <span className="login-brand-logo">
<img alt="鑫元" className="rc-brand-logo rc-brand-logo--light" src={xinyuanLogo} />
<img alt="鑫元" className="rc-brand-logo rc-brand-logo--dark" src={xinyuanLogoWhite} />
</span>
<strong>{APP_DISPLAY_NAME}</strong> <strong>{APP_DISPLAY_NAME}</strong>
</div> </div>
<form className="login-form" onSubmit={handleSubmit}> <form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField <TextField
className="login-field" className="login-field"
data-has-value={identity ? 'true' : undefined}
fullWidth fullWidth
isDisabled={loading} isDisabled={loading}
isRequired isRequired
...@@ -108,6 +113,7 @@ export default function LoginPage() { ...@@ -108,6 +113,7 @@ export default function LoginPage() {
<TextField <TextField
className="login-field" className="login-field"
data-has-value={password ? 'true' : undefined}
fullWidth fullWidth
isDisabled={loading} isDisabled={loading}
isRequired isRequired
...@@ -155,7 +161,7 @@ export default function LoginPage() { ...@@ -155,7 +161,7 @@ export default function LoginPage() {
> >
{captchaData?.image ? ( {captchaData?.image ? (
<span <span
className="captcha-image flex h-full w-full items-center justify-center" className="captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }} dangerouslySetInnerHTML={{ __html: captchaData.image }}
/> />
) : ( ) : (
......
This diff is collapsed.
...@@ -12,7 +12,6 @@ import { ...@@ -12,7 +12,6 @@ import {
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { useParams } from 'umi'; import { useParams } from 'umi';
import { import {
Breadcrumbs,
BusinessPagination, BusinessPagination,
EmptyCollection, EmptyCollection,
FieldLabel, FieldLabel,
...@@ -43,7 +42,8 @@ import { ...@@ -43,7 +42,8 @@ import {
updateBatchHiddenRecords, updateBatchHiddenRecords,
} from './modelMonitorService'; } from './modelMonitorService';
const PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
export default function ModelBatchData() { export default function ModelBatchData() {
const { modelId, batchId } = useParams(); const { modelId, batchId } = useParams();
...@@ -55,6 +55,7 @@ export default function ModelBatchData() { ...@@ -55,6 +55,7 @@ export default function ModelBatchData() {
const [forwardItems, setForwardItems] = useState([]); const [forwardItems, setForwardItems] = useState([]);
const [selectedRecordIds, setSelectedRecordIds] = useState([]); const [selectedRecordIds, setSelectedRecordIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
...@@ -118,7 +119,7 @@ export default function ModelBatchData() { ...@@ -118,7 +119,7 @@ export default function ModelBatchData() {
const ids = new Set((batch?.hiddenRecordIds || []).map(String)); const ids = new Set((batch?.hiddenRecordIds || []).map(String));
return allRecords.filter((record) => ids.has(String(record.recordName || record.id))); return allRecords.filter((record) => ids.has(String(record.recordName || record.id)));
}, [allRecords, batch?.hiddenRecordIds]); }, [allRecords, batch?.hiddenRecordIds]);
const pageRecords = visibleRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const pageRecords = visibleRecords.slice(page * pageSize, (page + 1) * pageSize);
const selectedRecords = allRecords.filter((record) => selectedRecordIds.includes(record.recordName || record.id)); const selectedRecords = allRecords.filter((record) => selectedRecordIds.includes(record.recordName || record.id));
const rowStatusByRecordId = useMemo(() => Object.fromEntries(allRecords.map((record) => { const rowStatusByRecordId = useMemo(() => Object.fromEntries(allRecords.map((record) => {
const key = String(record.recordName || record.id); const key = String(record.recordName || record.id);
...@@ -222,10 +223,6 @@ export default function ModelBatchData() { ...@@ -222,10 +223,6 @@ export default function ModelBatchData() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Breadcrumbs
actions={batch ? <><SyncStatusTag status={batch.syncStatus} /><Description className="text-sm tabular-nums">{batch.totalRecordCount} 条</Description></> : null}
items={[{ label: '全部模型', to: '/models' }, { label: model?.modelName || '模型', to: `/models/${modelId}/batches` }, { label: batch?.batchName || '批次数据' }]}
/>
<PageHeader eyebrow={batch?.batchCode || '模型批次'} title={batch?.batchName || '批次数据'} description="展示当前批次已同步的全部业务字段。页面不提供字段筛选或图表分析,勾选记录后可隐藏或进入转发审批。" /> <PageHeader eyebrow={batch?.batchCode || '模型批次'} title={batch?.batchName || '批次数据'} description="展示当前批次已同步的全部业务字段。页面不提供字段筛选或图表分析,勾选记录后可隐藏或进入转发审批。" />
{loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null}
...@@ -234,10 +231,37 @@ export default function ModelBatchData() { ...@@ -234,10 +231,37 @@ export default function ModelBatchData() {
{!loading && batch ? ( {!loading && batch ? (
<section className="min-w-0 space-y-4"> <section className="min-w-0 space-y-4">
<SectionHeader title="批次数据" action={<Button isDisabled={!hiddenRecords.length} variant="secondary" onPress={() => setIsHiddenDialogOpen(true)}>管理已隐藏</Button>} /> <SectionHeader
title="批次数据"
action={(
<div className="flex flex-wrap items-center justify-end gap-2">
<SyncStatusTag status={batch.syncStatus} />
<Description className="text-sm tabular-nums">{batch.totalRecordCount} 条</Description>
<Button isDisabled={!hiddenRecords.length} variant="secondary" onPress={() => setIsHiddenDialogOpen(true)}>管理已隐藏</Button>
</div>
)}
/>
<SelectionActionBar count={selectedRecordIds.length} onForward={openForwardDialog} onHide={handleHideSelected} onClear={() => setSelectedRecordIds([])} /> <SelectionActionBar count={selectedRecordIds.length} onForward={openForwardDialog} onHide={handleHideSelected} onClear={() => setSelectedRecordIds([])} />
<RecordsResultGrid records={pageRecords} columns={batch.columnSchema} selectedIds={selectedRecordIds} rowStatusByRecordId={rowStatusByRecordId} forwardItemByRecordId={forwardItemByRecordId} onOpenForwardItem={(record, item) => setForwardItemDetail({ record, item })} onSelectionChange={handlePageSelectionChange} /> <RecordsResultGrid records={pageRecords} columns={batch.columnSchema} selectedIds={selectedRecordIds} rowStatusByRecordId={rowStatusByRecordId} forwardItemByRecordId={forwardItemByRecordId} onOpenForwardItem={(record, item) => setForwardItemDetail({ record, item })} onSelectionChange={handlePageSelectionChange} />
{visibleRecords.length > PAGE_SIZE ? <div className="mt-4"><BusinessPagination page={page + 1} pageSize={PAGE_SIZE} total={visibleRecords.length} onPageChange={(nextPage) => setPage(nextPage - 1)} /></div> : null} {visibleRecords.length > 0 ? (
<div className="mt-4">
<BusinessPagination
page={page + 1}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={visibleRecords.length}
onPageChange={(nextPage) => {
setPage(nextPage - 1);
setSelectedRecordIds([]);
}}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedRecordIds([]);
}}
/>
</div>
) : null}
</section> </section>
) : null} ) : null}
...@@ -283,7 +307,7 @@ export default function ModelBatchData() { ...@@ -283,7 +307,7 @@ export default function ModelBatchData() {
title="记录回填详情" title="记录回填详情"
onOpenChange={(open) => { if (!open) setForwardItemDetail(null); }} onOpenChange={(open) => { if (!open) setForwardItemDetail(null); }}
> >
{forwardItemDetail?.item ? <Surface className="rounded-2xl px-5 py-2 sm:px-6 sm:py-3" variant="secondary"><dl className="grid gap-x-8 gap-y-2 sm:grid-cols-2"><div className="py-3"><Description className="text-sm font-medium" elementType="dt">是否属实</Description><dd className="mt-1.5 text-sm">{getTruthStatusLabel(forwardItemDetail.item.truthStatus)}</dd></div><div className="py-3"><Description className="text-sm font-medium" elementType="dt">预计整改完成时间</Description><dd className="mt-1.5 text-sm">{formatDateTime(forwardItemDetail.item.expectedRectificationCompletedAt)}</dd></div><div className="py-3 sm:col-span-2"><Description className="text-sm font-medium" elementType="dt">整改方案</Description><dd className="mt-1.5 whitespace-pre-wrap text-sm leading-6">{forwardItemDetail.item.rectificationPlan || '-'}</dd></div><div className="py-3"><Description className="text-sm font-medium" elementType="dt">填写人</Description><dd className="mt-1.5 text-sm">{forwardItemDetail.item.filledByName || '-'}</dd></div><div className="py-3"><Description className="text-sm font-medium" elementType="dt">填写时间</Description><dd className="mt-1.5 text-sm">{formatDateTime(forwardItemDetail.item.filledAt)}</dd></div></dl></Surface> : <EmptyCollection title="暂无回填详情" description="当前记录还没有进入转发填写流程。" />} {forwardItemDetail?.item ? <Surface className="rounded-2xl px-5 py-2 sm:px-6 sm:py-3" variant="secondary"><dl className="grid gap-x-8 gap-y-2 sm:grid-cols-2"><div className="py-3"><Description className="text-sm font-medium" elementType="dt">是否属实</Description><dd className="mt-1.5 text-sm text-foreground">{getTruthStatusLabel(forwardItemDetail.item.truthStatus)}</dd></div><div className="py-3"><Description className="text-sm font-medium" elementType="dt">预计整改完成时间</Description><dd className="mt-1.5 text-sm text-foreground">{formatDateTime(forwardItemDetail.item.expectedRectificationCompletedAt)}</dd></div><div className="py-3 sm:col-span-2"><Description className="text-sm font-medium" elementType="dt">整改方案</Description><dd className="mt-1.5 whitespace-pre-wrap text-sm leading-6 text-foreground">{forwardItemDetail.item.rectificationPlan || '-'}</dd></div><div className="py-3"><Description className="text-sm font-medium" elementType="dt">填写人</Description><dd className="mt-1.5 text-sm text-foreground">{forwardItemDetail.item.filledByName || '-'}</dd></div><div className="py-3"><Description className="text-sm font-medium" elementType="dt">填写时间</Description><dd className="mt-1.5 text-sm text-foreground">{formatDateTime(forwardItemDetail.item.filledAt)}</dd></div></dl></Surface> : <EmptyCollection title="暂无回填详情" description="当前记录还没有进入转发填写流程。" />}
</XinyuanModal> </XinyuanModal>
</div> </div>
); );
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EyeOutlined } from '@ant-design/icons'; import { Eye } from '@/components/AppIcons';
import { Button, Description, Label, ProgressBar } from '@heroui/react'; import { Button, Description, Label, ProgressBar, toast } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { history, useParams } from 'umi'; import { history, useParams } from 'umi';
import { import {
Breadcrumbs,
DataGridActionButton, DataGridActionButton,
BusinessPagination,
EmptyCollection, EmptyCollection,
FieldLabel, FieldLabel,
NoticeBanner, NoticeBanner,
PageHeader, PageHeader,
SearchInput, SearchInput,
SectionHeader,
SyncStatusTag, SyncStatusTag,
} from './ModelMonitorShared'; } from './ModelMonitorShared';
import { import {
...@@ -33,9 +32,10 @@ export default function ModelBatches() { ...@@ -33,9 +32,10 @@ export default function ModelBatches() {
const [model, setModel] = useState(null); const [model, setModel] = useState(null);
const [batches, setBatches] = useState([]); const [batches, setBatches] = useState([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false); const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false);
const [sourceModel, setSourceModel] = useState(null); const [sourceModel, setSourceModel] = useState(null);
const [sourceBatches, setSourceBatches] = useState([]); const [sourceBatches, setSourceBatches] = useState([]);
...@@ -71,10 +71,18 @@ export default function ModelBatches() { ...@@ -71,10 +71,18 @@ export default function ModelBatches() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [modelId]); }, [modelId]);
const visibleBatches = useMemo(() => { const filteredBatches = useMemo(() => {
const search = keyword.trim().toLowerCase(); const search = keyword.trim().toLowerCase();
return batches.filter((batch) => !search || `${batch.batchName} ${batch.batchCode} ${batch.syncStatus}`.toLowerCase().includes(search)); return batches.filter((batch) => !search || `${batch.batchName} ${batch.batchCode} ${batch.syncStatus}`.toLowerCase().includes(search));
}, [batches, keyword]); }, [batches, keyword]);
const visibleBatches = useMemo(
() => filteredBatches.slice(page * pageSize, (page + 1) * pageSize),
[filteredBatches, page, pageSize],
);
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(filteredBatches.length / pageSize) - 1);
setPage((current) => Math.min(current, lastPage));
}, [filteredBatches.length, pageSize]);
const syncedBatchCodes = useMemo( const syncedBatchCodes = useMemo(
() => new Set(batches.map((batch) => batch.batchCode)), () => new Set(batches.map((batch) => batch.batchCode)),
[batches], [batches],
...@@ -83,12 +91,12 @@ export default function ModelBatches() { ...@@ -83,12 +91,12 @@ export default function ModelBatches() {
{ {
id: 'batch', header: '批次', isRowHeader: true, minWidth: 260, width: '1fr', allowsSorting: true, id: 'batch', header: '批次', isRowHeader: true, minWidth: 260, width: '1fr', allowsSorting: true,
sortFn: (left, right) => left.batchName.localeCompare(right.batchName, 'zh-CN'), sortFn: (left, right) => left.batchName.localeCompare(right.batchName, 'zh-CN'),
cell: (batch) => <div className="flex min-w-0 flex-col gap-1"><span className="truncate font-medium">{batch.batchName}</span><Description className="truncate text-sm">{batch.batchCode}</Description></div>, cell: (batch) => <div className="flex min-w-0 flex-col gap-1"><span className="truncate font-medium text-foreground">{batch.batchName}</span><Description className="truncate text-sm">{batch.batchCode}</Description></div>,
}, },
{ id: 'status', header: '同步状态', minWidth: 120, cell: (batch) => <SyncStatusTag status={batch.syncStatus} /> }, { id: 'status', header: '同步状态', minWidth: 120, cell: (batch) => <SyncStatusTag status={batch.syncStatus} /> },
{ id: 'records', header: '数据量', minWidth: 120, align: 'end', allowsSorting: true, cell: (batch) => batch.totalRecordCount.toLocaleString('zh-CN') }, { id: 'records', header: '数据量', minWidth: 120, align: 'end', allowsSorting: true, cell: (batch) => batch.totalRecordCount.toLocaleString('zh-CN') },
{ id: 'syncedAt', header: '最近同步', minWidth: 180, allowsSorting: true, cell: (batch) => formatDateTime(batch.syncedAt) }, { id: 'syncedAt', header: '最近同步', minWidth: 180, allowsSorting: true, cell: (batch) => formatDateTime(batch.syncedAt) },
{ id: 'actions', header: '操作', width: 80, cell: (batch) => <DataGridActionButton label={`查看 ${batch.batchName}`} onPress={() => history.push(`/models/${model.id}/batches/${batch.id}/data`)}><EyeOutlined /></DataGridActionButton> }, { id: 'actions', header: '操作', width: 80, cell: (batch) => <DataGridActionButton label={`查看 ${batch.batchName}`} onPress={() => history.push(`/models/${model.id}/batches/${batch.id}/data`)}><Eye /></DataGridActionButton> },
], [model?.id]); ], [model?.id]);
async function openSyncDialog() { async function openSyncDialog() {
...@@ -105,7 +113,13 @@ export default function ModelBatches() { ...@@ -105,7 +113,13 @@ export default function ModelBatches() {
setSourceModel(matchedModel); setSourceModel(matchedModel);
setSourceBatches(await listSourceBatches(matchedModel)); setSourceBatches(await listSourceBatches(matchedModel));
} catch (loadError) { } catch (loadError) {
setError(loadError?.message || '外部批次列表加载失败。'); const message = loadError?.message || '外部批次列表加载失败。';
if (message === '外部模型列表中已找不到当前模型。') {
toast.info(message);
setError('');
} else {
setError(message);
}
setIsSyncDialogOpen(false); setIsSyncDialogOpen(false);
} finally { } finally {
setSyncing(false); setSyncing(false);
...@@ -123,7 +137,7 @@ export default function ModelBatches() { ...@@ -123,7 +137,7 @@ export default function ModelBatches() {
try { try {
const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress); const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress);
await refresh(result.model); await refresh(result.model);
setNotice(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`); toast.success(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`);
setError(''); setError('');
setIsSyncDialogOpen(false); setIsSyncDialogOpen(false);
} catch (syncError) { } catch (syncError) {
...@@ -136,27 +150,33 @@ export default function ModelBatches() { ...@@ -136,27 +150,33 @@ export default function ModelBatches() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Breadcrumbs
actions={<Button isDisabled={!model} size="sm" variant="primary" onPress={openSyncDialog}>同步批次</Button>}
items={[{ label: '全部模型', to: '/models' }, { label: model?.modelName || '模型批次' }]}
/>
<PageHeader eyebrow={model?.modelCode || '模型'} title={model?.modelName || '模型批次'} description="这里仅展示已经同步到本系统的批次。外部未同步批次通过右侧同步入口选择。" /> <PageHeader eyebrow={model?.modelCode || '模型'} title={model?.modelName || '模型批次'} description="这里仅展示已经同步到本系统的批次。外部未同步批次通过右侧同步入口选择。" />
{loading ? <NoticeBanner>正在加载模型批次...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载模型批次...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{!loading && model ? ( {!loading && model ? (
<section className="min-w-0 space-y-4"> <section className="min-w-0 space-y-4">
<SectionHeader title="全部批次" /> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full min-w-0 sm:w-80 lg:w-96"> <div className="min-w-0 flex-1 sm:max-w-96">
<SearchInput <SearchInput
value={keyword} value={keyword}
variant="primary" variant="primary"
onChange={setKeyword} onChange={(value) => { setKeyword(value); setPage(0); }}
placeholder="搜索批次名称、编码或同步状态" placeholder="搜索批次名称、编码或同步状态"
/> />
</div> </div>
<Button isDisabled={!model} size="sm" variant="primary" onPress={openSyncDialog}>同步批次</Button>
</div>
<DataGrid aria-label="模型批次列表" columns={batchColumns} contentClassName="min-w-full" data={visibleBatches} getRowId={(batch) => batch.id} renderEmptyState={() => <EmptyCollection title="当前模型还没有已同步批次" description="点击“同步批次”选择一个外部批次,系统会创建批次对象并写入数据。" action={<Button variant="primary" onPress={openSyncDialog}>同步第一个批次</Button>} />} verticalAlign="middle" /> <DataGrid aria-label="模型批次列表" columns={batchColumns} contentClassName="min-w-full" data={visibleBatches} getRowId={(batch) => batch.id} renderEmptyState={() => <EmptyCollection title="当前模型还没有已同步批次" description="点击“同步批次”选择一个外部批次,系统会创建批次对象并写入数据。" action={<Button variant="primary" onPress={openSyncDialog}>同步第一个批次</Button>} />} verticalAlign="middle" />
{filteredBatches.length > 0 ? (
<BusinessPagination
page={page + 1}
pageSize={pageSize}
total={filteredBatches.length}
onPageChange={(nextPage) => setPage(nextPage - 1)}
onPageSizeChange={(nextPageSize) => { setPageSize(nextPageSize); setPage(0); }}
/>
) : null}
</section> </section>
) : null} ) : null}
......
const MOCK_MODELS = [
{
source_model_id: '430',
source_model_name: 'AML_CUSTOMER_RISK',
model_code: 'AML_CUSTOMER_RISK',
model_name: '客户洗钱风险模型',
model_state: 'enabled',
model_apply_state: 'applied',
model_risk_level: 'high',
model_business_type: '12',
model_business_type_name: '风险分析',
source_model_updated_at: '2026-06-30 08:00:00',
},
{
source_model_id: '412',
source_model_name: 'TRANSACTION_ALERT',
model_code: 'TRANSACTION_ALERT',
model_name: '异常交易预警模型',
model_state: 'enabled',
model_apply_state: 'applied',
model_risk_level: 'medium',
model_business_type: '12',
model_business_type_name: '风险分析',
source_model_updated_at: '2026-07-20 08:00:00',
},
{
source_model_id: '411',
source_model_name: 'CREDIT_EXPOSURE',
model_code: 'CREDIT_EXPOSURE',
model_name: '授信敞口监测模型',
model_state: 'enabled',
model_apply_state: 'applied',
model_risk_level: 'medium',
model_business_type: '13',
model_business_type_name: '合规分析',
source_model_updated_at: '2026-06-30 08:00:00',
},
];
const MOCK_BATCHES = {
430: [
{ source_model_id: '430', batch_code: '690', batch_name: '2026 年 6 月批次', source_batch_date: '2026-06-30 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_430', source_model_data_count: 5, source_unchecked_count: 5, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '430', batch_code: '652', batch_name: '2026 年 5 月批次', source_batch_date: '2026-05-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_430', source_model_data_count: 5, source_unchecked_count: 5, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '430', batch_code: '590', batch_name: '2025 年 12 月批次', source_batch_date: '2025-12-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_430', source_model_data_count: 5, source_unchecked_count: 5, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
],
412: [
{ source_model_id: '412', batch_code: '670', batch_name: '2026 年第 29 周', source_batch_date: '2026-07-20 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_412', source_model_data_count: 7, source_unchecked_count: 7, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '412', batch_code: '669', batch_name: '2026 年第 28 周', source_batch_date: '2026-07-13 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_412', source_model_data_count: 7, source_unchecked_count: 7, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '412', batch_code: '570', batch_name: '2025 年第 52 周', source_batch_date: '2025-12-29 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_412', source_model_data_count: 7, source_unchecked_count: 7, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
],
411: [
{ source_model_id: '411', batch_code: '630', batch_name: '2026 年第二季度', source_batch_date: '2026-06-30 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_411', source_model_data_count: 4, source_unchecked_count: 4, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '411', batch_code: '629', batch_name: '2026 年第一季度', source_batch_date: '2026-03-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_411', source_model_data_count: 4, source_unchecked_count: 4, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '411', batch_code: '530', batch_name: '2025 年第四季度', source_batch_date: '2025-12-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_411', source_model_data_count: 4, source_unchecked_count: 4, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
],
};
const DATA_BUILDERS = {
AML_CUSTOMER_RISK(batchCode) {
const columns = [
{ name: 'customer_id', label: '客户编号', type: 'text' },
{ name: 'risk_date', label: '风险日期', type: 'date' },
{ name: 'customer_name', label: '客户名称', type: 'text' },
{ name: 'risk_level', label: '风险等级', type: 'text' },
{ name: 'risk_score', label: '风险分值', type: 'number' },
{ name: 'is_focus_customer', label: '是否重点客户', type: 'boolean' },
];
const period = batchCode === '590' ? { year: '2025', month: '12' } : batchCode === '652' ? { year: '2026', month: '05' } : { year: '2026', month: '06' };
const names = ['华远实业', '海川商贸', '恒信科技', '嘉禾供应链', '远景投资'];
return {
columns,
rows: names.map((name, index) => ({
customer_id: `C${String(index + 1).padStart(4, '0')}`,
risk_date: `${period.year}-${period.month}-${String(index + 3).padStart(2, '0')}T00:00:00.000Z`,
customer_name: name,
risk_level: index < 2 ? '高' : index < 4 ? '中' : '低',
risk_score: 91 - index * 9 + (period.month === '06' ? 2 : 0),
is_focus_customer: index < 2,
})),
};
},
TRANSACTION_ALERT(batchCode) {
const columns = [
{ name: 'alert_id', label: '预警编号', type: 'text' },
{ name: 'account_name', label: '账户名称', type: 'text' },
{ name: 'transaction_amount', label: '交易金额', type: 'number' },
{ name: 'transaction_time', label: '交易时间', type: 'date' },
{ name: 'alert_reason', label: '预警原因', type: 'text' },
];
const period = batchCode === '570'
? { year: '2025', week: 52, startDay: 22 }
: batchCode === '669'
? { year: '2026', week: 28, startDay: 6 }
: { year: '2026', week: 29, startDay: 13 };
return {
columns,
rows: Array.from({ length: 7 }, (_, index) => ({
alert_id: `AL${period.week}${String(index + 1).padStart(3, '0')}`,
account_name: `监测账户 ${index + 1}`,
transaction_amount: 180000 + index * 73500,
transaction_time: `${period.year}-${period.year === '2025' ? '12' : '07'}-${String(period.startDay + index).padStart(2, '0')}T0${index % 9}:30:00.000Z`,
alert_reason: index % 2 === 0 ? '短期高频大额交易' : '交易对手集中度异常',
})),
};
},
CREDIT_EXPOSURE(batchCode) {
const columns = [
{ name: 'institution_code', label: '机构编码', type: 'text' },
{ name: 'reporting_period', label: '报告期间', type: 'text' },
{ name: 'institution_name', label: '机构名称', type: 'text' },
{ name: 'credit_limit', label: '授信额度', type: 'number' },
{ name: 'used_amount', label: '已用额度', type: 'number' },
{ name: 'over_limit', label: '是否超限', type: 'boolean' },
];
const multiplier = batchCode === '629' ? 0.94 : 1;
return {
columns,
rows: ['总行营业部', '华东分行', '华南分行', '西部中心'].map((name, index) => {
const limit = (12000000 + index * 3500000) * multiplier;
const used = limit * (0.72 + index * 0.1);
return {
institution_code: `ORG${index + 1}`,
reporting_period: batchCode,
institution_name: name,
credit_limit: Math.round(limit),
used_amount: Math.round(used),
over_limit: used > limit,
};
}),
};
},
};
function wait(duration = 280) {
return new Promise((resolve) => window.setTimeout(resolve, duration));
}
export async function listSourceModels() {
await wait();
return MOCK_MODELS.map((item) => ({ ...item }));
}
export async function listSourceBatches(sourceModel) {
await wait();
return (MOCK_BATCHES[sourceModel?.source_model_id] || []).map((item) => ({
...item,
source_model_name: sourceModel.source_model_name || sourceModel.model_code,
}));
}
export async function loadSourceBatchData(sourceModel, sourceBatch) {
await wait(420);
const builder = DATA_BUILDERS[sourceModel?.model_code];
if (!builder) {
throw new Error('未找到所选模型的 Mock 数据。');
}
return builder(sourceBatch?.batch_code);
}
This diff is collapsed.
This diff is collapsed.
import { import { Bell, Close, OpenExternal, Search } from '@/components/AppIcons';
BellOutlined,
CloseOutlined,
ExportOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert, Alert,
...@@ -99,7 +94,7 @@ function NotificationItem({ ...@@ -99,7 +94,7 @@ function NotificationItem({
: category === 'todo' ? '待处理' : '未读'} : category === 'todo' ? '待处理' : '未读'}
</Chip> </Chip>
</div> </div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6"> <p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
{content} {content}
</p> </p>
</div> </div>
...@@ -136,7 +131,7 @@ function NotificationItem({ ...@@ -136,7 +131,7 @@ function NotificationItem({
variant="primary" variant="primary"
onPress={() => onOpenTarget(notification, target)} onPress={() => onOpenTarget(notification, target)}
> >
<ExportOutlined aria-hidden="true" /> <OpenExternal aria-hidden="true" />
进入流程 进入流程
</Button> </Button>
) : null} ) : null}
...@@ -377,11 +372,11 @@ export default function NotificationListPage() { ...@@ -377,11 +372,11 @@ export default function NotificationListPage() {
> >
<SearchField.Group> <SearchField.Group>
<SearchField.SearchIcon> <SearchField.SearchIcon>
<SearchOutlined /> <Search />
</SearchField.SearchIcon> </SearchField.SearchIcon>
<SearchField.Input placeholder="搜索通知内容" /> <SearchField.Input placeholder="搜索通知内容" />
<SearchField.ClearButton aria-label="清空通知搜索"> <SearchField.ClearButton aria-label="清空通知搜索">
<CloseOutlined /> <Close />
</SearchField.ClearButton> </SearchField.ClearButton>
</SearchField.Group> </SearchField.Group>
</SearchField> </SearchField>
...@@ -428,7 +423,7 @@ export default function NotificationListPage() { ...@@ -428,7 +423,7 @@ export default function NotificationListPage() {
{!loading && !error && notifications.length === 0 ? ( {!loading && !error && notifications.length === 0 ? (
<EmptyState className="min-h-64" size="md"> <EmptyState className="min-h-64" size="md">
<EmptyState.Media> <EmptyState.Media>
<BellOutlined /> <Bell />
</EmptyState.Media> </EmptyState.Media>
<EmptyState.Header> <EmptyState.Header>
<EmptyState.Title> <EmptyState.Title>
......
This diff is collapsed.
This diff is collapsed.
...@@ -84,7 +84,7 @@ export default function AuthWrapper() { ...@@ -84,7 +84,7 @@ export default function AuthWrapper() {
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-muted">
正在验证登录状态... 正在验证登录状态...
</div> </div>
); );
......
...@@ -58,7 +58,7 @@ export default function SiteWrapper() { ...@@ -58,7 +58,7 @@ export default function SiteWrapper() {
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-muted">
加载中... 加载中...
</div> </div>
); );
......
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