Commit afe17e13 authored by drigle's avatar drigle

chore: initialize risk manager application

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
.playwright-cli/
/test-results/
.swc
.pnpm-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',
dashboard: '/integrity_risk_table/dashboard',
businessDashboard: '/integrity_risk_table/business_dashboard',
problemRectification: '/problem_rectification',
issueRectificationArchive: '/issue_rectification_archive',
notifications: '/notifications',
problemRectificationForm: '/problem_rectification_form',
problemRectificationFlowTable: '/problem_rectification_flow_table',
fundProduct: '/fund_product',
investmentSupervisionForm: '/investment_supervision_form',
indicatorLibrary: '/indicator_library',
custodianAndManager: '/custodian_and_manager',
relationRelatedParty: '/relation_related_party',
relatedParty: '/related_party',
compareRecordField: '/compare_record_field',
} as const;
const APP_BASE_PATH = '/risk_manager_new/';
export default defineConfig({
layout: false,
fastRefresh: true,
base: APP_BASE_PATH,
publicPath: APP_BASE_PATH,
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
},
routes: [
{
path: ROUTE_PATHS.login,
layout: false,
component: './Login',
},
{
path: ROUTE_PATHS.problemRectificationFlowTable,
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './problem_rectification/flow-table/index',
},
{
path: ROUTE_PATHS.compareRecordField,
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './CompareRecordField',
},
{
path: '/',
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './problem_rectification/Layout',
routes: [
{
path: '/',
redirect: ROUTE_PATHS.fundProduct,
},
{
path: ROUTE_PATHS.dashboard,
component: './problem_rectification/dashboard/index',
},
{
path: ROUTE_PATHS.businessDashboard,
component: './problem_rectification/businessDashboard/BusinessDashboard',
},
{
path: ROUTE_PATHS.problemRectification,
component: './problem_rectification/list/index',
},
{
path: ROUTE_PATHS.issueRectificationArchive,
component: './problem_rectification/archive/index',
},
{
path: ROUTE_PATHS.notifications,
component: './problem_rectification/notifications/NotificationListPage',
},
{
path: ROUTE_PATHS.problemRectificationForm,
component: './problem_rectification/form/index',
},
{
path: ROUTE_PATHS.fundProduct,
component: './risk_management/FundProduct',
},
{
path: ROUTE_PATHS.investmentSupervisionForm,
component: './risk_management/InvestmentSupervisionForm',
},
{
path: ROUTE_PATHS.indicatorLibrary,
component: './risk_management/IndicatorLibrary',
},
{
path: ROUTE_PATHS.custodianAndManager,
component: './risk_management/CustodianAndManager',
},
{
path: ROUTE_PATHS.relationRelatedParty,
component: './risk_management/RelationRelatedParty',
},
{
path: ROUTE_PATHS.relatedParty,
component: './risk_management/RelatedParty',
},
],
},
],
npmClient: 'npm',
proxy: {
'/api': {
target: 'https://xy.streams7.com/api',
changeOrigin: true,
pathRewrite: { '^/api': '' },
secure: false,
},
'/parser': {
target: 'https://xy.streams7.com',
changeOrigin: true,
secure: false,
},
},
mfsu: false,
reactQuery: {
devtool: false,
},
chainWebpack(config, { env }) {
if (env === 'production') {
config.devtool('source-map');
}
},
esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
define: {
STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser',
PROBLEM_RECTIFICATION_APP_BASE_PATH: APP_BASE_PATH,
},
});
# AI Agent 角色与开发规范 (AGENTS.md)
## 1. 核心定位
你是一个资深的前端架构师和 React 专家,负责根据项目规范编写高质量、可维护的代码。
## 2. 强制性上下文读取流程
在生成任何业务代码(尤其是接口调用、页面开发、Hooks 编写)之前,**必须**执行以下步骤:
1. **读取接口定义**:查阅项目根目录或指定目录下的 `record-api.md`,确保 URL、请求方法、参数名与文档完全一致。
2. **读取数据结构**:如果涉及复杂对象、列表或状态,**必须**查阅 `subjectsDefinition.md`,获取字段定义、类型约束和业务含义。
3. **类型一致性**:如果是 TypeScript 项目,定义的 Interface/Type 必须以 `subjectsDefinition.md` 为准。
## 3. 代码风格指南
- **命名规范**:
- **JSX/TSX 文件**:必须使用 **大驼峰形式 (PascalCase)**,例如 `UserProfile.jsx` 或 `OrderList.tsx`。
- **组件函数**:必须使用大驼峰形式,例如 `const UserHeader = () => { ... }`。
- **变量与普通函数**:使用小驼峰 (camelCase)。
- **常量**:使用全大写下划线 (SNAKE_CASE)。
- **技术栈要求**:
- 使用 React Hooks 和函数组件。
- HeroUI 与 HeroUI Pro 是项目唯一 UI 组件体系。页面和共享 UI 组件必须直接使用 `@heroui/react` 与 `@heroui-pro/react`。
- `src/catalyst-ui-kit` 已废弃,仅作为待清理的历史源码保留。禁止在新功能、存量维护、重构、示例和测试中引用、复制或扩展其中的组件。
- 禁止新增 `@headlessui/react`、旧 UI 适配器或其他替代组件库,禁止以包装层兼容 Catalyst props,禁止使用可见原生表单控件替代 HeroUI。
- HeroUI 复合组件遵守官方 compound component 与 `slot` 结构,交互使用 `onPress`。
- DataGrid 使用 HeroUI Pro 原生选择、排序、分页、固定列和 ActionBar,不手写选择列。
## 4. 接口请求规范
- 所有的 API 请求应当封装在独立的 service 层或自定义 Hooks 中。
- 严禁硬编码 URL,必须参考 `record-api.md`。
- 必须处理 Loading、Success、Error 三种状态。
## 5. 交互原则
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(鑫元 HeroUI 强制约束)
- **全项目 UI 统一**:遵循 `xinyuan-design-system` 与 `docs/xinyuan-design-system.md`。
- **直接引用**:页面与共享组件必须从 `@heroui/react`、`@heroui-pro/react` 的官方导出直接组合;不得通过 Catalyst、Headless UI、旧适配器或兼容包装层间接使用。
- **一致性**:表单使用 HeroUI,复杂表格和 Shell 使用 HeroUI Pro,弹窗使用 HeroUI Modal/AlertDialog。
- **默认外观**:除 `src/design-system/theme.css` 的主题变量和批准的 Login/AppShell 布局外,组件尺寸、圆角、边框、阴影、Hover、选中态均使用 HeroUI/HeroUI Pro 默认实现;禁止页面 CSS 重画组件。
- **主题唯一性**:`src/design-system/theme.css` 是组件主题唯一权威;`tokens.css` 只允许提供 Shell/Login 别名,不得覆盖 DataGrid、Tabs、Card、Modal、Checkbox、Pagination 或图表样式。
- **图标统一**:所有业务功能图标通过 `src/components/AppIcons.jsx` 使用 Ant Design Icons,禁止 Heroicons、Lucide、Iconify 和自绘功能图标。
- **可访问性**:保留 HeroUI/React Aria 的键盘、焦点、slot 与 Portal 结构,不以自写 DOM 模拟。
# AI 开发执行规范
## 1. 自动 UI 组件映射逻辑 (UI Mapping)
当 AI 生成前端页面、表单或组件时,**必须**严格按照以下 `Field Type` 映射对应的 UI 组件:
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- |
| **文本 (String)** | HeroUI `TextField` + `Input` | 标准文本输入 |
| **引用 (Reference)** | HeroUI `Popover` + `ListBox` 复合组件 | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | HeroUI `NumberField` / `Input` | 仅允许输入数字 |
| **日期 (Date)** | HeroUI `DatePicker` | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | HeroUI `Checkbox` | 映射为 true/false |
| **JSON** | HeroUI `TextArea` | 需要包含 JSON 校验逻辑 |
| **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 |
---
## 2. 数据处理与存储规约 (Data Protocol)
AI 在编写 API 调用或数据转换逻辑时,必须遵守:
- **Metadata 封装**:所有业务字段必须包裹在 `metadata: { ... }` 对象中。
- **引用值处理**:对于 `Reference` 类型,写入时**严禁**传递整个对象,必须仅传递 ID 字符串或 ID 数组。
- **日期格式**:所有时间字段必须转换为 `YYYY-MM-DDTHH:mm:ss.sssZ` 格式。
- **子对象嵌套**:`Subject` 类型字段应作为嵌套对象直接存放在父级的 `metadata` 中。
---
## 3. 动态配置读取流程 (Workflow)
当用户要求“生成一个 [SubjectName] 的表单”时,AI 的默认思考路径如下:
1. **Step 1**: 查找 `subjectsDefinition.md` 获取该 Subject 的基础字段。
2. **Step 2**: 检查字段类型。如果遇到 `Subject` 类型,**自动生成**一段调用 `loadSubject` 的代码以获取深层结构。
3. **Step 3**: 参照上表(第 1 节)选择 HeroUI/HeroUI Pro 官方组件;Tailwind 仅用于页面布局,不得用于自制或重画交互组件。
4. **Step 4**: 自动生成 `onChange` 处理函数,确保数据实时同步到 `metadata` 对象的对应路径下。
---
## 4. 强制约束 (Hard Constraints)
- **组件库唯一性**:任何需求都不得重新引入 Catalyst、Headless UI 或其它 UI 组件库;HeroUI/HeroUI Pro 暂无对应组件时,应先按官方 compound API 组合,并遵循 `docs/xinyuan-design-system.md`,不得回退到 `src/catalyst-ui-kit`。
- **禁止硬编码**:Select 类型的选项必须从 `settings.options` 读取,严禁在代码里写死。
- **主键保护**:如果字段定义中 `isPrimaryKey: true`,在编辑模式下该组件必须设为 `disabled`。
- **必填校验**:根据 `isRequired` 自动在 UI 上添加红星标记,并生成相应的 Form Validation 规则。
- **流程文档同步**:如果本次修改涉及业务逻辑、页面交互流程、状态流转、字段规则、审批/操作步骤变化,除了更新代码外,**必须同步补全对应模块的流程文档**;若未找到对应文档,需先告知用户再继续。
---
## 5. 流程文档维护要求
- 开发前先确认本次需求归属的业务模块,并查阅对应流程文档。
- 开发完成后,检查实现结果是否与流程文档一致;如果存在新增、变更或删除的逻辑,必须同步更新文档。
- 流程文档更新内容至少应包含:功能入口、关键交互步骤、状态变化、核心字段/筛选条件、异常处理或限制条件。
- 提交结果时,除了说明改动代码,也要简要说明同步更新了哪个流程文档、补充了哪部分逻辑。
## 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.
This diff is collapsed.
# 鑫元跨应用五项功能扩展规范
本规范定义已经在“问题整改”完成验证、后续需要按范围同步到其它鑫元应用的五项功能。它是功能与组件架构合同,不是可以自由发挥的视觉参考。除本规范明确授权的新增能力外,目标应用原有 API、路由、鉴权、校验、保存回调和业务状态流必须保持不变。
## 1. 应用范围矩阵
以下六个应用实施全部五项功能:
- 问题整改
- 审计项目管理
- 警示案例库
- 廉洁从业管理
- 廉洁风险防控
- 党风廉政建设
六项名单以应用统一展示名称为准;批量迁移时应由用户或目标项目配置明确应用名称,不能仅凭目录名或相似代码推断。
| 功能 | 六个完整功能应用 | 其它鑫元应用 |
| --- | --- | --- |
| Grid `shortTitle` | 必须 | 禁止新增 |
| Detail `shortTitle` | 必须 | 禁止新增 |
| Subject View 权限 | 必须 | 禁止新增 |
| 消息通知 | 必须 | 必须 |
| 文件接入审计材料中心 | 必须 | 禁止新增 |
“其它鑫元应用”只增加消息通知。不得因为复用共享代码而把 `shortTitle`、View 权限或材料中心文件流程带入其业务页面。
## 2. Grid shortTitle
- `view.settings.shortTitle === true` 只隐藏 DataGrid 中的 `title` 展示列;`false` 或未配置时正常显示。
- 不删除、清空或改写记录标题,不改变标题参与的查询、关键词搜索、排序、筛选、权限、详情上下文或保存数据。
- 每次读取或切换动态 View 后重新解析配置,不缓存旧标题或旧字段常量;服务端调整 View 后页面必须随配置变化。
- 标题列原本承载的详情、状态或业务入口必须迁移到独立可见的上下文列或固定操作列,不能随标题列一起丢失。
- 上下文列属于表现层,不写入 `settings.columns`,不参与 Record 查询、排序或字段权限。
## 3. Detail shortTitle
- 详情使用当前激活的 `type = "detail"` View。该 View 的 `settings.shortTitle === true` 时,同时隐藏记录标题展示和字段区的 `title` 字段。
- Modal 的功能标题和可访问名称始终保留,不能生成无标题弹窗。
- 切换详情 View 后立即重新计算;`false` 或未配置时恢复记录标题和 `title` 字段。
- 隐藏只影响展示,不改变草稿、校验、保存 payload、详情查询或记录身份。
## 4. Subject View 权限
- Grid View 的 `settings.viewAuth` 只控制视图可见性;详情字段编辑优先使用当前 Detail View 的 `settings.auth` 与 `settings.disableColumns`,Detail 未配置字段权限或不存在实际 Detail 时回退活动 Grid View;选定权限来源中的 `settings.disableColumns` 对所有用户始终只读。
- 用户通过 `viewAuth` 获得 Grid Tab 查看权限后即可打开记录详情并查看该 Detail `columns` 中的字段;Detail View 不再使用 `viewAuth` 单独隐藏详情字段,字段授权只影响编辑能力。
- 普通用户遇到缺失 `viewAuth` 的 View 或缺失字段 `auth` 的字段时默认拒绝。系统管理员、系统账号和目标应用自身的“管理”角色可以旁路 View/字段授权,但不能旁路 `disableColumns`。
- 仅从 `user.applications[]` 中 `name` 等于目标应用真实稳定标识的单个应用项读取 `groups`、`roles` 和 `permissions`。每个项目必须使用自身应用标识,不得复制其它应用的标识,也不得合并顶层身份或其它应用授权。
- 授权类型只接受 `group`、`role`、`permission`;缺失或不支持的类型按“管理”角色规则处理。
- 从 Grid 打开详情时,当前激活 Detail View 存在有效 `auth` 或 `disableColumns` 配置则使用该 Detail 的整套字段权限;两项均无有效配置或不存在实际 Detail View 时才使用活动 Grid View。切换 Detail 或 Grid 后必须立即重新计算,不能合并两套字段授权。
- Detail 的 `columns` 与 `auth` 相互独立:`columns` 只决定详情字段的展示范围和顺序,`auth` 可以配置更多字段;已展示且命中 `auth` 的字段正常执行授权,未展示字段的授权暂不产生界面效果。Detail 一旦存在任意有效字段权限配置,已展示但未配置 `auth` 的字段对普通用户保持只读,不得逐字段回退 Grid。
- 没有可访问 Grid View 时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作。只有一个可访问 View 时保留其 key、查询和筛选,但隐藏单项 Tabs 轨道。
- View 的 restrictions、filters、排序、分页和请求参数继续按目标应用原协议执行。
## 5. 消息通知
该功能适用于所有鑫元应用。
- 使用独立 `/notifications` 页面,支持 `message / todo`、`全部 / 未读`、300ms 搜索防抖、固定每页 10 条、单条已读、全部已读、服务端动态 `actions` 和关联流程跳转。
- 未读筛选必须传服务端 `status: "unread"`;不能只过滤当前页。通知正文按纯文本渲染,不执行服务端 HTML。
- `GET /notice/customer/count` 的真实结果进入全局 Shell;通知更新事件和 60 秒轮询刷新同一计数。不得伪造数字。
- 通知入口不占 Sidebar 导航。Navbar 使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Bell 位于最右侧主题按钮左边;0 不显示 Badge,1–99 显示真实值,100 及以上显示 `99+`,Badge 可访问文本保留真实数量。
- 点击 Bell 进入目标应用原 `/notifications` 路由,不在 Navbar 复制通知 Popover。
- 站内链接必须适配目标应用自己的 `base/publicPath`;目标应用内路由去除自身 base 后交给路由器,其它应用和外部 HTTP(S) 链接新窗口打开。
- 保留现有鉴权、通知权限、已读协议、动态 action 名称、`notice_id` 请求体和错误处理。
## 6. 文件接入审计材料中心
该功能只适用于六个完整功能应用中允许编辑的 `file` 字段;若目标应用业务文档明确限定字段范围,以该范围为准。
- UI 使用 HeroUI Tabs 在“材料中心 / 本地上传”之间切换;材料选择使用 HeroUI Autocomplete,本地文件使用 HeroUI Pro `DropZone`。必须运行时读取 Subject 字段 `multiple`。
- 当前附件与来源入口分层展示:附件列表负责呈现已选文件,单值 Autocomplete 保持“请选择审计材料”作为替换入口,不重复回显当前文件名;删除图标与文件行垂直居中并使用 HeroUI `danger-soft`。
- 材料中心通过 `POST /record/query` 查询 `am_material` 最近 20 条,按当前用户 Record 权限返回,远程搜索 `metadata.material_title` 和 `metadata.file_asset.name`,正则关键词必须转义。
- 选择已有材料只把 `{name,type,size,url}` 文件快照写入业务草稿,不保存 `am_material` ID,不重新上传、不创建材料。
- 单值字段替换原值;多值字段追加并按 URL 去重,支持逐项删除。
- 本地文件选择后立即逐文件调用 `/upload`,规范化为 `{name,type,size,url}` 并保留在当前详情草稿;上传期间禁止保存和修改附件。成功项保留,失败项给出文件级汇总并可重试。
- 只有用户点击详情“保存”时,才为仍在草稿中的本地文件逐个创建 `am_material`。`source_app` 必须使用目标应用真实稳定标识,`uploader` 使用当前用户 ID,`uploaded_at` 使用 ISO 时间;禁止复制 `issue_rectification`。
- 所有待建档材料成功后才更新业务记录。部分建档成功或材料成功而业务更新失败时,当前编辑会话必须记住已建档项;再次保存只处理未建档项,不能产生重复材料。
- 取消编辑不创建材料主档;删除或替换业务附件不删除材料中心主档或底层文件,材料后续变化也不反向修改历史快照。
- 业务记录仍按目标字段既有协议保存单值对象或对象数组,不得改成材料 ID、引用字段或新数据结构。
## 7. HeroUI 架构边界
- 新页面和共享组件直接引用 `@heroui/react`、`@heroui-pro/react` 或 Pro 子路径;禁止 Catalyst、Headless UI、旧适配器和在旧组件内嵌 HeroUI。
- DataGrid、Modal、Tabs、Autocomplete、Badge、Tooltip 和 DropZone 使用官方 compound API、slot 和 React Aria Context。
- 可新建业务无关的 HeroUI 原生组合,但应用授权标识、`source_app`、Subject、字段路径、路由和保存事务必须由目标业务层传入,不能硬编码问题整改名称。
- 这些功能允许新增其明确需要的路由、请求和状态;不得借机覆盖整页、复制问题整改业务流程或修改其它业务。
## 8. 验收
- 六个完整功能应用验证 Grid/Detail `shortTitle` 的 true、false、缺失与动态切换。
- 验证系统管理员、目标应用管理角色、普通授权、无授权、无 `viewAuth`、字段 `auth` 和 `disableColumns`。
- 所有应用验证通知 0、1、99、100+,单条/全部已读、消息/待办、未读筛选、搜索、动态 action、站内外跳转和 Navbar 布局。
- 六个完整功能应用验证单值/多值附件、材料选择、本地上传、部分失败、取消、保存失败与无重复重试。
- 在 1440x900、1280x720、768x1024、390x844 的浅色/深色真实浏览器中验收,控制台无错误或 Context/slot 警告。
- 执行构建、`git diff --check`、鑫元审计脚本、旧组件扫描、HeroUI 依赖树和本机路径扫描。
This diff is collapsed.
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.2",
"@number-flow/react": ">=0.5.0",
"@react-aria/utils": ">=3.34.1",
"@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": ">=3.23.6",
"@tiptap/extension-link": ">=3.23.6",
"@tiptap/extension-underline": ">=3.23.6",
"@tiptap/extensions": ">=3.23.6",
"@tiptap/pm": ">=3.23.6",
"@tiptap/react": ">=3.23.6",
"@tiptap/starter-kit": ">=3.23.6",
"@tiptap/suggestion": ">=3.23.6",
"@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.0.0",
"marked": ">=18.0.0",
"motion": "^12.23.11",
"react": "^19.2.7",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.7",
"react-markdown": ">=10.1.0",
"react-resizable-panels": ">=4.10.0",
"recharts": "^3.10.1",
"remark-breaks": ">=4.0.0",
"remark-gfm": ">=4.0.0",
"shiki": ">=4.0.0",
"streamdown": ">=2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"typescript": "^5.0.3"
}
}
This diff is collapsed.
allowBuilds:
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
core-js: false
core-js-pure: false
es5-ext: false
esbuild: true
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
{
"name": "risk_manager_new",
"version": "1.0.1",
"build": "abc",
"description": "风险管理应用",
"date": "2025-07-14 19:27:20",
"author": "yang",
"spa": true
}
import '@/tailwind.css';
import { defineApp } from 'umi';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
} from '@/config/application';
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
document.documentElement.dataset.theme = 'light';
document.documentElement.style.colorScheme = 'light';
}
const LEGACY_FLOW_PATH = '/self_inspection_form';
const LEGACY_LOGIN_PATH = '/login';
const appBasePath = String(
PROBLEM_RECTIFICATION_APP_BASE_PATH || APPLICATION_BASE_PATH || '/',
).replace(/\/+$/, '');
const legacyFlowPaths = new Set([
LEGACY_FLOW_PATH,
`${appBasePath}${LEGACY_FLOW_PATH}`,
]);
if (typeof window !== 'undefined') {
if (appBasePath && window.location.pathname === LEGACY_LOGIN_PATH) {
window.location.replace(
`${appBasePath}${LEGACY_LOGIN_PATH}${window.location.search}${window.location.hash}`,
);
} else if (legacyFlowPaths.has(window.location.pathname)) {
window.location.replace(
`${appBasePath}/problem_rectification_flow_table${window.location.search}${window.location.hash}`,
);
}
}
export async function getInitialState() {
return {
name: APPLICATION_NAME,
};
}
export default defineApp({});
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import LockOutlined from '@ant-design/icons/LockOutlined';
import MailOutlined from '@ant-design/icons/MailOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import ProjectOutlined from '@ant-design/icons/ProjectOutlined';
import ReloadOutlined from '@ant-design/icons/ReloadOutlined';
import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UpOutlined from '@ant-design/icons/UpOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
<Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />
);
};
}
export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined);
export const Edit = withSize(EditOutlined);
export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export const Mail = withSize(MailOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
style={iconStyle(size, style)}
className={['anticon', props.className].filter(Boolean).join(' ')}
>
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable="false"
aria-hidden="true"
>
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
export const More = withSize(MoreOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowDownIcon = ChevronDown;
export const ArrowUpIcon = withSize(UpOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
export const CalendarDaysIcon = Calendar;
export const ChartBarSquareIcon = BarChart;
export const ChartPieIcon = withSize(PieChartOutlined);
export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const TableCellsIcon = withSize(OrderedListOutlined);
export const UserGroupIcon = Team;
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
...sidebarProps
}) {
return (
<Sidebar.Provider
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
title={title}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{normalizedNotificationCount > 0 ? (
<Badge
aria-label={`${normalizedNotificationCount}条未读通知`}
color="danger"
size="sm"
>
{normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { 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({
menuGroups,
onLogout,
onOpenApplicationSwitcher,
pathname,
userInfo,
}) {
const hasCurrentPath = (item) =>
pathname === item.path || item.items?.some(hasCurrentPath);
const renderMenuItems = (items) => items.map((item) => {
const Icon = item.icon;
const hasChildren = item.items?.length > 0;
return (
<Sidebar.MenuItem
href={hasChildren ? undefined : item.path}
isCurrent={!hasChildren && pathname === item.path}
key={item.path || item.name}
textValue={item.name}
tooltip={item.name}
>
{Icon ? <Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon> : null}
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
{hasChildren ? (
<>
<Sidebar.MenuTrigger>
<ChevronDown size={14} />
</Sidebar.MenuTrigger>
<Sidebar.Submenu>
{renderMenuItems(item.items)}
</Sidebar.Submenu>
</>
) : null}
</Sidebar.MenuItem>
);
});
const renderMenu = (group) => {
const defaultExpandedKeys = group.items
.filter((item) => item.items?.length && hasCurrentPath(item))
.map((item) => item.path || item.name);
return (
<Sidebar.Menu
aria-label={group.title}
defaultExpandedKeys={defaultExpandedKeys}
>
{renderMenuItems(group.items)}
</Sidebar.Menu>
);
};
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator>
<ChevronDown size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo"
src={xyLogoWhite}
/>
</div>
<div
className="app-sidebar-brand-main rc-sidebar-brand-main"
data-sidebar="label"
>
<span className="app-sidebar-title rc-sidebar-title">
{APPLICATION_DISPLAY_NAME}
</span>
</div>
<div
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
group.showLabel === false ? (
<Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
) : renderGroup(group)
))}
</Sidebar.Content>
<Sidebar.Footer>
<SidebarUserMenu userInfo={userInfo} onLogout={onLogout} />
</Sidebar.Footer>
</>
);
}
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) {
return null;
}
return (
<Sheet.Root
isOpen={isMobileOpen}
placement={side}
onOpenChange={setMobileOpen}
>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile"
>
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<AccessibleMobileSidebar {...props} />
</>
);
}
This diff is collapsed.
This diff is collapsed.
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 = '将文件拖到此处,或从本地选择。',
isDisabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback(
(files) => {
if (!isDisabled) {
onFilesChange?.(Array.from(files || []));
}
},
[isDisabled, 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={isDisabled}
onDrop={handleDrop}
>
<DropZone.Input
accept={accept}
disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
<Upload aria-hidden="true" />
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName
? `已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
: description}
</DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react';
function normalizeOption(option) {
if (option && typeof option === 'object') {
return {
label: String(option.label ?? option.value ?? ''),
value: String(option.value ?? ''),
disabled: Boolean(option.disabled),
};
}
return {
label: String(option ?? ''),
value: String(option ?? ''),
disabled: false,
};
}
export default function FilterSelect({
'aria-label': ariaLabel,
className,
disabled = false,
emptyText = '暂无可选项',
onChange,
options = [],
placeholder = '请选择',
value = '',
variant,
}) {
const normalizedOptions = useMemo(
() =>
options.map(normalizeOption).filter((option) => option.value !== '').map((option) => ({
...option,
id: option.value,
isDisabled: option.disabled,
})),
[options],
);
const selectedValue = String(value ?? '');
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const selectedKey = selectedValue || null;
return (
<XinyuanAutocomplete
aria-label={ariaLabel || placeholder}
className={className}
isDisabled={disabled}
isClearable={Boolean(selectedValue)}
emptyText={emptyText}
options={normalizedOptions}
onClear={() => onChange?.('')}
placeholder={placeholder}
selectedKey={selectedKey}
variant={variant}
onSelectionChange={(key) => {
onChange?.(String(key ?? ''));
}}
valueText={selectedOption?.label || placeholder}
/>
);
}
import {
Calendar as HeroCalendar,
CloseButton,
DateField,
DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react';
import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
function parseValue(value, mode) {
if (!value) {
return null;
}
try {
if (mode === 'month') {
return parseDate(`${String(value).slice(0, 7)}-01`);
}
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 === 'month') {
return rawValue.slice(0, 7);
}
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)}`;
}
function emitDateChange(onChange, name, nextValue) {
onChange?.({
target: {
name,
value: nextValue,
},
});
}
export default function FormDatePicker({
'aria-label': ariaLabel,
className,
disabled = false,
granularity,
isClearable = true,
max,
min,
mode,
name,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
variant,
...props
}) {
const resolvedMode =
mode === 'month' || type === 'month'
? 'month'
: mode === 'datetime' || type === 'datetime-local'
? 'datetime'
: 'date';
const resolvedGranularity =
granularity ||
(resolvedMode === 'datetime'
? 'minute'
: resolvedMode === 'month'
? 'month'
: '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) =>
emitDateChange(onChange, name, 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 ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
onPress={() => emitDateChange(onChange, name, '')}
>
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</CloseButton>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator>
<CalendarIcon
className="grid size-4 place-items-center leading-none [&>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) =>
emitDateChange(
onChange,
name,
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 XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Children, Fragment, isValidElement, useMemo } from 'react';
const EMPTY_OPTION_KEY = '__rc_form_select_empty__';
function extractText(node) {
if (node === null || node === undefined || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join('');
}
if (isValidElement(node)) {
return extractText(node.props.children);
}
return '';
}
function normalizeOptions(children) {
const options = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment || child.type === 'optgroup') {
options.push(...normalizeOptions(child.props.children));
return;
}
if (child.type !== 'option') {
return;
}
options.push({
disabled: Boolean(child.props.disabled),
label: extractText(child.props.children),
value: String(child.props.value ?? ''),
});
});
return options;
}
function toOptionKey(value) {
return value === '' ? EMPTY_OPTION_KEY : String(value);
}
function fromOptionKey(key) {
return key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
}
export default function FormSelect({
'aria-label': ariaLabel,
children,
className,
disabled = false,
multiple = false,
name,
onChange,
placeholder,
required = false,
value,
...props
}) {
const options = useMemo(
() =>
normalizeOptions(children).map((option) => ({
...option,
id: toOptionKey(option.value),
isDisabled: option.disabled,
})),
[children],
);
const emptyOption = options.find((option) => option.value === '');
const resolvedPlaceholder = placeholder || emptyOption?.label || '请选择';
const selectedValues = multiple
? (Array.isArray(value) ? value : []).map(String)
: [String(value ?? '')];
const selectedOptions = options.filter(
(option) => option.value !== '' && selectedValues.includes(option.value),
);
const displayText = multiple
? selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`
: options.find((option) => option.value === selectedValues[0])?.label ||
resolvedPlaceholder;
const emitChange = (nextValues) => {
const normalizedValues = nextValues.map(fromOptionKey);
onChange?.({
target: {
name,
selectedOptions: normalizedValues.map((nextValue) => ({
value: nextValue,
})),
value: normalizedValues[0] || '',
},
});
};
const selectionProps = {};
if (multiple) {
selectionProps.selectedKeys = new Set(selectedValues.map(toOptionKey));
selectionProps.onSelectionChange = (keys) => {
const nextKeys =
keys === 'all' ? options.map((option) => option.id) : [...keys];
emitChange(nextKeys);
};
} else {
selectionProps.selectedKey = toOptionKey(selectedValues[0]);
selectionProps.onSelectionChange = (key) => emitChange([key]);
}
return (
<XinyuanAutocomplete
{...props}
{...selectionProps}
aria-label={ariaLabel || resolvedPlaceholder}
className={className}
isDisabled={disabled}
isClearable={selectedOptions.length > 0}
isRequired={required}
name={name}
options={options}
onClear={() => emitChange(multiple ? [] : [''])}
placeholder={resolvedPlaceholder}
selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayText}
/>
);
}
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react';
function valueKey(value) {
return typeof value === 'string' ? value : JSON.stringify(value);
}
export default function MultiSelect({
value,
options = [],
onChange,
disabled = false,
placeholder = '请选择或搜索',
ariaLabel = '多选',
variant = 'secondary',
}) {
const normalizedOptions = useMemo(
() => options.map((option) => ({
...option,
id: valueKey(option.value),
label: String(option.label ?? option.value ?? ''),
originalValue: option.value,
isDisabled: Boolean(option.disabled),
})),
[options],
);
const optionMap = useMemo(
() => new Map(normalizedOptions.map((option) => [option.id, option.originalValue])),
[normalizedOptions],
);
const selectedKeys = new Set((Array.isArray(value) ? value : []).map(valueKey));
const selectedLabels = normalizedOptions
.filter((option) => selectedKeys.has(option.id))
.map((option) => option.label);
return (
<XinyuanAutocomplete
aria-label={ariaLabel}
isDisabled={disabled}
options={normalizedOptions}
placeholder={placeholder}
selectedKeys={selectedKeys}
selectionMode="multiple"
valueText={selectedLabels.length ? selectedLabels.join('、') : placeholder}
variant={variant}
onSelectionChange={(keys) => {
const nextKeys = keys === 'all' ? normalizedOptions.map((option) => option.id) : [...keys];
onChange?.(nextKeys.map((key) => optionMap.get(String(key))).filter((item) => item !== undefined));
}}
/>
);
}
This diff is collapsed.
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Button } from '@heroui/react';
import { useCallback, useMemo, useState } from 'react';
export default function ResourceFilterMultiSelect({
options,
value,
onChange,
placeholder,
disabled = false,
searchPlaceholder = '输入关键字搜索',
variant,
}) {
const [searchKeyword, setSearchKeyword] = useState('');
const [isOpen, setIsOpen] = useState(false);
const selectedValues = Array.isArray(value) ? value.filter(Boolean) : [];
const selectedOptions = options.filter((option) =>
selectedValues.includes(option.value),
);
const autocompleteOptions = useMemo(
() =>
options.map((option) => ({
...option,
id: option.value,
textValue: `${option.label || ''} ${option.value || ''}`.trim(),
})),
[options],
);
const summaryText =
selectedOptions.length === 0
? placeholder
: selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`;
const handleSelectionChange = useCallback(
(keys) => {
onChange?.(
keys === 'all' ? options.map((option) => option.value) : [...keys],
);
},
[onChange, options],
);
const handleClearSelection = useCallback(
() => {
if (disabled || selectedValues.length === 0) {
return;
}
setSearchKeyword('');
onChange?.([]);
},
[disabled, onChange, selectedValues.length],
);
const handleOpenChange = useCallback((nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) {
setSearchKeyword('');
}
}, []);
return (
<XinyuanAutocomplete
aria-label={placeholder}
emptyText="没有匹配选项"
inputValue={searchKeyword}
isDisabled={disabled}
isClearable={selectedValues.length > 0}
isOpen={isOpen}
options={autocompleteOptions}
onClear={handleClearSelection}
placeholder={placeholder}
popoverHeader={(
<div className="flex items-center justify-between gap-3 px-3 pb-1">
<span className="text-xs text-muted">可多选</span>
<Button
isDisabled={disabled || selectedValues.length === 0}
size="sm"
type="button"
variant="tertiary"
onPress={handleClearSelection}
>
清空
</Button>
</div>
)}
searchPlaceholder={searchPlaceholder}
selectedKeys={new Set(selectedValues)}
selectionMode="multiple"
valueText={summaryText}
variant={variant}
onInputChange={setSearchKeyword}
onOpenChange={handleOpenChange}
onSelectionChange={handleSelectionChange}
/>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment