Commit 1ac6b324 authored by drigle's avatar drigle

Initial commit: law reference project

parents
---
name: subject-schema-manager
description: Sync Stream Subject definitions from the API into project documentation, remove unreferenced object sections, and apply confirmed object/field/configuration changes through the documented Subject API.
---
# Subject Schema Manager
Use this skill when the user asks to synchronize Subject/object documentation or asks the AI to create, update, or configure Stream Subjects and fields.
## Source of truth
- The live Subject API is authoritative for object and field structure.
- Read the repository's `SUBJECT_API.md`, `SUBJECT_CONFIGURATION.md`, `record-api.md`, and `subjectsDefinition.md` before acting. Prefer `src/services/record-api.md` and `src/services/subjectsDefinition.md` when the files exist there.
- `subjectsDefinition.md` is a versioned, human-readable snapshot generated from the API. It is not a runtime API client.
- Form resources are managed by `/form`; do not treat a `form` field as a Subject dependency.
## Modes
### `sync`
Use for requests to update object documentation or remove copied-project objects.
1. Discover Subject roots from static Subject constants and literal Subject API calls in the project's source. Prefer explicit roots supplied by the user or the script's `--roots` option when discovery is ambiguous.
2. Exchange the supplied ticket for a token using `POST /auth/login` with `{ "type": "ticket", "ticket": "..." }`. Never put the ticket or token in a file, generated Markdown, or normal command output.
3. Fetch each root with `GET /subject/{subject}`. Recursively follow every non-empty `field.settings.subject` on `reference`, `subject`, or other fields. Stop at cycles after documenting the object once.
4. Rebuild only the managed object-definition block in `subjectsDefinition.md`. The block must contain the root objects, every recursively referenced object, complete field properties, and dependency edges. Object sections outside the current dependency closure are removed from the managed block.
5. Do not mutate remote objects in this mode. A missing/ambiguous `settings.subject` is documented as unresolved; never invent a target object.
Run the deterministic helper from the project root:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync \
--project "$PWD" --ticket-stdin
```
Use `--check` for a read-only diff. The helper discovers the API base URL from `--base-url`, `STREAMS_API_URL`, or an absolute proxy target in `.umirc.ts`.
### `apply`
Use for requests that create objects, create/update fields, or update object configuration.
1. Inspect the API docs and produce an exact operation plan containing object names, field aliases, payloads, and dependency order. Include a concise human-readable summary and ask for explicit confirmation before any remote mutation.
2. After confirmation, write the plan as JSON outside the repository (for example `/tmp/subject-schema-plan.json`) and run the helper with `--confirmed --plan-file`. Pass the ticket via `SUBJECT_TICKET` or `--ticket-stdin`; never hardcode it.
3. The helper validates aliases and payloads, previews new objects with `POST /subject?preview=true`, then executes only the bounded operations in the plan:
- object create: `POST /subject`
- object update: `PUT /subject/{subject}`
- field create: `POST /subject/{subject}/field`
- field update: `PUT /subject/{subject}/field/{field}`
- field import/reorder when explicitly present in the plan
4. Remote object/field deletion is never implied by documentation cleanup. It requires explicit user intent and the helper's destructive-operation flag.
5. After successful mutations, re-fetch the complete dependency closure and run the same documentation sync using the authenticated token. Report successes and any partial-failure operation; there is no automatic rollback.
Run:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs apply \
--project "$PWD" --plan-file /tmp/subject-schema-plan.json \
--ticket-stdin --confirmed
```
The plan format is documented in [references/plan-schema.md](references/plan-schema.md). Do not use a generic arbitrary-URL request list; keep mutations within the documented Subject operations.
## Safety and documentation rules
- A plan confirmation authorizes only the listed operations. Do not add inferred fields, dependencies, or destructive operations after confirmation.
- Validate `select.settings.options`, `reference/subject settings.subject`, `required`, `multiple`, and `primary` against the API response or the confirmed plan.
- Use IDs for `reference` and `user` values, ISO 8601 for dates, and preserve `form` values as `{ form, data, settings? }`.
- If the API or local docs conflict, stop before mutation and show the exact conflict.
- If a business workflow or state rule changes, update the module's business-flow document separately; schema synchronization alone does not authorize workflow changes.
interface:
display_name: "Subject Schema Manager"
short_description: "Sync Subject docs and apply confirmed schema plans"
default_prompt: "Use $subject-schema-manager to sync current Subject definitions or apply a confirmed object/field configuration plan."
# Subject Mutation Plan
`apply` accepts a JSON file containing only the explicitly approved Subject API operations. Keep
the file outside the repository when it contains environment-specific values.
```json
{
"version": 1,
"operations": [
{
"op": "create_subject",
"subject": {
"name": "customers",
"title": "Customers",
"type": "normal",
"access": "public",
"fields": [],
"views": []
}
},
{
"op": "update_subject",
"subject": "orders",
"patch": {"title": "Orders", "history": true}
},
{
"op": "create_field",
"subject": "orders",
"field": {
"name": "customer",
"label": "Customer",
"type": "reference",
"required": false,
"multiple": false,
"settings": {"subject": "customers"}
}
},
{
"op": "update_field",
"subject": "orders",
"field": "status",
"patch": {
"label": "Order status",
"settings": {"options": [{"label": "Open", "value": "open"}]}
}
},
{
"op": "import_fields",
"subject": "orders",
"fields": [{"name": "amount", "label": "Amount", "type": "number"}]
},
{
"op": "reorder_fields",
"subject": "orders",
"fields": ["order_no", "customer", "status", "amount"]
}
]
}
```
Supported operation names are `create_subject`, `update_subject`, `create_field`, `update_field`,
`import_fields`, `reorder_fields`, `delete_subject`, and `delete_field`. Object and field aliases
must contain only letters, digits, `_`, or `-`. `update_subject.patch` and `update_field.patch`
must contain only the properties supported by the corresponding API endpoints in
`SUBJECT_API.md`; the helper rejects arbitrary URLs or methods.
Creation of an object is always sent to `POST /subject?preview=true` before any mutating request.
Deletion operations are accepted only with the command-line flag `--allow-remote-delete` in
addition to `--confirmed`. A deletion may set `force: true` and `confirm` where the API requires
it; `confirm` must exactly equal the alias being deleted. Documentation synchronization alone
never creates a deletion plan.
/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
.playwright-cli/
.DS_Store
import { defineConfig } from '@umijs/max';
import path from 'path';
function resolvePackageRoot(packageName: string) {
return path.dirname(require.resolve(`${packageName}/package.json`));
}
const ROUTE_PATHS = {
login: '/login',
resourceStat: '/resource/stat',
integrityParty: '/integrity-party',
notifications: '/notifications',
} as const;
export default defineConfig({
layout: false,
fastRefresh: true,
base: '/law_reference_new/',
publicPath: '/law_reference_new/',
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
},
routes: [
{
path: ROUTE_PATHS.login,
layout: false,
component: './Login',
},
{
path: '/',
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './LawWorkbenchLayout',
routes: [
{ path: '/', component: './LawEntryRedirectPage' },
{ path: '/resource/stat', component: './LawDashboardPage' },
{ path: '/external_law_new', component: './LawSubjectPage' },
{ path: '/external_law', component: './LawSubjectPage' },
{ path: '/internal_regulation', component: './LawSubjectPage' },
{ path: '/external_law_new/:record', component: './LawRecordPage' },
{ path: '/external_law/:record', component: './LawRecordPage' },
{ path: '/internal_regulation/:record', component: './LawRecordPage' },
{ path: '/notifications', component: './project_manager_xy/notifications/NotificationListPage' },
{ path: '/search', component: './GlobalSearchPage' },
{ path: '/import', component: './LegacyFeaturePage' },
{ path: '/query', component: './LegacyFeaturePage' },
{ path: '/settings', component: './LegacyFeaturePage' },
{ path: '/graph', component: './LegacyFeaturePage' },
{ path: '/files', component: './LegacyFeaturePage' },
{ path: '/spider', component: './LegacyFeaturePage' },
{ path: '/spider/:spiderName', component: './LegacyFeaturePage' },
{ path: '/internal_regulation_manager', component: './LegacyFeaturePage' },
{ path: '/internal_regulation_revision/:name', component: './LegacyFeaturePage' },
{ path: '/view/:view', component: './LegacyFeaturePage' },
{ path: '/report_template/:templateId', component: './LegacyFeaturePage' },
{ path: '/report_template/create', component: './LegacyFeaturePage' },
{ path: '/audit_process', component: './LegacyFeaturePage' },
{ path: '/audit_report', component: './LegacyFeaturePage' },
{ path: '/process_monitor', component: './LegacyFeaturePage' },
{ path: '/process_monitor/:processId', component: './LegacyFeaturePage' },
{ path: '/process_monitor/:processId/:application', component: './LegacyFeaturePage' },
{ path: '/data_visualization_screen', component: './LegacyFeaturePage' },
{ path: '/data_visualization_screen/create', component: './LegacyFeaturePage' },
{ path: '/data_visualization_screen/:screenId', component: './LegacyFeaturePage' },
{ path: '/data_analytics', component: './LegacyFeaturePage' },
{ path: '/data_analytics/:record', component: './LegacyFeaturePage' },
{ path: '/data_visualization_flow', component: './LegacyFeaturePage' },
{ path: '/data_visualization_flow/create', component: './LegacyFeaturePage' },
{ path: '/data_visualization_flow/:flowId', component: './LegacyFeaturePage' },
{ path: '/process_flow/create', component: './LegacyFeaturePage' },
{ path: '/flow_graph', component: './LegacyFeaturePage' },
{ path: '/chat', component: './LegacyFeaturePage' },
{ path: '/chat/knowledge_base/:id', component: './LegacyFeaturePage' },
{ path: '/chat/mcp/:id', component: './LegacyFeaturePage' },
{ path: '/annotate', component: './LegacyFeaturePage' },
{ path: '/annotate/create', component: './LegacyFeaturePage' },
{ path: '/annotate/:name', component: './LegacyFeaturePage' },
{ path: '/grid', component: './LegacyFeaturePage' },
{ path: '/mind', component: './LegacyFeaturePage' },
{ path: '/:subject/:record', component: './LawRecordPage' },
{ path: '/:subject', component: './LawSubjectPage' },
],
},
],
npmClient: 'npm',
proxy: {
'/api': {
// target: 'https://xy.streams7.com/api',
target: 'http://127.0.0.1:3000/',
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',
},
});
# 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 和函数组件。
- 样式处理:**统一使用 Tailwind CSS + HeroUI / HeroUI Pro**;基础组件来自 `@heroui/react`,专业组件来自 `@heroui-pro/react`。
- 交互组件:**优先使用 HeroUI 原生组件**(其基于 React Aria 提供可访问性与交互状态);禁止引入 Catalyst、Headless UI 等其它 UI 框架。
- 组件选择优先级(从高到低):
1. `@heroui/react`(Input/Autocomplete/DatePicker/Checkbox/Modal/Card/Tabs 等基础组件)
2. `@heroui-pro/react/<component>`(DataGrid/DropZone/KPI/Sidebar 等 Pro 组件,按明确子路径导入)
3. 基于 HeroUI 规范的业务无关组合组件(仅在 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/react` 与 `@heroui-pro/react` 的 HeroUI 组件和主题,禁止引入 Catalyst、Headless UI 或其它 UI 框架。
- **交互组件**:弹窗使用 HeroUI `Modal`,下拉/选择器使用 `Autocomplete` 或 `Select`,提示和菜单使用 HeroUI 对应组件;Pro 组件按官方子路径导入。
- **一致性**:表单控件使用 HeroUI `Input/Autocomplete/Select/Checkbox/TextArea`,字段分组使用 `Fieldset`,表格使用 HeroUI Pro `DataGrid`,对话框使用 HeroUI `Modal`,上传使用 HeroUI Pro `DropZone`。
- **可访问性**:移动端侧栏、弹层、Popover、Dropdown 和 Tooltip 必须使用 HeroUI / HeroUI Pro 组件提供的焦点约束、键盘操作和 Esc 关闭能力,不得自制替代实现。
# 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 `DatePicker`(必要时使用 `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 节)选择 tailwind (或你指定的 UI 库) 的组件。
4. **Step 4**: 自动生成 `onChange` 处理函数,确保数据实时同步到 `metadata` 对象的对应路径下。
---
## 4. 强制约束 (Hard Constraints)
- **禁止硬编码**:Select 类型的选项必须从 `settings.options` 读取,严禁在代码里写死。
- **主键保护**:如果字段定义中 `isPrimaryKey: true`,在编辑模式下该组件必须设为 `disabled`。
- **必填校验**:根据 `isRequired` 自动在 UI 上添加红星标记,并生成相应的 Form Validation 规则。
- **流程文档同步**:如果本次修改涉及业务逻辑、页面交互流程、状态流转、字段规则、审批/操作步骤变化,除了更新代码外,**必须同步补全对应模块的流程文档**;若未找到对应文档,需先告知用户再继续。
---
## 5. 流程文档维护要求
- 开发前先确认本次需求归属的业务模块,并查阅对应流程文档。
- 开发完成后,检查实现结果是否与流程文档一致;如果存在新增、变更或删除的逻辑,必须同步更新文档。
- 流程文档更新内容至少应包含:功能入口、关键交互步骤、状态变化、核心字段/筛选条件、异常处理或限制条件。
- 提交结果时,除了说明改动代码,也要简要说明同步更新了哪个流程文档、补充了哪部分逻辑。
## 业务文档索引
- 审计材料中心模块 - 请阅读 `/src/pages/project_manager_xy/audit-material-center.business-flow.md`
- 模板来源说明 - 如需确认旧项目上下文,可参考 `/src/pages/project_manager_xy/project-management.business-flow.md`
## Subject Schema Skill
- 项目内置 `.agents/skills/subject-schema-manager/`,用于从 Subject API 同步 `src/services/subjectsDefinition.md`,并按已确认计划创建、更新或删除 Subject 与字段。
- 同步文档使用 `node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync --project "$PWD" --ticket-stdin`;远程变更必须先展示计划并在确认后使用 `apply --plan-file ... --confirmed`。
- ticket 仅允许通过 `SUBJECT_TICKET` 或标准输入传入,不得写入仓库、计划文件或普通日志。删除对象或字段必须显式提供 destructive 操作和确认值。
## 获取当前登录用户信息
可使用 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": [] } ] }
This diff is collapsed.
# Object Configuration Migration
对象、字段、视图和 ticket 换 token 的完整 HTTP API 参考见 [SUBJECT_API.md](./SUBJECT_API.md)。本文保留配置包迁移的详细说明。
The object configuration package moves metadata between Streams environments. It does not move records, database IDs, object members, tags, categories, connector settings, credentials, or existing external database tables.
## Workflow
1. Select one or more normal, embedded, or external objects in Admin > Object Management and choose `Export Configuration`.
2. Referenced objects are included automatically. The selected objects are listed in `selected_subjects`; dependency objects follow them in `subjects`.
3. In the target environment choose `Import Configuration` and select the JSON file.
4. Review the precheck. Only a package with no blocking issues can be applied.
5. The import uses `upsert`: matching objects and fields are updated, new ones are created, and target-only fields and views are preserved.
For an external object, create and enable a DM8 connector in the target environment before import. Its stable `connector.name` must match the alias exported in `external.connector`. A new external object provisions a new managed table using the `dm_<subject.name>` convention; import never adopts, binds to, or overwrites a pre-existing physical table.
## Stable identifiers
The following identifiers are portable business codes and must not be replaced with database IDs:
- object: `subject.name`
- field: `field.name`
- select option: `field.settings.options[].value`
- reference target: `field.settings.subject`
- external connector: `subject.external.connector` (`connector.name`)
For example:
```json
{
"kind": "streams.subject-configuration",
"version": 1,
"selected_subjects": ["orders"],
"include_dependencies": true,
"subjects": [
{
"name": "orders",
"title": "Orders",
"type": "normal",
"fields": [
{
"name": "status",
"label": "Status",
"type": "select",
"settings": {
"options": [
{"label": "Open", "value": "open"}
]
}
},
{
"name": "customer",
"label": "Customer",
"type": "reference",
"settings": {"subject": "customers"}
}
],
"views": []
},
{
"name": "customers",
"title": "Customers",
"type": "normal",
"fields": [],
"views": []
}
]
}
```
An external object only exports its connector alias:
```json
{
"name": "external_orders",
"title": "External Orders",
"type": "external",
"external": {
"connector": "business_dm"
},
"fields": [],
"views": []
}
```
The package never contains the connector host, account, password, settings, runtime state, physical table metadata, or records.
## API
- `POST /subject/configuration/export` with `{subjects: ["orders"], include_dependencies: true}`
- `POST /subject/configuration/import/preview` with `{mode: "upsert", package: {...}}`
- `POST /subject/configuration/import` with `{mode: "upsert", package: {...}}`
The import order is normal object shells, complete new external objects, object properties, normal fields, reference fields, and views. A new external object is created once with its complete field definition so the managed table is provisioned once. This also allows circular references between objects to be imported after referenced object names exist.
External-object precheck blocks import when:
- `external.connector` is missing or invalid;
- the same-alias connector does not exist, is inactive, or is not DM8-compatible;
- the target table for a new external object already exists;
- an existing external object's connector alias differs from the package;
- an existing external object is not in the `ready` state;
- an external field change is not supported by the managed-table schema.
## Deliberate limitations in V1
- Records are never included.
- Connector settings and credentials are never included; target connectors are resolved by alias.
- Existing physical tables are never adopted or overwritten by configuration import.
- Members, tags, and categories are reported as warnings and are not imported.
- Upsert never deletes fields or views that only exist in the target environment.
# UI 架构
## 分层
1. `src/services` 与 `integrityPartyService.js` 负责接口、Record/metadata 映射、Excel、日期和附件协议。
2. 页面负责权限、查询时机、筛选状态、表单 draft、选择状态和业务回调。
3. `src/components` 只提供业务无关的 HeroUI-native 组合,例如 DataGrid、选择字段、日期字段、上传字段与图标操作。
4. `src/design-system/theme.css` 是不可变 HeroUI 主题;`tokens.css` 只提供批准的扩展 Tokens,`shell.css` 只保留与“问题整改”相同的共享 Shell/布局模式。页面不得通过私有 CSS 改写组件外观。
字段后缀图标由 `src/components/AppIcons.jsx` 的 `FIELD_SUFFIX_ICON_STYLE` 统一提供语义颜色。`XinyuanAutocomplete` 和 `FormDatePicker` 直接把该样式绑定到 Ant 图标根节点,并在共享组件内部固定 16px 图标与 DatePicker 24px Trigger 几何;页面不得覆盖颜色、位置或内部 slot。
应用切换由 `ApplicationSwitcherDialog.jsx` 保留目标应用的 `/application/info`、用户应用合并、当前项识别和跳转逻辑,并从 `SiteContext` 使用 `/info -> site.group` 动态生成有权限的非空分组。结构严格继承 `model_analysis`:46rem/42rem Modal、28px inset、24px/12px 分组节奏、手机一列、`sm` 起固定两列、36px identity well;未归组应用进入“其他应用”,无分组配置时使用“全部应用”。
## 直接导入规则
- 基础组件从 `@heroui/react` 或明确子路径导入。
- Pro 组件从 `@heroui-pro/react/<component>` 导入,避免 barrel 引入无关可选依赖。
- 禁止引入其它 UI 框架、兼容原语、可见原生表单控件和旧 DOM 事件适配层。
- 共享组件使用 HeroUI 的 `selectedKey/onSelectionChange`、`value/onChange`、`isDisabled`、`onPress` 等当前契约。
- HeroUI v3 Checkbox 必须使用 `Checkbox.Content > Checkbox.Control > Checkbox.Indicator` 组合结构;业务代码统一复用 `src/components/HeroCheckbox.jsx`,不得恢复只传文字子节点的旧写法。共享样式为所有 `.checkbox` 控件提供可见边框,因此 HeroUI Pro DataGrid、ListView、FileTree 内建的选择框也遵循同一显示规则。
## 数据与业务边界
- 服务层接口 URL 以 `src/services/record-api.md` 为准,不在 UI 中硬编码。
- Record 自定义字段只进入 `metadata`。
- 引用字段若未来接入,只向服务层传 ID;当前人员/部门/岗位均按业务文档保持普通文本。
- 日期 UI 值在页面/服务边界转换,网络载荷保持 ISO 8601。
- UI 迁移不得改变路由、权限、请求时机、Excel 映射、标题生成、批量处理或错误策略。
## View、通知与材料事务
- `src/config/ApplicationConfig.js` 以 `APPLICATION_NAME = "incorrupt_party_build"` 统一路由、授权和材料来源标识;`src/utils/subjectView.js` 只解析 `user.applications[]` 中该应用项,不合并顶层身份或其它应用权限。
- `integrityPartyService.loadLedgerViewResources` 加载 Subject、View summaries/details 并过滤可访问 Grid View。没有真实可访问 Grid View 时不创建兜底 View、不查询记录,也不显示新增、批量或行操作。
- Detail 存在任一有效 `settings.auth` 或 `settings.disableColumns` 时整套作为字段权限来源;两项都未配置或没有实际 Detail 时整体回退当前 Grid。两套权限不合并、不逐字段回退,选定来源中的 `disableColumns` 对所有用户始终只读。
- Detail `columns` 只决定字段展示范围和顺序,`auth` 独立决定编辑能力;普通用户在选定权限来源中缺少字段 `auth` 时只读。切换 Detail 或 Grid 后展示和字段权限立即重新计算。
- `notifications/notificationService.js` 复用平台 Notice API;页面负责搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转,Shell 只维护 60 秒未读轮询和 Navbar Badge。
- `IntegrityPartyMaterialField` 管理文件快照和待建档列表;`pendingLocalMaterialsRef` 属于一次详情编辑会话。保存时先补建未建档 `am_material`,全部成功后才写业务 Record;失败保留已建档标记供本次重试跳过,关闭弹窗清空会话。
## 依赖来源
- 当前仓库通过官方 `hpsetup` 独立安装 HeroUI Pro,精确版本为 `1.0.0-beta.8`,npm lockfile 解析到官方 registry tarball。
- `@heroui/react`、`@heroui/styles`、React Aria 和 React 必须在应用构建中解析到一套兼容实例。
- 禁止恢复跨项目 `file:`、相邻 `node_modules`、软链接或绝对工作站路径。
- 仓库保留 npm 的 `package-lock.json`,不并存其它包管理器锁文件。
## 旧架构移除状态
活动源码仅保留 HeroUI / HeroUI Pro;旧 UI 框架适配层、旧 `FormSelect` DOM API、旧 Table 兼容解析和页容量 Select 均已移除。业务列表与只读 Excel 导入预览均使用 HeroUI Pro DataGrid。
Native HeroUI 静态架构门禁已通过:`src/xinyuan-portable.css` 已移除,业务图表统一保留并使用 ECharts 适配器,页面目录无私有 CSS/Less/SCSS;仅保留经确认的共享样式层。HeroUI 与 HeroUI Pro 的样式分别通过 `@heroui/styles/css` 和 `@heroui-pro/react/css` 显式加载;包根 `@heroui/styles` 是 JavaScript 样式工具入口,不能替代 CSS 入口。当前品牌主色固定为 `#DA241A`,HeroUI `accent/focus` 和 `primary-*` Token 不随浅深主题改变色相;图表 Token 按品牌红深浅、暖土色、中性灰紫和灰蓝分组,禁止绿色和荧光色。2026-08-26 已完成四视口、深浅主题、权限、通知和文件事务的运行态浏览器门禁。
# 共享 UI 架构补充
`XinyuanEChart` 是图表唯一渲染适配器,页面仅提供业务数据和 ECharts option;适配器统一主题、Tooltip、坐标轴和 Pie 扇区样式。相同分类维度的多个数据项使用 `createResolvedXinyuanChartColorMap` 和 `XINYUAN_CHART_CATEGORY_PALETTE` 按稳定顺序分配颜色,顺序为品牌红深浅 → 暖土色 → 中性灰紫 → 灰蓝,禁止绿色、荧光色和彩虹式跳色;单一指标或多指标系列使用 `XINYUAN_CHART_SEMANTIC` 语义色,单系列固定使用 `#DA241A`,页面不得写入硬编码颜色。业务源码禁止直接导入 Recharts 或 HeroUI Pro 图表组件;`recharts` 依赖仅因当前 HeroUI Pro `KPI` 包的静态运行时解析而保留,不作为应用图表渲染器。`XinyuanModal` 统一使用 `bg-background` Dialog Surface。党风廉政台账筛选由 HeroUI `form` 手动提交,字段值先保留在页面草稿,右侧操作固定为重置图标、`查询` 主按钮和 `展开/收起` Disclosure;查询时复用原有 `reload` 请求参数,不改变接口协议。列表分页由 `DataTablePagination` 统一承载容量选择器与页码状态:容量使用 HeroUI Autocomplete 受控单选,标准选项为 `20 / 50 / 100 条/页`、默认值必须属于选项集合,固定少量选项时关闭 `showSearch` 与 `isClearable`,切换容量回到第 1 页。
- Pie/Donut 在共享适配器中独立使用 `--rc-pie-chart-*`;适配器按完整稳定分类 key 生成确定性颜色映射并覆盖旧页面 `itemStyle.color`,只改变扇区颜色,不改变半径、间隙、圆角、标签、图例、Tooltip、尺寸或回调。普通 Bar/Line/Ranking 继续使用 `--rc-chart-*`,不得复用 Pie 专用色板。
长列表 DataGrid 的 `.data-table-scroll` 只隔离横向滚动;纵向在首行/末行继续滚动时必须允许滚动链传递到 `.app-shell-content-scroll`,保证筛选、列表和 Pagination 可以连续浏览,不增加页面级滚轮监听或改变数据请求时机。
驾驶舱模块标题沿用“审计项目管理”基准的 HeroUI `Card.Header` 左对齐横向结构;`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色)与 `min-w-0 flex-1 text-left`,操作或描述信息放在标题右侧,不通过页面私有 CSS 改写字号、颜色或字重。
普通业务单选 Autocomplete 在已有值时显示 HeroUI 官方 `Autocomplete.ClearButton`,由 Trigger 内部清除语义同步受控值且不会展开下拉;固定少量选项的页容量 Autocomplete 必须显式传 `showSearch={false}`、`isClearable={false}`,不显示搜索或删除图标。
台账详情预览使用固定功能标题,记录标题作为正文首项展示;编辑态在同一位置使用 HeroUI `Input` 修改顶层 `record.title`,不改变 `metadata`。标题展示尊重当前 View 的 `shortTitle` 配置。
详情弹窗结构固定为 HeroUI `Modal.Root > Modal.Backdrop > Modal.Container > Modal.Dialog`。字段控件直接来自 `@heroui/react`(`Input`、`TextArea`、`DatePicker`、`Autocomplete`)或 `@heroui-pro/react`(`DropZone`);预览态使用静态只读值,编辑态使用 `secondary` 控件,权限禁用字段不显示灰色伪控件。不得引入其它 UI 框架、原生表单控件或自制 Modal/字段外观。
预览态底部仅显示 `primary` 编辑按钮作为主操作;点击后通过同一 Modal 内的状态切换进入编辑态,不重建记录或材料会话。编辑态底部显示 `secondary` 取消与 `primary` 保存,取消关闭弹窗且不触发保存。
# 鑫元 UI 迁移清单
## 基础
- [x] 核对 Git、remote、当前分支、路由 base 与 npm 配置
- [x] 阅读 `record-api.md`、`subjectsDefinition.md` 和党风廉政业务流程
- [x] 建立设计规范、审计、迁移清单和架构文档
- [x] 同步权威 HeroUI 主题与扩展 Tokens
- [x] 品牌专项:HeroUI 主色固定为 `#DA241A`,Hover/Pressed 使用品牌红深色派生,图表与 KPI 使用低饱和暖/中性/灰蓝色板且不含绿色
- [x] 通过官方 `hpsetup` 安装并精确锁定 HeroUI Pro 1.0.0-beta.8,移除本地 `file:` 镜像
- [x] 收敛应用与 Pro 使用的 HeroUI 3.2.2 / React Aria Components 1.19.0 运行时
- [x] 删除多余 lockfile、旧 UI 框架依赖与未引用旧组件
## 页面
- [x] Login:迁移 HeroUI 原生控件、验证码、filled/autofill 和响应式结构
- [x] Shell:迁移品牌、offcanvas、用户菜单、主题和 base-aware 导航
- [x] 应用切换:同步 `model_analysis` HeroUI Modal、`site.group` 动态分组、真实数量、“其他应用”/“全部应用”回退和 36px identity well
- [x] 驾驶舱:KPI 使用 HeroUI Pro KPI compound API,所有业务图表统一通过共享 `XinyuanEChart`(ECharts)适配器渲染
- [x] 党风廉政 Tabs、结构化筛选与透明工具栏 SearchField;结构化筛选按基准使用重置图标、查询按钮和 Disclosure 手动提交,关键词搜索保持原有延迟
- [x] 党风廉政 DataGrid、选择、ActionBar、20/50/100 页容量 Pagination;默认 20,切换后回到第 1 页且无清除图标/搜索框
- [x] 长列表滚动边界:DataGrid 到达首/末行后继续纵向滚动可自然传递至页面,底部 Pagination 可达;横向滚动仍限制在列表内
- [x] 新建/编辑、导入、批量修改、删除确认 Modal
- [x] 详情预览/编辑遵循 HeroUI Modal:预览使用固定功能标题 + 正文记录标题静态值,编辑使用 HeroUI Input 修改顶层 `record.title`;权限禁用字段保持只读值,不以灰色控件冒充预览
- [x] 详情预览态通过同一 Modal 的 `primary` 编辑按钮平滑切换编辑态;编辑态显示取消/保存,保留字段与材料草稿,取消不保存
- [x] DropZone 上传、附件维护、Pro DataGrid Excel 预览
## 业务回归
- [x] 静态核对 6 类 Subject 查询与 Tab 切换
- [x] 静态核对新建、更新、软删除 API
- [x] 静态核对关键词/年份/关键字段查询
- [x] 静态核对 Excel 模板、导入、全部导出、勾选导出
- [x] 静态核对批量修改与批量删除
- [x] 静态核对日期 ISO 8601、boolean 三态、附件数组协议
## 验收
- [x] `npm run build`(最终工作树重新执行)
- [x] `git diff --check`(最终工作树重新执行)
- [x] Xinyuan audit 脚本与旧 import 扫描
- [x] 页面私有视觉 CSS 扫描归零
- [x] `file:`、本机路径与授权值扫描归零
- [x] HeroUI / React Aria 依赖树检查
- [x] HeroUI / HeroUI Pro 官方 CSS 入口进入生产构建产物
- [x] Pro CSS 文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 子路径
- [x] 1440x900、1280x720、768x1024、390x844
- [x] 浅色/深色、打开态弹层、选择与 Checkbox、Modal、Sidebar
- [x] HeroUI v3 Checkbox 组合结构全局收口;搜索、时效性、布尔字段、普通表格及 Pro DataGrid/ListView/FileTree 的未选中框保持可见
- [x] 活动页面无 runtime、slot、provider、重复 Context 错误
运行态验收说明:2026-08-26 使用 Playwright CLI 隔离会话完成四视口与深浅主题检查;1440x900 浅色、1280x720/768x1024/390x844 深色台账均已截图,390px `scrollWidth === 390`。通知页额外完成 1440/390 浅色截图。既有 HeroUI Pro Mobile Sidebar 隐藏 Dialog title 告警仍存在,但活动页面无运行时错误,未发现 slot、provider 或重复 Context 错误。
## 2026-08-26 应用切换专项验收
- [x] 与 `model_analysis` 同时打开并在 1440x900、1280x720、768x1024、390x844 测量:目标与基准均为桌面/平板 736px、390 视口 358px,24px 圆角、28px inset、24px 关闭按钮和 36px identity well。
- [x] `sm` 起严格两列、移动端一列,所有网格横纵 gap 12px;Body 独立滚动且长列表不增加第三列。
- [x] 动态分组保持配置和组内顺序、过滤空组与无权限应用,显示真实数量;未归组应用进入“其他应用”,无配置时使用单个“全部应用”。
- [x] 浅色和深色的 Modal/Card/Surface 均由 HeroUI 语义 Surface 提供,无白色硬编码背景;“当前”仅出现一次。
- [x] Escape、关闭按钮、焦点约束、dialog accessible name 与 `aria-current` 通过。
- [x] 应用切换自身无控制台错误或告警。
- [ ] 390px 从 Mobile Sidebar 入口打开时仍会触发既有 Sidebar Dialog title 告警,与本次应用切换组件无关,未扩大范围修改。
## 2026-08-26 跨应用五项能力
- [x] 应用归属判定为完整功能白名单“党风廉政建设”。
- [x] Grid `shortTitle` 只控制 title 展示列,操作列入口保留。
- [x] Detail `shortTitle` 随当前 Detail View 更新,Modal 功能标题始终保留。
- [x] `viewAuth/settings.auth/disableColumns` 只读取 `incorrupt_party_build` 授权;无权限不查询、不显示操作。
- [x] 对齐问题整改 `c047ec4`:稳定 `APPLICATION_NAME` 统一为 `incorrupt_party_build`,不合并其它应用身份,授权类型只接受 group/role/permission。
- [x] 对齐问题整改 `6763c26`:Detail 有有效 `auth/disableColumns` 时整套优先,两项均无配置时整体回退 Grid;不合并、不逐字段回退。
- [x] 无真实可访问 Grid View 时不再为管理员或本应用“管理”角色创建静态兜底 View。
- [x] 同步本机 `$xinyuan-design-system` 跨应用契约并通过 Skill 校验,移除旧的 Grid-only 详情授权规则。
- [x] `/notifications`、消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 action 与跳转。
- [x] Navbar 原生 Badge Bell,0 隐藏、1-99 原值、100+ 为 `99+`,Sidebar 无通知项。
- [x] 文件字段按 `multiple` 支持材料中心与本地上传;材料远程搜索标题/文件名并只保存文件快照。
- [x] 本地文件保存时建档、全部成功后更新业务记录、失败重试跳过已建档项、取消不建档。
- [x] 管理员/管理角色/普通/无权限浏览器 fixture:其它应用“管理”不串权;本应用“管理”和系统管理员可旁路 `auth`,但均不能旁路 Detail `disableColumns`;普通无权限时新增/行操作和 `/record/query` 均为 0。
- [x] Detail 权限来源浏览器 fixture:完整配置通过 `GET /subject/:subject/view/:view` 加载;Detail 有配置时优先、无配置或无实际 Detail 时整体回退 Grid;Detail 只配置其它字段时,当前展示字段不会逐字段回退 Grid。
- [x] 零 Grid 浏览器 fixture:管理员也不建立兜底 View,新增按钮和 `/record/query` 均为 0;当前真实后端前五个 Subject 无 Grid View,因此按合同显示无可访问视图。
- [x] 通知 0/1/99/100、已读、全部已读、动态 action 与内外链浏览器 fixture 验收:Badge 分别为隐藏/1/99/99+;搜索、未读筛选、第 2 页、单条/全部已读均命中原接口;动态 action 发送 `{notice_id}`;站内链接落到当前 base,外链新窗口打开。
- [x] 适用文件字段的材料选择、本地上传、取消、失败与重试浏览器 fixture 验收:当前六类台账的活动文件字段均为 `multiple: true`,无活动单值文件字段;材料中心打开态和远程列表已验证。本地上传夹具中上传 1 次、材料建档 1 次、业务保存 2 次,首次业务失败后弹窗保留,重试不重复建档;取消夹具中上传 1 次、材料/业务创建均为 0。
- [x] 文件 payload 验收:业务附件只包含 `{name,type,size,url}`,不含材料 ID 或内部重试标记;材料记录 `source_app` 为 `incorrupt_party_build`。
# 本轮同步清单
- [x] 固定深色 Sidebar 与基准选中/hover 交互
- [x] Navbar 移除主题切换,保留通知入口
- [x] Pagination 页容量 20/50/100;默认值属于选项集合,选择器关闭清除图标和关键词搜索
- [x] Modal 背景与页面 `bg-background` 一致
- [x] 资源统计统一 ECharts,饼图扇区 gap/radius
- [x] 饼图/环形图独立使用 `--xinyuan-pie-chart-*` / `--rc-pie-chart-*`:前六色固定、稳定分类 key 映射、冲突消解,无绿色/青绿色/荧光/彩虹色;普通 Bar/Line/Ranking 色板保持不变
- [x] 驾驶舱模块标题统一为 HeroUI `Card.Title` + `.rc-module-title`:16px/600、24px 行高、主题前景色,标题左对齐,右侧描述与操作保持同一行
- [x] 业务 API、权限、保存回调未改动
This diff is collapsed.
# 鑫元 UI 初始审计
审计日期:2026-08-19
> 本文记录迁移开始时的初始问题与后续验收结果;当前实现统一使用 HeroUI / HeroUI Pro,初始状态中的旧 UI 框架描述不代表现行技术栈。
## 工程与路由
- 框架:Umi Max 4、React 19、Tailwind CSS 4。
- 包管理器:`.umirc.ts` 指定 npm;仓库初始同时存在 `package-lock.json` 与 `pnpm-lock.yaml`。
- base/publicPath:`/incorrupt_party_build/`。
- 路由:`/login`、`/resource/stat`、`/integrity-party`。
- 登录:`getCaptcha` + `userLogin`,密码保持现有 AES 加密;失败后刷新验证码。
- 登出:`userLogout`,随后清理本地认证并跳转到 base-aware 登录页。
## 业务保护项
- 查询:`POST /record/query`。
- 新建:`POST /record`。
- 更新:`PUT /record/:name`。
- 删除:`DELETE /record/:name`,默认软删除。
- 所有业务字段写入 `metadata`;日期保存为 ISO 8601;附件为 `{name,type,size,url}[]`。
- Subject:`integrity_party_training`、`integrity_party_talk`、`integrity_party_research`、`integrity_party_meeting`、`integrity_party_activity`、`integrity_party_holiday_reminder`。
- Excel 导入逐条新建且不做重复判断;附件不参与 Excel 导入。
## 初始 UI 与架构问题
- 缺失权威 `src/design-system/theme.css`,旧 `tokens.css` 重定义 HeroUI 核心变量。
- `integrity-party/index.jsx` 混用旧 UI Button/Input/Dialog 与 HeroUI。
- 应用切换使用旧 UI 交互组件,应用 Logo 未使用统一 `Surface tertiary + Avatar soft` identity well。
- 登录页使用可见原生 input/button,未建立完整 HeroUI TextField 边界。
- Dropdown 选择仍通过兼容 `FormSelect` 模拟旧 `<option>` / DOM change 事件。
- 文件上传共享组件合成 `event.target.files`,不符合当前 HeroUI 文件模型。
- 分页带页容量 Select,且支持 10/20/50,偏离固定 10 条协议。
- `DataTable.jsx` 仍保留旧 Table 兼容解析器与页面私有分页窗口逻辑。
- Dashboard 使用 ECharts 与语义状态色混合作为分类色,含渐变条形色和非批准色值。
- 初始依赖树存在 HeroUI 3.2.2/3.2.3 与 React Aria 1.19/1.20 两套实例。
- HeroUI Pro 初始来源为相邻项目的本地镜像,无法跨机器复现。
- 初始仓库缺失四份鑫元迁移文档。
## 页面范围
| 页面/表面 | 初始状态 | 迁移目标 |
| --- | --- | --- |
| Login | 部分视觉迁移,原生控件 | HeroUI Card/TextField/Input/Button/Alert,保留验证码协议 |
| Shell | Pro Sidebar/Navbar 已接入,样式与名称需核验 | 原生 Pro Shell、对称 gutter、真实退出、根主题 |
| 应用切换 | 旧 UI 交互组件 | HeroUI Modal 与统一 identity well |
| 驾驶舱 | HeroUI Card + ECharts 混合 | 鑫元 24px Card/KPI、批准图表色、暗色 Tooltip |
| 党风廉政台账 | 旧 UI/HeroUI 混合 | HeroUI Tabs/Surface/Autocomplete/Modal、Pro DataGrid/DropZone/ActionBar |
## 初始依赖风险
迁移开始时使用相邻项目的本地文件依赖,不满足团队交付要求。该风险已在后续官方安装中消除。
## 当前迁移状态与阻断项
- 项目权威 `theme.css` 已完成品牌专项更新:HeroUI `accent/focus` 固定为 `#DA241A`,Hover/Pressed 使用 `#C91F17/#B51B14`,旧 Tokens 不重定义 HeroUI 核心变量。
- 已通过官方 `hpsetup` 在当前仓库独立安装 `@heroui-pro/react@1.0.0-beta.8`;`package.json`、lockfile 和安装目录均不再依赖相邻项目、绝对路径或软链接。
- HeroUI 基础样式入口已更正为 `@heroui/styles/css`。此前包根 JavaScript 入口导致 Tabs、Input、Button、Dropdown 和 DataGrid 缺失官方结构样式,是本次截图中纵向 Tabs、裸控件和尺寸异常的共同根因。
- 旧 UI 框架、旧 Select/Table 兼容层与多余 pnpm lockfile 已从活动源码移除。
- Login、Shell、党风廉政筛选/DataGrid/Modal/DropZone 已完成原生 HeroUI/HeroUI Pro 结构迁移。应用切换进一步同步 `model_analysis` 当前基准:保留 `/application/info` 与用户权限合并,读取 `/info -> site.group` 动态分组,显示真实组数并处理“其他应用”/“全部应用”回退。
- 党风廉政筛选对齐问题整改基线:Surface 内前三项等宽排列,溢出条件通过 Disclosure 展开;移除刷新和查询按钮,字段变化后自动请求,关键词采用约 300ms 延迟。
- 驾驶舱 KPI 使用 HeroUI Pro `KPI` compound API;所有业务图表统一使用共享 `XinyuanEChart`(底层 `echarts-for-react`/ECharts)适配器,页面只提供业务数据和 ECharts option。
- `src/xinyuan-portable.css` 已删除;页面目录不存在私有 CSS/Less/SCSS。`theme.css` 作为本应用品牌主题权威源,`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 保持共享结构与布局契约。
- 广义关键词已移到透明列表工具栏的 HeroUI `SearchField`,结构化筛选 Surface 仅保留年份和分类关键字段。
- 2026-08-19 最终静态门禁:`npm run build`、`git diff --check`、鑫元审计脚本均成功;旧组件、非 ECharts 图表库调用、页面私有视觉类、跨项目依赖、本机路径和授权值均无有效源码命中。
- HeroUI Pro 的 CSS、入口文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 导入均可用。
- 依赖树的应用与 Pro 主路径统一使用 HeroUI 3.2.2 / React Aria Components 1.19.0;Adobe Spectrum 的间接依赖仍包含 React Aria Components 1.20.0,不进入应用直接组件边界。
- 2026-08-26 修改前开发服务以 `npm run dev` 启动于 `http://localhost:8003/incorrupt_party_build/`,base/publicPath 为 `/incorrupt_party_build/`;未登录状态跳转 `/login?redirect=%2F`,控制台无错误或警告。应用切换改动后的四视口、深浅主题和打开态结果记录在迁移清单中。
- 应用切换已与 `model_analysis` 在四个规定视口逐项测量一致,深浅主题均通过;桌面入口控制台无错误或告警。移动端打开 Sidebar 时 HeroUI Mobile Sidebar 仍产生既有 Dialog title 告警,应用切换自身具备 accessible name/Heading,未产生新增告警。
## 原始业务状态记录
本次指令到达时工作树已经包含未提交 UI 迁移,无法再通过直接启动工作树还原“未修改页面”。原始业务结构改由 Git `HEAD` 只读核对:驾驶舱通过 `loadIntegrityPartyDashboard` 加载真实统计数据;党风廉政页面保留六类 Subject、固定 10 条分页、Excel 导入导出、附件上传、新建/更新/软删除和批量操作。迁移不得改变这些协议和回调。
## 工具能力说明
当前会话未暴露 HeroUI Pro MCP 的 `list_components` / `get_component_docs` 工具。组件 API 以当前仓库已安装包的真实 exports、类型声明和确认基准实现为依据;不得猜测 API。2026-08-26 已改用 Playwright CLI 隔离会话完成四视口、深浅主题、权限、通知与文件事务浏览器门禁,结果记录于迁移清单。
## 2026-08-26 五项能力审计
- 范围判定:本应用为“党风廉政建设”,属于六个完整功能应用;实施功能 1-5。
- Subject View:六个 Subject 中前五个无 View;`integrity_party_holiday_reminder` 有一个 Grid View `view_TqTjZv`。迁移前页面未加载 View、未执行 `viewAuth/settings.auth/disableColumns`,也未支持 `shortTitle`。
- 授权结构:当前账号 Andy 为 `admin` 且 `applications=[]`,用于管理员旁路验收;授权边界固定为 `incorrupt_party_build`。
- 文件协议:现有文件字段为 `{name,type,size,url}[]`,上传调用 `/upload`;迁移前不会创建 `am_material`。新流程保留快照协议并新增保存时建档。
- 通知协议:`DataService.js` 已有 `queryUserNotice/readUserNotice/unReadNoticeCount/exacteActionFunction`,迁移前无通知路由与 Bell。
- 路由与 Shell:base 为 `/incorrupt_party_build`;站内通知链接只剥离该 base,外部或其它应用链接新窗口打开。
- 应用切换:当前 `ApplicationSwitcherDialog` 已使用 HeroUI Modal,`sm:grid-cols-2` 严格两列,无需结构变更。
- 2026-08-26 权限复查:按问题整改 `c047ec4` 增加稳定 `APPLICATION_NAME = "incorrupt_party_build"`,授权仅取当前应用项;按 `6763c26` 改为 Detail 完整字段权限优先、无配置时整体回退 Grid。2026-09-01 增加固定台账 legacy compatibility view,仅用于后端完全未配置 Grid View 的 Subject。
- 当前真实后端前五个台账 Subject 没有 Grid View。为保持迁移前六类固定台账的可用性,只有在某个 Subject 完全没有后端 Grid View 时使用同名 legacy compatibility view;一旦后台配置真实 Grid View,即切换为真实 View 并严格执行 `viewAuth`。
# 2026-09-01 审计项目管理基准复核
- 共享壳层、Sidebar 选中态、Navbar 标题层级已对齐基准。
- 台账 Pagination 已接入 20/50/100 页容量选择器,默认 20;选择器关闭清除图标和关键词搜索,并在切换后回到第 1 页。
- 资源统计图表已迁移到共享 ECharts 适配器;Pie gap/radius 与 Tooltip 由适配器统一管理。
- 2026-09-08 饼图专项审计:`/resource/stat` 的“台账分类分布图”为唯一 Pie 调用点,已从普通 `--rc-chart-*` 分类色板切换至共享 `--rc-pie-chart-*` 专用色板;页面不再提供 `color`/`itemStyle.color` 覆盖。年度分布与年度开展次数为 Bar 图,继续使用普通图表 Token,未受影响。
- 资源统计各模块标题已与“审计项目管理”基准统一:HeroUI `Card.Header` 左对齐横向结构,`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色),右侧描述/操作与标题垂直居中。
- 详情与导入预览 Modal 使用页面背景层级,去除查看态大块灰色 Surface。
This diff is collapsed.
{
"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.2",
"@heroui/styles": "3.2.2",
"@internationalized/date": "3.12.3",
"@number-flow/react": "^0.6.2",
"@react-aria/ssr": "^3.10.1",
"@react-aria/utils": "^3.34.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": "^3.51.0",
"react-aria-components": "1.19.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"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"typescript": "^5.0.3"
}
}
This diff is collapsed.
allowBuilds:
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
core-js: true
core-js-pure: true
es5-ext: true
esbuild: true
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
{
"name": "law_reference_new",
"version": "1.0.1",
"build": "law-reference",
"description": "内部制度与外部法规",
"date": "2025-07-14 19:27:20",
"author": "yang",
"spa": true
}
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { transformSync } = require('@umijs/bundler-utils/compiled/esbuild');
const root = path.resolve(__dirname, '..');
function loadModule(file, dependencies = {}, globals = {}) {
const module = { exports: {} };
const code = transformSync(fs.readFileSync(path.join(root, file), 'utf8'), { format: 'cjs', loader: 'js' }).code;
vm.runInNewContext(code, { module, exports: module.exports, URLSearchParams, Date, ...globals, require: (name) => {
assert.ok(dependencies[name], `Unexpected dependency: ${name}`);
return dependencies[name];
} });
return module.exports;
}
const plain = (value) => JSON.parse(JSON.stringify(value));
(async () => {
const search = loadModule('src/utils/configuredSearch.js');
const fallback = { search: { enabled: true, subjects: ['fallback'] } };
assert.equal(search.resolveSearchConfig({ layouts: { default: { search: { enabled: false } } } }, fallback).enabled, false);
assert.equal(search.resolveSearchConfig({ layouts: { default: {} } }, fallback), undefined);
assert.equal(search.searchIndex({ subjects: [] }), '*');
assert.equal(search.searchIndex({ subjects: ['law', 'policy'] }), 'law,policy');
const q = '基金 A&B + #管理';
const route = new URL(search.globalSearchPath(q, 'law,policy'), 'http://localhost');
assert.equal(route.searchParams.get('q'), q);
assert.equal(route.searchParams.get('subject'), 'law,policy');
assert.equal(search.sanitizeSearchHighlight('<em>法规</em><img src=x onerror=alert(1)>'), '<em>法规</em>&lt;img src=x onerror=alert(1)&gt;');
const requests = [];
const request = (url, options) => { requests.push({ url, options }); return Promise.resolve({}); };
const data = loadModule('src/services/DataService.js', { '../utils/request': { default: request, createRequestWithPrefix: () => request, __esModule: true } }, { STREAMS_API_URL: '/api' });
await data.commonSearch(q, 'law,policy');
await data.aggregateSearch(q, 'law,policy', 0, 500);
for (const { url } of requests) {
assert.equal(new URL(url, 'http://localhost').searchParams.get('keyword'), q);
assert.equal(new URL(url, 'http://localhost').searchParams.get('index'), 'law,policy');
}
const calls = [];
const dependencies = {};
for (const method of ['aggregateRecords','createRecord','executeView','getView','loadRecord','loadRecords','queryTags','queryViews','removeRecord','updateRecord']) {
dependencies[method] = async (...args) => { calls.push({ method, args: plain(args) });
if (method === 'getView') return { _id: 'view-id', name: 'computed-view' };
if (method === 'executeView') return [{ total: 3 }];
if (method === 'createRecord') return { _id: 'created-id', name: 'created-record' };
return {};
};
}
const audit = loadModule('src/services/AuditIndicatorService.js', { './DataService': dependencies });
const group = { name: 'group', metadata: { indicators: [{ _id: 'existing-id' }] } };
const draft = { indicator_name: '风险指标', owner: { _id: 'owner-id' }, related_tags: [{ _id: 'tag-id' }], computed_view: { _id: 'view-id' }, computed_interval: '7' };
await audit.saveIndicator({ subjectName: 'indicator', group, draft });
const saved = calls.find((call) => call.method === 'createRecord').args[0];
assert.equal(saved.metadata.owner, 'owner-id');
assert.deepEqual(saved.metadata.related_tags, ['tag-id']);
assert.equal(saved.metadata.computed_view, 'view-id');
assert.equal(saved.metadata.computed_interval, 7);
assert.deepEqual(calls.find((call) => call.method === 'updateRecord').args[1].metadata.indicators, ['existing-id', 'created-id']);
await assert.rejects(audit.saveIndicator({ subjectName: 'indicator', group, draft: { ...draft, computed_interval: 0 } }), /1/);
await assert.rejects(audit.saveIndicator({ subjectName: 'indicator', group, draft: { ...draft, owner: null } }), /责任人/);
calls.length = 0;
await audit.executeIndicator({ _id: 'indicator-id', metadata: { computed_view: 'view-id', indicator_name: '风险指标' } }, 'results');
assert.deepEqual(calls.find((call) => call.method === 'executeView').args, ['computed-view', {}]);
const result = calls.find((call) => call.method === 'createRecord').args[0];
assert.equal(result.subject, 'results');
assert.equal(result.metadata.related_indicator, 'indicator-id');
assert.deepEqual(result.metadata.execution_data, [{ total: 3 }]);
assert.equal(typeof result.metadata.execution_time, 'number');
calls.length = 0;
await audit.searchIndicatorReferences('view', '测试');
assert.deepEqual(calls[0].args, [0, 20, { title: { $regex: '测试' } }]);
console.log('PASS: configuration scope, disabled fallback, Unicode/URL encoding, safe highlights, reference ID writes, required/numeric validation, execution view resolution and result payload, view query pagination.');
})().catch((error) => { console.error(error); process.exitCode = 1; });
import '@/tailwind.css';
import '@/global.less';
import { defineApp } from 'umi';
export async function getInitialState() {
return {
name: 'law_reference_new',
};
}
export default defineApp({});
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 181.98 34.02"><defs><style>.cls-1{fill:#fff}</style></defs><path class="cls-1" d="M129 29.71h-15.76v-5.24H129v5.24Zm-8.59-13.24c2.49 1.78 5.5 3.5 8.24 4.93h-15.24c2.21-1.29 4.65-2.92 7-4.93m2.48-2.31c.46-.46.91-.94 1.34-1.44l-3.37-2.25c-3.68 5.76-11.92 9.26-17.12 11.61.77 1.33 1.87 3.04 1.87 3.04s1.51-.52 3.79-1.6v10.5h3.84v-1.24H129v1.24h3.84V23.49a98.66 98.66 0 0 0 3.56 1.62l1.36-3.04s-9.94-4.28-14.87-7.91m2.4-2.17 9.9 5.76 1.87-3.04s-5.85-3.05-9.46-5.48c-.34.49-2.32 2.76-2.32 2.76m-8.11.07c-.34-.4-2.32-2.67-2.25-2.82-4.29 2.76-9.78 5.47-9.78 5.47l1.87 3.04 10.15-5.69Zm-8.19-5.05h24.23v3.75h3.84V3.94h-14.07L122.03 0l-3.74.85.74 3.09h-13.89v6.82h3.84V7.01ZM155.47 22.38l-1.78 1.96V9.62h-6.4v3.56h2.99v12.44c0 1.23-.38 2.28-1.14 3.13l2.13 2.42c1.9-1.99 3.79-3.94 5.69-5.84l-1.49-2.95Zm25.09 2.1c-.57-.29-1.04-.47-1.42-.57-.09.38-.14.9-.14 1.57-.28 1.62-.47 2.7-.57 3.27-.19.76-.43 1.14-.71 1.14-.85-.09-1.61-1.37-2.27-3.84l-.14-.85c1.99-3.04 3.84-6.83 5.54-11.39l-2.99-1.28c-.95 3.04-2.13 5.74-3.56 8.11-.47-2.85-.76-6.55-.85-11.1h8.1V6.27h-8.25V0h-3.55c0 2.37.05 4.46.14 6.26h-11.94v9.82c0 4.27-.19 7.36-.57 9.25-.47 1.9-1.42 3.99-2.84 6.26 1.04.76 2.04 1.57 2.99 2.42 2.75-4.27 4.03-9.91 3.84-16.94h3.7s.04-.01.06-.02v8.04h.01c-.04.86-.78 1.55-1.69 1.55-.21 0-1.06-.32-1.25-.38l-1.07 2.68c.69.38 1.48.6 2.33.6 2.57 0 4.66-1.99 4.7-4.45v-11.1l.03-.04h-6.83V9.54h8.53c.29 6.26.85 11.34 1.71 15.23-1.8 2.37-4.08 4.55-6.82 6.55.66.57 1.47 1.42 2.42 2.56 2.08-1.9 3.88-3.65 5.4-5.27 1.23 3.42 2.99 5.12 5.26 5.12 1.52 0 2.61-1.09 3.27-3.27.28-1.33.57-3.13.85-5.41-.28-.19-.76-.38-1.42-.57M155.68 5.84c-1.14-1.52-1.95-2.81-3.84-5.27l-2.99 1.71c2.08 3.04 2.8 4.47 3.56 5.7l3.27-2.13Zm22.6.28 2.42-1.85c-.38-.47-1.04-1.09-1.99-1.85-.85-.85-1.52-1.47-1.99-1.85l-2.42 1.71c.47.47 1.23 1.19 2.28 2.13l1.71 1.71M30.61 0h51.02v8.5H30.61zM0 0h6.63v8.5H0zM10.46 0h16.33v8.5H10.46zM44.64 21.21c0-2-.68-3.62-1.96-4.65-1.32-1.05-2.86-1.3-5.14-1.3h-6.93v18.48h3.76V18.58h3.31c.93 0 1.62.12 2.06.39.66.39 1.05 1.11 1.05 2.24 0 1.93-1.19 2.61-2.74 2.61h-2.5v3.35h1.49l3.91 6.58h4.34l-4.34-7.2c2.53-.74 3.7-2.84 3.7-5.33M56.03 23.27c-2.66-.92-5.66-1.03-5.66-3.05 0-1.3 1.19-1.91 2.88-1.91s3.13.15 5.62.82v-3.35c-2.14-.6-3.7-.82-5.72-.82-3.66 0-6.75 1.46-6.75 5.18 0 2.94 1.46 4.42 4.34 5.41 2.57.88 5.47 1.05 5.47 3.03 0 1.48-1.44 2.1-3.52 2.1s-3.29-.16-5.88-.86v3.37c2.16.64 3.97.82 6.07.82 4.59 0 7.28-2.01 7.28-5.72 0-2.94-1.93-4.26-4.13-5.02M76.73 15.26l-6.59 18.48h3.58l4.18-11.75h.06v11.75h3.68V15.26h-4.91zM71.42 26.76l-4.3-11.5h-4.98v18.48h3.64V21.99h.06l3.72 9.97 1.86-5.2zM91.84 0h1.7v34.02h-1.7z"/></svg>
\ No newline at end of file
<svg width="82" height="35" viewBox="0 0 82 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30.61 0H81.63V8.5H30.61V0ZM0 0H6.63V8.5H0V0ZM10.46 0H26.79V8.5H10.46V0ZM44.64 21.21C44.64 19.21 43.96 17.59 42.68 16.56C41.36 15.51 39.82 15.26 37.54 15.26H30.61V33.74H34.37V18.58H37.68C38.61 18.58 39.3 18.7 39.74 18.97C40.4 19.36 40.79 20.08 40.79 21.21C40.79 23.14 39.6 23.82 38.05 23.82H35.55V27.17H37.04L40.95 33.75H45.29L40.95 26.55C43.48 25.81 44.65 23.71 44.65 21.22M56.03 23.27C53.37 22.35 50.37 22.24 50.37 20.22C50.37 18.92 51.56 18.31 53.25 18.31C54.94 18.31 56.38 18.46 58.87 19.13V15.78C56.73 15.18 55.17 14.96 53.15 14.96C49.49 14.96 46.4 16.42 46.4 20.14C46.4 23.08 47.86 24.56 50.74 25.55C53.31 26.43 56.21 26.6 56.21 28.58C56.21 30.06 54.77 30.68 52.69 30.68C50.61 30.68 49.4 30.52 46.81 29.82V33.19C48.97 33.83 50.78 34.01 52.88 34.01C57.47 34.01 60.16 32 60.16 28.29C60.16 25.35 58.23 24.03 56.03 23.27ZM76.73 15.26L70.14 33.74H73.72L77.9 21.99H77.96V33.74H81.64V15.26H76.73ZM71.42 26.76L67.12 15.26H62.14V33.74H65.78V21.99H65.84L69.56 31.96L71.42 26.76Z" fill="white"/>
</svg>
import 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 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 PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MinusOutlined from '@ant-design/icons/MinusOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
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 StarFilled from '@ant-design/icons/StarFilled';
import StarOutlined from '@ant-design/icons/StarOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UpOutlined from '@ant-design/icons/UpOutlined';
import 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 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 LogOut = withSize(LogoutOutlined);
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 Minus = withSize(MinusOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Star = withSize(StarOutlined);
export const StarSolid = withSize(StarFilled);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const ChevronUp = withSize(UpOutlined);
export const Upload = withSize(UploadOutlined);
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 ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const UserGroupIcon = Team;
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { SiteContext } from '@/wrapper/Site';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
import { useContext, useState } from 'react';
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
searchConfig,
...sidebarProps
}) {
const siteInfo = useContext(SiteContext);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
return (
<Sidebar.Provider
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar
{...sidebarProps}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
/>
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={() => history.push(notificationPath)}
title={title}
searchConfig={searchConfig}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<ApplicationSwitcherDialog
isOpen={isApplicationDialogOpen}
siteInfo={siteInfo}
userInfo={sidebarProps.userInfo}
onOpenChange={setIsApplicationDialogOpen}
/>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, SidebarPanel } from './AppIcons';
import GlobalSearchBar from './GlobalSearchBar';
export default function DashboardNavbar({ notificationCount = 0, onOpenNotifications, title, searchConfig }) {
const { isMobile, isOpen } = useSidebar();
const count = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{searchConfig?.enabled ? <GlobalSearchBar config={searchConfig} /> : null}
{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>
{count > 0 ? <Badge aria-label={`${count}条未读通知`} color="danger" size="sm">{count > 99 ? '99+' : count}</Badge> : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip> : null}
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_NAME } from '@/config/ApplicationConfig';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import {
Avatar,
Button,
Disclosure,
Dropdown,
Label,
} from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName =
userInfo?.display_name || userInfo?.username || '当前用户';
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({
loading,
error,
menuActionState = {},
menuGroups,
onLogout,
onMenuAction,
onOpenApplicationSwitcher,
pathname,
userInfo,
}) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const actionState = item.action ? menuActionState[item.action] : null;
const isActionLoading = actionState?.status === 'loading';
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
isDisabled={isActionLoading}
key={item.key || item.path || item.action}
onAction={item.action ? () => onMenuAction?.(item.action) : undefined}
textValue={item.name}
tooltip={actionState?.message || item.name}
>
<Sidebar.MenuIcon><Icon className={isActionLoading ? 'animate-spin' : ''} size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{isActionLoading ? '正在获取产品手册' : item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
if (group.showLabel === false) return <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>;
if (group.collapsible === false) {
return (
<Sidebar.Group key={group.title}>
<Sidebar.GroupLabel>
<div 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>
</div>
</Sidebar.GroupLabel>
{renderMenu(group)}
</Sidebar.Group>
);
}
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 hidden={false}><Disclosure.Body>{renderMenu(group)}</Disclosure.Body></Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
src={xyLogoWhite}
alt="鑫元"
className="rc-sidebar-logo"
/>
</div>
<div
className="app-sidebar-brand-main rc-sidebar-brand-main"
data-sidebar="label"
>
<span className="app-sidebar-title rc-sidebar-title">
{APP_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>
{loading ? (
<div className="rc-sidebar-state" aria-live="polite">
菜单加载中...
</div>
) : error ? (
<div className="rc-sidebar-state is-error" role="alert">
菜单权限加载失败
</div>
) : (
menuGroups.map(renderGroup)
)}
</Sidebar.Content>
<Sidebar.Footer>
<SidebarUserMenu userInfo={userInfo} onLogout={onLogout} />
</Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
{isMobile ? (
<Sheet.Root isOpen={isMobileOpen} placement={side} onOpenChange={setMobileOpen}>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog aria-label={`${APP_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>
) : null}
</>
);
}
This diff is collapsed.
import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
className = '',
description = '将文件拖到此处,或从本地选择。',
disabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback(
(files) => {
if (!disabled) {
onFilesChange?.(Array.from(files || []));
}
},
[disabled, onFilesChange],
);
const handleDrop = useCallback(
async (event) => {
const fileItems = Array.from(event?.items || []).filter(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles, multiple],
);
return (
<DropZone className={className}>
<DropZone.Area
aria-label={ariaLabel}
isDisabled={disabled}
onDrop={handleDrop}
>
<DropZone.Input
accept={accept}
disabled={disabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
<Upload aria-hidden="true" />
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description>
<DropZone.Trigger isDisabled={disabled}>
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
import {
Calendar as HeroCalendar,
DateField,
DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react';
import {
Calendar as CalendarIcon,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
import XinyuanClearButton from './XinyuanClearButton';
function parseValue(value, mode) {
if (!value) {
return null;
}
try {
return mode === 'datetime'
? parseDateTime(String(value).slice(0, 16))
: parseDate(String(value).slice(0, 10));
} catch {
return null;
}
}
function formatValue(value, mode) {
if (!value) {
return '';
}
const rawValue = value.toString();
if (mode === 'date') {
return rawValue.slice(0, 10);
}
if (rawValue.includes('T')) {
return rawValue.slice(0, 16);
}
return `${rawValue.slice(0, 10)}T00:00`;
}
function parseTimeValue(value) {
if (!value || !String(value).includes('T')) {
return null;
}
try {
return parseTime(String(value).slice(11, 16));
} catch {
return null;
}
}
function formatTimeValue(value) {
return value ? value.toString().slice(0, 5) : '00:00';
}
function combineDateAndTime(value, dateValue, timeValue) {
const dateText = dateValue
? dateValue.toString().slice(0, 10)
: String(value || '').slice(0, 10);
if (!dateText) {
return '';
}
return `${dateText}T${formatTimeValue(timeValue)}`;
}
export default function FormDatePicker({
'aria-label': ariaLabel,
className,
disabled = false,
granularity,
isClearable = true,
max,
min,
mode,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
variant = 'secondary',
...props
}) {
const resolvedMode =
mode === 'datetime' || type === 'datetime-local' ? 'datetime' : 'date';
const resolvedGranularity =
granularity || (resolvedMode === 'datetime' ? 'minute' : 'day');
const parsedValue = useMemo(
() => parseValue(value, resolvedMode),
[resolvedMode, value],
);
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear = isClearable && Boolean(value) && !disabled && !readOnly && !required;
return (
<I18nProvider locale="zh-CN">
<DatePicker
{...props}
aria-label={ariaLabel || '选择日期'}
className={['w-full', className].filter(Boolean).join(' ')}
granularity={resolvedGranularity}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
maxValue={maxValue || undefined}
minValue={minValue || undefined}
value={parsedValue}
onChange={(nextValue) => onChange?.(formatValue(nextValue, resolvedMode))}
>
<DateField.Group className="relative" fullWidth variant={variant}>
<DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
{canClear ? (
<XinyuanClearButton
ariaLabel={`清空${ariaLabel || '日期'}`}
onPress={() => onChange?.('')}
/>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator>
<CalendarIcon
className="inline-flex size-4 items-center justify-center leading-none [&>svg]:m-0 [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</DatePicker.TriggerIndicator>
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover placement="bottom start">
<HeroCalendar>
<HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading />
<HeroCalendar.NavButton slot="next" />
</HeroCalendar.Header>
<HeroCalendar.Grid>
<HeroCalendar.GridHeader>
{(day) => <HeroCalendar.HeaderCell>{day}</HeroCalendar.HeaderCell>}
</HeroCalendar.GridHeader>
<HeroCalendar.GridBody>
{(date) => <HeroCalendar.Cell date={date} />}
</HeroCalendar.GridBody>
</HeroCalendar.Grid>
</HeroCalendar>
{resolvedMode === 'datetime' ? (
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-sm text-muted">时间</span>
<TimeField
aria-label="选择时间"
className="w-36"
granularity="minute"
hideTimeZone
hourCycle={24}
value={timeValue}
onChange={(nextTime) =>
onChange?.(combineDateAndTime(value, parsedValue, nextTime))
}
>
<TimeField.Group fullWidth variant={variant}>
<TimeField.Input>
{(segment) => <TimeField.Segment segment={segment} />}
</TimeField.Input>
</TimeField.Group>
</TimeField>
</div>
) : null}
</DatePicker.Popover>
</DatePicker>
</I18nProvider>
);
}
import { Chip, ComboBox, InputGroup, Label, ListBox, Spinner } from '@heroui/react';
import { useEffect, useState } from 'react';
import { history, useLocation } from 'umi';
import { Search } from '@/components/AppIcons';
import { APP_BASE_PATH } from '@/config/ApplicationConfig';
import { commonSearch } from '@/services/DataService';
import { globalSearchPath, sanitizeSearchHighlight, searchIndex } from '@/utils/configuredSearch';
export default function GlobalSearchBar({ config }) {
const location = useLocation();
const [keyword, setKeyword] = useState('');
const [result, setResult] = useState({ query: '', index: '', items: [], error: '' });
const index = searchIndex(config);
const query = keyword.trim();
const loading = Boolean(query && (result.query !== query || result.index !== index));
const items = loading ? [] : result.items;
useEffect(() => { setKeyword(''); }, [location.pathname, location.search]);
useEffect(() => {
let cancelled = false;
if (!query) return undefined;
const timer = setTimeout(async () => {
try {
const response = await commonSearch(query, index, 0, 5);
if (!cancelled) setResult({ query, index, items: response?.hits?.hits || [], error: '' });
} catch (error) {
if (!cancelled) setResult({ query, index, items: [], error: error?.message || '搜索失败,请重试' });
}
}, 300);
return () => { cancelled = true; clearTimeout(timer); };
}, [query, index]);
const options = query ? [
...items.map((hit, position) => ({
id: `record-${position}`,
hit,
label: hit._source?.title || hit._source?.name || '未命名记录',
})),
{ id: 'all-results', label: `查看“${query}”的全部结果` },
] : [];
return (
<ComboBox
aria-label="全局搜索"
className="rc-global-search"
allowsCustomValue
allowsEmptyCollection={Boolean(query)}
inputValue={keyword}
items={options}
menuTrigger="input"
selectedKey={null}
onInputChange={setKeyword}
onSelectionChange={(key) => {
if (key === 'all-results') {
history.push(globalSearchPath(query, index));
} else {
const record = options.find((item) => item.id === key)?.hit?._source;
const subject = record?.subject?.name || record?.subject;
if (!subject || !(record?.name || record?._id)) return;
window.open(`${APP_BASE_PATH}/${encodeURIComponent(subject)}/${encodeURIComponent(record.name || record._id)}?keyword=${encodeURIComponent(query)}`, '_blank', 'noopener,noreferrer');
}
}}
>
<ComboBox.InputGroup>
<InputGroup className="rc-global-search-input" fullWidth>
<InputGroup.Prefix><Search size={16} /></InputGroup.Prefix>
<InputGroup.Input placeholder="搜索" onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing && query && !event.currentTarget.getAttribute('aria-activedescendant')) {
event.preventDefault();
history.push(globalSearchPath(query, index));
}
}} />
</InputGroup>
</ComboBox.InputGroup>
<ComboBox.Popover className="rc-global-search-popover" placement="bottom end">
<div className="break-words border-b border-default px-3 py-2 text-xs text-muted" role="status">
{loading ? <span className="flex items-center gap-2"><Spinner size="sm" />正在搜索…</span> : result.error || (items.length ? '搜索建议' : '暂无搜索结果')}
</div>
<ListBox aria-label="搜索建议" items={options}>
{(option) => (
<ListBox.Item id={option.id} textValue={option.label} className="items-start py-3">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<Label className="min-w-0 flex-1 whitespace-normal break-words">{option.label}</Label>
{option.hit?._source?.subject?.title ? <Chip className="shrink-0" size="sm">{option.hit._source.subject.title}</Chip> : null}
</div>
{option.hit?.highlight ? <div className="rc-search-suggestion-summary mt-1 line-clamp-2 text-xs text-muted" dangerouslySetInnerHTML={{ __html: sanitizeSearchHighlight(Object.values(option.hit.highlight)[0]) }} /> : null}
</div>
</ListBox.Item>
)}
</ListBox>
</ComboBox.Popover>
</ComboBox>
);
}
import { Checkbox } from '@heroui/react';
export default function HeroCheckbox({ children, className, ...props }) {
return (
<Checkbox className={['rc-hero-checkbox', className].filter(Boolean).join(' ')} {...props}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
{children}
</Checkbox.Content>
</Checkbox>
);
}
import { Label, Switch } from '@heroui/react';
export default function HeroSwitch({ children, className, ...props }) {
return (
<Switch className={['rc-hero-switch', className].filter(Boolean).join(' ')} {...props}>
<Switch.Content>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Label>{children}</Label>
</Switch.Content>
</Switch>
);
}
This diff is collapsed.
import { Button } from '@heroui/react';
import { OpenExternal } from '@/components/AppIcons';
export default function PdfAttachmentViewer({ file }) {
const url = file?.url;
const title = file?.title || file?.name || 'PDF 附件';
if (!url) return null;
const openInNewWindow = () => {
window.open(url, '_blank', 'noopener,noreferrer');
};
return (
<section aria-label={`${title}预览`} className="law-pdf-viewer">
<div className="law-pdf-viewer-header">
<span title={title}>{title}</span>
<Button
isIconOnly
aria-label="在新窗口打开 PDF"
size="sm"
title="在新窗口打开 PDF"
variant="tertiary"
onPress={openInNewWindow}
>
<OpenExternal size={14} />
</Button>
</div>
<object
aria-label={`${title} PDF 内容`}
className="law-pdf-viewer-frame"
data={url}
type="application/pdf"
>
<div className="law-pdf-viewer-fallback">
<p>当前浏览器无法直接预览此 PDF。</p>
<a href={url} rel="noreferrer" target="_blank">打开 PDF 附件</a>
</div>
</object>
</section>
);
}
import { useEffect, useState } from 'react';
import XinyuanAutocomplete from './XinyuanAutocomplete';
const optionFrom = (item) => ({ id: item._id, label: item.title || item.label || item.name });
export default function ReferenceSearchSelect({ label, value, onChange, loadOptions, initialRecords = [], multiple = false, isRequired = false }) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState(() => initialRecords.filter((item) => item?._id).map(optionFrom));
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const selected = new Set(multiple ? value || [] : value ? [value] : []);
useEffect(() => {
let cancelled = false;
setLoading(true); setError('');
const timer = setTimeout(async () => {
try {
const items = (await loadOptions(keyword)).map(optionFrom);
if (!cancelled) setOptions((current) => [...new Map([...current.filter((item) => selected.has(item.id)), ...items].map((item) => [item.id, item])).values()]);
} catch (requestError) {
if (!cancelled) setError(requestError?.message || '选项加载失败');
} finally { if (!cancelled) setLoading(false); }
}, 300);
return () => { cancelled = true; clearTimeout(timer); };
}, [keyword, loadOptions, value]);
return <div className="space-y-2">
<div className="text-sm font-medium">{label}{isRequired ? <span className="ml-1 text-danger">*</span> : null}</div>
<XinyuanAutocomplete
aria-label={label}
placeholder={`请选择${label}`}
inputValue={keyword}
onInputChange={setKeyword}
options={options}
isLoading={loading}
errorText={error}
isRequired={isRequired}
shouldFilter={false}
selectionMode={multiple ? 'multiple' : 'single'}
selectedKey={multiple ? undefined : value}
selectedKeys={multiple ? value || [] : undefined}
onSelectionChange={(selection) => onChange(multiple ? [...selection] : selection || null)}
onClear={() => onChange(multiple ? [] : null)}
/>
</div>;
}
import {
Back,
Check,
Clock,
Close,
Delete,
Edit,
Eye,
More,
Send,
} from '@/components/AppIcons';
import { Button, Dropdown, Label, Tooltip } from '@heroui/react';
import clsx from 'clsx';
import { Children, Fragment, isValidElement } from 'react';
function resolveActionTone({ icon, tone }) {
if (tone) {
return tone;
}
const iconTone = Object.entries(TABLE_ACTION_ICONS).find(
([, ActionIcon]) => ActionIcon === icon,
)?.[0];
if (iconTone) {
return iconTone;
}
return 'view';
}
export const TABLE_ACTION_ICONS = {
approve: Check,
delete: Delete,
edit: Edit,
history: Clock,
reject: Close,
revoke: Back,
submit: Send,
view: Eye,
};
function flattenActions(children) {
const actions = [];
Children.forEach(children, (child) => {
if (!child) {
return;
}
if (isValidElement(child) && child.type === Fragment) {
actions.push(...flattenActions(child.props.children));
return;
}
actions.push(child);
});
return actions;
}
export function TableActionGroup({ className, children, ...props }) {
const actions = flattenActions(children);
const shouldCollapseOverflow = actions.length >= 4;
const visibleActions = shouldCollapseOverflow ? actions.slice(0, 2) : actions;
const overflowActions = shouldCollapseOverflow ? actions.slice(2) : [];
return (
<div
{...props}
className={clsx(
'flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap',
className,
)}
>
{visibleActions}
{overflowActions.length > 0 ? (
<Dropdown>
<Dropdown.Trigger>
<Tooltip delay={0}>
<Button
isIconOnly
size="sm"
variant="tertiary"
className="grid place-items-center p-0"
aria-label="更多操作"
>
<More size={16} />
</Button>
<Tooltip.Content>更多操作</Tooltip.Content>
</Tooltip>
</Dropdown.Trigger>
<Dropdown.Popover className="min-w-36" placement="bottom end">
<Dropdown.Menu
aria-label="更多操作"
onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}
>
{overflowActions.map((action, index) => {
const label = action?.props?.label || `操作 ${index + 3}`;
const Icon = action?.props?.icon;
return (
<Dropdown.Item
id={String(index)}
isDisabled={action?.props?.isDisabled}
key={`${label}-${index}`}
textValue={label}
>
<span className="flex items-center gap-2">
{Icon ? <Icon className="size-4" aria-hidden="true" /> : null}
<Label>{label}</Label>
</span>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
) : null}
</div>
);
}
export function TableActionButton({
className,
isDisabled,
icon: Icon,
label,
onPress,
tone,
...props
}) {
const resolvedTone = resolveActionTone({
icon: Icon,
tone,
});
const variant =
resolvedTone === 'delete' || resolvedTone === 'reject'
? 'danger-soft'
: resolvedTone === 'approve' || resolvedTone === 'submit'
? 'secondary'
: 'tertiary';
return (
<Tooltip delay={0}>
<Button
{...props}
isIconOnly
aria-label={label}
isDisabled={isDisabled}
size="sm"
variant={variant}
onPress={onPress}
className={clsx('rc-table-action-button grid place-items-center p-0', className)}
>
<Icon size={16} />
</Button>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip>
);
}
import { Check, ChevronDown, Close, FIELD_SUFFIX_ICON_STYLE, Search } from '@/components/AppIcons';
import { Autocomplete, Label, ListBox, SearchField, Spinner } from '@heroui/react';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
const optionKey = (option) => option?.id ?? option?.value;
const toSelectionKey = (value) => String(value ?? '');
export default function XinyuanAutocomplete({
'aria-label': ariaLabelProp, ariaLabel, className, emptyText = '暂无可选项', errorText = '', filter,
fullWidth = true, inputValue, isDisabled = false, isLoading = false, isOpen, isRequired = false, name,
onInputChange, onOpenChange, onSelectionChange, options = [], placeholder = '请选择', placement = 'bottom start',
popoverHeader, renderOption, searchPlaceholder = '输入关键词搜索', selectedKey, selectedKeys,
selectionMode = 'single', shouldFilter = true, valueText, variant = 'secondary',
showSearch = true, isClearable = true,
onClear,
}) {
const effectiveAriaLabel = ariaLabelProp || ariaLabel || placeholder;
const selectedKeySet = selectionMode === 'multiple'
? new Set(selectedKeys || [])
: new Set(selectedKey === null || selectedKey === undefined || selectedKey === '' ? [] : [selectedKey]);
const selectedKeyStringSet = new Set(Array.from(selectedKeySet).map(toSelectionKey));
const selectedOptions = options.filter((option) => selectedKeyStringSet.has(toSelectionKey(optionKey(option))));
const resolvedValueText = valueText ?? (selectedOptions.length ? selectedOptions.map((option) => option.label).join('、') : placeholder);
const rootSelectionProps = selectionMode === 'multiple'
? { selectedKeys: selectedKeyStringSet }
: { selectedKey: selectedKey ? toSelectionKey(selectedKey) : null };
const listBoxSelectionProps = selectionMode === 'multiple' ? { selectionMode, selectedKeys: selectedKeyStringSet } : {};
const visibleOptions = isLoading || errorText ? [] : options;
const handleMultipleOptionPress = (option) => {
if (selectionMode !== 'multiple' || !onSelectionChange || (option.isDisabled ?? option.disabled)) return;
const key = toSelectionKey(optionKey(option));
if (!key) return;
const nextKeys = new Set(Array.from(selectedKeyStringSet).filter(Boolean));
if (nextKeys.has(key)) nextKeys.delete(key);
else nextKeys.add(key);
onSelectionChange(nextKeys);
};
return (
<Autocomplete {...rootSelectionProps} aria-label={effectiveAriaLabel} className={className} fullWidth={fullWidth} isDisabled={isDisabled} isOpen={isOpen} isRequired={isRequired} name={name} onClear={onClear} selectionMode={selectionMode} variant={variant} onOpenChange={onOpenChange} onSelectionChange={onSelectionChange}>
<Autocomplete.Trigger>
<Autocomplete.Value><span className="block min-w-0 truncate" title={resolvedValueText}>{resolvedValueText}</span></Autocomplete.Value>
{isClearable && selectionMode === 'single' && selectedKey !== null && selectedKey !== undefined && selectedKey !== '' ? <Autocomplete.ClearButton aria-label="清空选择" /> : null}
<Autocomplete.Indicator><ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} /></Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter filter={shouldFilter ? filter || defaultFilter : () => true} inputValue={inputValue} onInputChange={onInputChange}>
{popoverHeader}
{showSearch ? <SearchField aria-label={searchPlaceholder} fullWidth variant={variant}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input autoFocus placeholder={searchPlaceholder} /><SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField> : null}
<ListBox {...listBoxSelectionProps} aria-label={effectiveAriaLabel} items={visibleOptions} renderEmptyState={() => <div className="grid min-h-20 place-items-center px-3 py-4" data-slot="empty-state">{isLoading ? <Spinner size="sm" /> : errorText || emptyText}</div>}>
{(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(optionKey(option)));
return (
<ListBox.Item id={toSelectionKey(optionKey(option))} isDisabled={option.isDisabled ?? option.disabled} textValue={option.textValue || option.label} onAction={selectionMode === 'multiple' ? () => handleMultipleOptionPress(option) : undefined}>
{renderOption ? renderOption(option) : <Label className="truncate" title={option.label}>{option.label}</Label>}
{selectionMode === 'multiple' ? <span aria-hidden="true" className="ml-auto inline-flex w-5 justify-center text-foreground">{isSelected ? <Check size={14} /> : null}</span> : <ListBox.ItemIndicator />}
</ListBox.Item>
);
}}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import { Button } from '@heroui/react';
import { Close, FIELD_SUFFIX_ICON_STYLE } from './AppIcons';
export default function XinyuanClearButton({ ariaLabel, onPress, isDisabled = false }) {
const stopTrigger = (event) => {
event.stopPropagation();
};
return (
<Button
aria-label={ariaLabel}
className="size-5 min-w-5 p-0"
isDisabled={isDisabled}
isIconOnly
size="sm"
variant="ghost"
onPointerDown={stopTrigger}
onMouseDown={stopTrigger}
onClick={(event) => {
stopTrigger(event);
onPress?.();
}}
>
<Close
className="inline-flex size-3.5 items-center justify-center leading-none [&>svg]:m-0 [&>svg]:block [&>svg]:size-3.5"
size={14}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</Button>
);
}
import {
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: { color: theme.axis, fontFamily: theme.fontFamily, fontSize: 12, ...item?.axisLabel },
axisLine: { ...item?.axisLine, lineStyle: { color: theme.grid, ...item?.axisLine?.lineStyle } },
axisTick: { ...item?.axisTick, lineStyle: { color: theme.grid, ...item?.axisTick?.lineStyle } },
splitLine: { ...item?.splitLine, lineStyle: { color: theme.grid, ...item?.splitLine?.lineStyle } },
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
export default function XinyuanEChart({ ariaLabel, className = '', height = 320, option }) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' }), []);
useEffect(() => { window.addEventListener('resize', hideTooltip); return () => window.removeEventListener('resize', hideTooltip); }, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const sourceSeries = Array.isArray(option?.series)
? option.series
: [option?.series];
const pieKeys = sourceSeries.flatMap((series) => (
series?.type === 'pie' && Array.isArray(series.data)
? series.data.map((item) => item?.name)
: []
));
const pieColorMap = createResolvedXinyuanPieChartColorMap(pieKeys);
const applySeries = (series) => {
if (series?.type !== 'pie') return series;
const data = Array.isArray(series.data)
? series.data.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const key = String(item.name ?? '').trim();
return {
...item,
itemStyle: {
...item.itemStyle,
// Pie/donut colors always come from the dedicated palette. This
// deliberately overrides legacy page-level itemStyle colors.
color: pieColorMap.get(key) || theme.piePalette[index % theme.piePalette.length],
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
};
})
: series.data;
return {
...series,
color: theme.piePalette,
itemStyle: {
...series.itemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
data,
padAngle: series.padAngle ?? 1,
};
};
const series = Array.isArray(option?.series)
? option.series.map(applySeries)
: applySeries(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: { enabled: true, decal: { show: false }, ...option?.aria },
color: option?.color || theme.palette,
textStyle: { color: theme.label, fontFamily: theme.fontFamily, ...option?.textStyle },
...option,
series,
legend: option?.legend ? { icon: 'circle', itemHeight: 8, itemWidth: 8, ...option.legend, textStyle: { color: theme.label, fontFamily: theme.fontFamily, fontSize: 12, lineHeight: 16, ...option.legend?.textStyle } } : undefined,
tooltip: { appendToBody: true, backgroundColor: theme.tooltipBackground, borderColor: theme.tooltipBorder, borderWidth: 1, className: 'rc-echarts-tooltip', confine: true, renderMode: 'html', extraCssText: `border-radius: 12px; box-shadow: ${theme.tooltipShadow};`, ...option?.tooltip, textStyle: { color: theme.foreground, fontFamily: theme.fontFamily, fontSize: 12, ...option?.tooltip?.textStyle } },
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return <div aria-label={ariaLabel} className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()} role="img" tabIndex={0}><ReactECharts lazyUpdate notMerge onEvents={{ globalout: hideTooltip }} option={themedOption} opts={{ renderer: 'canvas' }} ref={chartRef} style={{ height, width: '100%' }} /></div>;
}
import { Surface } from '@heroui/react';
export default function XinyuanFilterSurface({ children }) {
return (
<Surface className="rounded-3xl p-6" variant="default">
{children}
</Surface>
);
}
import { Modal } from '@heroui/react';
import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = {
xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]',
};
export default function XinyuanModal({
isOpen,
onOpenChange,
isDismissable,
title,
description,
size = 'lg',
dialogClassName,
bodyClassName,
children,
footer,
}) {
const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg';
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isDismissable={isDismissable}
isOpen={isOpen}
variant="blur"
onOpenChange={onOpenChange}
>
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
aria-label={title}
className={clsx(
'xy-modal-dialog bg-background p-7',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
{description ? <div className="text-sm text-muted">{description}</div> : null}
</Modal.Header>
<Modal.Body className={bodyClassName} style={{ marginTop: '0.75rem' }}>
{children}
</Modal.Body>
{footer ? <Modal.Footer style={{ marginTop: '1.5rem' }}>{footer}</Modal.Footer> : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export const APP_NAME = '法律法规库';
export const APPLICATION_NAME = 'law_reference_new';
export const APP_ROUTE_NAME = APPLICATION_NAME;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const APP_BASE_PATH = `/${APP_ROUTE_NAME}`;
export const LOGIN_PATH = '/login';
export const APPLICATION_LAYOUT = {
default: {
mode: 'top',
content: { width: 'Fluid' },
menu: {
items: [
{ path: '/external_law_new', label: '外部法规(法询)', icon: '' },
{ path: '/regulatory_notification_new', label: '监管通报(法询)' },
{ path: '/casus', label: '处罚案例(法询)' },
{ path: '/internal_regulation', label: '内部制度', icon: 'CodepenOutlined' },
{ path: '/external_law', label: '外部法规', icon: 'CopyOutlined' },
{ path: '/regulatory_notification', label: '监管通报', icon: 'ExceptionOutlined' },
{ path: '/subsidiary_system', label: '子公司制度' },
],
},
subjects: {
external_law: {
form: {
add: { title: { show: false, template: '{{metadata.law_name}}' }, content: { show: false }, fields: [
{ name: 'law_name' }, { name: 'document', label: '正文', mode: 'document' },
{ name: 'attachments', mode: 'document' }, { name: 'issue_date' },
{ name: 'effective_date' }, { name: 'issue_authority' },
] },
edit: { title: { show: false, template: '{{metadata.law_name}}' }, content: { show: false }, fields: [
{ name: 'law_name' }, { name: 'document', label: '正文', mode: 'document' },
{ name: 'attachments', mode: 'document' }, { name: 'issue_date' },
{ name: 'effective_date' }, { name: 'issue_authority' },
] },
fields: ['law_name', 'law_status', 'authority_level', 'issue_authority', 'issue_year', 'issue_date', 'categories', 'attachments'],
},
tag: { editable: true },
list: { view: 'search_view', sort: [{ 'metadata.issue_date': 'desc' }] },
tags: ['authority_level', 'law_status'],
description: '{{content}}',
detail: { view: 'article_view', fields: ['law_name', 'status', 'issue_authority', 'issue_date', 'law_authority_level', 'effective_date', 'doc_num', 'version'], tabs: [{ tab: '基本信息', key: 'base', type: 'base' }], content: { type: 'document', template: "<h2 style='text-align:center;padding:0 120px'>{{title}}</h2><div style='margin-bottom: 16px;text-align: center'>{{#metadata.tags}}<span>{{.}}</span>{{/metadata.tags}}</div>{{#metadata.law_content}}<p><b>{{索引号}}</b>{{内容}}</p>{{/metadata.law_content}}{{#metadata.attachments}}<a href='{{url}}' target='_blank'>{{title}}</a>{{/metadata.attachments}}" }, editable: true, editConfig: { metaField: 'law_content', indexField: '索引号', contentField: '内容', tagField: '标签' } },
},
internal_regulation: {
list: { view: 'column' },
form: { edit: { title: { show: false, template: '{{metadata.regulation_name}}' }, content: { show: false }, tags: { show: false }, fields: [
{ name: 'regulation_name' }, { name: 'effective_status' }, { name: 'effective_date' },
{ name: 'document', label: '正文', mode: 'document' }, { name: 'attachments', mode: 'document' },
] } },
tag: { editable: true, group: ['一级标签库', '二级标签库', '三级标签库'] },
description: '{{content}}',
detail: { view: 'article_view', tabs: [{ tab: '基本信息', key: 'base', type: 'base' }], fields: ['regulation_name', 'effective_status', 'level', 'public_date', 'departments'], content: { type: 'attachment', template: "<h2 style='text-align:center'>{{title}}</h2><div style='margin-bottom: 16px; border-bottom: 1px solid #eee'>{{content}}</div>{{#metadata.attachments}}<a href='{{url}}' target='_blank'>{{title}}</a>{{/metadata.attachments}}" } },
},
external_law_new: {
form: {
add: { title: { show: false, template: '{{metadata.law_name}}' }, content: { show: false }, fields: [
{ name: 'law_name' }, { name: 'document', label: '正文' }, { name: 'attachments', mode: 'document' },
{ name: 'issue_authority' }, { name: 'authority_level' }, { name: 'authority_level_second' },
{ name: 'categories' }, { name: 'categories_second' }, { name: 'law_status' }, { name: 'issue_date' },
{ name: 'effective_date' }, { name: 'issue_year' }, { name: 'issue_number' }, { name: 'statute_id', hide: true, random: true },
] },
edit: { title: { show: false, template: '{{metadata.law_name}}' }, content: { show: false }, fields: [
{ name: 'law_name' }, { name: 'document', label: '正文' }, { name: 'attachments', mode: 'document' },
{ name: 'issue_authority' }, { name: 'authority_level' }, { name: 'authority_level_second' },
{ name: 'categories' }, { name: 'categories_second' }, { name: 'law_status' }, { name: 'issue_date' },
{ name: 'effective_date' }, { name: 'issue_year' }, { name: 'issue_number' },
] },
fields: ['law_name', 'law_status', 'authority_level', 'issue_authority', 'issue_year', 'issue_date', 'categories', 'attachments', 'issue_number'],
},
tag: { editable: true },
list: { view: 'search_view', sort: [{ 'metadata.issue_date': 'desc' }] },
tags: ['authority_level', 'law_status'],
description: '{{content}}',
detail: { view: 'article_view', side_info: [{ type: 'attachment', title: '附件', field: 'attachments', show: true }], fields: ['law_name', 'issue_authority', 'issue_date', 'effective_date', 'doc_num', 'version', 'issue_number', 'law_status', 'authority_level', 'issue_year', 'categories'], tabs: [{ tab: '基本信息', key: 'base', type: 'base' }], content: { type: 'document', template: "<h2 style='text-align:center'>{{title}}</h2><div>{{content}}</div>{{#metadata.attachments}}<a href='{{url}}' target='_blank'>{{title}}</a>{{/metadata.attachments}}" }, editable: true, editConfig: { metaField: 'law_content', indexField: '索引号', contentField: '内容', tagField: '标签' } },
},
},
search: { enabled: true, subjects: ['external_law', 'internal_regulation'], aggregation: { issue_authority: { order: { '中国证券监督管理委员会': 1, '中国证券投资基金业协会': 2, '上海证券交易所': 3, '深圳证券交易所': 4, '北京证券交易所': 5, '中国人民银行': 6, '国家金融监督管理总局': 7 } }, authority_level: { order: { '法律': 1, '行政法规': 2, '地方法规': 3, '部门规章': 4, '行业规范': 5, '司法解释': 6 } }, categories: { order: { '证券基金监管': 1, '大资管综合': 2, '反洗钱': 3, '金融机构反洗钱及反恐怖融资相关规定': 4, '公司治理与合规风控': 5 } } } },
},
};
export function getBaseAwareLoginPath(redirect = '') {
const loginPath = `${APP_BASE_PATH}${LOGIN_PATH}`;
return redirect
? `${loginPath}?redirect=${encodeURIComponent(redirect)}`
: loginPath;
}
export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]);
const XINYUAN_CHART_FALLBACKS = Object.freeze([
'#DA241A', '#B83A32', '#C95A52', '#D47A70',
'#A65A45', '#B87958', '#C99772', '#8A6F63',
'#9A8174', '#6F6674', '#807783', '#938B96',
'#4F6380', '#61758F', '#74879A', '#B39A93',
]);
// Approved categorical sequence for charts where each category is independently meaningful.
// Keep the semantic series palette above unchanged for single-metric charts.
export const XINYUAN_CHART_CATEGORY_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]);
// Pie and donut slices use a separate palette so adjacent sectors stay
// distinguishable without changing the approved bar/line/ranking colors.
export const XINYUAN_PIE_CHART_PALETTE = Object.freeze([
'var(--rc-pie-chart-1)',
'var(--rc-pie-chart-2)',
'var(--rc-pie-chart-3)',
'var(--rc-pie-chart-4)',
'var(--rc-pie-chart-5)',
'var(--rc-pie-chart-6)',
'var(--rc-pie-chart-7)',
'var(--rc-pie-chart-8)',
'var(--rc-pie-chart-9)',
'var(--rc-pie-chart-10)',
'var(--rc-pie-chart-11)',
'var(--rc-pie-chart-12)',
'var(--rc-pie-chart-13)',
'var(--rc-pie-chart-14)',
'var(--rc-pie-chart-15)',
'var(--rc-pie-chart-16)',
]);
const XINYUAN_PIE_CHART_FALLBACKS = Object.freeze([
'#DA241A', '#3F5F86', '#8A668F', '#C28A3D',
'#8A6253', '#667080', '#74879A', '#9A8174',
'#6F6674', '#B87958', '#61758F', '#807783',
'#B0A4A0', '#AFA5B1', '#897A73', '#556B88',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
warning: 'var(--rc-chart-warning)',
positive: 'var(--rc-chart-positive)',
comparison: 'var(--rc-chart-comparison)',
neutral: 'var(--rc-chart-neutral)',
});
function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
);
}
export function createXinyuanChartColorMap(keys = [], options = {}) {
const sourceKeys = Array.isArray(keys) ? keys : [];
const order = Array.isArray(options?.order) ? options.order : [];
const palette = Array.isArray(options?.palette) && options.palette.length > 0
? options.palette
: XINYUAN_CHART_PALETTE;
const orderedKeys = [...new Set(sourceKeys.map(normalizeChartKey).filter(Boolean))];
const preferredOrder = new Map(
order.map(normalizeChartKey).filter(Boolean).map((key, index) => [key, index]),
);
const stableKeys = orderedKeys.sort((left, right) => {
const leftOrder = preferredOrder.has(left)
? preferredOrder.get(left)
: Number.MAX_SAFE_INTEGER;
const rightOrder = preferredOrder.has(right)
? preferredOrder.get(right)
: Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.localeCompare(right, 'zh-CN');
});
const unknownPositions = new Map(
stableKeys
.filter((key) => !preferredOrder.has(key))
.map((key, index) => [key, index]),
);
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key) => {
const hasExplicitOrder = preferredOrder.size > 0;
let paletteIndex = hasExplicitOrder && preferredOrder.has(key)
? preferredOrder.get(key) % palette.length
: hasExplicitOrder
? preferredOrder.size + unknownPositions.get(key)
: hashChartKey(key) % palette.length;
if (
paletteIndex >= palette.length
|| usedIndexes.has(paletteIndex)
) {
const preferredIndex = hashChartKey(key) % palette.length;
paletteIndex = preferredIndex;
for (let offset = 0; offset < palette.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % palette.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
break;
}
}
}
usedIndexes.add(paletteIndex);
colorMap.set(key, palette[paletteIndex]);
});
return colorMap;
}
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match ? readRootToken(match[1], fallback) : String(color || fallback);
}
export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
return new Map(
Array.from(createXinyuanChartColorMap(keys, options)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function createResolvedXinyuanPieChartColorMap(keys = []) {
return createResolvedXinyuanChartColorMap(keys, {
palette: XINYUAN_PIE_CHART_PALETTE,
});
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, XINYUAN_CHART_FALLBACKS[index])
)),
piePalette: Array.from({ length: 16 }, (_, index) => (
resolveXinyuanChartColor(
XINYUAN_PIE_CHART_PALETTE[index],
XINYUAN_PIE_CHART_FALLBACKS[index],
)
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
sectorGap: readRootToken('--rc-chart-sector-gap', '#ffffff'),
sectorRadius: Number.parseFloat(
readRootToken('--rc-chart-sector-radius', '10'),
) || 10,
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
:root {
--pm-brand: var(--rc-brand-primary, #DA241A);
--pm-brand-strong: var(--rc-brand-primary-pressed, #B51B14);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
--pm-text: var(--rc-text-primary, #172033);
--pm-muted: var(--rc-text-muted, #718096);
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: var(--rc-bg-canvas, #f5f5f5);
color: var(--pm-text);
font-family:
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
Arial,
sans-serif;
}
* {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
This diff is collapsed.
This diff is collapsed.
import AuditIndicatorView from './AuditIndicatorView';
import { Alert, Button, Card, Input, Spinner, TextArea } from '@heroui/react';
import { useEffect, useState } from 'react';
import { history } from 'umi';
import XinyuanModal from '@/components/XinyuanModal';
import { createRecord, loadRecord, loadRecords, removeRecord, updateRecord } from '@/services/DataService';
const valueOf = (value) => value && typeof value === 'object' ? value._id || value.name : value;
const titleOf = (value) => value && typeof value === 'object' ? value.title || value.name : value;
function GroupTabs({ groups, active, onChange, onCreate, label }) {
return <div className="mb-5 flex flex-wrap items-center gap-2">{groups.map((group) => <Button key={group.name || group._id} variant={active === group.name ? 'primary' : 'secondary'} onPress={() => onChange(group.name)}>{group.title || group.name}<span className="ml-2 text-xs opacity-70">{group.metadata?.indicators?.length ?? group.metadata?.process_list?.length ?? 0}</span></Button>)}<Button variant="ghost" onPress={onCreate}>+ 新建{label}</Button></div>;
}
function useSubjectRecords(subjectName, groupName) {
const [group, setGroup] = useState(null);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const reload = async () => {
if (!groupName) return;
setLoading(true); setError('');
try {
const detail = await loadRecord(groupName);
const refs = (detail?.metadata?.indicators || detail?.metadata?.process_list || []).filter((ref) => valueOf(ref));
const records = await Promise.all(refs.map((ref) => loadRecord(valueOf(ref)).catch(() => null)));
setGroup(detail); setItems(records.filter(Boolean));
} catch (requestError) { setError(requestError?.message || '加载失败'); }
finally { setLoading(false); }
};
useEffect(() => { reload(); }, [subjectName, groupName]);
return { group, items, loading, error, reload };
}
function ProcessListView({ subjectName, config }) {
const groupSubject = config?.group_subject || 'process_flow_group';
const [groups, setGroups] = useState([]); const [active, setActive] = useState(''); const [modal, setModal] = useState(false); const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); const [error, setError] = useState('');
const { group, items, loading, error: groupError, reload } = useSubjectRecords(subjectName, active);
const loadGroups = async () => { try { const result = await loadRecords(groupSubject, 0, 100); const next = result?.records || []; setGroups(next); setActive((current) => current || next[0]?.name || ''); } catch (requestError) { setError(requestError?.message || '流程分组加载失败'); } };
useEffect(() => { loadGroups(); }, [groupSubject]);
const save = async () => { setSaving(true); setError(''); try { if (draft.group_name !== undefined) await createRecord({ subject: groupSubject, title: draft.group_name, metadata: { group_name: draft.group_name, process_list: [] } }); else if (draft.editName) await updateRecord(draft.editName, { title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); else { const created = await createRecord({ subject: subjectName, title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: [...(group.metadata?.process_list || []).map(valueOf), created._id] } }); } setModal(false); setDraft({}); await loadGroups(); await reload(); } catch (requestError) { setError(requestError?.message || '保存失败'); } finally { setSaving(false); } };
const deleteProcess = async (item) => { if (!group || !window.confirm('确定删除该流程吗?')) return; await removeRecord(item.name || item._id); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: (group.metadata?.process_list || []).map(valueOf).filter((id) => id !== (item._id || item.name)) } }); await reload(); };
return <div className="rc-page-stack"><h2 className="mb-4 text-xl font-semibold">流程管理</h2>{error || groupError ? <Alert status="danger"><Alert.Content><Alert.Description>{error || groupError}</Alert.Description></Alert.Content></Alert> : null}<GroupTabs groups={groups} active={active} onChange={setActive} onCreate={() => { setDraft({ group_name: '' }); setModal(true); }} label="流程分组" /><Card><Card.Header><Card.Title className="mr-auto">{group?.title || '请选择流程分组'}</Card.Title>{group ? <Button variant="primary" onPress={() => { setDraft({}); setModal(true); }}>新增流程</Button> : null}</Card.Header><Card.Content>{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : !items.length ? <div className="py-16 text-center text-sm text-muted">暂无流程</div> : <ol className="space-y-3">{items.map((item, index) => <li className="flex items-center gap-3 rounded-lg border border-default p-3" key={item.name || item._id}><span className="grid h-8 w-8 place-items-center rounded-full bg-accent/10 text-sm font-semibold text-accent">{index + 1}</span><div className="min-w-0 flex-1"><div className="truncate font-medium">{item.title || item.name}</div><div className="text-sm text-muted">{item.metadata?.remark || '暂无说明'}</div></div><Button size="sm" variant="secondary" onPress={() => { setDraft({ ...item.metadata, title: item.title, editName: item.name }); setModal(true); }}>编辑</Button><Button size="sm" variant="secondary" onPress={() => history.push(`/${subjectName}/${item.name || item._id}`)}>查看</Button><Button size="sm" variant="danger" onPress={() => deleteProcess(item)}>删除</Button></li>)}</ol>}</Card.Content></Card><XinyuanModal isOpen={modal} onOpenChange={setModal} title={draft.group_name !== undefined ? '新建流程分组' : draft.editName ? '编辑流程' : '新建流程'} footer={<><Button variant="secondary" onPress={() => setModal(false)}>取消</Button><Button variant="primary" isPending={saving} onPress={save}>保存</Button></>}><div className="grid gap-4">{draft.group_name !== undefined ? <Input label="分组名称" value={draft.group_name} onChange={(event) => setDraft({ ...draft, group_name: event.target.value })} /> : <><Input label="流程名称" value={draft.title || ''} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /><TextArea label="流程说明" value={draft.remark || ''} onChange={(event) => setDraft({ ...draft, remark: event.target.value })} /></>}</div></XinyuanModal></div>;
}
export default function ConfiguredSpecialViews({ view, subjectName, config }) {
if (view === 'audit_indicator_list') return <AuditIndicatorView key={subjectName} config={config} subjectName={subjectName} />;
if (view === 'process_list') return <ProcessListView config={config} subjectName={subjectName} />;
return null;
}
This diff is collapsed.
import { Card } from '@heroui/react';
import { useContext } from 'react';
import { useNavigate } from 'umi';
import { APPLICATION_LAYOUT } from '@/config/ApplicationConfig';
import { ApplicationContext } from '@/pages/LawWorkbenchLayout';
export default function LawDashboardPage() {
const navigate = useNavigate();
const application = useContext(ApplicationContext);
const layout = application?.layouts?.default || APPLICATION_LAYOUT.default;
const subjects = (layout.menu?.items || []).filter((item) => item?.path && !item.path.startsWith('/search')).map((item) => [
item.path.replace(/^\//, ''),
layout.subjects?.[item.path.replace(/^\//, '')] || {},
item.label,
]);
return (
<div className="rc-page-stack">
<section className="rc-page-section">
<h2 className="text-xl font-semibold text-foreground">法规与制度工作台</h2>
<p className="mt-2 max-w-2xl text-sm text-muted">统一管理外部法规、法询法规和内部制度,支持检索、维护、标签和正文附件。</p>
</section>
<section className="grid gap-4 md:grid-cols-3">
{subjects.map(([key, config, menuLabel]) => (
<Card key={key} className="rc-card-surface cursor-pointer transition hover:-translate-y-0.5" onClick={() => navigate(`/${key}`)}>
<Card.Header><Card.Title>{menuLabel || key}</Card.Title></Card.Header>
<Card.Content><p className="text-sm text-muted">{config.list?.view === 'search_view' ? '全文检索与结构化筛选' : '按栏目浏览和维护制度记录'}</p></Card.Content>
</Card>
))}
</section>
</div>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { Card } from '@heroui/react';
import { useLocation } from 'umi';
const TITLES = { application: '应用管理', client: '客户端管理', subject: '对象管理', record: '记录管理', document: '文档管理', tag: '标签管理', category: '分类管理', form: '表单管理', source: '数据源管理', search: '搜索引擎', view: '视图管理', template: '模板管理', action: '动作管理', task: '任务管理', rule: '规则管理', trigger: '触发器管理', user: '用户管理', group: '组管理', query: '数据查询', import: '数据导入', connector: '数据库连接器', etl: 'ETL 数据同步', spider: '数据采集', ollama: '大语言模型', deploy: '部署包管理', notice: '消息管理', variable: '变量管理', audit: '审计日志', tracking: '埋点日志', recycle: '回收站', system: '系统设置' };
export default function LegacyFeaturePage() { const { pathname } = useLocation(); const key = pathname.split('/').filter(Boolean)[1] || 'admin'; const title = TITLES[key] || '管理功能'; return <div className="rc-page-stack"><Card><Card.Header><Card.Title>{title}</Card.Title></Card.Header><Card.Content><p className="text-sm text-muted">该功能入口已迁移到当前应用壳层,数据和权限继续复用 Streams API。</p></Card.Content></Card></div>; }
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.
import '@umijs/max/typings';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment