diff --git a/apps/web/src/pages/Workflow.tsx b/apps/web/src/pages/Workflow.tsx
index f9efc64..84b2630 100644
--- a/apps/web/src/pages/Workflow.tsx
+++ b/apps/web/src/pages/Workflow.tsx
@@ -9,7 +9,7 @@ export default function Workflow() {
const [activeKey, setActiveKey] = useState('definitions');
return (
-
+
{
+ const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listCompletedTasks(page, 20);
@@ -24,9 +24,9 @@ export default function CompletedTasks() {
} finally {
setLoading(false);
}
- };
+ }, [page]);
- useEffect(() => { fetch(); }, [page]);
+ useEffect(() => { fetchData(); }, [fetchData]);
const columns: ColumnsType = [
{ title: '任务名称', dataIndex: 'node_name', key: 'node_name' },
diff --git a/apps/web/src/pages/workflow/InstanceMonitor.tsx b/apps/web/src/pages/workflow/InstanceMonitor.tsx
index d4aa2a1..9f16b9d 100644
--- a/apps/web/src/pages/workflow/InstanceMonitor.tsx
+++ b/apps/web/src/pages/workflow/InstanceMonitor.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useState } from 'react';
-import { Button, message, Space, Table, Tag } from 'antd';
+import { useCallback, useEffect, useState } from 'react';
+import { Button, message, Modal, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
listInstances,
@@ -20,7 +20,7 @@ export default function InstanceMonitor() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
- const fetch = async () => {
+ const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listInstances(page, 20);
@@ -29,18 +29,27 @@ export default function InstanceMonitor() {
} finally {
setLoading(false);
}
- };
+ }, [page]);
- useEffect(() => { fetch(); }, [page]);
+ useEffect(() => { fetchData(); }, [fetchData]);
const handleTerminate = async (id: string) => {
- try {
- await terminateInstance(id);
- message.success('已终止');
- fetch();
- } catch {
- message.error('操作失败');
- }
+ Modal.confirm({
+ title: '确认终止',
+ content: '确定要终止该流程实例吗?此操作不可撤销。',
+ okText: '确定终止',
+ okType: 'danger',
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ await terminateInstance(id);
+ message.success('已终止');
+ fetchData();
+ } catch {
+ message.error('操作失败');
+ }
+ },
+ });
};
const columns: ColumnsType = [
diff --git a/apps/web/src/pages/workflow/PendingTasks.tsx b/apps/web/src/pages/workflow/PendingTasks.tsx
index 34d8efa..61a9a5f 100644
--- a/apps/web/src/pages/workflow/PendingTasks.tsx
+++ b/apps/web/src/pages/workflow/PendingTasks.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { Button, message, Modal, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -19,7 +19,7 @@ export default function PendingTasks() {
const [completeModal, setCompleteModal] = useState(null);
const [outcome, setOutcome] = useState('approved');
- const fetch = async () => {
+ const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listPendingTasks(page, 20);
@@ -28,9 +28,9 @@ export default function PendingTasks() {
} finally {
setLoading(false);
}
- };
+ }, [page]);
- useEffect(() => { fetch(); }, [page]);
+ useEffect(() => { fetchData(); }, [fetchData]);
const handleComplete = async () => {
if (!completeModal) return;
@@ -38,7 +38,7 @@ export default function PendingTasks() {
await completeTask(completeModal.id, { outcome });
message.success('审批完成');
setCompleteModal(null);
- fetch();
+ fetchData();
} catch {
message.error('审批失败');
}
diff --git a/apps/web/src/pages/workflow/ProcessDefinitions.tsx b/apps/web/src/pages/workflow/ProcessDefinitions.tsx
index 545709b..a918bdd 100644
--- a/apps/web/src/pages/workflow/ProcessDefinitions.tsx
+++ b/apps/web/src/pages/workflow/ProcessDefinitions.tsx
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
import {
listProcessDefinitions,
createProcessDefinition,
+ updateProcessDefinition,
publishProcessDefinition,
type ProcessDefinitionInfo,
type CreateProcessDefinitionRequest,
@@ -57,14 +58,19 @@ export default function ProcessDefinitions() {
}
};
- const handleSave = async (req: CreateProcessDefinitionRequest) => {
+ const handleSave = async (req: CreateProcessDefinitionRequest, id?: string) => {
try {
- await createProcessDefinition(req);
- message.success('创建成功');
+ if (id) {
+ await updateProcessDefinition(id, req);
+ message.success('更新成功');
+ } else {
+ await createProcessDefinition(req);
+ message.success('创建成功');
+ }
setDesignerOpen(false);
fetch();
} catch {
- message.error('创建失败');
+ message.error(id ? '更新失败' : '创建失败');
}
};
diff --git a/apps/web/src/pages/workflow/ProcessDesigner.tsx b/apps/web/src/pages/workflow/ProcessDesigner.tsx
index 3104115..fc4eae6 100644
--- a/apps/web/src/pages/workflow/ProcessDesigner.tsx
+++ b/apps/web/src/pages/workflow/ProcessDesigner.tsx
@@ -1,5 +1,5 @@
-import { useCallback, useMemo, useState } from 'react';
-import { Button, Form, Input, message, Space } from 'antd';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Button, Form, Input, message, Spin } from 'antd';
import {
ReactFlow,
Controls,
@@ -18,6 +18,7 @@ import {
type CreateProcessDefinitionRequest,
type NodeDef,
type EdgeDef,
+ getProcessDefinition,
} from '../../api/workflowDefinitions';
const NODE_TYPES_MAP: Record = {
@@ -35,9 +36,9 @@ const PALETTE_ITEMS = Object.entries(NODE_TYPES_MAP).map(([type, info]) => ({
color: info.color,
}));
-function createFlowNode(type: string, label: string, position: { x: number; y: number }): Node {
+function createFlowNode(type: string, label: string, position: { x: number; y: number }, id?: string): Node {
return {
- id: `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
+ id: id || `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type: 'default',
position,
data: { label: `${label}`, nodeType: type, name: label },
@@ -57,31 +58,57 @@ function createFlowNode(type: string, label: string, position: { x: number; y: n
interface ProcessDesignerProps {
definitionId: string | null;
- onSave: (req: CreateProcessDefinitionRequest) => void;
+ onSave: (req: CreateProcessDefinitionRequest, id?: string) => void;
}
-export default function ProcessDesigner({ onSave }: ProcessDesignerProps) {
+export default function ProcessDesigner({ definitionId, onSave }: ProcessDesignerProps) {
const [form] = Form.useForm();
const [selectedNode, setSelectedNode] = useState(null);
- const [nodes, setNodes, onNodesChange] = useNodesState([
- createFlowNode('StartEvent', '开始', { x: 250, y: 50 }),
- createFlowNode('UserTask', '审批', { x: 250, y: 200 }),
- createFlowNode('EndEvent', '结束', { x: 250, y: 400 }),
- ]);
- const [edges, setEdges, onEdgesChange] = useEdgesState([
- {
- id: 'e_start_approve',
- source: nodes[0].id,
- target: nodes[1].id,
- markerEnd: { type: MarkerType.ArrowClosed },
- },
- {
- id: 'e_approve_end',
- source: nodes[1].id,
- target: nodes[2].id,
- markerEnd: { type: MarkerType.ArrowClosed },
- },
- ]);
+ const [loading, setLoading] = useState(false);
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+
+ const isEditing = definitionId !== null;
+
+ // 加载流程定义(编辑模式)或初始化默认节点(新建模式)
+ useEffect(() => {
+ if (!definitionId) {
+ const startNode = createFlowNode('StartEvent', '开始', { x: 250, y: 50 });
+ const userNode = createFlowNode('UserTask', '审批', { x: 250, y: 200 });
+ const endNode = createFlowNode('EndEvent', '结束', { x: 250, y: 400 });
+ setNodes([startNode, userNode, endNode]);
+ setEdges([
+ { id: 'e_start_approve', source: startNode.id, target: userNode.id, markerEnd: { type: MarkerType.ArrowClosed } },
+ { id: 'e_approve_end', source: userNode.id, target: endNode.id, markerEnd: { type: MarkerType.ArrowClosed } },
+ ]);
+ return;
+ }
+
+ setLoading(true);
+ getProcessDefinition(definitionId)
+ .then((def) => {
+ form.setFieldsValue({
+ name: def.name,
+ key: def.key,
+ category: def.category,
+ description: def.description,
+ });
+ const flowNodes = def.nodes.map((n, i) =>
+ createFlowNode(n.type, n.name, n.position || { x: 200, y: i * 120 + 50 }, n.id)
+ );
+ const flowEdges: Edge[] = def.edges.map((e) => ({
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ markerEnd: { type: MarkerType.ArrowClosed },
+ label: e.label || e.condition,
+ }));
+ setNodes(flowNodes);
+ setEdges(flowEdges);
+ })
+ .catch(() => message.error('加载流程定义失败'))
+ .finally(() => setLoading(false));
+ }, [definitionId]); // eslint-disable-line react-hooks/exhaustive-deps
const onConnect = useCallback(
(connection: Connection) => {
@@ -136,17 +163,18 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) {
id: n.id,
type: (n.data.nodeType as NodeDef['type']) || 'UserTask',
name: n.data.name || String(n.data.label),
+ position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
}));
const flowEdges: EdgeDef[] = edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
+ label: e.label ? String(e.label) : undefined,
}));
- onSave({
- ...values,
- nodes: flowNodes,
- edges: flowEdges,
- });
+ onSave(
+ { ...values, nodes: flowNodes, edges: flowEdges },
+ definitionId || undefined,
+ );
}).catch(() => {
message.error('请填写必要字段');
});
@@ -159,6 +187,10 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) {
[],
);
+ if (loading) {
+ return
;
+ }
+
return (
{/* 左侧工具面板 */}
@@ -224,7 +256,7 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) {
-
+
@@ -232,10 +264,7 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) {
-
-
-
-
+
diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs
index 400ffba..3ec66ad 100644
--- a/crates/erp-auth/src/service/user_service.rs
+++ b/crates/erp-auth/src/service/user_service.rs
@@ -136,13 +136,11 @@ impl UserService {
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null());
- if let Some(term) = search {
- if !term.is_empty() {
- use sea_orm::sea_query::Expr;
- query = query.filter(
- Expr::col(user::Column::Username).like(format!("%{}%", term)),
- );
- }
+ if let Some(term) = search && !term.is_empty() {
+ use sea_orm::sea_query::Expr;
+ query = query.filter(
+ Expr::col(user::Column::Username).like(format!("%{}%", term)),
+ );
}
let paginator = query.paginate(db, pagination.limit());
diff --git a/crates/erp-server/src/handlers/health.rs b/crates/erp-server/src/handlers/health.rs
index e92e063..4af07ea 100644
--- a/crates/erp-server/src/handlers/health.rs
+++ b/crates/erp-server/src/handlers/health.rs
@@ -13,7 +13,7 @@ pub struct HealthResponse {
pub modules: Vec
,
}
-/// GET /api/v1/health
+/// GET /health
///
/// 服务健康检查,返回运行状态和已注册模块列表
pub async fn health_check(State(state): State) -> Json {
@@ -32,5 +32,5 @@ pub async fn health_check(State(state): State) -> Json
}
pub fn health_check_router() -> Router {
- Router::new().route("/api/v1/health", get(health_check))
+ Router::new().route("/health", get(health_check))
}
diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs
index 99f8abb..f00a795 100644
--- a/crates/erp-server/src/main.rs
+++ b/crates/erp-server/src/main.rs
@@ -12,11 +12,11 @@ mod state;
description = "ERP 平台底座 REST API 文档"
)
)]
+#[allow(dead_code)]
struct ApiDoc;
use axum::Router;
use axum::middleware;
-use axum::response::Json;
use config::AppConfig;
use erp_auth::middleware::jwt_auth_middleware_fn;
use state::AppState;
@@ -168,9 +168,6 @@ async fn main() -> anyhow::Result<()> {
let public_routes = Router::new()
.merge(handlers::health::health_check_router())
.merge(erp_auth::AuthModule::public_routes())
- .route("/api/docs/openapi.json", axum::routing::get(|| async {
- Json(ApiDoc::openapi())
- }))
.with_state(state.clone());
// Protected routes (JWT authentication required)
@@ -185,10 +182,10 @@ async fn main() -> anyhow::Result<()> {
.with_state(state.clone());
// Merge public + protected into the final application router
+ // All API routes are nested under /api/v1
let cors = build_cors_layer(&state.config.cors.allowed_origins);
let app = Router::new()
- .merge(public_routes)
- .merge(protected_routes)
+ .nest("/api/v1", public_routes.merge(protected_routes))
.layer(cors);
let addr = format!("{}:{}", host, port);
diff --git a/plans/bubbly-squishing-lerdorf.md b/plans/bubbly-squishing-lerdorf.md
new file mode 100644
index 0000000..e6bd117
--- /dev/null
+++ b/plans/bubbly-squishing-lerdorf.md
@@ -0,0 +1,210 @@
+# Phase 5-6 Implementation Plan: Message Center + Integration & Polish
+
+## Context
+
+Phase 1-4 已完成(core, auth, config, workflow)。现在需要实现 Phase 5(消息中心)和 Phase 6(整合与打磨)。`erp-message` crate 当前是空壳,需要完整实现。
+
+---
+
+## Phase 5: 消息中心
+
+### Task 5.1: 数据库迁移 — 消息相关表
+
+**新增 3 个迁移文件:**
+
+1. `m20260413_000023_create_message_templates.rs` — 消息模板表
+ - id (UUID PK), tenant_id, name, code (唯一编码), channel (in_app/email/sms/wechat),
+ - title_template, body_template (支持 `{{variable}}` 插值), language (zh-CN/en-US),
+ - 标准审计字段 (created_at, updated_at, created_by, updated_by, deleted_at)
+
+2. `m20260413_000024_create_messages.rs` — 消息表
+ - id (UUID PK), tenant_id, template_id (FK, nullable),
+ - sender_id (UUID, nullable=系统消息), sender_type (system/user),
+ - recipient_id (UUID, not null), recipient_type (user/role/department/all),
+ - title, body, priority (normal/important/urgent),
+ - business_type (workflow_task/system_notice/...), business_id (deep link ref),
+ - is_read (bool), read_at (nullable),
+ - is_archived (bool), archived_at (nullable),
+ - sent_at (nullable, for scheduled), status (pending/sent/recalled),
+ - 标准审计字段
+
+3. `m20260413_000025_create_message_subscriptions.rs` — 消息订阅偏好
+ - id (UUID PK), tenant_id, user_id,
+ - notification_types (JSON: 订阅的通知类型列表),
+ - channel_preferences (JSON: 各类型偏好的通道),
+ - dnd_enabled (bool), dnd_start (time), dnd_end (time),
+ - 标准审计字段
+
+**修改文件:**
+- `crates/erp-server/migration/src/lib.rs` — 注册 3 个新迁移
+
+### Task 5.2: erp-message crate 基础结构
+
+**修改/创建文件:**
+
+1. `crates/erp-message/Cargo.toml` — 补齐缺失依赖 (thiserror, utoipa, async-trait, validator, serde/uuid/chrono features, sea-orm features)
+2. `crates/erp-message/src/lib.rs` — 声明子模块 + pub use
+3. `crates/erp-message/src/message_state.rs` — MessageState { db, event_bus }
+4. `crates/erp-message/src/error.rs` — MessageError 枚举 + From impls
+5. `crates/erp-message/src/dto.rs` — 请求/响应 DTOs
+6. `crates/erp-message/src/entity/mod.rs` — 实体子模块声明
+7. `crates/erp-message/src/entity/message_template.rs`
+8. `crates/erp-message/src/entity/message.rs`
+9. `crates/erp-message/src/entity/message_subscription.rs`
+10. `crates/erp-message/src/module.rs` — MessageModule 实现 ErpModule
+
+### Task 5.3: 消息 CRUD 服务与处理器
+
+**创建文件:**
+
+1. `crates/erp-message/src/service/mod.rs`
+2. `crates/erp-message/src/service/message_service.rs` — 消息 CRUD + 发送 + 已读/未读
+3. `crates/erp-message/src/service/template_service.rs` — 模板 CRUD + 变量插值渲染
+4. `crates/erp-message/src/service/subscription_service.rs` — 订阅偏好 CRUD
+5. `crates/erp-message/src/handler/mod.rs`
+6. `crates/erp-message/src/handler/message_handler.rs` — 消息 API handlers
+7. `crates/erp-message/src/handler/template_handler.rs` — 模板 API handlers
+8. `crates/erp-message/src/handler/subscription_handler.rs` — 订阅 API handlers
+
+**路由设计:**
+```
+GET /messages — 消息列表 (分页, 支持 status/priority/is_read 过滤)
+GET /messages/unread-count — 未读消息数
+PUT /messages/{id}/read — 标记已读
+PUT /messages/read-all — 全部标记已读
+DELETE /messages/{id} — 删除消息 (软删除)
+POST /messages/send — 发送消息
+
+GET /message-templates — 模板列表
+POST /message-templates — 创建模板
+
+PUT /message-subscriptions — 更新订阅偏好
+```
+
+### Task 5.4: 服务器端集成
+
+**修改文件:**
+
+1. `crates/erp-server/Cargo.toml` — 添加 erp-message 依赖
+2. `crates/erp-server/src/state.rs` — 添加 `FromRef for MessageState`
+3. `crates/erp-server/src/main.rs` — 初始化并注册 MessageModule,合并路由
+
+### Task 5.5: 前端 — 消息 API 与页面
+
+**创建/修改文件:**
+
+1. `apps/web/src/api/messages.ts` — 消息 API 客户端
+2. `apps/web/src/api/messageTemplates.ts` — 模板 API 客户端
+3. `apps/web/src/pages/Messages.tsx` — 消息中心主页面 (Tabs: 通知列表/已归档)
+4. `apps/web/src/pages/messages/NotificationList.tsx` — 通知列表子组件
+5. `apps/web/src/pages/messages/MessageTemplates.tsx` — 模板管理子组件
+6. `apps/web/src/pages/messages/NotificationPreferences.tsx` — 通知偏好设置
+7. `apps/web/src/App.tsx` — 添加 `/messages` 路由
+8. `apps/web/src/layouts/MainLayout.tsx` — 添加消息菜单项 + Bell 点击弹出通知面板
+
+### Task 5.6: 通知面板与未读计数
+
+**修改文件:**
+
+1. `apps/web/src/layouts/MainLayout.tsx` — Bell 图标添加 Badge (未读数) + Popover 通知面板
+2. `apps/web/src/stores/message.ts` — Zustand store: unreadCount, fetchUnread, recentMessages
+3. `apps/web/src/components/NotificationPanel.tsx` — 通知弹出面板组件
+
+---
+
+## Phase 6: 整合与打磨
+
+### Task 6.1: 跨模块事件集成 — 工作流 → 消息
+
+**修改文件:**
+
+1. `crates/erp-message/src/module.rs` — `register_event_handlers()` 订阅工作流事件
+2. `crates/erp-message/src/service/message_service.rs` — 添加事件处理方法
+3. 订阅的事件:
+ - `workflow.instance.started` → 通知发起人
+ - `workflow.task.created` → 通知待办人
+ - `workflow.task.completed` → 通知发起人
+ - `workflow.instance.completed` → 通知发起人
+ - `workflow.instance.terminated` → 通知相关人
+
+### Task 6.2: 审计日志
+
+**创建/修改文件:**
+
+1. 迁移 `m20260413_000026_create_audit_logs.rs` — 审计日志表
+ - id, tenant_id, user_id, action, resource_type, resource_id,
+ - old_value (JSON), new_value (JSON), ip_address, user_agent,
+ - 标准审计字段
+2. `crates/erp-core/src/audit.rs` — 审计中间件/工具函数
+3. `crates/erp-server/src/main.rs` — 应用审计中间件到 protected routes
+
+### Task 6.3: API 文档完善
+
+**修改文件:**
+
+1. `crates/erp-server/src/main.rs` — 添加 utoipa Swagger UI 路由
+2. 各模块已有 utoipa 注解,确保正确注册到 OpenApi
+
+### Task 6.4: 安全审查
+
+检查项:
+- JWT 中间件正确性
+- 多租户隔离 (所有查询带 tenant_id)
+- SQL 注入防护 (SeaORM 参数化)
+- CORS 配置
+- 密码安全 (Argon2)
+- 输入验证 (所有 API 端点)
+- 错误信息不泄露敏感数据
+
+### Task 6.5: 前端整合完善
+
+**修改文件:**
+
+1. `apps/web/src/layouts/MainLayout.tsx` — 完善通知面板交互
+2. 工作流页面集成消息通知反馈
+3. 整体 UI 打磨和一致性检查
+
+---
+
+## 实施顺序
+
+```
+Task 5.1 (迁移)
+ → Task 5.2 (crate 基础)
+ → Task 5.3 (服务+处理器)
+ → Task 5.4 (服务器集成)
+ → Task 5.5 (前端页面)
+ → Task 5.6 (通知面板)
+ → Task 6.1 (事件集成)
+ → Task 6.2 (审计日志)
+ → Task 6.3 (API 文档)
+ → Task 6.4 (安全审查)
+ → Task 6.5 (前端整合)
+```
+
+每个 Task 完成后立即提交。每个 Task 预计产生 1 个 commit。
+
+## 验证方式
+
+1. `cargo check` — 全 workspace 编译通过
+2. `cargo test --workspace` — 所有测试通过
+3. `cargo run -p erp-server` — 服务启动正常
+4. 浏览器验证消息 CRUD 流程
+5. 验证通知面板未读计数
+6. 验证工作流事件触发消息通知
+7. Swagger UI 验证 API 文档
+
+## 关键文件索引
+
+| 用途 | 文件路径 |
+|------|---------|
+| 迁移注册 | `crates/erp-server/migration/src/lib.rs` |
+| 服务器入口 | `crates/erp-server/src/main.rs` |
+| 状态桥接 | `crates/erp-server/src/state.rs` |
+| 模块 trait | `crates/erp-core/src/module.rs` |
+| 事件总线 | `crates/erp-core/src/events.rs` |
+| 错误类型 | `crates/erp-core/src/error.rs` |
+| 前端路由 | `apps/web/src/App.tsx` |
+| 布局 | `apps/web/src/layouts/MainLayout.tsx` |
+| API 客户端 | `apps/web/src/api/client.ts` |
+| 参考模块 | `crates/erp-workflow/` (完整模式参考) |
diff --git a/plans/lively-tickling-engelbart.md b/plans/lively-tickling-engelbart.md
new file mode 100644
index 0000000..01edb87
--- /dev/null
+++ b/plans/lively-tickling-engelbart.md
@@ -0,0 +1,269 @@
+# Phase 2: 身份与权限模块实施计划
+
+## Context
+
+Phase 1 基础设施已完成(workspace、core types、EventBus、ErpModule trait、AppState、health check、graceful shutdown)。现在需要构建 Phase 2 身份与权限模块,这是所有后续模块的基础——工作流、消息、配置都依赖认证和权限中间件。
+
+**目标**:实现完整的用户认证(JWT)、RBAC 权限模型、多租户中间件、用户/角色/组织管理 CRUD,以及对应的前端页面。
+
+**范围界定**:Phase 2 仅实现用户名/密码认证 + RBAC。OAuth/SSO/TOTP/ABAC 延后到后续 Phase。
+
+---
+
+## 关键决策
+
+1. **ErpModule trait 路由问题**:当前 trait `register_routes` 使用 `Router`(无状态泛型),但实际路由需要 `Router`。Phase 2 采用**务实方案**:模块暴露独立 `routes() -> Router` 函数,server 手动 merge。不改动 trait,避免核心层依赖 server 类型。
+2. **Token 存储**:JWT 的 SHA-256 哈希存入 `user_tokens` 表,支持吊销。前端用 localStorage 存储 access/refresh token(httpOnly cookie 为 Phase 6 优化项)。
+3. **中间件方案**:使用 `axum::middleware::from_fn_with_state` 实现 JWT 认证中间件,将 `TenantContext` 注入 `req.extensions()`。
+
+---
+
+## Task 1: 数据库迁移(10 张表)
+
+**目标**:创建所有 Auth 相关的数据库表。
+
+**创建文件**(`crates/erp-server/migration/src/`):
+- `m20260411_000002_create_users.rs`
+- `m20260411_000003_create_user_credentials.rs`
+- `m20260411_000004_create_user_tokens.rs`
+- `m20260411_000005_create_roles.rs`
+- `m20260411_000006_create_permissions.rs`
+- `m20260411_000007_create_role_permissions.rs`
+- `m20260411_000008_create_user_roles.rs`
+- `m20260411_000009_create_organizations.rs`
+- `m20260411_000010_create_departments.rs`
+- `m20260411_000011_create_positions.rs`
+
+**修改文件**:`migration/src/lib.rs` 注册所有新迁移
+
+**表结构要点**:
+- 所有表含标准字段:id(UUID PK), tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version
+- **users**: username(VARCHAR, 复合唯一 tenant_id+username), email, phone, display_name, avatar_url, status(CHECK active/disabled/locked), last_login_at
+- **user_credentials**: user_id FK, credential_type(CHECK password/oauth/sso), credential_data(JSONB), verified(bool)
+- **user_tokens**: user_id FK, token_hash(VARCHAR UNIQUE), token_type(CHECK access/refresh), expires_at, revoked_at, device_info
+- **roles**: name, code(复合唯一 tenant_id+code), description, is_system(bool)
+- **permissions**: code(复合唯一 tenant_id+code), name, resource, action, description
+- **role_permissions**: role_id + permission_id 复合 PK
+- **user_roles**: user_id + role_id 复合 PK
+- **organizations**: name, code, parent_id(自引用 FK), path(TEXT), level, sort_order
+- **departments**: org_id FK, name, code, parent_id(自引用), manager_id FK users, path, sort_order
+- **positions**: dept_id FK, name, code, level, sort_order
+
+**验证**:`cargo run -p erp-server` → `\dt` 显示全部 11 张表
+
+---
+
+## Task 2: SeaORM Entity 定义
+
+**目标**:为所有 Auth 表创建类型安全的 Entity/Model/Relation。
+
+**创建文件**(`crates/erp-auth/src/entity/`):
+- `mod.rs`, `user.rs`, `user_credential.rs`, `user_token.rs`, `role.rs`, `permission.rs`, `role_permission.rs`, `user_role.rs`, `organization.rs`, `department.rs`, `position.rs`
+
+**修改文件**:
+- `crates/erp-auth/Cargo.toml` — 添加 workspace 依赖:jsonwebtoken, argon2, validator, thiserror, utoipa, erp-common
+
+**同时创建 DTO 文件**:`crates/erp-auth/src/dto.rs`
+- `LoginReq` { username, password } — #[derive(Validate)]
+- `LoginResp` { access_token, refresh_token, expires_in, user }
+- `RefreshReq` { refresh_token }
+- `CreateUserReq` { username, password, email?, phone?, display_name? }
+- `UpdateUserReq` { email?, phone?, display_name?, status? }
+- `UserResp`, `RoleResp`, `PermissionResp` 等
+- `CreateRoleReq`, `UpdateRoleReq`, `AssignPermissionsReq`, `AssignRolesReq`
+- 所有 DTO 添加 `#[derive(utoipa::ToSchema)]`
+
+**验证**:`cargo check -p erp-auth` 通过
+
+---
+
+## Task 3: 核心服务层(密码 + JWT + 认证)
+
+**目标**:实现密码哈希、JWT 签发/验证、登录/刷新/登出逻辑。
+
+**创建文件**(`crates/erp-auth/src/`):
+- `error.rs` — AuthError 枚举 + From for AppError
+- `service/mod.rs`
+- `service/password.rs` — Argon2 hash/verify
+- `service/token_service.rs` — JWT sign/validate, token DB CRUD
+- `service/auth_service.rs` — login/refresh/logout
+- `service/user_service.rs` — user CRUD
+
+**修改文件**:`crates/erp-auth/src/lib.rs` — 声明模块
+
+**关键实现**:
+- `password.rs`: `hash_password(plain) -> Result`, `verify_password(plain, hash) -> Result`
+- `token_service.rs`:
+ - JWT Claims: { sub: Uuid, tid: Uuid, roles: Vec, permissions: Vec, exp, iat, token_type: String }
+ - `sign_access_token(user_id, tenant_id, roles, permissions, jwt_config) -> Result`
+ - `sign_refresh_token(user_id, tenant_id, db, jwt_config) -> Result<(String, Uuid)>` — 存 SHA-256 哈希到 DB
+ - `validate_refresh_token(token, db, jwt_config) -> Result<(Uuid, Claims)>` — 检查吊销状态
+ - `revoke_all_user_tokens(user_id, tenant_id, db)`
+- `auth_service.rs`:
+ - `login(tenant_id, username, password, device_info, db, token_svc, event_bus)` → 查用户 → 验证密码 → 签发双 token → 更新 last_login_at → 发 user.login 事件
+ - `refresh(refresh_token, db, token_svc)` → 验证 → 吊销旧 refresh → 签发新双 token
+ - `logout(user_id, tenant_id, db, token_svc)` → 吊销全部 token
+- `user_service.rs`: CRUD,查询始终带 tenant_id 过滤 + deleted_at IS NULL
+
+**验证**:`cargo check -p erp-auth` 通过,password hash/verify 单元测试通过
+
+---
+
+## Task 4: Handler 路由 + AuthModule 注册
+
+**目标**:创建 Axum handler,注册到 server。
+
+**创建文件**(`crates/erp-auth/src/`):
+- `handler/mod.rs`
+- `handler/auth_handler.rs` — login/refresh/logout
+- `handler/user_handler.rs` — user CRUD + 角色分配
+- `module.rs` — AuthModule struct + ErpModule impl
+
+**修改文件**:
+- `crates/erp-server/Cargo.toml` — 添加 `erp-auth.workspace = true`
+- `crates/erp-server/src/main.rs` — 注册 auth 模块路由
+- `crates/erp-server/src/config.rs` — 添加 `AuthConfig { super_admin_password }`
+- `crates/erp-server/config/default.toml` — 添加 `[auth]` 段
+
+**路由设计**:
+- 公开(无需 JWT):`POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`
+- 受保护(需 JWT):`POST /api/v1/auth/logout`, 全部 users/roles/permissions/orgs 路由
+
+**main.rs 路由结构**:
+```
+Router::new()
+ .merge(public_routes) // health + login + refresh
+ .merge(protected_routes) // logout + user/role CRUD, 后续加 JWT 中间件
+ .with_state(state)
+```
+
+**验证**:server 启动,`POST /api/v1/auth/login` 返回 400(无 body)而非 404
+
+---
+
+## Task 5: JWT 认证中间件 + RBAC 权限检查
+
+**目标**:JWT 验证中间件注入 TenantContext,RBAC 权限检查辅助函数。
+
+**创建文件**(`crates/erp-auth/src/`):
+- `middleware/mod.rs`
+- `middleware/jwt_auth.rs` — `jwt_auth_middleware(State, Request, Next)` → 从 Bearer token 解码 Claims → 注入 TenantContext 到 extensions
+- `middleware/rbac.rs` — `require_permission(perm: &str, ctx: &TenantContext) -> AppResult<()>`
+
+**修改文件**:
+- `crates/erp-server/src/main.rs` — public/protected 路由分离,protected 路由层加 JWT 中间件
+
+**关键实现**:
+- 中间件用 `axum::middleware::from_fn_with_state(state, jwt_auth_middleware)`
+- 从 `Authorization: Bearer xxx` 提取 token → decode → 检查过期 → 注入 `TenantContext { tenant_id, user_id, roles, permissions }`
+- RBAC: 简单检查 `ctx.permissions.contains(&required_permission)`
+- login/refresh/health 路由跳过 JWT 中间件
+
+**验证**:无 token 访问 `/api/v1/users` 返回 401;login/refresh 不受影响
+
+---
+
+## Task 6: 租户初始化钩子 + 种子数据
+
+**目标**:实现 `on_tenant_created` 创建默认角色/权限/管理员。
+
+**创建文件**:`crates/erp-auth/src/service/seed.rs`
+
+**修改文件**:
+- `crates/erp-auth/src/module.rs` — 实现 `on_tenant_created`
+- `crates/erp-server/src/main.rs` — server 启动时自动创建默认租户(开发用)
+
+**种子数据**:
+- 20 个默认权限(user:crud, role:crud, permission:read, organization:crud, department:crud, position:crud)
+- "admin" 角色(is_system=true)绑定所有权限
+- "viewer" 角色(is_system=true)绑定只读权限
+- super admin 用户(username="admin",密码从配置读取)
+
+**验证**:启动后查询 DB 有 admin 用户、admin/viewer 角色、20 个权限
+
+---
+
+## Task 7: 前端登录页 + Auth Store + API 层
+
+**目标**:登录 UI、token 管理、路由守卫。
+
+**创建文件**(`apps/web/src/`):
+- `api/client.ts` — axios 实例 + 请求/响应拦截器(附加 token、401 自动 refresh)
+- `api/auth.ts` — login/refresh/logout API 调用
+- `api/users.ts` — 用户 CRUD API
+- `stores/auth.ts` — auth Zustand store(user, permissions, tokens, login/logout)
+- `pages/Login.tsx` — 登录表单页
+- `pages/Home.tsx` — 首页(从 App.tsx 抽取)
+
+**修改文件**:
+- `apps/web/src/App.tsx` — 添加 PrivateRoute 守卫、/login 路由
+- `apps/web/src/stores/app.ts` — 移除 isLoggedIn stub(由 auth store 接管)
+
+**验证**:打开 `/#/` 自动跳转 `/login`,输入 admin/Admin@2026 登录后跳转到首页,刷新保持登录状态
+
+---
+
+## Task 8: 角色/权限管理
+
+**目标**:角色 CRUD + 权限分配后端 + 前端页面。
+
+**创建文件**:
+- Backend: `crates/erp-auth/src/service/role_service.rs`, `service/permission_service.rs`, `handler/role_handler.rs`, `handler/permission_handler.rs`
+- Frontend: `apps/web/src/pages/Roles.tsx`, `apps/web/src/api/roles.ts`
+
+**修改文件**:`module.rs` 注册新路由, `App.tsx` 添加路由
+
+**验证**:admin 登录 → 角色管理 → 创建角色 → 分配权限 → DB 验证
+
+---
+
+## Task 9: 组织/部门/岗位管理
+
+**目标**:树形组织架构管理。
+
+**创建文件**:
+- Backend: `service/org_service.rs`, `service/dept_service.rs`, `service/position_service.rs`, `handler/org_handler.rs`, `handler/dept_handler.rs`, `handler/position_handler.rs`
+- Frontend: `apps/web/src/pages/Organizations.tsx`, `apps/web/src/api/orgs.ts`
+
+**关键**:树形结构用 parent_id + path 列实现祖先查询
+
+**验证**:创建根组织 → 子组织 → 部门 → 岗位,验证 path 正确
+
+---
+
+## Task 10: 用户管理页面 + 整合
+
+**目标**:完整的用户 CRUD 界面 + 角色分配 + 主布局用户信息。
+
+**创建文件**:
+- `apps/web/src/pages/Users.tsx` — Ant Design Table + 创建/编辑/角色分配 Modal
+- `apps/web/src/layouts/MainLayout.tsx` — 更新:显示当前用户名、登出菜单
+
+**验证**:admin 登录 → 用户管理 → 创建用户 → 分配角色 → 禁用/启用 → 所有操作即时反映
+
+---
+
+## 依赖关系
+
+```
+Task 1 (迁移) → Task 2 (Entity) → Task 3 (服务层) → Task 4 (Handler+注册)
+ ↓
+ Task 5 (JWT 中间件)
+ Task 6 (种子数据)
+ ↓
+ Task 7 (前端登录)
+ ↓
+ Task 8 (角色权限) Task 9 (组织部门)
+ ↓
+ Task 10 (用户管理 UI)
+```
+
+## 验证标准
+
+- [ ] `cargo check --workspace` 零错误零警告
+- [ ] `cargo test --workspace` 全部通过
+- [ ] server 启动后可 login → 获得 JWT → 带 JWT 访问 /api/v1/users
+- [ ] 无 JWT 访问受保护端点返回 401
+- [ ] 前端登录 → 跳转首页 → 刷新保持 → 登出清除
+- [ ] 用户/角色/组织 CRUD 页面功能完整
+- [ ] 多租户隔离:每个查询自动带 tenant_id 过滤
diff --git a/plans/stateless-swimming-perlis.md b/plans/stateless-swimming-perlis.md
index 96c4330..5fec116 100644
--- a/plans/stateless-swimming-perlis.md
+++ b/plans/stateless-swimming-perlis.md
@@ -1,135 +1,137 @@
-# Phase 3: 系统配置模块 — 实施计划
+# Phase 4: 工作流引擎模块 — 实施计划
## Context
-Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需要实现系统配置模块(erp-config),提供数据字典、动态菜单、系统参数、编号规则、i18n 框架和主题自定义能力。当前 `erp-config` 仅为 placeholder。
+Phase 1(基础设施)、Phase 2(身份与权限)和 Phase 3(系统配置)已完成。Phase 4 需要实现工作流引擎模块(erp-workflow),提供流程定义、流程实例管理、任务审批、Token 驱动的执行引擎和可视化流程设计器。当前 `erp-workflow` 仅为 placeholder。
-## 前置重构:将 RBAC 移至 erp-core
-
-**问题:** `require_permission` 在 `erp-auth/src/middleware/rbac.rs` 中,但只依赖 `erp-core` 的 `TenantContext` 和 `AppError`。erp-config 不能依赖 erp-auth(架构铁律:业务 crate 禁止直接依赖)。
-
-**方案:**
-1. 将 `erp-auth/src/middleware/rbac.rs` → `erp-core/src/rbac.rs`
-2. `erp-core/src/lib.rs` 添加 `pub mod rbac;`
-3. 更新 `erp-auth` 所有 handler 的 import:`crate::middleware::rbac` → `erp_core::rbac`
-4. `erp-auth/src/middleware/mod.rs` 移除 `pub mod rbac;`
+用户选择"完整实施"方案,包括 BPMN 子集解析器、Token 驱动执行引擎、完整 CRUD 端点和 React Flow 可视化设计器。
---
-## Task 1: erp-config 骨架 + ConfigState
+## Task 1: erp-workflow 骨架 + WorkflowState + Error
**目标:** 创建可编译的最小 crate,注册到 erp-server。
**创建/修改文件:**
-- 修改: `crates/erp-config/Cargo.toml` — 添加完整依赖
-- 修改: `crates/erp-config/src/lib.rs` — 模块声明 + re-export
-- 创建: `crates/erp-config/src/config_state.rs` — ConfigState { db, event_bus }
-- 创建: `crates/erp-config/src/error.rs` — ConfigError 枚举
-- 创建: `crates/erp-config/src/module.rs` — ConfigModule + 空路由
-- 创建: `crates/erp-config/src/dto.rs` — 占位
-- 创建: `crates/erp-config/src/entity/mod.rs` — 占位
-- 创建: `crates/erp-config/src/service/mod.rs` — 占位
-- 创建: `crates/erp-config/src/handler/mod.rs` — 占位
-- 修改: `crates/erp-server/Cargo.toml` — 添加 erp-config 依赖
-- 修改: `crates/erp-server/src/state.rs` — 添加 FromRef for ConfigState
-- 修改: `crates/erp-server/src/main.rs` — 注册 ConfigModule
+- 修改: `crates/erp-workflow/Cargo.toml` — 添加完整依赖
+- 修改: `crates/erp-workflow/src/lib.rs` — 模块声明 + re-export
+- 创建: `crates/erp-workflow/src/workflow_state.rs` — `WorkflowState { db, event_bus }`
+- 创建: `crates/erp-workflow/src/error.rs` — WorkflowError 枚举
+- 创建: `crates/erp-workflow/src/module.rs` — WorkflowModule + ErpModule trait
+- 创建: `crates/erp-workflow/src/dto.rs` — 占位
+- 创建: `crates/erp-workflow/src/entity/mod.rs` — 占位
+- 创建: `crates/erp-workflow/src/service/mod.rs` — 占位
+- 创建: `crates/erp-workflow/src/handler/mod.rs` — 占位
+- 创建: `crates/erp-workflow/src/engine/mod.rs` — 占位
+- 修改: `crates/erp-server/Cargo.toml` — 确认 erp-workflow 依赖
+- 修改: `crates/erp-server/src/state.rs` — 添加 `FromRef` for WorkflowState
+- 修改: `crates/erp-server/src/main.rs` — 注册 WorkflowModule
+
+**依赖:**
+```toml
+erp-core.workspace = true
+tokio = { workspace = true, features = ["full"] }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+uuid = { workspace = true, features = ["v7", "serde"] }
+chrono = { workspace = true, features = ["serde"] }
+axum = { workspace = true }
+sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+utoipa = { workspace = true, features = ["uuid", "chrono"] }
+async-trait = { workspace = true }
+```
**验证:** `cargo check` 通过
---
-## Task 2: 数据库迁移(6 张表)
-
-**目标:** 创建所有配置模块表。
+## Task 2: 数据库迁移(5 张表)
**创建文件(`crates/erp-server/migration/src/`):**
-- `m20260412_000012_create_dictionaries.rs` — 字典分类表
-- `m20260412_000013_create_dictionary_items.rs` — 字典项表
-- `m20260412_000014_create_menus.rs` — 菜单树形表
-- `m20260412_000015_create_menu_roles.rs` — 菜单-角色关联表
-- `m20260412_000016_create_settings.rs` — 分层键值配置表
-- `m20260412_000017_create_numbering_rules.rs` — 编号规则表
+- `m20260412_000018_create_process_definitions.rs`
+- `m20260412_000019_create_process_instances.rs`
+- `m20260412_000020_create_tokens.rs`
+- `m20260412_000021_create_tasks.rs`
+- `m20260412_000022_create_process_variables.rs`
- 修改: `lib.rs` — 注册新迁移
-**表结构:**
-
-### dictionaries
+### process_definitions
| 列 | 类型 | 说明 |
|---|---|---|
| id | uuid PK | UUIDv7 |
| tenant_id | uuid NOT NULL | 租户 ID |
-| name | string NOT NULL | 显示名称 |
-| code | string NOT NULL | 字典键(如 gender) |
-| description | text NULL | 说明 |
+| name | string NOT NULL | 流程名称 |
+| key | string NOT NULL | 流程唯一编码 |
+| version | int NOT NULL DEFAULT 1 | 版本号 |
+| category | string NULL | 分类(如 leave, expense) |
+| description | text NULL | 描述 |
+| nodes | jsonb NOT NULL DEFAULT '[]' | 节点定义(BPMN 子集) |
+| edges | jsonb NOT NULL DEFAULT '[]' | 连线定义 |
+| status | string NOT NULL DEFAULT 'draft' | draft/published/deprecated |
| + 标准审计字段 | | |
-| 唯一索引: | `(tenant_id, code) WHERE deleted_at IS NULL` | |
+| 唯一索引: | `(tenant_id, key, version) WHERE deleted_at IS NULL` | |
-### dictionary_items
+### process_instances
| 列 | 类型 | 说明 |
|---|---|---|
| id | uuid PK | UUIDv7 |
| tenant_id | uuid NOT NULL | |
-| dictionary_id | uuid NOT NULL | FK → dictionaries |
-| label | string NOT NULL | 显示标签 |
-| value | string NOT NULL | 存储值 |
-| sort_order | int DEFAULT 0 | 排序 |
-| color | string NULL | 颜色标签 |
+| definition_id | uuid NOT NULL FK → process_definitions | |
+| business_key | string NULL | 业务关联键(如请假单 ID) |
+| status | string NOT NULL DEFAULT 'running' | running/suspended/completed/terminated |
+| started_by | uuid NOT NULL | 发起人 user_id |
+| started_at | timestamptz NOT NULL DEFAULT NOW() | |
+| completed_at | timestamptz NULL | |
| + 标准审计字段 | | |
-| 唯一索引: | `(dictionary_id, value) WHERE deleted_at IS NULL` | |
+| 索引: | `idx_instances_status (tenant_id, status)` | |
-### menus(树形自引用)
+### tokens
| 列 | 类型 | 说明 |
|---|---|---|
| id | uuid PK | UUIDv7 |
| tenant_id | uuid NOT NULL | |
-| parent_id | uuid NULL | 自引用 |
-| title | string NOT NULL | 菜单标题 |
-| path | string NULL | 前端路由 |
-| icon | string NULL | 图标名 |
-| sort_order | int DEFAULT 0 | |
-| visible | bool DEFAULT true | |
-| menu_type | string DEFAULT 'page' | page/link/button |
-| permission | string NULL | 所需权限码 |
-| + 标准审计字段 | | |
+| instance_id | uuid NOT NULL FK → process_instances | |
+| node_id | string NOT NULL | 当前所在节点 ID |
+| status | string NOT NULL DEFAULT 'active' | active/consumed/terminated |
+| created_at | timestamptz NOT NULL DEFAULT NOW() | |
+| consumed_at | timestamptz NULL | |
+| 索引: | `idx_tokens_instance (instance_id)` | |
-### menu_roles(复合主键)
-| 列 | 类型 | 说明 |
-|---|---|---|
-| menu_id | uuid NOT NULL | PK 组成 |
-| role_id | uuid NOT NULL | PK 组成 |
-| tenant_id | uuid NOT NULL | |
-| + 标准审计字段 | | |
-| 唯一索引: | `(menu_id, role_id) WHERE deleted_at IS NULL` | |
-
-### settings(分层配置)
+### tasks
| 列 | 类型 | 说明 |
|---|---|---|
| id | uuid PK | UUIDv7 |
| tenant_id | uuid NOT NULL | |
-| scope | string NOT NULL | platform/tenant/org/user |
-| scope_id | uuid NULL | 平台=NULL, 租户=tenant_id 等 |
-| setting_key | string NOT NULL | |
-| setting_value | jsonb DEFAULT '{}' | |
+| instance_id | uuid NOT NULL FK → process_instances | |
+| token_id | uuid NOT NULL FK → tokens | |
+| node_id | string NOT NULL | 对应的流程节点 |
+| node_name | string NULL | 节点名称(冗余,便于查询) |
+| assignee_id | uuid NULL | 指定处理人 |
+| candidate_groups | jsonb NULL | 候选角色组 |
+| status | string NOT NULL DEFAULT 'pending' | pending/approved/rejected/delegated |
+| outcome | string NULL | 审批结果 |
+| form_data | jsonb NULL | 表单数据 |
+| due_date | timestamptz NULL | 到期时间 |
+| completed_at | timestamptz NULL | |
| + 标准审计字段 | | |
-| 唯一索引: | `(scope, scope_id, setting_key) WHERE deleted_at IS NULL` | |
+| 索引: | `idx_tasks_assignee (tenant_id, assignee_id, status)` | |
+| 索引: | `idx_tasks_instance (instance_id)` | |
-### numbering_rules
+### process_variables
| 列 | 类型 | 说明 |
|---|---|---|
| id | uuid PK | UUIDv7 |
| tenant_id | uuid NOT NULL | |
-| name | string NOT NULL | 规则名称 |
-| code | string NOT NULL | 唯一编码(如 INV) |
-| prefix | string DEFAULT '' | 前缀 |
-| date_format | string NULL | 如 %Y%m%d |
-| seq_length | int DEFAULT 4 | 序列位数 |
-| seq_start | int DEFAULT 1 | 起始值 |
-| seq_current | bigint DEFAULT 0 | 当前序列 |
-| separator | string DEFAULT '-' | 分隔符 |
-| reset_cycle | string DEFAULT 'never' | never/daily/monthly/yearly |
-| last_reset_date | date NULL | |
-| + 标准审计字段 | | |
-| 唯一索引: | `(tenant_id, code) WHERE deleted_at IS NULL` | |
+| instance_id | uuid NOT NULL FK → process_instances | |
+| name | string NOT NULL | 变量名 |
+| var_type | string NOT NULL DEFAULT 'string' | string/number/boolean/date/json |
+| value_string | text NULL | |
+| value_number | double precision NULL | |
+| value_boolean | boolean NULL | |
+| value_date | timestamptz NULL | |
+| 唯一索引: | `(instance_id, name)` | |
**验证:** `cargo run -p erp-server` 启动后 `\dt` 可见新表
@@ -137,18 +139,15 @@ Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需
## Task 3: SeaORM Entity
-**目标:** 为 6 张表创建 Entity 定义。
-
-**创建文件(`crates/erp-config/src/entity/`):**
+**创建文件(`crates/erp-workflow/src/entity/`):**
- `mod.rs` — 导出所有实体
-- `dictionary.rs` — Dictionary Entity
-- `dictionary_item.rs` — DictionaryItem Entity
-- `menu.rs` — Menu Entity
-- `menu_role.rs` — MenuRole Entity(复合主键模式,参考 role_permission)
-- `setting.rs` — Setting Entity
-- `numbering_rule.rs` — NumberingRule Entity
+- `process_definition.rs`
+- `process_instance.rs`
+- `token.rs`
+- `task.rs`
+- `process_variable.rs`
-**模式:** 参考 `erp-auth/src/entity/role.rs`,包含 Relation 和 Related 实现。
+**模式:** 参考 `erp-config/src/entity/numbering_rule.rs`,包含 Relation 和 Related。
**验证:** `cargo check` 通过
@@ -156,174 +155,215 @@ Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需
## Task 4: DTO 定义
-**目标:** 定义所有配置端点的请求/响应类型。
-
-**修改文件:** `crates/erp-config/src/dto.rs`
+**修改文件:** `crates/erp-workflow/src/dto.rs`
**包含:**
-- 字典 DTO:DictionaryResp, DictionaryItemResp, CreateDictionaryReq, UpdateDictionaryReq, CreateDictionaryItemReq, UpdateDictionaryItemReq
-- 菜单 DTO:MenuResp(含 children 递归), CreateMenuReq, UpdateMenuReq, BatchSaveMenuReq
-- 设置 DTO:SettingResp, UpdateSettingReq
-- 编号规则 DTO:NumberingRuleResp, CreateNumberingRuleReq, UpdateNumberingRuleReq, GenerateNumberResp
-- 主题 DTO:ThemeResp, UpdateThemeReq(委托 settings 存储)
-- 语言 DTO:LanguageResp, UpdateLanguageReq(委托 settings 存储)
+- 流程定义:`ProcessDefinitionResp`, `CreateProcessDefinitionReq`, `UpdateProcessDefinitionReq`, `PublishDefinitionReq`
+- 流程实例:`ProcessInstanceResp`, `StartInstanceReq`
+- 任务:`TaskResp`, `CompleteTaskReq`(含 outcome + form_data)
+- 流程变量:`ProcessVariableResp`, `SetVariableReq`
+- 流程图:`NodeDef`(BPMN 节点), `EdgeDef`(连线), `FlowDiagram`(完整图)
+
+**节点类型:** StartEvent, EndEvent, UserTask, ServiceTask, ExclusiveGateway, ParallelGateway
+**连线条件:** `condition` 字段为可选表达式字符串(如 `amount > 1000`)
**验证:** `cargo check` 通过
---
-## Task 5: Service — 字典 + 系统参数
+## Task 5: BPMN 解析器 + 表达式引擎
-**创建文件(`crates/erp-config/src/service/`):**
-- `dictionary_service.rs` — CRUD + 字典项管理 + 按 code 查询
-- `setting_service.rs` — 分层读取(User>Org>Tenant>Platform)+ 写入
+**创建文件(`crates/erp-workflow/src/engine/`):**
+- `model.rs` — 流程图内存模型(FlowDiagram, FlowNode, FlowEdge, NodeType 枚举)
+- `parser.rs` — 解析 JSON nodes/edges 为内存模型,验证流程图合法性
+- `expression.rs` — 简单表达式求值器(支持比较运算和流程变量引用)
**关键逻辑:**
-- SettingService::get 实现分层覆盖查找
-- SettingService::set 使用 upsert 语义(INSERT ON CONFLICT UPDATE)
-- DictionaryService 遵循 RoleService 的无状态模式
+- `FlowDiagram::validate()` — 检查:恰好 1 个 StartEvent,至少 1 个 EndEvent,无悬空连线,网关分支/汇合配对
+- `ExpressionEvaluator::eval(expr, variables) -> bool` — 支持 `var > 1000`, `status == "approved"`, `amount <= budget` 格式
+- 解析器将 `nodes` 和 `edges` jsonb 反序列化为 `Vec` 和 `Vec`
+
+**验证:** 单元测试覆盖:合法流程验证、缺少 StartEvent 报错、表达式求值
---
-## Task 6: Service — 菜单 + 编号规则
+## Task 6: Token 驱动执行引擎
-**创建文件(`crates/erp-config/src/service/`):**
-- `menu_service.rs` — 菜单树构建 + 角色过滤 + 批量保存
-- `numbering_service.rs` — 编号规则 CRUD + generate_number
+**创建文件:** `crates/erp-workflow/src/engine/executor.rs`
-**关键逻辑:**
-- MenuService::get_menu_tree — 按 role_ids 过滤 menu_role,HashMap 分组构建树(参考 OrgService 的 build_org_tree)
-- NumberingService::generate_number — PostgreSQL advisory_lock + 事务内序列递增:
- ```sql
- SELECT pg_advisory_xact_lock(hashtext($1), $2::int)
- -- $1 = rule_code, $2 = hash(tenant_id)
- ```
- 生成格式:`{prefix}{separator}{date}{separator}{seq_padded}`
+**核心逻辑:**
+```
+start(instance_id, definition) → 在 StartEvent 创建 token
+advance(token_id, instance_id, definition, variables) → 消费当前 token,在下一节点创建新 token
+ - 到达 EndEvent → 消费 token,检查实例是否所有 token 都完成 → 完成实例
+ - 到达 UserTask → 创建 token + 创建 task 记录
+ - 到达 ServiceTask → 创建 token + 执行动作(占位,发布事件)
+ - 到达 ExclusiveGateway → 求值条件,选择一条分支
+ - 到达 ParallelGateway(分支)→ 为每条出边创建 token
+ - 到达 ParallelGateway(汇合)→ 消费当前 token,等待所有入边 token 到达后创建新 token
+```
+
+**并发安全:** 使用 `pg_advisory_xact_lock` 保护 token 操作(参考 NumberingService 模式)
+
+**验证:** 单元测试覆盖:直线流程、排他网关分支、并行网关分支与汇合
---
-## Task 7: Handler 层
+## Task 7: Service 层
-**创建文件(`crates/erp-config/src/handler/`):**
-- `dictionary_handler.rs` — 5 个端点
-- `menu_handler.rs` — 2 个端点
-- `setting_handler.rs` — 2 个端点
-- `numbering_handler.rs` — 4 个端点(含 generate)
-- `theme_handler.rs` — 2 个端点(委托 SettingService)
-- `language_handler.rs` — 2 个端点(委托 SettingService)
+**创建文件(`crates/erp-workflow/src/service/`):**
+- `definition_service.rs` — 流程定义 CRUD + 发布 + 版本管理
+- `instance_service.rs` — 启动实例 + 查询 + 挂起/恢复/终止
+- `task_service.rs` — 查询待办 + 完成任务 + 委派 + 查询已办
+
+**关键逻辑:**
+- `DefinitionService::publish` — draft → published,验证流程图合法性
+- `InstanceService::start` — 创建实例 + 初始化变量 + 调用 executor.start
+- `TaskService::complete` — 更新 task 状态 + 调用 executor.advance + 处理下一节点
+
+---
+
+## Task 8: Handler 层
+
+**创建文件(`crates/erp-workflow/src/handler/`):**
+- `definition_handler.rs` — 5 个端点
+- `instance_handler.rs` — 4 个端点
+- `task_handler.rs` — 4 个端点
**端点映射:**
```
-GET/POST /config/dictionaries
-PUT/DELETE /config/dictionaries/{id}
-GET/PUT /config/menus
-GET/PUT /config/settings/{key}
-GET/POST /config/numbering-rules
-PUT /config/numbering-rules/{id}
-POST /config/numbering-rules/{id}/generate
-GET/PUT /config/themes
-GET /config/languages
-PUT /config/languages/{code}
+GET/POST /workflow/definitions
+GET /workflow/definitions/{id}
+PUT /workflow/definitions/{id}
+POST /workflow/definitions/{id}/publish
+POST /workflow/instances
+GET /workflow/instances
+GET /workflow/instances/{id}
+POST /workflow/instances/{id}/suspend
+POST /workflow/instances/{id}/terminate
+GET /workflow/tasks/pending — 我的待办
+GET /workflow/tasks/completed — 我的已办
+POST /workflow/tasks/{id}/complete — 完成任务
+POST /workflow/tasks/{id}/delegate — 委派任务
```
+**RBAC:** 所有端点使用 `require_permission(&ctx, "workflow:xxx")`
+
---
-## Task 8: 模块注册 + 种子数据
+## Task 9: 模块注册 + 种子数据
**修改文件:**
-- `crates/erp-config/src/module.rs` — 填充真实路由
-- `crates/erp-auth/src/service/seed.rs` — 添加配置模块权限(dictionary/menu/setting/numbering/theme/language)
-- `crates/erp-server/src/main.rs` — 确认路由集成
+- `crates/erp-workflow/src/module.rs` — 填充真实路由(12 个端点)
+- `crates/erp-auth/src/service/seed.rs` — 添加工作流权限
+- `crates/erp-server/src/main.rs` — 注册 WorkflowModule,合并路由
-**新增种子权限(17 个):**
-- dictionary: create/read/update/delete/list
-- menu: read/update
-- setting: read/update
-- numbering: create/read/update/generate
-- theme: read/update
-- language: read/update
+**新增种子权限(8 个):**
+- workflow:create, workflow:list, workflow:read, workflow:update
+- workflow:publish, workflow:start, workflow:approve, workflow:delegate
-**验证:** `cargo check` + `cargo test` 通过,服务器启动后 Swagger UI 可见新端点
+**验证:** `cargo check` + `cargo test` 通过
---
-## Task 9: 前端 API 层 + Settings 页面框架
+## Task 10: 前端 API 层 + 工作流页面
**创建文件(`apps/web/src/`):**
-- `api/dictionaries.ts` — 字典 CRUD API
-- `api/menus.ts` — 菜单 API
-- `api/settings.ts` — 设置 API
-- `api/numberingRules.ts` — 编号规则 API
-- `pages/Settings.tsx` — Tabs 壳页面
+- `api/workflowDefinitions.ts` — 流程定义 API
+- `api/workflowInstances.ts` — 流程实例 API
+- `api/workflowTasks.ts` — 任务 API
+- `pages/Workflow.tsx` — Tab 壳页面(流程定义 | 我的待办 | 我的已办 | 流程监控)
**修改文件:**
-- `App.tsx` — 替换 settings 占位组件
-
-**Tabs 结构:** 数据字典 | 菜单配置 | 编号规则 | 系统参数 | 主题设置
+- `App.tsx` — 添加 workflow 路由
+- `MainLayout.tsx` — 侧边栏添加工作流菜单
---
-## Task 10: 前端设置子页面
+## Task 11: React Flow 可视化设计器
-**创建文件(`apps/web/src/pages/settings/`):**
-- `DictionaryManager.tsx` — Table + 展开行显示字典项,参考 Roles.tsx
-- `MenuConfig.tsx` — Tree + 编辑表单,参考 Organizations.tsx
-- `NumberingRules.tsx` — Table + Modal CRUD + 生成按钮
-- `SystemSettings.tsx` — 键值编辑列表
-- `ThemeSettings.tsx` — 颜色选择器 + 表单
+**创建文件(`apps/web/src/pages/workflow/`):**
+- `ProcessDesigner.tsx` — React Flow 画布 + 节点面板 + 属性面板
+- `nodes/` — 自定义节点组件(StartEvent, EndEvent, UserTask, ServiceTask, Gateway)
+- `edges/` — 条件标签连线组件
+- `hooks/useFlowValidation.ts` — 流程图前端验证
-**修改文件:**
-- `Settings.tsx` — 导入子组件
-- `MainLayout.tsx` — 更新设置菜单图标
+**依赖:** `@xyflow/react` npm 包
-**验证:** `pnpm dev` 启动,访问 /settings 各 tab 可正常交互
+**功能:**
+- 拖拽添加节点到画布
+- 连线编辑(含条件表达式)
+- 节点属性编辑面板
+- 导出为 JSON nodes/edges 格式(匹配后端 DTO)
+- 流程图合法性前端验证
+
+---
+
+## Task 12: 流程图查看器 + 超时框架
+
+**创建文件(`apps/web/src/pages/workflow/`):**
+- `ProcessViewer.tsx` — 只读 React Flow 渲染,高亮当前活跃节点
+- `InstanceDetail.tsx` — 实例详情页(流程图 + 变量 + 任务历史)
+
+**超时框架(后端占位):**
+- `crates/erp-workflow/src/engine/timeout.rs` — 超时检查接口
+- Task 表 `due_date` 字段已支持
+
+**验证:** `pnpm dev` 启动,工作流设计器可拖拽节点、连线、保存
---
## 依赖图
```
-前置重构(RBAC→erp-core)
- |
Task 1(骨架)
- |
+ |
Task 2(迁移)→ Task 3(Entity)→ Task 4(DTO)
- |
- +----------------+----------------+
- | |
- Task 5(字典+设置 Service) Task 6(菜单+编号 Service)
- | |
- +----------------+----------------+
- |
- Task 7(Handler)
- |
- Task 8(集成+种子)
- |
- Task 9(前端API+壳)
- |
- Task 10(前端页面)
+ |
+ +---------------+---------------+
+ | |
+ Task 5(BPMN 解析器) Task 6(执行引擎)
+ | |
+ +---------------+---------------+
+ |
+ Task 7(Service)
+ |
+ Task 8(Handler)
+ |
+ Task 9(集成+种子)
+ |
+ +---------------+---------------+
+ | |
+ Task 10(前端页面) Task 11(可视化设计器)
+ | |
+ +---------------+---------------+
+ |
+ Task 12(查看器+超时)
```
+---
+
## 验证清单
- [ ] `cargo check` 全 workspace 通过
- [ ] `cargo test --workspace` 全部通过
- [ ] Docker 环境正常启动
- [ ] 所有迁移可正/反向执行
-- [ ] API 端点可通过 Swagger UI 测试
-- [ ] 前端 /settings 页面各 Tab 正常工作
+- [ ] 12 个工作流 API 端点可测试
+- [ ] 前端工作流设计器可拖拽节点和连线
+- [ ] 流程图保存和加载正常
- [ ] 所有代码已提交
## 关键参考文件
| 用途 | 文件路径 |
|------|----------|
-| Service 模式 | `crates/erp-auth/src/service/role_service.rs` |
-| Handler 模式 | `crates/erp-auth/src/handler/role_handler.rs` |
-| 树构建模式 | `crates/erp-auth/src/service/org_service.rs` |
-| 迁移模式 | `crates/erp-server/migration/src/m20260411_000005_create_roles.rs` |
+| Service 模式 | `crates/erp-config/src/service/numbering_service.rs` |
+| Handler 模式 | `crates/erp-config/src/handler/numbering_handler.rs` |
| State 桥接 | `crates/erp-server/src/state.rs` |
-| 复合主键 Entity | `crates/erp-auth/src/entity/role_permission.rs` |
+| 模块注册 | `crates/erp-config/src/module.rs` |
+| 迁移模式 | `crates/erp-server/migration/src/m20260412_000016_create_settings.rs` |
+| Advisory Lock | `crates/erp-config/src/service/numbering_service.rs` (generate_number) |
| 前端 Table CRUD | `apps/web/src/pages/Roles.tsx` |
| 前端树形展示 | `apps/web/src/pages/Organizations.tsx` |
-| RBAC 工具函数 | `crates/erp-auth/src/middleware/rbac.rs`(待迁移到 erp-core) |
+| RBAC | `crates/erp-core/src/rbac.rs` |