Compare commits

...

14 Commits

Author SHA1 Message Date
iven
5ac8e18d74 fix(web): 修复 visible_when 表达式评估器 !=/||/&& 支持 + 添加 validation 前端校验
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- exprEvaluator: 新增 neq 类型修复 != 操作符被当作 == 处理的 bug
- exprEvaluator: 支持 || 和 && 作为 OR/AND 的别名
- PluginCRUDPage: 读取 field.validation.pattern 添加表单正则校验规则
2026-04-21 00:19:10 +08:00
iven
89fc482d99 feat(web): 采用 UI UX Pro Max Soft UI Evolution 设计系统
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
从 Pinterest 风格切换到 Soft UI Evolution 设计系统,使用 UI UX Pro Max
推理引擎生成适合跨行业 ERP 业务用户的专业设计方案。

设计变更:
- 主色从 Pinterest Red (#e60023) 切换到 Trust Blue (#2563EB)
- 字体从系统默认切换到 Noto Sans SC(中文优先)
- 圆角从 16-20px 调整到 10-12px(专业但不夸张)
- 中性色从暖橄榄调切换到 Slate 石板蓝调
- 成功色 #103c25 → #059669,警告色 #b56e1a → #d97706
- 暗色模式从暖黑 (#1a1a18) 切换到深海军蓝 (#0f172a)

涉及文件:DESIGN.md + index.css + App.tsx + 24 个组件文件
2026-04-20 23:27:24 +08:00
iven
85e732cf12 feat(web): 从 Notion 风格切换到 Pinterest 设计系统
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 替换 DESIGN.md 为 Pinterest 设计规格(暖色调、红色主题、大圆角)
- 更新 CSS 变量:主色 #0075de→#e60023, 圆角 4px→16px, 背景 #f6f5f4→#f6f6f3
- 更新 Ant Design 主题令牌:更大圆角、Pinterest 色板、更大触控目标
- 批量更新 24 个页面/组件文件中的硬编码颜色值
- 暗色模式同步适配 Pinterest 暖色调暗色方案
2026-04-20 22:13:20 +08:00
iven
8f3d2d58e7 feat(web): 采用 Notion 设计系统 — 暖色调 + 白色侧边栏 + Inter 字体
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
引入 Notion 风格的 DESIGN.md 设计系统文件,并全面重构前端 UI:

- 主色从 Indigo (#4F46E5) 迁移到 Notion Blue (#0075de)
- 页面背景从冷灰 (#F1F5F9) 迁移到暖白 (#f6f5f4)
- 侧边栏从深色 (#0F172A) 迁移到白色,活跃项用蓝色指示
- 文字从 Slate 冷色迁移到暖灰系列 (Warm Gray 500/300)
- 圆角从 8px 缩小到 4px(按钮/输入),8px(卡片)
- 阴影改为多层超轻 Notion 风格(最大 opacity 0.05)
- 字体优先使用 Inter,保留中文回退
- 暗色模式适配暖黑色调 (#191918)
- 更新 27 个前端文件的硬编码颜色值
2026-04-20 13:08:22 +08:00
iven
40b37cc776 feat(plugin,freelance,itops,web): P5-P6 dashboard widgets 平台扩展 + 仪表盘声明
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
P5 平台扩展:
- manifest.rs: Dashboard 变体新增 widgets 字段
- manifest.rs: 定义 PluginWidget/StatCard/ActionQuery 类型
- 前端: 扩展 DashboardWidget 类型支持 stat_cards/action_list/funnel/card_list
- 前端: 新增 4 个 widget 渲染器 (StatCardsWidget/ActionListWidget/FunnelStageWidget/CardListWidget)
- 前端: PluginDashboardPage widget 数据加载支持新类型

P6 仪表盘 widgets:
- freelance: 工作台仪表盘 4 个 widgets (财务概览/紧急待办/商机漏斗/活跃项目)
- itops: 新增运维概览仪表盘 2 个 widgets (运维概览/紧急待办)
2026-04-20 09:35:27 +08:00
iven
301178067c feat(freelance,itops): P1-P4 智能业务引擎 + PDF 模板
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
freelance (P1+P3):
- settings: 7 配置项 (公司/财务/提醒)
- trigger_events: 5 个触发事件 (商机/合同/发票/任务/支出)
- cascade: 5 处级联过滤 (合同→商机/报价, 发票→项目/合同, 工时→任务)
- visible_when: 4 处条件显示 (收款日期/已付金额/实际工时/总金额)
- validation: 2 处校验 (手机号/邮箱)
- templates: 3 个 PDF 模板 (报价单/发票/合同)

itops (P2+P4):
- settings: 4 配置项 (SLA/提醒)
- trigger_events: 4 个触发事件 (工单/合同/巡检)
- cascade: 2 处级联过滤 (工单→合同, 巡检记录→合同)
- visible_when: 6 处条件显示 (解决方案/响应时间/解决时间/关闭时间/问题/措施)
- validation: 1 处校验 (合同编号格式)
- templates: 1 个 PDF 模板 (维保合同)
2026-04-20 09:15:57 +08:00
iven
7e063a7e88 docs(plan): freelance/itops 插件增强实施计划 — P1-P6 六阶段
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P1-P4 纯 plugin.toml(settings/trigger_events/cascade/visible_when/validation/templates)
P5 平台 manifest.rs + 前端 widgets 扩展
P6 两个插件仪表盘 widgets 声明
2026-04-20 07:14:30 +08:00
iven
bcc6662add docs(spec): 修复 spec 审查问题 — cascade/validation 标注为修改已有字段,dashboard 标注平台依赖
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- CRITICAL: 明确 dashboard widgets 需要平台 manifest.rs 扩展
- HIGH: cascade/validation/visible_when 均改为"已有字段追加属性"
- HIGH: visible_when 提供完整 TOML 语法
- MEDIUM: 模板引擎说明(Handlebars)和关系解析机制
- MEDIUM: itops validation 补充字段上下文
- MEDIUM: itops dashboard 明确插入位置
2026-04-20 00:50:29 +08:00
iven
f4afc969bd docs(spec): 自由职业者/IT运维插件增强设计规格
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
三层增强方案:智能业务引擎 + 仪表盘重构 + 专业输出
纯插件层实现,无需修改平台代码
2026-04-20 00:43:34 +08:00
iven
74378a7575 feat(itops): 编译 WASM 并验证安装 — 4 实体/8 权限已创建
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
2026-04-20 00:10:35 +08:00
iven
59339c2929 feat(itops): 创建 IT 运维服务台插件 — 4 实体/8 权限/4 页面 2026-04-20 00:09:24 +08:00
iven
fc76793d6d feat(freelance): 编译 WASM 并验证安装 — 10 实体/20 权限已创建 2026-04-19 23:59:03 +08:00
iven
813df3688c feat(freelance): 添加 plugin.toml — 10 实体/20 权限/7 页面 2026-04-19 23:49:40 +08:00
iven
fcf20dded1 feat(freelance): 创建插件 crate 骨架 2026-04-19 23:45:43 +08:00
54 changed files with 8513 additions and 591 deletions

View File

@@ -1,9 +1,21 @@
Compiling erp-plugin v0.1.0 (G:\erp\crates\erp-plugin) warning: unused import: `PluginError`
Compiling erp-server-migration v0.1.0 (G:\erp\crates\erp-server\migration) --> crates\erp-plugin\src\plugin_validator.rs:1:20
warning: field `chk` is never read
--> crates\erp-plugin\src\data_service.rs:376:39
| |
376 | struct RefCheck { chk: Option<i32> } 1 | use crate::error::{PluginError, PluginResult};
| ^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `parse_manifest`
--> crates\erp-plugin\src\plugin_validator.rs:2:23
|
2 | use crate::manifest::{parse_manifest, PluginManifest};
| ^^^^^^^^^^^^^^
warning: field `chk` is never read
--> crates\erp-plugin\src\data_service.rs:445:39
|
445 | struct RefCheck { chk: Option<i32> }
| -------- ^^^ | -------- ^^^
| | | |
| field in this struct | field in this struct
@@ -11,24 +23,23 @@ warning: field `chk` is never read
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: field `chk` is never read warning: field `chk` is never read
--> crates\erp-plugin\src\data_service.rs:477:51 --> crates\erp-plugin\src\data_service.rs:684:51
| |
477 | ... struct RefCheck { chk: Option<i32> } 684 | ... struct RefCheck { chk: Option<i32> }
| -------- ^^^ | -------- ^^^
| | | |
| field in this struct | field in this struct
warning: field `check_result` is never read warning: field `check_result` is never read
--> crates\erp-plugin\src\data_service.rs:1122:30 --> crates\erp-plugin\src\data_service.rs:1329:30
| |
1122 | struct ExistsCheck { check_result: Option<i32> } 1329 | struct ExistsCheck { check_result: Option<i32> }
| ----------- ^^^^^^^^^^^^ | ----------- ^^^^^^^^^^^^
| | | |
| field in this struct | field in this struct
warning: `erp-plugin` (lib) generated 3 warnings warning: `erp-plugin` (lib) generated 5 warnings (run `cargo fix --lib -p erp-plugin` to apply 2 suggestions)
Compiling erp-server v0.1.0 (G:\erp\crates\erp-server) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 45s
Running `target\debug\erp-server.exe` Running `target\debug\erp-server.exe`
Error: configuration file "config/default" not found Error: configuration file "config/default" not found
error: process didn't exit successfully: `target\debug\erp-server.exe` (exit code: 1) error: process didn't exit successfully: `target\debug\erp-server.exe` (exit code: 1)

1
.logs/backend.pid Normal file
View File

@@ -0,0 +1 @@
10056

View File

@@ -1,187 +0,0 @@
08:34:12 [vite] (client) [console.error] Warning: [antd: Modal] `destroyOnClose` is deprecated. Please use `destroyOnHidden` instead.
08:34:38 [vite] (client) [console.error] Warning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?
08:38:05 [vite] http proxy error: /api/v1/messages/unread-count
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:38:05 [vite] http proxy error: /api/v1/messages?page=1&page_size=5
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:39:35 [vite] (client) [console.error] Warning: [antd: Modal] `destroyOnClose` is deprecated. Please use `destroyOnHidden` instead.
08:39:54 [vite] (client) [console.error] Warning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?
08:43:34 [vite] http proxy error: /api/v1/messages/unread-count
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:43:35 [vite] http proxy error: /api/v1/messages?page=1&page_size=5
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:44:34 [vite] http proxy error: /api/v1/messages/unread-count
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:44:34 [vite] http proxy error: /api/v1/messages?page=1&page_size=5
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:33 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:33 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/product?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:33 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/product/resolve-labels
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:33 [vite] (client) [console.error] Warning: [antd: message] Static function can not consume context like dynamic theme. Please use 'App' component instead.
08:45:34 [vite] http proxy error: /api/v1/messages/unread-count
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:34 [vite] http proxy error: /api/v1/messages?page=1&page_size=5
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/warehouse?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/warehouse/resolve-labels
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/stock?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/stock/resolve-labels
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/supplier?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:35 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/supplier/resolve-labels
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:37 [vite] http proxy error: /api/v1/messages?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:38 [vite] http proxy error: /api/v1/workflow/definitions?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:38 [vite] http proxy error: /api/v1/workflow/definitions?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:38 [vite] (client) [Unhandled rejection] AxiosError: Request failed with status code 502
 > settle node_modules/.pnpm/axios@1.15.0/node_modules/axios/lib/core/settle.js:20:6
> XMLHttpRequest.onloadend node_modules/.pnpm/axios@1.15.0/node_modules/axios/lib/adapters/xhr.js:62:8
> Axios$1.request node_modules/.pnpm/axios@1.15.0/node_modules/axios/lib/core/Axios.js:46:40
> listProcessDefinitions src/api/workflowDefinitions.ts:54:19
52 |
53 | export async function listProcessDefinitions(page = 1, pageSize = 20) {
54 | const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessDefinitionInfo> }>(
| ^
55 | '/workflow/definitions',
56 | { params: { page, page_size: pageSize } },
> src/pages/workflow/ProcessDefinitions.tsx:34:18
08:45:38 [vite] (client) [Unhandled rejection] AxiosError: Request failed with status code 502
 > settle node_modules/.pnpm/axios@1.15.0/node_modules/axios/lib/core/settle.js:20:6
> XMLHttpRequest.onloadend node_modules/.pnpm/axios@1.15.0/node_modules/axios/lib/adapters/xhr.js:62:8
> Axios$1.request node_modules/.pnpm/axios@1.15.0/node_modules/axios/lib/core/Axios.js:46:40
> listProcessDefinitions src/api/workflowDefinitions.ts:54:19
52 |
53 | export async function listProcessDefinitions(page = 1, pageSize = 20) {
54 | const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessDefinitionInfo> }>(
| ^
55 | '/workflow/definitions',
56 | { params: { page, page_size: pageSize } },
> src/pages/workflow/ProcessDefinitions.tsx:34:18
08:45:39 [vite] http proxy error: /api/v1/organizations
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:39 [vite] http proxy error: /api/v1/organizations
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:39 [vite] http proxy error: /api/v1/roles?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:39 [vite] http proxy error: /api/v1/permissions
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:39 [vite] http proxy error: /api/v1/roles?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:39 [vite] http proxy error: /api/v1/permissions
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:41 [vite] http proxy error: /api/v1/users?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:41 [vite] http proxy error: /api/v1/roles?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:41 [vite] http proxy error: /api/v1/users?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:41 [vite] http proxy error: /api/v1/roles?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:42 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:42 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/purchase_order?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:42 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:42 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/purchase_order?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:43 [vite] http proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)
08:45:43 [vite] http proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/sales_order?page=1&page_size=20
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1142:49)
at afterConnectMultiple (node:net:1723:7)

View File

@@ -2,10 +2,9 @@
> web@0.0.0 dev G:\erp\apps\web > web@0.0.0 dev G:\erp\apps\web
> vite "--" "--strictPort" > vite "--" "--strictPort"
Port 5174 is in use, trying another one...
VITE v8.0.8 ready in 372 ms VITE v8.0.8 ready in 316 ms
➜ Local: http://localhost:5174/ ➜ Local: http://localhost:5175/
 ➜ Network: use --host to expose  ➜ Network: use --host to expose
08:36:42 [vite] (client) hmr update /src/index.css, /src/pages/PluginCRUDPage.tsx, /src/components/EntitySelect.tsx
08:37:13 [vite] (client) hmr update /src/index.css, /src/components/EntitySelect.tsx

View File

@@ -1 +1 @@
45128 50960

14
Cargo.lock generated
View File

@@ -1309,6 +1309,13 @@ dependencies = [
"wit-bindgen 0.55.0", "wit-bindgen 0.55.0",
] ]
[[package]]
name = "erp-plugin-freelance"
version = "0.1.0"
dependencies = [
"wit-bindgen 0.55.0",
]
[[package]] [[package]]
name = "erp-plugin-inventory" name = "erp-plugin-inventory"
version = "0.1.0" version = "0.1.0"
@@ -1318,6 +1325,13 @@ dependencies = [
"wit-bindgen 0.55.0", "wit-bindgen 0.55.0",
] ]
[[package]]
name = "erp-plugin-itops"
version = "0.1.0"
dependencies = [
"wit-bindgen 0.55.0",
]
[[package]] [[package]]
name = "erp-plugin-prototype" name = "erp-plugin-prototype"
version = "0.1.0" version = "0.1.0"

View File

@@ -13,6 +13,8 @@ members = [
"crates/erp-plugin", "crates/erp-plugin",
"crates/erp-plugin-crm", "crates/erp-plugin-crm",
"crates/erp-plugin-inventory", "crates/erp-plugin-inventory",
"crates/erp-plugin-freelance",
"crates/erp-plugin-itops",
] ]
[workspace.package] [workspace.package]

273
DESIGN.md Normal file
View File

@@ -0,0 +1,273 @@
# Design System — Enterprise ERP Platform
> Generated by UI UX Pro Max | Style: Soft UI Evolution | Target: Cross-industry business users
## 1. Visual Theme & Atmosphere
A warm, professional, and approachable design that feels trustworthy for business users across all industries — manufacturing, retail, finance, services, and more. The design uses a clean white canvas with soft blue as the primary brand color, conveying reliability and clarity without feeling cold or corporate.
The Soft UI Evolution style provides subtle depth through improved shadows — softer than flat design but clearer than neumorphism. This creates a sense of modern polish that invites interaction while maintaining excellent readability and accessibility (WCAG AA+).
**Key Characteristics:**
- Clean white canvas with soft blue accent — trustworthy, professional, warm
- Subtle depth through soft shadows — modern but not flat, not skeuomorphic
- Moderate border-radius (10-12px) — friendly but not playful
- Chinese-first typography with Noto Sans SC — readable at all sizes
- Two-tier token system: CSS Variables (`--erp-*`) + Ant Design ConfigProvider
- Dual theme support: light (default) + dark mode
## 2. Color Palette & Roles
### Primary Brand
- **Primary Blue** (`#2563EB`): Primary CTA, active states, links, brand accent
- **Primary Hover** (`#1D4ED8`): Pressed/active primary state
- **Primary Light** (`#EFF6FF`): Primary background tint, subtle highlights
- **Primary Subtle** (`#DBEAFE`): Light blue backgrounds for badges, chips
### Semantic Colors
- **Success Green** (`#059669`): Success states, positive indicators, confirmations
- **Success Light** (`#ECFDF5`): Success background tint
- **Warning Amber** (`#D97706`): Warnings, pending states, attention needed
- **Warning Light** (`#FFFBEB`): Warning background tint
- **Error Red** (`#DC2626`): Errors, destructive actions, required fields
- **Error Light** (`#FEF2F2`): Error background tint
- **Info Blue** (`#0284C7`): Informational elements, tooltips, help text
### Text
- **Primary Text** (`#0F172A`): Headings, primary body text — deep navy, professional
- **Secondary Text** (`#475569`): Descriptions, labels, helper text
- **Tertiary Text** (`#94A3B8`): Placeholders, disabled text, timestamps
- **Inverse Text** (`#FFFFFF`): Text on colored/dark surfaces
### Surface & Border
- **Page Background** (`#F8FAFC`): App background — cool off-white
- **Container Background** (`#FFFFFF`): Cards, panels, modals
- **Elevated Background** (`#FFFFFF`): Dropdowns, popovers, tooltips
- **Border** (`#E2E8F0`): Default borders, dividers
- **Border Strong** (`#CBD5E1`): Emphasized borders, active card outlines
- **Hover Background** (`#F1F5F9`): Row hover, item hover backgrounds
- **Muted Background** (`#F1F5F9`): Subtle section backgrounds, table headers
### Dark Mode
- **Dark Page** (`#0F172A`): Dark app background
- **Dark Container** (`#1E293B`): Dark cards, panels
- **Dark Elevated** (`#334155`): Dark dropdowns, popovers
- **Dark Border** (`#334155`): Dark borders
- **Dark Hover** (`#1E293B`): Dark row hover
- **Dark Text Primary** (`#F8FAFC`): Dark mode primary text
- **Dark Text Secondary** (`#94A3B8`): Dark mode secondary text
## 3. Typography Rules
### Font Family
- **Primary**: `Noto Sans SC`, fallbacks: `-apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif`
- **Monospace (optional)**: `'JetBrains Mono', 'Fira Code', Consolas, monospace`
### Hierarchy
| Role | Size | Weight | Line Height | Usage |
|------|------|--------|-------------|-------|
| Page Title | 24px (1.5rem) | 700 | 1.3 | Page headings, dialog titles |
| Section Title | 20px (1.25rem) | 600 | 1.4 | Section headings, card titles |
| Subsection | 16px (1rem) | 600 | 1.5 | Subsection labels, form group titles |
| Body | 14px (0.875rem) | 400 | 1.6 | Standard body text, table cells, descriptions |
| Caption | 12px (0.75rem) | 400 | 1.5 | Timestamps, badges, helper text, metadata |
### Principles
- **Chinese-first**: Noto Sans SC ensures excellent CJK rendering
- **Readable weights**: 400 for body, 600-700 for headings — always substantial, never thin
- **Generous line-height**: 1.5-1.6 for body text ensures comfortable reading
- **Moderate scale**: 12-24px range creates a compact, professional information hierarchy
## 4. Component Stylings
### Buttons
- **Primary**: Blue (#2563EB) background, white text, 10px radius, soft shadow
- **Secondary**: White background, slate border (#CBD5E1), dark text, 10px radius
- **Ghost**: Transparent background, primary blue text, no border
- **Danger**: Red (#DC2626) background, white text, for destructive actions
- **All buttons**: Min height 36px, 40px preferred; smooth hover transition 200ms
### Cards & Containers
- White background, 12px radius, soft shadow: `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)`
- Hover: Slightly elevated shadow: `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)`
- No thick borders — shadows and subtle background differences create depth
### Inputs
- White background, 1px solid #CBD5E1 border, 10px radius
- Focus: Blue ring (2px solid #2563EB) with outer glow
- Error: Red border + error message below field
- Height: 40px standard, 32px small
### Tables
- Header: #F1F5F9 background, #475569 text, 14px weight 600
- Row hover: #F1F5F9 background
- Cell padding: 16px vertical, 12px horizontal
- Border: Bottom-only using #E2E8F0
### Navigation / Sidebar
- Background: White (#FFFFFF) with right border #E2E8F0
- Active item: #EFF6FF background, #2563EB text, left accent bar (3px)
- Hover item: #F1F5F9 background
- Item height: 40px, 12px radius
- Icons: 20px, inline with label text
### Tags / Badges
- Small radius (6px), medium padding (4px 8px)
- Color-coded backgrounds: blue tint, green tint, amber tint, red tint
- Text matches semantic color (darker shade than background)
### Modals / Dialogs
- 16px radius, generous padding (24px)
- Soft elevated shadow: `0 8px 30px rgba(0,0,0,0.12)`
- Header with title, footer with action buttons
## 5. Layout Principles
### Spacing System
- Base unit: 4px
- Scale: 4px, 8px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px
- Content padding: 24px standard, 16px compact
### Layout
- Fixed sidebar: 240px wide, collapsible to 72px
- Sticky header: 56px
- Content area: Fluid width with max comfortable reading width
- Standard CRUD table/list views for data management
### Border Radius Scale
- Small (6px): Tags, badges, small elements
- Standard (10px): Buttons, inputs, cards
- Large (12px): Panels, modals, large containers
- Full (50%): Circular avatars, icon buttons
## 6. Depth & Elevation
| Level | Shadow | Use |
|-------|--------|-----|
| Level 0 (Flat) | None | Page background, sidebar |
| Level 1 (Subtle) | `0 1px 2px rgba(0,0,0,0.05)` | Cards, form sections |
| Level 2 (Default) | `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)` | Elevated cards, dropdowns |
| Level 3 (Elevated) | `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)` | Hover states, active cards |
| Level 4 (Modal) | `0 8px 30px rgba(0,0,0,0.12)` | Modals, overlays |
## 7. Interactive States
### Hover
- Background color shift: `#F1F5F9` for neutral, semantic tint for colored elements
- Transition: 200ms ease
- Cursor: `pointer` on clickable elements
### Focus
- 2px solid ring using `#2563EB`
- 2px offset for visibility
- Always visible for keyboard navigation
### Active / Pressed
- Slightly darker shade of the element's color
- Brief scale or shadow change for tactile feedback
### Disabled
- Reduced opacity (0.5)
- No hover effects
- Cursor: `not-allowed`
### Loading
- Spinner or skeleton placeholder
- Disabled interactions during async operations
## 8. Do's and Don'ts
### Do
- Use soft shadows for depth — the Soft UI Evolution identity
- Apply Primary Blue (#2563EB) only for primary actions and active states
- Use Noto Sans SC for consistent Chinese rendering
- Apply 10-12px radius — friendly but professional
- Use semantic color tints for status backgrounds
- Keep tables clean with subtle header differentiation
- Ensure 4.5:1 contrast ratio for all text
### Don't
- Don't use heavy or dramatic shadows — keep depth subtle
- Don't use pure black (#000000) for text — use #0F172A instead
- Don't use pill-shaped buttons — 10px radius is rounded but not pill
- Don't mix warm and cool neutrals — stay in the slate family
- Don't use emojis as icons — use SVG icons (Lucide/Heroicons)
- Don't use decorative-only animations — every animation must serve a purpose
- Don't use colors as the sole means of conveying information
## 9. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile | <576px | Sidebar collapsed, single column |
| Tablet | 576-768px | Sidebar collapsed, 2-column grid |
| Desktop Small | 768-1024px | Sidebar expanded, responsive grid |
| Desktop | 1024-1440px | Full layout |
| Large Desktop | >1440px | Maximum content width |
### Collapsing Strategy
- Sidebar: 240px → 72px (icon only) on mobile/tablet
- Tables: Horizontal scroll on narrow screens
- Forms: Single column on mobile, multi-column on desktop
- Cards: Full-width stack → grid layout
## 10. ERP Platform Adaptations
### Layout
- Fixed sidebar navigation (240px wide, collapsible to 72px)
- Sticky header (56px) with search, notifications, user menu
- Content area uses CSS Grid for responsive multi-column dashboards
- Standard CRUD table/list views replace masonry grid for data management
### Color Adaptations
- Primary Blue (#2563EB) for primary actions (Save, Create, Submit)
- Success Green (#059669) for positive states (Approved, Completed, Paid)
- Warning Amber (#D97706) for pending states (Pending Review, Awaiting)
- Error Red (#DC2626) for destructive/error states (Rejected, Failed, Overdue)
- Info Blue (#0284C7) for informational elements (Tips, Help, Documentation)
### Component Adaptations
- **Tables**: Slate header bg (#F1F5F9), generous cell padding, subtle hover (#F1F5F9)
- **Forms**: 10px radius inputs, slate borders (#CBD5E1), generous spacing
- **Cards**: 12px radius, soft shadow, white background
- **Sidebar**: Blue active states (#EFF6FF bg), slate hover, 12px radius items
- **Tags/Badges**: 6px radius, semantic color tints (blue/green/amber/red)
- **Modals**: 16px radius, elevated shadow, generous padding
### Dark Mode
- Background: #0F172A (deep navy)
- Container: #1E293B (dark slate)
- Elevated: #334155 (medium slate)
- Border: #334155
- Active accent: #3B82F6 (lighter blue for dark backgrounds)
- Text: #F8FAFC primary, #94A3B8 secondary
## 11. Agent Prompt Guide
### Quick Color Reference
- Brand: Primary Blue (#2563EB)
- Background: Cool Off-White (#F8FAFC)
- Text: Deep Navy (#0F172A)
- Secondary text: Slate (#475569)
- Border: Light Slate (#E2E8F0)
- Success: Green (#059669)
- Warning: Amber (#D97706)
- Error: Red (#DC2626)
- Focus: Blue (#2563EB)
### Example Component Prompts
- "Create a card: white background, 12px radius, soft shadow (0 1px 3px rgba(0,0,0,0.06)). Title in 16px weight 600 #0F172A. Body in 14px #475569."
- "Design a primary button: #2563EB background, white text, 10px radius, 8px 16px padding. Hover: #1D4ED8. Focus: 2px solid #2563EB ring."
- "Build a table: header #F1F5F9 background, #475569 text 14px weight 600. Row hover #F1F5F9. Cell padding 16px 12px. Border bottom #E2E8F0."
- "Create a sidebar: white background, right border #E2E8F0. Active item: #EFF6FF background, #2563EB text, 3px left accent bar. Hover: #F1F5F9."
- "Design a modal: 16px radius, shadow 0 8px 30px rgba(0,0,0,0.12). Title 20px weight 600. Footer with primary blue CTA."
### Iteration Guide
1. Soft shadows everywhere — subtle depth is the identity
2. Primary Blue for CTAs and active states — trustworthy, not overwhelming
3. 10px radius on buttons/inputs, 12px on cards — friendly but professional
4. Noto Sans SC is the primary font — Chinese-first, readable at all sizes
5. Slate neutrals — never pure black or pure gray, always with blue undertone
6. Semantic tints for status — green/amber/red backgrounds with matching text

View File

@@ -31,44 +31,44 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
const themeConfig = { const themeConfig = {
token: { token: {
colorPrimary: '#4F46E5', colorPrimary: '#2563eb',
colorSuccess: '#059669', colorSuccess: '#059669',
colorWarning: '#D97706', colorWarning: '#d97706',
colorError: '#DC2626', colorError: '#dc2626',
colorInfo: '#2563EB', colorInfo: '#0284c7',
colorBgLayout: '#F1F5F9', colorBgLayout: '#f8fafc',
colorBgContainer: '#FFFFFF', colorBgContainer: '#ffffff',
colorBgElevated: '#FFFFFF', colorBgElevated: '#ffffff',
colorBorder: '#E2E8F0', colorBorder: '#e2e8f0',
colorBorderSecondary: '#F1F5F9', colorBorderSecondary: '#f1f5f9',
borderRadius: 8, borderRadius: 10,
borderRadiusLG: 12, borderRadiusLG: 12,
borderRadiusSM: 6, borderRadiusSM: 6,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif", fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
fontSize: 14, fontSize: 14,
fontSizeHeading4: 20, fontSizeHeading4: 20,
controlHeight: 36, controlHeight: 40,
controlHeightLG: 40, controlHeightLG: 44,
controlHeightSM: 28, controlHeightSM: 32,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06)', boxShadow: 'none',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07)', boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
}, },
components: { components: {
Button: { Button: {
primaryShadow: '0 1px 2px 0 rgba(79, 70, 229, 0.3)', primaryShadow: 'none',
fontWeight: 500, fontWeight: 500,
}, },
Card: { Card: {
paddingLG: 20, paddingLG: 20,
}, },
Table: { Table: {
headerBg: '#F8FAFC', headerBg: '#f1f5f9',
headerColor: '#475569', headerColor: '#475569',
rowHoverBg: '#F5F3FF', rowHoverBg: '#f1f5f9',
fontSize: 14, fontSize: 14,
}, },
Menu: { Menu: {
itemBorderRadius: 8, itemBorderRadius: 10,
itemMarginInline: 8, itemMarginInline: 8,
itemHeight: 40, itemHeight: 40,
}, },
@@ -85,20 +85,20 @@ const darkThemeConfig = {
...themeConfig, ...themeConfig,
token: { token: {
...themeConfig.token, ...themeConfig.token,
colorBgLayout: '#0B0F1A', colorBgLayout: '#0f172a',
colorBgContainer: '#111827', colorBgContainer: '#1e293b',
colorBgElevated: '#1E293B', colorBgElevated: '#334155',
colorBorder: '#1E293B', colorBorder: '#334155',
colorBorderSecondary: '#1E293B', colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3)', boxShadow: 'none',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4)', boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
}, },
components: { components: {
...themeConfig.components, ...themeConfig.components,
Table: { Table: {
headerBg: '#1E293B', headerBg: '#1e293b',
headerColor: '#94A3B8', headerColor: '#94a3b8',
rowHoverBg: '#1E293B', rowHoverBg: '#1e293b',
}, },
}, },
}; };

View File

@@ -199,7 +199,8 @@ export type PluginPageSchema =
}; };
export interface DashboardWidget { export interface DashboardWidget {
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'; type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'
| 'stat_cards' | 'action_list' | 'funnel' | 'card_list';
entity: string; entity: string;
title: string; title: string;
icon?: string; icon?: string;
@@ -207,6 +208,44 @@ export interface DashboardWidget {
dimension_field?: string; dimension_field?: string;
dimension_order?: string[]; dimension_order?: string[];
metric?: string; metric?: string;
// stat_cards
cards?: StatCardDef[];
// action_list
max_items?: number;
queries?: ActionQueryDef[];
// funnel
lane_field?: string;
value_field?: string;
lane_order?: string[];
// card_list
filter?: string;
title_field?: string;
subtitle_field?: string;
tags?: string[];
label?: string;
label_field?: string;
action?: string;
sort?: string;
}
export interface StatCardDef {
entity: string;
aggregate?: string;
field?: string;
filter?: string;
label: string;
icon?: string;
color?: string;
}
export interface ActionQueryDef {
entity: string;
filter?: string;
sort?: string;
label_field: string;
subtitle_field?: string;
action: string;
icon?: string;
} }
export type PluginSectionSchema = export type PluginSectionSchema =

View File

@@ -50,7 +50,7 @@ export default function NotificationPanel() {
<Button <Button
type="text" type="text"
size="small" size="small"
style={{ fontSize: 12, color: '#4F46E5' }} style={{ fontSize: 12, color: '#2563eb' }}
onClick={() => navigate('/messages')} onClick={() => navigate('/messages')}
> >
@@ -76,7 +76,7 @@ export default function NotificationPanel() {
cursor: 'pointer', cursor: 'pointer',
transition: 'background 0.15s ease', transition: 'background 0.15s ease',
border: 'none', border: 'none',
background: !item.is_read ? (isDark ? '#1E293B' : '#F5F3FF') : 'transparent', background: !item.is_read ? (isDark ? '#0f172a' : '#eff6ff') : 'transparent',
}} }}
onClick={() => { onClick={() => {
if (!item.is_read) { if (!item.is_read) {
@@ -85,7 +85,7 @@ export default function NotificationPanel() {
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (item.is_read) { if (item.is_read) {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F8FAFC'; e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
@@ -109,7 +109,7 @@ export default function NotificationPanel() {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: '50%', borderRadius: '50%',
background: '#4F46E5', background: '#2563eb',
flexShrink: 0, flexShrink: 0,
}} /> }} />
)} )}
@@ -132,12 +132,12 @@ export default function NotificationPanel() {
textAlign: 'center', textAlign: 'center',
paddingTop: 8, paddingTop: 8,
marginTop: 4, marginTop: 4,
borderTop: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderTop: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}> }}>
<Button <Button
type="text" type="text"
onClick={() => navigate('/messages')} onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#4F46E5', fontWeight: 500 }} style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
> >
</Button> </Button>
@@ -166,7 +166,7 @@ export default function NotificationPanel() {
position: 'relative', position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F1F5F9'; e.currentTarget.style.background = isDark ? '#0f172a' : '#f8fafc';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'; e.currentTarget.style.background = 'transparent';
@@ -175,7 +175,7 @@ export default function NotificationPanel() {
<Badge count={unreadCount} size="small" offset={[4, -4]}> <Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{ <BellOutlined style={{
fontSize: 16, fontSize: 16,
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
}} /> }} />
</Badge> </Badge>
</div> </div>

View File

@@ -2,65 +2,66 @@
/* ==================================================================== /* ====================================================================
* ERP Platform — Design System Tokens & Global Styles * ERP Platform — Design System Tokens & Global Styles
* Inspired by Linear, Feishu, SAP Fiori modern design language * Soft UI Evolution: Professional, warm, accessible for all industries
* Generated by UI UX Pro Max
* ==================================================================== */ * ==================================================================== */
/* --- Design Tokens (CSS Custom Properties) --- */ /* --- Design Tokens (CSS Custom Properties) --- */
:root { :root {
/* Primary Palette */ /* Primary Palette — Trust Blue */
--erp-primary: #4F46E5; --erp-primary: #2563eb;
--erp-primary-hover: #4338CA; --erp-primary-hover: #1d4ed8;
--erp-primary-active: #3730A3; --erp-primary-active: #1e40af;
--erp-primary-light: #EEF2FF; --erp-primary-light: #eff6ff;
--erp-primary-light-hover: #E0E7FF; --erp-primary-light-hover: #dbeafe;
--erp-primary-bg-subtle: #F5F3FF; --erp-primary-bg-subtle: #eff6ff;
/* Semantic Colors */ /* Semantic Colors — Professional slate tones */
--erp-success: #059669; --erp-success: #059669;
--erp-success-bg: #ECFDF5; --erp-success-bg: #ecfdf5;
--erp-warning: #D97706; --erp-warning: #d97706;
--erp-warning-bg: #FFFBEB; --erp-warning-bg: #fffbeb;
--erp-error: #DC2626; --erp-error: #dc2626;
--erp-error-bg: #FEF2F2; --erp-error-bg: #fef2f2;
--erp-info: #2563EB; --erp-info: #0284c7;
--erp-info-bg: #EFF6FF; --erp-info-bg: #f0f9ff;
/* Neutral Palette */ /* Neutral Palette — Slate neutrals with blue undertones */
--erp-bg-page: #F1F5F9; --erp-bg-page: #f8fafc;
--erp-bg-container: #FFFFFF; --erp-bg-container: #ffffff;
--erp-bg-elevated: #FFFFFF; --erp-bg-elevated: #ffffff;
--erp-bg-spotlight: #F8FAFC; --erp-bg-spotlight: #f1f5f9;
--erp-bg-sidebar: #0F172A; --erp-bg-sidebar: #ffffff;
--erp-bg-sidebar-hover: #1E293B; --erp-bg-sidebar-hover: #f1f5f9;
--erp-bg-sidebar-active: rgba(79, 70, 229, 0.15); --erp-bg-sidebar-active: #eff6ff;
/* Text Colors */ /* Text Colors — Deep navy */
--erp-text-primary: #0F172A; --erp-text-primary: #0f172a;
--erp-text-secondary: #475569; --erp-text-secondary: #475569;
--erp-text-tertiary: #94A3B8; --erp-text-tertiary: #94a3b8;
--erp-text-inverse: #F8FAFC; --erp-text-inverse: #ffffff;
--erp-text-sidebar: #CBD5E1; --erp-text-sidebar: #475569;
--erp-text-sidebar-active: #FFFFFF; --erp-text-sidebar-active: #2563eb;
/* Border Colors */ /* Border Colors — Slate borders */
--erp-border: #E2E8F0; --erp-border: #e2e8f0;
--erp-border-light: #F1F5F9; --erp-border-light: #f1f5f9;
--erp-border-dark: #334155; --erp-border-dark: #cbd5e1;
/* Shadows */ /* Shadows — Soft UI Evolution: subtle, layered depth */
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.03); --erp-shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--erp-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); --erp-shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07); --erp-shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05);
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.08); --erp-shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
--erp-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.08); --erp-shadow-xl: 0 12px 40px rgba(0,0,0,0.15);
/* Radius */ /* Radius — Soft UI: friendly but professional */
--erp-radius-sm: 6px; --erp-radius-sm: 6px;
--erp-radius-md: 8px; --erp-radius-md: 10px;
--erp-radius-lg: 12px; --erp-radius-lg: 12px;
--erp-radius-xl: 16px; --erp-radius-xl: 16px;
/* Spacing */ /* Spacing — 4px base unit */
--erp-space-xs: 4px; --erp-space-xs: 4px;
--erp-space-sm: 8px; --erp-space-sm: 8px;
--erp-space-md: 16px; --erp-space-md: 16px;
@@ -68,11 +69,10 @@
--erp-space-xl: 32px; --erp-space-xl: 32px;
--erp-space-2xl: 48px; --erp-space-2xl: 48px;
/* Typography */ /* Typography — Noto Sans SC for Chinese-first ERP */
--erp-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', --erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif; 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
--erp-font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Menlo, Monaco, Consolas, --erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
'Liberation Mono', 'Courier New', monospace;
--erp-font-size-xs: 12px; --erp-font-size-xs: 12px;
--erp-font-size-sm: 13px; --erp-font-size-sm: 13px;
--erp-font-size-base: 14px; --erp-font-size-base: 14px;
@@ -88,8 +88,8 @@
/* Trend Colors */ /* Trend Colors */
--erp-trend-up: #059669; --erp-trend-up: #059669;
--erp-trend-down: #DC2626; --erp-trend-down: #dc2626;
--erp-trend-neutral: #64748B; --erp-trend-neutral: #475569;
/* Line Height */ /* Line Height */
--erp-line-height-tight: 1.25; --erp-line-height-tight: 1.25;
@@ -104,34 +104,46 @@
/* --- Dark Mode Tokens --- */ /* --- Dark Mode Tokens --- */
[data-theme='dark'] { [data-theme='dark'] {
--erp-bg-page: #0B0F1A; --erp-primary-light: rgba(37, 99, 235, 0.15);
--erp-bg-container: #111827; --erp-primary-light-hover: rgba(37, 99, 235, 0.22);
--erp-bg-elevated: #1E293B; --erp-primary-bg-subtle: rgba(37, 99, 235, 0.1);
--erp-bg-spotlight: #1E293B;
--erp-bg-sidebar: #070B14;
--erp-bg-sidebar-hover: #111827;
--erp-text-primary: #F1F5F9; --erp-bg-page: #0f172a;
--erp-text-secondary: #94A3B8; --erp-bg-container: #1e293b;
--erp-text-tertiary: #64748B; --erp-bg-elevated: #334155;
--erp-bg-spotlight: #1e293b;
--erp-bg-sidebar: #0f172a;
--erp-bg-sidebar-hover: #1e293b;
--erp-border: #1E293B; --erp-text-primary: rgba(255, 255, 255, 0.95);
--erp-border-light: #1E293B; --erp-text-secondary: #94a3b8;
--erp-text-tertiary: #64748b;
--erp-text-sidebar: #94a3b8;
--erp-text-sidebar-active: #60a5fa;
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3); --erp-border: #334155;
--erp-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.3); --erp-border-light: rgba(255, 255, 255, 0.06);
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); --erp-border-dark: rgba(255, 255, 255, 0.12);
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--erp-trend-up: #34D399; --erp-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--erp-trend-down: #F87171; --erp-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
--erp-trend-neutral: #94A3B8; --erp-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3), 0 2px 6px rgba(0, 0, 0, 0.2);
--erp-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
--erp-trend-up: #34d399;
--erp-trend-down: #f87171;
--erp-trend-neutral: #94a3b8;
--erp-success-bg: rgba(5, 150, 105, 0.15);
--erp-warning-bg: rgba(217, 119, 6, 0.15);
--erp-error-bg: rgba(220, 38, 38, 0.15);
--erp-info-bg: rgba(2, 132, 199, 0.15);
} }
[data-theme='dark'] .erp-stat-card-trend-up { color: #34D399; } [data-theme='dark'] .erp-stat-card-trend-up { color: #34d399; }
[data-theme='dark'] .erp-stat-card-trend-down { color: #FCA5A5; } [data-theme='dark'] .erp-stat-card-trend-down { color: #f87171; }
[data-theme='dark'] .erp-stat-card-trend-neutral { color: #94A3B8; } [data-theme='dark'] .erp-stat-card-trend-neutral { color: #94a3b8; }
[data-theme='dark'] .erp-stat-card-trend-label { color: #94A3B8; } [data-theme='dark'] .erp-stat-card-trend-label { color: #94a3b8; }
/* --- Global Reset & Base --- */ /* --- Global Reset & Base --- */
body { body {
@@ -170,20 +182,20 @@ body {
/* --- Selection --- */ /* --- Selection --- */
::selection { ::selection {
background-color: var(--erp-primary-light); background-color: rgba(37, 99, 235, 0.15);
color: var(--erp-primary); color: var(--erp-text-primary);
} }
/* ==================================================================== /* ====================================================================
* Component Overrides — Ant Design Enhancement * Component Overrides — Ant Design Enhancement
* ==================================================================== */ * ==================================================================== */
/* --- Card --- */ /* --- Card — Soft shadow, clean border --- */
.ant-card { .ant-card {
border-radius: var(--erp-radius-lg) !important; border-radius: var(--erp-radius-lg) !important;
border: 1px solid var(--erp-border-light) !important; border: 1px solid var(--erp-border) !important;
box-shadow: var(--erp-shadow-xs) !important; box-shadow: var(--erp-shadow-xs) !important;
transition: box-shadow var(--erp-transition-base), transform var(--erp-transition-base) !important; transition: box-shadow var(--erp-transition-base) !important;
} }
.ant-card:hover { .ant-card:hover {
@@ -209,15 +221,14 @@ body {
/* --- Statistic Cards --- */ /* --- Statistic Cards --- */
.stat-card { .stat-card {
border-radius: var(--erp-radius-lg) !important; border-radius: var(--erp-radius-lg) !important;
border: none !important; border: 1px solid var(--erp-border) !important;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
transition: all var(--erp-transition-base) !important; transition: box-shadow var(--erp-transition-base) !important;
} }
.stat-card:hover { .stat-card:hover {
transform: translateY(-2px) !important; box-shadow: var(--erp-shadow-sm) !important;
box-shadow: var(--erp-shadow-md) !important;
} }
.stat-card .ant-statistic-title { .stat-card .ant-statistic-title {
@@ -251,7 +262,7 @@ body {
} }
.ant-table-tbody > tr:hover > td { .ant-table-tbody > tr:hover > td {
background: var(--erp-primary-bg-subtle) !important; background: var(--erp-bg-spotlight) !important;
} }
.ant-table-tbody > tr > td { .ant-table-tbody > tr > td {
@@ -263,13 +274,12 @@ body {
.ant-btn-primary { .ant-btn-primary {
border-radius: var(--erp-radius-md) !important; border-radius: var(--erp-radius-md) !important;
font-weight: 500 !important; font-weight: 500 !important;
box-shadow: 0 1px 2px 0 rgba(79, 70, 229, 0.3) !important; box-shadow: none !important;
transition: all var(--erp-transition-fast) !important; transition: all var(--erp-transition-fast) !important;
} }
.ant-btn-primary:hover { .ant-btn-primary:hover {
box-shadow: 0 2px 4px 0 rgba(79, 70, 229, 0.4) !important; opacity: 0.9;
transform: translateY(-1px);
} }
.ant-btn-default { .ant-btn-default {
@@ -297,7 +307,7 @@ body {
.ant-select-focused .ant-select-selector, .ant-select-focused .ant-select-selector,
.ant-picker-focused { .ant-picker-focused {
border-color: var(--erp-primary) !important; border-color: var(--erp-primary) !important;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.12) !important; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12) !important;
} }
/* --- Modal --- */ /* --- Modal --- */
@@ -426,12 +436,12 @@ body {
border-radius: var(--erp-radius-lg) var(--erp-radius-lg) 0 0; border-radius: var(--erp-radius-lg) var(--erp-radius-lg) 0 0;
} }
.erp-gradient-card.indigo::before { background: linear-gradient(90deg, #4F46E5, #818CF8); } .erp-gradient-card.indigo::before { background: linear-gradient(90deg, #2563eb, #60a5fa); }
.erp-gradient-card.emerald::before { background: linear-gradient(90deg, #059669, #34D399); } .erp-gradient-card.emerald::before { background: linear-gradient(90deg, #059669, #34d399); }
.erp-gradient-card.amber::before { background: linear-gradient(90deg, #D97706, #FBBF24); } .erp-gradient-card.amber::before { background: linear-gradient(90deg, #d97706, #fbbf24); }
.erp-gradient-card.rose::before { background: linear-gradient(90deg, #E11D48, #FB7185); } .erp-gradient-card.rose::before { background: linear-gradient(90deg, #dc2626, #f87171); }
.erp-gradient-card.sky::before { background: linear-gradient(90deg, #0284C7, #38BDF8); } .erp-gradient-card.sky::before { background: linear-gradient(90deg, #0284c7, #38bdf8); }
.erp-gradient-card.violet::before { background: linear-gradient(90deg, #7C3AED, #A78BFA); } .erp-gradient-card.violet::before { background: linear-gradient(90deg, #7c3aed, #a78bfa); }
/* --- Fade-in Animation --- */ /* --- Fade-in Animation --- */
@keyframes erp-fade-in { @keyframes erp-fade-in {
@@ -465,7 +475,7 @@ body {
*:focus-visible { *:focus-visible {
outline: 2px solid var(--erp-primary); outline: 2px solid var(--erp-primary);
outline-offset: 2px; outline-offset: 2px;
border-radius: 4px; border-radius: var(--erp-radius-sm);
} }
.erp-sidebar-item:focus-visible { .erp-sidebar-item:focus-visible {
@@ -481,7 +491,7 @@ body {
background: var(--erp-primary); background: var(--erp-primary);
color: #fff; color: #fff;
padding: 8px 24px; padding: 8px 24px;
border-radius: 0 0 8px 8px; border-radius: 0 0 var(--erp-radius-md) var(--erp-radius-md);
z-index: 10000; z-index: 10000;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -529,23 +539,23 @@ body {
* ==================================================================== */ * ==================================================================== */
.erp-sidebar-menu .ant-menu-item { .erp-sidebar-menu .ant-menu-item {
margin: 2px 8px !important; margin: 1px 8px !important;
border-radius: var(--erp-radius-md) !important; border-radius: var(--erp-radius-md) !important;
height: 40px !important; height: 36px !important;
line-height: 40px !important; line-height: 36px !important;
} }
.erp-sidebar-menu .ant-menu-item-selected { .erp-sidebar-menu .ant-menu-item-selected {
background: var(--erp-primary) !important; background: #eff6ff !important;
color: #fff !important; color: #2563eb !important;
} }
.erp-sidebar-menu .ant-menu-item-selected .anticon { .erp-sidebar-menu .ant-menu-item-selected .anticon {
color: #fff !important; color: #2563eb !important;
} }
.erp-sidebar-menu .ant-menu-item:not(.ant-menu-item-selected):hover { .erp-sidebar-menu .ant-menu-item:not(.ant-menu-item-selected):hover {
background: var(--erp-bg-sidebar-hover) !important; background: #f1f5f9 !important;
} }
/* Sidebar group label */ /* Sidebar group label */
@@ -555,17 +565,17 @@ body {
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
color: #94A3B8; color: #94a3b8;
} }
/* ==================================================================== /* ====================================================================
* MainLayout — CSS classes replacing inline styles * MainLayout — CSS classes replacing inline styles
* ==================================================================== */ * ==================================================================== */
/* Sider */ /* Sider — White sidebar, Soft UI style */
.erp-sider-dark { .erp-sider-dark {
background: #0F172A !important; background: #ffffff !important;
border-right: none !important; border-right: 1px solid #e2e8f0 !important;
position: fixed !important; position: fixed !important;
left: 0; left: 0;
top: 0; top: 0;
@@ -575,7 +585,8 @@ body {
} }
[data-theme='dark'] .erp-sider-dark { [data-theme='dark'] .erp-sider-dark {
background: #070B14 !important; background: #0f172a !important;
border-right: 1px solid #334155 !important;
} }
/* Logo */ /* Logo */
@@ -584,48 +595,56 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid #e2e8f0;
cursor: pointer; cursor: pointer;
} }
[data-theme='dark'] .erp-sidebar-logo {
border-bottom: 1px solid #334155;
}
.ant-layout-sider-collapsed .erp-sidebar-logo { .ant-layout-sider-collapsed .erp-sidebar-logo {
justify-content: center; justify-content: center;
padding: 0; padding: 0;
} }
.erp-sidebar-logo-icon { .erp-sidebar-logo-icon {
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 8px; border-radius: var(--erp-radius-sm);
background: linear-gradient(135deg, #4F46E5, #818CF8); background: #2563eb;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
font-size: 14px; font-size: 13px;
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
} }
.erp-sidebar-logo-text { .erp-sidebar-logo-text {
margin-left: 12px; margin-left: 10px;
color: #F8FAFC; color: #0f172a;
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.3px; letter-spacing: -0.3px;
white-space: nowrap; white-space: nowrap;
} }
[data-theme='dark'] .erp-sidebar-logo-text {
color: rgba(255, 255, 255, 0.95);
}
/* Sidebar menu item */ /* Sidebar menu item */
.erp-sidebar-item { .erp-sidebar-item {
display: flex; display: flex;
align-items: center; align-items: center;
height: 40px; height: 36px;
margin: 2px 8px; margin: 1px 8px;
padding: 0 16px; padding: 0 12px;
border-radius: 8px; border-radius: var(--erp-radius-md);
cursor: pointer; cursor: pointer;
color: #94A3B8; color: #475569;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
@@ -638,14 +657,28 @@ body {
} }
.erp-sidebar-item:hover:not(.erp-sidebar-item-active) { .erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
background: rgba(255, 255, 255, 0.06); background: #f1f5f9;
color: #E2E8F0; color: #0f172a;
}
[data-theme='dark'] .erp-sidebar-item {
color: #94a3b8;
}
[data-theme='dark'] .erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
background: #1e293b;
color: rgba(255, 255, 255, 0.95);
} }
.erp-sidebar-item-active { .erp-sidebar-item-active {
background: linear-gradient(135deg, #4F46E5, #6366F1); background: #eff6ff;
color: #fff; color: #2563eb;
font-weight: 600; font-weight: 500;
}
[data-theme='dark'] .erp-sidebar-item-active {
background: rgba(37, 99, 235, 0.15);
color: #60a5fa;
} }
.erp-sidebar-item-icon { .erp-sidebar-item-icon {
@@ -665,10 +698,10 @@ body {
height: 32px; height: 32px;
margin: 6px 8px 2px 8px; margin: 6px 8px 2px 8px;
padding: 0 12px; padding: 0 12px;
border-radius: 6px; border-radius: var(--erp-radius-md);
cursor: pointer; cursor: pointer;
color: #94A3B8; color: #94a3b8;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -677,12 +710,25 @@ body {
} }
.erp-sidebar-submenu-title:hover { .erp-sidebar-submenu-title:hover {
background: rgba(255, 255, 255, 0.06); background: #f1f5f9;
color: #E2E8F0; color: #475569;
}
[data-theme='dark'] .erp-sidebar-submenu-title {
color: #64748b;
}
[data-theme='dark'] .erp-sidebar-submenu-title:hover {
background: #1e293b;
color: #94a3b8;
} }
.erp-sidebar-submenu-title-active { .erp-sidebar-submenu-title-active {
color: #A5B4FC; color: #2563eb;
}
[data-theme='dark'] .erp-sidebar-submenu-title-active {
color: #60a5fa;
} }
.erp-sidebar-submenu-arrow { .erp-sidebar-submenu-arrow {
@@ -707,8 +753,8 @@ body {
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
} }
.erp-main-layout-light { background: #F1F5F9; } .erp-main-layout-light { background: #f8fafc; }
.erp-main-layout-dark { background: #0B0F1A; } .erp-main-layout-dark { background: #0f172a; }
/* Header */ /* Header */
.erp-header { .erp-header {
@@ -724,44 +770,44 @@ body {
} }
.erp-header-light { .erp-header-light {
background: #FFFFFF !important; background: #ffffff !important;
border-bottom: 1px solid #F1F5F9; border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); box-shadow: none;
} }
.erp-header-dark { .erp-header-dark {
background: #111827 !important; background: #1e293b !important;
border-bottom: 1px solid #1E293B; border-bottom: 1px solid #334155;
box-shadow: none; box-shadow: none;
} }
.erp-header-btn { .erp-header-btn {
width: 36px; width: 32px;
height: 36px; height: 32px;
border-radius: 8px; border-radius: var(--erp-radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
color: #94A3B8; color: #475569;
will-change: background; will-change: background;
} }
.erp-header-light .erp-header-btn { color: #64748B; } .erp-header-light .erp-header-btn { color: #475569; }
.erp-header-dark .erp-header-btn { color: #94A3B8; } .erp-header-dark .erp-header-btn { color: #94a3b8; }
.erp-header-btn:hover { background: #F1F5F9; } .erp-header-btn:hover { background: #f1f5f9; }
.erp-header-dark .erp-header-btn:hover { background: #1E293B; } .erp-header-dark .erp-header-btn:hover { background: #334155; }
.erp-header-title { font-size: 15px; font-weight: 600; } .erp-header-title { font-size: 15px; font-weight: 600; }
.erp-text-light { color: #0F172A; } .erp-text-light { color: #0f172a; }
.erp-text-dark { color: #F1F5F9; } .erp-text-dark { color: rgba(255, 255, 255, 0.95); }
.erp-text-light-secondary { color: #334155; } .erp-text-light-secondary { color: #475569; }
.erp-text-dark-secondary { color: #E2E8F0; } .erp-text-dark-secondary { color: #94a3b8; }
.erp-header-divider { width: 1px; height: 24px; margin: 0 8px; } .erp-header-divider { width: 1px; height: 24px; margin: 0 8px; }
.erp-header-divider-light { background: #E2E8F0; } .erp-header-divider-light { background: rgba(0, 0, 0, 0.06); }
.erp-header-divider-dark { background: #1E293B; } .erp-header-divider-dark { background: rgba(255, 255, 255, 0.06); }
/* User avatar */ /* User avatar */
.erp-header-user { .erp-header-user {
@@ -770,15 +816,15 @@ body {
gap: 10px; gap: 10px;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
border-radius: 8px; border-radius: var(--erp-radius-sm);
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.erp-header-user:hover { background: #F1F5F9; } .erp-header-user:hover { background: #f1f5f9; }
.erp-header-dark .erp-header-user:hover { background: #1E293B; } .erp-header-dark .erp-header-user:hover { background: #334155; }
.erp-user-avatar { .erp-user-avatar {
background: linear-gradient(135deg, #4F46E5, #818CF8) !important; background: #2563eb !important;
font-size: 13px !important; font-size: 13px !important;
} }
@@ -786,11 +832,11 @@ body {
/* Footer */ /* Footer */
.erp-footer { text-align: center; padding: 12px 24px !important; background: transparent !important; font-size: 12px; } .erp-footer { text-align: center; padding: 12px 24px !important; background: transparent !important; font-size: 12px; }
.erp-footer-light { color: #475569; } .erp-footer-light { color: #94a3b8; }
.erp-footer-dark { color: #94A3B8; } .erp-footer-dark { color: #64748b; }
/* ==================================================================== /* ====================================================================
* Dashboard — Stat Cards & Quick Actions (replacing inline styles) * Dashboard — Stat Cards & Quick Actions
* ==================================================================== */ * ==================================================================== */
/* Stat Card */ /* Stat Card */
@@ -818,7 +864,7 @@ body {
left: 0; left: 0;
right: 0; right: 0;
height: 3px; height: 3px;
background: var(--card-gradient, linear-gradient(135deg, #4F46E5, #6366F1)); background: var(--card-gradient, linear-gradient(135deg, #2563eb, #60a5fa));
} }
.erp-stat-card-body { .erp-stat-card-body {
@@ -849,7 +895,7 @@ body {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: var(--erp-radius-lg); border-radius: var(--erp-radius-lg);
background: var(--card-icon-bg, rgba(79, 70, 229, 0.12)); background: var(--card-icon-bg, rgba(37, 99, 235, 0.08));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -857,7 +903,7 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
/* Section Header (shared by dashboard sections) */ /* Section Header */
.erp-section-header { .erp-section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -867,7 +913,7 @@ body {
.erp-section-icon { .erp-section-icon {
font-size: 16px; font-size: 16px;
color: #4F46E5; color: #2563eb;
} }
.erp-section-title { .erp-section-title {
@@ -882,7 +928,7 @@ body {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px 16px; padding: 14px 16px;
border-radius: 10px; border-radius: var(--erp-radius-lg);
cursor: pointer; cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease; transition: background 0.15s ease, border-color 0.15s ease;
background: var(--erp-bg-spotlight); background: var(--erp-bg-spotlight);
@@ -890,17 +936,17 @@ body {
} }
.erp-quick-action:hover { .erp-quick-action:hover {
background: #EEF2FF; background: #eff6ff;
border-color: var(--action-color, #4F46E5); border-color: var(--action-color, #2563eb);
} }
[data-theme='dark'] .erp-quick-action { [data-theme='dark'] .erp-quick-action {
background: #0B0F1A; background: #0f172a;
} }
[data-theme='dark'] .erp-quick-action:hover { [data-theme='dark'] .erp-quick-action:hover {
background: #1E293B; background: #1e293b;
border-color: var(--action-color, #4F46E5); border-color: var(--action-color, #2563eb);
} }
.erp-quick-action-icon { .erp-quick-action-icon {
@@ -910,8 +956,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent); background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
color: var(--action-color, #4F46E5); color: var(--action-color, #2563eb);
font-size: 16px; font-size: 16px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -962,12 +1008,12 @@ body {
font-weight: 500; font-weight: 500;
} }
.erp-stat-card-trend-up { color: #047857; } .erp-stat-card-trend-up { color: #059669; }
.erp-stat-card-trend-down { color: #B91C1C; } .erp-stat-card-trend-down { color: #dc2626; }
.erp-stat-card-trend-neutral { color: #64748B; } .erp-stat-card-trend-neutral { color: #475569; }
.erp-stat-card-trend-label { .erp-stat-card-trend-label {
color: #64748B; color: #475569;
font-weight: 400; font-weight: 400;
} }
@@ -1000,8 +1046,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent); background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
color: var(--action-color, #4F46E5); color: var(--action-color, #2563eb);
font-size: 18px; font-size: 18px;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.15s ease; transition: transform 0.15s ease;
@@ -1029,7 +1075,7 @@ body {
padding: 12px 16px; padding: 12px 16px;
border-radius: var(--erp-radius-md); border-radius: var(--erp-radius-md);
background: var(--erp-bg-spotlight); background: var(--erp-bg-spotlight);
border-left: 3px solid var(--task-color, #4F46E5); border-left: 3px solid var(--task-color, #2563eb);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@@ -1042,12 +1088,12 @@ body {
.erp-task-item-icon { .erp-task-item-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 8px; border-radius: var(--erp-radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: color-mix(in srgb, var(--task-color, #4F46E5) 12%, transparent); background: color-mix(in srgb, var(--task-color, #2563eb) 8%, transparent);
color: var(--task-color, #4F46E5); color: var(--task-color, #2563eb);
font-size: 14px; font-size: 14px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1069,25 +1115,25 @@ body {
gap: 12px; gap: 12px;
margin-top: 2px; margin-top: 2px;
font-size: var(--erp-font-size-xs); font-size: var(--erp-font-size-xs);
color: #64748B; color: #94a3b8;
} }
.erp-task-priority { .erp-task-priority {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 1px 8px; padding: 1px 8px;
border-radius: 10px; border-radius: var(--erp-radius-sm);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
.erp-task-priority-high { background: #FEF2F2; color: #B91C1C; } .erp-task-priority-high { background: #fef2f2; color: #dc2626; }
.erp-task-priority-medium { background: #FFFBEB; color: #92400E; } .erp-task-priority-medium { background: #fffbeb; color: #d97706; }
.erp-task-priority-low { background: #ECFDF5; color: #047857; } .erp-task-priority-low { background: #ecfdf5; color: #059669; }
[data-theme='dark'] .erp-task-priority-high { background: rgba(185, 28, 28, 0.15); color: #FCA5A5; } [data-theme='dark'] .erp-task-priority-high { background: rgba(220, 38, 38, 0.15); color: #f87171; }
[data-theme='dark'] .erp-task-priority-medium { background: rgba(146, 64, 14, 0.15); color: #FCD34D; } [data-theme='dark'] .erp-task-priority-medium { background: rgba(217, 119, 6, 0.15); color: #fbbf24; }
[data-theme='dark'] .erp-task-priority-low { background: rgba(4, 120, 87, 0.15); color: #6EE7B7; } [data-theme='dark'] .erp-task-priority-low { background: rgba(5, 150, 105, 0.15); color: #34d399; }
/* Activity Timeline */ /* Activity Timeline */
.erp-activity-list { .erp-activity-list {
@@ -1143,12 +1189,12 @@ body {
.erp-activity-time { .erp-activity-time {
font-size: 11px; font-size: 11px;
color: #64748B; color: #94a3b8;
margin-top: 2px; margin-top: 2px;
} }
[data-theme='dark'] .erp-activity-time { [data-theme='dark'] .erp-activity-time {
color: #94A3B8; color: #64748b;
} }
/* Empty State */ /* Empty State */

View File

@@ -167,7 +167,7 @@ export default function Home() {
title: '用户总数', title: '用户总数',
value: stats.userCount, value: stats.userCount,
icon: <UserOutlined />, icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)',
iconBg: 'rgba(79, 70, 229, 0.12)', iconBg: 'rgba(79, 70, 229, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-1', delay: 'erp-fade-in erp-fade-in-delay-1',
trend: { value: '+2', direction: 'up', label: '较上周' }, trend: { value: '+2', direction: 'up', label: '较上周' },
@@ -191,7 +191,7 @@ export default function Home() {
title: '流程实例', title: '流程实例',
value: stats.processInstanceCount, value: stats.processInstanceCount,
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', gradient: 'linear-gradient(135deg, #d97706, #F59E0B)',
iconBg: 'rgba(217, 119, 6, 0.12)', iconBg: 'rgba(217, 119, 6, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-3', delay: 'erp-fade-in erp-fade-in-delay-3',
trend: { value: '0', direction: 'neutral', label: '较昨日' }, trend: { value: '0', direction: 'neutral', label: '较昨日' },
@@ -213,17 +213,17 @@ export default function Home() {
]; ];
const quickActions = [ const quickActions = [
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' }, { icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#2563eb' },
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' }, { icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' },
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#D97706' }, { icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#d97706' },
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' }, { icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' }, { icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#64748B' }, { icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#475569' },
]; ];
const pendingTasks: TaskItem[] = [ const pendingTasks: TaskItem[] = [
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#DC2626', icon: <UserOutlined />, path: '/users' }, { id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#dc2626', icon: <UserOutlined />, path: '/users' },
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#D97706', icon: <PartitionOutlined />, path: '/workflow' }, { id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#d97706', icon: <PartitionOutlined />, path: '/workflow' },
{ id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' }, { id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
]; ];
@@ -243,13 +243,13 @@ export default function Home() {
<h2 style={{ <h2 style={{
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A', color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px', margin: '0 0 4px',
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
}}> }}>
</h2> </h2>
<p style={{ fontSize: 14, color: isDark ? '#94A3B8' : '#475569', margin: 0 }}> <p style={{ fontSize: 14, color: isDark ? '#94a3b8' : '#475569', margin: 0 }}>
</p> </p>
</div> </div>
@@ -308,12 +308,12 @@ export default function Home() {
<Col xs={24} lg={14}> <Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2"> <div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
<div className="erp-section-header"> <div className="erp-section-header">
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#E11D48' }} /> <CheckCircleOutlined className="erp-section-icon" style={{ color: '#2563eb' }} />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
<span style={{ <span style={{
marginLeft: 'auto', marginLeft: 'auto',
fontSize: 12, fontSize: 12,
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
}}> }}>
{pendingTasks.length} {pendingTasks.length}
</span> </span>
@@ -351,7 +351,7 @@ export default function Home() {
<Col xs={24} lg={10}> <Col xs={24} lg={10}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}> <div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
<div className="erp-section-header"> <div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} /> <ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
</div> </div>
<div className="erp-activity-list"> <div className="erp-activity-list">
@@ -400,7 +400,7 @@ export default function Home() {
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}> <div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
<div className="erp-section-header"> <div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} /> <ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
</div> </div>
<div className="erp-system-info-list"> <div className="erp-system-info-list">

View File

@@ -30,7 +30,7 @@ export default function Login() {
<div <div
style={{ style={{
flex: 1, flex: 1,
background: 'linear-gradient(135deg, #312E81 0%, #4F46E5 50%, #6366F1 100%)', background: 'linear-gradient(135deg, #312E81 0%, #2563eb 50%, #60a5fa 100%)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
@@ -151,7 +151,7 @@ export default function Login() {
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}> <h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
</h2> </h2>
<p style={{ fontSize: 14, color: '#64748B' }}> <p style={{ fontSize: 14, color: '#475569' }}>
</p> </p>
@@ -163,7 +163,7 @@ export default function Login() {
rules={[{ required: true, message: '请输入用户名' }]} rules={[{ required: true, message: '请输入用户名' }]}
> >
<Input <Input
prefix={<UserOutlined style={{ color: '#94A3B8' }} />} prefix={<UserOutlined style={{ color: '#94a3b8' }} />}
placeholder="用户名" placeholder="用户名"
style={{ height: 44, borderRadius: 10 }} style={{ height: 44, borderRadius: 10 }}
/> />
@@ -173,7 +173,7 @@ export default function Login() {
rules={[{ required: true, message: '请输入密码' }]} rules={[{ required: true, message: '请输入密码' }]}
> >
<Input.Password <Input.Password
prefix={<LockOutlined style={{ color: '#94A3B8' }} />} prefix={<LockOutlined style={{ color: '#94a3b8' }} />}
placeholder="密码" placeholder="密码"
style={{ height: 44, borderRadius: 10 }} style={{ height: 44, borderRadius: 10 }}
/> />
@@ -197,7 +197,7 @@ export default function Login() {
</Form> </Form>
<div style={{ marginTop: 32, textAlign: 'center' }}> <div style={{ marginTop: 32, textAlign: 'center' }}>
<p style={{ fontSize: 12, color: '#64748B', margin: 0 }}> <p style={{ fontSize: 12, color: '#475569', margin: 0 }}>
ERP Platform v0.1.0 · Powered by Rust + React ERP Platform v0.1.0 · Powered by Rust + React
</p> </p>
</div> </div>

View File

@@ -44,7 +44,7 @@ export default function Organizations() {
const cardStyle = { const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}; };
// --- Org tree state --- // --- Org tree state ---
@@ -264,9 +264,9 @@ export default function Organizations() {
{item.name}{' '} {item.name}{' '}
{item.code && <Tag style={{ {item.code && <Tag style={{
marginLeft: 4, marginLeft: 4,
background: isDark ? '#1E293B' : '#EEF2FF', background: isDark ? '#0f172a' : '#eff6ff',
border: 'none', border: 'none',
color: '#4F46E5', color: '#2563eb',
fontSize: 11, fontSize: 11,
}}>{item.code}</Tag>} }}>{item.code}</Tag>}
</span> </span>
@@ -282,7 +282,7 @@ export default function Organizations() {
{item.name}{' '} {item.name}{' '}
{item.code && <Tag style={{ {item.code && <Tag style={{
marginLeft: 4, marginLeft: 4,
background: isDark ? '#1E293B' : '#ECFDF5', background: isDark ? '#0f172a' : '#ECFDF5',
border: 'none', border: 'none',
color: '#059669', color: '#059669',
fontSize: 11, fontSize: 11,
@@ -343,7 +343,7 @@ export default function Organizations() {
<div className="erp-page-header"> <div className="erp-page-header">
<div> <div>
<h4> <h4>
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} /> <ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</h4> </h4>
<div className="erp-page-subtitle"></div> <div className="erp-page-subtitle"></div>
@@ -356,7 +356,7 @@ export default function Organizations() {
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}> <div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -418,7 +418,7 @@ export default function Organizations() {
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}> <div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -471,7 +471,7 @@ export default function Organizations() {
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}> <div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',

View File

@@ -41,11 +41,11 @@ import {
import PluginSettingsForm from '../components/PluginSettingsForm'; import PluginSettingsForm from '../components/PluginSettingsForm';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = { const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#64748B', label: '已上传' }, uploaded: { color: '#475569', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' }, installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' }, enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' }, running: { color: '#059669', label: '运行中' },
disabled: { color: '#DC2626', label: '已禁用' }, disabled: { color: '#dc2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' }, uninstalled: { color: '#9333EA', label: '已卸载' },
}; };
@@ -215,7 +215,7 @@ export default function PluginAdmin() {
key: 'status', key: 'status',
width: 100, width: 100,
render: (status: PluginStatus) => { render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status }; const cfg = STATUS_CONFIG[status] || { color: '#475569', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>; return <Tag color={cfg.color}>{cfg.label}</Tag>;
}, },
}, },

View File

@@ -773,9 +773,14 @@ export default function PluginCRUDPage({
name={field.name} name={field.name}
label={field.display_name || field.name} label={field.display_name || field.name}
rules={ rules={
field.required [
...(field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }] ? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: [] : []),
...(field.validation?.pattern
? [{ pattern: new RegExp(field.validation.pattern), message: field.validation.message || '格式不正确' }]
: []),
]
} }
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'} valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
> >

View File

@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Row, Col, Empty, Select, theme } from 'antd'; import { Row, Col, Empty, Select, theme } from 'antd';
import { DashboardOutlined } from '@ant-design/icons'; import { DashboardOutlined } from '@ant-design/icons';
import { countPluginData, aggregatePluginData } from '../api/pluginData'; import { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData';
import { import {
getPluginSchema, getPluginSchema,
type PluginEntitySchema, type PluginEntitySchema,
@@ -134,10 +134,65 @@ export function PluginDashboardPage() {
const results = await Promise.all( const results = await Promise.all(
widgets.map(async (widget) => { widgets.map(async (widget) => {
try { try {
// 旧类型
if (widget.type === 'stat_card') { if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity); const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count }; return { widget, data: [], count };
} }
// stat_cards — 多个统计卡片
if (widget.type === 'stat_cards' && widget.cards) {
const cardResults = await Promise.all(
widget.cards.map(async (card) => {
try {
const count = await countPluginData(pluginId!, card.entity, {
filter: card.filter ? JSON.parse(card.filter) : undefined,
});
return { card, value: count };
} catch {
return { card, value: 0 };
}
}),
);
return { widget, data: [], statCards: cardResults };
}
// action_list — 待办列表
if (widget.type === 'action_list' && widget.queries) {
const actionResults = await Promise.all(
widget.queries.map(async (query) => {
try {
const filterObj = query.filter ? JSON.parse(query.filter) : undefined;
const sortParts = query.sort?.split(' ') ?? [];
const result = await listPluginData(pluginId!, query.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
sort_by: sortParts[0] || undefined,
sort_order: (sortParts[1] as 'asc' | 'desc') || undefined,
});
return { query, records: result.data };
} catch {
return { query, records: [] };
}
}),
);
return { widget, data: [], actionItems: actionResults };
}
// funnel — 阶段漏斗
if (widget.type === 'funnel' && widget.lane_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.lane_field,
);
return { widget, data };
}
// card_list — 卡片列表
if (widget.type === 'card_list') {
const filterObj = widget.filter ? JSON.parse(widget.filter) : undefined;
const result = await listPluginData(pluginId!, widget.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
});
return { widget, data: [], records: result.data };
}
// 旧类型图表
if (widget.dimension_field) { if (widget.dimension_field) {
const data = await aggregatePluginData( const data = await aggregatePluginData(
pluginId!, pluginId!,
@@ -146,7 +201,7 @@ export function PluginDashboardPage() {
); );
return { widget, data }; return { widget, data };
} }
// 没有 dimension_field 时仅返回计数 // fallback — 仅返回计数
const count = await countPluginData(pluginId!, widget.entity); const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count }; return { widget, data: [], count };
} catch { } catch {
@@ -244,7 +299,7 @@ export function PluginDashboardPage() {
style={{ style={{
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A', color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px', margin: '0 0 4px',
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
}} }}
@@ -254,7 +309,7 @@ export function PluginDashboardPage() {
<p <p
style={{ style={{
fontSize: 14, fontSize: 14,
color: isDark ? '#94A3B8' : '#475569', color: isDark ? '#94a3b8' : '#475569',
margin: 0, margin: 0,
}} }}
> >
@@ -297,7 +352,7 @@ export function PluginDashboardPage() {
<div className="erp-section-header"> <div className="erp-section-header">
<DashboardOutlined <DashboardOutlined
className="erp-section-icon" className="erp-section-icon"
style={{ color: '#4F46E5' }} style={{ color: '#2563eb' }}
/> />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
</div> </div>
@@ -313,6 +368,10 @@ export function PluginDashboardPage() {
{widgetData.map((wd) => { {widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6 const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12 : wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: wd.widget.type === 'stat_cards' ? 24
: wd.widget.type === 'action_list' ? 12
: wd.widget.type === 'funnel' ? 12
: wd.widget.type === 'card_list' ? 12
: 12; : 12;
return ( return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}> <Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
@@ -330,7 +389,7 @@ export function PluginDashboardPage() {
<div className="erp-section-header"> <div className="erp-section-header">
<DashboardOutlined <DashboardOutlined
className="erp-section-icon" className="erp-section-icon"
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }} style={{ color: currentPalette.tagColor === 'purple' ? '#2563eb' : '#3B82F6' }}
/> />
<span className="erp-section-title"> <span className="erp-section-title">
{currentEntity?.display_name || selectedEntity} {currentEntity?.display_name || selectedEntity}

View File

@@ -313,8 +313,8 @@ export function PluginGraphPage() {
const r = degreeToRadius(degree, isCenter); const r = degreeToRadius(degree, isCenter);
// Determine node color from its most common edge type, or default palette // Determine node color from its most common edge type, or default palette
let nodeColorBase = '#4F46E5'; let nodeColorBase = '#2563eb';
let nodeColorLight = '#818CF8'; let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)'; let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) { if (isCenter) {

View File

@@ -40,9 +40,9 @@ const CATEGORY_COLORS: Record<string, string> = {
'财务': '#059669', '财务': '#059669',
'CRM': '#2563EB', 'CRM': '#2563EB',
'进销存': '#9333EA', '进销存': '#9333EA',
'生产': '#DC2626', '生产': '#dc2626',
'人力资源': '#D97706', '人力资源': '#d97706',
'基础': '#64748B', '基础': '#475569',
}; };
export default function PluginMarket() { export default function PluginMarket() {
@@ -190,7 +190,7 @@ export default function PluginMarket() {
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text> <Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
<Tag <Tag
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'} color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#475569'}
style={{ marginLeft: 8 }} style={{ marginLeft: 8 }}
> >
{plugin.category} {plugin.category}
@@ -244,7 +244,7 @@ export default function PluginMarket() {
<div> <div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Space> <Space>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#64748B'}> <Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#475569'}>
{selectedPlugin.category} {selectedPlugin.category}
</Tag> </Tag>
<Text type="secondary">v{selectedPlugin.version}</Text> <Text type="secondary">v{selectedPlugin.version}</Text>

View File

@@ -153,12 +153,12 @@ export default function Roles() {
height: 32, height: 32,
borderRadius: 8, borderRadius: 8,
background: record.is_system background: record.is_system
? 'linear-gradient(135deg, #4F46E5, #818CF8)' ? 'linear-gradient(135deg, #2563eb, #60a5fa)'
: isDark ? '#1E293B' : '#F1F5F9', : isDark ? '#0f172a' : '#f8fafc',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: record.is_system ? '#fff' : isDark ? '#94A3B8' : '#64748B', color: record.is_system ? '#fff' : isDark ? '#94a3b8' : '#475569',
fontSize: 14, fontSize: 14,
}} }}
> >
@@ -174,9 +174,9 @@ export default function Roles() {
key: 'code', key: 'code',
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -190,7 +190,7 @@ export default function Roles() {
key: 'description', key: 'description',
ellipsis: true, ellipsis: true,
render: (v: string | undefined) => ( render: (v: string | undefined) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{v || '-'}</span> <span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{v || '-'}</span>
), ),
}, },
{ {
@@ -201,8 +201,8 @@ export default function Roles() {
render: (v: boolean) => ( render: (v: boolean) => (
<Tag <Tag
style={{ style={{
color: v ? '#4F46E5' : (isDark ? '#94A3B8' : '#64748B'), color: v ? '#2563eb' : (isDark ? '#94a3b8' : '#475569'),
background: v ? '#EEF2FF' : (isDark ? '#1E293B' : '#F1F5F9'), background: v ? '#eff6ff' : (isDark ? '#0f172a' : '#f8fafc'),
border: 'none', border: 'none',
fontWeight: 500, fontWeight: 500,
}} }}
@@ -222,7 +222,7 @@ export default function Roles() {
type="text" type="text"
icon={<SafetyCertificateOutlined />} icon={<SafetyCertificateOutlined />}
onClick={() => openPermModal(record)} onClick={() => openPermModal(record)}
style={{ color: '#4F46E5' }} style={{ color: '#2563eb' }}
> >
</Button> </Button>
@@ -233,7 +233,7 @@ export default function Roles() {
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => openEditModal(record)} onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
<Popconfirm <Popconfirm
title="确定删除此角色?" title="确定删除此角色?"
@@ -279,7 +279,7 @@ export default function Roles() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table
@@ -336,8 +336,8 @@ export default function Roles() {
marginBottom: 16, marginBottom: 16,
padding: 16, padding: 16,
borderRadius: 10, borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`, border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC', background: isDark ? '#0B0F1A' : '#f1f5f9',
}} }}
> >
<div style={{ <div style={{

View File

@@ -36,8 +36,8 @@ import type { UserInfo } from '../api/auth';
const STATUS_COLOR_MAP: Record<string, string> = { const STATUS_COLOR_MAP: Record<string, string> = {
active: '#059669', active: '#059669',
disabled: '#DC2626', disabled: '#dc2626',
locked: '#D97706', locked: '#d97706',
}; };
const STATUS_BG_MAP: Record<string, string> = { const STATUS_BG_MAP: Record<string, string> = {
@@ -219,7 +219,7 @@ export default function Users() {
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 8, borderRadius: 8,
background: 'linear-gradient(135deg, #4F46E5, #818CF8)', background: 'linear-gradient(135deg, #2563eb, #60a5fa)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -233,7 +233,7 @@ export default function Users() {
<div> <div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div> <div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
{record.display_name && ( {record.display_name && (
<div style={{ fontSize: 12, color: isDark ? '#64748B' : '#94A3B8' }}> <div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
{record.display_name} {record.display_name}
</div> </div>
)} )}
@@ -261,8 +261,8 @@ export default function Users() {
render: (status: string) => ( render: (status: string) => (
<Tag <Tag
style={{ style={{
color: STATUS_COLOR_MAP[status] || '#64748B', color: STATUS_COLOR_MAP[status] || '#62625b',
background: STATUS_BG_MAP[status] || '#F1F5F9', background: STATUS_BG_MAP[status] || '#f8fafc',
border: 'none', border: 'none',
fontWeight: 500, fontWeight: 500,
}} }}
@@ -279,7 +279,7 @@ export default function Users() {
roles.length > 0 roles.length > 0
? roles.map((r) => ( ? roles.map((r) => (
<Tag key={r.id} style={{ <Tag key={r.id} style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#CBD5E1' : '#475569', color: isDark ? '#CBD5E1' : '#475569',
}}> }}>
@@ -299,14 +299,14 @@ export default function Users() {
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => openEditModal(record)} onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<SafetyCertificateOutlined />} icon={<SafetyCertificateOutlined />}
onClick={() => openRoleModal(record)} onClick={() => openRoleModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
{record.status === 'active' ? ( {record.status === 'active' ? (
<Popconfirm <Popconfirm
@@ -356,7 +356,7 @@ export default function Users() {
<Space size={8}> <Space size={8}>
<Input <Input
placeholder="搜索用户名..." placeholder="搜索用户名..."
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchText} value={searchText}
onChange={(e) => { onChange={(e) => {
setSearchText(e.target.value); setSearchText(e.target.value);
@@ -379,7 +379,7 @@ export default function Users() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table
@@ -415,7 +415,7 @@ export default function Users() {
label="用户名" label="用户名"
rules={[{ required: true, message: '请输入用户名' }]} rules={[{ required: true, message: '请输入用户名' }]}
> >
<Input prefix={<UserOutlined style={{ color: '#94A3B8' }} />} disabled={!!editUser} /> <Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!editUser} />
</Form.Item> </Form.Item>
{!editUser && ( {!editUser && (
<Form.Item <Form.Item
@@ -465,13 +465,13 @@ export default function Users() {
style={{ style={{
padding: '10px 14px', padding: '10px 14px',
borderRadius: 8, borderRadius: 8,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`, border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC', background: isDark ? '#0B0F1A' : '#f1f5f9',
}} }}
> >
<Checkbox value={r.id}> <Checkbox value={r.id}>
<span style={{ fontWeight: 500 }}>{r.name}</span> <span style={{ fontWeight: 500 }}>{r.name}</span>
<span style={{ color: isDark ? '#475569' : '#94A3B8', marginLeft: 8, fontSize: 12 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', marginLeft: 8, fontSize: 12 }}>
{r.code} {r.code}
</span> </span>
</Checkbox> </Checkbox>

View File

@@ -1,11 +1,13 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd'; import { Col, Row, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography, List, Badge } from 'antd';
import { import {
InfoCircleOutlined, InfoCircleOutlined,
DashboardOutlined, DashboardOutlined,
RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Column, Pie, Funnel, Line } from '@ant-design/charts'; import { Column, Pie, Funnel, Line } from '@ant-design/charts';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes'; import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
import type { ActionQueryDef } from '../../api/plugins';
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants'; import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
// ── 计数动画 Hook ── // ── 计数动画 Hook ──
@@ -44,7 +46,7 @@ function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
const TAG_COLOR_MAP: Record<string, string> = { const TAG_COLOR_MAP: Record<string, string> = {
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444', blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308', purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316', lime: '#84CC16', geekblue: '#60a5fa', volcano: '#F97316',
}; };
function tagStrokeColor(color: string): string { function tagStrokeColor(color: string): string {
@@ -202,7 +204,7 @@ export function SkeletonBreakdownCard({ index }: { index: number }) {
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) { function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, count } = widgetData; const { widget, count } = widgetData;
const animatedValue = useCountUp(count ?? 0); const animatedValue = useCountUp(count ?? 0);
const color = widget.color || '#4F46E5'; const color = widget.color || '#2563eb';
return ( return (
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}> <Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -227,7 +229,7 @@ function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData; const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order); const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' }; const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return ( return (
<WidgetCardShell title={widget.title} widgetType={widget.type}> <WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? ( {chartData.length > 0 ? (
@@ -273,7 +275,7 @@ function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData; const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order); const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' }; const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return ( return (
<WidgetCardShell title={widget.title} widgetType={widget.type}> <WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? ( {chartData.length > 0 ? (
@@ -293,6 +295,146 @@ export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData;
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />; case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />; case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />; case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
case 'stat_cards': return <StatCardsWidget widgetData={widgetData} />;
case 'action_list': return <ActionListWidget widgetData={widgetData} />;
case 'funnel': return <FunnelStageWidget widgetData={widgetData} />;
case 'card_list': return <CardListWidget widgetData={widgetData} />;
default: return null; default: return null;
} }
} }
// ── Manifest Widget 渲染器 ──
/** stat_cards — 多个统计卡片 */
function StatCardsWidget({ widgetData }: { widgetData: WidgetData }) {
const { statCards, widget } = widgetData;
if (!statCards || statCards.length === 0) return <ChartEmpty />;
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.stat_cards} {widget.label || widget.title}</span>} className="erp-fade-in">
<Row gutter={[12, 12]}>
{statCards.map((sc, i) => (
<Col xs={12} sm={6} key={`${sc.card.entity}-${sc.card.label}-${i}`}>
<div style={{
background: `${sc.card.color || '#2563eb'}10`,
borderRadius: 8,
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: `${sc.card.color || '#2563eb'}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: sc.card.color || '#2563eb', fontSize: 18,
}}>
<DashboardOutlined />
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{sc.card.label}</Typography.Text>
<div style={{ fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{sc.value.toLocaleString()}
</div>
</div>
</div>
</Col>
))}
</Row>
</Card>
);
}
/** action_list — 待办列表 */
function ActionListWidget({ widgetData }: { widgetData: WidgetData }) {
const { actionItems, widget } = widgetData;
if (!actionItems) return <ChartEmpty />;
const allItems = actionItems.flatMap((ai) =>
ai.records.map((r) => ({ ...r, _query: ai.query })),
);
const maxItems = widget.max_items ?? 10;
const displayItems = allItems.slice(0, maxItems);
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.action_list} {widget.label || widget.title}</span>} className="erp-fade-in">
{displayItems.length > 0 ? (
<List
size="small"
dataSource={displayItems}
renderItem={(item) => {
const q = item._query as ActionQueryDef;
const title = String(item.data?.[q.label_field] ?? '-');
const subtitle = q.subtitle_field ? String(item.data?.[q.subtitle_field] ?? '') : '';
return (
<List.Item style={{ padding: '8px 0', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<Badge color="blue" />
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div>
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
</div>
<RightOutlined style={{ fontSize: 12, color: 'var(--erp-text-quaternary)' }} />
</div>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待办" />
)}
</Card>
);
}
/** funnel — 阶段漏斗 */
function FunnelStageWidget({ widgetData }: { widgetData: WidgetData }) {
const { data, widget } = widgetData;
const chartData = (widget.lane_order ?? [])
.map((key) => {
const found = data.find((d) => d.key === key);
return { key, count: found?.count ?? 0 };
})
.filter((d) => d.count > 0);
return (
<WidgetCardShell title={widget.label || widget.title} widgetType="funnel_chart">
{chartData.length > 0 ? (
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** card_list — 卡片列表 */
function CardListWidget({ widgetData }: { widgetData: WidgetData }) {
const { records, widget } = widgetData;
const maxItems = widget.max_items ?? 10;
const displayRecords = (records ?? []).slice(0, maxItems);
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.card_list} {widget.label || widget.title}</span>} className="erp-fade-in">
{displayRecords.length > 0 ? (
<List
size="small"
dataSource={displayRecords}
renderItem={(item) => {
const title = String(item.data?.[widget.title_field ?? 'name'] ?? '-');
const subtitle = widget.subtitle_field ? String(item.data?.[widget.subtitle_field] ?? '') : '';
const tagValues = (widget.tags ?? []).map((t) => String(item.data?.[t] ?? '')).filter(Boolean);
return (
<List.Item style={{ padding: '8px 0' }}>
<div style={{ width: '100%' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div>
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
{tagValues.length > 0 && (
<div style={{ marginTop: 4 }}>
{tagValues.map((tv, i) => <Tag key={i} style={{ fontSize: 11 }}>{tv}</Tag>)}
</div>
)}
</div>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}

View File

@@ -19,9 +19,9 @@ import {
// ── 通用调色板 ── // ── 通用调色板 ──
const UNIVERSAL_COLORS = [ const UNIVERSAL_COLORS = [
{ gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' }, { gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' },
{ gradient: 'linear-gradient(135deg, #059669, #10B981)', iconBg: 'rgba(5, 150, 105, 0.12)', tagColor: 'green' }, { gradient: 'linear-gradient(135deg, #059669, #10B981)', iconBg: 'rgba(5, 150, 105, 0.12)', tagColor: 'green' },
{ gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' }, { gradient: 'linear-gradient(135deg, #d97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' },
{ gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', iconBg: 'rgba(124, 58, 237, 0.12)', tagColor: 'volcano' }, { gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', iconBg: 'rgba(124, 58, 237, 0.12)', tagColor: 'volcano' },
{ gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', iconBg: 'rgba(225, 29, 72, 0.12)', tagColor: 'red' }, { gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', iconBg: 'rgba(225, 29, 72, 0.12)', tagColor: 'red' },
{ gradient: 'linear-gradient(135deg, #0891B2, #06B6D4)', iconBg: 'rgba(8, 145, 178, 0.12)', tagColor: 'cyan' }, { gradient: 'linear-gradient(135deg, #0891B2, #06B6D4)', iconBg: 'rgba(8, 145, 178, 0.12)', tagColor: 'cyan' },
@@ -82,6 +82,10 @@ export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
pie_chart: <PieChartOutlined />, pie_chart: <PieChartOutlined />,
funnel_chart: <FunnelPlotOutlined />, funnel_chart: <FunnelPlotOutlined />,
line_chart: <LineChartOutlined />, line_chart: <LineChartOutlined />,
stat_cards: <DashboardOutlined />,
action_list: <AppstoreOutlined />,
funnel: <FunnelPlotOutlined />,
card_list: <DatabaseOutlined />,
}; };
// ── 延迟类名工具 ── // ── 延迟类名工具 ──

View File

@@ -1,6 +1,6 @@
import type React from 'react'; import type React from 'react';
import type { AggregateItem } from '../../api/pluginData'; import type { AggregateItem, PluginDataRecord } from '../../api/pluginData';
import type { DashboardWidget } from '../../api/plugins'; import type { DashboardWidget, StatCardDef, ActionQueryDef } from '../../api/plugins';
// ── 类型定义 ── // ── 类型定义 ──
@@ -23,4 +23,7 @@ export interface WidgetData {
widget: DashboardWidget; widget: DashboardWidget;
data: AggregateItem[]; data: AggregateItem[];
count?: number; count?: number;
records?: PluginDataRecord[];
statCards?: { card: StatCardDef; value: number }[];
actionItems?: { query: ActionQueryDef; records: PluginDataRecord[] }[];
} }

View File

@@ -11,11 +11,11 @@ import type { GraphEdge } from './graphTypes';
/** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */ /** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [ const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' }, { base: '#2563eb', light: '#60a5fa', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' }, { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' }, { base: '#d97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' }, { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' }, { base: '#dc2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' }, { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' }, { base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' }, { base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },

View File

@@ -5,9 +5,9 @@ import type { ColumnsType } from 'antd/es/table';
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates'; import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
const channelMap: Record<string, { label: string; color: string }> = { const channelMap: Record<string, { label: string; color: string }> = {
in_app: { label: '站内', color: '#4F46E5' }, in_app: { label: '站内', color: '#2563eb' },
email: { label: '邮件', color: '#059669' }, email: { label: '邮件', color: '#059669' },
sms: { label: '短信', color: '#D97706' }, sms: { label: '短信', color: '#d97706' },
wechat: { label: '微信', color: '#7C3AED' }, wechat: { label: '微信', color: '#7C3AED' },
}; };
@@ -64,9 +64,9 @@ export default function MessageTemplates() {
key: 'code', key: 'code',
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -80,7 +80,7 @@ export default function MessageTemplates() {
key: 'channel', key: 'channel',
width: 90, width: 90,
render: (c: string) => { render: (c: string) => {
const info = channelMap[c] || { label: c, color: '#64748B' }; const info = channelMap[c] || { label: c, color: '#475569' };
return ( return (
<Tag style={{ <Tag style={{
background: info.color + '15', background: info.color + '15',
@@ -111,7 +111,7 @@ export default function MessageTemplates() {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{v}</span>
), ),
}, },
]; ];
@@ -124,7 +124,7 @@ export default function MessageTemplates() {
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}}> }}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total} {total}
</span> </span>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
@@ -135,7 +135,7 @@ export default function MessageTemplates() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -11,9 +11,9 @@ interface Props {
} }
const priorityStyles: Record<string, { bg: string; color: string; text: string }> = { const priorityStyles: Record<string, { bg: string; color: string; text: string }> = {
urgent: { bg: '#FEF2F2', color: '#DC2626', text: '紧急' }, urgent: { bg: '#FEF2F2', color: '#dc2626', text: '紧急' },
important: { bg: '#FFFBEB', color: '#D97706', text: '重要' }, important: { bg: '#FFFBEB', color: '#d97706', text: '重要' },
normal: { bg: '#EEF2FF', color: '#4F46E5', text: '普通' }, normal: { bg: '#eff6ff', color: '#2563eb', text: '普通' },
}; };
export default function NotificationList({ queryFilter }: Props) { export default function NotificationList({ queryFilter }: Props) {
@@ -83,7 +83,7 @@ export default function NotificationList({ queryFilter }: Props) {
content: ( content: (
<div> <div>
<Paragraph>{record.body}</Paragraph> <Paragraph>{record.body}</Paragraph>
<div style={{ marginTop: 8, color: isDark ? '#475569' : '#94A3B8', fontSize: 12 }}> <div style={{ marginTop: 8, color: isDark ? '#475569' : '#94a3b8', fontSize: 12 }}>
{record.created_at} {record.created_at}
</div> </div>
</div> </div>
@@ -104,7 +104,7 @@ export default function NotificationList({ queryFilter }: Props) {
style={{ style={{
fontWeight: record.is_read ? 400 : 600, fontWeight: record.is_read ? 400 : 600,
cursor: 'pointer', cursor: 'pointer',
color: record.is_read ? (isDark ? '#94A3B8' : '#64748B') : 'inherit', color: record.is_read ? (isDark ? '#94a3b8' : '#475569') : 'inherit',
}} }}
onClick={() => showDetail(record)} onClick={() => showDetail(record)}
> >
@@ -114,7 +114,7 @@ export default function NotificationList({ queryFilter }: Props) {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: '50%', borderRadius: '50%',
background: '#4F46E5', background: '#2563eb',
marginRight: 8, marginRight: 8,
}} /> }} />
)} )}
@@ -128,7 +128,7 @@ export default function NotificationList({ queryFilter }: Props) {
key: 'priority', key: 'priority',
width: 90, width: 90,
render: (p: string) => { render: (p: string) => {
const info = priorityStyles[p] || { bg: '#F1F5F9', color: '#64748B', text: p }; const info = priorityStyles[p] || { bg: '#f8fafc', color: '#475569', text: p };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -146,7 +146,7 @@ export default function NotificationList({ queryFilter }: Props) {
dataIndex: 'sender_type', dataIndex: 'sender_type',
key: 'sender_type', key: 'sender_type',
width: 80, width: 80,
render: (s: string) => <span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{s === 'system' ? '系统' : '用户'}</span>, render: (s: string) => <span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{s === 'system' ? '系统' : '用户'}</span>,
}, },
{ {
title: '状态', title: '状态',
@@ -155,9 +155,9 @@ export default function NotificationList({ queryFilter }: Props) {
width: 80, width: 80,
render: (r: boolean) => ( render: (r: boolean) => (
<Tag style={{ <Tag style={{
background: r ? (isDark ? '#1E293B' : '#F1F5F9') : '#EEF2FF', background: r ? (isDark ? '#0f172a' : '#f8fafc') : '#eff6ff',
border: 'none', border: 'none',
color: r ? (isDark ? '#64748B' : '#94A3B8') : '#4F46E5', color: r ? (isDark ? '#475569' : '#94a3b8') : '#2563eb',
fontWeight: 500, fontWeight: 500,
}}> }}>
{r ? '已读' : '未读'} {r ? '已读' : '未读'}
@@ -170,7 +170,7 @@ export default function NotificationList({ queryFilter }: Props) {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{v}</span>
), ),
}, },
{ {
@@ -185,7 +185,7 @@ export default function NotificationList({ queryFilter }: Props) {
size="small" size="small"
icon={<CheckOutlined />} icon={<CheckOutlined />}
onClick={() => handleMarkRead(record.id)} onClick={() => handleMarkRead(record.id)}
style={{ color: '#4F46E5' }} style={{ color: '#2563eb' }}
/> />
)} )}
<Button <Button
@@ -193,7 +193,7 @@ export default function NotificationList({ queryFilter }: Props) {
size="small" size="small"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => showDetail(record)} onClick={() => showDetail(record)}
style={{ color: isDark ? '#64748B' : '#94A3B8' }} style={{ color: isDark ? '#475569' : '#94a3b8' }}
/> />
<Button <Button
type="text" type="text"
@@ -215,7 +215,7 @@ export default function NotificationList({ queryFilter }: Props) {
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}}> }}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total} {total}
</span> </span>
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}> <Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
@@ -226,7 +226,7 @@ export default function NotificationList({ queryFilter }: Props) {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -48,12 +48,12 @@ export default function NotificationPreferences() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
padding: 24, padding: 24,
maxWidth: 600, maxWidth: 600,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
<BellOutlined style={{ fontSize: 16, color: '#4F46E5' }} /> <BellOutlined style={{ fontSize: 16, color: '#2563eb' }} />
<span style={{ fontSize: 15, fontWeight: 600 }}></span> <span style={{ fontSize: 15, fontWeight: 600 }}></span>
</div> </div>

View File

@@ -5,11 +5,11 @@
// 通用边调色板 // 通用边调色板
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [ const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' }, { base: '#2563eb', light: '#60a5fa', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' }, { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' }, { base: '#d97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' }, { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' }, { base: '#dc2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' }, { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' }, { base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' }, { base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },

View File

@@ -295,8 +295,8 @@ export function drawFullGraph(
const degree = degreeMap.get(node.id) || 0; const degree = degreeMap.get(node.id) || 0;
const r = degreeToRadius(degree, isCenter); const r = degreeToRadius(degree, isCenter);
let nodeColorBase = '#4F46E5'; let nodeColorBase = '#2563eb';
let nodeColorLight = '#818CF8'; let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)'; let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) { if (isCenter) {

View File

@@ -18,8 +18,8 @@ const RESOURCE_TYPE_OPTIONS = [
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = { const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
create: { bg: '#ECFDF5', color: '#059669', text: '创建' }, create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
update: { bg: '#EEF2FF', color: '#4F46E5', text: '更新' }, update: { bg: '#eff6ff', color: '#2563eb', text: '更新' },
delete: { bg: '#FEF2F2', color: '#DC2626', text: '删除' }, delete: { bg: '#FEF2F2', color: '#dc2626', text: '删除' },
}; };
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
@@ -80,7 +80,7 @@ export default function AuditLogViewer() {
key: 'action', key: 'action',
width: 100, width: 100,
render: (action: string) => { render: (action: string) => {
const info = ACTION_STYLES[action] || { bg: '#F1F5F9', color: '#64748B', text: action }; const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -100,7 +100,7 @@ export default function AuditLogViewer() {
width: 120, width: 120,
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#CBD5E1' : '#475569', color: isDark ? '#CBD5E1' : '#475569',
}}> }}>
@@ -115,7 +115,7 @@ export default function AuditLogViewer() {
width: 200, width: 200,
ellipsis: true, ellipsis: true,
render: (v: string) => ( render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}> <span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v} {v}
</span> </span>
), ),
@@ -127,7 +127,7 @@ export default function AuditLogViewer() {
width: 200, width: 200,
ellipsis: true, ellipsis: true,
render: (v: string) => ( render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}> <span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v} {v}
</span> </span>
), ),
@@ -138,7 +138,7 @@ export default function AuditLogViewer() {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (value: string) => ( render: (value: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(value)} {formatDateTime(value)}
</span> </span>
), ),
@@ -156,7 +156,7 @@ export default function AuditLogViewer() {
padding: 12, padding: 12,
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10, borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}> }}>
<Select <Select
allowClear allowClear
@@ -173,7 +173,7 @@ export default function AuditLogViewer() {
value={query.user_id ?? ''} value={query.user_id ?? ''}
onChange={(e) => handleFilterChange('user_id', e.target.value)} onChange={(e) => handleFilterChange('user_id', e.target.value)}
/> />
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8', marginLeft: 'auto' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8', marginLeft: 'auto' }}>
{total} {total}
</span> </span>
</div> </div>
@@ -182,7 +182,7 @@ export default function AuditLogViewer() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -132,7 +132,7 @@ export default function SystemSettings() {
width: 250, width: 250,
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#CBD5E1' : '#475569', color: isDark ? '#CBD5E1' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -162,7 +162,7 @@ export default function SystemSettings() {
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => openEdit(record)} onClick={() => openEdit(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
<Popconfirm <Popconfirm
title="确定删除此设置?" title="确定删除此设置?"
@@ -191,7 +191,7 @@ export default function SystemSettings() {
<Space> <Space>
<Input <Input
placeholder="输入设置键名查询" placeholder="输入设置键名查询"
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchKey} value={searchKey}
onChange={(e) => setSearchKey(e.target.value)} onChange={(e) => setSearchKey(e.target.value)}
onPressEnter={handleSearch} onPressEnter={handleSearch}
@@ -207,7 +207,7 @@ export default function SystemSettings() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -5,8 +5,8 @@ import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks';
const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = { const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = {
approved: { bg: '#ECFDF5', color: '#059669', text: '同意' }, approved: { bg: '#ECFDF5', color: '#059669', text: '同意' },
rejected: { bg: '#FEF2F2', color: '#DC2626', text: '拒绝' }, rejected: { bg: '#FEF2F2', color: '#dc2626', text: '拒绝' },
delegated: { bg: '#EEF2FF', color: '#4F46E5', text: '已委派' }, delegated: { bg: '#eff6ff', color: '#2563eb', text: '已委派' },
}; };
export default function CompletedTasks() { export default function CompletedTasks() {
@@ -50,7 +50,7 @@ export default function CompletedTasks() {
key: 'outcome', key: 'outcome',
width: 100, width: 100,
render: (o: string) => { render: (o: string) => {
const info = outcomeStyles[o] || { bg: '#F1F5F9', color: '#64748B', text: o }; const info = outcomeStyles[o] || { bg: '#f8fafc', color: '#475569', text: o };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -69,7 +69,7 @@ export default function CompletedTasks() {
key: 'completed_at', key: 'completed_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{v ? new Date(v).toLocaleString() : '-'} {v ? new Date(v).toLocaleString() : '-'}
</span> </span>
), ),
@@ -80,7 +80,7 @@ export default function CompletedTasks() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -13,10 +13,10 @@ import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/work
import ProcessViewer from './ProcessViewer'; import ProcessViewer from './ProcessViewer';
const statusStyles: Record<string, { bg: string; color: string; text: string }> = { const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' }, running: { bg: '#eff6ff', color: '#2563eb', text: '运行中' },
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' }, suspended: { bg: '#FFFBEB', color: '#d97706', text: '已挂起' },
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' }, completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' }, terminated: { bg: '#FEF2F2', color: '#dc2626', text: '已终止' },
}; };
export default function InstanceMonitor() { export default function InstanceMonitor() {
@@ -129,7 +129,7 @@ export default function InstanceMonitor() {
key: 'status', key: 'status',
width: 100, width: 100,
render: (s: string) => { render: (s: string) => {
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s }; const info = statusStyles[s] || { bg: '#f8fafc', color: '#475569', text: s };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -154,7 +154,7 @@ export default function InstanceMonitor() {
key: 'started_at', key: 'started_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{new Date(v).toLocaleString()} {new Date(v).toLocaleString()}
</span> </span>
), ),
@@ -214,7 +214,7 @@ export default function InstanceMonitor() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -76,9 +76,9 @@ export default function PendingTasks() {
key: 'business_key', key: 'business_key',
render: (v: string | undefined) => v ? ( render: (v: string | undefined) => v ? (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -93,9 +93,9 @@ export default function PendingTasks() {
width: 100, width: 100,
render: (s: string) => ( render: (s: string) => (
<Tag style={{ <Tag style={{
background: '#EEF2FF', background: '#eff6ff',
border: 'none', border: 'none',
color: '#4F46E5', color: '#2563eb',
fontWeight: 500, fontWeight: 500,
}}> }}>
{s} {s}
@@ -108,7 +108,7 @@ export default function PendingTasks() {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{new Date(v).toLocaleString()} {new Date(v).toLocaleString()}
</span> </span>
), ),
@@ -145,7 +145,7 @@ export default function PendingTasks() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -13,9 +13,9 @@ import {
import ProcessDesigner from './ProcessDesigner'; import ProcessDesigner from './ProcessDesigner';
const statusColors: Record<string, { bg: string; color: string; text: string }> = { const statusColors: Record<string, { bg: string; color: string; text: string }> = {
draft: { bg: '#F1F5F9', color: '#64748B', text: '草稿' }, draft: { bg: '#f8fafc', color: '#475569', text: '草稿' },
published: { bg: '#ECFDF5', color: '#059669', text: '已发布' }, published: { bg: '#ecfdf5', color: '#059669', text: '已发布' },
deprecated: { bg: '#FEF2F2', color: '#DC2626', text: '已弃用' }, deprecated: { bg: '#fef2f2', color: '#dc2626', text: '已弃用' },
}; };
export default function ProcessDefinitions() { export default function ProcessDefinitions() {
@@ -92,9 +92,9 @@ export default function ProcessDefinitions() {
key: 'key', key: 'key',
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#1E293B' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -110,7 +110,7 @@ export default function ProcessDefinitions() {
key: 'status', key: 'status',
width: 100, width: 100,
render: (s: string) => { render: (s: string) => {
const info = statusColors[s] || { bg: '#F1F5F9', color: '#64748B', text: s }; const info = statusColors[s] || { bg: '#f8fafc', color: '#475569', text: s };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -152,7 +152,7 @@ export default function ProcessDefinitions() {
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}}> }}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total} {total}
</span> </span>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
@@ -163,7 +163,7 @@ export default function ProcessDefinitions() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#1E293B' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -11,7 +11,7 @@
*/ */
interface ExprNode { interface ExprNode {
type: 'eq' | 'and' | 'or' | 'not'; type: 'eq' | 'neq' | 'and' | 'or' | 'not';
field?: string; field?: string;
value?: string; value?: string;
left?: ExprNode; left?: ExprNode;
@@ -49,6 +49,16 @@ function tokenize(input: string): string[] {
i += 2; i += 2;
continue; continue;
} }
if (input[i] === '&' && input[i + 1] === '&') {
tokens.push('&&');
i += 2;
continue;
}
if (input[i] === '|' && input[i + 1] === '|') {
tokens.push('||');
i += 2;
continue;
}
let j = i; let j = i;
while ( while (
j < input.length && j < input.length &&
@@ -81,12 +91,12 @@ function parseAtom(tokens: string[]): ExprNode | null {
if (op !== '==' && op !== '!=') return null; if (op !== '==' && op !== '!=') return null;
const rawValue = tokens.shift() || ''; const rawValue = tokens.shift() || '';
const value = rawValue.replace(/^'(.*)'$/, '$1'); const value = rawValue.replace(/^'(.*)'$/, '$1');
return { type: 'eq', field, value }; return { type: op === '!=' ? 'neq' : 'eq', field, value };
} }
function parseAnd(tokens: string[]): ExprNode | null { function parseAnd(tokens: string[]): ExprNode | null {
let left = parseAtom(tokens); let left = parseAtom(tokens);
while (tokens[0] === 'AND') { while (tokens[0] === 'AND' || tokens[0] === '&&') {
tokens.shift(); tokens.shift();
const right = parseAtom(tokens); const right = parseAtom(tokens);
if (left && right) { if (left && right) {
@@ -98,7 +108,7 @@ function parseAnd(tokens: string[]): ExprNode | null {
function parseOr(tokens: string[]): ExprNode | null { function parseOr(tokens: string[]): ExprNode | null {
let left = parseAnd(tokens); let left = parseAnd(tokens);
while (tokens[0] === 'OR') { while (tokens[0] === 'OR' || tokens[0] === '||') {
tokens.shift(); tokens.shift();
const right = parseAnd(tokens); const right = parseAnd(tokens);
if (left && right) { if (left && right) {
@@ -117,6 +127,8 @@ export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): b
switch (node.type) { switch (node.type) {
case 'eq': case 'eq':
return String(values[node.field!] ?? '') === node.value; return String(values[node.field!] ?? '') === node.value;
case 'neq':
return String(values[node.field!] ?? '') !== node.value;
case 'and': case 'and':
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values); return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
case 'or': case 'or':

View File

@@ -0,0 +1,11 @@
[package]
name = "erp-plugin-freelance"
version = "0.1.0"
edition = "2024"
description = "自由职业者工作台 WASM 插件"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
//! 自由职业者工作台 WASM 插件
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct FreelancePlugin;
impl Guest for FreelancePlugin {
fn init() -> Result<(), String> {
Ok(())
}
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
Ok(())
}
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
Ok(())
}
}
export!(FreelancePlugin);

View File

@@ -0,0 +1,11 @@
[package]
name = "erp-plugin-itops"
version = "0.1.0"
edition = "2024"
description = "IT 运维服务台 WASM 插件"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"

View File

@@ -0,0 +1,633 @@
# IT 运维服务台 — plugin.toml
# 汕头市智界科技有限公司 IT 服务行业插件
[metadata]
id = "erp-itops"
name = "IT 运维服务台"
version = "0.1.0"
description = "IT 运维工单管理 + SLA 追踪 + 定期巡检"
author = "ERP Platform"
min_platform_version = "0.1.0"
# ── 权限声明4 实体 × 2 = 8 个权限码)──
[[permissions]]
code = "service_contract.list"
name = "查看维保合同"
description = "查看维保合同列表和详情"
[[permissions]]
code = "service_contract.manage"
name = "管理维保合同"
description = "创建、编辑、删除维保合同"
[[permissions]]
code = "ticket.list"
name = "查看工单"
description = "查看工单列表和详情"
[[permissions]]
code = "ticket.manage"
name = "管理工单"
description = "创建、编辑、删除工单"
[[permissions]]
code = "check_plan.list"
name = "查看巡检计划"
description = "查看巡检计划列表和详情"
[[permissions]]
code = "check_plan.manage"
name = "管理巡检计划"
description = "创建、编辑、删除巡检计划"
[[permissions]]
code = "check_record.list"
name = "查看巡检记录"
description = "查看巡检记录列表"
[[permissions]]
code = "check_record.manage"
name = "管理巡检记录"
description = "创建、编辑、删除巡检记录"
# ── 实体定义 ──
# ── 3.3.1 service_contract维保合同──
[[schema.entities]]
name = "service_contract"
display_name = "维保合同"
[[schema.entities.fields]]
name = "client_id"
field_type = "uuid"
display_name = "客户"
ui_widget = "entity_select"
ref_plugin = "erp-freelance"
ref_entity = "client"
ref_label_field = "name"
ref_search_fields = ["name"]
ref_fallback_label = "外部客户"
[[schema.entities.fields]]
name = "contract_number"
field_type = "string"
required = true
display_name = "合同编号"
unique = true
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "合同名称"
searchable = true
[[schema.entities.fields]]
name = "service_scope"
field_type = "string"
display_name = "服务范围"
ui_widget = "textarea"
[[schema.entities.fields]]
name = "sla_level"
field_type = "string"
required = true
display_name = "SLA 等级"
ui_widget = "select"
filterable = true
default = "standard"
options = [
{ label = "标准", value = "standard" },
{ label = "银牌", value = "silver" },
{ label = "金牌", value = "gold" }
]
[[schema.entities.fields]]
name = "sla_response_hours"
field_type = "integer"
display_name = "SLA 响应时间(小时)"
default = 8
[[schema.entities.fields]]
name = "sla_resolve_hours"
field_type = "integer"
display_name = "SLA 解决时间(小时)"
default = 48
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
default = "active"
options = [
{ label = "生效中", value = "active" },
{ label = "即将到期", value = "expiring" },
{ label = "已过期", value = "expired" },
{ label = "已终止", value = "terminated" }
]
[[schema.entities.fields]]
name = "start_date"
field_type = "date"
required = true
display_name = "开始日期"
[[schema.entities.fields]]
name = "end_date"
field_type = "date"
required = true
display_name = "结束日期"
[[schema.entities.fields]]
name = "amount"
field_type = "decimal"
display_name = "合同金额"
sortable = true
[[schema.entities.fields]]
name = "payment_terms"
field_type = "string"
display_name = "付款条款"
[[schema.entities.fields]]
name = "notes"
field_type = "string"
display_name = "备注"
ui_widget = "textarea"
[[schema.entities.relations]]
entity = "ticket"
foreign_key = "contract_id"
on_delete = "nullify"
name = "tickets"
type = "one_to_many"
display_field = "title"
[[schema.entities.relations]]
entity = "check_plan"
foreign_key = "contract_id"
on_delete = "cascade"
name = "check_plans"
type = "one_to_many"
display_field = "name"
# ── 3.3.2 ticket工单──
[[schema.entities]]
name = "ticket"
display_name = "工单"
[[schema.entities.fields]]
name = "contract_id"
field_type = "uuid"
display_name = "维保合同"
ui_widget = "entity_select"
ref_entity = "service_contract"
ref_label_field = "name"
ref_search_fields = ["name"]
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]]
name = "client_id"
field_type = "uuid"
display_name = "客户"
ui_widget = "entity_select"
ref_plugin = "erp-freelance"
ref_entity = "client"
ref_label_field = "name"
ref_search_fields = ["name"]
ref_fallback_label = "外部客户"
[[schema.entities.fields]]
name = "title"
field_type = "string"
required = true
display_name = "工单标题"
searchable = true
[[schema.entities.fields]]
name = "type"
field_type = "string"
required = true
display_name = "类型"
ui_widget = "select"
filterable = true
default = "fault"
options = [
{ label = "故障", value = "fault" },
{ label = "巡检", value = "check" },
{ label = "咨询", value = "consult" },
{ label = "变更", value = "change" },
{ label = "其他", value = "other" }
]
[[schema.entities.fields]]
name = "priority"
field_type = "string"
required = true
display_name = "优先级"
ui_widget = "select"
filterable = true
default = "medium"
options = [
{ label = "紧急", value = "urgent" },
{ label = "高", value = "high" },
{ label = "中", value = "medium" },
{ label = "低", value = "low" }
]
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
default = "open"
options = [
{ label = "待处理", value = "open" },
{ label = "处理中", value = "in_progress" },
{ label = "等待客户", value = "waiting_client" },
{ label = "已解决", value = "resolved" },
{ label = "已关闭", value = "closed" }
]
[[schema.entities.fields]]
name = "channel"
field_type = "string"
display_name = "来源渠道"
ui_widget = "select"
options = [
{ label = "电话", value = "phone" },
{ label = "微信", value = "wechat" },
{ label = "邮件", value = "email" },
{ label = "系统", value = "system" }
]
[[schema.entities.fields]]
name = "description"
field_type = "string"
display_name = "问题描述"
ui_widget = "textarea"
[[schema.entities.fields]]
name = "resolution"
field_type = "string"
display_name = "解决方案"
ui_widget = "textarea"
visible_when = "status == 'resolved' || status == 'closed'"
[[schema.entities.fields]]
name = "responded_at"
field_type = "date_time"
display_name = "首次响应时间"
visible_when = "status != 'open'"
[[schema.entities.fields]]
name = "resolved_at"
field_type = "date_time"
display_name = "解决时间"
visible_when = "status == 'resolved' || status == 'closed'"
[[schema.entities.fields]]
name = "closed_at"
field_type = "date_time"
display_name = "关闭时间"
visible_when = "status == 'closed'"
# ── 3.3.3 check_plan巡检计划──
[[schema.entities]]
name = "check_plan"
display_name = "巡检计划"
[[schema.entities.fields]]
name = "contract_id"
field_type = "uuid"
required = true
display_name = "维保合同"
ui_widget = "entity_select"
ref_entity = "service_contract"
ref_label_field = "name"
ref_search_fields = ["name"]
[[schema.entities.fields]]
name = "client_id"
field_type = "uuid"
display_name = "客户"
ui_widget = "entity_select"
ref_plugin = "erp-freelance"
ref_entity = "client"
ref_label_field = "name"
ref_search_fields = ["name"]
ref_fallback_label = "外部客户"
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "计划名称"
searchable = true
[[schema.entities.fields]]
name = "frequency"
field_type = "string"
required = true
display_name = "巡检频率"
ui_widget = "select"
options = [
{ label = "每周", value = "weekly" },
{ label = "每两周", value = "biweekly" },
{ label = "每月", value = "monthly" },
{ label = "每季度", value = "quarterly" }
]
[[schema.entities.fields]]
name = "check_items"
field_type = "json"
display_name = "检查项"
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
default = "active"
options = [
{ label = "启用", value = "active" },
{ label = "停用", value = "inactive" }
]
[[schema.entities.fields]]
name = "next_check_date"
field_type = "date"
display_name = "下次巡检日期"
[[schema.entities.fields]]
name = "notes"
field_type = "string"
display_name = "备注"
ui_widget = "textarea"
[[schema.entities.relations]]
entity = "check_record"
foreign_key = "plan_id"
on_delete = "cascade"
name = "records"
type = "one_to_many"
display_field = "check_date"
# ── 3.3.4 check_record巡检记录──
[[schema.entities]]
name = "check_record"
display_name = "巡检记录"
[[schema.entities.fields]]
name = "plan_id"
field_type = "uuid"
required = true
display_name = "巡检计划"
ui_widget = "entity_select"
ref_entity = "check_plan"
ref_label_field = "name"
ref_search_fields = ["name"]
[[schema.entities.fields]]
name = "contract_id"
field_type = "uuid"
display_name = "维保合同"
ref_entity = "service_contract"
cascade_from = "plan_id"
cascade_filter = "contract_id"
[[schema.entities.fields]]
name = "client_id"
field_type = "uuid"
display_name = "客户"
ui_widget = "entity_select"
ref_plugin = "erp-freelance"
ref_entity = "client"
ref_label_field = "name"
ref_search_fields = ["name"]
ref_fallback_label = "外部客户"
[[schema.entities.fields]]
name = "check_date"
field_type = "date"
required = true
display_name = "巡检日期"
[[schema.entities.fields]]
name = "result"
field_type = "string"
required = true
display_name = "结果"
ui_widget = "select"
filterable = true
options = [
{ label = "正常", value = "normal" },
{ label = "有异常", value = "abnormal" }
]
[[schema.entities.fields]]
name = "items_data"
field_type = "json"
display_name = "检查项结果"
[[schema.entities.fields]]
name = "issues_found"
field_type = "string"
display_name = "发现的问题"
ui_widget = "textarea"
visible_when = "result == 'abnormal'"
[[schema.entities.fields]]
name = "actions_taken"
field_type = "string"
display_name = "采取措施"
ui_widget = "textarea"
visible_when = "result == 'abnormal'"
[[schema.entities.fields]]
name = "notes"
field_type = "string"
display_name = "备注"
ui_widget = "textarea"
# ── 插件配置 ──
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
# ── 触发事件 ──
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
# ── 编号规则 ──
[[numbering]]
entity = "service_contract"
field = "contract_number"
prefix = "SC"
format = "{PREFIX}-{YEAR}-{SEQ}"
seq_length = 4
# ── 打印模板 ──
[[templates]]
name = "service_contract_pdf"
display_name = "维保合同"
entity = "service_contract"
format = "pdf"
# ── 页面设计 ──
# 页面 0运维概览仪表盘
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
# 页面 1合同管理 + 详情
[[ui.pages]]
type = "crud"
entity = "service_contract"
label = "合同管理"
icon = "file-text"
enable_search = true
[[ui.pages]]
type = "detail"
entity = "service_contract"
label = "合同详情"
[[ui.pages.sections]]
type = "fields"
label = "合同信息"
fields = ["name", "client_id", "service_scope", "sla_level", "sla_response_hours", "sla_resolve_hours", "status", "start_date", "end_date", "amount", "payment_terms", "notes"]
[[ui.pages.sections]]
type = "crud"
label = "工单"
entity = "ticket"
filter_field = "contract_id"
[[ui.pages.sections]]
type = "crud"
label = "巡检计划"
entity = "check_plan"
filter_field = "contract_id"
[[ui.pages.sections]]
type = "crud"
label = "巡检记录"
entity = "check_record"
filter_field = "contract_id"
# 页面 2工单中心
[[ui.pages]]
type = "tabs"
label = "工单中心"
icon = "tool"
[[ui.pages.tabs]]
label = "工单列表"
type = "crud"
entity = "ticket"
enable_search = true
[[ui.pages.tabs]]
label = "巡检计划"
type = "crud"
entity = "check_plan"
enable_search = true
[[ui.pages.tabs]]
label = "巡检记录"
type = "crud"
entity = "check_record"
enable_search = true

View File

@@ -0,0 +1,26 @@
//! IT 运维服务台 WASM 插件
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct ItopsPlugin;
impl Guest for ItopsPlugin {
fn init() -> Result<(), String> {
Ok(())
}
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
Ok(())
}
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
Ok(())
}
}
export!(ItopsPlugin);

View File

@@ -284,6 +284,8 @@ pub enum PluginPageType {
label: String, label: String,
#[serde(default)] #[serde(default)]
icon: Option<String>, icon: Option<String>,
#[serde(default)]
widgets: Vec<PluginWidget>,
}, },
#[serde(rename = "kanban")] #[serde(rename = "kanban")]
Kanban { Kanban {
@@ -304,6 +306,80 @@ pub enum PluginPageType {
}, },
} }
/// Dashboard Widget 类型
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
#[serde(rename = "stat_cards")]
StatCards {
label: String,
cards: Vec<StatCard>,
},
#[serde(rename = "action_list")]
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
#[serde(rename = "funnel")]
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
#[serde(rename = "card_list")]
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
/// 统计卡片
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
/// 待办行动查询
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
/// 插件页面区段(用于 detail 页面类型) /// 插件页面区段(用于 detail 页面类型)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")] #[serde(tag = "type")]
@@ -1553,4 +1629,153 @@ name = "管理发票"
assert_eq!(entities[0].importable, Some(true)); assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true)); assert_eq!(entities[0].exportable, Some(true));
} }
#[test]
fn parse_dashboard_with_widgets() {
let toml = r##"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[schema.entities.fields]]
name = "status"
field_type = "string"
display_name = "状态"
[[schema.entities.fields]]
name = "amount"
field_type = "decimal"
display_name = "金额"
[ui]
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "count"
label = "总发票"
icon = "FileTextOutlined"
color = "#1890ff"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "sum"
field = "amount"
filter = "status == 'pending'"
label = "待收金额"
icon = "DollarOutlined"
color = "#faad14"
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
[[ui.pages.widgets.queries]]
entity = "invoice"
filter = "status == 'overdue'"
sort = "due_date asc"
label_field = "invoice_number"
subtitle_field = "amount"
action = "open_invoice"
icon = "warning"
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "invoice"
lane_field = "status"
value_field = "amount"
lane_order = ["pending", "issued", "paid"]
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "invoice"
filter = "status == 'active'"
max_items = 10
title_field = "invoice_number"
subtitle_field = "amount"
tags = ["status"]
"##;
let manifest = parse_manifest(toml).unwrap();
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
match &ui.pages[0] {
PluginPageType::Dashboard {
label, icon, widgets,
} => {
assert_eq!(label, "工作台");
assert_eq!(icon.as_deref(), Some("DashboardOutlined"));
assert_eq!(widgets.len(), 4);
// stat_cards
match &widgets[0] {
PluginWidget::StatCards { label, cards } => {
assert_eq!(label, "财务概览");
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].entity, "invoice");
assert_eq!(cards[0].aggregate.as_deref(), Some("count"));
assert_eq!(cards[1].aggregate.as_deref(), Some("sum"));
assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'"));
}
_ => panic!("Expected StatCards"),
}
// action_list
match &widgets[1] {
PluginWidget::ActionList {
label, max_items, queries,
} => {
assert_eq!(label, "紧急待办");
assert_eq!(*max_items, Some(5));
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].entity, "invoice");
assert_eq!(queries[0].action, "open_invoice");
}
_ => panic!("Expected ActionList"),
}
// funnel
match &widgets[2] {
PluginWidget::Funnel {
label, entity, lane_field, value_field, lane_order,
} => {
assert_eq!(label, "商机漏斗");
assert_eq!(entity, "invoice");
assert_eq!(lane_field, "status");
assert_eq!(value_field.as_deref(), Some("amount"));
assert_eq!(lane_order, &["pending", "issued", "paid"]);
}
_ => panic!("Expected Funnel"),
}
// card_list
match &widgets[3] {
PluginWidget::CardList {
label, entity, title_field, ..
} => {
assert_eq!(label, "活跃项目");
assert_eq!(entity, "invoice");
assert_eq!(title_field, "invoice_number");
}
_ => panic!("Expected CardList"),
}
}
_ => panic!("Expected Dashboard page type"),
}
}
} }

View File

@@ -0,0 +1,484 @@
# 汕头市智界科技 IT 服务插件 — 实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为汕头市智界科技有限公司创建 freelance自由职业者工作台和 itopsIT 运维服务台)两个 WASM 插件,覆盖其全部 12 条经营范围。
**Architecture:** 两个独立的 WASM 插件 crate每个包含 Cargo.tomlcdylib、src/lib.rsGuest trait 实现、plugin.toml声明式 schema。通过插件安装 API 上传到系统平台自动创建动态表、注册权限、生成前端页面。itops 通过 ref_plugin 跨插件引用 freelance 的 client 实体。
**Tech Stack:** Rust (wit-bindgen 0.55, cdylib → WASM Component)、TOML manifest、Axum Host API
**Spec:** `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
---
## Chunk 1: freelance 插件
### Task 1: 创建 crate 目录和 Cargo.toml
**Files:**
- Create: `crates/erp-plugin-freelance/Cargo.toml`
- Create: `crates/erp-plugin-freelance/src/lib.rs`(空文件占位)
- [ ] **Step 1: 创建目录结构**
```bash
mkdir -p crates/erp-plugin-freelance/src
```
- [ ] **Step 2: 编写 Cargo.toml**
创建 `crates/erp-plugin-freelance/Cargo.toml`
```toml
[package]
name = "erp-plugin-freelance"
version = "0.1.0"
edition = "2024"
description = "自由职业者工作台 WASM 插件"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
```
- [ ] **Step 3: 编写 src/lib.rs**
创建 `crates/erp-plugin-freelance/src/lib.rs`
```rust
//! 自由职业者工作台 WASM 插件
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct FreelancePlugin;
impl Guest for FreelancePlugin {
fn init() -> Result<(), String> {
Ok(())
}
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
Ok(())
}
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
Ok(())
}
}
export!(FreelancePlugin);
```
- [ ] **Step 4: 注册到 workspace**
编辑根 `Cargo.toml`,在 `members` 数组末尾添加:
```toml
"crates/erp-plugin-freelance",
```
- [ ] **Step 5: 验证编译**
```bash
cargo check -p erp-plugin-freelance
```
Expected: 编译通过,无错误
- [ ] **Step 6: Commit**
```bash
git add crates/erp-plugin-freelance/ Cargo.toml
git commit -m "feat(freelance): 创建插件 crate 骨架"
```
---
### Task 2: 编写 plugin.tomlfreelance
**Files:**
- Create: `crates/erp-plugin-freelance/plugin.toml`
- [ ] **Step 1: 从设计规格文档复制完整 plugin.toml 内容**
从设计规格 `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md` 中提取 2.1(元数据)+ 2.2(权限)+ 2.310 个实体)+ 2.4(编号规则)+ 2.5(页面声明)的所有 TOML 内容,合并为完整的 `plugin.toml` 文件。
文件结构:
1. `[metadata]`
2. `[[permissions]]` × 20
3. `[[schema.entities]]` × 10client, opportunity, quote, quote_line, contract, project, task, time_entry, invoice, expense每个实体包含 fields 和 relations
4. `[[numbering]]` × 3quote_number, contract_number, invoice_number
5. `[[ui.pages]]` × 7dashboard, tabs+detail+kanban for client, crud+detail for project, tabs for finance, crud for expense
注意要点:
- client 实体必须标记 `is_public = true`(被 itops 跨插件引用)
- quote 到 quote_line 有 cascade 关系
- project 到 task 和 time_entry 有 cascade 关系
- 所有 uuid 引用字段使用 `ui_widget = "entity_select"` + `ref_label_field` + `ref_search_fields`
- 所有 select 字段使用 `options = [{ label = "X", value = "x" }]` 格式
- 长文本使用 `field_type = "string"` + `ui_widget = "textarea"`
- 金额使用 `field_type = "decimal"`
- 时间戳使用 `field_type = "date_time"`
- [ ] **Step 2: 验证 TOML 格式**
```bash
cargo check -p erp-plugin-freelance
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-plugin-freelance/plugin.toml
git commit -m "feat(freelance): 添加 plugin.toml — 10 实体/20 权限/7 页面"
```
---
### Task 3: 编译 WASM 并安装
- [ ] **Step 1: 编译为 WASM**
```bash
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
```
Expected: 编译成功,产出 `target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm`
- [ ] **Step 2: 转换为 Component**
```bash
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
```
- [ ] **Step 3: 检查产物大小**
```bash
ls -la target/erp_plugin_freelance.component.wasm
```
Expected: < 100KBCRM 约 22KB
- [ ] **Step 4: 启动后端服务**
```bash
cd crates/erp-server && cargo run
```
等待服务启动完成(看到 "listening on 0.0.0.0:3000" 日志)
- [ ] **Step 5: 登录获取 Token**
```bash
curl -s -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' | jq -r '.data.access_token'
```
保存输出的 token。
- [ ] **Step 6: 上传安装插件**
```bash
TOKEN="<上一步的 token>"
MANIFEST=$(cat crates/erp-plugin-freelance/plugin.toml)
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
-H "Authorization: Bearer $TOKEN" \
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
-F "manifest=$MANIST"
```
Expected: 返回插件 ID状态为 `installed`
- [ ] **Step 7: 启用插件**
使用上一步返回的插件 ID
```bash
PLUGIN_ID="<返回的插件 ID>"
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
-H "Authorization: Bearer $TOKEN"
```
Expected: 状态变为 `running`
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat(freelance): 编译 WASM 并验证安装"
```
---
### Task 4: 浏览器验证 freelance 插件
- [ ] **Step 1: 打开浏览器访问 http://localhost:5174**
- [ ] **Step 2: 登录后检查侧边栏**
Expected: 看到"自由职业者工作台"菜单组,包含:工作台、客户管理、商机看板、项目管理、项目详情、财务中心、支出管理
- [ ] **Step 3: 测试客户 CRUD**
进入客户管理 → 新增客户(填写名称、联系人、电话、行业等)→ 保存 → 列表中可见
- [ ] **Step 4: 测试项目 → 任务级联**
进入项目管理 → 新增项目 → 进入项目详情 → 新增任务 → 验证任务关联到项目
- [ ] **Step 5: 测试报价 → 报价明细级联**
进入财务中心 → 报价管理 tab → 新增报价 → 验证明细行可添加
- [ ] **Step 6: 测试商机看板**
进入商机看板 → 新增商机 → 拖拽改变阶段 → 验证数据更新
- [ ] **Step 7: 验证数据库表创建**
```bash
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_freelance_*"
```
Expected: 看到 10 张动态表
---
## Chunk 2: itops 插件
### Task 5: 创建 itops 插件 crate
**Files:**
- Create: `crates/erp-plugin-itops/Cargo.toml`
- Create: `crates/erp-plugin-itops/src/lib.rs`
- Create: `crates/erp-plugin-itops/plugin.toml`
- [ ] **Step 1: 创建目录结构**
```bash
mkdir -p crates/erp-plugin-itops/src
```
- [ ] **Step 2: 编写 Cargo.toml**
创建 `crates/erp-plugin-itops/Cargo.toml`
```toml
[package]
name = "erp-plugin-itops"
version = "0.1.0"
edition = "2024"
description = "IT 运维服务台 WASM 插件"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
```
- [ ] **Step 3: 编写 src/lib.rs**
创建 `crates/erp-plugin-itops/src/lib.rs`
```rust
//! IT 运维服务台 WASM 插件
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct ItopsPlugin;
impl Guest for ItopsPlugin {
fn init() -> Result<(), String> {
Ok(())
}
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
Ok(())
}
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
Ok(())
}
}
export!(ItopsPlugin);
```
- [ ] **Step 4: 编写 plugin.toml**
从设计规格文档 Section 3 提取完整内容:
1. `[metadata]` — id="erp-itops",无 dependencies松耦合
2. `[[permissions]]` × 8
3. `[[schema.entities]]` × 4service_contract, ticket, check_plan, check_record每个实体包含 fields 和 relations
4. `[[numbering]]` × 1contract_number
5. `[[ui.pages]]` × 4crud+detail for service_contract, tabs for ticket center
关键注意点:
- 4 个实体的 `client_id` 字段都使用 `ref_plugin = "erp-freelance"` + `ref_fallback_label = "外部客户"`
- `filterable` 只用于 string 类型的 status/type/category 字段,不用于 uuid 字段
- `check_items``items_data` 使用 `field_type = "json"`
- `responded_at` / `resolved_at` / `closed_at` 使用 `field_type = "date_time"`
- [ ] **Step 5: 注册到 workspace**
编辑根 `Cargo.toml`,在 members 数组末尾添加:
```toml
"crates/erp-plugin-itops",
```
- [ ] **Step 6: 验证编译**
```bash
cargo check -p erp-plugin-itops
```
- [ ] **Step 7: Commit**
```bash
git add crates/erp-plugin-itops/ Cargo.toml
git commit -m "feat(itops): 创建 IT 运维服务台插件 — 4 实体/8 权限/4 页面"
```
---
### Task 6: 编译 WASM 并安装 itops
- [ ] **Step 1: 编译为 WASM**
```bash
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
```
- [ ] **Step 2: 转换为 Component**
```bash
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
```
- [ ] **Step 3: 上传安装插件**
```bash
TOKEN="<之前获取的 token>"
MANIFEST=$(cat crates/erp-plugin-itops/plugin.toml)
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
-H "Authorization: Bearer $TOKEN" \
-F "wasm=@target/erp_plugin_itops.component.wasm" \
-F "manifest=$MANIFEST"
```
- [ ] **Step 4: 启用插件**
```bash
PLUGIN_ID="<返回的插件 ID>"
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
-H "Authorization: Bearer $TOKEN"
```
---
### Task 7: 浏览器验证 itops 插件
- [ ] **Step 1: 检查侧边栏**
Expected: 看到"IT 运维服务台"菜单组,包含:合同管理、合同详情、工单中心
- [ ] **Step 2: 测试维保合同 CRUD**
进入合同管理 → 新增维保合同(选择客户时验证:如 freelance 已安装,客户下拉显示 freelance 的客户列表)
- [ ] **Step 3: 测试跨插件引用**
场景 Afreelance 已安装):创建工单时 client_id 字段显示为下拉选择器,可搜索 freelance.client
场景 Bfreelance 未安装client_id 降级为文本输入,显示"外部客户"
- [ ] **Step 4: 测试合同 → 工单 → 巡检级联**
进入合同详情 → 工单 tab → 新增工单 → 巡检计划 tab → 新增巡检计划 → 巡检记录 tab → 新增巡检记录
- [ ] **Step 5: 验证数据库表**
```bash
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_itops_*"
```
Expected: 看到 4 张动态表
---
## Chunk 3: 集成验证
### Task 8: 全链路端到端验证
- [ ] **Step 1: 创建客户**
freelance → 客户管理 → 新增客户"汕头市XX科技有限公司"
- [ ] **Step 2: 创建商机**
商机看板 → 新增商机 → 选择客户 → 填写"官网开发"→ 拖拽到"成交"阶段
- [ ] **Step 3: 创建报价单**
财务中心 → 报价管理 → 新增报价 → 选择客户 → 添加明细行 → 保存
- [ ] **Step 4: 创建合同**
财务中心 → 合同管理 → 新增合同 → 选择客户 → 填写金额和日期 → 保存
- [ ] **Step 5: 创建项目**
项目管理 → 新增项目 → 选择客户和合同 → 填写"官网开发项目" → 添加任务 → 记录工时
- [ ] **Step 6: 创建发票**
财务中心 → 发票/收款 → 新增发票 → 选择客户和项目 → 填写金额 → 标记已收款
- [ ] **Step 7: 创建运维工单**
itops → 合同管理 → 新增维保合同 → 选择客户(验证跨插件引用)→ 保存
itops → 工单中心 → 新增工单 → 选择客户和合同 → 保存
- [ ] **Step 8: 记录支出**
freelance → 支出管理 → 新增支出 → 选择类别"云服务" → 填写金额 → 保存
- [ ] **Step 9: 提交并推送**
```bash
git add -A
git commit -m "feat(freelance,itops): 汕头市智界科技 IT 服务行业插件验证通过"
git push
```
---
## 关键参考文件
| 文件 | 用途 |
|------|------|
| `crates/erp-plugin-crm/Cargo.toml` | Cargo.toml 模板参考 |
| `crates/erp-plugin-crm/src/lib.rs` | lib.rs 代码模式参考 |
| `crates/erp-plugin-crm/plugin.toml` | plugin.toml 格式参考(同插件内引用) |
| `crates/erp-plugin-inventory/plugin.toml` | 跨插件引用格式参考ref_plugin |
| `crates/erp-plugin/src/manifest.rs` | PluginField/PluginFieldType 完整定义 |
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义 |
| `wiki/infrastructure.md` | 数据库连接、端口、登录凭据 |
| `wiki/wasm-plugin.md` | 插件制作完整流程 |

View File

@@ -0,0 +1,587 @@
# freelance + itops 插件增强实施计划
> 日期: 2026-04-20
> 对应规格: `docs/superpowers/specs/2026-04-20-freelance-itops-plugin-enhancement-design.md`
> 前置: 两插件已部署freelance 10 实体/20 权限itops 4 实体/8 权限)
---
## 总览
| Phase | 内容 | 类型 | 依赖 |
|-------|------|------|------|
| P1 | freelance Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
| P2 | itops Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
| P3 | freelance Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
| P4 | itops Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
| P5 | 平台 dashboard widgets 扩展 | manifest.rs + 前端 | P1-P4 完成 |
| P6 | freelance + itops Layer 2 — 仪表盘 | plugin.toml + 前端 | P5 完成 |
P1-P4 可并行P5-P6 顺序依赖。
---
## Phase 1: freelance Layer 1 — 智能业务引擎
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
### Task 1.1: 新增 `[settings]` 段落
`[[numbering]]` 之前插入 7 个配置项:
```toml
[settings]
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### Task 1.2: 新增 `[[trigger_events]]` 段落
`[settings]` 之后插入 5 个触发事件:
```toml
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
```
### Task 1.3: 追加 cascade 属性5 处已有字段)
**1.3a** contract.opportunity_id第 450-454 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3b** contract.quote_id第 456-460 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3c** invoice.project_id第 796-800 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3d** invoice.contract_id第 802-806 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3e** time_entry.task_id第 745-751 行)追加:
```toml
cascade_from = "project_id"
cascade_filter = "project_id"
```
### Task 1.4: 追加 visible_when 属性4 处已有字段)
**1.4a** invoice.payment_date第 860-863 行)追加:
```toml
visible_when = "status == 'paid' || status == 'partial'"
```
**1.4b** contract.paid_amount第 516-520 行)追加:
```toml
visible_when = "status != 'drafting'"
```
**1.4c** task.actual_hours第 727-730 行)追加:
```toml
visible_when = "status != 'todo'"
```
**1.4d** quote.total_amount第 357-361 行)追加:
```toml
visible_when = "status != 'draft'"
```
### Task 1.5: 追加 validation 属性2 处已有字段)
**1.5a** client.phone第 135-138 行)追加:
```toml
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
```
**1.5b** client.email第 140-143 行)追加:
```toml
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
```
### Task 1.6: 编译 WASM + 升级插件
```bash
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
```
通过 API 升级:
```bash
# 上传新版本 WASM
curl -X POST http://localhost:3000/api/v1/admin/plugins/{plugin_id}/upgrade \
-H "Authorization: Bearer {token}" \
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
-F "manifest=@crates/erp-plugin-freelance/plugin.toml"
```
### Task 1.7: 验证
- [ ] `cargo check` 通过
- [ ] 重新登录获取新 JWT权限可能变化
- [ ] 前端打开 freelance 插件 → 设置页面可见 7 个配置项
- [ ] 创建客户 → phone 格式错误时提示校验信息
- [ ] 创建客户 → email 格式错误时提示校验信息
- [ ] 创建合同 → 选客户后 opportunity_id 和 quote_id 自动过滤
- [ ] 创建发票 → 选客户后 project_id 和 contract_id 自动过滤
- [ ] 创建工时 → 选项目后 task_id 自动过滤
- [ ] invoice 状态为 pending 时payment_date 字段不显示
- [ ] contract 状态为 drafting 时paid_amount 字段不显示
- [ ] 触发事件:更新商机阶段 → 消息中心收到通知
- [ ] `git add && git commit && git push`
---
## Phase 2: itops Layer 1 — 智能业务引擎
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
### Task 2.1: 新增 `[settings]` 段落
`[[numbering]]` 之前插入 4 个配置项:
```toml
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### Task 2.2: 新增 `[[trigger_events]]` 段落
`[settings]` 之后插入 4 个触发事件:
```toml
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
```
### Task 2.3: 追加 cascade 属性2 处已有字段)
**2.3a** ticket.contract_id第 186-192 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**2.3b** check_record.contract_id第 398-400 行)追加:
```toml
cascade_from = "plan_id"
cascade_filter = "contract_id"
```
### Task 2.4: 追加 visible_when 属性6 处已有字段)
**2.4a** ticket.resolution → `visible_when = "status == 'resolved' || status == 'closed'"`
**2.4b** ticket.responded_at → `visible_when = "status != 'open'"`
**2.4c** ticket.resolved_at → `visible_when = "status == 'resolved' || status == 'closed'"`
**2.4d** ticket.closed_at → `visible_when = "status == 'closed'"`
**2.4e** check_record.issues_found → `visible_when = "result == 'abnormal'"`
**2.4f** check_record.actions_taken → `visible_when = "result == 'abnormal'"`
### Task 2.5: 追加 validation 属性1 处已有字段)
**2.5a** service_contract.contract_number第 73-78 行)追加:
```toml
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
```
### Task 2.6: 编译 WASM + 升级插件
```bash
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
```
### Task 2.7: 验证
- [ ] `cargo check` 通过
- [ ] 重新登录获取新 JWT
- [ ] 前端打开 itops 插件 → 设置页面可见 4 个配置项
- [ ] 创建工单 → 选客户后 contract_id 自动过滤
- [ ] 工单状态为 open 时resolution/resolved_at/closed_at 不显示
- [ ] 工单状态改为 resolved → resolution 和 resolved_at 出现
- [ ] 巡检记录结果为 normal → issues_found/actions_taken 不显示
- [ ] 巡检记录结果改为 abnormal → issues_found/actions_taken 出现
- [ ] 触发事件:创建工单 → 消息中心收到通知
- [ ] `git add && git commit && git push`
---
## Phase 3: freelance Layer 3 — PDF 模板
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
### Task 3.1: 新增 `[[templates]]` 段落3 个模板)
`[[ui.pages]]` 之前插入报价单、发票、合同 3 个 PDF 模板。
**报价单模板** (`quote_pdf`)
- entity = "quote"
- 包含 Handlebars 语法:`{{quote_number}}`, `{{client.name}}`, `{{#each lines}}`
- 表格渲染item_name / description / quantity / unit_price / amount
- 底部subtotal / tax_rate / total_amount
**发票模板** (`invoice_pdf`)
- entity = "invoice"
- grid 布局client.name / type / issue_date / due_date
- 大字金额:`¥{{amount}}`
- 状态 badge
**合同模板** (`contract_pdf`)
- entity = "contract"
- 签章区域:甲方/乙方
- parties 区块client.name / amount / paid_amount / 期限 / payment_terms
### Task 3.2: 编译 WASM + 升级
同 Task 1.6 流程。
### Task 3.3: 验证
- [ ] 前端打开报价单详情 → 可见"生成 PDF"按钮
- [ ] 点击生成 → 下载 PDF内容包含正确的字段值
- [ ] 发票 PDF → 金额/客户名正确
- [ ] 合同 PDF → 签章区域正确
- [ ] `git add && git commit && git push`
---
## Phase 4: itops Layer 3 — 维保合同 PDF 模板
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
### Task 4.1: 新增 `[[templates]]` 段落1 个模板)
维保合同模板 (`service_contract_pdf`)
- entity = "service_contract"
- SLA 承诺框:响应/解决时间
- grid 布局client.name / amount / 期限 / status
- 服务范围 / 付款条款 / 签章区
### Task 4.2: 编译 WASM + 升级
同 Task 2.6 流程。
### Task 4.3: 验证
- [ ] 前端打开维保合同详情 → 可见"生成 PDF"按钮
- [ ] 点击生成 → 下载 PDFSLA 承诺正确
- [ ] `git add && git commit && git push`
---
## Phase 5: 平台 dashboard widgets 扩展
> **注意:** 此阶段需要修改平台 Rust 代码 + 前端代码,不是纯 plugin.toml 改动。
### Task 5.1: 扩展 manifest.rs — 定义 PluginWidget 类型
**目标文件:** `crates/erp-plugin/src/manifest.rs`
`PluginPageType::Dashboard` 结构体中新增 `widgets` 字段:
```rust
// PluginPageType::Dashboard 新增字段
Dashboard {
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
widgets: Option<Vec<PluginWidget>>, // 新增
},
```
定义 `PluginWidget` 枚举及其子类型:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
StatCards {
label: String,
cards: Vec<StatCard>,
},
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>, // count, sum
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
```
### Task 5.2: 扩展插件 API — 返回 widgets 数据
**目标文件:** `crates/erp-plugin/src/module.rs`
新增 API 端点,为 dashboard widgets 提供数据:
- `GET /api/v1/plugins/{plugin_id}/dashboard/widgets` — 返回 widgets 定义
- `GET /api/v1/plugins/{plugin_id}/dashboard/data` — 返回 widgets 聚合数据(调用已有 count/aggregate API
### Task 5.3: 前端渲染 dashboard widgets
**目标目录:** `apps/web/src/`
新增组件:
- `PluginDashboard.tsx` — 仪表盘容器,读取 widgets 定义并渲染
- `StatCardsWidget.tsx` — 统计卡片组件4 个指标卡片)
- `ActionListWidget.tsx` — 待办列表组件
- `FunnelWidget.tsx` — 漏斗图组件
- `CardListWidget.tsx` — 卡片列表组件
### Task 5.4: 验证
- [ ] `cargo check` 通过
- [ ] 前端 `pnpm build` 通过
- [ ] manifest.rs 正确解析 widgets TOML
- [ ] API 返回 widgets 定义和聚合数据
- [ ] `git add && git commit && git push`
---
## Phase 6: freelance + itops Layer 2 — 仪表盘 widgets
> **前置:** Phase 5 完成(平台支持 widgets
### Task 6.1: freelance — 替换仪表盘页面为 widgets 版本
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
将现有的空仪表盘(第 949-952 行)替换为包含 4 个 widgets 的完整仪表盘:
1. stat_cards — 财务概览4 张卡片)
2. action_list — 紧急待办4 种查询)
3. funnel — 商机漏斗
4. card_list — 活跃项目
### Task 6.2: itops — 新增仪表盘页面到最前面
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
在现有页面列表最前面插入仪表盘页面2 个 widgets
1. stat_cards — 运维概览4 张卡片)
2. action_list — 紧急待办3 种查询)
### Task 6.3: 两个插件各自编译 WASM + 升级
### Task 6.4: 验证
- [ ] freelance 仪表盘 → 4 个 widget 正确渲染
- [ ] itops 仪表盘 → 2 个 widget 正确渲染
- [ ] 财务卡片数值正确(调用 aggregate API
- [ ] 紧急待办列表有数据时显示条目
- [ ] 商机漏斗按阶段显示金额分布
- [ ] `git add && git commit && git push`
---
## 执行策略
**P1-P4 并行策略:** P1 和 P2 可以同时开始不同文件P3 和 P4 在 P1/P2 完成后立即跟进。每个 Phase 独立编译 WASM、独立验证、独立提交。
**P5-P6 顺序策略:** P5 是平台改动Rust + 前端P6 依赖 P5 的平台能力才能生效。
**预估工作量:**
- P1: 30-40 分钟plugin.toml 编辑 + 编译 + 验证)
- P2: 20-30 分钟(规模小于 P1
- P3: 15-20 分钟3 个模板插入)
- P4: 10-15 分钟1 个模板插入)
- P5: 60-90 分钟manifest 扩展 + API + 前端组件)
- P6: 20-30 分钟plugin.toml widgets 声明)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,624 @@
# freelance + itops 插件增强设计规格
> 日期: 2026-04-20
> 来源: 多专家头脑风暴UX专家 + 业务顾问 + 运维专家 + 财务专家)
> 状态: Draft
> 前置: `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
---
## 1. 背景与动机
当前插件是「数据录入系统」,不是「赚钱工具」。一人 IT 服务公司的核心痛点:
1. **钱从哪里来?** — 商机跟进靠人记,没有自动提醒、没有漏斗分析
2. **项目做到哪了?** — 任务状态和工时手动填,跟合同金额/应收款脱节
3. **钱收回来了吗?** — 报价→合同→开票→收款割裂,没有串联
4. **运维服务会不会忘?** — 巡检计划写了没人催SLA 超时了才知道
5. **税和利润算不清?** — 收支分散在不同表里,月底做账要手动汇总
**问题根因:** 平台已有 trigger_events、settings、templates、cascade_from、visible_when、validation 六大能力,但两个插件完全没有使用。
**改进目标:** 纯插件层增强,三层递进:
- Layer 1: 智能业务引擎 — 让系统主动驱动用户做事
- Layer 2: 仪表盘重构 — 一个页面掌控全局
- Layer 3: 专业输出 — 一键生成报价单/发票/合同 PDF
---
## 2. Layer 1: 智能业务引擎 — freelance 插件
### 2.1 Settings插件配置页
一次性配置公司信息和业务偏好,后续自动生效:
```toml
[settings]
# ── 基本信息 ──
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
# ── 财务 ──
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
# ── 提醒 ──
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### 2.2 Trigger Events自动事件驱动
关键操作时自动发通知,把"人找事"变"事找人"
```toml
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
```
### 2.3 Cascade智能联动下拉
选客户后自动过滤其关联数据。以下均为**已有字段追加 cascade 属性**,不是新增字段:
**contract 实体 — 已有 opportunity_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**contract 实体 — 已有 quote_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**invoice 实体 — 已有 project_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**invoice 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**time_entry 实体 — 已有 task_id 字段追加:**
```toml
cascade_from = "project_id"
cascade_filter = "project_id"
```
### 2.4 Visible When条件显示
只在有意义时才显示字段。以下为**已有字段追加 visible_when 属性**
**invoice 实体 — 已有 payment_date 字段追加:**
```toml
visible_when = "status == 'paid' || status == 'partial'"
```
**contract 实体 — 已有 paid_amount 字段追加:**
```toml
visible_when = "status != 'drafting'"
```
**task 实体 — 已有 actual_hours 字段追加:**
```toml
visible_when = "status != 'todo'"
```
**quote 实体 — 已有 total_amount 字段追加:**
```toml
visible_when = "status != 'draft'"
```
### 2.5 Validation字段校验
**已有字段追加 validation 属性**,不是新增字段:
**client 实体 — 已有 email 字段追加:**
```toml
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
```
**client 实体 — 已有 phone 字段追加:**
```toml
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
```
---
## 3. Layer 2: 仪表盘重构 — freelance 插件
将占位符仪表盘升级为真正的指挥中心。通过 `widgets` 声明告诉平台该展示什么。
> **平台依赖:** 仪表盘 widgets 需要平台层配合:
> 1. `manifest.rs` 的 `PluginPageType::Dashboard` 需要新增 `widgets: Option<Vec<PluginWidget>>` 字段
> 2. 定义 `PluginWidget` 枚举stat_cards/action_list/funnel/card_list 类型)
> 3. 更新 TOML 解析和验证逻辑
> 4. 前端解析 `widgets` 声明并渲染对应组件
>
> 因此 P5/P6 **不是纯 plugin.toml 改动**,需要平台+前端联合实施。以下 widgets 声明作为设计参考,实施时需先完成平台侧支持。
```toml
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
# ── 财务概览卡片 ──
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
cards = [
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
]
# ── 紧急待办 ──
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
]
# ── 商机漏斗 ──
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "opportunity"
lane_field = "stage"
value_field = "estimated_amount"
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
# ── 活跃项目卡片 ──
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "project"
filter = "status == 'in_progress'"
max_items = 4
title_field = "name"
subtitle_field = "contract_amount"
tags = ["business_type", "status"]
```
**依赖:** 数据源来自平台已有的聚合 API`/count``/aggregate`。Filter 表达式使用平台过滤 DSL`==`, `!=`, `||`, `&&`, `<=`)。
---
## 4. Layer 3: 专业输出 — freelance 插件
一键生成专业 PDF替代手动排 Word。
> **模板引擎说明:**
> - 语法基于 Handlebars`{{field}}`, `{{#each relation}}...{{/each}}`
> - 当前实体字段直接可用:`{{amount}}`, `{{status}}`
> - 关系字段解析:`{{client.name}}` 表示通过 `client_id` 引用的 client 实体的 name 字段,渲染器需自动解析
> - `{{#each lines}}` 用于一对多关系(如 quote → quote_line渲染器查询子实体并遍历
> - 平台需要实现 PDF 渲染管道TOML 模板 → Handlebars 渲染(注入数据)→ HTML → wkhtmltopdf/浏览器打印 → PDF
### 4.1 报价单模板
```toml
[[templates]]
name = "quote_pdf"
display_name = "报价单"
entity = "quote"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f5f5f5; }
.total { text-align: right; font-size: 18px; font-weight: bold; }
.footer { margin-top: 40px; color: #666; font-size: 12px; }
</style></head>
<body>
<h1>报价单 {{quote_number}}</h1>
<p>客户:{{client.name}} | 有效期至:{{valid_until}}</p>
<table>
<tr><th>项目</th><th>描述</th><th>数量</th><th>单价</th><th>金额</th></tr>
{{#each lines}}
<tr><td>{{item_name}}</td><td>{{description}}</td><td>{{quantity}}</td><td>{{unit_price}}</td><td>{{amount}}</td></tr>
{{/each}}
</table>
<p class="total">小计:{{subtotal}} | 税率:{{tax_rate}}% | 总计:{{total_amount}}</p>
<div class="footer">备注:{{notes}}</div>
</body>
</html>
"""
```
### 4.2 发票模板
```toml
[[templates]]
name = "invoice_pdf"
display_name = "发票"
entity = "invoice"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.info-item { padding: 8px; background: #fafafa; }
.amount { font-size: 24px; font-weight: bold; text-align: center; color: #f5222d; margin: 20px 0; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; background: #f0f0f0; }
</style></head>
<body>
<h1>发票 {{invoice_number}}</h1>
<div class="info-grid">
<div class="info-item">客户:{{client.name}}</div>
<div class="info-item">类型:{{type}}</div>
<div class="info-item">开票日期:{{issue_date}}</div>
<div class="info-item">到期日:{{due_date}}</div>
</div>
<div class="amount">¥{{amount}}</div>
<p>状态:<span class="status-badge">{{status}}</span></p>
<p>备注:{{notes}}</p>
</body>
</html>
"""
```
### 4.3 合同模板
```toml
[[templates]]
name = "contract_pdf"
display_name = "合同"
entity = "contract"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 3px double #333; padding-bottom: 10px; }
.parties { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #1890ff; }
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
</style></head>
<body>
<h1>{{title}}</h1>
<p>合同编号:{{contract_number}}</p>
<div class="parties">
<p>甲方:{{client.name}}</p>
<p>合同金额:¥{{amount}} | 已付:¥{{paid_amount}}</p>
<p>期限:{{start_date}} 至 {{end_date}}</p>
<p>付款条款:{{payment_terms}}</p>
</div>
<p>备注:{{notes}}</p>
<div class="signature">
<div class="sig-box">甲方签章</div>
<div class="sig-box">乙方签章</div>
</div>
</body>
</html>
"""
```
---
## 5. itops 插件增强
### 5.1 Settings
```toml
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### 5.2 Trigger Events
```toml
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
```
### 5.3 Cascade
**已有字段追加 cascade 属性**,不是新增字段:
**ticket 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**check_record 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "plan_id"
cascade_filter = "contract_id"
```
### 5.4 Visible When
**已有字段追加 visible_when 属性**
**ticket 实体 — 已有 resolution 字段追加:**
```toml
visible_when = "status == 'resolved' || status == 'closed'"
```
**ticket 实体 — 已有 responded_at 字段追加:**
```toml
visible_when = "status != 'open'"
```
**ticket 实体 — 已有 resolved_at 字段追加:**
```toml
visible_when = "status == 'resolved' || status == 'closed'"
```
**ticket 实体 — 已有 closed_at 字段追加:**
```toml
visible_when = "status == 'closed'"
```
**check_record 实体 — 已有 issues_found 字段追加:**
```toml
visible_when = "result == 'abnormal'"
```
**check_record 实体 — 已有 actions_taken 字段追加:**
```toml
visible_when = "result == 'abnormal'"
```
### 5.5 Validation
**已有字段追加 validation 属性**
**service_contract 实体 — 已有 contract_number 字段追加:**
```toml
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
```
### 5.6 Dashboard
> **同 Layer 2 说明:** widgets 需要平台层配合manifest.rs 扩展 + 前端渲染),非纯 plugin.toml 改动。此仪表盘页面**插入到现有页面列表最前面**,现有 4 个页面保持不变。
```toml
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
```
### 5.7 Template维保合同 PDF
```toml
[[templates]]
name = "service_contract_pdf"
display_name = "维保合同"
entity = "service_contract"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 3px double #1890ff; padding-bottom: 10px; color: #1890ff; }
.sla-box { margin: 20px 0; padding: 15px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.info-item { padding: 8px; background: #fafafa; }
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
</style></head>
<body>
<h1>{{name}}</h1>
<p>合同编号:{{contract_number}}</p>
<div class="info-grid">
<div class="info-item">客户:{{client.name}}</div>
<div class="info-item">合同金额:¥{{amount}}</div>
<div class="info-item">期限:{{start_date}} 至 {{end_date}}</div>
<div class="info-item">状态:{{status}}</div>
</div>
<div class="sla-box">
<strong>SLA 承诺:</strong>响应 {{sla_response_hours}} 小时内 / 解决 {{sla_resolve_hours}} 小时内
</div>
<p>服务范围:{{service_scope}}</p>
<p>付款条款:{{payment_terms}}</p>
<p>备注:{{notes}}</p>
<div class="signature">
<div class="sig-box">甲方签章</div>
<div class="sig-box">乙方签章</div>
</div>
</body>
</html>
"""
```
---
## 6. 改进汇总
| 层次 | 能力 | freelance | itops |
|------|------|-----------|-------|
| Layer 1 | settings | 7 个配置项(公司名/税率/提醒偏好) | 4 个配置项SLA默认值/提醒偏好) |
| Layer 1 | trigger_events | 5 个事件(商机/合同/发票/任务/支出) | 4 个事件(工单/合同/巡检) |
| Layer 1 | cascade | 4 处联动(合同/发票/工时表单) | 2 处联动(工单/巡检记录) |
| Layer 1 | visible_when | 4 个条件字段 | 6 个条件字段 |
| Layer 1 | validation | 2 个校验(邮箱/手机) | 1 个校验(合同编号格式) |
| Layer 2 | dashboard widgets | 财务卡片+紧急待办+商机漏斗+项目卡片 | 运维卡片+紧急待办 |
| Layer 3 | templates | 3 个 PDF报价单/发票/合同) | 1 个 PDF维保合同 |
**总计:** 2 个插件 × 3 层增强,从「数据录入」升级为「赚钱工具」。
---
## 7. 实施优先级
```
P1: freelance Layer 1settings + trigger_events + cascade + visible_when + validation
P2: itops Layer 1settings + trigger_events + cascade + visible_when + validation
P3: freelance Layer 33 个 PDF 模板)
P4: itops Layer 3维保合同 PDF 模板)
P5: freelance Layer 2仪表盘 widgets
P6: itops Layer 2仪表盘 widgets
```
P1-P4 是纯 plugin.toml 改动(给已有字段追加 cascade/visible_when/validation 属性,以及新增 settings/trigger_events/templates 段落可立即实施。P5-P6 的仪表盘 widgets 需要平台层配合:扩展 `manifest.rs``PluginPageType::Dashboard` 支持 `widgets` 字段 + 前端渲染组件。

View File

@@ -0,0 +1,172 @@
# P1-P4 审计修复实施计划
## Context
对 P1-P4 审计发现 8 项高/中优先级缺失Excel/CSV 导入导出、市场后端 API、对账扫描、运行时监控、通知规则、编号 reset_rule。本计划按优先级分 3 批推进,每批独立可提交。
---
## 第一批高优先级Excel/CSV + 市场后端 + 对账扫描)
### 1.1 Excel/CSV 导入导出
**思路**: 后端新增 `csv` + `rust_xlsxwriter` 依赖export handler 支持 format 参数输出 CSV/XLSX前端同时支持。
**后端改动**:
1. `Cargo.toml` (workspace): 新增 `csv = "1"``rust_xlsxwriter = "0.82"`
2. `crates/erp-plugin/Cargo.toml`: 添加 `csv``rust_xlsxwriter` 依赖
3. `crates/erp-plugin/src/data_service.rs`:
- `export()` 签名增加 `format: Option<String>` 参数
- 内部新增 `export_csv()``export_xlsx()` 私有方法,返回 `Vec<u8>` bytes
- format 为空/json 时返回原 JSONcsv/xlsx 时返回二进制
- 返回类型改为 enum `ExportPayload { Json(Vec<Value>), Csv(Vec<u8>), Xlsx(Vec<u8>) }`
4. `crates/erp-plugin/src/data_dto.rs`: ExportParams 的 format 字段已有
5. `crates/erp-plugin/src/handler/data_handler.rs`:
- `export_plugin_data` 根据 format 参数返回不同 Content-Type:
- JSON: `application/json`
- CSV: `text/csv` + `Content-Disposition: attachment`
- XLSX: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- 返回类型改为 `axum::response::Response`(不是 Json<>
6. 前端 `pluginData.ts`: `exportPluginData` 支持 format 参数CSV/XLSX 时用 `responseType: 'blob'`
7. 前端 `PluginCRUDPage.tsx`: 导出按钮增加下拉菜单选择格式JSON/CSV/Excel
**注意**: 导入仍保持 JSON复杂度低模板生成和导入历史不在本批范围。
### 1.2 P4 市场后端 API
**思路**: 新建 `market_service.rs` + `market_handler.rs`,复用 DB 迁移已建好的 `plugin_market_entries``plugin_market_reviews` 表。
**新增文件**:
- `crates/erp-plugin/src/service/market_service.rs`: 市场业务逻辑
- `crates/erp-plugin/src/handler/market_handler.rs`: 市场 API handler
- `crates/erp-plugin/src/entity/market_entry.rs`: SeaORM Entity
- `crates/erp-plugin/src/entity/market_review.rs`: SeaORM Entity
**后端 API**:
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/market/entries` | 浏览市场目录(分类/搜索/分页) |
| GET | `/api/v1/market/entries/{id}` | 市场条目详情 |
| POST | `/api/v1/market/entries/{id}/install` | 从市场一键安装 |
| GET | `/api/v1/market/entries/{id}/reviews` | 查看评论 |
| POST | `/api/v1/market/entries/{id}/reviews` | 提交评分/评论 |
**一键安装逻辑**: 从 `plugin_market_entries``wasm_binary` + `manifest_toml`,调用已有的 `PluginService::upload` + `PluginService::install` + `PluginService::enable`
**依赖提示**: install 时检查 manifest.dependencies若目标插件未安装则返回警告软提示不阻塞
**前端改动**:
- `apps/web/src/api/plugins.ts`: 新增市场 API 函数
- `apps/web/src/pages/PluginMarket.tsx`: 对接真实 API替换 mock 数据;增加评分提交 UI安装按钮对接真实 API
### 1.3 P1 对账扫描
**思路**: 新增 `reconcile` service 方法和 handler在插件重新启用时扫描悬空引用。
**后端改动**:
- `crates/erp-plugin/src/data_service.rs`: 新增 `reconcile_references()` 方法
- 查找所有指向目标插件的 `ref_entity` 字段(从 plugin_entities schema_json 解析)
- 扫描这些字段的 UUID 值,验证目标表中是否存在
- 返回 `ReconciliationReport { valid: N, dangling: M, details: Vec<DanglingRef> }`
- `crates/erp-plugin/src/data_dto.rs`: 新增 DTO
- `crates/erp-plugin/src/handler/data_handler.rs`: 新增 `reconcile_refs` handler
- `crates/erp-plugin/src/module.rs`: 注册路由 `POST /plugins/{plugin_id}/reconcile`
**前端**: 暂不实现完整对账 UI低优先级仅提供 API 供后续使用。
---
## 第二批:中优先级(运行时监控 + 通知规则 + 编号 reset
### 2.1 P3 运行时监控
**后端改动**:
1. 新建迁移 `m20260420_000041_plugin_runtime_metrics.rs`:
- `plugin_runtime_metrics` 表: plugin_id, tenant_id, error_count, total_invocations, avg_response_ms, fuel_consumption_avg, memory_peak_bytes, last_error, updated_at
2. `crates/erp-plugin/src/engine.rs`:
- `LoadedPlugin` 新增 `metrics: Arc<RwLock<RuntimeMetrics>>` 字段
- `execute_wasm` 中采集指标: 记录开始时间、成功/失败计数、fuel 消耗
- 定期持久化到 DB每 10 次调用或 60 秒)
3. `crates/erp-plugin/src/handler/plugin_handler.rs`:
- 扩展 `health_check` 返回 RuntimeMetrics
- 新增 `GET /admin/plugins/{id}/metrics` 端点
### 2.2 P2 通知规则引擎
**思路**: 复用 EventBus 的 `subscribe_filtered` + erp-message 的 `send_system`,在 plugin 模块启动时监听 `plugin.trigger.*` 前缀事件。
**后端改动**:
- `crates/erp-plugin/src/module.rs`: 启动事件监听(参考 erp-message 的 `start_event_listener` 模式)
- 新建 `crates/erp-plugin/src/notification.rs`:
- 订阅 `plugin.trigger.*` 事件
- 查询 trigger_events 声明,匹配事件名
- 调用 erp-message 的系统消息发送(通过 EventBus 发布 `message.send` 事件,或直接调用 message service 的 REST API
- 通知对象: 通过 manifest 声明扩展(当前简化为通知所有管理员)
### 2.3 P2 编号 reset_rule
**思路**: 参考 erp-config 的 `numbering_service.rs``maybe_reset_sequence` 模式,替换 PostgreSQL 序列为表行 + advisory lock。
**后端改动**:
- `crates/erp-plugin/src/host.rs`: 重写 `numbering_generate`
- 改用 `pg_advisory_xact_lock` + 表行序列(而非 PostgreSQL SEQUENCE
- 在事务内: 读序列行 → 检查 reset_rule 是否需要重置 → 递增/重置 → 写回
- 序列表: 使用已有的动态表模式,或新建 `plugin_numbering_sequences`
- `crates/erp-plugin/src/engine.rs`: `NumberingRule` 中 reset_rule 字段已被传递但未使用,直接在 host.rs 中消费
---
## 第三批:低优先级(配置变更通知 + 自定义视图)
### 3.1 P2 配置变更通知
**后端改动**:
- `crates/erp-plugin/src/service.rs`: `update_config` 增加 `event_bus: &EventBus` 参数,更新成功后发布 `plugin.config.updated` 事件
- `crates/erp-plugin/src/handler/plugin_handler.rs`: `update_plugin_config` handler 从 state 获取 event_bus 传入
- `crates/erp-plugin/src/engine.rs`: 订阅 `plugin.config.updated` 事件,刷新内存中的 `plugin_config`
### 3.2 P2 自定义视图
**后端改动**:
1. 新建迁移 `plugin_user_views`
2. 新建 `crates/erp-plugin/src/service/view_service.rs`: CRUD user views
3. 新建 handler: `GET/POST/PUT/DELETE /plugins/{plugin_id}/{entity}/views`
4. **前端**: PluginCRUDPage 增加视图保存/加载 UI
---
## 关键文件清单
| 文件 | 改动类型 |
|------|---------|
| `Cargo.toml` (workspace) | 新增 csv, rust_xlsxwriter 依赖 |
| `crates/erp-plugin/Cargo.toml` | 新增依赖 |
| `crates/erp-plugin/src/data_service.rs` | export format 支持, reconcile 方法 |
| `crates/erp-plugin/src/data_dto.rs` | ExportPayload enum, ReconciliationReport |
| `crates/erp-plugin/src/handler/data_handler.rs` | export 返回 Response, reconcile handler |
| `crates/erp-plugin/src/handler/market_handler.rs` | **新建** 市场 API |
| `crates/erp-plugin/src/service/market_service.rs` | **新建** 市场业务逻辑 |
| `crates/erp-plugin/src/entity/market_entry.rs` | **新建** SeaORM Entity |
| `crates/erp-plugin/src/entity/market_review.rs` | **新建** SeaORM Entity |
| `crates/erp-plugin/src/notification.rs` | **新建** 通知规则引擎 |
| `crates/erp-plugin/src/engine.rs` | LoadedPlugin 增加 metrics, 配置热更新 |
| `crates/erp-plugin/src/host.rs` | numbering_generate 重写 |
| `crates/erp-plugin/src/service.rs` | update_config 增加 event_bus |
| `crates/erp-plugin/src/module.rs` | 注册新路由, 启动通知监听 |
| `crates/erp-plugin/src/lib.rs` | 导出新模块 |
| `crates/erp-server/migration/src/m20260420_*.rs` | **新建** metrics 表迁移 |
| `apps/web/src/api/plugins.ts` | 市场前端 API |
| `apps/web/src/api/pluginData.ts` | export format 支持 |
| `apps/web/src/pages/PluginMarket.tsx` | 对接真实 API |
| `apps/web/src/pages/PluginCRUDPage.tsx` | 导出格式选择 |
## 验证计划
1. `cargo check` — 全 workspace 编译通过
2. `pnpm build` — 前端构建通过
3. 启动后端 + 前端,浏览器中验证:
- CRM customer 导出 CSV/Excel 下载
- 市场 API 返回数据curl 测试)
- 插件 health 接口返回 metrics
4. 每批完成后独立提交推送

View File

@@ -0,0 +1,138 @@
# UX 分析报告:一人 IT 公司 ERP 插件方案
> 基于智界科技(一人 IT 服务公司)的业务场景,对 freelance + itops 两个插件的 UX 审查。
---
## 1. 一人公司的 UX 痛点
**WHY**: 一个人没有分工,老板就是销售、项目经理、财务、运维工程师。每次切换页面等于中断心流,表单越复杂越容易填一半放弃。
**核心摩擦**
- **上下文切换成本高** -- 一个人同时处理客户咨询、写代码、记账、回工单。在"客户详情"和"工时记录"之间来回跳转,每次跳转丢失工作记忆。
- **重复录入** -- 同一个客户信息在 client、opportunity、quote、invoice、ticket 中反复手填。一人公司没有人帮忙补数据。
- **决策疲劳** -- 每天面对 10 个入口,要思考"这个操作该去哪个页面"。对于一人公司ERP 应该像手机首页一样直觉。
- **过度结构化** -- 一人公司的商机通常是微信聊几句就定了,不需要复杂的销售漏斗流程。
**HOW -- 减少操作的具体措施**
1. **全局搜索 + 命令面板**Ctrl+K输入"张三"直接跳到客户详情,输入"新工时"直接弹出计时器,输入"#102"跳到工单。一人公司的 ERP 应该像一个大的搜索框 + 几个快捷按钮。
2. **自动填充上下文**:在项目工作台记工时时,自动关联当前活跃项目;从客户详情页创建报价单时,自动带入客户信息。减少手动关联操作。
3. **合并创建流程**:新建项目时一步内同时创建第一个任务,不用先建项目再跳到任务页。
---
## 2. 页面布局合理性 -- 10 个页面是否太多
**结论:可以压缩到 7 个页面,但不应低于 5 个。**
**WHY**: 一人公司的操作场景有明确的节奏切换(见客户 vs 做项目 vs 记账),完全合并会导致单页信息过载。但两个插件共 10 个页面确实有冗余。
**建议合并方案**
| 原方案 (10 页) | 优化方案 (7 页) | 理由 |
|---|---|---|
| freelance 仪表盘 | **全局工作台**(合并两个仪表盘) | 一人只需一个首页 |
| 客户管理 (360度) | 客户管理 (保留) | 核心入口,高频使用 |
| 商机跟进 (看板) | **并入客户管理**,作为客户详情的一个 tab | 一人公司的商机极少同时超过 5 个,看板过重 |
| 项目工作台 | 项目工作台 (保留) | 核心工作场景,需要独立空间 |
| 财务中心 | 财务中心 (保留) | 收支是独立节奏,需要集中视图 |
| 报价管理 | **并入财务中心**,作为 tab | 报价是财务流程的前置步骤,不放独立页面 |
| itops 运维仪表盘 | (已合并到全局工作台) | -- |
| 合同管理 | 合同管理 (保留) | 维保合同是独立业务实体 |
| 工单中心 | 工单中心 (保留) | 最高频运维操作 |
| 巡检管理 | **并入工单中心**,作为 tab 或筛选 | 巡检本质是周期性工单,不需要独立页面 |
**HOW -- 实现层面**
- freelance 插件减少为 4 个页面全局工作台dashboard、客户管理tabs 类型,含商机看板 tab、项目工作台、财务中心tabs 类型,含报价 tab
- itops 插件减少为 3 个页面工单中心tabs 类型,含巡检 tab、合同管理、全局工作台跨插件共享
- 跨插件共享的 dashboard 通过 ui.pages 的 `shared: true` 或放在 freelance 插件中声明itops 通过 `dependencies = ["erp-freelance"]` 引用
---
## 3. 关键缺失场景
**WHY**: 一人 IT 公司有 3 个高频场景在当前方案中完全缺失,不做这些等于 ERP 只覆盖了 60% 的日常工作。
| 缺失场景 | 严重性 | 说明 |
|---|---|---|
| **合同/报价到期提醒** | 高 | 维保合同到期前 30 天没有提醒 = 流失续费收入。一人公司靠记忆管理ERP 必须补上 |
| **工时 -> 开票 自动联动** | 高 | 项目完成后手动从工时记录汇总金额再创建发票,这个手工过程在一人公司中最容易被跳过,导致漏收 |
| **知识库/文档管理** | 中 | IT 运维的核心资产是文档(网络拓扑、服务器配置、密码记录)。当前方案只有结构化数据,缺非结构化知识 |
| **续约提醒 + 自动创建续约商机** | 中 | 维保合同到期时自动生成一个续约 opportunity串联 freelance 和 itops |
**HOW -- 实现建议**
1. **到期提醒**:在 itops 插件的 service_contract 实体上加 `end_date` 字段(已有),在后端增加定时事件检查 `contract.expiring`,通过消息中心的订阅机制推送到通知面板。
2. **工时 -> 开票联动**:在 invoice 实体增加 `source_type = "time_entry"``source_ids` 字段,前端提供"从工时记录生成发票"的一键操作,按项目汇总自动填充。
3. **知识库**Phase 2 考虑。可以在 client 或 project 实体上加 `attachments` (json) 字段存储文件引用,先做轻量版。
---
## 4. 仪表盘设计建议 -- 合并为一个全局工作台
**WHY**: 一人只有一个视角(老板视角),不存在"销售看销售数据、运维看运维数据"的角色分离。两个仪表盘让用户每次登录还要选择看哪个,增加了无意义的决策。
**HOW -- 全局工作台设计**
```
+------------------------------------------------------------------+
| 全局工作台 |
+------------------------------------------------------------------+
| 今日待办 (3) 本周收入: ¥12,500 |
| [ ] 回复张三报价 (2h前) 待开票: ¥8,200 |
| [ ] 完成服务器巡检 (今天) 本月支出: ¥3,400 |
| [ ] 提交项目A发票 (明天截止) 到期合同: 2个 (30天内) |
+------------------------------------------------------------------+
| 活跃项目 (2) 最新工单 (3) |
| 项目A - 进行中 ██████░░ 75% #102 网络... 进行中 |
| 项目B - 待启动 ░░░░░░░░ 0% #101 备份... 已完成 |
| #100 升级... 待处理 |
+------------------------------------------------------------------+
| [快速操作] +新建客户 +新建工单 +开始计时 +新建报价 |
+------------------------------------------------------------------+
```
**布局规则**
- 上方:紧急事项 + 财务概览(左右分栏)
- 中间:核心业务对象快照(活跃项目 + 最新工单)
- 下方:一键操作按钮条
**实现**:使用现有的 `PluginDashboardPage` 组件,通过 plugin.toml 的 `ui.pages` 中 type = "dashboard" 声明dashboard widgets 跨插件聚合数据。freelance 插件声明这个 dashboarditops 插件通过 `dependencies` 引用后注册自己的 widgets。
---
## 5. 快速操作 -- 一键完成的快捷入口
**WHY**: 一人公司最高频的操作是"快速记一笔"和"快速创建"。如果每次都要打开表单、填完所有字段、点击保存,摩擦太大导致用户放弃使用 ERP回到微信记事本。
| 快速操作 | 频率 | HOW |
|---|---|---|
| **开始/停止计时** | 每天 3-5 次 | 全局悬浮按钮,点击选择项目 -> 开始计时,再点停止自动生成 time_entry。不需要打开任何页面 |
| **快速记工单** | 每天 2-3 次 | 工单中心的 "+新建" 按钮,弹出一个精简表单(只填标题+客户+紧急度),详情后续补充 |
| **快速记支出** | 每周 2-3 次 | 财务中心的"+记一笔"按钮3 个字段:金额、分类、备注。日期默认今天 |
| **快速创建报价** | 每周 1-2 次 | 从客户详情页一键"生成报价",自动带入客户信息 + 最近的项目工时数据 |
| **快速创建工单 from 合同** | 每月 1-2 次 | 合同详情页"创建工单"按钮,自动关联合同+客户 |
**实现要点**
- 全局悬浮计时器通过前端组件实现,不依赖特定插件页面,放在 MainLayout 层
- 快速操作按钮放在各页面的 PageHeader 区域,使用 Ant Design 的 `FloatButton``Button` 组件
- 精简表单 = 只标记 `required = true` 的字段,其他字段全部可选,后续可补充
---
## 总结 -- 核心建议优先级
| 优先级 | 建议 | 预期收益 |
|---|---|---|
| P0 | 合并两个仪表盘为全局工作台 | 消除首次登录的困惑 |
| P0 | 全局悬浮计时器(开始/停止) | 工时记录从"每周补"变成"实时记" |
| P1 | 商机看板并入客户管理 tab | 减少 1 个页面,降低认知负担 |
| P1 | 工时 -> 发票一键生成 | 消除最大手工流程,防漏收 |
| P1 | 合同到期提醒 | 防止续费流失 |
| P2 | 报价并入财务中心 tab | 减少 1 个页面 |
| P2 | 巡检并入工单中心 tab | 减少 1 个页面 |
| P2 | 全局搜索命令面板 (Ctrl+K) | 极大提升操作效率 |
**核心原则**:一人公司的 ERP 应该像瑞士军刀,不是像工具箱。不需要 10 个抽屉分门别类,需要一把刀随时打开就能用。

View File

@@ -0,0 +1,50 @@
# Freelance + IT-OPS 插件技术评审
## 1. 实体数量合理性
**freelance 8 实体不过重。** 现有插件代码证实WASM Guest 实现极其轻量CRM 仅 30 行 Rust只实现 Guest trait 的 3 个空方法),所有业务逻辑由 Host 侧的 `PluginDataService` + `DynamicTableManager` 通用处理。WASM 二进制不含 ORM/业务逻辑,因此 8 实体与 5 实体的 Guest 代码几乎无差别。manifest 解析(`manifest.rs`)和建表 DDL 均按 entity 循环处理,无硬上限。
真正的复杂度在前端页面数量和表间关联quote/quote_line 父子关系),需确保 plugin.toml 的 `relations` 声明完整。
## 2. 跨插件引用性能
itops 4 个实体都引用 `freelance.client` 是合理的。代码显示跨插件引用走的是**同一数据库内 SQL 查询**`resolve_cross_plugin_entity` 解析出 `plugin_erp-freelance_client` 表名后直接 JOIN/EXISTS**没有 RPC 调用或跨服务开销**。列表查询时 `resolve_labels` 会批量解析 UUID→label也是单次 IN 查询。
风险点4 个实体同时查询时各做一次跨插件表 JOIN并发高时需关注连接池。但每个查询都是标准 SQLPostgreSQL 处理无压力。**建议 itops 的 client_id 字段设 `filterable = true` 使其走 generated column 索引。**
## 3. select 枚举字段声明
现有代码已完全支持。CRM plugin.toml 中有大量实例:
```toml
[[schema.entities.fields]]
name = "status"
field_type = "string"
ui_widget = "select"
filterable = true
options = [
{ label = "草稿", value = "draft" },
{ label = "已审核", value = "approved" },
]
```
字段类型声明为 `string`,枚举值通过 `options` 数组提供(`label` + `value`)。解析侧 `PluginField.options: Option<Vec<serde_json::Value>>` 兼容此格式。`filterable = true` 会自动创建 generated column + 索引以加速过滤查询。
## 4. WASM 体积预估
实测数据:
- CRM (5 实体): **22 KB** (raw), **22.8 KB** (component)
- Inventory (6 实体): **22 KB** (raw)
- test-sample (含 Host API 回调测试): **109 KB**
freelance (8 实体) Guest 代码同样只有 Guest trait 空实现,预估 **22-23 KB**。体积与实体数量无关,取决于引入的 Host API 回调复杂度。itops 更小4 实体),预估 22 KB。两者合计约 45 KB对运行时内存和加载速度无影响。
## 5. 技术风险
1. **quote/quote_line 父子关系**quote_line 引用 quote 是同插件内引用,需在 plugin.toml 中声明 `ref_entity = "quote"` + `relations``on_delete = "cascade"`。父实体删除时需级联软删除子记录 -- 当前 `validate_ref_entities` 只做引用存在性校验,级联软删除需确认 `DynamicTableManager` 是否支持(需检查 `on_delete: cascade` 在 list/create 流程中的实现)。
2. **itops 依赖声明**`metadata.dependencies = ["erp-plugin-freelance"]`,但 `ref_plugin` 字段应填 manifest ID`"erp-plugin-freelance"`)。需确认 manifest ID 与 Cargo crate name 的命名映射一致。
3. **freelance.client 需标记 `is_public = true`**:否则 itops 的跨插件 `ref_plugin` 查询会找不到目标实体。CRM 的 customer 已正确标记。
4. **权限码数量**freelance 16 个权限码、itops 8 个,均在合理范围。注意每个实体必须声明 `.list` + `.manage`,缺 `.list` 会导致列表页 403。

View File

@@ -0,0 +1,74 @@
# 智界科技 ERP 插件方案 -- 业务顾问分析
## 分析结论
### 1. 经营范围覆盖度
| 经营范围 | 覆盖插件 | 覆盖情况 |
|----------|---------|---------|
| 软件开发 | freelance(project/task) | 部分 -- 缺合同签约流程 |
| AI 开发 | 无 | 未覆盖 |
| 系统集成 | freelance(project) | 部分 |
| 软件销售(批发+零售) | 无 | 未覆盖 |
| IT 运维服务 | itops(service_contract/ticket/check_plan/check_record) | 覆盖良好 |
| 软件外包 | freelance(project/task/time_entry) | 部分 |
| IT 咨询 | freelance(opportunity/quote) | 部分 -- 缺知识产品化 |
| 数字内容制作 | 无 | 未覆盖 |
| 市场营销策划 | 无 | 未覆盖 |
**覆盖 5/9遗漏 4 条。**
### 2. 最赚钱业务优先级
汕头市场实际排序:
1. **软件开发 + AI 开发**(利润率 70-90%,一人公司最佳赛道)
2. **IT 运维服务**稳定年费收入itops 已覆盖)
3. **系统集成**客单价高freelance 的 project 可部分支撑)
4. **软件销售批发零售**(需配合 inventory 插件)
5. **IT 咨询**(高毛利但低频)
插件设计基本正确地优先了 1-3但 freelance 插件缺少对"产品化销售"的支持。
### 3. 市场营销策划 -- 需要补充吗?
**不需要独立插件。** 原因:一人公司做营销策划,本质是卖自己的专业能力,核心需求是:
- 客户管理freelance.client 已覆盖)
- 报价freelance.quote 已覆盖)
- 项目交付freelance.project 已覆盖)
在 freelance 的 project 实体中增加 `type` 字段枚举software/ai/integration/consulting/marketing/content即可区分不同业务线无需新增插件。
### 4. 软硬件批发零售 -- inventory 需要配合吗?
**需要,但方式不同。** 软硬件批发零售有两种场景:
- **代理分销**(从供应商进货再卖)-- 需要 inventory 插件管库存 + freelance 的 invoice 开票
- **纯中介/推荐**(帮客户选型,供应商直发)-- 只需 freelance 的 quote + invoice库存量写 0 或标记"虚拟商品"
建议inventory 插件中增加 `product.type`enum: physical/virtual/servicevirtual 类型走零库存逻辑physical 走完整进销存。freelance 的 invoice 关联 inventory 的 product 即可。
### 5. 数字内容制作 -- 需要什么?
**不需要独立插件。** 数字内容制作(网站、小程序、视频、设计稿等)本质是项目制交付,与软件开发共用同一套 project/task/time_entry 流程。在 freelance 的 project 增加 `deliverable_type`enum: software/website/miniprogram/video/design/document即可。
---
## 调整建议300 字版)
**freelance 插件调整:**
1. **project 实体增加字段:**
- `business_type`enum: software_development/ai_development/system_integration/software_sales/it_outsourcing/it_consulting/marketing_planning/digital_content-- 对齐 9 条经营范围
- `deliverable_type`enum: software/website/miniprogram/video/design/document/consulting_report
2. **client 实体增加字段:**
- `source`enum: referral/marketing/tender/platform/repeat-- 追踪客户来源,为营销策划提供数据
3. **新增 contract 实体:** 独立于 quote合同签约、履约跟踪是法律实体目前只有报价没有合同这是 B2B 业务的核心缺失。字段title/client_id/quote_id/amount/start_date/end_date/status/terms
4. **invoice 关联 product** 增加 `line_items`JSON 数组),每行关联 inventory 的 product_id + quantity + unit_price打通软硬件销售闭环。
**itops 插件:保持不变,设计合理。**
**inventory 插件:** 增加 `product.type`physical/virtual/servicevirtual/service 走零库存逻辑。
**不新增独立插件。** 9 条经营范围通过 freelance 的分类字段 + inventory 配合即可全覆盖。一人公司最忌讳系统复杂度过高三个插件freelance + itops + inventory足够。

File diff suppressed because it is too large Load Diff