Compare commits
14 Commits
a7a48167ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ac8e18d74 | ||
|
|
89fc482d99 | ||
|
|
85e732cf12 | ||
|
|
8f3d2d58e7 | ||
|
|
40b37cc776 | ||
|
|
301178067c | ||
|
|
7e063a7e88 | ||
|
|
bcc6662add | ||
|
|
f4afc969bd | ||
|
|
74378a7575 | ||
|
|
59339c2929 | ||
|
|
fc76793d6d | ||
|
|
813df3688c | ||
|
|
fcf20dded1 |
@@ -1,9 +1,21 @@
|
||||
Compiling erp-plugin v0.1.0 (G:\erp\crates\erp-plugin)
|
||||
Compiling erp-server-migration v0.1.0 (G:\erp\crates\erp-server\migration)
|
||||
warning: unused import: `PluginError`
|
||||
--> crates\erp-plugin\src\plugin_validator.rs:1:20
|
||||
|
|
||||
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:376:39
|
||||
--> crates\erp-plugin\src\data_service.rs:445:39
|
||||
|
|
||||
376 | struct RefCheck { chk: Option<i32> }
|
||||
445 | struct RefCheck { chk: Option<i32> }
|
||||
| -------- ^^^
|
||||
| |
|
||||
| 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
warning: `erp-plugin` (lib) generated 3 warnings
|
||||
Compiling erp-server v0.1.0 (G:\erp\crates\erp-server)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 45s
|
||||
warning: `erp-plugin` (lib) generated 5 warnings (run `cargo fix --lib -p erp-plugin` to apply 2 suggestions)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
|
||||
Running `target\debug\erp-server.exe`
|
||||
Error: configuration file "config/default" not found
|
||||
error: process didn't exit successfully: `target\debug\erp-server.exe` (exit code: 1)
|
||||
|
||||
1
.logs/backend.pid
Normal file
1
.logs/backend.pid
Normal file
@@ -0,0 +1 @@
|
||||
10056
|
||||
@@ -1,187 +0,0 @@
|
||||
[2m08:34:12[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [2m[console.error] [22mWarning: [antd: Modal] `destroyOnClose` is deprecated. Please use `destroyOnHidden` instead.
|
||||
[2m08:34:38[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [2m[console.error] [22mWarning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?
|
||||
[2m08:38:05[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages/unread-count[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:38:05[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages?page=1&page_size=5[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:39:35[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [2m[console.error] [22mWarning: [antd: Modal] `destroyOnClose` is deprecated. Please use `destroyOnHidden` instead.
|
||||
[2m08:39:54[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [2m[console.error] [22mWarning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?
|
||||
[2m08:43:34[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages/unread-count[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:43:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages?page=1&page_size=5[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:44:34[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages/unread-count[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:44:34[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages?page=1&page_size=5[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:33[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:33[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/product?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:33[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/product/resolve-labels[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:33[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [2m[console.error] [22mWarning: [antd: message] Static function can not consume context like dynamic theme. Please use 'App' component instead.
|
||||
[2m08:45:34[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages/unread-count[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:34[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages?page=1&page_size=5[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/warehouse?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/warehouse/resolve-labels[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/stock?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/stock/resolve-labels[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/supplier?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:35[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/supplier/resolve-labels[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:37[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/messages?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:38[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/workflow/definitions?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:38[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/workflow/definitions?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:38[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [31m[Unhandled rejection] [1mAxiosError[22m: Request failed with status code 502
|
||||
[39m > 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
|
||||
|
||||
[2m08:45:38[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m [31m[Unhandled rejection] [1mAxiosError[22m: Request failed with status code 502
|
||||
[39m > 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
|
||||
|
||||
[2m08:45:39[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/organizations[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:39[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/organizations[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:39[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/roles?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:39[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/permissions[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:39[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/roles?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:39[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/permissions[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:41[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/users?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:41[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/roles?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:41[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/users?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:41[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/roles?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:42[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:42[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/purchase_order?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:42[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:42[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/purchase_order?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:43[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/admin/plugins/019da31e-17ae-7801-a1e8-640b5248f352/schema[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
[2m08:45:43[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/v1/plugins/019da31e-17ae-7801-a1e8-640b5248f352/sales_order?page=1&page_size=20[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1142:49)
|
||||
at afterConnectMultiple (node:net:1723:7)
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
> web@0.0.0 dev G:\erp\apps\web
|
||||
> vite "--" "--strictPort"
|
||||
|
||||
Port 5174 is in use, trying another one...
|
||||
|
||||
[32m[1mVITE[22m v8.0.8[39m [2mready in [0m[1m372[22m[2m[0m ms[22m
|
||||
[32m[1mVITE[22m v8.0.8[39m [2mready in [0m[1m316[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5174[22m/[39m
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5175[22m/[39m
|
||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||
[2m08:36:42[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/index.css, /src/pages/PluginCRUDPage.tsx, /src/components/EntitySelect.tsx[22m
|
||||
[2m08:37:13[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/index.css, /src/components/EntitySelect.tsx[22m
|
||||
|
||||
@@ -1 +1 @@
|
||||
45128
|
||||
50960
|
||||
|
||||
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -1309,6 +1309,13 @@ dependencies = [
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-freelance"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "0.1.0"
|
||||
@@ -1318,6 +1325,13 @@ dependencies = [
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-itops"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-prototype"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -13,6 +13,8 @@ members = [
|
||||
"crates/erp-plugin",
|
||||
"crates/erp-plugin-crm",
|
||||
"crates/erp-plugin-inventory",
|
||||
"crates/erp-plugin-freelance",
|
||||
"crates/erp-plugin-itops",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
273
DESIGN.md
Normal file
273
DESIGN.md
Normal 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
|
||||
@@ -31,44 +31,44 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#4F46E5',
|
||||
colorPrimary: '#2563eb',
|
||||
colorSuccess: '#059669',
|
||||
colorWarning: '#D97706',
|
||||
colorError: '#DC2626',
|
||||
colorInfo: '#2563EB',
|
||||
colorBgLayout: '#F1F5F9',
|
||||
colorBgContainer: '#FFFFFF',
|
||||
colorBgElevated: '#FFFFFF',
|
||||
colorBorder: '#E2E8F0',
|
||||
colorBorderSecondary: '#F1F5F9',
|
||||
borderRadius: 8,
|
||||
colorWarning: '#d97706',
|
||||
colorError: '#dc2626',
|
||||
colorInfo: '#0284c7',
|
||||
colorBgLayout: '#f8fafc',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBorder: '#e2e8f0',
|
||||
colorBorderSecondary: '#f1f5f9',
|
||||
borderRadius: 10,
|
||||
borderRadiusLG: 12,
|
||||
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,
|
||||
fontSizeHeading4: 20,
|
||||
controlHeight: 36,
|
||||
controlHeightLG: 40,
|
||||
controlHeightSM: 28,
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06)',
|
||||
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07)',
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 44,
|
||||
controlHeightSM: 32,
|
||||
boxShadow: 'none',
|
||||
boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: '0 1px 2px 0 rgba(79, 70, 229, 0.3)',
|
||||
primaryShadow: 'none',
|
||||
fontWeight: 500,
|
||||
},
|
||||
Card: {
|
||||
paddingLG: 20,
|
||||
},
|
||||
Table: {
|
||||
headerBg: '#F8FAFC',
|
||||
headerBg: '#f1f5f9',
|
||||
headerColor: '#475569',
|
||||
rowHoverBg: '#F5F3FF',
|
||||
rowHoverBg: '#f1f5f9',
|
||||
fontSize: 14,
|
||||
},
|
||||
Menu: {
|
||||
itemBorderRadius: 8,
|
||||
itemBorderRadius: 10,
|
||||
itemMarginInline: 8,
|
||||
itemHeight: 40,
|
||||
},
|
||||
@@ -85,20 +85,20 @@ const darkThemeConfig = {
|
||||
...themeConfig,
|
||||
token: {
|
||||
...themeConfig.token,
|
||||
colorBgLayout: '#0B0F1A',
|
||||
colorBgContainer: '#111827',
|
||||
colorBgElevated: '#1E293B',
|
||||
colorBorder: '#1E293B',
|
||||
colorBorderSecondary: '#1E293B',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3)',
|
||||
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4)',
|
||||
colorBgLayout: '#0f172a',
|
||||
colorBgContainer: '#1e293b',
|
||||
colorBgElevated: '#334155',
|
||||
colorBorder: '#334155',
|
||||
colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
|
||||
boxShadow: 'none',
|
||||
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
|
||||
},
|
||||
components: {
|
||||
...themeConfig.components,
|
||||
Table: {
|
||||
headerBg: '#1E293B',
|
||||
headerColor: '#94A3B8',
|
||||
rowHoverBg: '#1E293B',
|
||||
headerBg: '#1e293b',
|
||||
headerColor: '#94a3b8',
|
||||
rowHoverBg: '#1e293b',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -199,7 +199,8 @@ export type PluginPageSchema =
|
||||
};
|
||||
|
||||
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;
|
||||
title: string;
|
||||
icon?: string;
|
||||
@@ -207,6 +208,44 @@ export interface DashboardWidget {
|
||||
dimension_field?: string;
|
||||
dimension_order?: 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 =
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function NotificationPanel() {
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 12, color: '#4F46E5' }}
|
||||
style={{ fontSize: 12, color: '#2563eb' }}
|
||||
onClick={() => navigate('/messages')}
|
||||
>
|
||||
查看全部
|
||||
@@ -76,7 +76,7 @@ export default function NotificationPanel() {
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
border: 'none',
|
||||
background: !item.is_read ? (isDark ? '#1E293B' : '#F5F3FF') : 'transparent',
|
||||
background: !item.is_read ? (isDark ? '#0f172a' : '#eff6ff') : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!item.is_read) {
|
||||
@@ -85,7 +85,7 @@ export default function NotificationPanel() {
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (item.is_read) {
|
||||
e.currentTarget.style.background = isDark ? '#1E293B' : '#F8FAFC';
|
||||
e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
@@ -109,7 +109,7 @@ export default function NotificationPanel() {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#4F46E5',
|
||||
background: '#2563eb',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
@@ -132,12 +132,12 @@ export default function NotificationPanel() {
|
||||
textAlign: 'center',
|
||||
paddingTop: 8,
|
||||
marginTop: 4,
|
||||
borderTop: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
borderTop: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate('/messages')}
|
||||
style={{ fontSize: 13, color: '#4F46E5', fontWeight: 500 }}
|
||||
style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
|
||||
>
|
||||
查看全部消息
|
||||
</Button>
|
||||
@@ -166,7 +166,7 @@ export default function NotificationPanel() {
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark ? '#1E293B' : '#F1F5F9';
|
||||
e.currentTarget.style.background = isDark ? '#0f172a' : '#f8fafc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
@@ -175,7 +175,7 @@ export default function NotificationPanel() {
|
||||
<Badge count={unreadCount} size="small" offset={[4, -4]}>
|
||||
<BellOutlined style={{
|
||||
fontSize: 16,
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -2,65 +2,66 @@
|
||||
|
||||
/* ====================================================================
|
||||
* 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) --- */
|
||||
:root {
|
||||
/* Primary Palette */
|
||||
--erp-primary: #4F46E5;
|
||||
--erp-primary-hover: #4338CA;
|
||||
--erp-primary-active: #3730A3;
|
||||
--erp-primary-light: #EEF2FF;
|
||||
--erp-primary-light-hover: #E0E7FF;
|
||||
--erp-primary-bg-subtle: #F5F3FF;
|
||||
/* Primary Palette — Trust Blue */
|
||||
--erp-primary: #2563eb;
|
||||
--erp-primary-hover: #1d4ed8;
|
||||
--erp-primary-active: #1e40af;
|
||||
--erp-primary-light: #eff6ff;
|
||||
--erp-primary-light-hover: #dbeafe;
|
||||
--erp-primary-bg-subtle: #eff6ff;
|
||||
|
||||
/* Semantic Colors */
|
||||
/* Semantic Colors — Professional slate tones */
|
||||
--erp-success: #059669;
|
||||
--erp-success-bg: #ECFDF5;
|
||||
--erp-warning: #D97706;
|
||||
--erp-warning-bg: #FFFBEB;
|
||||
--erp-error: #DC2626;
|
||||
--erp-error-bg: #FEF2F2;
|
||||
--erp-info: #2563EB;
|
||||
--erp-info-bg: #EFF6FF;
|
||||
--erp-success-bg: #ecfdf5;
|
||||
--erp-warning: #d97706;
|
||||
--erp-warning-bg: #fffbeb;
|
||||
--erp-error: #dc2626;
|
||||
--erp-error-bg: #fef2f2;
|
||||
--erp-info: #0284c7;
|
||||
--erp-info-bg: #f0f9ff;
|
||||
|
||||
/* Neutral Palette */
|
||||
--erp-bg-page: #F1F5F9;
|
||||
--erp-bg-container: #FFFFFF;
|
||||
--erp-bg-elevated: #FFFFFF;
|
||||
--erp-bg-spotlight: #F8FAFC;
|
||||
--erp-bg-sidebar: #0F172A;
|
||||
--erp-bg-sidebar-hover: #1E293B;
|
||||
--erp-bg-sidebar-active: rgba(79, 70, 229, 0.15);
|
||||
/* Neutral Palette — Slate neutrals with blue undertones */
|
||||
--erp-bg-page: #f8fafc;
|
||||
--erp-bg-container: #ffffff;
|
||||
--erp-bg-elevated: #ffffff;
|
||||
--erp-bg-spotlight: #f1f5f9;
|
||||
--erp-bg-sidebar: #ffffff;
|
||||
--erp-bg-sidebar-hover: #f1f5f9;
|
||||
--erp-bg-sidebar-active: #eff6ff;
|
||||
|
||||
/* Text Colors */
|
||||
--erp-text-primary: #0F172A;
|
||||
/* Text Colors — Deep navy */
|
||||
--erp-text-primary: #0f172a;
|
||||
--erp-text-secondary: #475569;
|
||||
--erp-text-tertiary: #94A3B8;
|
||||
--erp-text-inverse: #F8FAFC;
|
||||
--erp-text-sidebar: #CBD5E1;
|
||||
--erp-text-sidebar-active: #FFFFFF;
|
||||
--erp-text-tertiary: #94a3b8;
|
||||
--erp-text-inverse: #ffffff;
|
||||
--erp-text-sidebar: #475569;
|
||||
--erp-text-sidebar-active: #2563eb;
|
||||
|
||||
/* Border Colors */
|
||||
--erp-border: #E2E8F0;
|
||||
--erp-border-light: #F1F5F9;
|
||||
--erp-border-dark: #334155;
|
||||
/* Border Colors — Slate borders */
|
||||
--erp-border: #e2e8f0;
|
||||
--erp-border-light: #f1f5f9;
|
||||
--erp-border-dark: #cbd5e1;
|
||||
|
||||
/* Shadows */
|
||||
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
|
||||
--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-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07);
|
||||
--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-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.08);
|
||||
/* Shadows — Soft UI Evolution: subtle, layered depth */
|
||||
--erp-shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--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 rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05);
|
||||
--erp-shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
|
||||
--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-md: 8px;
|
||||
--erp-radius-md: 10px;
|
||||
--erp-radius-lg: 12px;
|
||||
--erp-radius-xl: 16px;
|
||||
|
||||
/* Spacing */
|
||||
/* Spacing — 4px base unit */
|
||||
--erp-space-xs: 4px;
|
||||
--erp-space-sm: 8px;
|
||||
--erp-space-md: 16px;
|
||||
@@ -68,11 +69,10 @@
|
||||
--erp-space-xl: 32px;
|
||||
--erp-space-2xl: 48px;
|
||||
|
||||
/* Typography */
|
||||
--erp-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC',
|
||||
'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--erp-font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
/* Typography — Noto Sans SC for Chinese-first ERP */
|
||||
--erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
|
||||
'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
|
||||
--erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||
--erp-font-size-xs: 12px;
|
||||
--erp-font-size-sm: 13px;
|
||||
--erp-font-size-base: 14px;
|
||||
@@ -88,8 +88,8 @@
|
||||
|
||||
/* Trend Colors */
|
||||
--erp-trend-up: #059669;
|
||||
--erp-trend-down: #DC2626;
|
||||
--erp-trend-neutral: #64748B;
|
||||
--erp-trend-down: #dc2626;
|
||||
--erp-trend-neutral: #475569;
|
||||
|
||||
/* Line Height */
|
||||
--erp-line-height-tight: 1.25;
|
||||
@@ -104,34 +104,46 @@
|
||||
|
||||
/* --- Dark Mode Tokens --- */
|
||||
[data-theme='dark'] {
|
||||
--erp-bg-page: #0B0F1A;
|
||||
--erp-bg-container: #111827;
|
||||
--erp-bg-elevated: #1E293B;
|
||||
--erp-bg-spotlight: #1E293B;
|
||||
--erp-bg-sidebar: #070B14;
|
||||
--erp-bg-sidebar-hover: #111827;
|
||||
--erp-primary-light: rgba(37, 99, 235, 0.15);
|
||||
--erp-primary-light-hover: rgba(37, 99, 235, 0.22);
|
||||
--erp-primary-bg-subtle: rgba(37, 99, 235, 0.1);
|
||||
|
||||
--erp-text-primary: #F1F5F9;
|
||||
--erp-text-secondary: #94A3B8;
|
||||
--erp-text-tertiary: #64748B;
|
||||
--erp-bg-page: #0f172a;
|
||||
--erp-bg-container: #1e293b;
|
||||
--erp-bg-elevated: #334155;
|
||||
--erp-bg-spotlight: #1e293b;
|
||||
--erp-bg-sidebar: #0f172a;
|
||||
--erp-bg-sidebar-hover: #1e293b;
|
||||
|
||||
--erp-border: #1E293B;
|
||||
--erp-border-light: #1E293B;
|
||||
--erp-text-primary: rgba(255, 255, 255, 0.95);
|
||||
--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-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
|
||||
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||
--erp-border: #334155;
|
||||
--erp-border-light: rgba(255, 255, 255, 0.06);
|
||||
--erp-border-dark: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--erp-trend-up: #34D399;
|
||||
--erp-trend-down: #F87171;
|
||||
--erp-trend-neutral: #94A3B8;
|
||||
--erp-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--erp-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
--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-down { color: #FCA5A5; }
|
||||
[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-up { color: #34d399; }
|
||||
[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-label { color: #94a3b8; }
|
||||
|
||||
/* --- Global Reset & Base --- */
|
||||
body {
|
||||
@@ -170,20 +182,20 @@ body {
|
||||
|
||||
/* --- Selection --- */
|
||||
::selection {
|
||||
background-color: var(--erp-primary-light);
|
||||
color: var(--erp-primary);
|
||||
background-color: rgba(37, 99, 235, 0.15);
|
||||
color: var(--erp-text-primary);
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Component Overrides — Ant Design Enhancement
|
||||
* ==================================================================== */
|
||||
|
||||
/* --- Card --- */
|
||||
/* --- Card — Soft shadow, clean border --- */
|
||||
.ant-card {
|
||||
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;
|
||||
transition: box-shadow var(--erp-transition-base), transform var(--erp-transition-base) !important;
|
||||
transition: box-shadow var(--erp-transition-base) !important;
|
||||
}
|
||||
|
||||
.ant-card:hover {
|
||||
@@ -209,15 +221,14 @@ body {
|
||||
/* --- Statistic Cards --- */
|
||||
.stat-card {
|
||||
border-radius: var(--erp-radius-lg) !important;
|
||||
border: none !important;
|
||||
border: 1px solid var(--erp-border) !important;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all var(--erp-transition-base) !important;
|
||||
transition: box-shadow var(--erp-transition-base) !important;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: var(--erp-shadow-md) !important;
|
||||
box-shadow: var(--erp-shadow-sm) !important;
|
||||
}
|
||||
|
||||
.stat-card .ant-statistic-title {
|
||||
@@ -251,7 +262,7 @@ body {
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: var(--erp-primary-bg-subtle) !important;
|
||||
background: var(--erp-bg-spotlight) !important;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
@@ -263,13 +274,12 @@ body {
|
||||
.ant-btn-primary {
|
||||
border-radius: var(--erp-radius-md) !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;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
box-shadow: 0 2px 4px 0 rgba(79, 70, 229, 0.4) !important;
|
||||
transform: translateY(-1px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ant-btn-default {
|
||||
@@ -297,7 +307,7 @@ body {
|
||||
.ant-select-focused .ant-select-selector,
|
||||
.ant-picker-focused {
|
||||
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 --- */
|
||||
@@ -426,12 +436,12 @@ body {
|
||||
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.emerald::before { background: linear-gradient(90deg, #059669, #34D399); }
|
||||
.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.sky::before { background: linear-gradient(90deg, #0284C7, #38BDF8); }
|
||||
.erp-gradient-card.violet::before { background: linear-gradient(90deg, #7C3AED, #A78BFA); }
|
||||
.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.amber::before { background: linear-gradient(90deg, #d97706, #fbbf24); }
|
||||
.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.violet::before { background: linear-gradient(90deg, #7c3aed, #a78bfa); }
|
||||
|
||||
/* --- Fade-in Animation --- */
|
||||
@keyframes erp-fade-in {
|
||||
@@ -465,7 +475,7 @@ body {
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--erp-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--erp-radius-sm);
|
||||
}
|
||||
|
||||
.erp-sidebar-item:focus-visible {
|
||||
@@ -481,7 +491,7 @@ body {
|
||||
background: var(--erp-primary);
|
||||
color: #fff;
|
||||
padding: 8px 24px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border-radius: 0 0 var(--erp-radius-md) var(--erp-radius-md);
|
||||
z-index: 10000;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -529,23 +539,23 @@ body {
|
||||
* ==================================================================== */
|
||||
|
||||
.erp-sidebar-menu .ant-menu-item {
|
||||
margin: 2px 8px !important;
|
||||
margin: 1px 8px !important;
|
||||
border-radius: var(--erp-radius-md) !important;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
height: 36px !important;
|
||||
line-height: 36px !important;
|
||||
}
|
||||
|
||||
.erp-sidebar-menu .ant-menu-item-selected {
|
||||
background: var(--erp-primary) !important;
|
||||
color: #fff !important;
|
||||
background: #eff6ff !important;
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--erp-bg-sidebar-hover) !important;
|
||||
background: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
/* Sidebar group label */
|
||||
@@ -555,17 +565,17 @@ body {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: #94A3B8;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* MainLayout — CSS classes replacing inline styles
|
||||
* ==================================================================== */
|
||||
|
||||
/* Sider */
|
||||
/* Sider — White sidebar, Soft UI style */
|
||||
.erp-sider-dark {
|
||||
background: #0F172A !important;
|
||||
border-right: none !important;
|
||||
background: #ffffff !important;
|
||||
border-right: 1px solid #e2e8f0 !important;
|
||||
position: fixed !important;
|
||||
left: 0;
|
||||
top: 0;
|
||||
@@ -575,7 +585,8 @@ body {
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-sider-dark {
|
||||
background: #070B14 !important;
|
||||
background: #0f172a !important;
|
||||
border-right: 1px solid #334155 !important;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
@@ -584,48 +595,56 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-sidebar-logo {
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed .erp-sidebar-logo {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.erp-sidebar-logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #4F46E5, #818CF8);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--erp-radius-sm);
|
||||
background: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.erp-sidebar-logo-text {
|
||||
margin-left: 12px;
|
||||
color: #F8FAFC;
|
||||
font-size: 16px;
|
||||
margin-left: 10px;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-sidebar-logo-text {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Sidebar menu item */
|
||||
.erp-sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
margin: 2px 8px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
height: 36px;
|
||||
margin: 1px 8px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--erp-radius-md);
|
||||
cursor: pointer;
|
||||
color: #94A3B8;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
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) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #E2E8F0;
|
||||
background: #f1f5f9;
|
||||
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 {
|
||||
background: linear-gradient(135deg, #4F46E5, #6366F1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-sidebar-item-active {
|
||||
background: rgba(37, 99, 235, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.erp-sidebar-item-icon {
|
||||
@@ -665,10 +698,10 @@ body {
|
||||
height: 32px;
|
||||
margin: 6px 8px 2px 8px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--erp-radius-md);
|
||||
cursor: pointer;
|
||||
color: #94A3B8;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -677,12 +710,25 @@ body {
|
||||
}
|
||||
|
||||
.erp-sidebar-submenu-title:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #E2E8F0;
|
||||
background: #f1f5f9;
|
||||
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 {
|
||||
color: #A5B4FC;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-sidebar-submenu-title-active {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.erp-sidebar-submenu-arrow {
|
||||
@@ -707,8 +753,8 @@ body {
|
||||
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.erp-main-layout-light { background: #F1F5F9; }
|
||||
.erp-main-layout-dark { background: #0B0F1A; }
|
||||
.erp-main-layout-light { background: #f8fafc; }
|
||||
.erp-main-layout-dark { background: #0f172a; }
|
||||
|
||||
/* Header */
|
||||
.erp-header {
|
||||
@@ -724,44 +770,44 @@ body {
|
||||
}
|
||||
|
||||
.erp-header-light {
|
||||
background: #FFFFFF !important;
|
||||
border-bottom: 1px solid #F1F5F9;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
background: #ffffff !important;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.erp-header-dark {
|
||||
background: #111827 !important;
|
||||
border-bottom: 1px solid #1E293B;
|
||||
background: #1e293b !important;
|
||||
border-bottom: 1px solid #334155;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.erp-header-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--erp-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: #94A3B8;
|
||||
color: #475569;
|
||||
will-change: background;
|
||||
}
|
||||
|
||||
.erp-header-light .erp-header-btn { color: #64748B; }
|
||||
.erp-header-dark .erp-header-btn { color: #94A3B8; }
|
||||
.erp-header-btn:hover { background: #F1F5F9; }
|
||||
.erp-header-dark .erp-header-btn:hover { background: #1E293B; }
|
||||
.erp-header-light .erp-header-btn { color: #475569; }
|
||||
.erp-header-dark .erp-header-btn { color: #94a3b8; }
|
||||
.erp-header-btn:hover { background: #f1f5f9; }
|
||||
.erp-header-dark .erp-header-btn:hover { background: #334155; }
|
||||
|
||||
.erp-header-title { font-size: 15px; font-weight: 600; }
|
||||
.erp-text-light { color: #0F172A; }
|
||||
.erp-text-dark { color: #F1F5F9; }
|
||||
.erp-text-light-secondary { color: #334155; }
|
||||
.erp-text-dark-secondary { color: #E2E8F0; }
|
||||
.erp-text-light { color: #0f172a; }
|
||||
.erp-text-dark { color: rgba(255, 255, 255, 0.95); }
|
||||
.erp-text-light-secondary { color: #475569; }
|
||||
.erp-text-dark-secondary { color: #94a3b8; }
|
||||
|
||||
.erp-header-divider { width: 1px; height: 24px; margin: 0 8px; }
|
||||
.erp-header-divider-light { background: #E2E8F0; }
|
||||
.erp-header-divider-dark { background: #1E293B; }
|
||||
.erp-header-divider-light { background: rgba(0, 0, 0, 0.06); }
|
||||
.erp-header-divider-dark { background: rgba(255, 255, 255, 0.06); }
|
||||
|
||||
/* User avatar */
|
||||
.erp-header-user {
|
||||
@@ -770,15 +816,15 @@ body {
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--erp-radius-sm);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.erp-header-user:hover { background: #F1F5F9; }
|
||||
.erp-header-dark .erp-header-user:hover { background: #1E293B; }
|
||||
.erp-header-user:hover { background: #f1f5f9; }
|
||||
.erp-header-dark .erp-header-user:hover { background: #334155; }
|
||||
|
||||
.erp-user-avatar {
|
||||
background: linear-gradient(135deg, #4F46E5, #818CF8) !important;
|
||||
background: #2563eb !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
@@ -786,11 +832,11 @@ body {
|
||||
|
||||
/* Footer */
|
||||
.erp-footer { text-align: center; padding: 12px 24px !important; background: transparent !important; font-size: 12px; }
|
||||
.erp-footer-light { color: #475569; }
|
||||
.erp-footer-dark { color: #94A3B8; }
|
||||
.erp-footer-light { color: #94a3b8; }
|
||||
.erp-footer-dark { color: #64748b; }
|
||||
|
||||
/* ====================================================================
|
||||
* Dashboard — Stat Cards & Quick Actions (replacing inline styles)
|
||||
* Dashboard — Stat Cards & Quick Actions
|
||||
* ==================================================================== */
|
||||
|
||||
/* Stat Card */
|
||||
@@ -818,7 +864,7 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--card-gradient, linear-gradient(135deg, #4F46E5, #6366F1));
|
||||
background: var(--card-gradient, linear-gradient(135deg, #2563eb, #60a5fa));
|
||||
}
|
||||
|
||||
.erp-stat-card-body {
|
||||
@@ -849,7 +895,7 @@ body {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -857,7 +903,7 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Section Header (shared by dashboard sections) */
|
||||
/* Section Header */
|
||||
.erp-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -867,7 +913,7 @@ body {
|
||||
|
||||
.erp-section-icon {
|
||||
font-size: 16px;
|
||||
color: #4F46E5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.erp-section-title {
|
||||
@@ -882,7 +928,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--erp-radius-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
background: var(--erp-bg-spotlight);
|
||||
@@ -890,17 +936,17 @@ body {
|
||||
}
|
||||
|
||||
.erp-quick-action:hover {
|
||||
background: #EEF2FF;
|
||||
border-color: var(--action-color, #4F46E5);
|
||||
background: #eff6ff;
|
||||
border-color: var(--action-color, #2563eb);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-quick-action {
|
||||
background: #0B0F1A;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-quick-action:hover {
|
||||
background: #1E293B;
|
||||
border-color: var(--action-color, #4F46E5);
|
||||
background: #1e293b;
|
||||
border-color: var(--action-color, #2563eb);
|
||||
}
|
||||
|
||||
.erp-quick-action-icon {
|
||||
@@ -910,8 +956,8 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent);
|
||||
color: var(--action-color, #4F46E5);
|
||||
background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
|
||||
color: var(--action-color, #2563eb);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -962,12 +1008,12 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.erp-stat-card-trend-up { color: #047857; }
|
||||
.erp-stat-card-trend-down { color: #B91C1C; }
|
||||
.erp-stat-card-trend-neutral { color: #64748B; }
|
||||
.erp-stat-card-trend-up { color: #059669; }
|
||||
.erp-stat-card-trend-down { color: #dc2626; }
|
||||
.erp-stat-card-trend-neutral { color: #475569; }
|
||||
|
||||
.erp-stat-card-trend-label {
|
||||
color: #64748B;
|
||||
color: #475569;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -1000,8 +1046,8 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent);
|
||||
color: var(--action-color, #4F46E5);
|
||||
background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
|
||||
color: var(--action-color, #2563eb);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
@@ -1029,7 +1075,7 @@ body {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--erp-radius-md);
|
||||
background: var(--erp-bg-spotlight);
|
||||
border-left: 3px solid var(--task-color, #4F46E5);
|
||||
border-left: 3px solid var(--task-color, #2563eb);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
@@ -1042,12 +1088,12 @@ body {
|
||||
.erp-task-item-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--erp-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--task-color, #4F46E5) 12%, transparent);
|
||||
color: var(--task-color, #4F46E5);
|
||||
background: color-mix(in srgb, var(--task-color, #2563eb) 8%, transparent);
|
||||
color: var(--task-color, #2563eb);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1069,25 +1115,25 @@ body {
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
font-size: var(--erp-font-size-xs);
|
||||
color: #64748B;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.erp-task-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--erp-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.erp-task-priority-high { background: #FEF2F2; color: #B91C1C; }
|
||||
.erp-task-priority-medium { background: #FFFBEB; color: #92400E; }
|
||||
.erp-task-priority-low { background: #ECFDF5; color: #047857; }
|
||||
.erp-task-priority-high { background: #fef2f2; color: #dc2626; }
|
||||
.erp-task-priority-medium { background: #fffbeb; color: #d97706; }
|
||||
.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-medium { background: rgba(146, 64, 14, 0.15); color: #FCD34D; }
|
||||
[data-theme='dark'] .erp-task-priority-low { background: rgba(4, 120, 87, 0.15); color: #6EE7B7; }
|
||||
[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(217, 119, 6, 0.15); color: #fbbf24; }
|
||||
[data-theme='dark'] .erp-task-priority-low { background: rgba(5, 150, 105, 0.15); color: #34d399; }
|
||||
|
||||
/* Activity Timeline */
|
||||
.erp-activity-list {
|
||||
@@ -1143,12 +1189,12 @@ body {
|
||||
|
||||
.erp-activity-time {
|
||||
font-size: 11px;
|
||||
color: #64748B;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .erp-activity-time {
|
||||
color: #94A3B8;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function Home() {
|
||||
title: '用户总数',
|
||||
value: stats.userCount,
|
||||
icon: <UserOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||
gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-1',
|
||||
trend: { value: '+2', direction: 'up', label: '较上周' },
|
||||
@@ -191,7 +191,7 @@ export default function Home() {
|
||||
title: '流程实例',
|
||||
value: stats.processInstanceCount,
|
||||
icon: <FileTextOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
||||
gradient: 'linear-gradient(135deg, #d97706, #F59E0B)',
|
||||
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-3',
|
||||
trend: { value: '0', direction: 'neutral', label: '较昨日' },
|
||||
@@ -213,17 +213,17 @@ export default function Home() {
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' },
|
||||
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#2563eb' },
|
||||
{ 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: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
|
||||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#64748B' },
|
||||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#475569' },
|
||||
];
|
||||
|
||||
const pendingTasks: TaskItem[] = [
|
||||
{ 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: '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: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
|
||||
];
|
||||
|
||||
@@ -243,13 +243,13 @@ export default function Home() {
|
||||
<h2 style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: isDark ? '#F1F5F9' : '#0F172A',
|
||||
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
|
||||
margin: '0 0 4px',
|
||||
letterSpacing: '-0.5px',
|
||||
}}>
|
||||
工作台
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: isDark ? '#94A3B8' : '#475569', margin: 0 }}>
|
||||
<p style={{ fontSize: 14, color: isDark ? '#94a3b8' : '#475569', margin: 0 }}>
|
||||
欢迎回来,这是您的系统概览
|
||||
</p>
|
||||
</div>
|
||||
@@ -308,12 +308,12 @@ export default function Home() {
|
||||
<Col xs={24} lg={14}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
|
||||
<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 style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}>
|
||||
{pendingTasks.length} 项待处理
|
||||
</span>
|
||||
@@ -351,7 +351,7 @@ export default function Home() {
|
||||
<Col xs={24} lg={10}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
|
||||
<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>
|
||||
</div>
|
||||
<div className="erp-activity-list">
|
||||
@@ -400,7 +400,7 @@ export default function Home() {
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
|
||||
<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>
|
||||
</div>
|
||||
<div className="erp-system-info-list">
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Login() {
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #312E81 0%, #4F46E5 50%, #6366F1 100%)',
|
||||
background: 'linear-gradient(135deg, #312E81 0%, #2563eb 50%, #60a5fa 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
@@ -151,7 +151,7 @@ export default function Login() {
|
||||
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
|
||||
欢迎回来
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: '#64748B' }}>
|
||||
<p style={{ fontSize: 14, color: '#475569' }}>
|
||||
请登录您的账户以继续
|
||||
</p>
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function Login() {
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
|
||||
prefix={<UserOutlined style={{ color: '#94a3b8' }} />}
|
||||
placeholder="用户名"
|
||||
style={{ height: 44, borderRadius: 10 }}
|
||||
/>
|
||||
@@ -173,7 +173,7 @@ export default function Login() {
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
|
||||
prefix={<LockOutlined style={{ color: '#94a3b8' }} />}
|
||||
placeholder="密码"
|
||||
style={{ height: 44, borderRadius: 10 }}
|
||||
/>
|
||||
@@ -197,7 +197,7 @@ export default function Login() {
|
||||
</Form>
|
||||
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function Organizations() {
|
||||
const cardStyle = {
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
};
|
||||
|
||||
// --- Org tree state ---
|
||||
@@ -264,9 +264,9 @@ export default function Organizations() {
|
||||
{item.name}{' '}
|
||||
{item.code && <Tag style={{
|
||||
marginLeft: 4,
|
||||
background: isDark ? '#1E293B' : '#EEF2FF',
|
||||
background: isDark ? '#0f172a' : '#eff6ff',
|
||||
border: 'none',
|
||||
color: '#4F46E5',
|
||||
color: '#2563eb',
|
||||
fontSize: 11,
|
||||
}}>{item.code}</Tag>}
|
||||
</span>
|
||||
@@ -282,7 +282,7 @@ export default function Organizations() {
|
||||
{item.name}{' '}
|
||||
{item.code && <Tag style={{
|
||||
marginLeft: 4,
|
||||
background: isDark ? '#1E293B' : '#ECFDF5',
|
||||
background: isDark ? '#0f172a' : '#ECFDF5',
|
||||
border: 'none',
|
||||
color: '#059669',
|
||||
fontSize: 11,
|
||||
@@ -343,7 +343,7 @@ export default function Organizations() {
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>
|
||||
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} />
|
||||
<ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
||||
组织架构管理
|
||||
</h4>
|
||||
<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={{
|
||||
padding: '14px 20px',
|
||||
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
@@ -418,7 +418,7 @@ export default function Organizations() {
|
||||
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
@@ -471,7 +471,7 @@ export default function Organizations() {
|
||||
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -41,11 +41,11 @@ import {
|
||||
import PluginSettingsForm from '../components/PluginSettingsForm';
|
||||
|
||||
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
|
||||
uploaded: { color: '#64748B', label: '已上传' },
|
||||
uploaded: { color: '#475569', label: '已上传' },
|
||||
installed: { color: '#2563EB', label: '已安装' },
|
||||
enabled: { color: '#059669', label: '已启用' },
|
||||
running: { color: '#059669', label: '运行中' },
|
||||
disabled: { color: '#DC2626', label: '已禁用' },
|
||||
disabled: { color: '#dc2626', label: '已禁用' },
|
||||
uninstalled: { color: '#9333EA', label: '已卸载' },
|
||||
};
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function PluginAdmin() {
|
||||
key: 'status',
|
||||
width: 100,
|
||||
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>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -773,9 +773,14 @@ export default function PluginCRUDPage({
|
||||
name={field.name}
|
||||
label={field.display_name || field.name}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
|
||||
: []
|
||||
[
|
||||
...(field.required
|
||||
? [{ 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'}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Row, Col, Empty, Select, theme } from 'antd';
|
||||
import { DashboardOutlined } from '@ant-design/icons';
|
||||
import { countPluginData, aggregatePluginData } from '../api/pluginData';
|
||||
import { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginEntitySchema,
|
||||
@@ -134,10 +134,65 @@ export function PluginDashboardPage() {
|
||||
const results = await Promise.all(
|
||||
widgets.map(async (widget) => {
|
||||
try {
|
||||
// 旧类型
|
||||
if (widget.type === 'stat_card') {
|
||||
const count = await countPluginData(pluginId!, widget.entity);
|
||||
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) {
|
||||
const data = await aggregatePluginData(
|
||||
pluginId!,
|
||||
@@ -146,7 +201,7 @@ export function PluginDashboardPage() {
|
||||
);
|
||||
return { widget, data };
|
||||
}
|
||||
// 没有 dimension_field 时仅返回计数
|
||||
// fallback — 仅返回计数
|
||||
const count = await countPluginData(pluginId!, widget.entity);
|
||||
return { widget, data: [], count };
|
||||
} catch {
|
||||
@@ -244,7 +299,7 @@ export function PluginDashboardPage() {
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: isDark ? '#F1F5F9' : '#0F172A',
|
||||
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
|
||||
margin: '0 0 4px',
|
||||
letterSpacing: '-0.5px',
|
||||
}}
|
||||
@@ -254,7 +309,7 @@ export function PluginDashboardPage() {
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#94A3B8' : '#475569',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
@@ -297,7 +352,7 @@ export function PluginDashboardPage() {
|
||||
<div className="erp-section-header">
|
||||
<DashboardOutlined
|
||||
className="erp-section-icon"
|
||||
style={{ color: '#4F46E5' }}
|
||||
style={{ color: '#2563eb' }}
|
||||
/>
|
||||
<span className="erp-section-title">图表分析</span>
|
||||
</div>
|
||||
@@ -313,6 +368,10 @@ export function PluginDashboardPage() {
|
||||
{widgetData.map((wd) => {
|
||||
const colSpan = wd.widget.type === 'stat_card' ? 6
|
||||
: 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;
|
||||
return (
|
||||
<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">
|
||||
<DashboardOutlined
|
||||
className="erp-section-icon"
|
||||
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
|
||||
style={{ color: currentPalette.tagColor === 'purple' ? '#2563eb' : '#3B82F6' }}
|
||||
/>
|
||||
<span className="erp-section-title">
|
||||
{currentEntity?.display_name || selectedEntity} 数据分布
|
||||
|
||||
@@ -313,8 +313,8 @@ export function PluginGraphPage() {
|
||||
const r = degreeToRadius(degree, isCenter);
|
||||
|
||||
// Determine node color from its most common edge type, or default palette
|
||||
let nodeColorBase = '#4F46E5';
|
||||
let nodeColorLight = '#818CF8';
|
||||
let nodeColorBase = '#2563eb';
|
||||
let nodeColorLight = '#60a5fa';
|
||||
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
||||
|
||||
if (isCenter) {
|
||||
|
||||
@@ -40,9 +40,9 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
'财务': '#059669',
|
||||
'CRM': '#2563EB',
|
||||
'进销存': '#9333EA',
|
||||
'生产': '#DC2626',
|
||||
'人力资源': '#D97706',
|
||||
'基础': '#64748B',
|
||||
'生产': '#dc2626',
|
||||
'人力资源': '#d97706',
|
||||
'基础': '#475569',
|
||||
};
|
||||
|
||||
export default function PluginMarket() {
|
||||
@@ -190,7 +190,7 @@ export default function PluginMarket() {
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
|
||||
<Tag
|
||||
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'}
|
||||
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#475569'}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{plugin.category}
|
||||
@@ -244,7 +244,7 @@ export default function PluginMarket() {
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#64748B'}>
|
||||
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#475569'}>
|
||||
{selectedPlugin.category}
|
||||
</Tag>
|
||||
<Text type="secondary">v{selectedPlugin.version}</Text>
|
||||
|
||||
@@ -153,12 +153,12 @@ export default function Roles() {
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: record.is_system
|
||||
? 'linear-gradient(135deg, #4F46E5, #818CF8)'
|
||||
: isDark ? '#1E293B' : '#F1F5F9',
|
||||
? 'linear-gradient(135deg, #2563eb, #60a5fa)'
|
||||
: isDark ? '#0f172a' : '#f8fafc',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: record.is_system ? '#fff' : isDark ? '#94A3B8' : '#64748B',
|
||||
color: record.is_system ? '#fff' : isDark ? '#94a3b8' : '#475569',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
@@ -174,9 +174,9 @@ export default function Roles() {
|
||||
key: 'code',
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
@@ -190,7 +190,7 @@ export default function Roles() {
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
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) => (
|
||||
<Tag
|
||||
style={{
|
||||
color: v ? '#4F46E5' : (isDark ? '#94A3B8' : '#64748B'),
|
||||
background: v ? '#EEF2FF' : (isDark ? '#1E293B' : '#F1F5F9'),
|
||||
color: v ? '#2563eb' : (isDark ? '#94a3b8' : '#475569'),
|
||||
background: v ? '#eff6ff' : (isDark ? '#0f172a' : '#f8fafc'),
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
@@ -222,7 +222,7 @@ export default function Roles() {
|
||||
type="text"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
onClick={() => openPermModal(record)}
|
||||
style={{ color: '#4F46E5' }}
|
||||
style={{ color: '#2563eb' }}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
@@ -233,7 +233,7 @@ export default function Roles() {
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此角色?"
|
||||
@@ -279,7 +279,7 @@ export default function Roles() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
@@ -336,8 +336,8 @@ export default function Roles() {
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
|
||||
background: isDark ? '#0B0F1A' : '#F8FAFC',
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
|
||||
background: isDark ? '#0B0F1A' : '#f1f5f9',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
|
||||
@@ -36,8 +36,8 @@ import type { UserInfo } from '../api/auth';
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, string> = {
|
||||
active: '#059669',
|
||||
disabled: '#DC2626',
|
||||
locked: '#D97706',
|
||||
disabled: '#dc2626',
|
||||
locked: '#d97706',
|
||||
};
|
||||
|
||||
const STATUS_BG_MAP: Record<string, string> = {
|
||||
@@ -219,7 +219,7 @@ export default function Users() {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #4F46E5, #818CF8)',
|
||||
background: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -233,7 +233,7 @@ export default function Users() {
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
|
||||
{record.display_name && (
|
||||
<div style={{ fontSize: 12, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
|
||||
{record.display_name}
|
||||
</div>
|
||||
)}
|
||||
@@ -261,8 +261,8 @@ export default function Users() {
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
style={{
|
||||
color: STATUS_COLOR_MAP[status] || '#64748B',
|
||||
background: STATUS_BG_MAP[status] || '#F1F5F9',
|
||||
color: STATUS_COLOR_MAP[status] || '#62625b',
|
||||
background: STATUS_BG_MAP[status] || '#f8fafc',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
@@ -279,7 +279,7 @@ export default function Users() {
|
||||
roles.length > 0
|
||||
? roles.map((r) => (
|
||||
<Tag key={r.id} style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#CBD5E1' : '#475569',
|
||||
}}>
|
||||
@@ -299,14 +299,14 @@ export default function Users() {
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
onClick={() => openRoleModal(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
{record.status === 'active' ? (
|
||||
<Popconfirm
|
||||
@@ -356,7 +356,7 @@ export default function Users() {
|
||||
<Space size={8}>
|
||||
<Input
|
||||
placeholder="搜索用户名..."
|
||||
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
@@ -379,7 +379,7 @@ export default function Users() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
@@ -415,7 +415,7 @@ export default function Users() {
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined style={{ color: '#94A3B8' }} />} disabled={!!editUser} />
|
||||
<Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!editUser} />
|
||||
</Form.Item>
|
||||
{!editUser && (
|
||||
<Form.Item
|
||||
@@ -465,13 +465,13 @@ export default function Users() {
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
|
||||
background: isDark ? '#0B0F1A' : '#F8FAFC',
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
|
||||
background: isDark ? '#0B0F1A' : '#f1f5f9',
|
||||
}}
|
||||
>
|
||||
<Checkbox value={r.id}>
|
||||
<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}
|
||||
</span>
|
||||
</Checkbox>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 {
|
||||
InfoCircleOutlined,
|
||||
DashboardOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
||||
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
|
||||
import type { ActionQueryDef } from '../../api/plugins';
|
||||
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
|
||||
|
||||
// ── 计数动画 Hook ──
|
||||
@@ -44,7 +46,7 @@ function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
|
||||
const TAG_COLOR_MAP: Record<string, string> = {
|
||||
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
|
||||
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 {
|
||||
@@ -202,7 +204,7 @@ export function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||
const { widget, count } = widgetData;
|
||||
const animatedValue = useCountUp(count ?? 0);
|
||||
const color = widget.color || '#4F46E5';
|
||||
const color = widget.color || '#2563eb';
|
||||
return (
|
||||
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
|
||||
<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 }) {
|
||||
const { widget, data } = widgetData;
|
||||
const chartData = prepareChartData(data, widget.dimension_order);
|
||||
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
|
||||
return (
|
||||
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||
{chartData.length > 0 ? (
|
||||
@@ -273,7 +275,7 @@ function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||
const { widget, data } = widgetData;
|
||||
const chartData = prepareChartData(data, widget.dimension_order);
|
||||
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
|
||||
return (
|
||||
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||
{chartData.length > 0 ? (
|
||||
@@ -293,6 +295,146 @@ export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData;
|
||||
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
|
||||
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
// ── 通用调色板 ──
|
||||
|
||||
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, #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, #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' },
|
||||
@@ -82,6 +82,10 @@ export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
pie_chart: <PieChartOutlined />,
|
||||
funnel_chart: <FunnelPlotOutlined />,
|
||||
line_chart: <LineChartOutlined />,
|
||||
stat_cards: <DashboardOutlined />,
|
||||
action_list: <AppstoreOutlined />,
|
||||
funnel: <FunnelPlotOutlined />,
|
||||
card_list: <DatabaseOutlined />,
|
||||
};
|
||||
|
||||
// ── 延迟类名工具 ──
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from 'react';
|
||||
import type { AggregateItem } from '../../api/pluginData';
|
||||
import type { DashboardWidget } from '../../api/plugins';
|
||||
import type { AggregateItem, PluginDataRecord } from '../../api/pluginData';
|
||||
import type { DashboardWidget, StatCardDef, ActionQueryDef } from '../../api/plugins';
|
||||
|
||||
// ── 类型定义 ──
|
||||
|
||||
@@ -23,4 +23,7 @@ export interface WidgetData {
|
||||
widget: DashboardWidget;
|
||||
data: AggregateItem[];
|
||||
count?: number;
|
||||
records?: PluginDataRecord[];
|
||||
statCards?: { card: StatCardDef; value: number }[];
|
||||
actionItems?: { query: ActionQueryDef; records: PluginDataRecord[] }[];
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import type { GraphEdge } from './graphTypes';
|
||||
|
||||
/** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */
|
||||
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: '#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: '#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: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
|
||||
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
|
||||
|
||||
const channelMap: Record<string, { label: string; color: string }> = {
|
||||
in_app: { label: '站内', color: '#4F46E5' },
|
||||
in_app: { label: '站内', color: '#2563eb' },
|
||||
email: { label: '邮件', color: '#059669' },
|
||||
sms: { label: '短信', color: '#D97706' },
|
||||
sms: { label: '短信', color: '#d97706' },
|
||||
wechat: { label: '微信', color: '#7C3AED' },
|
||||
};
|
||||
|
||||
@@ -64,9 +64,9 @@ export default function MessageTemplates() {
|
||||
key: 'code',
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
@@ -80,7 +80,7 @@ export default function MessageTemplates() {
|
||||
key: 'channel',
|
||||
width: 90,
|
||||
render: (c: string) => {
|
||||
const info = channelMap[c] || { label: c, color: '#64748B' };
|
||||
const info = channelMap[c] || { label: c, color: '#475569' };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.color + '15',
|
||||
@@ -111,7 +111,7 @@ export default function MessageTemplates() {
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
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',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
|
||||
共 {total} 个模板
|
||||
</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
@@ -135,7 +135,7 @@ export default function MessageTemplates() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -11,9 +11,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const priorityStyles: Record<string, { bg: string; color: string; text: string }> = {
|
||||
urgent: { bg: '#FEF2F2', color: '#DC2626', text: '紧急' },
|
||||
important: { bg: '#FFFBEB', color: '#D97706', text: '重要' },
|
||||
normal: { bg: '#EEF2FF', color: '#4F46E5', text: '普通' },
|
||||
urgent: { bg: '#FEF2F2', color: '#dc2626', text: '紧急' },
|
||||
important: { bg: '#FFFBEB', color: '#d97706', text: '重要' },
|
||||
normal: { bg: '#eff6ff', color: '#2563eb', text: '普通' },
|
||||
};
|
||||
|
||||
export default function NotificationList({ queryFilter }: Props) {
|
||||
@@ -83,7 +83,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
content: (
|
||||
<div>
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
style={{
|
||||
fontWeight: record.is_read ? 400 : 600,
|
||||
cursor: 'pointer',
|
||||
color: record.is_read ? (isDark ? '#94A3B8' : '#64748B') : 'inherit',
|
||||
color: record.is_read ? (isDark ? '#94a3b8' : '#475569') : 'inherit',
|
||||
}}
|
||||
onClick={() => showDetail(record)}
|
||||
>
|
||||
@@ -114,7 +114,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#4F46E5',
|
||||
background: '#2563eb',
|
||||
marginRight: 8,
|
||||
}} />
|
||||
)}
|
||||
@@ -128,7 +128,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
key: 'priority',
|
||||
width: 90,
|
||||
render: (p: string) => {
|
||||
const info = priorityStyles[p] || { bg: '#F1F5F9', color: '#64748B', text: p };
|
||||
const info = priorityStyles[p] || { bg: '#f8fafc', color: '#475569', text: p };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
@@ -146,7 +146,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
dataIndex: 'sender_type',
|
||||
key: 'sender_type',
|
||||
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: '状态',
|
||||
@@ -155,9 +155,9 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
width: 80,
|
||||
render: (r: boolean) => (
|
||||
<Tag style={{
|
||||
background: r ? (isDark ? '#1E293B' : '#F1F5F9') : '#EEF2FF',
|
||||
background: r ? (isDark ? '#0f172a' : '#f8fafc') : '#eff6ff',
|
||||
border: 'none',
|
||||
color: r ? (isDark ? '#64748B' : '#94A3B8') : '#4F46E5',
|
||||
color: r ? (isDark ? '#475569' : '#94a3b8') : '#2563eb',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{r ? '已读' : '未读'}
|
||||
@@ -170,7 +170,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
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"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleMarkRead(record.id)}
|
||||
style={{ color: '#4F46E5' }}
|
||||
style={{ color: '#2563eb' }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
@@ -193,7 +193,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => showDetail(record)}
|
||||
style={{ color: isDark ? '#64748B' : '#94A3B8' }}
|
||||
style={{ color: isDark ? '#475569' : '#94a3b8' }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -215,7 +215,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
|
||||
共 {total} 条消息
|
||||
</span>
|
||||
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
|
||||
@@ -226,7 +226,7 @@ export default function NotificationList({ queryFilter }: Props) {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -48,12 +48,12 @@ export default function NotificationPreferences() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
padding: 24,
|
||||
maxWidth: 600,
|
||||
}}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
// 通用边调色板
|
||||
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: '#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: '#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: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
|
||||
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },
|
||||
|
||||
@@ -295,8 +295,8 @@ export function drawFullGraph(
|
||||
const degree = degreeMap.get(node.id) || 0;
|
||||
const r = degreeToRadius(degree, isCenter);
|
||||
|
||||
let nodeColorBase = '#4F46E5';
|
||||
let nodeColorLight = '#818CF8';
|
||||
let nodeColorBase = '#2563eb';
|
||||
let nodeColorLight = '#60a5fa';
|
||||
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
||||
|
||||
if (isCenter) {
|
||||
|
||||
@@ -18,8 +18,8 @@ const RESOURCE_TYPE_OPTIONS = [
|
||||
|
||||
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
|
||||
create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
|
||||
update: { bg: '#EEF2FF', color: '#4F46E5', text: '更新' },
|
||||
delete: { bg: '#FEF2F2', color: '#DC2626', text: '删除' },
|
||||
update: { bg: '#eff6ff', color: '#2563eb', text: '更新' },
|
||||
delete: { bg: '#FEF2F2', color: '#dc2626', text: '删除' },
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
@@ -80,7 +80,7 @@ export default function AuditLogViewer() {
|
||||
key: 'action',
|
||||
width: 100,
|
||||
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 (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
@@ -100,7 +100,7 @@ export default function AuditLogViewer() {
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#CBD5E1' : '#475569',
|
||||
}}>
|
||||
@@ -115,7 +115,7 @@ export default function AuditLogViewer() {
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
@@ -127,7 +127,7 @@ export default function AuditLogViewer() {
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
@@ -138,7 +138,7 @@ export default function AuditLogViewer() {
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{formatDateTime(value)}
|
||||
</span>
|
||||
),
|
||||
@@ -156,7 +156,7 @@ export default function AuditLogViewer() {
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}>
|
||||
<Select
|
||||
allowClear
|
||||
@@ -173,7 +173,7 @@ export default function AuditLogViewer() {
|
||||
value={query.user_id ?? ''}
|
||||
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} 条日志
|
||||
</span>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ export default function AuditLogViewer() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function SystemSettings() {
|
||||
width: 250,
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#CBD5E1' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
@@ -162,7 +162,7 @@ export default function SystemSettings() {
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此设置?"
|
||||
@@ -191,7 +191,7 @@ export default function SystemSettings() {
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="输入设置键名查询"
|
||||
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
@@ -207,7 +207,7 @@ export default function SystemSettings() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -5,8 +5,8 @@ import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks';
|
||||
|
||||
const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = {
|
||||
approved: { bg: '#ECFDF5', color: '#059669', text: '同意' },
|
||||
rejected: { bg: '#FEF2F2', color: '#DC2626', text: '拒绝' },
|
||||
delegated: { bg: '#EEF2FF', color: '#4F46E5', text: '已委派' },
|
||||
rejected: { bg: '#FEF2F2', color: '#dc2626', text: '拒绝' },
|
||||
delegated: { bg: '#eff6ff', color: '#2563eb', text: '已委派' },
|
||||
};
|
||||
|
||||
export default function CompletedTasks() {
|
||||
@@ -50,7 +50,7 @@ export default function CompletedTasks() {
|
||||
key: 'outcome',
|
||||
width: 100,
|
||||
render: (o: string) => {
|
||||
const info = outcomeStyles[o] || { bg: '#F1F5F9', color: '#64748B', text: o };
|
||||
const info = outcomeStyles[o] || { bg: '#f8fafc', color: '#475569', text: o };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
@@ -69,7 +69,7 @@ export default function CompletedTasks() {
|
||||
key: 'completed_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{v ? new Date(v).toLocaleString() : '-'}
|
||||
</span>
|
||||
),
|
||||
@@ -80,7 +80,7 @@ export default function CompletedTasks() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -13,10 +13,10 @@ import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/work
|
||||
import ProcessViewer from './ProcessViewer';
|
||||
|
||||
const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
|
||||
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' },
|
||||
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' },
|
||||
running: { bg: '#eff6ff', color: '#2563eb', text: '运行中' },
|
||||
suspended: { bg: '#FFFBEB', color: '#d97706', text: '已挂起' },
|
||||
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
|
||||
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' },
|
||||
terminated: { bg: '#FEF2F2', color: '#dc2626', text: '已终止' },
|
||||
};
|
||||
|
||||
export default function InstanceMonitor() {
|
||||
@@ -129,7 +129,7 @@ export default function InstanceMonitor() {
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
|
||||
const info = statusStyles[s] || { bg: '#f8fafc', color: '#475569', text: s };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
@@ -154,7 +154,7 @@ export default function InstanceMonitor() {
|
||||
key: 'started_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{new Date(v).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
@@ -214,7 +214,7 @@ export default function InstanceMonitor() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -76,9 +76,9 @@ export default function PendingTasks() {
|
||||
key: 'business_key',
|
||||
render: (v: string | undefined) => v ? (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
@@ -93,9 +93,9 @@ export default function PendingTasks() {
|
||||
width: 100,
|
||||
render: (s: string) => (
|
||||
<Tag style={{
|
||||
background: '#EEF2FF',
|
||||
background: '#eff6ff',
|
||||
border: 'none',
|
||||
color: '#4F46E5',
|
||||
color: '#2563eb',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{s}
|
||||
@@ -108,7 +108,7 @@ export default function PendingTasks() {
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{new Date(v).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
@@ -145,7 +145,7 @@ export default function PendingTasks() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
import ProcessDesigner from './ProcessDesigner';
|
||||
|
||||
const statusColors: Record<string, { bg: string; color: string; text: string }> = {
|
||||
draft: { bg: '#F1F5F9', color: '#64748B', text: '草稿' },
|
||||
published: { bg: '#ECFDF5', color: '#059669', text: '已发布' },
|
||||
deprecated: { bg: '#FEF2F2', color: '#DC2626', text: '已弃用' },
|
||||
draft: { bg: '#f8fafc', color: '#475569', text: '草稿' },
|
||||
published: { bg: '#ecfdf5', color: '#059669', text: '已发布' },
|
||||
deprecated: { bg: '#fef2f2', color: '#dc2626', text: '已弃用' },
|
||||
};
|
||||
|
||||
export default function ProcessDefinitions() {
|
||||
@@ -92,9 +92,9 @@ export default function ProcessDefinitions() {
|
||||
key: 'key',
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
background: isDark ? '#1E293B' : '#f8fafc',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
@@ -110,7 +110,7 @@ export default function ProcessDefinitions() {
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const info = statusColors[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
|
||||
const info = statusColors[s] || { bg: '#f8fafc', color: '#475569', text: s };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
@@ -152,7 +152,7 @@ export default function ProcessDefinitions() {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
|
||||
共 {total} 个流程定义
|
||||
</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
@@ -163,7 +163,7 @@ export default function ProcessDefinitions() {
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
type: 'eq' | 'neq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
@@ -49,6 +49,16 @@ function tokenize(input: string): string[] {
|
||||
i += 2;
|
||||
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;
|
||||
while (
|
||||
j < input.length &&
|
||||
@@ -81,12 +91,12 @@ function parseAtom(tokens: string[]): ExprNode | null {
|
||||
if (op !== '==' && op !== '!=') return null;
|
||||
const rawValue = tokens.shift() || '';
|
||||
const value = rawValue.replace(/^'(.*)'$/, '$1');
|
||||
return { type: 'eq', field, value };
|
||||
return { type: op === '!=' ? 'neq' : 'eq', field, value };
|
||||
}
|
||||
|
||||
function parseAnd(tokens: string[]): ExprNode | null {
|
||||
let left = parseAtom(tokens);
|
||||
while (tokens[0] === 'AND') {
|
||||
while (tokens[0] === 'AND' || tokens[0] === '&&') {
|
||||
tokens.shift();
|
||||
const right = parseAtom(tokens);
|
||||
if (left && right) {
|
||||
@@ -98,7 +108,7 @@ function parseAnd(tokens: string[]): ExprNode | null {
|
||||
|
||||
function parseOr(tokens: string[]): ExprNode | null {
|
||||
let left = parseAnd(tokens);
|
||||
while (tokens[0] === 'OR') {
|
||||
while (tokens[0] === 'OR' || tokens[0] === '||') {
|
||||
tokens.shift();
|
||||
const right = parseAnd(tokens);
|
||||
if (left && right) {
|
||||
@@ -117,6 +127,8 @@ export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): b
|
||||
switch (node.type) {
|
||||
case 'eq':
|
||||
return String(values[node.field!] ?? '') === node.value;
|
||||
case 'neq':
|
||||
return String(values[node.field!] ?? '') !== node.value;
|
||||
case 'and':
|
||||
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
|
||||
case 'or':
|
||||
|
||||
11
crates/erp-plugin-freelance/Cargo.toml
Normal file
11
crates/erp-plugin-freelance/Cargo.toml
Normal 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"
|
||||
1250
crates/erp-plugin-freelance/plugin.toml
Normal file
1250
crates/erp-plugin-freelance/plugin.toml
Normal file
File diff suppressed because it is too large
Load Diff
26
crates/erp-plugin-freelance/src/lib.rs
Normal file
26
crates/erp-plugin-freelance/src/lib.rs
Normal 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);
|
||||
11
crates/erp-plugin-itops/Cargo.toml
Normal file
11
crates/erp-plugin-itops/Cargo.toml
Normal 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"
|
||||
633
crates/erp-plugin-itops/plugin.toml
Normal file
633
crates/erp-plugin-itops/plugin.toml
Normal 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
|
||||
26
crates/erp-plugin-itops/src/lib.rs
Normal file
26
crates/erp-plugin-itops/src/lib.rs
Normal 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);
|
||||
@@ -284,6 +284,8 @@ pub enum PluginPageType {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
widgets: Vec<PluginWidget>,
|
||||
},
|
||||
#[serde(rename = "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 页面类型)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
@@ -1553,4 +1629,153 @@ name = "管理发票"
|
||||
assert_eq!(entities[0].importable, 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(自由职业者工作台)和 itops(IT 运维服务台)两个 WASM 插件,覆盖其全部 12 条经营范围。
|
||||
|
||||
**Architecture:** 两个独立的 WASM 插件 crate,每个包含 Cargo.toml(cdylib)、src/lib.rs(Guest 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.toml(freelance)
|
||||
|
||||
**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.3(10 个实体)+ 2.4(编号规则)+ 2.5(页面声明)的所有 TOML 内容,合并为完整的 `plugin.toml` 文件。
|
||||
|
||||
文件结构:
|
||||
1. `[metadata]` 段
|
||||
2. `[[permissions]]` × 20
|
||||
3. `[[schema.entities]]` × 10(client, opportunity, quote, quote_line, contract, project, task, time_entry, invoice, expense),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 3(quote_number, contract_number, invoice_number)
|
||||
5. `[[ui.pages]]` × 7(dashboard, 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: < 100KB(CRM 约 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]]` × 4(service_contract, ticket, check_plan, check_record),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 1(contract_number)
|
||||
5. `[[ui.pages]]` × 4(crud+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: 测试跨插件引用**
|
||||
|
||||
场景 A(freelance 已安装):创建工单时 client_id 字段显示为下拉选择器,可搜索 freelance.client
|
||||
场景 B(freelance 未安装):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` | 插件制作完整流程 |
|
||||
@@ -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"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,SLA 承诺正确
|
||||
- [ ] `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
@@ -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 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P2: itops Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P3: freelance Layer 3(3 个 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` 字段 + 前端渲染组件。
|
||||
172
plans/calm-forging-puddle.md
Normal file
172
plans/calm-forging-puddle.md
Normal 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 时返回原 JSON;csv/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. 每批完成后独立提交推送
|
||||
138
plans/skill-parallel-pixel-agent-a2852b5abd5e15119.md
Normal file
138
plans/skill-parallel-pixel-agent-a2852b5abd5e15119.md
Normal 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 插件声明这个 dashboard,itops 插件通过 `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 个抽屉分门别类,需要一把刀随时打开就能用。
|
||||
50
plans/skill-parallel-pixel-agent-a8e98f2c813be4f33.md
Normal file
50
plans/skill-parallel-pixel-agent-a8e98f2c813be4f33.md
Normal 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,并发高时需关注连接池。但每个查询都是标准 SQL,PostgreSQL 处理无压力。**建议 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。
|
||||
74
plans/skill-parallel-pixel-agent-af894fc9048c54be3.md
Normal file
74
plans/skill-parallel-pixel-agent-af894fc9048c54be3.md
Normal 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/service),virtual 类型走零库存逻辑,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/service),virtual/service 走零库存逻辑。
|
||||
|
||||
**不新增独立插件。** 9 条经营范围通过 freelance 的分类字段 + inventory 配合即可全覆盖。一人公司最忌讳系统复杂度过高,三个插件(freelance + itops + inventory)足够。
|
||||
1301
plans/skill-parallel-pixel.md
Normal file
1301
plans/skill-parallel-pixel.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user