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 diff is collapsed.
{
"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 diff is collapsed.
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>
This diff is collapsed.
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>
);
}
This diff is collapsed.
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>
);
}
This diff is collapsed.
# 党风廉政建设流程文档
## 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 主键配置扩展覆盖或拦截逻辑
This diff is collapsed.
This diff is collapsed.
# 说明
当前应用已从 `project_manager_xy` 模板进一步演进为“党风廉政建设”,原项目管理流程文档不再作为本应用的主业务文档。
请改为阅读:
- `src/pages/project_manager_xy/integrity-party.business-flow.md`
保留本文件仅用于说明模板来源,避免后续开发继续误引用旧的“项目管理”或“材料中心”业务语义。
This diff is collapsed.
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;
This diff is collapsed.
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;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import '@umijs/max/typings';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment