Commit 54ae61f4 authored by drigle's avatar drigle

feat: initialize process management application

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
.swc
.pnpm-store
.DS_Store
/.playwright-cli/
/docs/screenshots/
import { defineConfig } from '@umijs/max';
import path from 'path';
function resolvePackageRoot(packageName: string) {
return path.dirname(require.resolve(`${packageName}/package.json`));
}
const APP_BASE_PATH = '/process_management_new/';
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: '/login', layout: false, component: './Login' },
{ path: '/', wrappers: ['@/wrapper/Site', '@/wrapper/Auth'], component: './app/process_manager/Layout', routes: [
{ path: '/', redirect: '/process_flow' },
{ path: '/process_flow', component: './app/process_manager/ProcessList' },
{ path: '/process_flow/create', component: './app/process_manager/ProcessFlowDetail' },
{ path: '/process_flow/:record', component: './app/process_manager/ProcessFlowDetail' },
{ path: '/flow_graph', component: './app/process_manager/ProcessFlowGraph' },
{ path: '/notifications', component: './notifications/NotificationListPage' },
] },
],
npmClient: 'pnpm',
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',
PROCESS_MANAGEMENT_APP_BASE_PATH: APP_BASE_PATH,
},
});
# 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 和函数组件。
- 样式处理:**优先使用 HeroUI Pro + HeroUI + Tailwind CSS**。
- 交互组件:**优先使用 HeroUI / HeroUI Pro 提供的可访问性交互组件**;仅当 HeroUI 缺少所需能力时,才直接使用 `@headlessui/react`。
- 组件选择优先级(从高到低):
1. `@heroui-pro/react` 中的 HeroUI Pro 组件、区块和页面模板
2. `@heroui/react` 中的 HeroUI 基础组件(Button/Input/Modal/Table/Autocomplete/...)
3. 基于 HeroUI 设计规范和 Tailwind CSS 的自定义组件(仅在 HeroUI / HeroUI Pro 无现成组件时)
4. 原生 HTML + 自写样式(尽量避免)
- **禁止继续使用 Catalyst UI Kit**:后续新增或修改的 UI 不得新增对 `src/catalyst-ui-kit` 的引用;修改已有 Catalyst 页面或组件时,应优先迁移本次变更涉及的部分至 HeroUI / HeroUI Pro。
## 4. 接口请求规范
- 所有的 API 请求应当封装在独立的 service 层或自定义 Hooks 中。
- 严禁硬编码 URL,必须参考 `record-api.md`。
- 必须处理 Loading、Success、Error 三种状态。
## 5. 交互原则
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(HeroUI / HeroUI Pro 强制约束)
- **全项目 UI 统一**:默认使用 HeroUI Pro 与 HeroUI 的组件和样式体系,避免引入其它 UI 框架。
- **HeroUI Pro 优先**:页面布局、复合区块和高级交互优先复用 `@heroui-pro/react`;基础控件使用 `@heroui/react`。
- **一致性**:表单控件优先用 HeroUI 的 `Input`、`Select`、`Checkbox`、`Textarea`,表格用 `Table`,对话框用 `Modal`。
- **可访问性**:移动端侧栏、弹层、下拉和选择器必须使用 HeroUI / HeroUI Pro 的可访问性交互能力(包含焦点管理、Esc 关闭和键盘操作);HeroUI 无法满足时才使用 Headless UI。
- **Catalyst 停用**:不得在新代码中使用 Catalyst UI Kit,也不得为新增功能扩展 `src/catalyst-ui-kit`。
# AI 开发执行规范
## 1. 自动 UI 组件映射逻辑 (UI Mapping)
当 AI 生成前端页面、表单或组件时,**必须**严格按照以下 `Field Type` 映射对应的 UI 组件:
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- |
| **文本 (String)** | HeroUI `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(基于 HeroUI `Autocomplete`) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | HeroUI `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | HeroUI `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | HeroUI `Checkbox` | 映射为 true/false |
| **JSON** | HeroUI `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 节),优先选择 HeroUI Pro / HeroUI 组件。
4. **Step 4**: 自动生成 `onChange` 处理函数,确保数据实时同步到 `metadata` 对象的对应路径下。
---
## 4. 强制约束 (Hard Constraints)
- **禁止硬编码**:Select 类型的选项必须从 `settings.options` 读取,严禁在代码里写死。
- **主键保护**:如果字段定义中 `isPrimaryKey: true`,在编辑模式下该组件必须设为 `disabled`。
- **必填校验**:根据 `isRequired` 自动在 UI 上添加红星标记,并生成相应的 Form Validation 规则。
- **流程文档同步**:如果本次修改涉及业务逻辑、页面交互流程、状态流转、字段规则、审批/操作步骤变化,除了更新代码外,**必须同步补全对应模块的流程文档**;若未找到对应文档,需先告知用户再继续。
---
## 5. 流程文档维护要求
- 开发前先确认本次需求归属的业务模块,并查阅对应流程文档。
- 开发完成后,检查实现结果是否与流程文档一致;如果存在新增、变更或删除的逻辑,必须同步更新文档。
- 流程文档更新内容至少应包含:功能入口、关键交互步骤、状态变化、核心字段/筛选条件、异常处理或限制条件。
- 提交结果时,除了说明改动代码,也要简要说明同步更新了哪个流程文档、补充了哪部分逻辑。
## 业务文档索引
- 流程管理模块 - 请阅读 `/src/pages/app/process_manager/process-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": [] } ] }
# OA 流程监控 UI/UX 全局审计
审计日期:2026-08-11
审计分支:`codex/xinyuan-ui-ux`
约束:只调整 UI/UX,不修改接口、路由、权限、数据结构、表单提交和业务状态机。
## 审计范围
- 登录页、鉴权加载态与验证码。
- 桌面/移动端 Shell、侧栏、导航、应用切换和用户信息。
- 流程监控工作台、分组切换、模板列表及管理弹窗。
- 模板配置、流程实例详情、记录抽屉和引用记录表格。
- 通用筛选、表格、空状态、反馈状态、分页和行操作。
## 迁移前主要问题
1. 品牌身份不统一:登录页为深色营销式布局,Shell 使用深灰侧栏,局部存在 teal、sky、cyan、rose 等页面私有品牌色。
2. Logo 与标题关系不统一:展开侧栏缺少完整鑫元 Logo,折叠态与应用切换按钮尺寸过大,品牌区间距未使用统一 Token。
3. 页面标题装饰过重:工作台使用大面积渐变 Banner,并展示面向开发者的说明文字,不符合企业业务应用的信息层级。
4. 页面切换样式不统一:分组切换使用大卡片,未采用统一的紧凑 Tabs/Segment 视觉。
5. 筛选模块分散:搜索和选择器直接放在标题区域或普通 Card 中,按钮、字段间距、展开区和响应式行为不一致。
6. 列表样式不统一:Catalyst Table 与页面私有表格并存,表头、固定列、行操作、空状态和选择状态缺乏统一语义。
7. 分页不统一:上一页/下一页与页码采用普通文本按钮,当前页、每页条数和总数摘要未使用鑫元分页视觉。
8. 提示信息过多直出:页面内存在开发背景、迁移说明和重复帮助文案,应收敛为标题旁 `info` 小提示,仅在需要时查看。
9. 表面层级不统一:页面存在 22/24/28/32px 多套圆角、彩色渐变、玻璃效果和页面私有阴影。
10. 主题策略已统一为固定浅色内容画布与深色 Sidebar;Overlay 继承根主题变量,不提供页面级主题切换。
## 本轮迁移基线
- 主品牌色统一为 `#DA241A`,禁止将 sky/teal/cyan/green/rose 作为可见品牌色。
- 使用本地完整 HeroUI Pro 镜像及 HeroUI 样式层。
- 同步已验收鑫元 Token、Shell 和组件 CSS。
- 登录页使用鑫元红 Logo、紧凑品牌区、统一输入高度和不透明浅红 filled/autofill 状态。
- Shell 使用深色 Sidebar、紧凑品牌区、中性选中项和无阴影导航表面;主题切换不属于当前基准交互。
- 页面说明收敛为标题旁 `info` 图标;业务状态和错误信息仍保留可见反馈。
- 分组切换改为统一页面 Tabs 视觉;筛选区按统一 Card、字段、按钮和响应式规则治理。
- 分页当前页使用鑫元红实心状态,其余操作保持中性。
- 页面级 Card 使用统一模块圆角、边框和 `--rc-shadow-card`,DataGrid/列表主体不叠加多余阴影。
## 后续验收清单
- 1440px 与 390px 下检查登录、Shell、列表和弹层无横向溢出。
- 检查 Logo 与标题间距、应用切换按钮尺寸、折叠侧栏几何。
- 检查 Tabs 的默认、Hover、选中和键盘 Focus 状态。
- 检查筛选字段、重置/查询/展开按钮和移动端堆叠。
- 检查分页总数、每页条数、当前页、禁用态和页码省略符。
- 检查提示字段仅以 `info` 图标存在,Tooltip 可访问且不遮挡内容。
- 检查浅色/深色主题下 Dropdown、Modal、Tooltip、表格和图表表面。
# OA流程监控 UI 架构
## 运行边界
- Umi Max React 应用,base/publicPath 为 `/oa_process_monitor_new/`。
- `Site` 加载站点配置,`Auth` 维护用户和真实退出,再进入 `AppShell`。
- 应用切换由 `SiteContext` 提供 `/info -> site.group`,按应用 `name` 与当前用户真实可访问应用匹配;分组仅负责展示顺序,不参与权限或跳转决策。
- 服务层继续拥有 API、Subject、payload、动态字段和业务转换;页面持有筛选、页码、选择、权限及回调。
- 单个低频结构化筛选由列表工具栏的 HeroUI Popover 承载,Popover 内继续使用 HeroUI Autocomplete;父浮层保留子 Autocomplete Portal 的交互边界。
## 原生 HeroUI 边界
- 基础组件直接来自 `@heroui/react`,Sidebar/Navbar/DataGrid/KPI/EmptyState 等直接来自 `@heroui-pro/react` 官方子路径。
- 共享组件只做业务无关组合:Shell、应用切换、Autocomplete、顶部页面标题、标题说明和行操作。
- 顶部页面标题由 Navbar 内的 HeroUI `Breadcrumbs` 直接承载;当前项保持原标题字号和深色,前级项与分隔箭头使用 `muted` 灰色并支持点击返回,页面内容区不再挂载第二套页面级 Breadcrumbs。
- 应用切换直接使用 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;Modal Body 是唯一滚动容器,桌面及平板固定两列、移动端一列。
- compound API 使用点语法,Button 使用 `onPress`;不提供 Catalyst/Headless 旧 props 兼容层。
- `XinyuanAutocomplete` 组合官方 Autocomplete、Popover Heading、SearchField 和 ListBox,不定义组件外观。
## CSS 与 Surface
```css
@import "tailwindcss" source(none);
@import "@heroui/styles";
@import "@heroui-pro/react/css";
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
```
- `theme.css` 是技能权威原始副本;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理同步。
- Inter 400/500/600 的拉丁字符字体文件位于 `public/fonts/`,由 `.umirc.ts` 生成的常驻 `inter.css` link 加载,不参与 `umi.css` 热替换。
- `src/tailwind.css` 不导入 `@fontsource/inter/*.css`,并用显式 `@source` 只扫描入口、组件、页面和 wrapper;`src/.umi` 不进入 Tailwind 扫描范围。
- 开发时同一工作区只运行一个 Umi dev 实例,防止多个进程交叉改写 `src/.umi` 形成编译循环。
- 页面 className 只承担 flex、grid、宽度、间距、对齐和响应式布局。
- Surface 层级为 page background -> surface/default -> surface-secondary;overlay 是独立平面。
- 不在页面覆盖 HeroUI slot、BEM、伪元素、颜色、边框、圆角、阴影或交互状态。
## 品牌色与数据可视化
- `src/design-system/theme.css` 中 `--accent`、`--focus`、`--primary` 固定为 `#DA241A`,
由 HeroUI 官方状态变量承接按钮、选中、Hover、Pressed 和 Focus;页面不再声明另一套 Primary 色。
- 图表通过 `--rc-chart-*` 使用统一的品牌红、暖色、中性色和灰蓝色低冲突序列,禁止绿色系和随机彩虹色。
- KPI compound API 的图标 tone 仅引用共享 `--rc-metric-tone-*`,不在页面写入 hex/OKLCH 颜色。
## 列表与反馈
- 流程列表默认每页 20 条,并可通过宽度 128px 的 HeroUI Autocomplete 切换 20/50/100 条,使用 Pro DataGrid 与 HeroUI compound Pagination。
- 列表模块使用基准的透明三层结构:独立数据集标题行、`rc-page-toolbar` 命令行、`rc-list-data-stack` 数据与分页;不使用 Card 或页面外观把三层框在一起。
- DataGrid 列表外层使用 `rc-data-table-sticky`,滚动容器使用 `data-table-scroll`;横向 overscroll 在表格内消化,纵向 overscroll 允许向 `app-shell-content-scroll` 传递,滚到数据首尾后可继续浏览页面分页区域。
- 搜索位于左侧,业务操作在右,主操作最右;移动端搜索独占行。
- 成功反馈使用 HeroUI toast;需要用户处理的错误保留上下文 Alert。
- 查看详情使用单一 secondary Surface,编辑/确认使用 Modal 或 AlertDialog。
## 数据保护
- `metadata.progresses` 与 `metadata.related_progress` 继续只保存 ID。
- `fields_definition` 继续兼容数组/对象映射并驱动动态列。
- 附件继续先调用 `sync_progress:get_attachment_link`。
- 登录参数、验证码、密码加密、logout、权限、保存回调、请求时机和分页协议保持不变。
## 通知中心架构
- `src/pages/notifications/NotificationListPage.jsx` 只负责通知列表状态和 HeroUI 交互;
`notificationService.js` 负责通知 API、状态判断、动作调用和 base-aware 跳转。
- 真实未读数由 `GET /notice/customer/count` 注入 AppShell/Navbar;通知操作派发
`oa-process-monitor:notification-state` 事件,Shell 监听该事件并每 60 秒轮询刷新。
- `POST /notice/customer/query` 使用 `{ page, page_size, filter, status }`,消息/待办由
`filter.category` 区分,未读通过 `status: "unread"`;读取使用
`PUT /notice/customer/read`,动态 action 使用
`PUT /notice/customer/:action/action/:fname` 和 `{ notice_id }`。
- 站内链接仅剥离 `/oa_process_monitor_new/` 后交给 Umi history;其他应用路径与 HTTP(S)
链接在新窗口打开。通知文本不作为 HTML 插入 DOM。
- Sidebar 不显示通知菜单项,通知中心是 Navbar 全局动作;通知失败使用 HeroUI Toast/Alert
反馈,不改变原有登录、退出和业务页面请求。
# OA流程监控 UI 迁移清单
状态:`[ ]` 未完成,`[~]` 受外部条件限制,`[x]` 完成,`[N/A]` 不适用。
## 依赖与基线
- [x] 官方 hpsetup 在目标根目录安装成功
- [x] Pro 版本、解析路径、dist 和要求子路径验证
- [x] 移除跨项目 `file:` 依赖,统一 pnpm 锁文件
- [x] 启动修改前目标应用与审计项目管理基准并记录截图/几何
- [x] 权威 `theme.css` 与技能副本逐字节一致
- [x] `tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理当前基准一致
## 架构与 Shell
- [x] 活动路由 Catalyst/Headless UI/旧组件引用为零
- [x] 可见原生 select/date/file 控件为零
- [x] 页面私有视觉 CSS 为零
- [x] 登录、Sidebar 真 offcanvas、Navbar 与 48/32/16 gutter
- [x] 用户 Dropdown 与原有真实 logout 回调
- [x] HeroUI Modal 应用切换、`site.group` 动态分组、真实数量、两列卡片、identity well 和唯一当前状态
## 页面
- [x] `/process_monitor`:分组、筛选、KPI、DataGrid、分页和管理弹窗
- [x] `/process_monitor/:processId`:动态列、搜索、DataGrid、选择配置、字段 Modal
- [x] `/process_monitor/:processId/:application`:导航、单一 secondary Surface、动态字段和附件入口
- [x] Loading/Success/Error、空状态、权限与管理操作状态保留
- [x] 同步更新 `process-monitor.business-flow.md`
- [N/A] 图表:目标应用没有图表业务页面
## 白名单功能范围
- [x] OA流程监控按跨应用白名单仅实施功能 4:消息通知
- [N/A] Grid/Detail shortTitle:非六个完整功能应用,明确不实施
- [N/A] Subject View 授权:非六个完整功能应用,明确不实施
- [N/A] 审计材料中心文件流程:非六个完整功能应用,明确不实施
## 消息通知
- [x] `/notifications` 独立路由和应用 base-aware 导航
- [x] Navbar `Badge.Anchor + Badge + Button + Tooltip`,真实未读数和 99+ 上限
- [x] 消息/待办、全部/未读、300ms 转义搜索、固定 10 条分页
- [x] 单条已读、全部已读、动态 action 和 `{ notice_id }` 请求体
- [x] 站内链接剥离自身 base,外部链接新窗口,通知内容纯文本渲染
- [x] 状态事件刷新和 60 秒未读轮询
## 浏览器验收
- [x] 1440x900 浅色/深色目标与基准对照
- [x] 1280x720 目标与基准对照
- [x] 768x1024 目标与基准对照
- [x] 390x844 浅色/深色目标与基准对照
- [x] 登录、Sidebar、Navbar、应用切换、Modal、Autocomplete 打开态和空列表
- [x] 干净退出与应用切换会话控制台 0 error / 0 warning
- [x] Autocomplete、Modal、应用切换和移动 Sidebar 对话框可访问名称
- [~] populated DataGrid、Checkbox 半选、ActionBar、真实记录 CRUD、实例详情和附件:缺少有效业务会话;不属于本次通知白名单范围
## 最终门槛
- [x] `npm run build`
- [x] `git diff --check`
- [x] Xinyuan `audit_project.sh`
- [x] 旧组件引用扫描
- [x] 页面私有视觉 CSS 扫描
- [x] `file:` 与本机路径扫描
- [x] HeroUI/React Aria 依赖树检查
- [x] HeroUI Pro 子路径检查
# OA流程监控鑫元设计系统
## 权威来源
- 主题唯一权威为 Xinyuan Skill 的 `assets/baseline/design-system/theme.css`,项目副本必须逐字节一致。
- 视觉和交互唯一运行基准为已确认完成的“审计项目管理”应用。
- 页面不得自定义 HeroUI/HeroUI Pro 的颜色、背景、描边、圆角、阴影或交互状态。
## 品牌与图表配色(2026-09)
- 鑫元唯一 Primary Brand Color 为 `#DA241A`。HeroUI 的 `accent`、`focus`、`primary`
及 `rc-brand-primary` 均固定到该值;Hover 使用 `#C91F17`,Pressed 使用 `#B51B14`,
Disabled 使用低透明度品牌红。浅色和深色模式不得由 HeroUI 默认主题改写品牌色。
- 图表只使用 `--xinyuan-chart-1` 到 `--xinyuan-chart-16` / `--rc-chart-*` Token:
`#DA241A, #B83A32, #C95A52, #D47A70, #A65A45, #B87958, #C99772,
#6F6674, #807783, #938B96, #4F6380, #61758F, #74879A, #8A6F63,
#9A8174, #5F5A61`。
- 色板按品牌红深浅、暖色、中性色、灰蓝色排序,禁止绿色、青色、荧光色和彩虹式高冲突相邻配色。
单系列图表使用 `--rc-chart-primary`;同一指标使用同一语义 Token,分类数量增加时按稳定业务 key 分配颜色。
- KPI 图标色也从同一克制色板取值,Card 主体保持中性,不以页面私有颜色覆盖共享 Token。
## 字体与样式入口
- `.umirc.ts` 通过常驻 `<link>` 加载 `public/fonts/inter.css`,字体文件固定为 Inter 400/500/600 拉丁字符子集。
- `src/tailwind.css` 禁止导入 `@fontsource/inter/*.css`,避免 Umi 开发时替换 `umi.css` 导致 Inter 卸载、回退和重载。
- Tailwind v4 使用 `source(none)` 和活动源码目录的显式 `@source`;不得扫描 Umi 生成目录 `src/.umi`,避免生成文件与 CSS 编译相互触发 HMR。
- 同一项目工作区只运行一个 Umi dev 实例,避免多个进程同时写入 `src/.umi`。
## 组件边界
- 基础组件直接从 `@heroui/react` 或其官方子路径导入。
- Sidebar、Navbar、DataGrid、ActionBar、EmptyState 等直接从 `@heroui-pro/react` 官方子路径导入。
- compound API 使用点语法;Button 交互使用 `onPress`。
- 禁止 Catalyst、Headless UI、可见原生 select/date/file、旧 props 兼容包装层和手写 HeroUI 仿制组件。
- 共享组件只能组合官方组件、业务无关布局和可访问性,不得重绘组件外观。
## Shell
- Sidebar 宽 240px,`collapsible="offcanvas"`,收起后宽度为零且无 icon rail。
- Navbar 高 64px,直接使用 HeroUI Pro `Sidebar.Trigger` 默认图形。
- Navbar 和页面共用左右 gutter:桌面 48px、平板 32px、手机 16px。
- Navbar 标题本身使用 HeroUI `Breadcrumbs` 作为唯一页面级导航:当前层级沿用原 Navbar 标题的字号、字重、行高和深色;前级层级与分隔箭头使用 `muted` 灰色,前级节点必须可点击返回对应路由;内容区不得再重复渲染页面级面包屑或返回导航。
- Sidebar 用户入口通过 `Dropdown.Trigger` 打开菜单,退出必须调用现有真实 logout。
- 应用切换使用 HeroUI Modal,桌面宽 46rem/最大高 42rem,手机一列、`sm` 起固定两列;分组来自 `/info -> site.group`,保留后端顺序并提供“其他应用/全部应用”回退。
## Surface 与布局
- L0 page:`background`;L1 内容:`surface`;L2 嵌套:`surface-secondary`;L3 仅用于 L2 内罕见静态内容。
- Modal/Dropdown/Popover/Tooltip 使用独立 `overlay`,不是 L3/L4。
- 三个以上筛选字段使用 `Surface variant="default"`;内部字段统一 `secondary`;一到两个字段不额外套 Surface。
- 一到两个结构化筛选字段与列表标题、工具栏、DataGrid 和 Pagination 归入同一个列表堆栈;少量高频字段使用紧凑行内筛选,单个低频字段可由列表工具栏的 HeroUI Popover 按需展开,不单独占用 Surface。
- 列表标题、工具栏与 Pagination 透明无框;DataGrid 自己拥有 20px 数据 Surface。
- 长列表使用 DataGrid 的 `data-table-scroll` 滚动容器:横向滚动保留在表格内,纵向使用
`overscroll-behavior-y: auto`。滚到首行或末行后继续滚动会自然交给页面容器,确保顶部工具栏和底部分页始终可达。
- 页面一级模块间距 32px,普通模块 24px,紧密列表/KPI/工具栏 16px;移动端分别收敛为 24px/16px。
## 控件
- 下拉型选择使用 HeroUI Autocomplete compound API,打开态使用官方 Popover/SearchField/ListBox。
- 日期使用 HeroUI DatePicker/DateField/Calendar;文件使用 HeroUI Pro DropZone。
- 普通业务文字 Button 不带装饰图标;主操作在最右。
- 详情/表单使用 HeroUI Modal;查看态整组字段放一个 secondary Surface,编辑态移除该 Surface。
- DataGrid 使用原生 selection、排序、固定列、横向滚动、ActionBar、EmptyState 和 compound Pagination。
- 流程业务列表默认每页 20 条,分页条使用宽度 128px 的 HeroUI Autocomplete 提供 20/50/100 条选择;通知中心仍固定每页 10 条。
## 主题与资产
- 浅色使用 `xy-logo-red.png`,深色使用 `xy-logo-white.png`,不滤镜、不反色、不替换 RSM 标识。
- 根元素持有 `.light/.dark` 与匹配的 `data-theme`,所有 Portal 继承根主题。
- 组件外观只来自 HeroUI CSS、Pro CSS、权威 theme.css 和批准的共享 Tokens。
## 通知中心
- `OA流程监控` 不在六个完整功能应用白名单内,本项目只实施消息通知,不加入
`shortTitle`、Subject View 授权或审计材料中心文件流程。
- 通知页路由为 `/notifications`,沿用应用 base `/oa_process_monitor_new/`。
- Navbar 使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,Ant Design
`BellOutlined` 为 Navbar 全局动作;未读数为 0 时隐藏徽标,1-99 显示真实值,100 及以上显示
`99+`,可访问名称保留真实数量。
- 通知页使用 HeroUI Tabs、SearchField、Card、Chip、Pagination、Toast 和 HeroUI Pro
EmptyState。查询固定每页 10 条,搜索延迟 300ms 并转义正则字符,未读状态通过服务端
`status: "unread"` 查询。
- 单条已读、全部已读和服务端动态待办 action 继续使用平台接口;action 请求体只传
`{ notice_id }`。通知内容按纯文本渲染,禁止执行服务端 HTML。
# OA流程监控鑫元 UI 审计
审计日期:2026-09-02
## 范围与基准
- 目标仓库:`oa_process_monitor`,分支 `codex/xinyuan-ui-ux`。
- 整体实现基准:本机 `audit_project_manager` 的实际代码与运行页面;其共享 Shell 和应用切换结构为唯一验收基准。
- 路由基址:`/oa_process_monitor_new/`。
- 活动路由:`/login`、`/process_monitor`、`/notifications`、`/process_monitor/:processId`、`/process_monitor/:processId/:application`。
- 业务依据:`src/services/record-api.md`、`src/services/subjectsDefinition.md`、`src/pages/process_monitor/process-monitor.business-flow.md`。
## 安装与依赖结论
- 实际执行目录:`/Users/liuchenlong/Desktop/job/oa_process_monitor`。
- 已在该目录成功执行用户指定的官方 `pnpm dlx hpsetup@latest` 授权安装命令;授权值未写入源码、文档、环境文件或 Git 提交。
- `@heroui-pro/react@1.0.0-beta.8` 解析到 `/Users/liuchenlong/Desktop/job/oa_process_monitor/node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8/node_modules/@heroui-pro/react/package.json`。
- `@heroui/react@3.2.4`、`@heroui/styles@3.2.4` 使用正式版本,并与审计项目管理当前运行依赖一致,不再存在跨项目 `file:` 依赖。
- 本次同步保留 HeroUI Pro 正式安装;新增 ECharts 依赖,`recharts` 仅作为 HeroUI Pro 图表运行时 peer 依赖保留,锁文件由 pnpm 更新。
- `package.json` 写入正式版本依赖及 Pro 运行依赖;`package-lock.json` 已移除,`pnpm-lock.yaml` 已更新并作为唯一锁文件保留,Umi 的 `npmClient` 已切换为 `pnpm`。
- `@heroui-pro/react/css`、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state` 均通过 ESM 导入检查,CSS 解析目标文件存在。
- 监控分组 Tabs 使用审计项目管理相同的 compound 布局合同;同步 HeroUI 3.2.4 后,Tabs 根轨道与各标签均按内容宽度收敛,不再均分或占满页面剩余宽度。
- Catalyst、Headless UI、Heroicons 及旧组件实现引用扫描为零;OA 当前没有业务图表页面,后续图表只允许 ECharts + `echarts-for-react`,现有 `recharts` 不被业务代码直接使用。
## 修改前状态
- 修改前目标与基准均已启动并记录 1440x900 登录页截图。
- 目标登录 Card 为 448x522.8,基准为 448x488.8;目标字段根高 76,基准为 64。
- 目标缺少权威主题、Inter 字体和统一 Surface;活动页混用 Catalyst、Headless UI、自制筛选/分页及 ECharts。
- Sidebar、Navbar、应用切换、列表和弹窗的结构与审计项目管理不一致。
## 迁移后审计结论
- 登录、Shell、Sidebar、Navbar 和用户退出菜单使用审计项目管理共享结构;应用切换同步其 HeroUI Modal 几何、动态分组、真实数量和两列卡片结构。
- 应用切换消费现有 `SiteContext` 的 `/info -> site.group`,按应用名称匹配真实可访问应用,保持分组与组内顺序,并保留原有应用获取、合并、当前应用判断及跳转逻辑。
- 三个流程监控页面直接使用 HeroUI/HeroUI Pro;KPI 使用 Pro KPI,列表使用 Pro DataGrid,空状态使用 Pro EmptyState。
- 筛选使用 HeroUI SearchField/Autocomplete,弹窗使用 Modal/AlertDialog,分页固定 10 条并使用 compound Pagination。
- 详情查看态只保留一个 `Surface secondary`;页面不再定义组件背景、颜色、描边、圆角、阴影或交互状态。
- 删除不可达的 Catalyst、RecordDetailDrawer、record-workbench、FilterSelect 和旧组件样式文件,旧组件引用扫描为零。
- `theme.css` 沿用技能权威结构,并按本次鑫元品牌专项将 HeroUI 主色固定为 `#DA241A`;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理基准一致。
- 本应用无业务图表页面,图表迁移和 Tooltip 验收标记为不适用,不新增虚构图表。
- 共享图表 Token 已专项收敛为鑫元红及低饱和暖色、中性色、灰蓝色序列;不存在绿色或彩虹式默认系列。
- HeroUI `accent/focus/primary` 和共享 `rc-brand-primary` 已固定为 `#DA241A`,浅色/深色模式均保持一致。
## 本次共享同步
- AppShell 固定 `.light`,移除主题 state、localStorage 和 Sun/Moon 按钮。
- Sidebar 对齐基准 Disclosure 分组、深色白 Logo、offcanvas 结构和无 Tooltip 应用切换按钮。
- ApplicationSwitcherDialog 使用 Pro ItemCard/ItemCardGroup、SearchField、ScrollShadow 和动态分组,保留 OA 自身 basePath、应用授权和跳转逻辑。
## 业务保护
- 未改变路由、Subject、API URL、请求方法、payload、metadata 字段或 Reference ID-only 协议。
- 未改变模板同步、分组回写、取消监控、动态列生成、附件链接获取、权限判定、保存回调或请求时机。
- 流程列表默认每页 20 条,并新增 128px HeroUI Autocomplete 条数选择器(20/50/100);查询仍使用原有 `page` / `page_size` 协议。
- 401 仍在相同响应时机清理登录态并跳转登录页,只修正为带 Umi base 的应用内地址。
## 通知中心审计
- 按跨应用白名单判定,OA流程监控仅新增通知中心;没有新增 shortTitle、视图权限或材料中心
文件服务/路由。
- 目标与审计项目管理均以 Andy 真实账号登录,在 1280x720 下打开 `/notifications` 对照,Shell、
Navbar Bell、Tabs、搜索、空状态和间距一致;目标真实接口返回 0 条通知。
- 浏览器内路由拦截覆盖了 0/1/99/100 未读数、消息/待办、未读查询、正则转义搜索、分页、
单条已读、全部已读、动态 action、站内/外部跳转和纯文本 HTML 渲染。拦截只存在于 Playwright
会话,不写入仓库或服务端数据。
- 通知 API 实测:`POST /api/notice/customer/query`、`GET /api/notice/customer/count` 均
`200`;动态 action 使用 `PUT /api/notice/customer/approval/action/approve`,请求体为
`{ "notice_id": "..." }`;控制台 0 error / 0 warning。
- 目标与审计项目管理通知页已在 1440x900、1280x720、768x1024、390x844 的浅色/深色验证,
均无页面横向溢出。1280x720 两边使用真实登录和真实 0 条通知;其余基准视口在验证码会话失效后
使用浏览器内只读认证/空通知响应夹具,只验证视觉结构,不写入仓库或服务端。截图均保存在
`/tmp`,未纳入版本控制。
## 浏览器证据与限制
- 已覆盖 1440x900、1280x720、768x1024、390x844;包含浅色、深色、Sidebar offcanvas、移动 Sidebar、应用切换和创建分组 Modal。
- 2026-09-02 应用切换专项复验按审计项目管理基准检查四个视口:弹窗位置、宽高、24px 圆角、28px 内边距、Header/Body 起点、分组顶点、两列/单列断点及卡片尺寸保持一致。
- 浏览器层覆盖动态分组顺序、组内顺序、空分组隐藏、未归组应用进入“其他应用”、无分组回退“全部应用”、真实数量、唯一“当前”、长列表 Body-only 滚动、失败/透明/缺失 Logo 稳定 identity well,以及 Escape、遮罩、关闭按钮和焦点顺序。
- 最终目标与基准应用均已在 1440x900、1280x720、768x1024、390x844 的浅色和深色模式完成本地截图比对;验收截图仅作本地临时证据,不纳入版本控制。
- 另记录 `final-production-app-switcher-1440x900-light.png`、`final-production-app-switcher-1440x900-three-apps.png` 和移动 Sidebar 截图;在相同视口逐项比较登录、Shell、页面 gutter、Surface、KPI、筛选、空 DataGrid、Pagination 和 overlay 几何。
- 干净退出会话与应用切换会话的浏览器控制台均为 0 error / 0 warning;Autocomplete、Modal、应用切换和移动 Sidebar 的可访问名称已通过生产预览快照确认。
- 未提供可访问真实业务数据的有效认证会话。验收使用用户给出的真实用户结构,但业务 API 只返回明确错误/空响应,未伪造业务记录或指标。
- 因此 populated DataGrid、选中/半选 Checkbox、ActionBar、真实记录编辑/删除、实例详情和真实附件仍需在有效业务会话中复验,不能据此宣称整项迁移完成。
## 最终门槛结果
- `npm run build`:通过,Webpack 编译成功。
- `git diff --check`:通过。
- Xinyuan `audit_project.sh`:退出码 0;主题、旧组件引用和四份必需文档检查通过。脚本基于 CommonJS/CSS 加载的 Pro 子路径提示属于检查器误报,独立 ESM 导入检查全部通过。
- 旧组件、原生可见控件、页面私有视觉 CSS、页面局部 CSS/LESS、`file:`/本机路径依赖扫描:均为零。
- `theme.css` 与 Skill 权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理基准一致。
- HeroUI/React Aria 依赖树和 Pro 子路径检查:通过;目标仓库不存在相邻项目依赖、软链接或本机 vendor 替代包。
- `pnpm list` 显示应用运行层统一为 React/ReactDOM 19.2.8、HeroUI 3.2.4、React Aria
Components 1.20.0;完整 `npm ls` 仍报告 Umi/Ant Design 传递依赖声明的旧 React peer 警告,
属于本次功能前已存在的依赖元数据风险,本次未更改该依赖树。
{
"private": true,
"scripts": {
"build": "max build",
"dev": "max dev",
"postinstall": "max setup",
"setup": "max setup",
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@fontsource/inter": "5.3.0",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "3.2.4",
"@heroui/styles": "3.2.4",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@react-stately/utils": "3.12.1",
"@tailwindcss/postcss": "^4.2.0",
"@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.5.3",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^13.1.0",
"react": "^19.2.8",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"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.3.1",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5",
"reactflow": "^11.11.4",
"dagre": "^0.8.5",
"zustand": "^4.5.7",
"nanoid": "^5.1.5",
"html-to-image": "1.11.11",
"lodash": "^4.17.21",
"ahooks": "^3.9.0",
"numeral": "^2.0.6"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"typescript": "^5.0.3"
}
}
This diff is collapsed.
allowBuilds:
core-js: false
core-js-pure: false
es5-ext: false
esbuild: false
"@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": "oa_process_monitor",
"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 '@/global.less';
import '@/tailwind.css';
import { I18nProvider } from '@heroui/react';
import { createElement } from 'react';
import { defineApp } from 'umi';
export async function getInitialState() {
return {
name: 'process_management_new',
};
}
export default defineApp({
rootContainer(container) {
return createElement(I18nProvider, { locale: 'zh-CN' }, container);
},
});
<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 BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import ControlOutlined from '@ant-design/icons/ControlOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import ProjectOutlined from '@ant-design/icons/ProjectOutlined';
import ReloadOutlined from '@ant-design/icons/ReloadOutlined';
import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
<Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />
);
};
}
export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const FilterControls = withSize(ControlOutlined);
export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined);
export const Edit = withSize(EditOutlined);
export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
style={iconStyle(size, style)}
className={['anticon', props.className].filter(Boolean).join(' ')}
>
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable="false"
aria-hidden="true"
>
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
export const More = withSize(MoreOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
export const CalendarDaysIcon = Calendar;
export const ChartBarSquareIcon = BarChart;
export const ChartPieIcon = withSize(PieChartOutlined);
export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const UserGroupIcon = Team;
// Business aliases retained at the page boundary; all resolve to the shared Ant icon catalog.
export const ArrowLeft = Back;
export const DataStore = Database;
export const External = OpenExternal;
export const FieldTime = Clock;
export const Filter = Group;
export const FolderOpen = Folder;
export const ProcessList = ClipboardDocumentListIcon;
export const Reload = ArrowPathIcon;
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,
breadcrumbItems,
title,
...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
}
breadcrumbItems={breadcrumbItems}
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>
);
}
This diff is collapsed.
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Breadcrumbs, Button, Tooltip } from '@heroui/react';
import { Bell, ChevronRight, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
breadcrumbItems = [],
title,
}) {
const { isMobile, isOpen } = useSidebar();
const normalizedNotificationCount = 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>
<Breadcrumbs
aria-label="当前页面"
className="app-navbar-page-title rc-navbar-title min-w-0"
separator={<ChevronRight size={16} />}
>
{(breadcrumbItems.length ? breadcrumbItems : [{ label: title }]).map((item, index, items) => (
<Breadcrumbs.Item
className={index === items.length - 1 ? 'rc-navbar-breadcrumb-current' : undefined}
href={index === items.length - 1 ? undefined : item.href}
key={`${item.href || 'current'}-${item.label}`}
>
{item.label}
</Breadcrumbs.Item>
))}
</Breadcrumbs>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
<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>
{normalizedNotificationCount > 0 ? (
<Badge
aria-label={`${normalizedNotificationCount}条未读通知`}
color="danger"
size="sm"
>
{normalizedNotificationCount > 99
? '99+'
: normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Sheet } from '@heroui-pro/react';
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 || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
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) => {
if (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={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>
);
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">
{APPLICATION_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) => {
if (group.showLabel === false) {
return <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>;
}
const hasCurrentItem = group.items.some((item) => 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>
);
})}
</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={`${APPLICATION_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 { ArrowLeft, ChevronRight } from '@/components/AppIcons';
import { Breadcrumbs, Button } from '@heroui/react';
import { useNavigate } from 'umi';
export default function DetailNavigation({
backHref,
backLabel,
items,
currentLabel,
}) {
const navigate = useNavigate();
return (
<nav className="rc-detail-navigation flex min-w-0 items-center gap-4" aria-label="详情页导航">
<Button
isIconOnly
aria-label={backLabel}
className="rc-detail-navigation__back shrink-0"
size="lg"
variant="outline"
onPress={() => navigate(backHref)}
>
<ArrowLeft size={16} />
</Button>
<Breadcrumbs
className="rc-detail-navigation__breadcrumbs min-w-0"
separator={<ChevronRight size={16} />}
>
{items.map((item) => (
<Breadcrumbs.Item className="no-underline" href={item.href} key={`${item.href}-${item.label}`}>
{item.label}
</Breadcrumbs.Item>
))}
<Breadcrumbs.Item>{currentLabel}</Breadcrumbs.Item>
</Breadcrumbs>
</nav>
);
}
import {
buildDepartmentLookup,
buildMemberLookup,
downloadProcessAttachment,
formatDateTime,
getDisplayFieldMap,
} from '@/pages/process_monitor/processMonitorService';
import { Button, Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { FileText } from '@/components/AppIcons';
import { useMemo } from 'react';
function resolveDetailValue(field, fieldMap, lookups) {
if (field?.type === 'file') {
const files = Array.isArray(field?.value) ? field.value : [];
if (!files.length) return <span className="text-muted">暂无内容</span>;
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
}
const fieldDefinition = fieldMap[field?.label];
if (fieldDefinition?.type === 'member') {
const value = String(field?.value || '').split(',').map((item) => item.trim()).filter(Boolean).map((item) => lookups.memberLookup[item] || item).join('、');
return value || <span className="text-muted">暂无内容</span>;
}
if (fieldDefinition?.type === 'department') {
const value = String(field?.value || '').split(',').map((item) => item.trim()).filter(Boolean).map((item) => lookups.departmentLookup[item] || item).join('、');
return value || <span className="text-muted">暂无内容</span>;
}
if (fieldDefinition?.type === 'date') return formatDateTime(field?.value, 'YYYY-MM-DD HH:mm');
return field?.value || <span className="text-muted">暂无内容</span>;
}
function DetailField({ label, children, fullWidth = false }) {
return <div className={fullWidth ? 'min-w-0 sm:col-span-2' : 'min-w-0'}><div className="text-sm font-medium text-muted">{label}</div><div className="mt-1.5 min-w-0 text-sm text-foreground">{children || <span className="text-muted">暂无内容</span>}</div></div>;
}
export default function ProcessInstanceDetailContent({ data, loading = false, withSurface = false }) {
const record = data?.record;
const fieldsDefinition = data?.fieldsDefinition || [];
const members = data?.members || [];
const departments = data?.departments || [];
const lookups = useMemo(() => ({ memberLookup: buildMemberLookup(members), departmentLookup: buildDepartmentLookup(departments) }), [departments, members]);
const fieldMap = useMemo(() => getDisplayFieldMap(fieldsDefinition), [fieldsDefinition]);
const details = useMemo(() => record?.metadata?.detail || [], [record]);
const relatedTemplateTitle = record?.metadata?.related_progress?.title || '-';
const relatedCondition = record?.metadata?.related_progress?.metadata?.conditions;
if (loading) return <div className="py-8 text-sm text-muted">正在加载字段详情...</div>;
if (!record) return <EmptyState size="sm"><EmptyState.Header><EmptyState.Media variant="icon"><FileText size={20} /></EmptyState.Media><EmptyState.Title>暂无详情</EmptyState.Title></EmptyState.Header></EmptyState>;
const fieldGrid = <div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
<DetailField label="提交流程模板">{relatedTemplateTitle}</DetailField>
<DetailField label="提交时间">{formatDateTime(record?.metadata?.submit_at, 'YYYY-MM-DD HH:mm')}</DetailField>
<DetailField label="提交者">{record?.metadata?.submitter || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="审批者">{record?.metadata?.approver || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="所属部门">{record?.metadata?.department || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="显示条件">{relatedCondition?.key ? `${relatedCondition.key} = ${relatedCondition.value || '-'}` : '未配置'}</DetailField>
{details.map((field, index) => <DetailField key={`${field.label}-${index}`} label={field.label || `字段 ${index + 1}`} fullWidth={field?.type === 'file' || String(field?.value || '').length > 80}>{resolveDetailValue(field, fieldMap, lookups)}</DetailField>)}
</div>;
return withSurface ? <Surface className="rounded-2xl px-5 py-5 sm:px-6 sm:py-6" variant="secondary">{fieldGrid}</Surface> : fieldGrid;
}
.content{
z-index: 0;
max-width:750px;
padding: 30px 0px;
margin:0 auto;
position:relative;
.block{
position:relative;
margin: 1em 0;
line-height: 1.8;
table {
br{
display: none;
}
}
.mask{
transition: background-color 0.5s;
position:absolute;
left:30px;
right:30px;
top:0px;
bottom:0px;
}
.tool{
display:none;
position:absolute;
top:0;
right:0;
user-select:none;
}
&:hover{
.mask{
background-color: rgb(228, 221, 235);
}
.tool{
display:block;
}
}
}
}
.commentList{
background-color:white;
border-radius:4px;
max-height:calc(100vh - 80px);
overflow:auto;
}
import React from "react";
import { BezierEdge } from "reactflow";
import { theme } from "@/components/process-ui/ProcessUi";
const { useToken } = theme;
export default function GraphEdge(props) {
const { style, selected, data } = props;
const { token } = useToken();
const { active } = data || {};
return (
<>
<BezierEdge
{...props}
style={{
stroke: selected ? token.colorPrimaryBgHover : "none",
strokeWidth: 8,
}}
/>
<BezierEdge
{...props}
style={{
...style,
stroke: selected || active ? token.colorPrimary : token.colorBorder,
strokeWidth: 2,
}}
/>
</>
);
}
import { Button, Tooltip } from '@heroui/react';
import clsx from 'clsx';
export function TableActionGroup({ children, className }) {
return (
<div className={clsx('icon-actions flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap', className)}>
{children}
</div>
);
}
export function TableActionButton({ icon: Icon, label, onPress, tone = 'default' }) {
return (
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={label}
className={clsx('pm-table-action shrink-0', `pm-table-action--${tone}`)}
size="sm"
title={label}
variant={tone === 'danger' ? 'danger-soft' : 'tertiary'}
onPress={onPress}
>
<Icon size={16} />
</Button>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip>
);
}
import {
ChevronDown,
Check,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
Popover,
SearchField,
} from '@heroui/react';
function filterOption(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
}
function toSelectionKey(value) {
return String(value ?? '');
}
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
className,
emptyText = '暂无可选项',
fullWidth = true,
isDisabled = false,
onSelectionChange,
options = [],
placeholder = '请选择',
searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey,
selectedKeys,
selectionMode = 'single',
valueText: valueTextOverride,
variant = 'secondary',
}) {
const selectedKeySet = getSelectedKeySet(
selectionMode,
selectedKey,
selectedKeys,
);
const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey),
);
const selectedOptions = options.filter((option) =>
selectedKeyStringSet.has(toSelectionKey(option.id)),
);
const valueText = valueTextOverride ?? (selectedOptions.length
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const listBoxSelectionProps =
selectionMode === 'multiple'
? {
selectionMode,
selectedKeys,
}
: {};
const handleMultipleOptionPress = (option) => {
if (
selectionMode !== 'multiple' ||
!onSelectionChange ||
option?.isDisabled
) {
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 || placeholder}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
selectionMode={selectionMode}
variant={variant}
onSelectionChange={onSelectionChange}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span className="block min-w-0 truncate">
{valueText}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover aria-label={ariaLabel || placeholder} placement="bottom start">
<Popover.Heading className="sr-only">
{ariaLabel || placeholder}
</Popover.Heading>
<Autocomplete.Filter filter={showSearch ? filterOption : () => true}>
{showSearch ? (
<SearchField aria-label={searchPlaceholder} fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
) : null}
<ListBox
aria-label={ariaLabel || placeholder}
items={options}
{...listBoxSelectionProps}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4 text-sm text-muted">
{emptyText}
</div>
)}
>
{(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(option.id));
return (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
{...(selectionMode === 'multiple'
? { onPress: () => handleMultipleOptionPress(option) }
: {})}
>
<Label className="truncate">{option.label}</Label>
{selectionMode === 'multiple' ? (
<span
aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground"
>
{isSelected ? <Check size={14} /> : null}
</span>
) : (
<ListBox.ItemIndicator />
)}
</ListBox.Item>
);
}}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import { Info } from '@/components/AppIcons';
import { Button, Tooltip } from '@heroui/react';
export function TitleInfo({ label, content }) {
if (!content) return null;
return (
<Tooltip delay={0}>
<Tooltip.Trigger
render={(triggerProps) => (
<Button
{...triggerProps}
isIconOnly
aria-label={`${label}说明`}
className="rc-title-info-trigger"
size="sm"
variant="ghost"
>
<Info size={15} />
</Button>
)}
/>
<Tooltip.Content>{content}</Tooltip.Content>
</Tooltip>
);
}
import { memo } from "react";
import { Handle, Position } from "reactflow";
import { theme } from "@/components/process-ui/ProcessUi";
const { useToken } = theme;
export default memo((props) => {
const { id, type = "source", active = false, style, ...rest } = props;
const { token } = useToken();
return (
<Handle
id={id}
type={type}
position={type === "source" ? Position.Right : Position.Left}
style={{
position: "absolute",
transform: "none",
left: type === "target" ? -12 : "auto",
right: type === "source" ? -12 : "auto",
width: 12,
height: "auto",
top: 0,
bottom: 36,
backgroundColor: active ? token.colorPrimary : token.colorPrimaryBorder,
borderRadius:
type === "source"
? `0 ${token.borderRadius}px ${token.borderRadius}px 0`
: `${token.borderRadius}px 0 0 ${token.borderRadius}px`,
border: "none",
...style,
}}
{...rest}
/>
);
});
import { useAuthorization } from "@/hooks/user";
import useStore from "@/pages/app/process_manager/process_store.js";
import {
createRecord,
removeRecord,
updateRecord,
} from "@/services/DataService";
import { CopyOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons";
import {
Button,
Card,
message,
Popconfirm,
theme,
Tooltip,
} from "@/components/process-ui/ProcessUi";
import { memo } from "react";
import { history } from "umi";
import { useProcessParams as useParams } from "@/hooks/useProcessParams";
import FlowHandle from "@/components/flow/components/FlowHandle.jsx";
import styles from "@/components/flow/nodes/ProcessNode.less";
const { useToken } = theme;
export default memo((props) => {
const params = useParams();
const { token } = useToken();
const { admin } = useAuthorization();
const { id, data = {}, selected } = props;
const processSubject = "process_flow";
const {
setNodes,
setEdges,
nodes,
edges,
currentGroup,
removeNode,
setCurrentProcess,
setCurrentGroup,
flowRef,
parseGraph,
} = useStore((state) => state);
const copyProcess = async () => {
const res = await createRecord({
subject: processSubject,
title: data?.title,
metadata: data?.metadata,
});
const copy_node = {
...props,
id: res._id,
data: {
...data,
name: res.name,
_id: res._id,
},
selected: false,
};
await updateRecord(currentGroup?.name, {
metadata: {
process_list: [
...(currentGroup?.metadata?.process_list?.map((v) => v?._id) ?? []),
res?._id,
],
},
});
setCurrentGroup({
...currentGroup,
metadata: {
process_list: [...currentGroup?.metadata?.process_list, res],
},
});
const graph = parseGraph([...currentGroup?.metadata?.process_list, res]);
setNodes(graph?.nodes);
setEdges(graph?.edges);
setTimeout(() => {
flowRef?.fitView();
}, 200);
message.success("已复制流程");
};
const removeProcess = async () => {
await removeRecord(data?.name);
await removeNode(id);
await updateRecord(currentGroup?.name, {
metadata: {
process_list: currentGroup?.metadata?.process_list
?.filter((v) => v?._id !== data?._id)
?.map((v) => v?._id),
},
});
setCurrentGroup({
...currentGroup,
metadata: {
process_list: currentGroup?.metadata?.process_list?.filter(
(v) => v?._id !== data?._id
),
},
});
flowRef?.fitView();
message.success("已删除流程");
};
return (
<Card
size="small"
className={styles.processNode}
style={{
position: "relative",
width: 240,
border: "none",
outline: `2px solid ${selected ? token.colorPrimary : "transparent"}`,
boxShadow: selected ? token.boxShadow : token.boxShadowTertiary,
}}
actions={
admin
? [
<Popconfirm
title={`是否确认复制流程"${data?.title}"`}
placement="left"
onConfirm={async () => {
await copyProcess();
}}
>
<Tooltip title="复制流程">
<Button
type="text"
icon={<CopyOutlined />}
aria-label="复制流程"
/>
</Tooltip>
</Popconfirm>,
<Tooltip title="编辑流程" key={`edit${data?.name}`}>
<Button
type="text"
icon={<EditOutlined />}
aria-label="编辑流程"
onClick={() => {
setCurrentProcess({ ...data, type: "edit" });
}}
/>
</Tooltip>,
<Popconfirm
key={`remove${data?.name}`}
title={`是否确认删除流程"${data?.title}"`}
placement="left"
onConfirm={async () => {
await removeProcess();
}}
>
<Tooltip title="删除流程">
<Button
type="text"
icon={<DeleteOutlined />}
aria-label="删除流程"
/>
</Tooltip>
</Popconfirm>,
]
: []
}
>
<Button
type="link"
style={{
fontSize: 14,
fontWeight: 500,
color: token.colorPrimary,
marginBottom: 8,
cursor: "pointer",
}}
onClick={() => history.push(`/process_flow/${data?.name}`)}
>
{data?.title}
</Button>
<div style={{ fontSize: 12, marginBottom: 8 }}>
{data?.metadata?.remark || "暂无备注"}
</div>
<FlowHandle active={selected} type="source" />
<FlowHandle active={selected} type="target" />
</Card>
);
});
.processNode { position: relative; }
.process-page {
color: var(--foreground);
}
.process-page--group-list {
margin-top: calc(-1 * var(--rc-page-padding-block-start));
}
.process-page__topbar {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.process-page__topbar--sticky {
position: sticky;
top: 0;
z-index: 10;
isolation: isolate;
background: var(--rc-bg-canvas);
}
.process-page__tabs {
flex: 1 1 auto;
min-width: 0;
}
.process-page__tabs .tabs {
width: 100%;
min-width: 0;
max-width: 100%;
}
.process-page__tabs .tabs__list-container,
.process-page__tabs .tabs__list-container__scroller {
width: 100%;
min-width: 0;
max-width: 100%;
}
.process-page__tabs .tabs__list {
width: max-content;
min-width: max-content;
padding: 0;
gap: 8px;
}
.process-page__tabs .tabs__tab {
flex: 0 0 auto;
}
.process-group-tab {
width: 192px;
min-width: 192px;
min-height: 88px;
padding: 0;
overflow: hidden;
white-space: normal;
text-align: start;
border: 1px solid var(--separator);
border-radius: var(--radius);
background: var(--surface);
}
.process-group-tab[aria-selected="true"],
.process-group-tab[data-selected="true"] {
border-color: var(--accent);
background: var(--accent-soft);
}
.process-group-tab__content {
display: flex;
box-sizing: border-box;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
width: 100%;
min-height: 86px;
padding: 12px 14px 10px;
gap: 8px;
}
.process-group-tab__title {
display: block;
width: 100%;
overflow-wrap: anywhere;
color: var(--foreground);
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.process-group-tab__meta {
display: flex;
align-items: baseline;
gap: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1;
}
.process-group-tab__meta strong {
color: var(--foreground);
font-size: 24px;
font-weight: 600;
line-height: 1;
}
.process-group-tab .tabs__indicator {
height: 3px;
border-radius: 3px 3px 0 0;
}
.process-editor > .react-flow {
flex: 1;
min-height: 0;
}
.process-editor .react-flow__edgelabel-renderer {
z-index: 5;
}
.process-editor .react-flow__panel {
max-width: calc(100% - 24px);
}
.process-editor .node-label {
background: transparent;
box-shadow: none;
}
.process-editor .color-swatch {
min-width: 28px;
width: 28px;
height: 28px;
padding: 0;
}
.process-editor .react-flow__panel.top.right {
max-height: calc(100% - 24px);
}
@media (max-width: 760px) {
.process-page__topbar {
flex-direction: column;
gap: 12px;
}
.process-page__header {
order: -1;
justify-content: flex-start;
}
.process-page__tabs {
width: 100%;
}
.process-group-tab {
width: 168px;
min-width: 168px;
}
.process-editor {
min-width: 620px;
}
.process-page {
overflow-x: auto;
}
}
This diff is collapsed.
import {
Button,
Modal,
ProTable,
Spin,
} from "@/components/process-ui/ProcessUi";
import { queryRecords } from "@/services/DataService";
import dayjs from "dayjs";
import numeral from "numeral";
import { useEffect, useState } from "react";
// Same configured fields and history query as streams-admin RecordBase/RecordField.
export default function RecordFields({ subjectConfig, subject, record }) {
const [history, setHistory] = useState([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setLoading(true);
queryRecords({
subject: "internal_regulation",
filter: { "metadata.origin_regulation": record._id },
})
.then((result) => {
if (active)
setHistory(
(result.records || []).map((item) => ({
...item.metadata,
_id: item._id,
title: item.metadata.regulation_name,
public_date: dayjs(item.metadata.public_date).format(
"YYYY-MM-DD"
),
}))
);
})
.catch((error) => {
if (active) setError(error.message || "历史版本加载失败");
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [record._id]);
const format = (field, value) => {
if (value == null || value.length === 0) return "-";
if (["reference", "document", "user"].includes(field.type))
return Array.isArray(value)
? value.map((v) => v.title || v.display_name || v.name).join("、")
: value.title || value.display_name || value.name;
if (field.type === "number") return numeral(value).format("0,0.00");
if (field.type === "date") return dayjs(value).format("YYYY-MM-DD");
if (field.type === "object")
return (
<pre className="max-h-60 overflow-auto text-xs">
{JSON.stringify(value, null, 2)}
</pre>
);
return String(value);
};
const fields = (
subjectConfig?.detail?.fields ||
subject?.fields?.map((field) => field.name) ||
[]
)
.map((name) => subject?.fields?.find((field) => field.name === name))
.filter(Boolean);
return (
<>
<dl className="grid grid-cols-1 gap-4 rounded-lg border border-separator p-4 sm:grid-cols-2">
{fields.map((field) => (
<div key={field.name}>
<dt className="text-sm text-muted">{field.label}</dt>
<dd className="mt-1">
{format(field, record.metadata?.[field.name])}
</dd>
</div>
))}
</dl>
<div className="flex justify-end">
<Button type="link" onClick={() => setOpen(true)}>
历史版本
</Button>
</div>
<Modal
title="历史制度"
width={700}
open={open}
onCancel={() => setOpen(false)}
footer={null}
>
{error ? (
<p role="alert">{error}</p>
) : loading ? (
<Spin />
) : (
<ProTable
dataSource={history}
columns={[
{
title: "制度名称",
dataIndex: "title",
render: (text, item) =>
item.attachments?.[0]?.url ? (
<a
href={item.attachments[0].url}
download={item.attachments[0].title}
>
{text}
</a>
) : (
text
),
},
{
title: "制定部门",
dataIndex: "departments",
render: (value) =>
Array.isArray(value) ? value.join(",") : value,
},
{ title: "层级", dataIndex: "level" },
{ title: "发文日期", dataIndex: "public_date" },
]}
/>
)}
</Modal>
</>
);
}
import {
Modal,
Flex,
Space,
Spin,
Tag,
} from "@/components/process-ui/ProcessUi";
import React, { useEffect, useMemo, useState } from "react";
import { loadRecord, loadSubject } from "@/services/DataService";
import RecordField from "@/components/record/RecordFields";
import { useAppLayout } from "@/hooks/App.jsx";
import styles from "@/components/RecordBase/DocumentView.less";
import _ from "lodash";
const DOCUMENT_PADDING = 30;
const RecordPreviewModal = (props) => {
const { record, onCancel } = props;
const [detail, setDetail] = useState(null);
const [error, setError] = useState("");
const [subject, setSubject] = useState(null);
const { getSubjectConfig } = useAppLayout();
const subjectConfig = getSubjectConfig(record?.type);
useEffect(() => {
(async () => {
setError("");
setDetail(null);
try {
if (record) {
const res = await loadRecord(record._id);
setDetail(res);
const subjectDetail = await loadSubject(record?.type);
setSubject(subjectDetail);
}
} catch (error) {
setError(error.message || "详情加载失败,请关闭后重试");
}
})();
}, [record]);
const documentMeta = useMemo(() => detail?.metadata?.document, [detail]);
const sections = useMemo(() => documentMeta?.sections || [], [documentMeta]);
const getBlockStyle = (format) => {
return {
textAlign:
format?.alignment == "LEFT"
? "justify"
: format?.alignment ?? "justify",
position: "relative",
zIndex: 99,
paddingRight: 1,
};
};
const getInlineStyle = (format) => {
return {
backgroundColor:
format?.background_color == "default"
? "inherit"
: format?.background_color ?? "inherit",
fontWeight: format?.bold == true ? 500 : 400,
color: format?.font_color ?? "inherit",
fontStyle: format?.italic ? "italic" : "normal",
fontSize: format?.size ? `${format?.size}pt` : "14pt",
textDecoration: format?.strike_through ? "line-through" : "blink",
};
};
const renderSection = (section, sectionIndex) => {
return (
<div key={`sectiion${sectionIndex}`}>
{(section?.blocks || [])?.map((block, blockIndex) => {
return renderBlock({ block, blockIndex, sectionIndex });
})}
</div>
);
};
const renderBlock = ({
block,
blockIndex,
sectionIndex,
editable = true,
}) => {
if (block?.type == "paragraph" && block?.inlines?.length == 0) {
return <br style={{ userSelect: "none" }} />;
}
return (
<div
className={styles.block}
style={{ padding: `0 ${DOCUMENT_PADDING}px` }}
>
{block?.type == "paragraph" &&
renderParagraph({ block, blockIndex, editable, sectionIndex })}
{block?.type == "table" &&
renderTable({ block, blockIndex, editable, sectionIndex })}
</div>
);
};
const renderParagraph = ({ block, blockIndex, editable, sectionIndex }) => {
const listInlines = block?.list_item?.inlines || [];
const inlines = block?.inlines || [];
let blockStyle = getBlockStyle(block?.format);
return (
<p
id={`block-${sectionIndex}-${blockIndex}`}
key={blockIndex}
style={{
...blockStyle,
}}
>
{block?.list_item && (
<span style={{ marginRight: 8 }}>
{listInlines?.map((v, inlineIndex) => {
return renderInline({
v,
block,
inlineIndex,
blockIndex,
sectionIndex,
editable,
});
})}
</span>
)}
{inlines?.map((v, inlineIndex) => {
return renderInline({
v,
block,
inlineIndex: inlineIndex + listInlines?.length,
blockIndex,
sectionIndex,
editable,
});
})}
</p>
);
};
const renderInline = ({
v,
inlineIndex,
block,
blockIndex,
sectionIndex,
editable,
}) => {
let inlineStyle = getInlineStyle(v?.format);
return (
<span
id={`block-${sectionIndex}-${blockIndex}-${inlineIndex}`}
key={`block-${sectionIndex}-${blockIndex}-${inlineIndex}`}
style={{
...inlineStyle,
}}
>
{v?.text}
</span>
);
};
const renderTable = ({ block, blockIndex, editable, sectionIndex }) => {
const rows = (block?.rows || [])?.filter((v) => JSON.stringify(v) !== "{}");
if (rows?.length > 0) {
return (
<table
style={{ width: "100%" }}
border={1}
cellPadding={8}
id={`block-${sectionIndex}-${blockIndex}`}
>
<tbody>
{rows?.map((row, rowKey) => {
if (row?.type == "table_row") {
return (
<tr key={`tr-${blockIndex}-${rowKey}`}>
{row?.cells.map((cell, cellKey) => {
return (
<td
key={`cell-${blockIndex}-${cellKey}`}
colSpan={cell?.column_span}
rowSpan={cell?.row_span}
>
{cell?.blocks?.map((b, k) =>
renderBlock({
block: b,
blockIndex: k,
editable: false,
sectionIndex,
})
)}
</td>
);
})}
</tr>
);
}
})}
</tbody>
</table>
);
}
};
const renderDetail = () => {
return (
<Flex vertical gap={30}>
<div style={{ fontSize: 20, fontWeight: 800, textAlign: "center" }}>
{detail?.title}
</div>
<RecordField
subjectConfig={subjectConfig}
subject={subject}
record={detail}
/>
<div
style={{
padding: "30px 0px",
margin: "0 35px",
maxHeight: 400,
overflow: "auto",
}}
>
{sections.map((v, k) => {
return renderSection(v, k);
})}
</div>
</Flex>
);
};
return (
<Modal
open={true}
width={1200}
onCancel={onCancel}
maskClosable={false}
footer={null}
>
{error ? (
<p role="alert">{error}</p>
) : !detail ? (
<Flex style={{ height: 400 }} align="center" justify="center">
<Spin spinning={true} />
</Flex>
) : (
renderDetail()
)}
</Modal>
);
};
export default RecordPreviewModal;
export const APPLICATION_DISPLAY_NAME = '流程管理';
export const APPLICATION_NAME = 'process_management_new';
export const APPLICATION_BASE_PATH = '/process_management_new/';
export const ROUTE_TITLES = Object.freeze({
'/process_flow': '流程管理', '/flow_graph': '视图面板', '/notifications': '通知消息',
});
// Migrated from the supplied application configuration.
export const PROCESS_LAYOUTS = {
default: {
mode: "top",
content: { width: "Fluid" },
menu: {
items: [
{ path: "/process_flow", label: "流程管理" },
{ path: "/flow_graph", label: "视图面板" },
],
},
subjects: {
process_flow: {
group_subject: "process_flow_group",
list: { view: "process_list" },
detail: { view: "process_detail" },
config: {
relations: [
{ label: "内部制度", value: "internal_regulation", tag: "制度" },
{ label: "外部法规", value: "external_law", tag: "法规" },
],
},
},
internal_regulation: {
detail: {
fields: [
"regulation_name",
"effective_status",
"departments",
"level",
"effective_date",
"version",
],
},
},
external_law: {
detail: {
fields: [
"law_status",
"issue_date",
"issue_authority",
"issue_number",
"authority_level",
"categories",
],
},
},
},
},
};
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 brand primary; HeroUI accent remains stable in every mode. */
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-disabled: color-mix(in srgb, #DA241A 42%, transparent);
--primary: #DA241A;
--primary-foreground: #ffffff;
--primary-50: #FFF4F3;
--primary-100: #FFE3E0;
--primary-200: #FFC7C2;
--primary-300: #F89A92;
--primary-400: #EB675D;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #8F1712;
--primary-900: #6F120F;
--primary-950: #480B09;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03);
--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: #61758F;
--success-foreground: #ffffff;
--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.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/* Ordered low-conflict palette: brand red, warm neutrals, gray-blue. */
--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: #5F5A61;
--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) */
/* Keep brand identity fixed in dark mode; surfaces provide contrast. */
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-disabled: color-mix(in srgb, #DA241A 42%, transparent);
--primary: #DA241A;
--primary-foreground: #ffffff;
--primary-50: #FFF4F3;
--primary-100: #FFE3E0;
--primary-200: #FFC7C2;
--primary-300: #F89A92;
--primary-400: #EB675D;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #8F1712;
--primary-900: #6F120F;
--primary-950: #480B09;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--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: #74879A;
--success-foreground: #ffffff;
--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);
/* Chart tokens stay identical across light and dark modes. */
--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: #5F5A61;
--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;
}
:root,
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: #DA241A;
--rc-brand-on-primary: #ffffff;
--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-medium: 500;
--rc-font-semibold: 600;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
--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: var(--rc-chart-11);
--rc-metric-tone-success: var(--rc-chart-2);
--rc-metric-tone-teal: var(--rc-chart-9);
--rc-metric-tone-danger: var(--rc-chart-1);
--rc-metric-tone-warning: var(--rc-chart-6);
--rc-metric-tone-violet: var(--rc-chart-8);
--rc-metric-tone-indigo: var(--rc-chart-12);
--rc-metric-tone-coral: var(--rc-chart-3);
--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-2);
--rc-chart-comparison: var(--rc-chart-11);
--rc-chart-neutral: var(--rc-chart-9);
--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%;
}
* {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
import _ from "lodash";
import { useCallback, useContext, useMemo } from "react";
import { AppContext } from "@/wrapper/ProcessApp";
import { useAuthorization } from "./user";
export function useAppInfo() {
const app = useContext(AppContext);
const appInfo = useMemo(() => {
return app;
}, [app]);
return appInfo;
}
export function useAppLayout() {
const app = useContext(AppContext);
const { check } = useAuthorization();
const layout = useMemo(() => {
return app?.layouts?.default;
}, [app]);
const getConfig = useCallback(
(path, defaultValue = null) => {
return _.get(layout, path, defaultValue);
},
[app]
);
const getRouteConfig = useCallback(
(route) => {
return getConfig(`routes.${route}`, {});
},
[getConfig]
);
const getSubjectConfig = useCallback(
(subject) => {
return getConfig(`subjects.${subject}`, {});
},
[getConfig]
);
const getUserMenu = useCallback(() => {
const menu = getConfig("menu", { items: [] });
return {
items: menu.items.filter((item) => {
if (item.allow) {
return check(item.allow);
}
if (item.deny) {
return !check(item.deny);
}
return true;
}),
};
}, [getConfig, check]);
const getSearchConfig = useCallback(() => {
return getConfig("search", {
enabled: false,
});
}, [getConfig]);
return {
layout,
getUserMenu,
getConfig,
getSubjectConfig,
getRouteConfig,
getSearchConfig,
};
}
import { useParams } from "umi";
import { APPLICATION_NAME } from "@/config/application";
export function useProcessParams() {
const params = useParams();
return { ...params, app: APPLICATION_NAME, subject: "process_flow" };
}
import { UserContext } from '@/wrapper/Auth';
import { useCallback, useContext, useMemo } from 'react';
import { APPLICATION_NAME } from '@/config/application';
const DEFAULT_APP_NAME = APPLICATION_NAME;
function normalizeApplications(user, appName) {
const applications = Array.isArray(user?.applications) ? user.applications : [];
if (appName) {
const matched = applications.find((application) => application?.name === appName);
if (matched) {
return [matched];
}
}
return appName ? [] : applications;
}
function unique(values) {
return Array.from(new Set((values || []).filter(Boolean)));
}
function hasIntersection(left, right) {
const rightSet = new Set(right);
return left.some((item) => rightSet.has(item));
}
export function useUserInfo() {
return useContext(UserContext);
}
export function useAuthorization(appName = DEFAULT_APP_NAME) {
const user = useContext(UserContext);
const scopedApplications = useMemo(
() => normalizeApplications(user, appName),
[user, appName],
);
const roles = useMemo(
() => unique(scopedApplications.flatMap((application) => application?.roles || [])),
[scopedApplications],
);
const permissions = useMemo(
() => unique(scopedApplications.flatMap((application) => application?.permissions || [])),
[scopedApplications],
);
const groups = useMemo(
() => unique(scopedApplications.flatMap((application) => application?.groups || [])),
[scopedApplications],
);
const check = useCallback(
(auth) => {
if (typeof auth === 'string') {
auth = { role: auth };
}
if (!auth) {
return true;
}
if (!user) {
return false;
}
if (user.type === 'admin' || user.type === 'system') {
return true;
}
if (auth.role) {
const authRoles = Array.isArray(auth.role) ? auth.role : [auth.role];
if (!hasIntersection(roles, authRoles)) {
return false;
}
}
if (auth.permission) {
const authPermissions = Array.isArray(auth.permission)
? auth.permission
: [auth.permission];
if (!hasIntersection(permissions, authPermissions)) {
return false;
}
}
if (auth.group) {
const authGroups = Array.isArray(auth.group) ? auth.group : [auth.group];
if (!hasIntersection(groups, authGroups)) {
return false;
}
}
if (auth.username) {
const authUsernames = Array.isArray(auth.username)
? auth.username
: [auth.username];
if (!authUsernames.includes(user.username)) {
return false;
}
}
return true;
},
[groups, permissions, roles, user],
);
return {
admin: user?.type === 'admin' || user?.type === 'system',
guest: user?.type === 'guest',
username: user?.username,
roles,
permissions,
groups,
check,
};
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
} from '@/config/application';
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;
}
function normalizeRedirect(value) {
if (!value) {
return '/';
}
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) {
return '/';
}
const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
const targetPath = `${target.pathname}${target.search}${target.hash}`;
if (
applicationBasePath &&
(target.pathname === applicationBasePath ||
target.pathname.startsWith(`${applicationBasePath}/`))
) {
return targetPath.slice(applicationBasePath.length) || '/';
}
return targetPath;
} catch {
return '/';
}
}
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 = normalizeRedirect(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 = `${APPLICATION_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={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--dark"
src={xyLogoWhite}
/>
</span>
<strong>{APPLICATION_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 status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
isDisabled={
loading || captchaLoading || !identity || !password || !captchaCode
}
isPending={loading}
type="submit"
variant="primary"
>
{loading ? '登录中...' : '登录'}
</Button>
</form>
</Card.Content>
</Card>
</section>
</main>
);
}
.react-flow__attribution {
display: none;
}
.react-flow .anticon svg {
display: inline-block !important;
}
\ No newline at end of file
import AppShell from "@/components/AppShell";
import ApplicationSwitcherDialog from "@/components/ApplicationSwitcherDialog";
import { ProcessList } from "@/components/AppIcons";
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from "@/config/application";
import { useUserInfo } from "@/hooks/user";
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from "@/pages/notifications/notificationService";
import { SiteContext } from "@/wrapper/Site";
import { useContext, useEffect, useMemo, useState } from "react";
import { Outlet, useLocation } from "umi";
const MENU_GROUPS = [
{
title: APPLICATION_DISPLAY_NAME,
showLabel: false,
items: [
{ name: "流程管理", path: "/process_flow", icon: ProcessList },
{ name: "视图面板", path: "/flow_graph", icon: ProcessList },
],
},
];
export default function ProcessManagementLayout() {
const location = useLocation();
const userInfo = useUserInfo();
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = ROUTE_TITLES[location.pathname] || "流程详情";
const breadcrumbItems = location.pathname.startsWith("/process_flow/")
? [{ label: "流程管理", href: "/process_flow" }, { label: "流程详情" }]
: [{ label: currentTitle }];
useEffect(() => {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentTitle]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch (error) {
if (!cancelled) setUnreadNotificationCount(0);
}
};
refreshUnreadCount();
window.addEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
const timer = window.setInterval(refreshUnreadCount, 60000);
return () => {
cancelled = true;
window.removeEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
window.clearInterval(timer);
};
}, [location.pathname]);
return (
<>
<AppShell
menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
onOpenChange={setSidebarOpen}
open={sidebarOpen}
pathname={location.pathname}
breadcrumbItems={breadcrumbItems}
title={currentTitle}
userInfo={userInfo}
>
<Outlet />
</AppShell>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
siteInfo={siteInfo}
userInfo={userInfo}
/>
</>
);
}
This diff is collapsed.
This diff is collapsed.
import ProcessFlow from "./ProcessFlow";
export default function ProcessFlowDetail() {
return (
<section
className="process-editor flex min-h-[480px] flex-col overflow-hidden rounded-xl border border-separator bg-surface"
style={{ height: "calc(100dvh - 140px)" }}
>
<ProcessFlow />
</section>
);
}
.wrapper { height: 100%; min-height: 480px; }
This diff is collapsed.
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<defs>
<style>
.cls-1, .cls-2 {
fill: none;
}
.cls-2 {
stroke: #000;
stroke-linecap: round;
stroke-miterlimit: 10;
stroke-width: 10px;
}
</style>
</defs>
<rect class="cls-1" width="240" height="240"/>
<path class="cls-2" d="M191.14,71.62c-81.45,0-147.49,43.32-147.49,96.77"/>
</svg>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="_图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 240 240" style="enable-background:new 0 0 240 240;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:none;stroke:#000000;stroke-width:10;stroke-linecap:round;stroke-miterlimit:10;}
.st2{fill:none;stroke:#000000;stroke-width:10;stroke-linecap:round;stroke-miterlimit:10;stroke-dasharray:17.0128,20.4154,0,0,0,0;}
</style>
<rect class="st0" width="240" height="240"/>
<g>
<g>
<line class="st1" x1="43.7" y1="120" x2="53.7" y2="120"/>
<line class="st2" x1="74.1" y1="120" x2="186.4" y2="120"/>
<line class="st1" x1="186.4" y1="120" x2="196.4" y2="120"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<defs>
<style>
.cls-1, .cls-2 {
fill: none;
}
.cls-2 {
stroke: #000;
stroke-linecap: round;
stroke-miterlimit: 10;
stroke-width: 10px;
}
</style>
</defs>
<rect class="cls-1" width="240" height="240"/>
<line class="cls-2" x1="43.65" y1="120" x2="196.35" y2="120"/>
</svg>
\ No newline at end of file
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.
function Rectangle({ width, height, ...svgAttributes }) {
return <rect x={0} y={0} width={width} height={height} {...svgAttributes} />;
}
export default Rectangle;
This diff is collapsed.
// this util functions generates an svg path from a list of points
export function generatePath(points) {
const path = points.map(([x, y]) => `${x},${y}`).join(" L");
return `M${path} Z`;
}
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.
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.
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.
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