Commit 97a10aed authored by David Yang's avatar David Yang

初始化项目

parents
File added
/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
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',
} as const;
export default defineConfig({
layout: false,
fastRefresh: true,
base: '/incorrupt_party_build/',
publicPath: '/incorrupt_party_build/',
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
},
routes: [
{
path: ROUTE_PATHS.login,
layout: false,
component: './Login',
},
{
path: '/',
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './project_manager_xy/Layout',
routes: [
{
path: '/',
redirect: ROUTE_PATHS.resourceStat,
},
{
path: '/resource',
redirect: ROUTE_PATHS.resourceStat,
},
{
path: ROUTE_PATHS.resourceStat,
component: './project_manager_xy/resource-stat/index',
},
{
path: ROUTE_PATHS.integrityParty,
component: './project_manager_xy/integrity-party/index',
},
],
},
],
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,
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 Plus / Catalyst UI Kit(见 `src/catalyst-ui-kit`)+ Tailwind CSS**。
- 交互组件:**优先使用 Headless UI**(已在 Catalyst 组件内封装,除非缺组件才直接用 `@headlessui/react`)。
- 组件选择优先级(从高到低):
1. `src/catalyst-ui-kit/javascript/*`(Button/Input/Dialog/Table/Combobox/SidebarLayout/...)
2. 基于 Catalyst 样式规范的自定义组件(仅在 Kit 无现成组件时)
3. 原生 HTML + 自写样式(尽量避免)
## 4. 接口请求规范
- 所有的 API 请求应当封装在独立的 service 层或自定义 Hooks 中。
- 严禁硬编码 URL,必须参考 `record-api.md`。
- 必须处理 Loading、Success、Error 三种状态。
## 5. 交互原则
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(Tailwind Plus / Headless UI 强制约束)
- **全项目 UI 统一**:默认使用 `src/catalyst-ui-kit` 中的 Catalyst 组件与样式体系,避免引入其它 UI 框架。
- **Headless UI 优先**:弹窗/下拉/选择器等交互优先用 Headless UI(直接或通过 Catalyst 组件)。
- **一致性**:表单控件优先用 Catalyst 的 `Input/Select/Checkbox/Textarea/Fieldset`,表格用 `Table`,对话框用 `Dialog`。
- **可访问性**:移动端侧栏/弹层必须使用 Headless UI 的 Dialog 等可访问性组件(focus trap / esc 关闭等)。
# AI 开发执行规范
## 1. 自动 UI 组件映射逻辑 (UI Mapping)
当 AI 生成前端页面、表单或组件时,**必须**严格按照以下 `Field Type` 映射对应的 UI 组件:
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- |
| **文本 (String)** | Catalyst `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(Headless UI Combobox) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | Catalyst `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | Catalyst `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | Catalyst `Checkbox` | 映射为 true/false |
| **JSON** | Catalyst `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`
## 获取当前登录用户信息
可使用 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 source diff could not be displayed because it is too large. You can view the blob instead.
{
"private": true,
"scripts": {
"build": "max build",
"dev": "max dev",
"postinstall": "max setup",
"setup": "max setup",
"start": "npm run dev"
},
"dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@tailwindcss/postcss": "^4.2.0",
"@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",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"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 source diff could not be displayed because it is too large. You can view the blob instead.
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
{
"name": "incorrupt_party_build",
"version": "1.0.4",
"build": "abc",
"description": "党风廉政建设",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
File added
import '@/global.less';
import '@/tailwind.css';
import { defineApp } from 'umi';
export async function getInitialState() {
return {
name: 'project_manager_xy',
};
}
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 * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { Link } from './link'
const styles = {
base: [
// Base
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
// Sizing
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]',
// Disabled
'data-disabled:opacity-50',
// Icon
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
],
solid: [
// Optical border, implemented as the button background to avoid corner artifacts
'border-transparent bg-(--btn-border)',
// Dark mode: border is rendered on `after` so background is set to button background
,
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
// Drop shadow, applied to the inset `before` layer so it blends with the border
'before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Dark mode: Subtle white outline is applied using a border
,
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
// Inner highlight shadow
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
// White overlay on hover
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
// Dark mode: `after` layer expands to cover entire button
,
// Disabled
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
],
outline: [
// Base
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
// Dark mode
,
// Icon
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)]',
],
plain: [
// Base
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
// Dark mode
,
// Icon
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)]',
],
colors: {
'dark/zinc': [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
,
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
light: [
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
,
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)]',
],
'dark/white': [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
,
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
dark: [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
,
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
white: [
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
,
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
],
zinc: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
,
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
indigo: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
],
cyan: [
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
'[--btn-icon:var(--color-cyan-500)]',
],
red: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
],
orange: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
],
amber: [
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
'[--btn-icon:var(--color-amber-600)]',
],
yellow: [
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
],
lime: [
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
],
green: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
emerald: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
teal: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
sky: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
blue: [
'text-white [--btn-hover-overlay:rgb(255_255_255_/_0.16)] [--btn-bg:var(--pm-brand)] [--btn-border:var(--pm-brand-strong)]',
'[--btn-icon:rgb(255_255_255_/_0.72)] data-active:[--btn-icon:var(--color-white)] data-hover:[--btn-icon:var(--color-white)]',
],
violet: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
],
purple: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
],
fuchsia: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
],
pink: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
],
rose: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
],
},
}
export const Button = forwardRef(function Button({ color, outline, plain, className, children, ...props }, ref) {
let classes = clsx(
className,
styles.base,
outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
)
return typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref}>
<TouchTarget>{children}</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
)
})
/**
* Expand the hit area to at least 44×44px on touch devices
*/
export function TouchTarget({ children }) {
return (
<>
<span
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
aria-hidden="true"
/>
{children}
</>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Dialog({ size = 'lg', className, children, ...props }) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
className="fixed inset-0 z-50 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
className,
sizes[size],
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl forced-colors:outline',
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function DialogTitle({ className, ...props }) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6')}
/>
)
}
export function DialogDescription({ className, ...props }) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
}
export function DialogBody({ className, ...props }) {
return <div {...props} className={clsx(className, 'mt-6')} />
}
export function DialogActions({ className, ...props }) {
return (
<div
{...props}
className={clsx(
className,
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
)}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export function InputGroup({ children }) {
return (
<span
data-slot="control"
className={clsx(
'relative isolate block',
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
'*:data-[slot=icon]:text-zinc-500'
)}
>
{children}
</span>
)
}
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
export const Input = forwardRef(function Input(
{ className, ...props },
ref
) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Input
ref={ref}
{...props}
className={clsx([
// Date classes
props.type &&
dateTypes.includes(props.type) && [
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
'[&::-webkit-datetime-edit]:inline-flex',
'[&::-webkit-datetime-edit]:p-0',
'[&::-webkit-datetime-edit-year-field]:p-0',
'[&::-webkit-datetime-edit-month-field]:p-0',
'[&::-webkit-datetime-edit-day-field]:p-0',
'[&::-webkit-datetime-edit-hour-field]:p-0',
'[&::-webkit-datetime-edit-minute-field]:p-0',
'[&::-webkit-datetime-edit-second-field]:p-0',
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
],
// Basic layout
'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'data-disabled:border-zinc-950/20',
// System icons
,
])}
/>
</span>
)
})
/**
* TODO: Update this component to use your client-side framework's link
* component. We've provided examples of how to do this for Next.js, Remix, and
* Inertia.js in the Catalyst documentation:
*
* https://catalyst.tailwindui.com/docs#client-side-router-integration
*/
import * as Headless from '@headlessui/react'
import React, { forwardRef } from 'react'
export const Link = forwardRef(function Link(props, ref) {
return (
<Headless.DataInteractive>
<a {...props} ref={ref} />
</Headless.DataInteractive>
)
})
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Select = forwardRef(function Select({ className, multiple, ...props }, ref) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'group relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Select
ref={ref}
multiple={multiple}
{...props}
className={clsx([
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Horizontal padding
multiple
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Options (multi-select)
'[&_optgroup]:font-semibold',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'data-disabled:border-zinc-950/20 data-disabled:opacity-100',
])}
/>
{!multiple && (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
)}
</span>
)
})
'use client';
import clsx from 'clsx';
import { createContext, useContext, useState } from 'react';
import { Link } from './link';
const TableContext = createContext({
bleed: false,
dense: false,
grid: false,
striped: false,
});
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
className,
children,
...props
}) {
return (
<TableContext.Provider value={{ bleed, dense, grid, striped }}>
<div className="flow-root">
<div
{...props}
className={clsx(
className,
'-mx-(--gutter) overflow-x-auto whitespace-nowrap',
)}
>
<div
className={clsx(
'inline-block min-w-full align-middle',
!bleed && 'sm:px-(--gutter)',
)}
>
<table className="min-w-full text-left text-sm/6 text-zinc-950">
{children}
</table>
</div>
</div>
</div>
</TableContext.Provider>
);
}
export function TableHead({ className, ...props }) {
return <thead {...props} className={clsx(className, 'text-zinc-500')} />;
}
export function TableBody(props) {
return <tbody {...props} />;
}
const TableRowContext = createContext({
href: undefined,
target: undefined,
title: undefined,
});
export function TableRow({ href, target, title, className, ...props }) {
let { striped } = useContext(TableContext);
return (
<TableRowContext.Provider value={{ href, target, title }}>
<tr
{...props}
className={clsx(
className,
href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-[var(--pm-brand)]',
striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5',
)}
/>
</TableRowContext.Provider>
);
}
export function TableHeader({ className, ...props }) {
let { bleed, grid } = useContext(TableContext);
return (
<th
{...props}
className={clsx(
className,
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
/>
);
}
export function TableCell({ className, children, ...props }) {
let { bleed, dense, grid, striped } = useContext(TableContext);
let { href, target, title } = useContext(TableRowContext);
let [cellRef, setCellRef] = useState(null);
return (
<td
ref={href ? setCellRef : undefined}
{...props}
className={clsx(
className,
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
!striped && 'border-b border-zinc-950/5',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
dense ? 'py-2.5' : 'py-4',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
>
{href && (
<Link
data-row-link
href={href}
target={target}
aria-label={title}
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
className="absolute inset-0 focus:outline-hidden"
/>
)}
{children}
</td>
);
}
import clsx from 'clsx'
import { Link } from './link'
export function Text({ className, ...props }) {
return (
<p
data-slot="text"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6')}
/>
)
}
export function TextLink({ className, ...props }) {
return (
<Link
{...props}
className={clsx(
className,
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950'
)}
/>
)
}
export function Strong({ className, ...props }) {
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950')} />
}
export function Code({ className, ...props }) {
return (
<code
{...props}
className={clsx(
className,
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem]'
)}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Textarea = forwardRef(function Textarea({ className, resizable = true, ...props }, ref) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Textarea
ref={ref}
{...props}
className={clsx([
// Basic layout
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'disabled:border-zinc-950/20',
// Resizable
resizable ? 'resize-y' : 'resize-none',
])}
/>
</span>
)
})
:root {
--pm-brand: #b42318;
--pm-brand-strong: #991b1b;
--pm-surface: #ffffff;
--pm-bg: #f8fafc;
--pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b;
--pm-text: #0f172a;
--pm-muted: #64748b;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(251, 191, 188, 0.22), transparent 26%),
radial-gradient(circle at bottom right, rgba(251, 207, 232, 0.2), transparent 30%),
#f8fafc;
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;
}
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useMemo, useState } from 'react';
import { history, useSearchParams } from 'umi';
function buildPassword(password, siteKey) {
if (!password) {
return password;
}
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
}
export default function LoginPage() {
const site = useContext(SiteContext);
const [searchParams] = useSearchParams();
const [identity, setIdentity] = useState('');
const [password, setPassword] = useState('');
const [captchaCode, setCaptchaCode] = useState('');
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/';
const siteTitle = site?.title || site?.name || '项目管理系统';
const siteSubtitle =
site?.subtitle || '项目下发、成员协作、甘特排期与个人日历统一协同';
const backgroundStyle = useMemo(() => {
const overlay =
'linear-gradient(135deg, rgba(5, 23, 41, 0.92) 0%, rgba(14, 62, 90, 0.9) 50%, rgba(0, 122, 181, 0.82) 100%)';
if (!site?.background) {
return {
backgroundImage: `${overlay}, radial-gradient(circle at top, #12385d 0%, #081a2c 55%, #040b15 100%)`,
};
}
return {
backgroundImage: `${overlay}, url(${site.background})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
};
}, [site?.background]);
const refreshCaptcha = async () => {
setCaptchaLoading(true);
try {
const nextCaptcha = await getCaptcha();
setCaptchaData(nextCaptcha || null);
} catch (requestError) {
setCaptchaData(null);
setError(requestError?.message || '验证码加载失败,请稍后重试');
} finally {
setCaptchaLoading(false);
}
};
useEffect(() => {
refreshCaptcha();
}, []);
const handleSubmit = async (event) => {
event.preventDefault();
setLoading(true);
setError('');
try {
const loginInfo = await userLogin({
identity,
password: buildPassword(password, site?.key),
captcha: captchaCode,
token: captchaData?.token,
type: 'password',
});
authService.setCurrentUser(loginInfo);
if (loginInfo?.token) {
authService.setToken(loginInfo.token);
}
history.push(redirect);
} catch (requestError) {
setError(requestError?.message || '登录失败,请重试');
setCaptchaCode('');
refreshCaptcha();
} finally {
setLoading(false);
}
};
return (
<div
className="min-h-screen bg-[#061321] px-4 py-6 text-white md:px-8"
style={backgroundStyle}
>
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-7xl overflow-hidden rounded-[32px] border border-white/10 bg-white/6 shadow-[0_28px_120px_rgba(0,0,0,0.38)] backdrop-blur-sm">
<section className="relative hidden w-[52%] flex-col justify-between overflow-hidden px-10 py-12 lg:flex">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(49,196,255,0.3),transparent_32%)]" />
<div className="relative z-10 max-w-xl">
<p className="text-xs font-medium uppercase tracking-[0.45em] text-white/60">
Streams Style Login
</p>
<h1 className="mt-6 text-4xl font-semibold leading-tight text-white">
{siteTitle}
</h1>
<p className="mt-5 max-w-lg text-base leading-7 text-white/72">
{siteSubtitle}
</p>
</div>
<div className="relative z-10 grid gap-4 xl:grid-cols-2">
<div className="rounded-3xl border border-white/10 bg-white/8 p-6">
<div className="text-sm text-white/65">协同视图</div>
<div className="mt-3 text-2xl font-semibold text-white">
甘特图 + 日历图
</div>
<p className="mt-3 text-sm leading-6 text-white/68">
领导下发项目后,成员可同步在个人日历中查看并共享进度状态。
</p>
</div>
<div className="rounded-3xl border border-white/10 bg-black/18 p-6">
<div className="text-sm text-white/65">状态追踪</div>
<div className="mt-3 text-2xl font-semibold text-white">完成与超时</div>
<p className="mt-3 text-sm leading-6 text-white/68">
计划周期、实际完成与超时状态统一记录,适合项目排期与资源预定。
</p>
</div>
</div>
</section>
<section className="flex flex-1 items-center justify-center bg-white/92 px-6 py-10 text-slate-900 sm:px-10">
<div className="w-full max-w-md">
<div className="mb-8">
<p className="text-sm font-medium uppercase tracking-[0.35em] text-sky-600/80">
Sign In
</p>
<h2 className="mt-3 text-3xl font-semibold text-slate-900">
登录项目管理系统
</h2>
<p className="mt-3 text-sm leading-6 text-slate-500">
参考 `streams-admin` 登录页改造,已接入验证码校验流程。
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">
账号
</label>
<input
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:bg-white"
value={identity}
onChange={(event) => setIdentity(event.target.value)}
disabled={loading}
placeholder="请输入账号"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">
密码
</label>
<input
type="password"
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:bg-white"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={loading}
placeholder="请输入密码"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">
验证码
</label>
<div className="flex gap-3">
<input
className="min-w-0 flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:bg-white"
value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value)}
disabled={loading}
placeholder="请输入验证码"
required
/>
<button
type="button"
onClick={() => {
setError('');
refreshCaptcha();
}}
disabled={loading || captchaLoading}
className="flex h-[56px] w-[148px] shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white px-2 text-xs text-slate-500 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-70"
>
{captchaData?.image ? (
<span
className="captcha-image flex h-full w-full items-center justify-center"
dangerouslySetInnerHTML={{ __html: captchaData.image }}
/>
) : (
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)}
</button>
</div>
</div>
{error ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{error}
</div>
) : null}
<button
type="submit"
disabled={loading || captchaLoading}
className="flex w-full items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#0b75b6_0%,#1095d3_55%,#18b6f6_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_40px_rgba(17,128,196,0.28)] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
</section>
</div>
</div>
);
}
import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react';
import {
ArrowTopRightOnSquareIcon,
ExclamationCircleIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
return {
name,
title:
application?.title ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return (
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={clsx(
'group relative min-h-[84px] overflow-hidden rounded-2xl border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.06)] transition duration-200 ease-out hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-[0_16px_32px_rgba(244,63,94,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
current &&
'border-rose-300 bg-[linear-gradient(180deg,rgba(255,241,242,0.95),rgba(255,255,255,0.96))]',
)}
>
<div className="absolute inset-x-0 top-0 h-14 bg-[radial-gradient(circle_at_top_right,rgba(251,113,133,0.14),transparent_50%)] opacity-0 transition duration-200 group-hover:opacity-100" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className="size-9 rounded-xl border border-white/80 bg-white object-cover shadow-sm"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,#b42318,#f43f5e)] text-sm font-semibold text-white shadow-[0_8px_18px_rgba(180,35,24,0.22)]">
{application.title.slice(0, 1)}
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-950">
{application.title}
</div>
<div className="mt-0.5 truncate text-[11px] leading-4 text-slate-500">
{application.description}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{current ? (
<span className="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700">
当前
</span>
) : null}
<span className="rounded-full bg-slate-100 p-1.5 text-slate-500 transition group-hover:bg-rose-50 group-hover:text-rose-600">
<ArrowTopRightOnSquareIcon className="size-3.5" />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className="relative mt-2 flex flex-wrap gap-1.5">
{accessLabel ? (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{accessLabel}
</span>
) : null}
{firstRole ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700">
{firstGroup}
</span>
) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[84px] rounded-2xl border border-zinc-200 bg-white/90 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)]"
>
<div className="animate-pulse">
<div className="flex items-start gap-3">
<div className="size-9 rounded-xl bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" />
<div className="h-3 w-full rounded-full bg-zinc-100" />
</div>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-12 rounded-full bg-zinc-100" />
<div className="h-5 w-14 rounded-full bg-zinc-100" />
</div>
</div>
</div>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, siteInfo?.group);
}, [applications, siteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Headless.Dialog
open={open}
onClose={onClose}
className="relative z-[180]"
>
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-[rgba(15,23,42,0.48)] backdrop-blur-sm transition duration-300 ease-out data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-y-auto p-3 sm:p-4 lg:p-5">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-[92rem] flex-col overflow-hidden rounded-[28px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(248,250,252,0.94))] p-4 shadow-[0_30px_80px_rgba(15,23,42,0.22)] ring-1 ring-black/5 transition duration-300 ease-out data-closed:translate-y-4 data-closed:scale-[0.97] data-closed:opacity-0 sm:p-5"
>
<div className="flex shrink-0 flex-col gap-3 border-b border-zinc-200/80 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-600">
<SparklesIcon className="size-3.5" />
Applications
</div>
<Headless.DialogTitle className="mt-2 text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
应用切换
</Headless.DialogTitle>
<p className="mt-1 max-w-2xl text-xs leading-5 text-slate-500">
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-rose-100 bg-rose-50/70 px-3 py-2 text-right">
<div className="text-[11px] font-medium tracking-wide text-rose-500">
可见应用
</div>
<div className="mt-0.5 text-xl font-semibold tracking-tight text-slate-950">
{applications.length}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 items-center justify-center rounded-xl border border-zinc-200 bg-white px-3.5 text-sm font-medium text-slate-600 transition hover:border-zinc-300 hover:text-slate-900"
>
关闭
</button>
</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<ExclamationCircleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" />
<div>{errorMessage}</div>
</div>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-4">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-2 flex items-center gap-3">
<h3 className="text-sm font-semibold text-slate-900">
{section.label}
</h3>
<span className="h-px flex-1 bg-zinc-200/80" />
<span className="text-xs font-medium text-slate-400">
{section.applications.length}
</span>
</div>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-[28px] border border-dashed border-zinc-200 bg-zinc-50/80 px-6 text-center">
<div className="text-lg font-semibold text-slate-900">
暂无可切换应用
</div>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</p>
</div>
)}
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
);
}
export default function EmptyStatePage({ title, description }) {
return (
<section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-white/90 p-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-sky-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-cyan-50 blur-3xl" />
<div className="relative">
<h2 className="text-3xl font-semibold text-slate-900">{title}</h2>
{description ? (
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-500">
{description}
</p>
) : null}
</div>
</section>
);
}
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import {
Bars3Icon,
ClipboardDocumentListIcon,
Squares2X2Icon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useLocation } from 'umi';
const APP_NAME = '党风廉政建设';
const menuGroups = [
{
title: '党风廉政工作台',
items: [
{
name: '驾驶舱',
path: '/resource/stat',
icon: Squares2X2Icon,
},
{
name: '党风廉政建设',
path: '/integrity-party',
icon: ClipboardDocumentListIcon,
},
],
},
];
function NavbarItem({ className, ...props }) {
return (
<button
type="button"
className={clsx(
'inline-flex items-center justify-center rounded-lg p-2 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900',
className,
)}
{...props}
/>
);
}
function NavItem({ name, path, active, collapsed, icon: Icon, onNavigate }) {
return (
<Link
to={path}
onClick={onNavigate}
title={collapsed ? name : undefined}
aria-label={collapsed ? name : undefined}
className={clsx(
'group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
active
? 'bg-[var(--pm-brand)] text-white shadow-lg'
: 'text-white/90 hover:bg-zinc-800 hover:text-white',
collapsed && 'justify-center px-2',
)}
>
{Icon ? (
<Icon
className={clsx(
'size-5 shrink-0 transition-colors',
active ? 'text-white' : 'text-white/80 group-hover:text-white',
)}
/>
) : null}
{!collapsed ? (
<span
className={clsx(
'min-w-0 flex-1 truncate',
active ? 'text-white' : 'text-white/90 group-hover:text-white',
)}
>
{name}
</span>
) : null}
{active && !collapsed ? (
<span className="absolute right-2 size-1.5 rounded-full bg-white/50" />
) : null}
</Link>
);
}
function MobileNavItem({ name, path, active, icon: Icon, onNavigate }) {
return (
<Link
to={path}
onClick={onNavigate}
className={clsx(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
active
? 'bg-[var(--pm-brand)] text-white shadow-lg'
: 'text-white/90 hover:bg-zinc-800 hover:text-white',
)}
>
{Icon ? (
<Icon
className={clsx(
'size-5 shrink-0',
active ? 'text-white' : 'text-white/80',
)}
/>
) : null}
<span
className={clsx(
'min-w-0 flex-1 truncate',
active ? 'text-white' : 'text-white/90',
)}
>
{name}
</span>
</Link>
);
}
function CollapseSidebarIcon() {
return (
<svg
data-slot="icon"
viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor"
>
<path d="M12.78 15.53a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 1 1 1.06 1.06L8.81 10l3.97 3.97a.75.75 0 0 1 0 1.06Z" />
</svg>
);
}
function ExpandSidebarIcon() {
return (
<svg
data-slot="icon"
viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor"
>
<path d="M7.22 15.53a.75.75 0 0 0 1.06 0l4.5-4.5a.75.75 0 0 0 0-1.06l-4.5-4.5a.75.75 0 1 0-1.06 1.06L11.19 10l-3.97 3.97a.75.75 0 0 0 0 1.06Z" />
</svg>
);
}
function MobileSidebar({ open, close, children }) {
if (!open) {
return null;
}
return (
<div className="relative z-[140] lg:hidden">
<div
className="fixed inset-0 z-[140] bg-black/30"
onClick={close}
aria-hidden="true"
/>
<div className="fixed inset-y-0 z-[150] w-full max-w-80 p-2">
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
<div className="-mb-3 flex justify-end px-4 pt-3">
<NavbarItem
onClick={close}
aria-label="Close navigation"
className="text-zinc-400 hover:text-white"
>
<XMarkIcon className="size-5" />
</NavbarItem>
</div>
{children}
</div>
</div>
</div>
);
}
export default function ProjectManagerLayout() {
const location = useLocation();
const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 1023px)');
const sync = () => {
const mobile = !!media.matches;
setIsMobile(mobile);
if (mobile) {
setIsMobileSidebarOpen(false);
setIsSidebarCollapsed(true);
} else {
const storedCollapsed = window.localStorage?.getItem?.(
'pm_sidebar_collapsed',
);
if (storedCollapsed !== null && storedCollapsed !== undefined) {
setIsSidebarCollapsed(storedCollapsed === '1');
}
}
};
sync();
media.addEventListener?.('change', sync);
return () => {
media.removeEventListener?.('change', sync);
};
}, []);
const sidebarCollapsed = isMobile ? true : isSidebarCollapsed;
const currentGroupTitle = useMemo(() => {
return (
menuGroups.find((group) =>
group.items.some((item) => item.path === location.pathname),
)?.title || ''
);
}, [location.pathname]);
const applicationSwitcherButton = (
<button
type="button"
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
title="应用切换"
className={clsx(
'inline-flex items-center justify-center rounded-[20px] border border-[#f97316]/70 bg-[linear-gradient(180deg,rgba(39,39,42,0.92),rgba(24,24,27,0.96))] text-[#fb923c] shadow-[0_16px_30px_rgba(249,115,22,0.16)] transition duration-200 hover:-translate-y-0.5 hover:border-[#fb923c] hover:text-[#fdba74] hover:shadow-[0_18px_36px_rgba(249,115,22,0.26)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/50',
sidebarCollapsed ? 'size-11' : 'size-14',
)}
>
<Squares2X2Icon className={clsx(sidebarCollapsed ? 'size-4' : 'size-5')} />
</button>
);
return (
<div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]">
<aside
className={clsx(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex',
sidebarCollapsed ? 'w-20' : 'w-72',
)}
>
<div
className={clsx(
'mb-7',
sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)}
>
{sidebarCollapsed ? (
<>
<img
src={xyLogoSmall}
alt="XY Logo"
className="h-8 w-auto max-w-8 shrink-0 object-contain"
/>
{applicationSwitcherButton}
</>
) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
<div className="mt-1 text-xs text-zinc-400">
党风廉政台账工作台
</div>
</div>
{applicationSwitcherButton}
</>
)}
</div>
<nav className="flex-1 space-y-5 overflow-y-auto">
{menuGroups.map((group) => (
<div key={group.title}>
{!sidebarCollapsed ? (
<h2 className="mb-2 px-1 text-xs font-medium tracking-wide text-zinc-500">
{group.title}
</h2>
) : null}
<div className="space-y-1">
{group.items.map((item) => (
<NavItem
key={item.path}
name={item.name}
path={item.path}
active={location.pathname === item.path}
collapsed={sidebarCollapsed}
icon={item.icon}
/>
))}
</div>
</div>
))}
</nav>
{!sidebarCollapsed ? (
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-300">
<div className="font-medium text-white/90">
{userInfo?.display_name || userInfo?.username || '当前用户'}
</div>
<div className="mt-1 text-xs text-zinc-500">
当前正在查看党风廉政建设
</div>
</div>
) : null}
</aside>
<div className="relative z-30 hidden lg:flex">
<button
type="button"
onClick={() =>
setIsSidebarCollapsed((prev) => {
const next = !prev;
window?.localStorage?.setItem?.(
'pm_sidebar_collapsed',
next ? '1' : '0',
);
return next;
})
}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="absolute -left-3.5 top-16 z-10 flex size-7 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 shadow hover:bg-zinc-700 hover:text-white [&_svg]:size-4"
>
{sidebarCollapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />}
</button>
</div>
<MobileSidebar
open={isMobileSidebarOpen}
close={() => setIsMobileSidebarOpen(false)}
>
<div className="px-5 pt-6">
<div>
<h1 className="text-base font-semibold tracking-[0.02em] text-white">
{APP_NAME}
</h1>
<div className="text-xs text-zinc-500">党风廉政工作台</div>
</div>
</div>
<nav className="space-y-5 overflow-y-auto px-5 pb-6 pt-6">
{menuGroups.map((group) => (
<div key={group.title}>
<h2 className="mb-2 px-1 text-xs font-medium tracking-wide text-zinc-600">
{group.title}
</h2>
<div className="space-y-1">
{group.items.map((item) => (
<MobileNavItem
key={item.path}
name={item.name}
path={item.path}
active={location.pathname === item.path}
icon={item.icon}
onNavigate={() => setIsMobileSidebarOpen(false)}
/>
))}
</div>
</div>
))}
</nav>
</MobileSidebar>
<main className="flex flex-1 flex-col overflow-hidden p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3 lg:hidden">
<NavbarItem
onClick={() => setIsMobileSidebarOpen(true)}
aria-label="Open navigation"
>
<Bars3Icon className="size-5" />
</NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div>
<NavbarItem
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
className="border border-rose-200 bg-white text-rose-500 shadow-sm hover:bg-rose-50 hover:text-rose-600"
>
<Squares2X2Icon className="size-4" />
</NavbarItem>
</div>
<div className="flex-1 overflow-y-auto rounded-[32px] border border-white/70 bg-white/75 p-4 shadow-[0_24px_80px_rgba(15,23,42,0.06)] backdrop-blur sm:p-6">
<Outlet />
</div>
</main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div>
);
}
# 党风廉政建设流程文档
## 1. 模块目标
本模块用于维护党风廉政建设相关台账,覆盖培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类记录。
核心原则:
- 6 类台账分别使用独立 Subject
- 每条台账对应一条 `Record`
- 所有业务字段均写入 `metadata`
- 附件字段均按多附件数组保存,由系统内上传控件维护
- Excel 导入不处理附件,导出时输出已上传附件的文件名和 URL
## 2. 功能入口
- 左侧菜单:`驾驶舱`
- 页面直接展示党风廉政统计,不再提供统计切换
- 驾驶舱展示台账总数、本年度记录、最近 30 天更新、台账分类分布、年度分布和本年度模块概览
- 本年度模块概览展示培训类 4 个培训类别的开展场次和参加人数、谈话类已谈话人数及投资序列/管理序列人数、调研类/专题会议类/廉洁主题活动/节前廉洁提醒的开展次数
- 左侧菜单:`党风廉政建设`
- 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒`
- 每个 Tab 对应独立台账对象和独立 Excel 模板
## 3. 核心业务对象
| 台账分类 | Subject |
| --- | --- |
| 培训类 | `integrity_party_training` |
| 谈话类 | `integrity_party_talk` |
| 调研类 | `integrity_party_research` |
| 专题会议类 | `integrity_party_meeting` |
| 廉洁主题活动 | `integrity_party_activity` |
| 节前廉洁提醒 | `integrity_party_holiday_reminder` |
## 4. 业务流程
### 4.1 浏览与检索台账
1. 用户进入 `党风廉政建设`
2. 通过顶部 Tab 选择台账分类
3. 页面展示该分类的表格台账
4. 筛选区统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选
5. 用户点击 `查询` 后,前端调用 `POST /record/query`
6. 列表按 `updated_at`、`created_at` 倒序展示
7. 表格仅展示核心字段和操作入口;专题会议类、廉洁主题活动、节前廉洁提醒保留附件状态、更新时间辅助列;培训类外层展示序号、培训类别、时间、授课主题、授课人、参加人数、培训对象、牵头部门,不展示附件状态和更新时间;谈话类外层展示序号、年份、姓名、所属一级部门、岗位名称、是否党员、是否领导干部、是否投资序列、本年度计划谈话时间、本年度实际谈话时间,不展示附件状态和更新时间;调研类外层展示序号、调研时间、调研单位、调研人员及职务、调研内容、调研收集的建议意见,不展示附件状态和更新时间
8. 完整字段在编辑弹窗中维护
### 4.2 手动新建台账
1. 用户点击 `新建记录`
2. 前端根据当前 Tab 字段配置生成表单
3. 用户填写最小必填字段
4. 附件字段通过系统上传控件上传,一个字段可保存多个附件
5. 前端自动生成 `title`
6. 前端调用 `POST /record`
7. 保存成功后提示并刷新当前列表
最小必填字段:
- 培训类:培训类别、时间、授课主题
- 谈话类:年份、姓名
- 调研类:调研时间、调研单位
- 专题会议类:会议时间、会议议题
- 廉洁主题活动:开展年份、活动主题
- 节前廉洁提醒:年份、重大节假日
### 4.3 编辑台账
1. 用户在表格中点击 `编辑`
2. 页面打开详情弹窗,展示当前分类全部字段
3. 用户修改字段或上传、移除附件
4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新当前列表
### 4.4 删除台账
1. 用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2. 页面弹出二次确认
3. 前端调用 `DELETE /record/:name`
4. 删除后刷新当前列表
5. 删除默认走软删除
### 4.5 Excel 导入
1. 用户在当前 Tab 点击 `下载模板`
2. 前端导出当前分类的独立模板
3. 用户维护 Excel 后点击 `Excel 导入`
4. 前端读取首个工作表并按当前 Tab 字段映射解析
5. 附件字段不参与导入
6. 前端逐条调用 `POST /record`
7. 当前阶段不做重复判断、不自动覆盖,导入全部新增
8. 导入完成后提示成功条数和失败条数
### 4.6 Excel 导出
1. 用户点击 `Excel 导出`
2. 前端按当前筛选条件分页读取全部记录
3. 导出当前分类全部字段
4. 附件字段导出为文件名和 URL,多附件用换行分隔
5. 用户也可以先勾选记录,再点击 `导出勾选`
### 4.7 批量修改
1. 用户勾选一条或多条记录
2. 点击 `批量修改`
3. 弹窗只展示当前分类允许批量覆盖的简单字段
4. 未填写字段保持原值不变
5. 前端逐条调用 `PUT /record/:name`
6. 附件、长文本主要内容等字段一期不参与批量修改
## 5. 字段与数据规则
- 日期字段页面和 Excel 统一展示为 `YYYY-MM-DD`
- 保存时日期字段转换为 ISO 8601 字符串
- 布尔字段使用三态 UI:是、否、未填写,对应 `true / false / null`
- 培训类别固定为:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训
- 驾驶舱培训类按 `training_category` 统计本年度场次,并汇总 `participant_count` 为参加人数
- 驾驶舱谈话类仅统计本年度存在 `actual_talk_date` 的已谈话记录;投资序列按 `is_investment_sequence = true` 统计,管理序列按 `is_leader = true` 统计
- 驾驶舱调研类、专题会议类、廉洁主题活动、节前廉洁提醒按本年度记录数统计开展次数
- 驾驶舱仅读取党风廉政 6 类台账 Subject,不再读取其他业务数据
- 重大节假日固定为:元旦、春节、清明、五一、端午、中秋、国庆、其他
- 选择重大节假日 `其他` 时,可填写 `holiday_other`
- 人员、部门、岗位、单位等字段一期全部使用普通文本输入,不做系统引用
## 6. 异常处理与限制
- 保存失败时保留当前表单输入并提示用户
- Excel 解析失败时不发起创建请求
- Excel 批量导入单条失败不影响其它条目继续导入
- 批量删除和单条删除均需要二次确认
- 当前阶段不做导入重复校验,后续可根据 Subject 主键配置扩展覆盖或拦截逻辑
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CheckIcon,
DocumentArrowDownIcon,
DocumentArrowUpIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '../../../catalyst-ui-kit/javascript/button';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '../../../catalyst-ui-kit/javascript/dialog';
import { Input } from '../../../catalyst-ui-kit/javascript/input';
import { Select } from '../../../catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../../catalyst-ui-kit/javascript/table';
import { Textarea } from '../../../catalyst-ui-kit/javascript/textarea';
import EmptyStatePage from '../EmptyStatePage';
import {
DEFAULT_INTEGRITY_PAGE_SIZE,
INTEGRITY_LEDGER_CONFIG_MAP,
INTEGRITY_LEDGER_CONFIGS,
INTEGRITY_PAGE_SIZE_OPTIONS,
createEmptyLedgerForm,
createLedgerRecord,
deleteLedgerRecord,
deleteLedgerRecords,
exportLedgerTemplateWorkbook,
exportLedgerWorkbook,
exportSelectedLedgerWorkbook,
formatDateTime,
importLedgerWorkbook,
loadLedgerPage,
previewLedgerWorkbook,
updateLedgerRecord,
updateLedgerRecords,
uploadLedgerAttachment,
} from '../integrityPartyService';
function Panel({ className, children }) {
return (
<section
className={clsx(
'rounded-[24px] border border-white/70 bg-white/92 shadow-[0_18px_60px_rgba(15,23,42,0.07)] backdrop-blur',
className,
)}
>
{children}
</section>
);
}
function FieldLabel({ field }) {
return (
<label className="mb-2 block text-sm font-medium text-zinc-700">
{field.label}
{field.required ? <span className="ml-1 text-red-600">*</span> : null}
</label>
);
}
function formatFileSize(size = 0) {
const numericSize = Number(size || 0);
if (!numericSize) {
return '';
}
if (numericSize < 1024) {
return `${numericSize} B`;
}
if (numericSize < 1024 * 1024) {
return `${(numericSize / 1024).toFixed(1)} KB`;
}
return `${(numericSize / 1024 / 1024).toFixed(1)} MB`;
}
function formatCellValue(field, value) {
if (field?.type === 'boolean') {
if (value === true || value === 'true') {
return '是';
}
if (value === false || value === 'false') {
return '否';
}
return '-';
}
if (field?.type === 'file') {
return Array.isArray(value) && value.length > 0 ? `${value.length} 个附件` : '-';
}
if (field?.type === 'long_text') {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > 42 ? `${text.slice(0, 42)}...` : text || '-';
}
return value === null || value === undefined || value === '' ? '-' : String(value);
}
function AttachmentField({ field, value = [], onChange, disabled }) {
const inputRef = useRef(null);
const [uploading, setUploading] = useState(false);
const files = Array.isArray(value) ? value : [];
async function handleUpload(event) {
const selectedFiles = Array.from(event.target.files || []);
if (selectedFiles.length === 0) {
return;
}
setUploading(true);
try {
const uploadedFiles = [];
for (const file of selectedFiles) {
uploadedFiles.push(await uploadLedgerAttachment(file));
}
onChange([...files, ...uploadedFiles]);
} finally {
setUploading(false);
event.target.value = '';
}
}
return (
<div>
<div className="flex flex-wrap items-center gap-3">
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
disabled={disabled || uploading}
/>
<Button
type="button"
outline
onClick={() => inputRef.current?.click()}
disabled={disabled || uploading}
>
<DocumentArrowUpIcon data-slot="icon" />
{uploading ? '上传中...' : `上传${field.label}`}
</Button>
<span className="text-sm text-zinc-500">已上传 {files.length} 个</span>
</div>
{files.length > 0 ? (
<div className="mt-3 space-y-2">
{files.map((file, index) => (
<div
key={`${file.name || file.url}-${index}`}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
{file.url ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="truncate font-medium text-[var(--pm-brand)] hover:underline"
>
{file.name || file.url}
</a>
) : (
<div className="truncate font-medium text-zinc-800">
{file.name || '未命名附件'}
</div>
)}
<div className="mt-0.5 text-xs text-zinc-500">
{[file.type, formatFileSize(file.size)].filter(Boolean).join(' · ') || '系统附件'}
</div>
</div>
<Button
type="button"
plain
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
disabled={disabled}
>
<TrashIcon data-slot="icon" />
移除
</Button>
</div>
))}
</div>
) : null}
</div>
);
}
function FieldControl({ field, value, onChange, compact = false }) {
const commonProps = {
value: value ?? '',
onChange: (event) => onChange(event.target.value),
};
if (field.type === 'select') {
return (
<Select {...commonProps}>
<option value="">{field.emptyLabel || '请选择'}</option>
{(field.options || []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
);
}
if (field.type === 'boolean') {
return (
<Select {...commonProps}>
<option value="">{field.emptyLabel || '未填写'}</option>
<option value="true">是</option>
<option value="false">否</option>
</Select>
);
}
if (field.type === 'long_text') {
return (
<Textarea
{...commonProps}
rows={compact ? 3 : 5}
placeholder={`请输入${field.label}`}
/>
);
}
if (field.type === 'file') {
return (
<AttachmentField
field={field}
value={Array.isArray(value) ? value : []}
onChange={onChange}
/>
);
}
return (
<Input
{...commonProps}
type={
field.type === 'number'
? 'number'
: field.type === 'date'
? 'date'
: 'text'
}
placeholder={`请输入${field.label}`}
/>
);
}
function LedgerForm({ config, form, setForm, fields = null, compact = false }) {
const visibleFields = fields || config.fields;
return (
<div className={clsx('grid gap-5', compact ? 'md:grid-cols-2' : 'md:grid-cols-2')}>
{visibleFields.map((field) => (
<div
key={field.name}
className={clsx(
field.type === 'long_text' || field.type === 'file'
? 'md:col-span-2'
: '',
)}
>
<FieldLabel field={field} />
<FieldControl
field={field}
value={form[field.name]}
compact={compact}
onChange={(nextValue) =>
setForm((current) => ({
...current,
[field.name]: nextValue,
}))
}
/>
</div>
))}
</div>
);
}
function createInitialFilters() {
return INTEGRITY_LEDGER_CONFIGS.reduce((accumulator, config) => {
accumulator[config.key] = createEmptyFilters(config);
return accumulator;
}, {});
}
function createEmptyFilters(config) {
return {
keyword: '',
year: '',
focusValue: '',
...(config.filterFields || []).reduce((accumulator, field) => {
accumulator[field.name] = '';
return accumulator;
}, {}),
};
}
export default function IntegrityPartyPage() {
const [activeKey, setActiveKey] = useState(INTEGRITY_LEDGER_CONFIGS[0].key);
const activeConfig = INTEGRITY_LEDGER_CONFIG_MAP[activeKey];
const [draftFilters, setDraftFilters] = useState(() => createInitialFilters());
const [appliedFilters, setAppliedFilters] = useState(() => createInitialFilters());
const [records, setRecords] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_INTEGRITY_PAGE_SIZE);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState('create');
const [activeRecord, setActiveRecord] = useState(null);
const [detailForm, setDetailForm] = useState(() => createEmptyLedgerForm(activeKey));
const [saving, setSaving] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importPreview, setImportPreview] = useState(null);
const [importing, setImporting] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [batchForm, setBatchForm] = useState(() => createEmptyLedgerForm(activeKey));
const [batchSaving, setBatchSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState({ open: false, mode: 'single' });
const fieldMap = useMemo(() => {
return activeConfig.fields.reduce((accumulator, field) => {
accumulator[field.name] = field;
return accumulator;
}, {});
}, [activeConfig]);
const selectedRecords = useMemo(
() => records.filter((record) => selectedIds.includes(record.id)),
[records, selectedIds],
);
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
const pageStart = total === 0 ? 0 : page * pageSize + 1;
const pageEnd = Math.min((page + 1) * pageSize, total);
async function reload(nextPage = page) {
setLoading(true);
setError('');
const result = await loadLedgerPage(activeKey, {
...appliedFilters[activeKey],
page: nextPage,
pageSize,
});
setRecords(result.records || []);
setTotal(result.total || 0);
setPage(result.page || 0);
setSelectedIds([]);
if (result.error) {
setError(result.error);
}
setLoading(false);
}
useEffect(() => {
reload(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey, pageSize, appliedFilters]);
function updateDraftFilter(name, value) {
setDraftFilters((current) => ({
...current,
[activeKey]: {
...current[activeKey],
[name]: value,
},
}));
}
function applyFilters() {
setAppliedFilters((current) => ({
...current,
[activeKey]: {
...draftFilters[activeKey],
},
}));
setPage(0);
}
function resetFilters() {
const empty = createEmptyFilters(activeConfig);
setDraftFilters((current) => ({
...current,
[activeKey]: empty,
}));
setAppliedFilters((current) => ({
...current,
[activeKey]: empty,
}));
setPage(0);
}
function openCreateDialog() {
setDetailMode('create');
setActiveRecord(null);
setDetailForm(createEmptyLedgerForm(activeKey));
setDetailOpen(true);
}
function openEditDialog(record) {
setDetailMode('edit');
setActiveRecord(record);
setDetailForm({
...createEmptyLedgerForm(activeKey),
...record.metadata,
});
setDetailOpen(true);
}
async function saveDetail() {
setSaving(true);
setError('');
try {
if (detailMode === 'create') {
const created = await createLedgerRecord(activeKey, detailForm);
setNotice(`记录“${created.title}”已创建。`);
} else {
const updated = await updateLedgerRecord(activeKey, activeRecord, detailForm);
setNotice(`记录“${updated.title}”已保存。`);
}
setDetailOpen(false);
await reload(page);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function confirmDelete() {
setSaving(true);
setError('');
try {
if (deleteConfirm.mode === 'batch') {
const result = await deleteLedgerRecords(selectedRecords);
setNotice(
result.failedCount
? `已删除 ${result.deletedCount} 条,${result.failedCount} 条删除失败。`
: `已批量删除 ${result.deletedCount} 条记录。`,
);
} else if (activeRecord) {
await deleteLedgerRecord(activeRecord);
setNotice(`记录“${activeRecord.title}”已删除。`);
setDetailOpen(false);
}
setDeleteConfirm({ open: false, mode: 'single' });
await reload(0);
} catch (deleteError) {
setError(deleteError?.message || '删除失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function handleTemplateExport() {
try {
const result = await exportLedgerTemplateWorkbook(activeKey);
setNotice(`导入模板已导出:${result.fileName}`);
} catch (exportError) {
setError(exportError?.message || '模板导出失败,请稍后重试。');
}
}
async function handleWorkbookExport() {
try {
const result = await exportLedgerWorkbook(activeKey, appliedFilters[activeKey]);
setNotice(`已导出 ${result.count} 条记录到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '导出失败,请稍后重试。');
}
}
async function handleSelectedExport() {
if (selectedRecords.length === 0) {
setError('请先勾选需要导出的记录。');
return;
}
try {
const result = await exportSelectedLedgerWorkbook(activeKey, selectedRecords);
setNotice(`已导出 ${result.count} 条勾选记录到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '勾选导出失败,请稍后重试。');
}
}
async function handlePreviewImport(file) {
setImportFile(file);
setImportPreview(null);
if (!file) {
return;
}
try {
const preview = await previewLedgerWorkbook(activeKey, file);
setImportPreview(preview);
} catch (previewError) {
setError(previewError?.message || 'Excel 预览失败,请检查文件。');
}
}
async function handleImportWorkbook() {
if (!importFile) {
setError('请先选择 Excel 文件。');
return;
}
setImporting(true);
try {
const result = await importLedgerWorkbook(activeKey, importFile);
setNotice(
result.failedCount
? `已导入 ${result.createdCount} 条,${result.failedCount} 条失败。`
: `已从 ${result.sheetName} 导入 ${result.createdCount} 条记录。`,
);
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
await reload(0);
} catch (importError) {
setError(importError?.message || '导入失败,请稍后重试。');
} finally {
setImporting(false);
}
}
function openBatchDialog() {
if (selectedRecords.length === 0) {
setError('请先勾选需要批量修改的记录。');
return;
}
setBatchForm(createEmptyLedgerForm(activeKey));
setBatchOpen(true);
}
async function handleBatchSave() {
setBatchSaving(true);
try {
const result = await updateLedgerRecords(activeKey, selectedRecords, batchForm);
setNotice(
result.failedCount
? `已批量更新 ${result.updatedCount} 条,${result.failedCount} 条失败。`
: `已批量更新 ${result.updatedCount} 条记录。`,
);
setBatchOpen(false);
await reload(page);
} catch (batchError) {
setError(batchError?.message || '批量修改失败,请稍后重试。');
} finally {
setBatchSaving(false);
}
}
const focusFilter = activeConfig.focusFilter;
const configuredFilterFields = activeConfig.filterFields || [];
const focusField =
focusFilter?.type === 'select'
? {
label: focusFilter.label,
name: focusFilter.name,
type: 'select',
options: focusFilter.options || [],
}
: null;
return (
<div className="space-y-5">
<Panel className="p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="text-sm font-medium text-[var(--pm-brand)]">
党风廉政建设
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-950 sm:text-3xl">
台账管理
</h1>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" outline onClick={handleTemplateExport}>
<DocumentArrowDownIcon data-slot="icon" />
下载模板
</Button>
<Button
type="button"
outline
onClick={() => {
setImportOpen(true);
setImportFile(null);
setImportPreview(null);
}}
>
<DocumentArrowUpIcon data-slot="icon" />
Excel 导入
</Button>
<Button type="button" outline onClick={handleWorkbookExport}>
<ArrowDownTrayIcon data-slot="icon" />
Excel 导出
</Button>
<Button type="button" color="blue" onClick={openCreateDialog}>
<PlusIcon data-slot="icon" />
新建记录
</Button>
</div>
</div>
<div className="mt-6 flex gap-2 overflow-x-auto pb-1">
{INTEGRITY_LEDGER_CONFIGS.map((config) => (
<button
key={config.key}
type="button"
onClick={() => {
setActiveKey(config.key);
setPage(0);
setSelectedIds([]);
}}
className={clsx(
'shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition',
activeKey === config.key
? 'bg-[var(--pm-brand)] text-white shadow-sm'
: 'bg-white text-zinc-600 ring-1 ring-zinc-200 hover:bg-zinc-50 hover:text-zinc-950',
)}
>
{config.tabLabel}
</button>
))}
</div>
</Panel>
{notice ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{notice}
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
) : null}
<Panel className="p-5">
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1">
<div className="mb-2 text-sm font-medium text-zinc-600">关键词</div>
<Input
value={draftFilters[activeKey]?.keyword || ''}
onChange={(event) => updateDraftFilter('keyword', event.target.value)}
placeholder="搜索标题、主要内容、部门、人员等"
/>
</div>
<div className="min-w-[140px] flex-1">
<div className="mb-2 text-sm font-medium text-zinc-600">年份</div>
<Input
type="number"
value={draftFilters[activeKey]?.year || ''}
onChange={(event) => updateDraftFilter('year', event.target.value)}
placeholder="YYYY"
/>
</div>
<div className="min-w-[180px] flex-1">
<div className="mb-2 text-sm font-medium text-zinc-600">
{focusFilter.label}
</div>
{focusField ? (
<FieldControl
field={focusField}
value={draftFilters[activeKey]?.focusValue || ''}
onChange={(value) => updateDraftFilter('focusValue', value)}
/>
) : (
<Input
value={draftFilters[activeKey]?.focusValue || ''}
onChange={(event) => updateDraftFilter('focusValue', event.target.value)}
placeholder={`输入${focusFilter.label}`}
/>
)}
</div>
{configuredFilterFields.map((field) => (
<div key={field.name} className="min-w-[160px] flex-1">
<div className="mb-2 text-sm font-medium text-zinc-600">
{field.label}
</div>
<FieldControl
field={field}
value={draftFilters[activeKey]?.[field.name] || ''}
onChange={(value) => updateDraftFilter(field.name, value)}
compact
/>
</div>
))}
<Button type="button" color="blue" onClick={applyFilters}>
<CheckIcon data-slot="icon" />
查询
</Button>
<Button type="button" outline onClick={resetFilters}>
<ArrowPathIcon data-slot="icon" />
重置
</Button>
</div>
</Panel>
<Panel className="overflow-hidden">
<div className="flex flex-col gap-3 border-b border-zinc-200 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="font-semibold text-zinc-950">{activeConfig.label}</div>
<div className="mt-1 text-sm text-zinc-500">
共 {total} 条,当前显示 {pageStart}-{pageEnd} 条
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" outline onClick={handleSelectedExport}>
<ArrowDownTrayIcon data-slot="icon" />
导出勾选
</Button>
<Button type="button" outline onClick={openBatchDialog}>
<PencilSquareIcon data-slot="icon" />
批量修改
</Button>
<Button
type="button"
outline
onClick={() => {
if (selectedRecords.length === 0) {
setError('请先勾选需要批量删除的记录。');
return;
}
setDeleteConfirm({ open: true, mode: 'batch' });
}}
>
<TrashIcon data-slot="icon" />
批量删除
</Button>
</div>
</div>
{loading ? (
<div className="px-5 py-8 text-sm text-[var(--pm-brand)]">
正在加载台账...
</div>
) : records.length === 0 ? (
<EmptyStatePage title="当前筛选条件下还没有台账记录" />
) : (
<Table dense striped className="[--gutter:--spacing(5)]">
<TableHead>
<TableRow>
<TableHeader className="w-12">
<input
type="checkbox"
aria-label="全选当前页"
checked={
records.length > 0 &&
records.every((record) => selectedIds.includes(record.id))
}
onChange={(event) =>
setSelectedIds(
event.target.checked ? records.map((record) => record.id) : [],
)
}
className="size-4 rounded border-zinc-300 text-[var(--pm-brand)]"
/>
</TableHeader>
{activeConfig.tableColumns.map((fieldName) => (
<TableHeader key={fieldName}>
{fieldMap[fieldName]?.label || fieldName}
</TableHeader>
))}
{activeConfig.showAttachmentStatusColumn === false ? null : (
<TableHeader>附件状态</TableHeader>
)}
{activeConfig.showUpdatedAtColumn === false ? null : (
<TableHeader>更新时间</TableHeader>
)}
<TableHeader className="text-right">操作</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{records.map((record) => (
<TableRow key={record.id}>
<TableCell>
<input
type="checkbox"
aria-label={`勾选记录 ${record.title}`}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked
? [...new Set([...current, record.id])]
: current.filter((id) => id !== record.id),
);
}}
className="size-4 rounded border-zinc-300 text-[var(--pm-brand)]"
/>
</TableCell>
{activeConfig.tableColumns.map((fieldName) => (
<TableCell key={fieldName} className="max-w-[260px]">
<span title={formatCellValue(fieldMap[fieldName], record.metadata[fieldName])}>
{formatCellValue(fieldMap[fieldName], record.metadata[fieldName])}
</span>
</TableCell>
))}
{activeConfig.showAttachmentStatusColumn === false ? null : (
<TableCell>
<span
className={clsx(
'inline-flex rounded-full px-2.5 py-1 text-xs font-medium',
record.attachmentCount > 0
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
: 'bg-zinc-100 text-zinc-600 ring-1 ring-zinc-200',
)}
>
{record.attachmentStatus}
</span>
</TableCell>
)}
{activeConfig.showUpdatedAtColumn === false ? null : (
<TableCell>{formatDateTime(record.updatedAt)}</TableCell>
)}
<TableCell className="text-right">
<Button type="button" plain onClick={() => openEditDialog(record)}>
<PencilSquareIcon data-slot="icon" />
编辑
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex flex-col gap-3 border-t border-zinc-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-zinc-500">已勾选 {selectedRecords.length} 条</div>
<div className="flex flex-wrap items-center gap-2">
<Select
aria-label="选择每页数量"
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
className="w-28"
>
{INTEGRITY_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option} 条/页
</option>
))}
</Select>
<Button
type="button"
outline
disabled={page <= 0}
onClick={() => reload(Math.max(page - 1, 0))}
>
上一页
</Button>
<span className="px-2 text-sm text-zinc-500">
{page + 1} / {totalPages}
</span>
<Button
type="button"
outline
disabled={page + 1 >= totalPages}
onClick={() => reload(page + 1)}
>
下一页
</Button>
</div>
</div>
</Panel>
<Dialog open={detailOpen} onClose={setDetailOpen} size="5xl">
<DialogTitle>
{detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : '编辑台账记录'}
</DialogTitle>
<DialogDescription>
业务字段统一保存到 Record 的 metadata 中,附件为系统内上传材料。
</DialogDescription>
<DialogBody>
<LedgerForm config={activeConfig} form={detailForm} setForm={setDetailForm} />
</DialogBody>
<DialogActions>
{detailMode === 'edit' ? (
<Button
type="button"
outline
onClick={() => setDeleteConfirm({ open: true, mode: 'single' })}
>
<TrashIcon data-slot="icon" />
删除
</Button>
) : null}
<Button type="button" plain onClick={() => setDetailOpen(false)}>
取消
</Button>
<Button type="button" color="blue" onClick={saveDetail} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={setImportOpen} size="4xl">
<DialogTitle>Excel 导入{activeConfig.tabLabel}</DialogTitle>
<DialogDescription>
当前导入只处理台账字段,附件请在系统内上传维护。
</DialogDescription>
<DialogBody>
<div className="rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 p-5">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(event) => handlePreviewImport(event.target.files?.[0] || null)}
className="block w-full text-sm text-zinc-600 file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--pm-brand)] file:px-4 file:py-2 file:text-sm file:font-medium file:text-white"
/>
</div>
{importPreview ? (
<div className="mt-5">
<div className="mb-3 text-sm text-zinc-600">
工作表:{importPreview.sheetName},识别到 {importPreview.totalRows} 行
</div>
<div className="overflow-x-auto rounded-2xl border border-zinc-200">
<table className="min-w-full text-left text-sm">
<thead className="bg-zinc-50 text-zinc-500">
<tr>
<th className="px-4 py-3">行号</th>
{activeConfig.fields
.filter((field) => field.type !== 'file')
.slice(0, 6)
.map((field) => (
<th key={field.name} className="px-4 py-3">
{field.label}
</th>
))}
</tr>
</thead>
<tbody>
{importPreview.previewRows.map((row) => (
<tr key={row.rowNumber} className="border-t border-zinc-100">
<td className="px-4 py-3 text-zinc-500">{row.rowNumber}</td>
{activeConfig.fields
.filter((field) => field.type !== 'file')
.slice(0, 6)
.map((field) => (
<td key={field.name} className="px-4 py-3">
{row.metadata[field.name] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</DialogBody>
<DialogActions>
<Button type="button" outline onClick={handleTemplateExport}>
下载模板
</Button>
<Button type="button" plain onClick={() => setImportOpen(false)}>
取消
</Button>
<Button
type="button"
color="blue"
onClick={handleImportWorkbook}
disabled={importing || !importFile}
>
{importing ? '导入中...' : '确认导入'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={batchOpen} onClose={setBatchOpen} size="3xl">
<DialogTitle>批量修改{activeConfig.tabLabel}</DialogTitle>
<DialogDescription>
当前已勾选 {selectedRecords.length} 条记录。仅覆盖你填写的字段,留空字段保持原值。
</DialogDescription>
<DialogBody>
<LedgerForm
config={activeConfig}
form={batchForm}
setForm={setBatchForm}
fields={activeConfig.batchFields.map((fieldName) => fieldMap[fieldName]).filter(Boolean)}
compact
/>
</DialogBody>
<DialogActions>
<Button type="button" plain onClick={() => setBatchOpen(false)}>
取消
</Button>
<Button
type="button"
color="blue"
onClick={handleBatchSave}
disabled={batchSaving}
>
{batchSaving ? '更新中...' : '批量保存'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={deleteConfirm.open}
onClose={(open) => setDeleteConfirm((current) => ({ ...current, open }))}
size="md"
>
<DialogTitle>
{deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'}
</DialogTitle>
<DialogDescription>
{deleteConfirm.mode === 'batch'
? `即将删除 ${selectedRecords.length} 条勾选记录,请确认是否继续。`
: activeRecord
? `即将删除:${activeRecord.title}`
: '未找到当前记录。'}
</DialogDescription>
<DialogActions>
<Button
type="button"
plain
onClick={() => setDeleteConfirm({ open: false, mode: 'single' })}
>
取消
</Button>
<Button type="button" color="red" onClick={confirmDelete} disabled={saving}>
确认删除
</Button>
</DialogActions>
</Dialog>
</div>
);
}
import {
countRecords,
createRecord,
queryRecords,
removeRecord,
updateRecord,
uploadFile,
} from '@/services/DataService';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
export const INTEGRITY_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_INTEGRITY_PAGE_SIZE = 20;
export const TRAINING_CATEGORY_OPTIONS = [
'廉政党课讲授',
'廉洁合规培训',
'专题廉洁合规教育培训',
'新员工廉洁教育培训',
];
export const HOLIDAY_OPTIONS = [
'元旦',
'春节',
'清明',
'五一',
'端午',
'中秋',
'国庆',
'其他',
];
const FIELD_TYPES = {
TEXT: 'text',
LONG_TEXT: 'long_text',
NUMBER: 'number',
DATE: 'date',
SELECT: 'select',
BOOLEAN: 'boolean',
FILE: 'file',
};
export const INTEGRITY_LEDGER_CONFIGS = [
{
key: 'training',
subject: 'integrity_party_training',
label: '党风廉政培训记录',
tabLabel: '培训类',
filePrefix: '党风廉政培训记录',
yearField: 'training_date',
titleFallback: '未命名培训记录',
buildTitle: (metadata) =>
compactTitle([
metadata.training_category,
metadata.lecture_topic,
formatDate(metadata.training_date),
]) || '未命名培训记录',
focusFilter: {
name: 'training_category',
label: '培训类别',
type: FIELD_TYPES.SELECT,
options: TRAINING_CATEGORY_OPTIONS,
},
tableColumns: [
'sequence_no',
'training_category',
'training_date',
'lecture_topic',
'lecturer',
'participant_count',
'training_target',
'lead_department',
],
showAttachmentStatusColumn: false,
showUpdatedAtColumn: false,
batchFields: ['training_category', 'training_date', 'lead_department'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('培训类别', 'training_category', FIELD_TYPES.SELECT, {
required: true,
options: TRAINING_CATEGORY_OPTIONS,
}),
field('时间', 'training_date', FIELD_TYPES.DATE, { required: true }),
field('培训对象', 'training_target', FIELD_TYPES.TEXT),
field('培训形式', 'training_format', FIELD_TYPES.TEXT),
field('牵头部门', 'lead_department', FIELD_TYPES.TEXT),
field('授课人', 'lecturer', FIELD_TYPES.TEXT),
field('授课主题', 'lecture_topic', FIELD_TYPES.TEXT, { required: true }),
field('主要内容', 'main_content', FIELD_TYPES.LONG_TEXT),
field('参加人数', 'participant_count', FIELD_TYPES.NUMBER),
field('课件材料', 'courseware_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('签到表', 'sign_in_sheets', FIELD_TYPES.FILE, { multiple: true }),
],
},
{
key: 'talk',
subject: 'integrity_party_talk',
label: '党风廉政谈话记录',
tabLabel: '谈话类',
filePrefix: '党风廉政谈话记录',
yearField: 'year',
titleFallback: '未命名谈话记录',
buildTitle: (metadata) =>
compactTitle([
metadata.person_name,
metadata.year ? `${metadata.year}年` : '',
formatDate(metadata.planned_talk_date),
]) || '未命名谈话记录',
focusFilter: {
name: 'person_or_department',
label: '姓名 / 部门',
matchFields: ['person_name', 'primary_department'],
},
tableColumns: [
'sequence_no',
'year',
'person_name',
'primary_department',
'position_name',
'is_party_member',
'is_leader',
'is_investment_sequence',
'planned_talk_date',
'actual_talk_date',
],
showAttachmentStatusColumn: false,
showUpdatedAtColumn: false,
filterFields: [
field('是否党员', 'is_party_member', FIELD_TYPES.BOOLEAN, {
emptyLabel: '全部',
}),
field('是否领导干部', 'is_leader', FIELD_TYPES.BOOLEAN, {
emptyLabel: '全部',
}),
field('是否投资序列', 'is_investment_sequence', FIELD_TYPES.BOOLEAN, {
emptyLabel: '全部',
}),
],
batchFields: ['year', 'primary_department', 'position_name'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('年份', 'year', FIELD_TYPES.NUMBER, { required: true }),
field('姓名', 'person_name', FIELD_TYPES.TEXT, { required: true }),
field('所属一级部门', 'primary_department', FIELD_TYPES.TEXT),
field('岗位名称', 'position_name', FIELD_TYPES.TEXT),
field('岗位晋升时间', 'promotion_date', FIELD_TYPES.DATE),
field('是否党员', 'is_party_member', FIELD_TYPES.BOOLEAN),
field('是否领导干部', 'is_leader', FIELD_TYPES.BOOLEAN),
field('是否投资序列', 'is_investment_sequence', FIELD_TYPES.BOOLEAN),
field('最近一期谈话时间', 'latest_talk_date', FIELD_TYPES.DATE),
field('本年度计划谈话时间', 'planned_talk_date', FIELD_TYPES.DATE),
field('是否应做任前谈话', 'should_pre_appointment_talk', FIELD_TYPES.BOOLEAN),
field('是否已做任前谈话', 'has_pre_appointment_talk', FIELD_TYPES.BOOLEAN),
field(
'是否纪委书记主动约谈',
'is_discipline_secretary_interview',
FIELD_TYPES.BOOLEAN,
),
field('本年度实际谈话时间', 'actual_talk_date', FIELD_TYPES.DATE),
field('谈话支持材料', 'talk_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
],
},
{
key: 'research',
subject: 'integrity_party_research',
label: '党风廉政调研记录',
tabLabel: '调研类',
filePrefix: '党风廉政调研记录',
yearField: 'research_date',
titleFallback: '未命名调研记录',
buildTitle: (metadata) =>
compactTitle([metadata.research_unit, formatDate(metadata.research_date)]) ||
'未命名调研记录',
focusFilter: {
name: 'research_unit',
label: '调研单位',
matchFields: ['research_unit'],
},
tableColumns: [
'sequence_no',
'research_date',
'research_unit',
'research_staff_and_position',
'research_content',
'survey_suggestions',
],
showAttachmentStatusColumn: false,
showUpdatedAtColumn: false,
batchFields: ['research_date', 'research_unit', 'remarks'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('调研时间', 'research_date', FIELD_TYPES.DATE, { required: true }),
field('调研人员及职务', 'research_staff_and_position', FIELD_TYPES.TEXT),
field('调研单位', 'research_unit', FIELD_TYPES.TEXT, { required: true }),
field('调研内容', 'research_content', FIELD_TYPES.LONG_TEXT),
field('调研收集的建议意见', 'survey_suggestions', FIELD_TYPES.LONG_TEXT),
field('调研方案或调研报告', 'research_report_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('备注', 'remarks', FIELD_TYPES.LONG_TEXT),
],
},
{
key: 'meeting',
subject: 'integrity_party_meeting',
label: '党风廉政专题会议记录',
tabLabel: '专题会议类',
filePrefix: '党风廉政专题会议记录',
yearField: 'meeting_date',
titleFallback: '未命名会议记录',
buildTitle: (metadata) =>
compactTitle([metadata.meeting_topic, formatDate(metadata.meeting_date)]) ||
'未命名会议记录',
focusFilter: {
name: 'meeting_topic',
label: '会议议题',
matchFields: ['meeting_topic'],
},
tableColumns: ['sequence_no', 'meeting_date', 'meeting_topic', 'main_content'],
batchFields: ['meeting_date', 'meeting_topic'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('会议时间', 'meeting_date', FIELD_TYPES.DATE, { required: true }),
field('会议议题', 'meeting_topic', FIELD_TYPES.TEXT, { required: true }),
field('会议主要内容', 'main_content', FIELD_TYPES.LONG_TEXT),
field('支持材料', 'supporting_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
],
},
{
key: 'activity',
subject: 'integrity_party_activity',
label: '廉洁主题活动记录',
tabLabel: '廉洁主题活动',
filePrefix: '廉洁主题活动记录',
yearField: 'year',
titleFallback: '未命名活动记录',
buildTitle: (metadata) =>
compactTitle([
metadata.activity_theme,
metadata.year ? `${metadata.year}年` : '',
]) || '未命名活动记录',
focusFilter: {
name: 'activity_theme',
label: '活动主题',
matchFields: ['activity_theme'],
},
tableColumns: ['sequence_no', 'year', 'activity_theme', 'activity_format'],
batchFields: ['year', 'activity_format'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('开展年份', 'year', FIELD_TYPES.NUMBER, { required: true }),
field('活动主题', 'activity_theme', FIELD_TYPES.TEXT, { required: true }),
field('活动形式', 'activity_format', FIELD_TYPES.TEXT),
field('活动成果', 'activity_result', FIELD_TYPES.LONG_TEXT),
field('支持材料', 'supporting_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
],
},
{
key: 'holiday_reminder',
subject: 'integrity_party_holiday_reminder',
label: '节前廉洁提醒记录',
tabLabel: '节前廉洁提醒',
filePrefix: '节前廉洁提醒记录',
yearField: 'year',
titleFallback: '未命名节前提醒',
buildTitle: (metadata) =>
compactTitle([
metadata.year ? `${metadata.year}年` : '',
metadata.holiday_name === '其他'
? metadata.holiday_other || metadata.holiday_name
: metadata.holiday_name,
]) || '未命名节前提醒',
focusFilter: {
name: 'holiday_name',
label: '重大节假日',
type: FIELD_TYPES.SELECT,
options: HOLIDAY_OPTIONS,
},
tableColumns: ['year', 'holiday_name', 'holiday_other', 'other_measures'],
batchFields: ['year', 'holiday_name', 'holiday_other'],
fields: [
field('年份', 'year', FIELD_TYPES.NUMBER, { required: true }),
field('重大节假日', 'holiday_name', FIELD_TYPES.SELECT, {
required: true,
options: HOLIDAY_OPTIONS,
}),
field('其他节假日说明', 'holiday_other', FIELD_TYPES.TEXT),
field('廉洁谈话提纲上传', 'talk_outline_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('廉洁提醒支持材料上传', 'reminder_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('其他活动举措', 'other_measures', FIELD_TYPES.LONG_TEXT),
],
},
];
export const INTEGRITY_LEDGER_CONFIG_MAP = INTEGRITY_LEDGER_CONFIGS.reduce(
(accumulator, config) => {
accumulator[config.key] = config;
return accumulator;
},
{},
);
function field(label, name, type, options = {}) {
return {
label,
name,
type,
required: Boolean(options.required),
multiple: Boolean(options.multiple),
options: options.options || [],
emptyLabel: options.emptyLabel || '',
aliases: [label, name, ...(options.aliases || [])],
};
}
function compactTitle(parts = []) {
return parts
.map((item) => String(item || '').trim())
.filter(Boolean)
.join(' · ');
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractArray(response) {
const candidates = [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
return candidate;
}
}
return [];
}
function extractCount(response) {
const candidates = [
response,
response?.data,
response?.result,
response?.total_records,
response?.data?.total_records,
response?.result?.total_records,
response?.count,
response?.data?.count,
response?.data?.total,
response?.total,
response?.result?.count,
response?.result?.total,
];
for (const candidate of candidates) {
const numericCandidate = Number(candidate);
if (Number.isFinite(numericCandidate)) {
return numericCandidate;
}
}
return 0;
}
function extractTotalRecords(response) {
const candidates = [
response?.total_records,
response?.data?.total_records,
response?.result?.total_records,
response?.total,
response?.data?.total,
response?.result?.total,
response?.count,
response?.data?.count,
response?.result?.count,
];
for (const candidate of candidates) {
const numericCandidate = Number(candidate);
if (Number.isFinite(numericCandidate)) {
return numericCandidate;
}
}
return null;
}
function normalizeHeaderKey(value = '') {
return String(value)
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '');
}
function getConfig(configKey) {
const config = INTEGRITY_LEDGER_CONFIG_MAP[configKey];
if (!config) {
throw new Error('未找到对应的党风廉政台账配置');
}
return config;
}
function getFieldMap(config) {
return config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = fieldConfig;
return accumulator;
}, {});
}
function normalizeBoolean(value) {
if (value === true || value === false || value === null) {
return value;
}
const text = String(value ?? '').trim();
if (!text) {
return null;
}
if (['true', '1', '是', 'Y', 'y', 'yes', 'YES'].includes(text)) {
return true;
}
if (['false', '0', '否', 'N', 'n', 'no', 'NO'].includes(text)) {
return false;
}
return null;
}
function normalizeNumber(value) {
const text = String(value ?? '').trim();
if (!text) {
return null;
}
const numericValue = Number(text);
return Number.isFinite(numericValue) ? numericValue : null;
}
export function formatDate(value) {
if (!value) {
return '';
}
const parsed = dayjs(value);
if (parsed.isValid()) {
return parsed.format('YYYY-MM-DD');
}
const text = String(value).trim();
const matched = text.match(/^(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})/);
if (!matched) {
return text;
}
return [
matched[1],
String(matched[2]).padStart(2, '0'),
String(matched[3]).padStart(2, '0'),
].join('-');
}
function normalizeDateValue(value) {
const dateText = formatDate(value);
if (!dateText) {
return null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
return `${dateText}T00:00:00.000Z`;
}
return dateText;
}
function normalizeFileList(value) {
const list = Array.isArray(value) ? value : value ? [value] : [];
return list
.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return {
name: item,
type: '',
size: 0,
url: item,
};
}
return {
name: item.name || item.filename || item.title || item.url || '未命名附件',
type: item.type || item.mime_type || '',
size: Number(item.size || 0),
url: item.url || item.path || item.href || item.src || '',
};
})
.filter((item) => item?.name || item?.url);
}
function normalizeFieldValue(fieldConfig, value, { forForm = false } = {}) {
if (fieldConfig.type === FIELD_TYPES.NUMBER) {
return forForm
? value === null || value === undefined
? ''
: String(value)
: normalizeNumber(value);
}
if (fieldConfig.type === FIELD_TYPES.DATE) {
return forForm ? formatDate(value) : normalizeDateValue(value);
}
if (fieldConfig.type === FIELD_TYPES.BOOLEAN) {
if (forForm) {
const booleanValue = normalizeBoolean(value);
if (booleanValue === true) {
return 'true';
}
if (booleanValue === false) {
return 'false';
}
return '';
}
return normalizeBoolean(value);
}
if (fieldConfig.type === FIELD_TYPES.FILE) {
return normalizeFileList(value);
}
return String(value ?? '').trim();
}
function isEmptyMetadataRow(metadata = {}, config) {
return config.fields
.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE)
.every((fieldConfig) => {
const value = metadata[fieldConfig.name];
return value === '' || value === null || value === undefined;
});
}
export function createEmptyLedgerForm(configKey) {
const config = getConfig(configKey);
return config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] =
fieldConfig.type === FIELD_TYPES.FILE ? [] : '';
return accumulator;
}, {});
}
export function normalizeLedgerRecord(record = {}, configKey) {
const config = getConfig(configKey);
const fieldMap = getFieldMap(config);
const metadata = record?.metadata || {};
const normalizedMetadata = config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
metadata[fieldConfig.name],
{ forForm: true },
);
return accumulator;
}, {});
const attachmentCount = config.fields
.filter((fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE)
.reduce((sum, fieldConfig) => {
return sum + normalizeFileList(metadata[fieldConfig.name]).length;
}, 0);
const requiredAttachmentFields = config.fields.filter(
(fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE,
);
const filledAttachmentFields = requiredAttachmentFields.filter(
(fieldConfig) => normalizeFileList(metadata[fieldConfig.name]).length > 0,
);
const title = record.title || config.buildTitle(normalizedMetadata);
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title: title || config.titleFallback,
subject: config.subject,
ledgerKey: config.key,
ledgerLabel: config.label,
metadata: normalizedMetadata,
rawMetadata: metadata,
rawRecord: record,
attachmentCount,
attachmentStatus:
requiredAttachmentFields.length === 0
? '无附件字段'
: `${filledAttachmentFields.length}/${requiredAttachmentFields.length}`,
createdAt: record.created_at || '',
updatedAt: record.updated_at || record.created_at || '',
updatedAtLabel: formatDateTime(record.updated_at || record.created_at),
fieldMap,
};
}
export function formatDateTime(value) {
if (!value) {
return '-';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : String(value);
}
function buildLedgerMetadata(config, values = {}) {
return config.fields.reduce((metadata, fieldConfig) => {
metadata[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
values[fieldConfig.name],
);
return metadata;
}, {});
}
function validateLedgerMetadata(config, metadata = {}) {
const missingFields = config.fields.filter((fieldConfig) => {
if (!fieldConfig.required) {
return false;
}
const value = metadata[fieldConfig.name];
return value === '' || value === null || value === undefined;
});
if (missingFields.length > 0) {
throw new Error(`请填写必填字段:${missingFields.map((item) => item.label).join('、')}`);
}
}
export function buildLedgerPayload(configKey, values = {}) {
const config = getConfig(configKey);
const metadata = buildLedgerMetadata(config, values);
validateLedgerMetadata(config, metadata);
const displayMetadata = config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
metadata[fieldConfig.name],
{ forForm: true },
);
return accumulator;
}, {});
const title = config.buildTitle(displayMetadata);
return {
subject: config.subject,
title,
content_type: 'text',
content: title,
metadata,
};
}
export async function createLedgerRecord(configKey, values = {}) {
const payload = buildLedgerPayload(configKey, values);
const created = await createRecord(payload);
return normalizeLedgerRecord(created?.data || created?.result || payload, configKey);
}
export async function updateLedgerRecord(configKey, ledgerRecord, values = {}) {
const config = getConfig(configKey);
const recordName = ledgerRecord?.recordName || ledgerRecord?.id;
if (!recordName) {
throw new Error('未找到可更新的台账记录标识');
}
const payload = buildLedgerPayload(configKey, values);
const nextMetadata = {
...ledgerRecord.rawMetadata,
...payload.metadata,
};
const updated = await updateRecord(recordName, {
title: payload.title,
content_type: 'text',
content: payload.content,
metadata: nextMetadata,
});
return normalizeLedgerRecord(
updated?.data ||
updated?.result || {
...ledgerRecord.rawRecord,
title: payload.title,
content: payload.content,
metadata: nextMetadata,
},
config.key,
);
}
export async function deleteLedgerRecord(ledgerRecord) {
const recordName = ledgerRecord?.recordName || ledgerRecord?.id;
if (!recordName) {
throw new Error('未找到可删除的台账记录标识');
}
await removeRecord(recordName);
}
function buildKeywordFilter(config, keyword = '') {
const normalizedKeyword = String(keyword || '').trim();
if (!normalizedKeyword) {
return null;
}
const regex = {
$regex: escapeRegExp(normalizedKeyword),
$options: 'i',
};
const textFields = config.fields.filter(
(fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE,
);
return {
$or: [{ title: regex }, ...textFields.map((fieldConfig) => ({
[`metadata.${fieldConfig.name}`]: regex,
}))],
};
}
function buildYearFilter(config, year = '') {
const normalizedYear = String(year || '').trim();
if (!normalizedYear) {
return null;
}
const yearField = config.fields.find(
(fieldConfig) => fieldConfig.name === config.yearField,
);
if (!yearField) {
return null;
}
if (yearField.type === FIELD_TYPES.NUMBER) {
const numericYear = Number(normalizedYear);
return Number.isFinite(numericYear)
? { [`metadata.${config.yearField}`]: numericYear }
: null;
}
return {
[`metadata.${config.yearField}`]: {
$regex: `^${escapeRegExp(normalizedYear)}`,
},
};
}
function buildFocusFilter(config, value = '') {
const normalizedValue = String(value || '').trim();
if (!normalizedValue || !config.focusFilter) {
return null;
}
if (config.focusFilter.type === FIELD_TYPES.SELECT) {
return {
[`metadata.${config.focusFilter.name}`]: normalizedValue,
};
}
const regex = {
$regex: escapeRegExp(normalizedValue),
$options: 'i',
};
return {
$or: (config.focusFilter.matchFields || [config.focusFilter.name]).map(
(fieldName) => ({
[`metadata.${fieldName}`]: regex,
}),
),
};
}
function buildConfiguredFilters(config, filters = {}) {
return (config.filterFields || [])
.map((fieldConfig) => {
const value = filters[fieldConfig.name];
if (value === '' || value === null || value === undefined) {
return null;
}
if (fieldConfig.type === FIELD_TYPES.BOOLEAN) {
const booleanValue = normalizeBoolean(value);
return booleanValue === null
? null
: { [`metadata.${fieldConfig.name}`]: booleanValue };
}
const normalizedValue = String(value).trim();
return normalizedValue ? { [`metadata.${fieldConfig.name}`]: normalizedValue } : null;
})
.filter(Boolean);
}
function buildLedgerFilter(config, filters = {}) {
const clauses = [
buildKeywordFilter(config, filters.keyword),
buildYearFilter(config, filters.year),
buildFocusFilter(config, filters.focusValue),
...buildConfiguredFilters(config, filters),
].filter(Boolean);
if (clauses.length === 0) {
return {};
}
if (clauses.length === 1) {
return clauses[0];
}
return {
$and: clauses,
};
}
export async function loadLedgerPage(configKey, filters = {}) {
const config = getConfig(configKey);
const page = Number(filters.page || 0);
const pageSize = Number(filters.pageSize || DEFAULT_INTEGRITY_PAGE_SIZE);
const filter = buildLedgerFilter(config, filters);
try {
const response = await queryRecords({
subject: config.subject,
page,
page_size: pageSize,
filter,
sort: '-updated_at -created_at',
});
const records = extractArray(response).map((record) =>
normalizeLedgerRecord(record, config.key),
);
const total =
extractTotalRecords(response) ??
extractCount(
await countRecords({
subject: config.subject,
filter,
}),
);
return {
records,
total,
page,
pageSize,
hasMore: (page + 1) * pageSize < total,
error: '',
};
} catch (error) {
return {
records: [],
total: 0,
page,
pageSize,
hasMore: false,
error: error?.message || '台账数据加载失败,请稍后重试。',
};
}
}
async function loadAllLedgerRecords(configKey, filters = {}) {
const pageSize = 200;
const mergedRecords = [];
let page = 0;
let hasMore = true;
while (hasMore) {
const result = await loadLedgerPage(configKey, {
...filters,
page,
pageSize,
});
if (result.error) {
throw new Error(result.error);
}
mergedRecords.push(...(result.records || []));
hasMore = Boolean(result.hasMore);
page += 1;
}
return mergedRecords;
}
async function runMutationInBatches(entries = [], worker, batchSize = 5) {
const successes = [];
const failures = [];
for (let index = 0; index < entries.length; index += batchSize) {
const batch = entries.slice(index, index + batchSize);
const results = await Promise.allSettled(batch.map((entry) => worker(entry)));
results.forEach((result, batchIndex) => {
const entry = batch[batchIndex];
if (result.status === 'fulfilled') {
successes.push(result.value ?? entry);
return;
}
failures.push({
id: entry?.id || entry?.recordName || '',
label: entry?.title || entry?.label || '未命名记录',
rowNumber: entry?.rowNumber,
message: result.reason?.message || '操作失败',
});
});
}
return {
successes,
failures,
};
}
export async function deleteLedgerRecords(records = []) {
const { successes, failures } = await runMutationInBatches(
records.filter(Boolean),
(record) => deleteLedgerRecord(record),
);
return {
deletedCount: successes.length,
failedCount: failures.length,
failures,
};
}
export async function updateLedgerRecords(configKey, records = [], values = {}) {
const config = getConfig(configKey);
const fieldMap = getFieldMap(config);
const metadataPatch = {};
config.batchFields.forEach((fieldName) => {
const fieldConfig = fieldMap[fieldName];
const rawValue = values[fieldName];
const hasPatchValue =
fieldConfig?.type === FIELD_TYPES.BOOLEAN
? rawValue === 'true' || rawValue === 'false'
: String(rawValue ?? '').trim() !== '';
if (fieldConfig && hasPatchValue) {
metadataPatch[fieldName] = normalizeFieldValue(fieldConfig, rawValue);
}
});
if (Object.keys(metadataPatch).length === 0) {
throw new Error('请至少填写一个需要批量覆盖的字段');
}
const { successes, failures } = await runMutationInBatches(
records.filter(Boolean),
async (record) => {
const recordName = record?.recordName || record?.id;
if (!recordName) {
throw new Error('未找到可更新的台账记录标识');
}
const nextMetadata = {
...record.rawMetadata,
...metadataPatch,
};
const formMetadata = config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
nextMetadata[fieldConfig.name],
{ forForm: true },
);
return accumulator;
}, {});
const title = config.buildTitle(formMetadata);
const updated = await updateRecord(recordName, {
title,
content_type: 'text',
content: title,
metadata: nextMetadata,
});
return normalizeLedgerRecord(
updated?.data ||
updated?.result || {
...record.rawRecord,
title,
content: title,
metadata: nextMetadata,
},
config.key,
);
},
);
return {
updatedRecords: successes,
updatedCount: successes.length,
failedCount: failures.length,
failures,
};
}
function getImportFieldValue(row = {}, aliases = []) {
const normalizedAliasMap = new Map(
Object.entries(row).map(([key, value]) => [normalizeHeaderKey(key), value]),
);
for (const alias of aliases) {
const matchedValue = normalizedAliasMap.get(normalizeHeaderKey(alias));
if (matchedValue !== undefined && matchedValue !== null) {
return String(matchedValue).trim();
}
}
return '';
}
function normalizeWorkbookRow(config, row = {}, rowIndex = 0) {
const metadata = {};
config.fields
.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE)
.forEach((fieldConfig) => {
metadata[fieldConfig.name] = getImportFieldValue(row, fieldConfig.aliases);
});
return {
rowNumber: rowIndex + 2,
metadata,
};
}
export async function previewLedgerWorkbook(configKey, file) {
const config = getConfig(configKey);
if (!file) {
throw new Error('请先选择 Excel 文件');
}
const workbook = XLSX.read(await file.arrayBuffer(), {
type: 'array',
});
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error('Excel 中没有可读取的工作表');
}
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils
.sheet_to_json(sheet, {
defval: '',
raw: false,
})
.map((row, index) => normalizeWorkbookRow(config, row, index))
.filter((row) => !isEmptyMetadataRow(row.metadata, config));
return {
sheetName,
rows,
totalRows: rows.length,
previewRows: rows.slice(0, 5),
};
}
export async function importLedgerWorkbook(configKey, file) {
const config = getConfig(configKey);
const preview = await previewLedgerWorkbook(configKey, file);
if (preview.totalRows === 0) {
throw new Error('未识别到可导入的数据行,请检查当前 Tab 对应的模板表头');
}
const entries = preview.rows.map((row) => ({
rowNumber: row.rowNumber,
payload: buildLedgerPayload(config.key, row.metadata),
}));
const { successes, failures } = await runMutationInBatches(entries, async (entry) => {
const created = await createRecord(entry.payload);
return normalizeLedgerRecord(
created?.data || created?.result || entry.payload,
config.key,
);
});
return {
sheetName: preview.sheetName,
totalRows: preview.totalRows,
createdCount: successes.length,
failedCount: failures.length,
failures,
createdRecords: successes,
};
}
function formatBoolean(value) {
const booleanValue = normalizeBoolean(value);
if (booleanValue === true) {
return '是';
}
if (booleanValue === false) {
return '否';
}
return '';
}
function formatFileList(value) {
return normalizeFileList(value)
.map((item) => [item.name, item.url].filter(Boolean).join(' '))
.join('\n');
}
function formatExportValue(fieldConfig, value) {
if (fieldConfig.type === FIELD_TYPES.DATE) {
return formatDate(value);
}
if (fieldConfig.type === FIELD_TYPES.BOOLEAN) {
return formatBoolean(value);
}
if (fieldConfig.type === FIELD_TYPES.FILE) {
return formatFileList(value);
}
return value ?? '';
}
function buildExportRow(config, record = {}) {
const source = record.rawMetadata || record.metadata || {};
return config.fields.reduce((row, fieldConfig) => {
row[fieldConfig.label] = formatExportValue(fieldConfig, source[fieldConfig.name]);
return row;
}, {});
}
function createWorkbookFromRows(config, rows = [], sheetName) {
const headers = config.fields.map((fieldConfig) => fieldConfig.label);
const worksheet = XLSX.utils.json_to_sheet(rows, { header: headers });
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
return workbook;
}
export async function exportLedgerWorkbook(configKey, filters = {}) {
const records = await loadAllLedgerRecords(configKey, filters);
return exportSelectedLedgerWorkbook(configKey, records);
}
export async function exportSelectedLedgerWorkbook(configKey, records = []) {
const config = getConfig(configKey);
const rows = records.map((record) => buildExportRow(config, record));
const workbook = createWorkbookFromRows(config, rows, config.tabLabel);
const fileName = `${config.filePrefix}_导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName);
return {
fileName,
count: rows.length,
};
}
export async function exportLedgerTemplateWorkbook(configKey) {
const config = getConfig(configKey);
const importFields = {
...config,
fields: config.fields.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE),
};
const workbook = createWorkbookFromRows(importFields, [], `${config.tabLabel}导入模板`);
const fileName = `${config.filePrefix}_导入模板_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName);
return {
fileName,
};
}
function findUploadUrl(response) {
const candidates = [
response?.url,
response?.data?.url,
response?.result?.url,
response?.path,
response?.data?.path,
response?.result?.path,
response?.file?.url,
response?.data?.file?.url,
];
return candidates.find(Boolean) || '';
}
export async function uploadLedgerAttachment(file) {
const response = await uploadFile(file);
const url = findUploadUrl(response);
return {
name: file.name,
type: file.type,
size: file.size,
url,
};
}
function buildGroup(list, keyGetter, labelGetter) {
return Object.values(
list.reduce((accumulator, item) => {
const key = keyGetter(item);
if (!key) {
return accumulator;
}
if (!accumulator[key]) {
accumulator[key] = {
key,
label: labelGetter(item),
count: 0,
};
}
accumulator[key].count += 1;
return accumulator;
}, {}),
).sort((left, right) => right.count - left.count);
}
export async function loadIntegrityPartyDashboard() {
const ledgerResults = await Promise.all(
INTEGRITY_LEDGER_CONFIGS.map(async (config) => {
const result = await loadLedgerPage(config.key, {
page: 0,
pageSize: 500,
});
return {
config,
records: result.records || [],
total: result.total || 0,
error: result.error,
};
}),
);
const records = ledgerResults.flatMap((result) => result.records);
const now = dayjs();
const currentYear = now.year();
const recentThreshold = now.subtract(30, 'day');
const currentYearRecords = records.filter(
(record) => getRecordYear(record) === String(currentYear),
);
const ledgerGroups = ledgerResults.map((result) => ({
key: result.config.key,
label: result.config.tabLabel,
count: result.total,
}));
const yearGroups = buildGroup(
records.map((record) => ({
year: getRecordYear(record),
})),
(item) => item.year || '未填写年份',
(item) => item.year || '未填写年份',
);
return {
totalCount: ledgerResults.reduce((sum, result) => sum + result.total, 0),
currentYearCount: currentYearRecords.length,
recentCount: records.filter((record) => {
if (!record.updatedAt) {
return false;
}
return dayjs(record.updatedAt).isAfter(recentThreshold);
}).length,
ledgerGroups,
yearGroups,
moduleOverview: buildIntegrityModuleOverview(currentYearRecords, currentYear),
errors: ledgerResults.map((result) => result.error).filter(Boolean),
};
}
function buildIntegrityModuleOverview(currentYearRecords, currentYear) {
const trainingRecords = currentYearRecords.filter(
(record) => record.ledgerKey === 'training',
);
const talkedRecords = currentYearRecords.filter((record) => {
if (record.ledgerKey !== 'talk') {
return false;
}
const actualTalkDate = formatDate(
record.rawMetadata?.actual_talk_date ?? record.metadata?.actual_talk_date,
);
return actualTalkDate.startsWith(String(currentYear));
});
return {
year: currentYear,
trainingCategories: TRAINING_CATEGORY_OPTIONS.map((category) => {
const categoryRecords = trainingRecords.filter(
(record) =>
String(
record.rawMetadata?.training_category ??
record.metadata?.training_category ??
'',
).trim() === category,
);
return {
key: category,
label: category,
sessionCount: categoryRecords.length,
participantCount: categoryRecords.reduce((sum, record) => {
const participantCount = Number(
record.rawMetadata?.participant_count ??
record.metadata?.participant_count ??
0,
);
return sum + (Number.isFinite(participantCount) ? participantCount : 0);
}, 0),
};
}),
talkSummary: {
totalCount: talkedRecords.length,
investmentCount: talkedRecords.filter(
(record) =>
normalizeBoolean(
record.rawMetadata?.is_investment_sequence ??
record.metadata?.is_investment_sequence,
) === true,
).length,
managementCount: talkedRecords.filter(
(record) =>
normalizeBoolean(record.rawMetadata?.is_leader ?? record.metadata?.is_leader) ===
true,
).length,
},
yearlyModuleCounts: [
buildYearlyModuleCount(currentYearRecords, 'research', '调研类'),
buildYearlyModuleCount(currentYearRecords, 'meeting', '专题会议类'),
buildYearlyModuleCount(currentYearRecords, 'activity', '廉洁主题活动'),
buildYearlyModuleCount(currentYearRecords, 'holiday_reminder', '节前廉洁提醒'),
],
};
}
function buildYearlyModuleCount(records, ledgerKey, label) {
return {
key: ledgerKey,
label,
count: records.filter((record) => record.ledgerKey === ledgerKey).length,
};
}
function getRecordYear(record) {
const config = getConfig(record.ledgerKey);
const yearField = config.fields.find(
(fieldConfig) => fieldConfig.name === config.yearField,
);
const value = record.rawMetadata?.[config.yearField] ?? record.metadata?.[config.yearField];
if (yearField?.type === FIELD_TYPES.DATE) {
return formatDate(value).slice(0, 4);
}
return value === null || value === undefined || value === ''
? ''
: String(value).trim();
}
# 说明
当前应用已从 `project_manager_xy` 模板进一步演进为“党风廉政建设”,原项目管理流程文档不再作为本应用的主业务文档。
请改为阅读:
- `src/pages/project_manager_xy/integrity-party.business-flow.md`
保留本文件仅用于说明模板来源,避免后续开发继续误引用旧的“项目管理”或“材料中心”业务语义。
import {
ArrowTrendingUpIcon,
CalendarDaysIcon,
ClipboardDocumentListIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import ReactECharts from 'echarts-for-react';
import { useEffect, useState } from 'react';
import { loadIntegrityPartyDashboard } from '../integrityPartyService';
function Panel({ className, children }) {
return (
<section
className={clsx(
'rounded-[28px] border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur',
className,
)}
>
{children}
</section>
);
}
function MetricCard({ icon: Icon, label, value, tone = 'blue' }) {
const toneClass =
tone === 'green'
? 'bg-emerald-100 text-emerald-700'
: tone === 'amber'
? 'bg-amber-100 text-amber-700'
: tone === 'rose'
? 'bg-rose-100 text-rose-700'
: 'bg-sky-100 text-sky-700';
return (
<div className="rounded-[24px] border border-zinc-200/80 bg-white/90 p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-medium text-zinc-500">{label}</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-zinc-950">
{value}
</div>
</div>
<div
className={clsx(
'flex h-11 w-11 items-center justify-center rounded-2xl',
toneClass,
)}
>
<Icon className="size-5" />
</div>
</div>
</div>
);
}
function SectionHeader({ title }) {
return (
<div className="mb-5">
<div className="text-lg font-semibold tracking-tight text-zinc-950">
{title}
</div>
</div>
);
}
const CHART_COLORS = [
'#0ea5e9',
'#10b981',
'#f59e0b',
'#6366f1',
'#ef4444',
'#14b8a6',
];
function getPieOption(groups = []) {
return {
color: CHART_COLORS,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>{c} 条 · {d}%',
},
legend: {
bottom: 0,
icon: 'circle',
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: '#52525b',
fontSize: 12,
},
},
series: [
{
name: '分类',
type: 'pie',
radius: ['46%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
label: {
color: '#3f3f46',
formatter: '{b}\n{c} 条',
lineHeight: 18,
},
labelLine: {
length: 10,
length2: 8,
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
},
data: groups.map((item) => ({
name: item.label,
value: item.count,
})),
},
],
};
}
function getBarOption(groups = [], { horizontal = false } = {}) {
const visibleGroups = groups.slice(0, 8);
const labels = visibleGroups.map((item) => item.label);
const values = visibleGroups.map((item) => item.count);
return {
color: CHART_COLORS,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: (params = []) => {
const item = params[0];
return item ? `${item.name}<br/>${item.value} 条` : '';
},
},
grid: {
top: 18,
right: 14,
bottom: horizontal ? 18 : 48,
left: horizontal ? 84 : 36,
containLabel: true,
},
xAxis: horizontal
? {
type: 'value',
splitLine: {
lineStyle: {
color: '#e4e4e7',
type: 'dashed',
},
},
axisLabel: {
color: '#71717a',
},
}
: {
type: 'category',
data: labels,
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: '#e4e4e7',
},
},
axisLabel: {
color: '#71717a',
interval: 0,
rotate: labels.some((label) => label.length > 5) ? 28 : 0,
},
},
yAxis: horizontal
? {
type: 'category',
data: labels,
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: '#52525b',
width: 74,
overflow: 'truncate',
},
}
: {
type: 'value',
splitLine: {
lineStyle: {
color: '#e4e4e7',
type: 'dashed',
},
},
axisLabel: {
color: '#71717a',
},
},
series: [
{
type: 'bar',
data: values,
barMaxWidth: horizontal ? 18 : 34,
itemStyle: {
borderRadius: horizontal ? [0, 8, 8, 0] : [8, 8, 0, 0],
color: {
type: 'linear',
x: 0,
y: 0,
x2: horizontal ? 1 : 0,
y2: horizontal ? 0 : 1,
colorStops: [
{ offset: 0, color: '#0ea5e9' },
{ offset: 1, color: '#10b981' },
],
},
},
},
],
};
}
function ChartPanel({ title, groups, type = 'bar' }) {
const hasData = groups.length > 0;
const option =
type === 'pie'
? getPieOption(groups)
: getBarOption(groups, { horizontal: type === 'horizontalBar' });
return (
<Panel className="p-6">
<SectionHeader title={title} />
{hasData ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: type === 'pie' ? 320 : 300, width: '100%' }}
/>
) : (
<div className="flex h-[300px] items-center justify-center rounded-[22px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400">
暂无可展示的数据
</div>
)}
</Panel>
);
}
export default function ResourceStatPage() {
const [integrityDashboard, setIntegrityDashboard] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
try {
const integrityResult = await loadIntegrityPartyDashboard();
if (cancelled) {
return;
}
setIntegrityDashboard(integrityResult);
setError((integrityResult.errors || []).filter(Boolean).join(';'));
} catch (loadError) {
if (cancelled) {
return;
}
setIntegrityDashboard(null);
setError(loadError?.message || '党风廉政驾驶舱加载失败');
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8">
<div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,247,237,0.94)_0%,rgba(255,255,255,0.9)_48%,rgba(224,242,254,0.86)_100%),radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_36%),radial-gradient(circle_at_top_right,rgba(14,165,233,0.18),transparent_34%)]" />
<div className="relative">
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl">
<h1 className="text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl">
驾驶舱
</h1>
<div className="mt-4 text-sm leading-6 text-zinc-600">
汇总展示党风廉政台账的总量、本年度记录、最近更新与分类分布。
</div>
</div>
<div className="grid gap-3 sm:w-56">
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
最近 30 天更新
</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{integrityDashboard?.recentCount || 0}
</div>
</div>
</div>
</div>
{error ? (
<div
className={clsx(
'mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700',
)}
>
{error}
</div>
) : null}
{loading ? (
<div className="mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700">
正在加载驾驶舱...
</div>
) : null}
</div>
</Panel>
<IntegrityDashboardView dashboard={integrityDashboard} loading={loading} />
</div>
);
}
function IntegrityDashboardView({ dashboard, loading }) {
const safeDashboard =
dashboard || {
totalCount: 0,
currentYearCount: 0,
recentCount: 0,
ledgerGroups: [],
yearGroups: [],
moduleOverview: {
year: new Date().getFullYear(),
trainingCategories: [],
talkSummary: {
totalCount: 0,
investmentCount: 0,
managementCount: 0,
},
yearlyModuleCounts: [],
},
};
return (
<>
<div className="grid gap-4 md:grid-cols-3">
<MetricCard
icon={ClipboardDocumentListIcon}
label="台账总数"
value={safeDashboard.totalCount}
tone="blue"
/>
<MetricCard
icon={CalendarDaysIcon}
label="本年度记录"
value={safeDashboard.currentYearCount}
tone="green"
/>
<MetricCard
icon={ArrowTrendingUpIcon}
label="最近 30 天更新"
value={safeDashboard.recentCount}
tone="rose"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<ChartPanel title="台账分类分布" groups={safeDashboard.ledgerGroups} type="pie" />
<ChartPanel title="年度分布" groups={safeDashboard.yearGroups} />
</div>
<IntegrityModuleOverview overview={safeDashboard.moduleOverview} loading={loading} />
</>
);
}
function IntegrityModuleOverview({ overview, loading }) {
const safeOverview = overview || {
year: new Date().getFullYear(),
trainingCategories: [],
talkSummary: {
totalCount: 0,
investmentCount: 0,
managementCount: 0,
},
yearlyModuleCounts: [],
};
const talkItems = [
['已谈话人数', safeOverview.talkSummary.totalCount],
['投资序列', safeOverview.talkSummary.investmentCount],
['管理序列', safeOverview.talkSummary.managementCount],
];
return (
<Panel className="p-6">
<SectionHeader title={`${safeOverview.year} 年模块概览`} />
{loading ? (
<div className="text-sm text-zinc-400">正在加载党风廉政统计...</div>
) : (
<div className="grid gap-6 xl:grid-cols-[1.35fr_0.9fr_1fr]">
<div>
<div className="mb-3 text-sm font-semibold text-zinc-900">培训类</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200">
<div className="grid grid-cols-[1fr_88px_88px] bg-zinc-50 px-4 py-3 text-xs font-semibold text-zinc-500">
<div>培训类别</div>
<div className="text-right">场次</div>
<div className="text-right">人数</div>
</div>
{safeOverview.trainingCategories.map((item) => (
<div
key={item.key}
className="grid grid-cols-[1fr_88px_88px] border-t border-zinc-200 px-4 py-3 text-sm"
>
<div className="min-w-0 truncate font-medium text-zinc-800">
{item.label}
</div>
<div className="text-right tabular-nums text-zinc-950">
{item.sessionCount}
</div>
<div className="text-right tabular-nums text-zinc-950">
{item.participantCount}
</div>
</div>
))}
</div>
</div>
<div>
<div className="mb-3 text-sm font-semibold text-zinc-900">谈话类</div>
<div className="grid gap-3">
{talkItems.map(([label, value]) => (
<div
key={label}
className="flex items-center justify-between rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<div className="text-sm text-zinc-600">{label}</div>
<div className="text-2xl font-semibold tabular-nums text-zinc-950">
{value}
</div>
</div>
))}
</div>
</div>
<div>
<div className="mb-3 text-sm font-semibold text-zinc-900">年度开展次数</div>
<div className="space-y-2">
{safeOverview.yearlyModuleCounts.map((item) => (
<div
key={item.key}
className="flex items-center justify-between border-b border-zinc-200 py-3 last:border-b-0"
>
<div className="text-sm font-medium text-zinc-700">{item.label}</div>
<div className="text-xl font-semibold tabular-nums text-zinc-950">
{item.count}
</div>
</div>
))}
</div>
</div>
</div>
)}
</Panel>
);
}
import { batchRemoveRecords, loadRecord, queryRecords } from './DataService';
export class BaseService {
constructor() {}
/**
* 通用方法:从选中的ID数组获取详情数据
* @param {string[]} selectedRowIds - 选中的行ID数组
* @param {string} subjectName - subject名称
* @returns {Promise<Object[]>} 详情数据数组
*/
async getSelectedRecords(selectedRowIds, subjectName) {
if (!selectedRowIds || selectedRowIds.length === 0) {
return [];
}
// 过滤有效的ID(长度大于8的ID)
const validIds = selectedRowIds.filter((id) => id && id.length > 8);
if (validIds.length === 0) {
return [];
}
try {
const result = await queryRecords({
subject: subjectName,
filter: {
_id: { $in: validIds },
},
page_size: 1000,
mode: 'full',
});
return result?.records || [];
} catch (error) {
console.error('获取选中记录详情失败:', error);
return [];
}
}
/**
* 通用方法:处理选中行数据,支持从ID数组或详情数组获取记录
* @param {*} selectedRowsData - 可能是ID数组或详情数组
* @param {Object} clickedRowData - 当前点击行的数据
* @param {string} subjectName - subject名称
* @returns {Promise<Object[]>} 处理后的记录数组
*/
async processSelectedRecords(selectedRowsData, clickedRowData, subjectName) {
let recordsToProcess = [];
if (selectedRowsData && selectedRowsData.length > 0) {
// 检查第一个元素是否为字符串ID
if (typeof selectedRowsData[0] === 'string') {
// 是ID数组,需要获取详情
recordsToProcess = await this.getSelectedRecords(
selectedRowsData,
subjectName,
);
} else {
// 是详情数组,直接使用
recordsToProcess = selectedRowsData;
}
} else if (clickedRowData) {
// 没有选中行,使用点击行
recordsToProcess = [clickedRowData];
}
return recordsToProcess;
}
// 通用功能
// 查看详情
async viewDetail(params, callbacks) {
const record = params.node?.data;
if (record && callbacks?.setSelectedRecord && callbacks?.setDrawerOpen) {
callbacks.setSelectedRecord(record);
callbacks.setDrawerOpen(true);
}
}
// 删除数据
async deleteData(params, callbacks, subjectName) {
if (!subjectName) {
console.error('缺少subjectName参数');
return;
}
const selectedRowIds = callbacks?.gridRef?.current?.getSelectedRows?.();
const clickedNode = params.node;
const isClickedInSelection = selectedRowIds?.some(
(id) => id === clickedNode.id,
);
if (isClickedInSelection) {
// 删除所有选中行 - 获取选中行的详情数据
const selectedRecords = await this.getSelectedRecords(
selectedRowIds,
subjectName,
);
console.log('selectedRecords', selectedRecords);
const storagedData = selectedRecords?.filter((v) => !!v?.name);
if (storagedData.length > 0) {
await batchRemoveRecords({
subject: subjectName,
filter: {
name: { $in: storagedData?.map((v) => v?.name) },
},
});
}
// 从网格中移除选中的行(使用详情数据)
params?.api?.applyServerSideTransaction({
remove: selectedRecords,
});
} else {
// 清空选中,删除当前行
if (clickedNode.data?.name) {
const record = await loadRecord(clickedNode.data.name);
console.log('clickedNode.data', record);
const recordSubjectName = record?.__t || subjectName;
await batchRemoveRecords({
subject: recordSubjectName,
filter: {
name: { $in: [clickedNode.data?.name] },
},
});
}
params?.api?.deselectAll();
params?.api?.applyServerSideTransaction({
remove: [clickedNode.data],
});
}
}
}
export default BaseService;
import request, { createRequestWithPrefix } from '../utils/request';
const baseUrl = STREAMS_API_URL;
const CONNECTOR_ID = 'kgwnrm5l6QsQ';
const PLAYWRIGHT_BASE_URL = 'http://localhost:3001';
const playwrightRequest = createRequestWithPrefix(PLAYWRIGHT_BASE_URL);
function getSystemInfo() {
return request('/info', {
method: 'get',
});
}
function putSystemInfo(data) {
return request(`/system/info`, {
method: 'put',
data,
});
}
function initializeSystem(data) {
return request('/init', {
method: 'post',
data,
});
}
function querySubjects(query) {
return request('/subject/query', {
method: 'post',
data: query,
});
}
function loadSubjects() {
return request('/subject', {
method: 'get',
});
}
function loadSubject(name) {
return request(`/subject/${name}`, {
method: 'get',
});
}
function removeSubject(name, force = false) {
return request(`/subject/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreSubject(name) {
return request(`/subject/${name}/restore`, {
method: 'put',
});
}
function updateSubject(name, data = {}) {
return request(`/subject/${name}`, {
method: 'put',
data,
});
}
function createSubject(params = {}, preview = false) {
console.log(params);
return request('/subject?preview=' + preview, {
method: 'post',
data: params,
});
}
function getSubjectField(name) {
return request(`/subject/${name}/field`, {
method: 'get',
});
}
function deleteSubjectField(subjectName, fieldName) {
return request(`/subject/${subjectName}/field/${fieldName}`, {
method: 'delete',
});
}
function updateSubjectField(subjectName, fieldName, params = {}) {
return request(`/subject/${subjectName}/field/${fieldName}`, {
method: 'put',
data: params,
});
}
function addSubjectField(subjectName, params = {}) {
return request(`/subject/${subjectName}/field`, {
method: 'post',
data: params,
});
}
function reorderSubjectField(subjectName, params = {}) {
return request(`/subject/${subjectName}/field/reorder`, {
method: 'put',
data: params,
});
}
function importSubjectFields(subjectName, params = {}) {
return request(`/subject/${subjectName}/field/import`, {
method: 'put',
data: params,
});
}
function getSubjectIndex(subjectName) {
return request(`/search/index/${subjectName}`, {
method: 'get',
});
}
function refreshSubjectIndex(subjectName) {
return request(`/search/index/${subjectName}/refresh`, {
method: 'put',
});
}
function getSubjectView(subjectName) {
return request(`/subject/${subjectName}/view`, {
method: 'get',
});
}
function getSubjectViewDetail(subjectName, viewName) {
return request(`/subject/${subjectName}/view/${viewName}`, {
method: 'get',
});
}
function createSubjectView(subjectName, query) {
return request(`/subject/${subjectName}/view`, {
method: 'post',
data: query,
});
}
function updateSubjectView(subjectName, viewName, query) {
return request(`/subject/${subjectName}/view/${viewName}`, {
method: 'put',
data: query,
});
}
function deleteSubjectView(subjectName, viewName) {
return request(`/subject/${subjectName}/view/${viewName}`, {
method: 'delete',
});
}
function reorderSubjectView(subjectName, params = {}) {
return request(`/subject/${subjectName}/view/reorder`, {
method: 'put',
data: params,
});
}
function changeSearchIndex(params) {
return request('/search/change/index', {
method: 'POST',
data: params,
});
}
function loadRecord(name) {
return request(`/record/${name}`, {
method: 'get',
});
}
function loadRecordRevisions(name, page = 0, pageSize = 20) {
return request(
`/record/${name}/revision?page=${page}&page_size=${pageSize}`,
{
method: 'get',
},
);
}
function applyRecordRevision(name, version) {
return request(`/record/${name}/revision`, {
method: 'put',
data: {
version,
},
});
}
function proxyGet(url, options = {}) {
return request(`/proxy?url=${encodeURIComponent(url)}`, {
method: 'get',
...options,
});
}
function loadRecords(
subjectName,
page = 0,
page_size = 20,
filter = {},
sort = null,
keyword = null,
full = false,
select = null,
) {
return request('/record/query', {
method: 'post',
data: {
subject: subjectName,
sort: sort,
page,
page_size,
filter,
keyword,
full,
select,
},
});
}
function queryRecords(query) {
return request('/record/query', {
method: 'post',
data: query,
});
}
function countRecords(query) {
return request('/record/count', {
method: 'post',
data: query,
});
}
function queryRemovedRecord(
page = 0,
page_size = 20,
filter = {},
keyword = null,
) {
return request('/record/query/removed', {
method: 'post',
data: {
page,
page_size,
filter,
keyword,
},
});
}
function aggregateRecords(aggregation) {
return request('/record/aggregate', {
method: 'post',
data: aggregation,
});
}
function reorderRecords(params) {
return request('/record/reorder', {
method: 'POST',
data: params,
});
}
function search(index, query) {
return request(`/search?index=${index}`, {
method: 'post',
data: query,
});
}
function commonSearch(
keyword,
index = '*',
page = 0,
pageSize = 5,
exact = true,
) {
return request(
`/search/common?keyword=${keyword}&index=${index}&page=${page}&page_size=${pageSize}&exact=${exact}`,
);
}
function aggregateSearch(keyword, index = '*', page = 0, pageSize = 5) {
return request(
`/search/aggregate?keyword=${keyword}&index=${index}&page=${page}&page_size=${pageSize}`,
);
}
function subjectSearch(subject, query) {
return request(`/search/${subject}`, {
method: 'post',
data: query,
});
}
function vectorSearch(subject, query) {
return request(`/search/vector?index=${subject}`, {
method: 'post',
data: query,
});
}
// 阿里云 词库完善
function queryAnalyze(data) {
return request(`/search/service/query-analyze`, {
method: 'post',
data,
});
}
function documentAnalyze(data) {
return request(`/search/service/document-analyze`, {
method: 'post',
data,
});
}
function imageAnalyze(data) {
return request(`/search/service/image-analyze`, {
method: 'post',
data,
});
}
function documentRerank(data) {
return request(`/search/service/document-rerank`, {
method: 'post',
data,
});
}
function webSearch(data) {
return request(`/search/service/web-search`, {
method: 'post',
data,
});
}
function rrfSearch(subject, query) {
return request(`/search/vector_rff?index=${subject}`, {
method: 'post',
data: query,
});
}
function semanticSearch(subject, query) {
return request(`/search/semantic?index=${subject}`, {
method: 'post',
data: query,
});
}
function ollamaChat(data) {
return fetch(`${baseUrl}/ollama/chat`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}
function llmList() {
return request('/llm/list', {
method: 'get',
});
}
function llmChat(data, signal = null) {
return fetch(`${baseUrl}/llm/chat`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
signal,
body: JSON.stringify(data),
});
}
function ollamaList() {
return request(`/ollama/list`, {
method: 'post',
});
}
// 注意:需要安装 @microsoft/fetch-event-source 包才能使用此功能
function ollamaPull(data, onmessage) {
console.warn('ollamaPull 功能需要安装 @microsoft/fetch-event-source 包');
// 临时使用普通 fetch 替代
return fetch(`${baseUrl}/ollama/pull`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}
function ollamaShow(model) {
return request(`/ollama/show`, {
method: 'post',
data: {
model,
},
});
}
function ollamaRemove(model) {
return request(`/ollama/delete`, {
method: 'post',
data: {
model,
},
});
}
function createRecord(params, preview = false) {
return request('/record?preview=' + preview, {
method: 'post',
data: params,
});
}
function removeRecord(name, force = false) {
return request(`/record/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreRecord(name) {
return request(`/record/${name}/restore`, {
method: 'put',
});
}
function updateRecord(name, model = {}) {
return request(`/record/${name}`, {
method: 'put',
data: model,
});
}
function patchRecord(name, model = {}) {
return request(`/record/${name}`, {
method: 'patch',
data: model,
});
}
function cloneRecord(name) {
return request('/record/clone', {
method: 'post',
data: {
from: name,
},
});
}
function importRecords(subject, records) {
return request(`/record/import`, {
method: 'post',
data: {
subject,
records,
},
});
}
function clearRecords(subject) {
return request(`/record/clear?subject=${subject}`, {
method: 'delete',
});
}
function batchCreateRecords(data) {
return request(`/record/batch/create`, {
method: 'post',
data,
});
}
function batchUpdateRecords(data) {
return request(`/record/batch/update`, {
method: 'post',
data,
});
}
function batchRemoveRecords(data) {
return request(`/record/batch/remove`, {
method: 'post',
data,
});
}
function getRecordMembers(name) {
return request(`/record/${name}/member`, {
method: 'get',
});
}
function createRecordMember(name, data) {
return request(`/record/${name}/member`, {
method: 'post',
data,
});
}
function updateRecordMember(name, owner, data) {
return request(`/record/${name}/member/${owner}`, {
method: 'put',
data,
});
}
function removeRecordMember(name, owner) {
return request(`/record/${name}/member/${owner}`, {
method: 'delete',
});
}
// file
function queryDocuments(page, page_size, filter = {}, status) {
return request('/document/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getDocument(name) {
return request(`/document/${name}`, {
method: 'get',
});
}
function removeDocument(name, force = false) {
return request(`/document/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreDocument(name) {
return request(`/document/${name}/restore`, {
method: 'put',
});
}
function updateDocument(name, data) {
return request(`/document/${name}`, {
method: 'put',
data,
});
}
function createDocument(formData) {
return request('/document', {
method: 'post',
requestType: 'form',
body: formData,
});
}
function uploadDocument(file) {
let formData = new FormData();
const filename = file.name;
const title = filename.split('.')[0];
formData.set('title', title);
formData.append('file', file);
return createDocument(formData);
}
function uploadFile(file) {
let formData = new FormData();
formData.append('file', file);
return request('/upload', {
method: 'post',
requestType: 'form',
body: formData,
});
}
function parseDocument(formData) {
return request('/parser/document/parse', {
method: 'POST',
requestType: 'form',
body: formData,
});
}
// user
function queryUsers(page, page_size, filter = {}, status = null) {
return request('/user/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function loadUser(name) {
return request(`/user/${name}`, {
method: 'get',
});
}
function createUser(data) {
return request(`/user`, {
method: 'post',
data,
});
}
function removeUser(name) {
return request(`/user/${name}`, {
method: 'delete',
});
}
function updateUser(name, data) {
return request(`/user/${name}`, {
method: 'put',
data,
});
}
function enableUser(name) {
return request(`/user/${name}/enable`, {
method: 'put',
});
}
function disableUser(name) {
return request(`/user/${name}/disable`, {
method: 'put',
});
}
function generateTicket(name) {
return request(`/user/${name}/ticket`, {
method: 'put',
});
}
function importUsers(data) {
return request(`/user/import`, {
method: 'post',
data,
});
}
function getUserInfo() {
return request(`/user/info`, {
method: 'get',
});
}
function putUserInfo(data) {
return request(`/user/info`, {
method: 'put',
data,
});
}
function changePassword(data) {
return request(`/user/password/change`, {
method: 'put',
data,
});
}
// group
function queryGroups(query) {
return request('/user/group/query', {
method: 'post',
data: query,
});
}
function getGroup(name) {
return request(`/user/group/${name}`, {
method: 'get',
});
}
function createGroup(data) {
return request(`/user/group`, {
method: 'post',
data,
});
}
function updateGroup(name, data) {
return request(`/user/group/${name}`, {
method: 'put',
data,
});
}
function removeGroup(name) {
return request(`/user/group/${name}`, {
method: 'delete',
});
}
function addGroupUser(group, user) {
return request(`/user/group/${group}/${user}`, {
method: 'put',
});
}
function removeGroupUser(group, user) {
return request(`/user/group/${group}/${user}`, {
method: 'delete',
});
}
// application
function getApplications() {
return request('/application/info', {
method: 'get',
});
}
function queryApplications(page, page_size, filter = {}, status) {
return request('/application/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function loadApplication(name) {
return request(`/application/${name}`, {
method: 'get',
});
}
function removeApplication(name, force = false) {
return request(`/application/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreApplication(name) {
return request(`/application/${name}/restore`, {
method: 'put',
});
}
function updateApplication(name, data) {
return request(`/application/${name}`, {
method: 'put',
data,
});
}
function createApplication(data) {
return request('/application', {
method: 'post',
data,
});
}
function changeApplicationRole(name, role, data) {
return request(`/application/${name}/role/${role}`, {
method: 'put',
data,
});
}
function removeApplicationRole(name, role) {
return request(`/application/${name}/role/${role}`, {
method: 'delete',
});
}
function changeApplicationMember(name, group, data) {
return request(`/application/${name}/member/${group}`, {
method: 'put',
data,
});
}
function removeApplicationMember(name, group) {
return request(`/application/${name}/member/${group}`, {
method: 'delete',
});
}
function changeApplicationPermission(name, permission, data) {
return request(`/application/${name}/permission/${permission}`, {
method: 'put',
data,
});
}
function removeApplicationPermission(name, permission) {
return request(`/application/${name}/permission/${permission}`, {
method: 'delete',
});
}
function createApplicationPackage(name, formData, cb) {
return request(`/application/${name}/package`, {
method: 'post',
requestType: 'form',
body: formData,
onReqProgress: function (ev) {
let percent = parseInt((ev?.loaded / ev?.total) * 100);
cb?.(percent);
},
});
}
function activateApplicationPackage(applicationName, packageName) {
return request(
`/application/${applicationName}/package/${packageName}/activate`,
{
method: 'put',
},
);
}
function deleteApplicationPackage(applicationName, packageName) {
return request(`/application/${applicationName}/package/${packageName}`, {
method: 'delete',
});
}
function authenticate(data) {
return request(`/auth/token`, {
method: 'post',
data,
});
}
function userLogin(data) {
return request(`/auth/login`, {
method: 'post',
data,
});
}
function userLogout(username) {
return request(`/auth/logout`, {
method: 'post',
data: {
username,
},
});
}
function getAuthMethods() {
return request(`/auth/method`, {
method: 'get',
});
}
function getCaptcha() {
return request(`/auth/captcha`, {
method: 'get',
});
}
function providerAuthorize(provider) {
return request(`/auth/provider/${provider}/authorize`, {
method: 'get',
});
}
function providerToken(provider, code) {
return request(`/auth/provider/${provider}/token?code=${code}`, {
method: 'get',
});
}
function providerProfile(provider, token) {
return request(`/auth/provider/${provider}/profile?token=${token}`, {
method: 'get',
});
}
function queryViews(page, page_size, filter = {}, status) {
return request('/view/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getView(name) {
return request(`/view/${name}`, {
method: 'get',
});
}
function createView(data) {
return request('/view', {
method: 'post',
data,
});
}
function updateView(name, data) {
return request(`/view/${name}`, {
method: 'put',
data,
});
}
function removeView(name, force = false) {
return request(`/view/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreView(name) {
return request(`/view/${name}/restore`, {
method: 'put',
});
}
function executeView(name, data) {
return request(`/view/${name}/execute`, {
method: 'post',
data,
});
}
function queryTemplates(page, page_size, filter = {}, status) {
return request('/template/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getTemplate(name) {
return request(`/template/${name}`, {
method: 'get',
});
}
function createTemplate(data) {
return request('/template', {
method: 'post',
data,
});
}
function updateTemplate(name, data) {
return request(`/template/${name}`, {
method: 'put',
data,
});
}
function removeTemplate(name, force = false) {
return request(`/template/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreTemplate(name) {
return request(`/template/${name}/restore`, {
method: 'put',
});
}
function queryRuleSets(page, page_size, filter = {}, status) {
return request('/rule/set/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getRuleSet(name) {
return request(`/rule/set/${name}`, {
method: 'get',
});
}
function createRuleSet(data) {
return request('/rule/set', {
method: 'post',
data,
});
}
function updateRuleSet(name, data) {
return request(`/rule/set/${name}`, {
method: 'put',
data,
});
}
function removeRuleSet(name, force = false) {
return request(`/rule/set/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreRuleSet(name) {
return request(`/rule/set/${name}/restore`, {
method: 'put',
});
}
function queryRules(page, page_size, filter = {}, status) {
return request('/rule/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getRule(name) {
return request(`/rule/${name}`, {
method: 'get',
});
}
function createRule(data) {
return request('/rule', {
method: 'post',
data,
});
}
function updateRule(name, data) {
return request(`/rule/${name}`, {
method: 'put',
data,
});
}
function removeRule(name, force = false) {
return request(`/rule/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreRule(name) {
return request(`/rule/${name}/restore`, {
method: 'put',
});
}
function queryTasks(page, page_size, filter = {}, status = null) {
return request('/task/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getTask(name) {
return request(`/task/${name}`, {
method: 'get',
});
}
function createTask(data) {
return request('/task', {
method: 'post',
data,
});
}
function updateTask(name, data) {
return request(`/task/${name}`, {
method: 'put',
data,
});
}
function removeTask(name, force = false) {
return request(`/task/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreTask(name) {
return request(`/task/${name}/restore`, {
method: 'put',
});
}
function enableTask(name) {
return request(`/task/${name}/enable`, {
method: 'put',
});
}
function disableTask(name) {
return request(`/task/${name}/disable`, {
method: 'put',
});
}
function setTaskData(name, data) {
return request(`/task/${name}/data`, {
method: 'put',
data,
});
}
function getTaskJob(name) {
return request(`/task/job/${name}`, {
method: 'get',
});
}
function getAction(name) {
return request(`/action/${name}`, {
method: 'get',
});
}
function createAction(data) {
return request('/action', {
method: 'post',
data,
});
}
function updateAction(name, data) {
return request(`/action/${name}`, {
method: 'put',
data,
});
}
function executeAction(name, data) {
return request(`/action/execute/${name}`, {
method: 'post',
data,
});
}
function exacteActionFunction(action, fname, data) {
return request(`/notice/customer/${action}/action/${fname}`, {
method: 'put',
data,
});
}
function uploadActionScript(file, name = null) {
const formData = new FormData();
formData.append('file', file);
if (name) {
formData.append('name', name);
}
return request('/action/upload', {
method: 'post',
requestType: 'form',
body: formData,
});
}
function removeAction(name, force = false) {
return request(`/action/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function queryActions(page, page_size, filter = {}, status = null) {
return request('/action/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getActionJob(name) {
return request(`/action/job/${name}`, {
method: 'get',
});
}
function getPackage() {
return request(`/package`, {
method: 'get',
});
}
function currentPackage() {
return request(`/package/manifest`, {
method: 'get',
});
}
function createPackage(formData, cb) {
return request('/package', {
method: 'post',
requestType: 'form',
body: formData,
onReqProgress: function (ev) {
let percent = parseInt((ev?.loaded / ev?.total) * 100);
cb?.(percent);
},
});
}
function activatePackage(name) {
return request(`/package/activate/${name}`, {
method: 'put',
});
}
function deletePackage(name) {
return request(`/package/${name}`, {
method: 'delete',
});
}
function importTags(tags) {
return request('/tag/import', {
method: 'post',
data: {
tags,
},
});
}
function queryTags({ page, page_size, keyword, groups, filter, status }) {
return request('/tag/query', {
method: 'post',
data: {
page,
page_size,
keyword,
groups,
filter,
status,
},
});
}
function getTag(name) {
return request(`/tag/${name}`, {
method: 'get',
});
}
function createTag(data) {
return request('/tag', {
method: 'post',
data,
});
}
function updateTag(name, data) {
return request(`/tag/${name}`, {
method: 'put',
data,
});
}
function removeTag(name, force = false) {
return request(`/tag/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function restoreTag(name) {
return request(`/tag/${name}/restore`, {
method: 'put',
});
}
function getTagGroup() {
return request(`/tag/group`, {
method: 'get',
});
}
function sendEmail(data) {
return request(`/message/email`, {
method: 'post',
data,
});
}
function connectorTest(data) {
return request(`/connector/test`, {
method: 'post',
data,
});
}
function connectorList(data) {
return request(`/connector/list`, {
method: 'post',
data,
});
}
function connectorInspect(data) {
return request(`/connector/inspect`, {
method: 'post',
data,
});
}
function connectorCount(data) {
return request(`/connector/count`, {
method: 'post',
data,
});
}
function connectorSelect(data) {
return request(`/connector/select`, {
method: 'post',
data,
});
}
function connectorExecute(data) {
return request(`/connector/execute`, {
method: 'post',
data,
});
}
function queryAuditLogs(data) {
return request(`/system/audit`, {
method: 'post',
data,
});
}
function queryAction(route, data) {
return request(`/action/execute/${route}`, {
method: 'post',
data,
});
}
// category
function queryCategories(query) {
return request('/category/query', {
method: 'post',
data: query,
});
}
function loadCategory(name) {
return request(`/category/${name}`, {
method: 'get',
});
}
function createCategory(data) {
return request('/category', {
method: 'post',
data,
});
}
function removeCategory(name, force = false) {
return request(`/category/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function updateCategory(name, data) {
return request(`/category/${name}`, {
method: 'put',
data,
});
}
// client
function queryClients(query) {
return request('/client/query', {
method: 'post',
data: query,
});
}
function loadClient(name) {
return request(`/client/${name}`, {
method: 'get',
});
}
function createClient(data) {
return request('/client', {
method: 'post',
data,
});
}
function removeClient(name, force = false) {
return request(`/client/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function updateClient(name, data) {
return request(`/client/${name}`, {
method: 'put',
data,
});
}
function executeEndpoint(params = {}) {
return request(`/client/${params.clientName}/call/${params.endpointName}`, {
method: params.method,
data: params.data || undefined,
});
}
// spider
function querySpider(page, page_size, filter = {}, status) {
return request('/spider/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function getSpider(name) {
return request(`/spider/${name}`, {
method: 'get',
});
}
function createSpider(data) {
return request('/spider', {
method: 'post',
data,
});
}
function updateSpider(name, data) {
return request(`/spider/${name}`, {
method: 'put',
data,
});
}
function removeSpider(name, force = false) {
return request(`/spider/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function getSearchEngineInfo() {
return request('search/info', {
method: 'get',
});
}
function querySearchIndex() {
return request('/search/index', {
method: 'get',
});
}
function getSearchIndex(name) {
return request(`/search/index/${name}`, {
method: 'get',
});
}
function removeSearchIndex(name) {
return request(`/search/index/${name}`, {
method: 'delete',
});
}
function createSearchIndex(name) {
return request(`/search/index`, {
method: 'post',
data: {
subject: name,
},
});
}
function updateSearchIndex(name) {
return request(`/search/index/${name}`, {
method: 'put',
});
}
function queryDictList(data) {
return request('/search/service/dict', {
method: 'post',
data,
});
}
function analyzeText(data) {
return request('/search/analyze', {
method: 'post',
data,
});
}
function updateDictList(data) {
return request('/search/service/dict-update', {
method: 'post',
data,
});
}
function getMcpList(data) {
return request('/mcp', {
method: 'get',
});
}
function queryMcps(query) {
return request('/mcp/query', {
method: 'post',
data: query,
});
}
function getMcp(name) {
return request(`/mcp/${name}`, {
method: 'get',
});
}
function createMcp(data) {
return request('/mcp', {
method: 'post',
data,
});
}
function updateMcp(name, data) {
return request(`/mcp/${name}`, {
method: 'put',
data,
});
}
function removeMcp(name, force = false) {
return request(`/mcp/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
function getMcpDetail(name) {
return request(`/mcp/${name}`, {
method: 'get',
});
}
// 注意:需要安装 @modelcontextprotocol/sdk 包才能使用此功能
async function getMcpTools(name) {
console.warn('getMcpTools 功能需要安装 @modelcontextprotocol/sdk 包');
// 临时返回空数组
return [];
}
// 注意:需要安装 @modelcontextprotocol/sdk 包才能使用此功能
async function callMcpTool(mcpName, toolName, data = {}) {
console.warn('callMcpTool 功能需要安装 @modelcontextprotocol/sdk 包');
// 临时返回空对象
return {};
}
// Notice
function queryNotice(page, page_size, filter = {}, status) {
return request('/notice/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
// 详情
function getNotice(name) {
return request(`/notice/${name}`, {
method: 'get',
});
}
// 创建
function createNotice(data) {
return request(`/notice`, {
method: 'post',
data,
});
}
function sendNotice(name) {
return request(`/notice/${name}/send`, {
method: 'put',
});
}
// 更新
function updateNotice(name, data) {
return request(`/notice/${name}`, {
method: 'put',
data,
});
}
//删除
function removeNotice(name, force = false) {
return request(`/notice/${name}`, {
method: 'delete',
});
}
// 禁用
function disableNotice(name) {
return request(`/notice/${name}/disable`, {
method: 'put',
});
}
// 启用
function enableNotice(name) {
return request(`/notice/${name}/enable`, {
method: 'put',
});
}
// Trigger
// 列表
function queryTrigger(page, page_size, filter = {}, status) {
return request('/trigger/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
// 详情
function getTrigger(name) {
return request(`/trigger/${name}`, {
method: 'get',
});
}
// 创建
function createTrigger(data) {
return request(`/trigger`, {
method: 'post',
ContentType: 'applicaton/json',
data,
});
}
// 更新
function updateTrigger(name, data) {
return request(`/trigger/${name}`, {
method: 'put',
ContentType: 'applicaton/json',
data,
});
}
//删除
function removeTrigger(name, force = false) {
return request(`/trigger/${name}`, {
method: 'delete',
});
}
// 禁用
function disableTrigger(name) {
return request(`/trigger/${name}/disable`, {
method: 'put',
});
}
// 启用
function enableTrigger(name) {
return request(`/trigger/${name}/enable`, {
method: 'put',
});
}
// Variable
// {page,page_size,filter,status}
function queryVariable(page, page_size, filter = {}, status) {
return request('/variable/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
// 详情
function getVariable(name) {
return request(`/variable/${name}`, {
method: 'get',
});
}
// 创建
function createVariable(data) {
return request(`/variable`, {
method: 'post',
data,
});
}
// 更新
function updateVariable(name, data) {
return request(`/variable/${name}`, {
method: 'put',
data,
});
}
//删除
function removeVariable(name, force = false) {
return request(`/Variable/${name}`, {
method: 'delete',
});
}
// 禁用
function disableVariable(name) {
return request(`/Variable/${name}/disable`, {
method: 'put',
});
}
// 启用
function enableVariable(name) {
return request(`/variable/${name}/enable`, {
method: 'put',
});
}
// dynamicForm
// 表单列表
function queryDForm(page, page_size, filter = {}, status = null, sort = null) {
return request('/form/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
sort,
},
});
}
// 创建表单
function createDForm(data) {
return request('/form', {
method: 'post',
data,
});
}
// 更新表单
function updateDForm(name, data) {
return request(`/form/${name}`, {
method: 'put',
data,
});
}
// 删除表单
function removeDForm(name) {
return request(`/form/${name}`, {
method: 'delete',
});
}
// 查询表单详情
function getDFormDetail(name) {
return request(`/form/${name}`, {
method: 'get',
});
}
// 创建表单字段
function createDFormField(name, data) {
return request(`/form/${name}/field`, {
method: 'post',
data,
});
}
// 更新表单字段
function updateDFormField(name, fieldName, data) {
return request(`/form/${name}/field/${fieldName}`, {
method: 'put',
data,
});
}
// 删除表单字段
function removeDFormField(name, fieldName) {
return request(`/form/${name}/field/${fieldName}`, {
method: 'delete',
});
}
// 表单字段排序
function sortDFormField(name, data) {
return request(`/form/${name}/field/reorder`, {
method: 'put',
data,
});
}
// 导入表单字段
function importDFormField(name, data) {
return request(`/form/${name}/field/import`, {
method: 'put',
data,
});
}
// 获取表单指定字段
function getDFormField(name, fieldName) {
return request(`/form/${name}/field/${fieldName}`, {
method: 'get',
});
}
// 获取指定表单所有字段
function getDFormFields(name) {
return request(`/form/${name}/field`, {
method: 'get',
});
}
// 对象带有动态表单创建记录
function createRecordWithDForm(data) {
return request(`/record`, {
method: 'post',
data,
});
}
// 消息获取
function queryUserNotice(page, page_size, filter = {}, status = null) {
// https://www.streams7.com/api/notice/customer/query
return request('/notice/customer/query', {
method: 'post',
data: {
page,
page_size,
filter,
status,
},
});
}
function readUserNotice(name) {
return request('/notice/customer/read', {
method: 'put',
data: {
name,
},
});
}
function unReadNoticeCount() {
return request('/notice/customer/count', {
method: 'get',
});
}
// 消息清空
function cleanUserNotice(name) {
return request('/notice/customer/clean', {
method: 'put',
data: {
name,
},
});
}
function searchTeamVectors(data) {
return request(`/search/team-vectors?index=${data.subject_name}`, {
method: 'post',
data: {
fields: data.fields,
ids: data.record_ids,
},
});
}
function connectorQuery(name) {
return request('/connector/' + CONNECTOR_ID + '/query/' + name, {
method: 'get',
});
}
function connectorQueryExecute(data) {
return request('/connector/execute', {
method: 'post',
data,
});
}
function createTracking(data) {
return request('/tracking', {
method: 'post',
data,
});
}
// playwright
function createPlaywrightSession(data = {}) {
return playwrightRequest('/session', {
method: 'post',
data,
});
}
function getPlaywrightSessionStatus(sessionId) {
return playwrightRequest(`/session/${sessionId}`, {
method: 'get',
});
}
function listPlaywrightSessions() {
return playwrightRequest('/session', {
method: 'get',
});
}
function destroyPlaywrightSession(sessionId) {
return playwrightRequest(`/session/${sessionId}`, {
method: 'delete',
});
}
function navigatePlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/navigate`, {
method: 'post',
data,
});
}
function clickPlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/click`, {
method: 'post',
data,
});
}
function waitForPlaywrightSelector(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/wait`, {
method: 'post',
data,
});
}
function screenshotPlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/screenshot`, {
method: 'post',
data,
});
}
function queryPlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/query`, {
method: 'post',
data,
});
}
function fillPlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/fill`, {
method: 'post',
data,
});
}
function evaluatePlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/evaluate`, {
method: 'post',
data,
});
}
function scrollPlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/scroll`, {
method: 'post',
data,
});
}
function selectAllPlaywrightSession(sessionId, data = {}) {
return playwrightRequest(`/session/${sessionId}/selectAll`, {
method: 'post',
data,
});
}
export {
activateApplicationPackage,
activatePackage,
addGroupUser,
addSubjectField,
aggregateRecords,
aggregateSearch,
analyzeText,
applyRecordRevision,
//auth
authenticate,
batchCreateRecords,
batchRemoveRecords,
batchUpdateRecords,
callMcpTool,
changeApplicationMember,
changeApplicationPermission,
changeApplicationRole,
changePassword,
changeSearchIndex,
cleanUserNotice,
clearRecords,
clickPlaywrightSession,
cloneRecord,
commonSearch,
connectorCount,
connectorExecute,
connectorInspect,
connectorList,
connectorQuery,
connectorQueryExecute,
connectorSelect,
//connector,
connectorTest,
countRecords,
createAction,
createApplication,
createApplicationPackage,
createCategory,
createClient,
createDForm,
createDFormField,
createDocument,
createGroup,
createMcp,
createNotice,
createPackage,
createPlaywrightSession,
createRecord,
createRecordMember,
createRecordWithDForm,
createRule,
createRuleSet,
createSearchIndex,
createSpider,
createSubject,
createSubjectView,
createTag,
createTask,
createTemplate,
createTracking,
// trigger
createTrigger,
createUser,
createVariable,
createView,
currentPackage,
deleteApplicationPackage,
deletePackage,
deleteSubjectField,
deleteSubjectView,
destroyPlaywrightSession,
disableNotice,
disableTask,
disableTrigger,
disableUser,
disableVariable,
documentAnalyze,
documentRerank,
enableNotice,
enableTask,
enableTrigger,
enableUser,
enableVariable,
evaluatePlaywrightSession,
exacteActionFunction,
executeAction,
executeEndpoint,
executeView,
fillPlaywrightSession,
generateTicket,
// action
getAction,
getActionJob,
//application
getApplications,
getAuthMethods,
getCaptcha,
// form field
getDFormDetail,
getDFormField,
getDFormFields,
getDocument,
getGroup,
getMcp,
getMcpDetail,
// mcp
getMcpList,
getMcpTools,
getNotice,
// package
getPackage,
getPlaywrightSessionStatus,
getRecordMembers,
getRule,
getRuleSet,
// 搜索引擎信息
getSearchEngineInfo,
getSearchIndex,
getSpider,
//fields
getSubjectField,
getSubjectIndex,
//subjectView
getSubjectView,
getSubjectViewDetail,
getSystemInfo,
getTag,
getTagGroup,
getTask,
getTaskJob,
getTemplate,
getTrigger,
getUserInfo,
getVariable,
getView,
imageAnalyze,
importDFormField,
importRecords,
importSubjectFields,
// tag
importTags,
importUsers,
initializeSystem,
listPlaywrightSessions,
llmChat,
llmList,
loadApplication,
loadCategory,
loadClient,
// record
loadRecord,
loadRecordRevisions,
loadRecords,
//subject
loadSubject,
loadSubjects,
loadUser,
navigatePlaywrightSession,
ollamaChat,
ollamaList,
ollamaPull,
ollamaRemove,
ollamaShow,
// patch
patchRecord,
providerAuthorize,
providerProfile,
providerToken,
proxyGet,
putSystemInfo,
putUserInfo,
queryAction,
queryActions,
queryAnalyze,
queryApplications,
queryAuditLogs,
// category
queryCategories,
// client
queryClients,
// form
queryDForm,
queryDictList,
// document
queryDocuments,
//group,
queryGroups,
queryMcps,
// Notice
queryNotice,
queryPlaywrightSession,
queryRecords,
queryRemovedRecord,
//rule
queryRules,
//ruleSet
queryRuleSets,
querySearchIndex,
//spider
querySpider,
querySubjects,
queryTags,
//task
queryTasks,
//template
queryTemplates,
queryTrigger,
queryUserNotice,
//users
queryUsers,
// variable
queryVariable,
//view
queryViews,
readUserNotice,
refreshSubjectIndex,
removeAction,
removeApplication,
removeApplicationMember,
removeApplicationPermission,
removeApplicationRole,
removeCategory,
removeClient,
removeDForm,
removeDFormField,
removeDocument,
removeGroup,
removeGroupUser,
removeMcp,
removeNotice,
removeRecord,
removeRecordMember,
removeRule,
removeRuleSet,
removeSearchIndex,
removeSpider,
removeSubject,
removeTag,
removeTask,
removeTemplate,
removeTrigger,
removeUser,
removeVariable,
removeView,
reorderRecords,
reorderSubjectField,
reorderSubjectView,
restoreApplication,
restoreDocument,
restoreRecord,
restoreRule,
restoreRuleSet,
restoreSubject,
restoreTag,
restoreTask,
restoreTemplate,
restoreView,
rrfSearch,
screenshotPlaywrightSession,
scrollPlaywrightSession,
search,
searchTeamVectors,
selectAllPlaywrightSession,
semanticSearch,
//message
sendEmail,
sendNotice,
setTaskData,
sortDFormField,
subjectSearch,
unReadNoticeCount,
updateAction,
updateApplication,
updateCategory,
updateClient,
updateDForm,
updateDFormField,
updateDictList,
updateDocument,
updateGroup,
updateMcp,
updateNotice,
updateRecord,
updateRecordMember,
updateRule,
updateRuleSet,
updateSearchIndex,
updateSpider,
updateSubject,
updateSubjectField,
updateSubjectView,
updateTag,
updateTask,
updateTemplate,
updateTrigger,
updateUser,
updateVariable,
updateView,
uploadActionScript,
uploadDocument,
uploadFile,
userLogin,
userLogout,
vectorSearch,
waitForPlaywrightSelector,
webSearch,
};
import {extend} from 'umi-request'
const request = extend({
prefix: STREAMS_PARSER_URL,
timeout: 20000
})
export default class DocumentService {
static parse(file) {
const form = new FormData()
form.append('file', file)
return request('/document/parse', {
method: 'post',
body: form,
requestType: 'form'
})
}
static convert(file, format = 'txt') {
const form = new FormData()
form.append('file', file)
return request('/document/convert?format=' + format, {
method: 'post',
body: form,
requestType: 'form'
})
}
static generate(model) {
return request('/document/generate', {
method: 'post',
data: model,
responseType: 'blob',
})
}
static fill(file, model) {
const form = new FormData()
form.append('file', file)
form.append('model', JSON.stringify(model))
return request('/document/fill', {
method: 'post',
body: form,
requestType: 'form',
responseType: 'blob'
})
}
}
// 用户认证服务
class AuthService {
// 获取当前用户信息
getCurrentUser() {
try {
const userStr = localStorage.getItem('current_user');
return userStr ? JSON.parse(userStr) : null;
} catch (error) {
console.error('解析用户信息失败:', error);
return null;
}
}
// 设置当前用户信息
setCurrentUser(user) {
try {
localStorage.setItem('current_user', JSON.stringify(user));
return true;
} catch (error) {
console.error('保存用户信息失败:', error);
return false;
}
}
// 获取用户 token
getToken() {
const user = this.getCurrentUser();
return user?.token || localStorage.getItem('auth_token');
}
// 设置用户 token
setToken(token) {
try {
localStorage.setItem('auth_token', token);
return true;
} catch (error) {
console.error('保存 token 失败:', error);
return false;
}
}
// 清除用户信息
clearUser() {
try {
localStorage.removeItem('current_user');
localStorage.removeItem('auth_token');
return true;
} catch (error) {
console.error('清除用户信息失败:', error);
return false;
}
}
// 检查用户是否已登录
isLoggedIn() {
const user = this.getCurrentUser();
const token = this.getToken();
return !!(user && token);
}
// 登录
async login(credentials) {
try {
// 这里应该调用你的登录 API
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('登录失败');
}
const data = await response.json();
// 保存用户信息
this.setCurrentUser(data.user || data.username);
this.setToken(data.token);
return data;
} catch (error) {
console.error('登录失败:', error);
throw error;
}
}
// 登出
logout() {
this.clearUser();
// 可以在这里调用登出 API
}
}
// 创建单例实例
const authService = new AuthService();
export default authService;
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import { request } from '@umijs/max';
/** 此处后端没有提供注释 GET /api/v1/queryUserList */
export async function queryUserList(
params: {
// query
/** keyword */
keyword?: string;
/** current */
current?: number;
/** pageSize */
pageSize?: number;
},
options?: { [key: string]: any },
) {
return request<API.Result_PageInfo_UserInfo__>('/api/v1/queryUserList', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}
/** 此处后端没有提供注释 POST /api/v1/user */
export async function addUser(
body?: API.UserInfoVO,
options?: { [key: string]: any },
) {
return request<API.Result_UserInfo_>('/api/v1/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 GET /api/v1/user/${param0} */
export async function getUserDetail(
params: {
// path
/** userId */
userId?: string;
},
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
method: 'GET',
params: { ...params },
...(options || {}),
});
}
/** 此处后端没有提供注释 PUT /api/v1/user/${param0} */
export async function modifyUser(
params: {
// path
/** userId */
userId?: string;
},
body?: API.UserInfoVO,
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
params: { ...params },
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 DELETE /api/v1/user/${param0} */
export async function deleteUser(
params: {
// path
/** userId */
userId?: string;
},
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_string_>(`/api/v1/user/${param0}`, {
method: 'DELETE',
params: { ...params },
...(options || {}),
});
}
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import * as UserController from './UserController';
export default {
UserController,
};
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
declare namespace API {
interface PageInfo {
/**
1 */
current?: number;
pageSize?: number;
total?: number;
list?: Array<Record<string, any>>;
}
interface PageInfo_UserInfo_ {
/**
1 */
current?: number;
pageSize?: number;
total?: number;
list?: Array<UserInfo>;
}
interface Result {
success?: boolean;
errorMessage?: string;
data?: Record<string, any>;
}
interface Result_PageInfo_UserInfo__ {
success?: boolean;
errorMessage?: string;
data?: PageInfo_UserInfo_;
}
interface Result_UserInfo_ {
success?: boolean;
errorMessage?: string;
data?: UserInfo;
}
interface Result_string_ {
success?: boolean;
errorMessage?: string;
data?: string;
}
type UserGenderEnum = 'MALE' | 'FEMALE';
interface UserInfo {
id?: string;
name?: string;
/** nick */
nickName?: string;
/** email */
email?: string;
gender?: UserGenderEnum;
}
interface UserInfoVO {
name?: string;
/** nick */
nickName?: string;
/** email */
email?: string;
}
type definitions_0 = null;
}
# Record 接口文档
**最后更新**:2026 年 3 月
**基于代码实现整理**
参考文件:
- `src/routers/record.js`
- `src/managers/RecordManager.js`
- `src/models/Record.js`
- `src/models/Revision.js`
## 1. 基础信息
- **路由前缀**:`/record`
- **鉴权方式**:全部接口需要登录(支持 `Authorization: Bearer <token>` 或登录态 Cookie)
- **权限控制**:每个接口绑定对应 `api.record.*` 权限点(详见各接口)
- **Content-Type**:`application/json`
- **路径参数**:多数接口的 `:name` / `:record` 支持**记录 `name`** 或 **MongoDB ObjectId 字符串**
### 1.1 通用错误响应格式
全局错误中间件统一返回:
```json
{
"code": 500,
"message": "Internal Server Error"
}
```
开启调试模式(`DEBUG=1`)时,会额外返回:
```json
{
"code": 500,
"message": "具体错误描述...",
"error": { ... }
}
```
常见业务/权限错误码:
- `401` Unauthorized
- `403` Forbidden
- `404` `{"code":404,"message":"record '<name>' not found"}`
- `400` 业务校验失败
## 2. Record 核心模型字段
不同 `subject` 通过 `metadata` 扩展业务字段。
| 字段 | 类型 | 说明 | 是否必填 | 备注 |
| --- | --- | --- | --- | --- |
| `_id` | ObjectId | MongoDB 主键 | — | — |
| `name` | String | 业务唯一标识 | 部分场景 | 可作为查询/更新键 |
| `title` | String | 显示标题 | 通常必填 | — |
| `subject` | ObjectId / 嵌入对象 | 所属主题 | 必填 | — |
| `content_type` | String enum | `text` \| `document` | 可选 | 默认 text |
| `content` | Any | 正文内容 | 可选 | — |
| `status` | String enum | `active` \| `inactive` \| `removed` | — | 默认 active |
| `tags` | String[] | 标签数组 | 可选 | — |
| `terms` | Object | 分类维度映射 | 可选 | — |
| `order` | Number | 排序值 | 可选 | 默认 0 |
| `metadata` | Object | 业务动态字段 | 可选 | 核心扩展区 |
| `access` | String enum | `inherited` \| `public` \| `protected` \| `private` | 可选 | 默认 inherited |
| `members` | Array | 记录级成员权限 | 可选 | — |
| `created_by` | Object | 创建人 | — | — |
| `updated_by` | Object | 最后修改人 | — | — |
| `created_at` | ISODate | 创建时间 | — | — |
| `updated_at` | ISODate | 更新时间 | — | — |
### 2.1 metadata 字段常见类型与校验
由 Subject → fields 配置决定,支持类型包括:
- 文本:`text`, `long_text`, `rich_text`, `select`, `formula`
- 布尔:`boolean`
- 数字:`number`
- 日期:`date`
- 对象:`object`
- 文件:`file` → `{name, type, size, url}`
- 引用:`reference`, `user`, `document`, `view`, `template`, `task`, `tag`, `group`, `category`, `subject`, `form`
## 3. 接口列表
### 3.1 查询类
#### 3.1.1 POST `/record/query`
权限:`api.record.query`
功能:查询 **active** 记录
常用请求体字段:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| subject | string | 是 | — | 主题名 |
| page | number | 否 | 0 | 页码 |
| page_size | number | 否 | 20 | 每页条数 |
| keyword | string | 否 | — | 模糊匹配 title |
| tags | string[] | 否 | — | 标签过滤 |
| filter | object | 否 | — | Mongo 查询条件 |
| sort | string | 否 | `-order -created_at _id` | 排序 |
#### 3.1.2 GET `/record/:name`
权限:`api.record.get`
功能:获取单条详情
### 3.2 创建 / 更新
- POST `/record`
- PUT `/record/:name`
- PATCH `/record/:name`
### 3.3 删除 / 恢复
- DELETE `/record/:name`
- PUT `/record/:record/restore`
## 4. 常见问题 & 注意事项
1. **name 与 _id**:路径参数 `:name` 同时支持业务 name 和 ObjectId 字符串。
2. **业务字段都放 metadata**:不要把 subject 自定义字段直接挂到根层。
3. **公式字段计算**:默认不计算,需显式 `calculated=true`。
4. **软删除 vs 物理删除**:默认软删除,需显式 `force` 才物理删除。
## 5. 党风廉政建设接入模式
本模块继续复用统一 `Record` 接口,6 类台账分别使用独立 subject:
- `integrity_party_training`:党风廉政培训记录
- `integrity_party_talk`:党风廉政谈话记录
- `integrity_party_research`:党风廉政调研记录
- `integrity_party_meeting`:党风廉政专题会议记录
- `integrity_party_activity`:廉洁主题活动记录
- `integrity_party_holiday_reminder`:节前廉洁提醒记录
### 5.1 创建 / 更新建议
调用:
- `POST /record`
- `PUT /record/:name`
示例:
```json
{
"subject": "integrity_party_training",
"title": "廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08",
"content_type": "text",
"content": "廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08",
"metadata": {
"sequence_no": 1,
"training_category": "廉洁合规培训",
"training_date": "2026-06-08T00:00:00.000Z",
"training_target": "新员工",
"training_format": "现场培训",
"lead_department": "纪检办公室",
"lecturer": "张三",
"lecture_topic": "新员工廉洁合规入职培训",
"main_content": "围绕廉洁从业要求、合规底线和典型案例开展培训。",
"participant_count": 32,
"courseware_materials": [
{
"name": "培训课件.pptx",
"type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"size": 204800,
"url": "https://example.com/file.pptx"
}
],
"sign_in_sheets": []
}
}
```
### 5.2 查询建议
调用:
- `POST /record/query`
示例:
```json
{
"subject": "integrity_party_talk",
"page": 0,
"page_size": 20,
"filter": {
"$and": [
{ "metadata.year": 2026 },
{
"$or": [
{ "metadata.person_name": { "$regex": "张三", "$options": "i" } },
{ "metadata.primary_department": { "$regex": "张三", "$options": "i" } }
]
}
]
},
"sort": "-updated_at -created_at"
}
```
注意事项:
- 业务字段全部写入 `metadata`
- 文件字段统一保存为多附件数组 `{name,type,size,url}[]`
- 日期字段页面和 Excel 展示为 `YYYY-MM-DD`,保存为 ISO 8601 字符串
- Excel 导入不处理附件字段,附件仅通过系统上传控件维护
- 当前阶段导入不做重复判断、不覆盖已有记录,全部新增
# `subjectsDefinition.md`
## 0. 目的与适用范围
本文件定义当前项目使用的 Subject(业务对象)字段协议,供前端页面、服务层、动态表单与 AI 代码生成使用。
当前项目当前阶段以“党风廉政建设”为目标,核心业务对象如下:
- `integrity_party_training`:党风廉政培训记录
- `integrity_party_talk`:党风廉政谈话记录
- `integrity_party_research`:党风廉政调研记录
- `integrity_party_meeting`:党风廉政专题会议记录
- `integrity_party_activity`:廉洁主题活动记录
- `integrity_party_holiday_reminder`:节前廉洁提醒记录
---
## 1. 统一记录模型
所有业务对象均基于统一 `Record` 模型:
```ts
type Record = {
_id?: string;
name?: string;
title?: string;
subject: string;
status?: string;
content?: string;
metadata: Record<string, any>;
};
```
### 1.1 字段路径规则
- 所有业务字段必须放在 `metadata.<fieldAlias>` 下
- 不要把业务字段直接挂在根节点
- `title` 建议与 `metadata.label` 保持一致,便于统一检索和列表展示
正确示例:
```json
{
"subject": "integrity_party_training",
"title": "廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08",
"content_type": "text",
"content": "廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08",
"metadata": {
"sequence_no": 1,
"training_category": "廉洁合规培训",
"training_date": "2026-06-08T00:00:00.000Z",
"training_target": "新员工",
"training_format": "现场培训",
"lead_department": "纪检办公室",
"lecturer": "张三",
"lecture_topic": "新员工廉洁合规入职培训",
"main_content": "围绕廉洁从业要求、合规底线和典型要求开展培训。",
"participant_count": 32,
"courseware_materials": [],
"sign_in_sheets": []
}
}
```
---
## 2. 类型写入协议
### 2.1 `text`
- 统一存储为字符串
- Excel 导入时,空单元格需要转换为空字符串,避免 `undefined`
- 若字段配置 `multiple: true`,统一存储为字符串数组;Excel 单元格中的英文逗号或中文逗号拼接值需要拆分、去空并去重
### 2.2 `Record.title`
- 党风廉政建设模块由前端根据当前台账对象核心字段自动生成
- 培训类建议使用 `培训类别 + 授课主题 + 时间`
- 谈话类建议使用 `姓名 + 年份 + 本年度计划谈话时间`
- 调研类建议使用 `调研单位 + 调研时间`
- 专题会议类建议使用 `会议议题 + 会议时间`
- 廉洁主题活动建议使用 `活动主题 + 开展年份`
- 节前廉洁提醒建议使用 `年份 + 重大节假日`
### 2.3 搜索与展示建议
- 列表主标题使用自动生成的 `title`
- 筛选区统一包含关键词、年份和当前分类关键字段
- 关键词检索应覆盖当前台账对象的核心文本字段
- 日期字段页面和 Excel 展示为 `YYYY-MM-DD`,保存为 ISO 8601 字符串
- 附件字段在列表中展示上传状态,完整文件列表在编辑弹窗维护
---
## 3. Subject Definitions
## 3.1 党风廉政建设
本模块使用 6 个独立 Subject 承载不同台账类型。每条台账记录对应一条 `Record`,业务字段全部写入 `metadata`,附件字段按多文件数组存储,结构为 `{ name, type, size, url }`。
统一约定:
- 日期字段页面和 Excel 展示为 `YYYY-MM-DD`,保存时前端转换为 ISO 8601 字符串
- Excel 导入不处理附件列,附件仅通过系统上传控件维护
- `title` 由前端按各对象核心字段自动生成,不提供单独标题字段
- `sequence_no` 仅用于台账顺序和历史 Excel 对照,不作为主键
- 当前阶段不做导入重复判断,Excel 导入全部新增
### 3.1.1 党风廉政培训记录
- **subject**: `integrity_party_training`
- **title 生成**:`培训类别 + 授课主题 + 时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 培训类别 | `training_category` | `select` | `null` | 否 | 否 | 是 | 否 | 否 | 选项:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训 |
| 时间 | `training_date` | `date` | `null` | 否 | 否 | 是 | 否 | 否 | 培训日期 |
| 培训对象 | `training_target` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 培训覆盖对象 |
| 培训形式 | `training_format` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 培训方式 |
| 牵头部门 | `lead_department` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 牵头组织部门 |
| 授课人 | `lecturer` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 授课人员 |
| 授课主题 | `lecture_topic` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 培训主题 |
| 主要内容 | `main_content` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 培训主要内容 |
| 参加人数 | `participant_count` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 实际参加人数 |
| 课件材料 | `courseware_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传的 PPT、PDF 等课件 |
| 签到表 | `sign_in_sheets` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传签到材料 |
### 3.1.2 党风廉政谈话记录
- **subject**: `integrity_party_talk`
- **title 生成**:`姓名 + 年份 + 本年度计划谈话时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 年份 | `year` | `number` | `null` | 否 | 否 | 是 | 否 | 否 | 台账年份 |
| 姓名 | `person_name` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 谈话对象姓名 |
| 所属一级部门 | `primary_department` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 一级部门名称 |
| 岗位名称 | `position_name` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 岗位名称 |
| 岗位晋升时间 | `promotion_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 岗位晋升日期 |
| 是否党员 | `is_party_member` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否领导干部 | `is_leader` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否投资序列 | `is_investment_sequence` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 最近一期谈话时间 | `latest_talk_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 最近一次谈话日期 |
| 本年度计划谈话时间 | `planned_talk_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 本年度计划谈话日期 |
| 是否应做任前谈话 | `should_pre_appointment_talk` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 以 OA 转岗流程或发文为准 |
| 是否已做任前谈话 | `has_pre_appointment_talk` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否纪委书记主动约谈 | `is_discipline_secretary_interview` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 报送口径字段 |
| 本年度实际谈话时间 | `actual_talk_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 本年度实际谈话日期 |
| 谈话支持材料 | `talk_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传谈话材料 |
### 3.1.3 党风廉政调研记录
- **subject**: `integrity_party_research`
- **title 生成**:`调研单位 + 调研时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 调研时间 | `research_date` | `date` | `null` | 否 | 否 | 是 | 否 | 否 | 调研日期 |
| 调研人员及职务 | `research_staff_and_position` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 调研人员和职务描述 |
| 调研单位 | `research_unit` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 被调研单位 |
| 调研内容 | `research_content` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 调研主要内容 |
| 调研收集的建议意见 | `survey_suggestions` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 调研过程中收集的建议意见 |
| 调研方案或调研报告 | `research_report_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传调研方案或报告 |
| 备注 | `remarks` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 备注信息 |
### 3.1.4 党风廉政专题会议记录
- **subject**: `integrity_party_meeting`
- **title 生成**:`会议议题 + 会议时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 会议时间 | `meeting_date` | `date` | `null` | 否 | 否 | 是 | 否 | 否 | 会议日期 |
| 会议议题 | `meeting_topic` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 会议议题 |
| 会议主要内容 | `main_content` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 会议主要内容 |
| 支持材料 | `supporting_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传会议支持材料 |
### 3.1.5 廉洁主题活动记录
- **subject**: `integrity_party_activity`
- **title 生成**:`活动主题 + 开展年份`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 开展年份 | `year` | `number` | `null` | 否 | 否 | 是 | 否 | 否 | 活动开展年份 |
| 活动主题 | `activity_theme` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 活动主题 |
| 活动形式 | `activity_format` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 活动形式 |
| 活动成果 | `activity_result` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 活动成果描述 |
| 支持材料 | `supporting_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传活动支持材料 |
### 3.1.6 节前廉洁提醒记录
- **subject**: `integrity_party_holiday_reminder`
- **title 生成**:`年份 + 重大节假日`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 年份 | `year` | `number` | `null` | 否 | 否 | 是 | 否 | 否 | 台账年份 |
| 重大节假日 | `holiday_name` | `select` | `null` | 否 | 否 | 是 | 否 | 否 | 选项:元旦、春节、清明、五一、端午、中秋、国庆、其他 |
| 其他节假日说明 | `holiday_other` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 选择其他时补充说明 |
| 廉洁谈话提纲上传 | `talk_outline_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传谈话提纲 |
| 廉洁提醒支持材料上传 | `reminder_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传提醒材料 |
| 其他活动举措 | `other_measures` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 其他举措说明 |
@import 'tailwindcss';
@theme {
--font-sans:
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
Arial,
sans-serif;
}
:root {
color-scheme: light;
}
import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress';
import authService from '@/services/authService';
// 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL;
function createRequestWithPrefix(prefix = baseUrl) {
const requestInstance = extend({
prefix,
timeout: 300000,
headers: {
Authorization: null,
},
});
requestInstance.use(progressMiddleware, { core: true });
requestInstance.interceptors.request.use((url, options) => {
// 使用 authService 获取 token,更加可靠
const token = authService.getToken();
const headers = {
...options.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return {
url,
options: {
...options,
headers,
},
};
});
requestInstance.interceptors.response.use(async (response) => {
if (response?.status === 401) {
// 使用 authService 清除用户信息
authService.clearUser();
console.warn('[request] authentication required (401)');
// 跳转到登录页(可选,根据业务需求调整)
location.href = '/login?redirect=' + encodeURIComponent(location.href);
} else if (response?.status === 403) {
authService.clearUser();
console.warn('[request] forbidden (403)');
} else if (response?.status >= 400) {
try {
const data = await response.clone().json();
console.error('[request] error', data);
throw new Error(data?.message || '请求失败');
} catch (error) {
console.error('[request] request failed', error);
throw error;
}
}
return response;
});
return requestInstance;
}
// 创建请求实例
const request = createRequestWithPrefix(baseUrl);
export { createRequestWithPrefix };
export default request;
import authService from '@/services/authService';
import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi';
export const UserContext = createContext(null);
function getStoredUser() {
const value = authService.getCurrentUser();
if (typeof value === 'string') {
return {
username: value,
};
}
return value;
}
export default function AuthWrapper() {
const location = useLocation();
const [loaded, setLoaded] = useState(false);
const [userInfo, setUserInfo] = useState(() => getStoredUser());
useEffect(() => {
let cancelled = false;
if (!authService.getToken() || !userInfo) {
setLoaded(true);
return;
}
(async () => {
try {
const profile = await getUserInfo();
if (cancelled) {
return;
}
const mergedUser = {
...userInfo,
...profile,
};
authService.setCurrentUser(mergedUser);
setUserInfo(mergedUser);
} catch (error) {
if (!cancelled) {
authService.clearUser();
setUserInfo(null);
}
} finally {
if (!cancelled) {
setLoaded(true);
}
}
})();
return () => {
cancelled = true;
};
}, []);
const value = useMemo(() => {
if (!userInfo) {
return null;
}
return {
...userInfo,
logout: async () => {
try {
if (userInfo?.username) {
await userLogout(userInfo.username);
}
} finally {
authService.clearUser();
history.push('/login');
}
},
};
}, [userInfo]);
if (!loaded) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
正在验证登录状态...
</div>
);
}
if (!userInfo || !authService.getToken()) {
return (
<Navigate
to={`/login?redirect=${encodeURIComponent(
location.pathname + location.search,
)}`}
replace
/>
);
}
return (
<UserContext.Provider value={value}>
<Outlet />
</UserContext.Provider>
);
}
import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import localeData from 'dayjs/plugin/localeData';
import weekday from 'dayjs/plugin/weekday';
import { createContext, useEffect, useState } from 'react';
import { Outlet } from 'umi';
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(customParseFormat);
dayjs.locale('zh-cn');
export const SiteContext = createContext(null);
export const MetadataContext = createContext(null);
export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
try {
const info = await getSystemInfo();
if (cancelled) {
return;
}
setSiteInfo({
...(info?.site || {}),
key: info?.key,
});
} catch (error) {
if (cancelled) {
return;
}
setLoadError(error?.message || '加载站点配置失败');
setSiteInfo({
name: '项目管理系统',
});
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
加载中...
</div>
);
}
return (
<SiteContext.Provider value={siteInfo}>
<MetadataContext.Provider
value={{
value: metadata,
update: setMetadata,
error: loadError,
}}
>
<Outlet />
</MetadataContext.Provider>
</SiteContext.Provider>
);
}
{
"extends": "./src/.umi/tsconfig.json"
}
import '@umijs/max/typings';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment