Commit d1c08e54 authored by drigle's avatar drigle

feat: initialize internal regulation manager

parents
/node_modules
/.env.local
/.umirc.local.ts
/config/config.local.ts
/src/.umi
/src/.umi-production
/src/.umi-test
/.umi
/.umi-production
/.umi-test
/dist
/.mfsu
/.playwright-cli/
.swc
.pnpm-store
.DS_Store
.pnpm-debug.log
import { defineConfig } from '@umijs/max';
import path from 'path';
function resolvePackageRoot(packageName: string) {
return path.dirname(require.resolve(`${packageName}/package.json`));
}
const ROUTE_PATHS = {
login: '/login',
notifications: '/notifications',
modelOverview: '/models/overview',
models: '/models',
modelForwards: '/models/forwards',
regulationManager: '/internal_regulation_manager',
regulationGraph: '/graph',
} as const;
const APP_BASE_PATH = '/';
export default defineConfig({
layout: false,
fastRefresh: true,
base: APP_BASE_PATH,
publicPath: APP_BASE_PATH,
links: [
{
rel: 'stylesheet',
href: `${APP_BASE_PATH}fonts/inter.css`,
},
],
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
},
routes: [
{
path: ROUTE_PATHS.login,
layout: false,
component: './Login',
},
{
path: '/',
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './project_manager_xy/Layout',
routes: [
{
path: '/',
redirect: ROUTE_PATHS.regulationManager,
},
{
path: '/analysis',
redirect: ROUTE_PATHS.regulationManager,
},
{
path: '/resource',
redirect: ROUTE_PATHS.regulationManager,
},
{
path: '/resource/stat',
redirect: ROUTE_PATHS.regulationManager,
},
{
path: '/resource/booking',
redirect: ROUTE_PATHS.models,
},
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
{
path: ROUTE_PATHS.modelOverview,
component: './project_manager_xy/ModelOverview',
},
{
path: ROUTE_PATHS.modelForwards,
component: './project_manager_xy/ForwardApprovals',
},
{
path: '/models/:modelId/batches/:batchId/data',
component: './project_manager_xy/ModelBatchData',
},
{
path: '/models/:modelId/batches',
component: './project_manager_xy/ModelBatches',
},
{
path: ROUTE_PATHS.models,
component: './project_manager_xy/Models',
},
{
path: ROUTE_PATHS.regulationManager,
component: './internal_regulation_manager/InternalRegulationManager',
},
{
path: ROUTE_PATHS.regulationGraph,
component: './internal_regulation_manager/RegulationGraph',
},
{
path: '/internal_regulation/:name',
component: './internal_regulation_manager/InternalRegulationDetail',
},
{
path: '/internal_regulation_revision/:name',
component: './internal_regulation_manager/RevisionDetail',
},
{
path: '/analysis/overview',
redirect: ROUTE_PATHS.modelOverview,
},
{
path: '/analysis/views',
redirect: ROUTE_PATHS.models,
},
{
path: '/analysis/forwards',
redirect: ROUTE_PATHS.modelForwards,
},
{
path: '/analysis/boards',
redirect: ROUTE_PATHS.models,
},
],
},
],
npmClient: 'npm',
proxy: {
'/api': {
target: 'https://xy.streams7.com/api',
changeOrigin: true,
pathRewrite: { '^/api': '' },
secure: false,
},
'/parser': {
target: 'https://xy.streams7.com',
changeOrigin: true,
secure: false,
},
},
mfsu: false,
reactQuery: {
devtool: false,
},
chainWebpack(config, { env }) {
if (env === 'production') {
config.devtool('source-map');
}
},
esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
define: {
STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser',
MODEL_MONITOR_CONNECTOR_NAME: process.env.MODEL_MONITOR_CONNECTOR_NAME || 'MODEL_MONITOR_ORACLE',
},
});
# AI Agent 角色与开发规范 (AGENTS.md)
## 1. 核心定位
你是一个资深的前端架构师和 React 专家,负责根据项目规范编写高质量、可维护的代码。
## 2. 强制性上下文读取流程
在生成任何业务代码(尤其是接口调用、页面开发、Hooks 编写)之前,**必须**执行以下步骤:
1. **读取接口定义**:查阅项目根目录或指定目录下的 `record-api.md`,确保 URL、请求方法、参数名与文档完全一致。
2. **读取数据结构**:如果涉及复杂对象、列表或状态,**必须**查阅 `subjectsDefinition.md`,获取字段定义、类型约束和业务含义。
3. **类型一致性**:如果是 TypeScript 项目,定义的 Interface/Type 必须以 `subjectsDefinition.md` 为准。
## 3. 代码风格指南
- **命名规范**:
- **JSX/TSX 文件**:必须使用 **大驼峰形式 (PascalCase)**,例如 `UserProfile.jsx` 或 `OrderList.tsx`。
- **组件函数**:必须使用大驼峰形式,例如 `const UserHeader = () => { ... }`。
- **变量与普通函数**:使用小驼峰 (camelCase)。
- **常量**:使用全大写下划线 (SNAKE_CASE)。
- **技术栈偏好**:
- 优先使用 React Hooks 和函数组件。
- 样式处理:**统一使用 Hero UI / Hero UI Pro + Tailwind CSS**。
- 交互组件:**优先使用 Hero UI / Hero UI Pro 组件**,不再使用 Catalyst UI Kit 或 Headless UI 作为默认组件来源。
- 组件选择优先级(从高到低):
1. Hero UI / Hero UI Pro 官方组件(Button/Input/Modal/Table/Autocomplete/Select/Checkbox/Textarea/...)
2. 基于 Hero UI 设计规范的自定义组件(仅在 Hero UI / Hero UI Pro 无现成组件时)
3. 原生 HTML + 自写样式(尽量避免)
## 4. 接口请求规范
- 所有的 API 请求应当封装在独立的 service 层或自定义 Hooks 中。
- 严禁硬编码 URL,必须参考 `record-api.md`。
- 必须处理 Loading、Success、Error 三种状态。
## 5. 交互原则
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(Hero UI / Hero UI Pro 强制约束)
- **全项目 UI 统一**:默认使用 Hero UI / Hero UI Pro 组件与样式体系,避免引入其它 UI 框架。
- **Hero UI 优先**:弹窗/下拉/选择器/自动完成等交互优先使用 Hero UI / Hero UI Pro 组件。
- **一致性**:表单控件优先用 Hero UI 的 `Input/Select/Checkbox/Textarea`,表格用 `Table`,弹层用 `Modal`,复杂选择用 `Autocomplete`。
- **可访问性**:移动端侧栏/弹层必须使用 Hero UI / Hero UI Pro 提供的可访问性交互组件,确保 focus trap / esc 关闭等基础能力。
# AI 开发执行规范
## 1. 自动 UI 组件映射逻辑 (UI Mapping)
当 AI 生成前端页面、表单或组件时,**必须**严格按照以下 `Field Type` 映射对应的 UI 组件:
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- |
| **文本 (String)** | Hero UI `Input` | 标准文本输入 |
| **引用 (Reference)** | Hero UI `Autocomplete` / `Select` 封装的 `ReferenceSearchSelect` | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | Hero UI `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | Hero UI `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | Hero UI `Checkbox` | 映射为 true/false |
| **JSON** | Hero UI `Textarea` | 需要包含 JSON 校验逻辑 |
| **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 |
---
## 2. 数据处理与存储规约 (Data Protocol)
AI 在编写 API 调用或数据转换逻辑时,必须遵守:
- **Metadata 封装**:所有业务字段必须包裹在 `metadata: { ... }` 对象中。
- **引用值处理**:对于 `Reference` 类型,写入时**严禁**传递整个对象,必须仅传递 ID 字符串或 ID 数组。
- **日期格式**:所有时间字段必须转换为 `YYYY-MM-DDTHH:mm:ss.sssZ` 格式。
- **子对象嵌套**:`Subject` 类型字段应作为嵌套对象直接存放在父级的 `metadata` 中。
---
## 3. 动态配置读取流程 (Workflow)
当用户要求“生成一个 [SubjectName] 的表单”时,AI 的默认思考路径如下:
1. **Step 1**: 查找 `subjectsDefinition.md` 获取该 Subject 的基础字段。
2. **Step 2**: 检查字段类型。如果遇到 `Subject` 类型,**自动生成**一段调用 `loadSubject` 的代码以获取深层结构。
3. **Step 3**: 参照上表(第 1 节)选择 tailwind (或你指定的 UI 库) 的组件。
4. **Step 4**: 自动生成 `onChange` 处理函数,确保数据实时同步到 `metadata` 对象的对应路径下。
---
## 4. 强制约束 (Hard Constraints)
- **禁止硬编码**:Select 类型的选项必须从 `settings.options` 读取,严禁在代码里写死。
- **主键保护**:如果字段定义中 `isPrimaryKey: true`,在编辑模式下该组件必须设为 `disabled`。
- **必填校验**:根据 `isRequired` 自动在 UI 上添加红星标记,并生成相应的 Form Validation 规则。
- **流程文档同步**:如果本次修改涉及业务逻辑、页面交互流程、状态流转、字段规则、审批/操作步骤变化,除了更新代码外,**必须同步补全对应模块的流程文档**;若未找到对应文档,需先告知用户再继续。
---
## 5. 流程文档维护要求
- 开发前先确认本次需求归属的业务模块,并查阅对应流程文档。
- 开发完成后,检查实现结果是否与流程文档一致;如果存在新增、变更或删除的逻辑,必须同步更新文档。
- 流程文档更新内容至少应包含:功能入口、关键交互步骤、状态变化、核心字段/筛选条件、异常处理或限制条件。
- 提交结果时,除了说明改动代码,也要简要说明同步更新了哪个流程文档、补充了哪部分逻辑。
## 业务文档索引
- 模型监控模块 - 请阅读 `/src/pages/project_manager_xy/model-monitor.business-flow.md`
- 模板来源说明 - 如需确认旧项目上下文,可参考 `/src/pages/project_manager_xy/project-management.business-flow.md`
## 获取当前登录用户信息
可使用 src/hooks/user.jsx 中的 hook useUserInfo 数据结构如下 { "username": "yang_admin", "password_changed": true, "token_expires_at": null, "\_id": "69b12a47e92b1de77c94a9f5", "display_name": "杨康", "type": "admin", "avatar": null, "profile": {}, "groups": [ { "\_id": "649e5a55ad1c1038911baabd", "name": "default", "display_name": "默认用户组", "users": [ { "_id": "649e5a55ad1c1038911baac0", "username": "system", "display_name": "系统" } ], "settings": {} } ], "applications": [ { "name": "fund_audit", "title": "数智报告平台", "groups": [ "default" ], "roles": [ "成员" ], "permissions": [] }, { "name": "aml_tables", "title": "反洗钱数据表", "groups": [ "default" ], "roles": [ "管理" ], "permissions": [] } ] }
# 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` 热替换。
2. `src/tailwind.css`:Tailwind、HeroUI、HeroUI Pro CSS 入口;使用 `source(none)` 和显式源码目录,排除 Umi 生成目录。
3. `src/design-system/theme.css`:不可修改的权威 HeroUI 主题。
4. `src/design-system/tokens.css`:Skill 批准的鑫元扩展 Token。
5. `src/design-system/shell.css`:基准 Shell、列表、分页、KPI 和响应式几何。
6. 页面 JSX:仅业务组合和布局,不包含私有组件外观。
## 字体稳定性
- Inter 字体文件固定在 `public/fonts/`,由 Umi HTML 中的常驻 `<link>` 加载;开发态替换 `umi.css` 时不会卸载字体注册。
- `src/tailwind.css` 禁止重新导入 `@fontsource/inter/*.css`,并禁止扫描 `src/.umi`,避免生成文件反向触发 CSS 编译循环。
## 组件边界
- `Layout.jsx`:通过 `AppShell` 组合 HeroUI Pro Sidebar/Navbar、应用切换和真实退出;内容区固定浅色,Sidebar 固定深色,移动端使用 HeroUI Pro Sheet。页面层级面包屑由 Layout 根据路由注入 Navbar,父级可返回、当前级为深色,页面正文不再重复显示面包屑。
- `Layout.jsx`:从通知 service 读取真实未读数,监听跨页面状态事件并每 60 秒刷新;Navbar 只负责铃铛展示和路由跳转。
- `notifications/notificationService.js`:复用 `DataService` 的平台通知请求,负责 300ms 搜索参数、未读状态、已读事件、动态 action 和 base-aware 链接解析。
- `notifications/NotificationListPage.jsx`:HeroUI 原生消息/待办列表、搜索、状态 Tabs、Pagination、EmptyState 与 Toast,不持有模型监控业务状态。
- `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 样式负责。
- `ModelMonitorShared.jsx`:业务中性的 SearchField、DataGrid、Pagination、ActionBar、状态 Chip、动态字段组合。
- `XinyuanAutocomplete.jsx`:HeroUI Autocomplete/SearchField/ListBox 组合,暴露当前 HeroUI 值契约,不兼容旧 Select props。
- 页面:直接组合 HeroUI/HeroUI Pro,不通过 Catalyst、Headless UI 或旧 primitive wrapper。
## 数据流
页面状态和 service 调用保持迁移前的业务边界。组件只把 HeroUI 的 `onPress`、`onChange`、`onSelectionChange` 和 `selectedKeys` 映射回已有 handler;API 路径、metadata、Record/Subject 字段和审批状态不在 UI 层重定义。
通知是本项目唯一获准新增的跨应用能力。`/notifications` 复用既有 `/notice/customer/*` API;模型分析不引入 `subjectView`、`shortTitle`、View 授权或材料中心附件适配代码。
## 删除边界
`src/catalyst-ui-kit`、不可达资源预约模板及 ECharts 统计模板已从仓库移除。它们不在 `.umirc.ts` 的活动路由中,备份为 `/tmp/model-analysis-legacy-ui.iaSi6Y`。
# UI 迁移验收清单
## 已完成
- [x] 2026-09-02 复核并同步审计项目管理基准:固定浅色页面、深色 offcanvas Sidebar、共享 Navbar、分组应用切换 Modal、共享图标出口
- [x] 官方 HeroUI Pro 安装成功,完整包和六个要求子路径可导入
- [x] 单一 `pnpm-lock.yaml`,无 `file:`、vendor、本机绝对路径或跨项目依赖
- [x] 权威 `theme.css`、`tokens.css` 与共享 `shell.css` 已同步基准
- [x] 登录、Sidebar、Navbar、应用切换和退出结构迁移
- [x] SearchField、Autocomplete、DatePicker、Checkbox、Tabs 使用 HeroUI compound API
- [x] 模型、批次、批次数据、转发审批列表迁移为 DataGrid
- [x] DataGrid 原生选择、真实溢出固定列、横向滚动、单一空态
- [x] 模型、模型批次和批次数据 Pagination 使用 HeroUI 标准组合:列表有数据即显示,默认 20 条/页,支持 20/50/100 条切换,最多五页窗口;空列表隐藏分页
- [x] ActionBar 固定定位并对齐活动内容区
- [x] 模型分析按跨应用白名单只接入消息通知,未引入 `shortTitle`、View 权限或材料中心文件功能
- [x] 新增 `/notifications`,支持消息/待办、全部/未读、300ms 搜索、固定 10 条分页、单条/全部已读、动态 action 和关联跳转
- [x] Navbar 使用 HeroUI Badge.Anchor + Badge + Button + Tooltip,Bell 位于主题按钮左侧,Sidebar 无通知入口
- [x] 路由层级面包屑统一放入 Navbar;模型批次数据使用“全部模型 > 模型批次 > 批次数据”,父级可返回对应列表
- [x] 弹窗使用 HeroUI Modal/AlertDialog;全部业务 Modal 接入共享 320–1216px 尺寸梯度,审批详情使用单一 secondary Surface
- [x] 批量同步弹窗使用 HeroUI Pro Stepper/ListView、HeroUI ScrollShadow 和正确 Checkbox anatomy;移除手写灰色模型/批次分组及重复 Surface
- [x] KPI 使用 HeroUI Pro compound API 与 16px grid gap
- [x] Catalyst、Headless UI、ECharts、原生可见 select/date/file 和旧兼容包装仓库扫描为零
- [x] 活动页面私有颜色/背景/描边/圆角/阴影类扫描为零
- [x] 业务 API、参数、校验、回调、状态流和请求时机保持不变
## 最终命令
- [x] `npm run build` / `pnpm build`
- [x] `git diff --check`
- [x] 鑫元 `audit_project.sh`
- [x] 依赖树与 Pro 子路径检查
- [x] 旧组件、私有视觉 CSS、本机依赖路径扫描
## 浏览器
- [x] 1440x900 登录页与问题整改双应用对比
- [x] 1280x720 登录页浅色/深色
- [x] 768x1024 登录页浅色/深色
- [x] 390x844 登录页浅色/深色
- [x] 目标与问题整改通知页在 1440x900、1280x720、768x1024、390x844 完成浅色/深色同视口对照
- [x] 未读 0/1/99/100、消息/待办、全部/未读、搜索、单条/全部已读、动态 action、站内外跳转、HTML 纯文本、Pagination、EmptyState、Toast
- [x] Navbar 36px Bell/主题按钮、8px 间距、48/32/16px gutter;移动 offcanvas、Escape、无页面横向溢出
- [x] 最终通知验收控制台 0 error / 0 warning
- [ ] 真实通知数据端到端验收:Andy 会话调用 `/notice/customer/count` 和 `/notice/customer/query` 返回 401
浏览器结构和交互验收所用通知响应仅存在于 Playwright route 内存,不写入仓库、不作为业务数据。真实接口 401 继续交由平台权限/环境修复,前端未伪造成功状态或绕过既有请求拦截器。
# 模型分析鑫元设计系统
本项目的唯一视觉与交互基准为已确认的“审计项目管理”应用:
`/Users/liuchenlong/Desktop/job/audit_project_manager`。业务行为以 `record-api.md`、
`subjectsDefinition.md` 和 `model-monitor.business-flow.md` 为准。
## 基础约束
- 页面和共享组件直接使用 `@heroui/react`、`@heroui-pro/react` 及官方子路径。
- 权威 `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` 时造成英文、数字和符号字体回退抖动。
- 统一文字层级:正文 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`。
- 文字命令按钮不加装饰图标;真实图标操作使用 HeroUI Button、Tooltip 与可访问名称。
## Shell 与登录
- Sidebar 为 HeroUI Pro offcanvas,宽 240px,折叠为零宽;单菜单组不显示 GroupLabel。
- Sidebar Footer 使用直接 `Dropdown.Trigger`、HeroUI Avatar 和真实退出回调。
- Navbar 为透明 64px HeroUI Pro Navbar;Trigger 使用与基准一致的实心 16px 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 与页面共用 48/32/16px 对称 gutter。
- 页面层级导航统一放在 Navbar 标题位置:父级使用灰色文字并可点击返回,层级之间使用灰色右箭头,当前页面使用深色文字;正文不重复渲染小面包屑。进入模型批次数据时沿用“全部模型 > 模型批次 > 批次数据”,其中“模型批次”返回当前模型的批次列表。
- 登录页使用居中 28rem Card、36px Logo、40px 字段、24px 字段节奏和 46px 提交按钮;验证码及原鉴权协议保留。
- 应用切换为 HeroUI Modal,宽 46rem、高 42rem,移动一列、`sm` 起两列,36px identity well。
## 业务组件
- 筛选:HeroUI Surface、SearchField、Autocomplete、DatePicker、Disclosure。
- 列表:HeroUI Pro DataGrid,原生选择/排序/固定列/横向滚动;空态使用 `renderEmptyState`。
- 分页:HeroUI compound Pagination 与 HeroUI Autocomplete 页容量选择器组合;业务列表默认 20 条/页,支持 20/50/100 条切换,最多五个连续页码,有数据才显示。
- 批量操作: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`。
- 普通 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。
- KPI:HeroUI Pro KPI compound API;四项使用 `.rc-kpi-grid` 16px gap,标题不附加说明图标。
- 当前活动业务没有图表;不得为视觉验收制造图表或测试业务数据。未来增加时只使用 HeroUI Pro chart 与 ChartTooltip。
- 通知中心使用 HeroUI Tabs、SearchField、Card、Avatar、Chip、Pagination、Toast 和 HeroUI Pro EmptyState;一级模块桌面间距 32px、移动端 24px,工具栏间距 16px;页面不设置组件背景、颜色、描边、圆角、阴影或状态皮肤。
## 跨应用功能范围
- 当前应用显示名为“模型分析”,不属于问题整改、审计项目管理、警示案例库、廉洁从业管理、廉洁风险防控、党风廉政建设六个完整功能应用。
- 本项目只实施全局消息通知。不得加入 Grid/Detail `shortTitle`、Subject View 权限或审计材料中心文件工作流,即使相邻基准仓库存在这些实现。
## 数据保护
- Reference 写入仍只保存 ID 或现有页面协议对象;枚举来自 `settings.options`。
- DatePicker 保持既有 service 转 ISO 流程;动态 Subject 继续使用现有 `loadSubject`/subject definition。
- UI 迁移不得改变 API、路由、权限、校验、保存回调、请求时机、分页口径或审批状态。
# 模型分析 UI 审计
审计日期: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、数据结构、保存回调和请求时机未改动。
## 安装硬门槛
- 执行目录:`/Users/liuchenlong/Desktop/job/model_analysis`
- 官方 `hpsetup` 命令:成功,退出码 0;授权值未写入仓库或本文档。
- `@heroui-pro/react`:`1.0.0-beta.8`
- 实际解析:`node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8/node_modules/@heroui-pro/react/package.json`
- 完整包文件:`dist/index.js`、`dist/css/index.css` 均存在。
- 已验证子路径:`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state`。
- 本轮复核:`@heroui-pro/react@1.0.0-beta.8` 解析到当前仓库 `node_modules/.pnpm`,`dist/index.js` 与 `dist/css/index.css` 存在,六个 JS 子路径动态 import 全部成功;CSS export 可解析,裸 Node 不执行 CSS 文件。
- 依赖变化:更新 `package.json` 和 `pnpm-lock.yaml`,新增 `pnpm-workspace.yaml`;删除旧 `package-lock.json` 与本机 vendor TGZ。唯一锁文件为 `pnpm-lock.yaml`。
- 依赖中不存在 `file:`、相邻项目 `node_modules`、手工软链接替代或 `/Users/...` 路径;`node_modules` 内仅有 pnpm 正常生成的内容寻址链接。
## 业务与路由
| 路由 | 页面 | UI 状态 |
| --- | --- | --- |
| `/login` | 登录 | HeroUI Card/TextField/Input/Button,验证码和原登录协议保留 |
| `/models/overview` | 模型总览 | HeroUI Pro KPI、Widget、ListView、ItemCard |
| `/models` | 全部模型 | HeroUI Surface、SearchField、DataGrid、Modal/AlertDialog |
| `/models/:modelId/batches` | 模型批次 | HeroUI Pro DataGrid、Modal、ProgressBar |
| `/models/:modelId/batches/:batchId/data` | 批次数据 | DataGrid 原生选择、固定列、Pagination、ActionBar、Modal |
| `/models/forwards` | 转发审批 | Tabs、DataGrid、单一 secondary Surface 详情、4xl Modal |
| `/notifications` | 通知消息 | HeroUI Tabs/SearchField/Card/Pagination/Toast、HeroUI Pro EmptyState;消息/待办与全部/未读 |
历史 `/analysis/*` 与 `/resource/*` 仅重定向到上述活动路由。不可达的资源预约/统计模板以及 `src/catalyst-ui-kit` 已从仓库移除,备份位于 `/tmp/model-analysis-legacy-ui.iaSi6Y`。
## 鉴权与业务保护
- 登录继续调用 `getCaptcha()`、`userLogin()`,保持 AES 密码、`captcha/token/type`、localStorage 键和 redirect。
- Auth wrapper 继续通过 `getUserInfo()` 合并用户;退出继续调用 `userLogout(username)`,最终清理登录态并进入 `/login`。
- 模型同步、批次同步、动态 Subject、隐藏记录、转发、填写和两级审批的 service、参数、校验、调用顺序与状态条件未修改。
- 模型、模型批次和批次数据列表有数据时显示标准 Pagination:左侧为总条数/页数摘要和 20/50/100 条页容量选择器,右侧为最多五个连续页码;空列表隐藏分页。
- 仓库没有生成测试数字、浏览器 fixture 或伪造业务数据;验收 route 响应只存在于 Playwright 会话内存。
## 跨应用功能审计
- 应用统一显示名为“模型分析”,应用 route/name 为 `model_analysis`,base/publicPath 为 `/model_analysis/`;不在六个完整功能应用白名单内,因此只实施消息通知。
- 当前模型监控页面由固定 Subject 与自有 service 驱动,没有动态 Grid/Detail Subject View 链路;未发现可接入 `shortTitle` 或 `viewAuth` 的合法范围。
- Andy 本轮登录返回 `type: admin` 且 `applications: []`。通知能力沿用平台用户会话,不从其他应用复制或合并授权,也不读取 `integrity_risk_manager`。
- 项目已有材料归档 `materialService.js`,但本轮没有可编辑通用 file 字段或详情草稿事务接入需求;该业务保持不变,没有加入问题整改的材料中心文件流。
- `DataService.js` 已存在 `queryUserNotice`、`readUserNotice`、`unReadNoticeCount`、`exacteActionFunction`。本轮只增加通知业务 service、页面、Navbar 状态和路由,没有改变这些 API 函数的 URL、方法或请求体。
- 真实 Andy 会话下,当前环境的 `/notice/customer/count` 与 `/notice/customer/query` 返回 401;原请求拦截器按既有规则清理会话。该后端权限/环境阻塞保留并在浏览器验收中单独报告,不在前端伪造数据规避。
## 组件与样式审计
- 活动页面直接使用 `@heroui/react`、`@heroui-pro/react` 及 Pro 子路径,没有旧 props 兼容包装。
- `XinyuanAutocomplete` 是 HeroUI 原生业务中性组合,远程用户选择保留现有对象协议。
- `XinyuanModal` 是 HeroUI 原生业务中性 compound 组合;已清除向 `Modal.Container` 直接传递不受支持的 `xl/4xl` 值,应用切换、分组、模型编辑、同步、隐藏管理、转发、审批详情和填写弹窗均按共享尺寸梯度约束。
- 批量同步弹窗此前将 Checkbox 标签放在 `Checkbox.Content` 外部,并用 `Surface tertiary + Checkbox` 手写模型分组列表,造成方框/文案错位和陌生灰色块。现已改为正确 Checkbox compound 层级、紧凑 HeroUI Pro Stepper、单一 ListView `primary` 原生多选与 ScrollShadow;来源模型信息进入批次行说明,保留原年份筛选、跨年份选择、已同步禁用和提交回调。
- 核心列表均使用 DataGrid;空态由 `renderEmptyState` 单一渲染。
- 页面视觉色、背景、描边、圆角、阴影及 HeroUI 内部状态覆盖扫描为零;次要文字直接使用 HeroUI `Description`,必填状态由 `Label isRequired` 管理。
- `shell.css` 已同步“审计项目管理”基准的共享 Shell、列表、分页、KPI 和响应式几何;组件外观由权威主题及官方 variant 管理。
- Navbar 通知与主题按钮复用批准的共享动作组:36x36px、8px 间距、HeroUI 中性 Surface;移动端 Sidebar Dialog 通过 HeroUI Pro `Sheet.Heading` 获得可访问名称,复验控制台无警告。
- 当前业务需求没有活动图表;旧 ECharts 页面已移除,因此没有伪造图表或业务数据。
## 主题
- `src/design-system/theme.css` 与 Skill 权威文件逐字节一致。
- SHA-256:`a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`
- `tokens.css` 与 Skill 副本逐字节一致。
- Shell gutter:桌面 48px、平板 32px、移动 16px,Navbar 与页面对称共用。
## 浏览器证据
- 目标:`http://localhost:8001/model_analysis/`
- 基准:`http://localhost:8000/audit_project_manager_new/`
- 使用独立 Playwright 会话同时打开目标通知页和审计项目管理通知页,在 1440x900、1280x720、768x1024、390x844 的浅色与深色完成同视口对照。基准真实登录受验证码接口拒绝,因此仅在浏览器内存中拦截鉴权/通知请求用于结构比较,未写入仓库或充当业务数据。
- 桌面通知页的一级 Tabs、搜索、状态 Tabs、Card 和 Pagination 几何与基准一致;目标继续执行当前合同要求的 48/32/16px Shell gutter。所有视口 `scrollWidth === clientWidth`。
- 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。
- 真实 Andy 会话仍被 `/notice/customer/count` 与 `/notice/customer/query` 的 401 阻断;因此前端实现和浏览器结构验收完成,但真实通知数据的端到端验收未完成。
## 最终扫描
- `npm run build`:成功,通知页异步 chunk 已生成。
- `git diff --check`:成功。
- `bash /Users/liuchenlong/.codex/skills/xinyuan-design-system/scripts/audit_project.sh "$(git rev-parse --show-toplevel)"`:退出码 0;脚本的 CommonJS 探测对六个 ESM Pro 子路径报告 warning,后续 `npm run verify:ui-runtime` 动态 import 全部成功。
- 审计脚本另保留 7 条当前分支既有启发式 warning:共享 Modal panel 探测、DataGrid typography token、DataGrid 主次字段层级、人员身份组合、可点击主字段、固定列 stacking rule、`--rc-shadow-card` 基准比对。本轮通知范围未扩大修改这些既有模块。
- 旧 Catalyst/Headless/旧组件 import、可见原生 select/date/file、通知页私有视觉 class、HeroUI 内部 BEM/伪元素覆盖:扫描无命中。
- `file:`、相邻 `node_modules` 和 `/Users/...` package/lock 依赖:扫描无命中。
- `pnpm list @heroui-pro/react @heroui/react @heroui/styles @react-aria/utils react react-dom react-aria-components --depth 1`:应用运行时为 React/ReactDOM 19.2.7、HeroUI 3.2.2、React Aria Components 1.20.0。
{
"private": true,
"scripts": {
"build": "max build",
"dev": "max dev",
"postinstall": "max setup",
"setup": "max setup",
"start": "npm run dev",
"verify:ui-runtime": "node scripts/verify-ui-runtime.mjs"
},
"dependencies": {
"@fontsource/inter": "5.3.0",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "3.2.2",
"@heroui/styles": "3.2.2",
"@internationalized/date": "3.12.3",
"@number-flow/react": "0.6.2",
"@react-aria/utils": "3.34.1",
"@tailwindcss/postcss": "4.2.4",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "4.6.51",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dagre": "0.8.5",
"dayjs": "^1.11.20",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "12.35.1",
"react": "19.2.7",
"react-aria": "3.51.0",
"react-aria-components": "1.20.0",
"react-dom": "19.2.7",
"react-markdown": "^10.1.0",
"react-resizable-panels": "4.12.2",
"react-stately": "3.49.0",
"reactflow": "11.11.4",
"recharts": "3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwindcss": "4.2.4",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "^5.0.3"
}
}
This diff is collapsed.
allowBuilds:
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("./inter-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("./inter-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 600;
src: url("./inter-latin-600-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
{
"name": "model_analysis",
"version": "1.0.3",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang",
"spa": true
}
This diff is collapsed.
import { readFileSync, realpathSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersions = {
'@heroui-pro/react': '1.0.0-beta.8',
'@heroui/react': '3.2.2',
'@heroui/styles': '3.2.2',
'@react-aria/utils': '3.34.1',
react: '19.2.7',
'react-aria-components': '1.20.0',
'react-dom': '19.2.7',
};
function packageMetadata(packageName) {
const packageJson = require.resolve(`${packageName}/package.json`);
return {
packageJson: realpathSync(packageJson),
value: JSON.parse(readFileSync(packageJson, 'utf8')),
};
}
let heroUiProRoot = '';
for (const [packageName, expectedVersion] of Object.entries(expectedVersions)) {
const { packageJson, value } = packageMetadata(packageName);
const expectedRoot = path.join(projectRoot, 'node_modules');
if (!packageJson.startsWith(expectedRoot + path.sep)) {
throw new Error(`${packageName} resolved outside this project: ${packageJson}`);
}
if (value.version !== expectedVersion) {
throw new Error(`${packageName} expected ${expectedVersion}, received ${value.version}`);
}
if (packageName === '@heroui-pro/react') {
heroUiProRoot = path.dirname(packageJson);
}
console.log(`${packageName}@${value.version}: ${packageJson}`);
}
const cssPath = realpathSync(fileURLToPath(import.meta.resolve('@heroui-pro/react/css')));
const expectedCssPath = realpathSync(path.join(heroUiProRoot, 'dist/css/index.css'));
if (cssPath !== expectedCssPath) {
throw new Error(`Unexpected HeroUI Pro CSS path: ${cssPath}`);
}
console.log(`@heroui-pro/react/css: ${cssPath}`);
for (const specifier of [
'@heroui-pro/react/sidebar',
'@heroui-pro/react/navbar',
'@heroui-pro/react/data-grid',
'@heroui-pro/react/drop-zone',
'@heroui-pro/react/action-bar',
'@heroui-pro/react/empty-state',
]) {
await import(specifier);
console.log(`${specifier}: import ok`);
}
import '@/global.less';
import '@/tailwind.css';
import { defineApp } from 'umi';
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() {
return {
name: 'internal_regulation_manager',
};
}
export default defineApp({});
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 181.98 34.02"><defs><style>.cls-1{fill:#fff}</style></defs><path class="cls-1" d="M129 29.71h-15.76v-5.24H129v5.24Zm-8.59-13.24c2.49 1.78 5.5 3.5 8.24 4.93h-15.24c2.21-1.29 4.65-2.92 7-4.93m2.48-2.31c.46-.46.91-.94 1.34-1.44l-3.37-2.25c-3.68 5.76-11.92 9.26-17.12 11.61.77 1.33 1.87 3.04 1.87 3.04s1.51-.52 3.79-1.6v10.5h3.84v-1.24H129v1.24h3.84V23.49a98.66 98.66 0 0 0 3.56 1.62l1.36-3.04s-9.94-4.28-14.87-7.91m2.4-2.17 9.9 5.76 1.87-3.04s-5.85-3.05-9.46-5.48c-.34.49-2.32 2.76-2.32 2.76m-8.11.07c-.34-.4-2.32-2.67-2.25-2.82-4.29 2.76-9.78 5.47-9.78 5.47l1.87 3.04 10.15-5.69Zm-8.19-5.05h24.23v3.75h3.84V3.94h-14.07L122.03 0l-3.74.85.74 3.09h-13.89v6.82h3.84V7.01ZM155.47 22.38l-1.78 1.96V9.62h-6.4v3.56h2.99v12.44c0 1.23-.38 2.28-1.14 3.13l2.13 2.42c1.9-1.99 3.79-3.94 5.69-5.84l-1.49-2.95Zm25.09 2.1c-.57-.29-1.04-.47-1.42-.57-.09.38-.14.9-.14 1.57-.28 1.62-.47 2.7-.57 3.27-.19.76-.43 1.14-.71 1.14-.85-.09-1.61-1.37-2.27-3.84l-.14-.85c1.99-3.04 3.84-6.83 5.54-11.39l-2.99-1.28c-.95 3.04-2.13 5.74-3.56 8.11-.47-2.85-.76-6.55-.85-11.1h8.1V6.27h-8.25V0h-3.55c0 2.37.05 4.46.14 6.26h-11.94v9.82c0 4.27-.19 7.36-.57 9.25-.47 1.9-1.42 3.99-2.84 6.26 1.04.76 2.04 1.57 2.99 2.42 2.75-4.27 4.03-9.91 3.84-16.94h3.7s.04-.01.06-.02v8.04h.01c-.04.86-.78 1.55-1.69 1.55-.21 0-1.06-.32-1.25-.38l-1.07 2.68c.69.38 1.48.6 2.33.6 2.57 0 4.66-1.99 4.7-4.45v-11.1l.03-.04h-6.83V9.54h8.53c.29 6.26.85 11.34 1.71 15.23-1.8 2.37-4.08 4.55-6.82 6.55.66.57 1.47 1.42 2.42 2.56 2.08-1.9 3.88-3.65 5.4-5.27 1.23 3.42 2.99 5.12 5.26 5.12 1.52 0 2.61-1.09 3.27-3.27.28-1.33.57-3.13.85-5.41-.28-.19-.76-.38-1.42-.57M155.68 5.84c-1.14-1.52-1.95-2.81-3.84-5.27l-2.99 1.71c2.08 3.04 2.8 4.47 3.56 5.7l3.27-2.13Zm22.6.28 2.42-1.85c-.38-.47-1.04-1.09-1.99-1.85-.85-.85-1.52-1.47-1.99-1.85l-2.42 1.71c.47.47 1.23 1.19 2.28 2.13l1.71 1.71M30.61 0h51.02v8.5H30.61zM0 0h6.63v8.5H0zM10.46 0h16.33v8.5H10.46zM44.64 21.21c0-2-.68-3.62-1.96-4.65-1.32-1.05-2.86-1.3-5.14-1.3h-6.93v18.48h3.76V18.58h3.31c.93 0 1.62.12 2.06.39.66.39 1.05 1.11 1.05 2.24 0 1.93-1.19 2.61-2.74 2.61h-2.5v3.35h1.49l3.91 6.58h4.34l-4.34-7.2c2.53-.74 3.7-2.84 3.7-5.33M56.03 23.27c-2.66-.92-5.66-1.03-5.66-3.05 0-1.3 1.19-1.91 2.88-1.91s3.13.15 5.62.82v-3.35c-2.14-.6-3.7-.82-5.72-.82-3.66 0-6.75 1.46-6.75 5.18 0 2.94 1.46 4.42 4.34 5.41 2.57.88 5.47 1.05 5.47 3.03 0 1.48-1.44 2.1-3.52 2.1s-3.29-.16-5.88-.86v3.37c2.16.64 3.97.82 6.07.82 4.59 0 7.28-2.01 7.28-5.72 0-2.94-1.93-4.26-4.13-5.02M76.73 15.26l-6.59 18.48h3.58l4.18-11.75h.06v11.75h3.68V15.26h-4.91zM71.42 26.76l-4.3-11.5h-4.98v18.48h3.64V21.99h.06l3.72 9.97 1.86-5.2zM91.84 0h1.7v34.02h-1.7z"/></svg>
\ No newline at end of file
<svg width="82" height="35" viewBox="0 0 82 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30.61 0H81.63V8.5H30.61V0ZM0 0H6.63V8.5H0V0ZM10.46 0H26.79V8.5H10.46V0ZM44.64 21.21C44.64 19.21 43.96 17.59 42.68 16.56C41.36 15.51 39.82 15.26 37.54 15.26H30.61V33.74H34.37V18.58H37.68C38.61 18.58 39.3 18.7 39.74 18.97C40.4 19.36 40.79 20.08 40.79 21.21C40.79 23.14 39.6 23.82 38.05 23.82H35.55V27.17H37.04L40.95 33.75H45.29L40.95 26.55C43.48 25.81 44.65 23.71 44.65 21.22M56.03 23.27C53.37 22.35 50.37 22.24 50.37 20.22C50.37 18.92 51.56 18.31 53.25 18.31C54.94 18.31 56.38 18.46 58.87 19.13V15.78C56.73 15.18 55.17 14.96 53.15 14.96C49.49 14.96 46.4 16.42 46.4 20.14C46.4 23.08 47.86 24.56 50.74 25.55C53.31 26.43 56.21 26.6 56.21 28.58C56.21 30.06 54.77 30.68 52.69 30.68C50.61 30.68 49.4 30.52 46.81 29.82V33.19C48.97 33.83 50.78 34.01 52.88 34.01C57.47 34.01 60.16 32 60.16 28.29C60.16 25.35 58.23 24.03 56.03 23.27ZM76.73 15.26L70.14 33.74H73.72L77.9 21.99H77.96V33.74H81.64V15.26H76.73ZM71.42 26.76L67.12 15.26H62.14V33.74H65.78V21.99H65.84L69.56 31.96L71.42 26.76Z" fill="white"/>
</svg>
import React from 'react';
const ICON_PATHS = {
bank: ['M3 10h18', 'M5 10v9M9 10v9M15 10v9M19 10v9', 'M3 19h18', 'M12 3l9 5H3l9-5Z'],
archive: ['M4 7h16v13H4z', 'M3 4h18v3H3z', 'M9 11h6'],
appGrid: ['M4 4h5v5H4z', 'M15 4h5v5h-5z', 'M4 15h5v5H4z', 'M15 15h5v5h-5z'],
barChart: ['M5 20V10M12 20V4M19 20v-7', 'M3 20h18'],
bell: ['M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9', 'M10 21h4'],
calendar: ['M5 4v3M19 4v3', 'M4 6h16v14H4z', 'M4 10h16'],
check: ['m5 12 4 4L19 6'],
chevronDown: ['m6 9 6 6 6-6'],
chevronRight: ['m9 6 6 6-6 6'],
chevronUp: ['m18 15-6-6-6 6'],
clock: ['M12 7v5l3 2', 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'],
close: ['m6 6 12 12M18 6 6 18'],
copy: ['M8 8h11v12H8z', 'M5 16H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1v1'],
delete: ['M4 7h16', 'M10 11v6M14 11v6', 'M6 7l1 13h10l1-13', 'M9 7V4h6v3'],
dollar: ['M12 2v20', 'M17 6.5C16 5.5 14.5 5 12.5 5 10 5 8 6.3 8 8.5c0 2.5 2.2 3.2 4.5 3.8 2.3.6 4.5 1.3 4.5 3.8 0 2.2-2 3.9-4.8 3.9-2.1 0-3.8-.6-5-1.8'],
database: ['M4 5c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3Z', 'M4 5v7c0 1.7 3.6 3 8 3s8-1.3 8-3V5', 'M4 12v7c0 1.7 3.6 3 8 3s8-1.3 8-3v-7'],
edit: ['M4 20h4L19 9l-4-4L4 16v4Z', 'm13.5 6.5 4 4'],
exclamationCircle: ['M12 8v5', 'M12 16h.01', 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'],
eye: ['M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'],
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6'],
file: ['M6 3h8l4 4v14H6z', 'M14 3v5h5', 'M9 13h6M9 17h6'],
folder: ['M3 6h7l2 2h9v11H3z'],
fullscreen: ['M8 3H3v5M16 3h5v5M8 21H3v-5M21 16v5h-5'],
fullscreenExit: ['M9 3v6H3M15 3v6h6M9 21v-6H3M15 21v-6h6'],
group: ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z', 'M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8'],
paperclip: ['m21.4 11.6-8.9 8.9a6 6 0 0 1-8.5-8.5l9.1-9.1a4 4 0 0 1 5.7 5.7L9.7 17.7a2 2 0 0 1-2.8-2.8l8.5-8.5'],
left: ['m15 18-6-6 6-6'],
logout: ['M10 17l5-5-5-5', 'M15 12H3', 'M21 19V5a2 2 0 0 0-2-2h-5'],
lock: ['M5 11h14v10H5z', 'M8 11V7a4 4 0 0 1 8 0v4'],
inbox: ['M4 4h16v16H4z', 'M4 14h4l2 2h4l2-2h4'],
info: ['M12 16v-4', 'M12 8h.01', 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
ordered: ['M8 6h12M8 12h12M8 18h12', 'M4 6h.01M4 12h.01M4 18h.01'],
pie: ['M12 3v9h9', 'M19.1 15a8 8 0 1 1-10.1-10', 'M12 3a9 9 0 0 1 9 9'],
plus: ['M12 5v14M5 12h14'],
project: ['M4 4h16v16H4z', 'M8 8h8v8H8z'],
reload: ['M20 11a8 8 0 1 0 1 5', 'M20 4v7h-7'],
rise: ['M4 16l6-6 4 4 6-7', 'M16 7h4v4'],
search: ['m21 21-4.3-4.3', 'M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z'],
send: ['m22 2-7 20-4-9-9-4 20-7Z', 'M22 2 11 13'],
share: ['M18 8a3 3 0 1 0-2.8-4', 'M6 15a3 3 0 1 0 2.8 4', 'm8.8 17.5 6.4-3.5M15.2 10l-6.4-3.5'],
user: ['M20 21a8 8 0 0 0-16 0', 'M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z'],
upload: ['M12 16V4', 'm7 9 5-5 5 5', 'M4 20h16'],
unlock: ['M7 11V7a5 5 0 0 1 9.8-1', 'M5 11h14v10H5z'],
warning: ['M12 3 2 21h20L12 3Z', 'M12 9v4M12 17h.01'],
expand: ['M8 3H3v5M16 3h5v5M8 21H3v-5M21 16v5h-5'],
download: ['M12 3v12', 'm7 10 5 5 5-5', 'M4 21h16'],
save: ['M5 3h12l2 2v16H5z', 'M8 3v6h8V3', 'M8 21v-7h8v7'],
};
function iconStyle(size, style) {
return size === undefined ? style : { width: size, height: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function createIcon(name) {
return function AppIcon({ size, style, className, ...props }) {
const hasAccessibleName = Boolean(props['aria-label'] || props.title);
return (
<svg
{...props}
aria-hidden={hasAccessibleName ? undefined : true}
className={['app-icon', className].filter(Boolean).join(' ')}
fill="none"
focusable="false"
height={size === undefined ? '1em' : size}
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
style={iconStyle(size, style)}
viewBox="0 0 24 24"
width={size === undefined ? '1em' : size}
>
{ICON_PATHS[name].map((path, index) => <path d={path} key={index} />)}
</svg>
);
};
}
export const Bank = createIcon('bank');
export const Archive = createIcon('archive');
export const AppGrid = createIcon('appGrid');
export const BarChart = createIcon('barChart');
export const Bell = createIcon('bell');
export const Calendar = createIcon('calendar');
export const Check = createIcon('check');
export const ChevronDown = createIcon('chevronDown');
export const ChevronRight = createIcon('chevronRight');
export const Clock = createIcon('clock');
export const Close = createIcon('close');
export const Copy = createIcon('copy');
export const Delete = createIcon('delete');
export const Dollar = createIcon('dollar');
export const Database = createIcon('database');
export const Edit = createIcon('edit');
export const ExclamationCircle = createIcon('exclamationCircle');
export const Eye = createIcon('eye');
export const OpenExternal = createIcon('external');
export const FileText = createIcon('file');
export const Info = createIcon('info');
export const Folder = createIcon('folder');
export const Fullscreen = createIcon('fullscreen');
export const FullscreenExit = createIcon('fullscreenExit');
export const Group = createIcon('group');
export const LogOut = createIcon('logout');
export const Lock = createIcon('lock');
export const CodepenOutlined = createIcon('project');
export const More = createIcon('more');
export const Project = createIcon('project');
export const Search = createIcon('search');
export const Send = createIcon('send');
export const Share = createIcon('share');
export const Team = createIcon('group');
export const User = createIcon('user');
export const Upload = createIcon('upload');
export const Unlock = createIcon('unlock');
export const Back = createIcon('left');
export const ArrowDownTrayIcon = createIcon('download');
export const ArrowDownIcon = createIcon('chevronDown');
export const ArrowUpIcon = createIcon('chevronUp');
export const ArrowPathIcon = createIcon('reload');
export const ArrowTrendingUpIcon = createIcon('rise');
export const BanknotesIcon = createIcon('dollar');
export const CalendarDaysIcon = createIcon('calendar');
export const ChartBarSquareIcon = createIcon('barChart');
export const ChartPieIcon = createIcon('pie');
export const CheckCircleIcon = createIcon('check');
export const ChevronDownIcon = createIcon('chevronDown');
export const ChevronRightIcon = createIcon('chevronRight');
export const ClipboardDocumentListIcon = createIcon('ordered');
export const DocumentDuplicateIcon = createIcon('copy');
export const ClockIcon = createIcon('clock');
export const ExclamationTriangleIcon = createIcon('warning');
export const ExclamationCircleIcon = createIcon('exclamationCircle');
export const FolderOpenIcon = createIcon('folder');
export const InformationCircleIcon = createIcon('info');
export const LockClosedIcon = createIcon('lock');
export const LockOpenIcon = createIcon('unlock');
export const MagnifyingGlassIcon = createIcon('search');
export const PaperClipIcon = createIcon('paperclip');
export const PlusIcon = createIcon('plus');
export const TableCellsIcon = createIcon('ordered');
export const UserGroupIcon = createIcon('group');
export const Download = createIcon('download');
export const Expand = createIcon('expand');
export const Reload = createIcon('reload');
export const Save = createIcon('save');
export function SidebarPanel({ size = 20, style, className, ...props }) {
return createIcon('fullscreen')({ size, style, className, ...props });
}
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} /></>;
}
import { Check, ChevronDown, Search } from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
Spinner,
} from '@heroui/react';
const EMPTY_KEY = '__xinyuan_empty__';
const FIELD_SUFFIX_ICON_STYLE = {
color: 'var(--field-placeholder, var(--muted))',
};
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
function toSelectionKey(value) {
return String(value ?? '');
}
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
allowsEmptyCollection = false,
className,
emptyText = '暂无可选项',
errorText = '',
filter,
fullWidth = true,
inputValue,
isDisabled = false,
isClearable = false,
isLoading = false,
isOpen,
isRequired = false,
label,
onInputChange,
onClear,
onOpenChange,
onSelectionChange,
options = [],
placeholder = '请选择',
placement = 'bottom start',
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey = '',
selectedKeys,
selectionMode = 'single',
valueText,
showSearch = true,
shouldFilter = true,
variant = 'secondary',
}) {
const normalizedOptions = options.map((option) => {
const optionValue = Object.prototype.hasOwnProperty.call(option, 'id')
? option.id
: option.value;
return {
...option,
id: optionValue === '' ? EMPTY_KEY : toSelectionKey(optionValue),
isDisabled: Boolean(option.isDisabled ?? option.disabled),
label: String(option.label ?? option.textValue ?? optionValue ?? ''),
};
});
const normalizedKey = selectedKey === '' || selectedKey == null
? EMPTY_KEY
: toSelectionKey(selectedKey);
const selectedKeySet = selectionMode === 'multiple'
? new Set(selectedKeys === 'all' ? [] : Array.from(selectedKeys || []))
: new Set([normalizedKey]);
const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey),
);
const selectedOptions = normalizedOptions.filter((option) =>
selectedKeyStringSet.has(toSelectionKey(option.id)),
);
const resolvedValueText = valueText ?? (
selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder
);
const rootSelectionProps = selectionMode === 'multiple'
? { selectedKeys: selectedKeyStringSet }
: { selectedKey: normalizedKey };
const listBoxSelectionProps = selectionMode === 'multiple'
? {
selectedKeys: selectedKeyStringSet,
selectionMode,
}
: {};
const handleMultipleOptionPress = (option) => {
if (
selectionMode !== 'multiple'
|| !onSelectionChange
|| option?.isDisabled
|| option?.id === EMPTY_KEY
) {
return;
}
const optionKey = toSelectionKey(option.id);
if (!optionKey) return;
const nextKeys = new Set(
Array.from(selectedKeyStringSet).filter(Boolean),
);
if (nextKeys.has(optionKey)) {
nextKeys.delete(optionKey);
} else {
nextKeys.add(optionKey);
}
onSelectionChange(nextKeys);
};
return (
<Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || label || placeholder}
allowsEmptyCollection={allowsEmptyCollection}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
selectionMode={selectionMode}
variant={variant}
onClear={onClear}
onOpenChange={onOpenChange}
onSelectionChange={selectionMode === 'multiple'
? onSelectionChange
: (key) => {
onSelectionChange?.(key === EMPTY_KEY ? '' : toSelectionKey(key));
}}
>
{label ? <Label>{label}</Label> : null}
<Autocomplete.Trigger>
<Autocomplete.Value>
<span className="block min-w-0 truncate">
{resolvedValueText}
</span>
</Autocomplete.Value>
{isClearable ? <Autocomplete.ClearButton aria-label={`清除${ariaLabel || placeholder}`} /> : null}
<Autocomplete.Indicator>
<ChevronDown className="size-4" style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
{popoverHeader}
{showSearch ? <SearchField aria-label={searchPlaceholder} fullWidth variant={variant}>
<SearchField.Group>
<SearchField.SearchIcon>
<Search className="size-4" />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索" />
</SearchField.Group>
</SearchField> : null}
<ListBox
{...listBoxSelectionProps}
aria-label={ariaLabel || label || placeholder}
items={isLoading || errorText ? [] : normalizedOptions}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4">
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => {
const isSelected = selectedKeyStringSet.has(
toSelectionKey(option.id),
);
return (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.label}
{...(selectionMode === 'multiple'
? { onPress: () => handleMultipleOptionPress(option) }
: {})}
>
{renderOption ? renderOption(option) : <Label className="truncate" title={option.label}>{option.label}</Label>}
{selectionMode === 'multiple' ? (
<span
aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground"
>
{isSelected ? <Check className="size-3.5" /> : null}
</span>
) : (
<ListBox.ItemIndicator />
)}
</ListBox.Item>
);
}}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import { Close } from '@/components/AppIcons';
import { Modal } from '@heroui/react';
import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = {
md: 'sm:max-w-[30rem]',
xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]',
};
const STANDARD_PANEL_PADDING_SIZES = new Set(['xl', '2xl', '3xl', '4xl', '5xl']);
export default function XinyuanModal({
isOpen,
onOpenChange,
isDismissable,
title,
description,
size = 'lg',
dialogClassName,
bodyClassName,
children,
footer,
}) {
const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg';
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isDismissable={isDismissable} isOpen={isOpen} onOpenChange={onOpenChange} variant="blur">
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
className={clsx(
STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
</Modal.Header>
<Modal.Body className={bodyClassName}>
{description ? (
<div className="text-sm text-muted">{description}</div>
) : null}
{children}
</Modal.Body>
{footer ? (
<Modal.Footer>{footer}</Modal.Footer>
) : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export const APP_DISPLAY_NAME = '内部制度管理';
export const APP_ROUTE_NAME = 'internal_regulation_manager';
export const APP_BASE_PATH = '';
export function getAppUrl(path = '/') {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${APP_BASE_PATH}${normalizedPath}`;
}
export function stripAppBase(pathname = '/') {
if (pathname === APP_BASE_PATH) {
return '/';
}
if (pathname.startsWith(`${APP_BASE_PATH}/`)) {
return pathname.slice(APP_BASE_PATH.length) || '/';
}
return pathname || '/';
}
export const ROUTE_TITLES = [
{ match: /^\/notifications$/, title: '通知消息' },
{ match: /^\/graph$/, title: '制度拓扑图' },
{ match: /^\/internal_regulation\/[^/]+$/, title: '制度详情' },
{ match: /^\/internal_regulation_revision\/[^/]+$/, title: '修订详情' },
{ match: /^\/internal_regulation_manager/, title: '内部制度' },
];
export function getRouteTitle(pathname) {
return ROUTE_TITLES.find((item) => item.match.test(pathname))?.title || APP_DISPLAY_NAME;
}
:root {
--font-inter: 'Inter', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
Arial, sans-serif;
}
This diff is collapsed.
/*
* HeroUI Theme Customization
* Add this to your global.css after importing @heroui/styles
* Only includes variables users need to customize
* @see https://heroui.com/docs/react/getting-started/theming
*/
:root,
.light,
.default,
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
/* 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);
--primary: #DA241A;
--primary-hover: #C91F17;
--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);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
--field-radius: 0.75rem;
/* Font Family */
/* Make sure to load Inter font in your app */
--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.
* 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,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-foreground: oklch(99.11% 0 0);
--primary: #DA241A;
--primary-hover: #C91F17;
--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);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
:root,
.light,
.default,
[data-theme='light'],
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
--rc-bg-canvas: var(--background);
--rc-bg-surface: var(--surface);
--rc-border-default: var(--border);
--rc-field-background: var(--field-background);
--rc-field-border-focus: var(--focus);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 12%,
transparent
);
--rc-text-primary: var(--foreground);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 72%,
var(--background)
);
--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-normal: 400;
--rc-font-medium: 500;
--rc-font-semibold: 600;
--rc-font-bold: 700;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
--rc-text-section: 1.125rem;
--rc-text-module: 1rem;
--rc-space-1: 0.25rem;
--rc-space-2: 0.5rem;
--rc-space-3: 0.75rem;
--rc-space-4: 1rem;
--rc-space-5: 1.25rem;
--rc-space-6: 1.5rem;
--rc-space-8: 2rem;
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: 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-padding-block-start: 1.5rem;
--rc-page-padding-block-end: 2rem;
--rc-page-section-gap: 2rem;
--rc-page-module-gap: 1.5rem;
--rc-related-item-gap: 1rem;
--rc-page-toolbar-gap: 1rem;
--rc-list-stack-gap: 1rem;
--rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem;
--rc-motion-fast: 120ms;
--rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--rc-button-radius: 1.5rem;
--rc-navbar-trigger-size: 2rem;
--rc-navbar-icon-button-size: 2.25rem;
--rc-navbar-title-size: 1.25rem;
--rc-navbar-title-weight: 600;
--rc-navbar-title-line-height: 1.75rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-login-panel-width: 28rem;
--rc-login-brand-size: 1.5rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-brand-gap: 1.5rem;
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-shadow-card: 0 1px 2px 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. */
--rc-metric-tone-info: #4F6380;
--rc-metric-tone-success: #61758F;
--rc-metric-tone-teal: #74879A;
--rc-metric-tone-danger: #DA241A;
--rc-metric-tone-warning: #B87958;
--rc-metric-tone-violet: #6F6674;
--rc-metric-tone-indigo: #807783;
--rc-metric-tone-coral: #C95A52;
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: var(--xinyuan-chart-7);
--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-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-7);
--rc-chart-comparison: var(--rc-chart-5);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
html,
body,
#root {
min-height: 100%;
}
html {
background: var(--rc-bg-canvas);
}
body {
margin: 0;
overflow-x: hidden;
background: var(--rc-bg-canvas);
color: var(--rc-text-primary);
font-family: var(--rc-font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
letter-spacing: 0;
}
a {
color: inherit;
text-decoration: none;
}
import xinyuanLogo from '@/assets/xy-logo-red.png';
import xinyuanLogoWhite from '@/assets/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
function buildPassword(password, siteKey) {
if (!password) {
return password;
}
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
}
export default function LoginPage() {
const site = useContext(SiteContext);
const [searchParams] = useSearchParams();
const [identity, setIdentity] = useState('');
const [password, setPassword] = useState('');
const [captchaCode, setCaptchaCode] = useState('');
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/';
const refreshCaptcha = async () => {
setCaptchaLoading(true);
try {
const nextCaptcha = await getCaptcha();
setCaptchaData(nextCaptcha || null);
} catch (requestError) {
setCaptchaData(null);
setError(requestError?.message || '验证码加载失败,请稍后重试');
} finally {
setCaptchaLoading(false);
}
};
useEffect(() => {
document.title = `${APP_DISPLAY_NAME} - 登录`;
refreshCaptcha();
}, []);
const handleSubmit = async (event) => {
event.preventDefault();
setLoading(true);
setError('');
try {
const loginInfo = await userLogin({
identity,
password: buildPassword(password, site?.key),
captcha: captchaCode,
token: captchaData?.token,
type: 'password',
});
authService.setCurrentUser(loginInfo);
if (loginInfo?.token) {
authService.setToken(loginInfo.token);
}
history.push(redirect);
} catch (requestError) {
setError(requestError?.message || '登录失败,请重试');
setCaptchaCode('');
refreshCaptcha();
} finally {
setLoading(false);
}
};
return (
<main className="login-page rc-login-page">
<section className="login-shell rc-login-shell">
<Card className="login-card rc-login-card">
<Card.Content>
<div className="login-brand login-card-brand rc-login-brand">
<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>
</div>
<form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField
className="login-field"
data-has-value={identity ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
value={identity}
onChange={setIdentity}
>
<Label>账号</Label>
<Input
autoComplete="username"
autoFocus
className="login-input"
data-filled={identity ? 'true' : undefined}
placeholder="请输入账号"
variant="secondary"
/>
</TextField>
<TextField
className="login-field"
data-has-value={password ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
type="password"
value={password}
onChange={setPassword}
>
<Label>密码</Label>
<Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码"
variant="secondary"
/>
</TextField>
<div
className="login-field"
data-has-value={captchaCode ? 'true' : undefined}
>
<Label>验证码</Label>
<div className="login-captcha-row">
<Input
aria-label="验证码"
autoComplete="off"
className="login-input"
data-filled={captchaCode ? 'true' : undefined}
disabled={loading}
placeholder="请输入验证码"
required
value={captchaCode}
variant="secondary"
onChange={(event) => setCaptchaCode(event.target.value)}
/>
<Button
aria-label="刷新验证码"
className="login-captcha-button"
isDisabled={loading || captchaLoading}
variant="secondary"
onPress={() => {
setError('');
refreshCaptcha();
}}
>
{captchaData?.image ? (
<span
className="captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }}
/>
) : (
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)}
</Button>
</div>
</div>
{error ? (
<Alert className="login-error" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
className="login-submit-button"
fullWidth
isDisabled={!identity || !password || !captchaCode || captchaLoading}
isPending={loading}
type="submit"
variant="primary"
>
登录
</Button>
</form>
</Card.Content>
</Card>
</section>
</main>
);
}
import { Button, Input, Label, Pagination, Spinner, TextField } from '@heroui/react';
import { Download, Search } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useEffect, useState } from 'react';
import { history } from 'umi';
import { ARCHIVE_SUBJECT, loadArchiveOptions, loadArchivedRegulations, queryAll } from '@/services/internalRegulationService';
import { downloadCsv } from './csv';
export default function ArchiveList() {
const [archive, setArchive] = useState('');
const [archiveOptions, setArchiveOptions] = useState([]);
const [searchText, setSearchText] = useState('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [records, setRecords] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const refresh = async () => {
try {
setLoading(true);
const result = await loadArchivedRegulations({ archive, keyword, page, pageSize: 20 });
setRecords(result.records); setTotal(result.total); setError('');
} catch (loadError) { setError(loadError?.message || '归档历史加载失败。'); } finally { setLoading(false); }
};
useEffect(() => { loadArchiveOptions().then((values) => setArchiveOptions(values.map((value) => ({ value, label: value })))).catch(() => setArchiveOptions([])); }, []);
useEffect(() => { refresh(); }, [archive, keyword, page]);
const submitSearch = () => {
setPage(0);
setKeyword(searchText.trim());
};
const exportList = async () => {
try {
const filter = {};
if (archive) filter['metadata.archive'] = archive;
if (keyword.trim()) filter['metadata.regulation_name'] = { $regex: keyword.trim() };
const rows = await queryAll(ARCHIVE_SUBJECT, { filter, sort: '-metadata.public_date' });
downloadCsv('内部制度归档历史.csv', rows, [
{ label: '制度名称', get: (row) => row.metadata?.regulation_name || row.title },
{ label: '时效性', get: (row) => row.metadata?.effective_status },
{ label: '效力位阶', get: (row) => row.metadata?.level },
{ label: '审批主体', get: (row) => row.metadata?.approve },
{ label: '制定部门', get: (row) => row.metadata?.departments },
{ label: '发文日期', get: (row) => row.metadata?.public_date },
{ label: '归档批次', get: (row) => row.metadata?.archive },
]);
} catch (exportError) { setError(exportError?.message || '导出失败。'); }
};
return <div className="space-y-4"><div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"><div className="flex flex-col gap-3 sm:flex-row sm:items-end"><XinyuanAutocomplete className="w-full sm:w-56" label="归档批次" options={[{ value: '', label: '全部归档' }, ...archiveOptions]} selectedKey={archive} onSelectionChange={(value) => { setArchive(String(value || '')); setPage(0); }} /><div className="flex w-full items-end gap-2 sm:w-72"><TextField className="min-w-0 flex-1" fullWidth value={searchText} onChange={setSearchText} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch(); } }}><Label className="sr-only">搜索归档制度</Label><div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={16} /><Input className="pl-9" fullWidth placeholder="搜索制度名称" variant="secondary" /></div></TextField><Button isDisabled={loading} variant="secondary" onPress={submitSearch}>查询</Button></div></div><div className="flex items-center gap-3"><span className="text-sm text-muted">归档记录只读展示,不会修改现行制度</span><Button variant="tertiary" onPress={exportList}><Download size={16} />批量下载</Button></div></div>{error ? <div className="rounded-xl border border-danger/30 bg-danger/5 p-3 text-sm text-danger">{error}</div> : null}{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner label="正在加载归档记录..." /></div> : records.length === 0 ? <div className="rounded-2xl border border-dashed border-separator p-10 text-center text-sm text-muted">暂无归档记录</div> : <div className="overflow-x-auto rounded-2xl border border-separator"><table className="w-full min-w-[900px] text-left text-sm"><thead className="bg-surface-secondary text-muted"><tr>{['制度名称', '时效性', '效力位阶', '审批主体', '制定部门', '发文日期', '归档批次', '操作'].map((title) => <th className="px-4 py-3 font-medium" key={title}>{title}</th>)}</tr></thead><tbody className="divide-y divide-separator">{records.map((record) => { const metadata = record.metadata || {}; return <tr key={record._id || record.name}><td className="px-4 py-3"><button className="font-medium text-accent hover:underline" onClick={() => history.push(`/internal_regulation/${metadata.origin_regulation_id || record.name}`)}>{metadata.regulation_name || record.title || '-'}</button></td><td className="px-4 py-3">{metadata.effective_status || '-'}</td><td className="px-4 py-3">{metadata.level || '-'}</td><td className="max-w-48 truncate px-4 py-3">{Array.isArray(metadata.approve) ? metadata.approve.join('、') : metadata.approve || '-'}</td><td className="max-w-48 truncate px-4 py-3">{Array.isArray(metadata.departments) ? metadata.departments.join('、') : metadata.departments || '-'}</td><td className="px-4 py-3">{metadata.public_date ? new Date(metadata.public_date).toLocaleDateString('zh-CN') : '-'}</td><td className="px-4 py-3">{metadata.archive || '-'}</td><td className="px-4 py-3"><Button size="sm" variant="tertiary" onPress={() => history.push(`/internal_regulation/${metadata.origin_regulation_id || record.name}`)}>查看详情</Button></td></tr>; })}</tbody></table></div>}{total > 20 ? <Pagination className="justify-end" size="sm"><Pagination.Content><Pagination.Item><Pagination.Previous isDisabled={page === 0} onPress={() => setPage((value) => Math.max(0, value - 1))} /></Pagination.Item><Pagination.Item><Pagination.Link isActive>{page + 1}</Pagination.Link></Pagination.Item><Pagination.Item><Pagination.Next isDisabled={(page + 1) * 20 >= total} onPress={() => setPage((value) => value + 1)} /></Pagination.Item></Pagination.Content></Pagination> : null}</div>;
}
import { Handle, Position } from 'reactflow';
import { ChevronRight, Close, Info } from '@/components/AppIcons';
const TYPE_LABELS = { rootNode: '根节点', groupNode: '组节点', regulationNode: '制度节点' };
export default function GraphNode({ data }) {
const { title, type = 'regulationNode', childCount = 0, expanded, removed, selected, relatedRegulationType = 'active', content } = data;
const statusClass = relatedRegulationType === 'addition' ? 'border-blue-300 bg-blue-50' : relatedRegulationType === 'revise' ? 'border-amber-300 bg-amber-50' : relatedRegulationType === 'forbidden' ? 'border-red-300 bg-red-50' : 'border-slate-200 bg-white';
return <div className={`relative flex h-11 w-[240px] items-center rounded-lg border px-3 shadow-sm transition ${statusClass} ${type === 'rootNode' ? 'w-[280px] border-teal-300 bg-teal-50' : ''} ${selected ? 'ring-2 ring-accent/40' : ''} ${removed ? 'opacity-45' : ''}`} title={content || TYPE_LABELS[type] || '制度节点'}>
<Handle type="target" position={Position.Left} className="!border-0 !bg-transparent" />
{removed ? <Close className="mr-1 shrink-0 text-danger" size={14} /> : null}
<span className={`min-w-0 flex-1 truncate ${type === 'rootNode' ? 'font-semibold' : 'font-medium'}`}>{title}</span>
{content ? <Info className="ml-2 shrink-0 text-muted" size={14} /> : null}
{childCount > 0 ? <button className="nodrag nowheel ml-2 grid size-7 shrink-0 place-items-center rounded-full border border-separator bg-background text-xs font-semibold hover:border-accent hover:text-accent" aria-label={expanded ? '折叠子节点' : `展开 ${childCount} 个子节点`} onClick={(event) => { event.stopPropagation(); data.onToggle?.(data.id); }}>{expanded ? <ChevronRight className="rotate-180" size={13} /> : childCount}</button> : null}
<Handle type="source" position={Position.Right} className="!border-0 !bg-transparent" />
</div>;
}
import { Button, Card, Chip, Spinner, TextArea, TextField, Label, Input } from '@heroui/react';
import { Back, Edit, FileText, PaperClipIcon } from '@/components/AppIcons';
import XinyuanModal from '@/components/XinyuanModal';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useEffect, useMemo, useState } from 'react';
import { history, useParams } from 'umi';
import { loadRecord, loadRegulationHistory, loadRegulationSubject, loadSubjectOptions, normalizeMetadata, referenceId, updateRecord } from '@/services/internalRegulationService';
function dateText(value) { if (!value) return '-'; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? String(value).slice(0, 10) : parsed.toLocaleDateString('zh-CN'); }
function dateInputValue(value) { if (!value) return ''; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? String(value).slice(0, 10) : parsed.toISOString().slice(0, 10); }
export default function InternalRegulationDetail() {
const { name } = useParams();
const [record, setRecord] = useState(null);
const [subject, setSubject] = useState(null);
const [options, setOptions] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({});
const [historyRecords, setHistoryRecords] = useState([]);
const load = async () => { try { setLoading(true); const [loaded, loadedSubject, loadedOptions] = await Promise.all([loadRecord(name), loadRegulationSubject(), loadSubjectOptions('internal_regulation')]); const histories = await loadRegulationHistory(loaded); setRecord(loaded); setHistoryRecords(histories); setSubject(loadedSubject); setOptions(loadedOptions.options); setForm(loaded?.metadata || {}); setError(''); } catch (loadError) { setError(loadError?.message || '制度详情加载失败。'); } finally { setLoading(false); } };
useEffect(() => { load(); }, [name]);
const fields = useMemo(() => (subject?.fields || []).filter((field) => ['regulation_name', 'effective_status', 'level', 'public_date', 'departments', 'approve', 'effective_date', 'comment'].includes(field.name)), [subject]);
const save = async () => { try { setSaving(true); await updateRecord(record.name || name, { name: record.name || name, metadata: normalizeMetadata({ ...record.metadata, ...form }) }); setEditOpen(false); await load(); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); } };
if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载制度详情..." /></div>;
if (error || !record) return <div className="space-y-4"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回</Button><div className="rounded-2xl border border-danger/30 bg-danger/5 p-6 text-sm text-danger">{error || '制度不存在。'}</div></div>;
const metadata = record.metadata || {};
const fieldOptions = (fieldName) => (options[fieldName] || []).map((item) => ({ value: item.value ?? item.label ?? item, label: item.label ?? item.value ?? item }));
const referenceFields = new Set(['departments', 'approve']);
return <div className="space-y-5"><div className="flex flex-wrap items-center justify-between gap-3"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回制度目录</Button><Button variant="secondary" onPress={() => setEditOpen(true)}><Edit size={16} />编辑信息</Button></div><Card className="rc-card-surface"><Card.Header><div className="flex flex-wrap items-start justify-between gap-4"><div><p className="text-sm text-muted">内部制度</p><Card.Title className="mt-1 text-2xl">{metadata.regulation_name || record.title}</Card.Title><div className="mt-3 flex flex-wrap gap-2">{metadata.effective_status ? <Chip color={metadata.effective_status === '现行有效' ? 'success' : 'default'} size="sm" variant="soft"><Chip.Label>{metadata.effective_status}</Chip.Label></Chip> : null}{metadata.level ? <Chip size="sm" variant="soft"><Chip.Label>{metadata.level}</Chip.Label></Chip> : null}</div></div><div className="text-right text-sm text-muted">发文日期<br /><span className="font-medium text-foreground">{dateText(metadata.public_date)}</span></div></div></Card.Header><Card.Content><dl className="grid gap-x-8 gap-y-5 sm:grid-cols-2">{fields.map((field) => { const value = metadata[field.name]; return <div key={field.name}><dt className="text-sm font-medium text-muted">{field.label || field.name}</dt><dd className="mt-1 whitespace-pre-wrap text-sm">{Array.isArray(value) ? value.map(referenceId).join('、') : field.type === 'date' ? dateText(value) : typeof value === 'object' ? referenceId(value) : value || '-'}</dd></div>; })}</dl></Card.Content></Card><div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]"><Card className="rc-card-surface"><Card.Header><Card.Title className="flex items-center gap-2"><FileText size={18} />制度正文</Card.Title></Card.Header><Card.Content><article className="prose prose-slate max-w-none whitespace-pre-wrap text-sm leading-7" dangerouslySetInnerHTML={{ __html: record.content || '<p class="text-muted">暂无正文内容</p>' }} /></Card.Content></Card><Card className="rc-card-surface"><Card.Header><Card.Title>关联信息</Card.Title></Card.Header><Card.Content className="space-y-4 text-sm"><div><p className="mb-1 text-muted">附件</p>{(metadata.attachments || []).length ? metadata.attachments.map((attachment, index) => { const href = typeof attachment === 'string' ? attachment : attachment.url; return href ? <a className="flex items-center gap-2 text-accent hover:underline" href={href} key={referenceId(attachment) || index} rel="noreferrer" target="_blank"><PaperClipIcon size={15} />{attachment.title || attachment.name || `附件 ${index + 1}`}</a> : <span className="text-muted" key={referenceId(attachment) || index}>附件 {index + 1}</span>; }) : <span className="text-muted">暂无附件</span>}</div><div><p className="mb-1 text-muted">相关制度</p><span>{metadata.related_regulations?.length ? `${metadata.related_regulations.length} 条` : '暂无关联'}</span></div><div><p className="mb-1 text-muted">引用法规</p><span>{metadata.reference_regulation ? '已关联' : '暂无引用'}</span></div><div><p className="mb-1 text-muted">历史沿革</p>{historyRecords.length ? <div className="space-y-2">{historyRecords.map((item) => <div className="border-t border-separator pt-2" key={item._id || item.name}><div className="font-medium">{item.metadata?.regulation_name || item.title || '-'}</div><div className="text-muted">{dateText(item.metadata?.public_date)} · {item.metadata?.effective_status || '历史版本'}</div></div>)}</div> : <span className="text-muted">暂无历史版本</span>}</div></Card.Content></Card></div><XinyuanModal isOpen={editOpen} onOpenChange={setEditOpen} size="2xl" title="编辑制度信息" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setEditOpen(false)}>取消</Button><Button isDisabled={saving} variant="primary" onPress={save}>{saving ? '保存中...' : '保存'}</Button></div>}><div className="grid gap-4 sm:grid-cols-2">{fields.map((field) => { const value = form[field.name]; const selectedValues = Array.isArray(value) ? value.map(referenceId) : value ? [referenceId(value)] : []; if (field.name === 'regulation_name') return <TextField className="sm:col-span-2" fullWidth isRequired key={field.name} value={value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth variant="secondary" /></TextField>; if (field.type === 'long_text' || field.name === 'comment') return <TextField className="sm:col-span-2" fullWidth key={field.name} value={value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField>; if (referenceFields.has(field.name)) return <XinyuanAutocomplete key={field.name} label={field.label || field.name} options={fieldOptions(field.name)} selectedKeys={new Set(selectedValues)} selectionMode="multiple" onSelectionChange={(next) => setForm((current) => ({ ...current, [field.name]: next === 'all' ? fieldOptions(field.name).map((item) => item.value) : Array.from(next || []) }))} />; if (field.name === 'level' || field.name === 'effective_status') return <XinyuanAutocomplete key={field.name} label={field.label || field.name} options={fieldOptions(field.name)} selectedKey={value || ''} onSelectionChange={(next) => setForm((current) => ({ ...current, [field.name]: String(next) }))} />; if (field.type === 'date' || ['public_date', 'effective_date'].includes(field.name)) return <TextField fullWidth key={field.name} value={dateInputValue(value)} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth type="date" variant="secondary" /></TextField>; return <TextField fullWidth key={field.name} value={Array.isArray(value) ? value.join('、') : value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth variant="secondary" /></TextField>; })}</div></XinyuanModal></div>;
}
import { Card, Spinner, Tabs } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { loadSubjectOptions } from '@/services/internalRegulationService';
import RegulationList from './RegulationList';
import RevisionList from './RevisionList';
import ArchiveList from './ArchiveList';
const TAB_ITEMS = [
{ id: 'regulation', label: '制度目录' },
{ id: 'process', label: '修订计划' },
{ id: 'archive', label: '归档历史' },
];
export default function InternalRegulationManager() {
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'regulation');
const [subject, setSubject] = useState(null);
const [options, setOptions] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const [{ subject: loadedSubject, options: loadedOptions }] = await Promise.all([loadSubjectOptions('internal_regulation')]);
if (!cancelled) {
setSubject(loadedSubject);
setOptions(loadedOptions);
setError('');
}
} catch (loadError) {
if (!cancelled) setError(loadError?.message || '制度配置加载失败,请稍后重试。');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
const changeTab = (key) => {
const value = String(key);
setActiveTab(value);
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set('tab', value);
return next;
});
};
const content = useMemo(() => {
if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载制度配置..." /></div>;
if (error) return <div className="rounded-2xl border border-danger/30 bg-danger/5 p-6 text-sm text-danger">{error}</div>;
if (activeTab === 'process') return <RevisionList options={options} />;
if (activeTab === 'archive') return <ArchiveList options={options} />;
return <RegulationList subject={subject} options={options} />;
}, [activeTab, error, loading, options, subject]);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm text-muted">内部制度管理</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight">内部制度</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted">统一管理制度目录、修订计划和归档历史,所有业务字段均通过平台 Record 的 metadata 保存。</p>
</div>
</div>
<Card className="rc-card-surface">
<Card.Content className="p-0">
<Tabs className="w-fit max-w-full" selectedKey={activeTab} variant="primary" onSelectionChange={changeTab}>
<Tabs.ListContainer className="border-b border-separator px-4 sm:px-6">
<Tabs.List aria-label="制度管理模块" className="w-max">
{TAB_ITEMS.map((item) => <Tabs.Tab className="whitespace-nowrap" id={item.id} key={item.id}>{item.label}<Tabs.Indicator /></Tabs.Tab>)}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<div className="p-4 sm:p-6">{content}</div>
</Card.Content>
</Card>
</div>
);
}
This diff is collapsed.
This diff is collapsed.
import { Button, Card, Input, Label, Spinner, TextArea, TextField } from '@heroui/react';
import { Back } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useEffect, useState } from 'react';
import { history, useParams } from 'umi';
import { loadRecord, loadSubjectOptions, REGULATION_LEVELS, updateRevision } from '@/services/internalRegulationService';
export default function RevisionDetail() {
const { name } = useParams();
const [record, setRecord] = useState(null);
const [options, setOptions] = useState({});
const [form, setForm] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => { Promise.all([loadRecord(name), loadSubjectOptions('internal_regulation')]).then(([loaded, loadedOptions]) => { setRecord(loaded); setForm(loaded?.metadata || {}); setOptions(loadedOptions.options); }).catch((loadError) => setError(loadError?.message || '修订详情加载失败。')).finally(() => setLoading(false)); }, [name]);
const save = async () => { try { setSaving(true); await updateRevision(record.name || name, form); history.push('/internal_regulation_manager?tab=process'); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); } };
if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载修订详情..." /></div>;
if (error || !record) return <div className="space-y-4"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回</Button><div className="rounded-2xl border border-danger/30 bg-danger/5 p-6 text-sm text-danger">{error || '修订计划不存在。'}</div></div>;
const selectOptions = (field) => (options[field] || []).map((item) => ({ value: item.value ?? item.label ?? item, label: item.label ?? item.value ?? item }));
return <div className="mx-auto max-w-4xl space-y-5"><div className="flex items-center justify-between"><Button variant="tertiary" onPress={() => history.push('/internal_regulation_manager?tab=process')}><Back size={16} />返回修订计划</Button><Button isDisabled={saving || !form.regulation_name} variant="primary" onPress={save}>{saving ? '保存中...' : '保存'}</Button></div><Card className="rc-card-surface"><Card.Header><Card.Title>编辑修订计划</Card.Title></Card.Header><Card.Content><div className="grid gap-4 sm:grid-cols-2"><TextField className="sm:col-span-2" fullWidth isRequired value={form.regulation_name || ''} onChange={(value) => setForm((current) => ({ ...current, regulation_name: value }))}><Label>制度名称</Label><Input fullWidth variant="secondary" /></TextField><XinyuanAutocomplete label="修订类型" options={['新增', '修订', '废止'].map((value) => ({ value, label: value }))} selectedKey={form.type || ''} onSelectionChange={(value) => setForm((current) => ({ ...current, type: String(value) }))} /><XinyuanAutocomplete label="效力位阶" options={REGULATION_LEVELS.map((value) => ({ value, label: value }))} selectedKey={form.level || ''} onSelectionChange={(value) => setForm((current) => ({ ...current, level: String(value) }))} /><XinyuanAutocomplete label="审批主体" options={selectOptions('approve')} selectedKeys={new Set(form.approve || [])} selectionMode="multiple" onSelectionChange={(value) => setForm((current) => ({ ...current, approve: value === 'all' ? selectOptions('approve').map((item) => item.value) : Array.from(value || []) }))} /><XinyuanAutocomplete label="制定部门" options={selectOptions('departments')} selectedKeys={new Set(form.departments || [])} selectionMode="multiple" onSelectionChange={(value) => setForm((current) => ({ ...current, departments: value === 'all' ? selectOptions('departments').map((item) => item.value) : Array.from(value || []) }))} /><TextField className="sm:col-span-2" fullWidth value={form.comment || ''} onChange={(value) => setForm((current) => ({ ...current, comment: value }))}><Label>备注</Label><TextArea fullWidth rows={6} variant="secondary" /></TextField></div></Card.Content></Card></div>;
}
This diff is collapsed.
function csvValue(value) {
if (Array.isArray(value)) return value.join('、');
if (value && typeof value === 'object') return value.label || value.title || value.name || value._id || '';
return value ?? '';
}
export function downloadCsv(filename, rows, columns) {
const header = columns.map((column) => column.label);
const body = rows.map((row) => columns.map((column) => `"${String(csvValue(column.get ? column.get(row) : row[column.key])).replaceAll('"', '""')}"`).join(','));
const csv = `\uFEFF${header.join(',')}\n${body.join('\n')}`;
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
# 内部制度管理业务流程
## 功能入口
- `/internal_regulation_manager`:制度目录、修订计划、归档历史三个 Tab。
- `/internal_regulation/:name`:制度详情、正文、附件、关联制度与历史沿革入口。
- `/internal_regulation_revision/:name`:修订计划编辑页。
- `/graph`:制度拓扑图(与 `/internal_regulation_manager` 制度目录为平级入口),根据 `internal_regulation_graph.metadata.graph_info` 展示制度、流程节点和父子连线;图谱记录缺失或节点为空时,根据制度引用关系生成降级图,确保首次进入页面仍能看到制度节点,同时保留非空图谱中的删除节点供“删除节点”列表查看。
## 制度目录
1. 页面读取 `internal_regulation` Subject 定义,并从字段 `settings.options` 生成审批主体、制定部门等选项。
2. 列表按 `metadata.public_date` 倒序查询,支持制度名称搜索、时效性/位阶/部门/审批主体筛选与展示;制度名称输入后点击“查询”或按 Enter 才提交搜索,输入过程不实时请求;“批量下载”按当前已提交的搜索和筛选条件导出 CSV。
3. 创建修订计划时写入 `internal_regulation_revision`,业务字段全部放入 `metadata`,初始状态为“起草中”。
4. 制度名称进入详情路由;编辑仅更新制度允许修改的 `metadata` 字段,引用字段只保存 ID。
## 修订计划与状态
- `起草中`:可编辑、取消或提交审核。
- `已提交`:可取消审核或提交发文;发文需要发文日期,新增/修订允许上传附件和正文摘要。
- `已发文`:发布后同步更新/创建 `internal_regulation`,原修订记录保留为已发文。
- `已取消`:保留现场供排查,不自动删除记录。
- 修订计划支持按当前状态和制度名称导出 CSV;制度名称输入后点击“查询”或按 Enter 才提交搜索,输入过程不实时请求;发文弹窗支持日期、正文摘要和 doc/docx 附件上传。
发文时:新增制度拒绝重复名称;修订制度先生成失效历史版本,再更新现行版本;废止制度更新现行制度为“已失效”。日期统一转换为 ISO 8601,附件仅保存引用 ID。
## 归档与拓扑
- 制度拓扑图与制度目录是两个平级入口;进入拓扑图后不呈现制度目录层级,也不提供返回制度目录操作,用户可通过左侧一级菜单在两个模块之间切换。
- 归档历史从 `internal_regulation_archive` 读取,按归档批次和制度名称筛选,只读展示;制度名称输入后点击“查询”或按 Enter 才提交搜索,输入过程不实时请求。
- 归档历史支持按当前批次和关键字导出 CSV,详情入口回到对应现行制度。
- 拓扑图沿用旧版 React Flow + Dagre 的层级画布逻辑,左侧提供“全部 / 待分类 / 节点 / 删除节点”四个列表 Tab;“节点”和“删除节点”列表支持搜索和点击定位,点击隐藏层级中的节点时会先自动展开该节点的全部祖先路径,再将画布平滑移动到目标节点并打开详情。打开时默认展开根节点和第一层流程节点,第二层及更深节点按节点右侧计数按钮逐级展开。节点支持拖拽、缩放、邻接连线、展开/折叠和双击进入关联制度详情。
- 拓扑页高度锁定为当前视口可用高度,标题、工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
- 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;右键可添加流程节点或描述。
- 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类;也支持将待处理制度拖入选中节点下。
- 保存写入 `internal_regulation_graph` 的 `graph_info.nodes/edges`,导出生成制度列表 CSV;XMind 导入、节点编辑和拖拽归类均需保存后持久化图谱。
## 加载、错误与限制
- Subject、Record、附件请求均显示 loading、success 或 error 状态;错误保留在当前页面,不使用演示数据或 localStorage 回退。
- 保存、提交、发文期间禁用重复提交按钮;失败时保留表单内容。
- 归档记录不修改现行制度;不存在的制度或修订计划显示明确空状态。
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# 说明
当前应用已从 `project_manager_xy` 模板演进为“审计材料统一归档中心”,原项目管理流程文档不再作为本应用的主业务文档。
请改为阅读:
- `src/pages/project_manager_xy/model-monitor.business-flow.md`
保留本文件仅用于说明模板来源,避免后续开发继续误引用旧的“项目管理”业务语义。
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import * as UserController from './UserController';
export default {
UserController,
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
{
"extends": "./src/.umi/tsconfig.json"
}
import '@umijs/max/typings';
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