diff --git a/docs/superpowers/plans/2026-04-17-platform-maturity-q3-plan.md b/docs/superpowers/plans/2026-04-17-platform-maturity-q3-plan.md new file mode 100644 index 0000000..c97721d --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-platform-maturity-q3-plan.md @@ -0,0 +1,1112 @@ +# Q3 架构强化 + 前端体验 实施计划 + +> **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:** 重构 ErpModule trait 实现自动化路由注册;修复 N+1 查询和错误映射;前端添加 Error Boundary、提取 5 个自定义 hooks、搭建 i18n 基础设施、接线行级数据权限。 + +**Architecture:** ErpModule trait 新增双路由注册方法,ModuleRegistry::build() 自动收集路由;前端 hooks 提取复用逻辑,react-i18next 管理文案;data_scope 从 JWT → handler → SQL 全链路贯通。 + +**Tech Stack:** Rust (Axum, SeaORM), React 19, Ant Design 6, Zustand 5, react-i18next + +**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §3 + +--- + +## File Structure + +| 操作 | 文件 | 职责 | +|------|------|------| +| Modify | `crates/erp-core/src/module.rs:43-121` | ErpModule trait 添加路由注册方法 | +| Modify | `crates/erp-core/src/module.rs:124-258` | ModuleRegistry 添加 build() 返回路由 | +| Modify | `crates/erp-server/src/main.rs:300-430` | 简化模块注册和路由组装 | +| Modify | `crates/erp-auth/src/module.rs` | 实现 register_public/protected_routes | +| Modify | `crates/erp-config/src/module.rs` | 实现 register_public/protected_routes | +| Modify | `crates/erp-workflow/src/module.rs` | 实现 register_public/protected_routes | +| Modify | `crates/erp-message/src/module.rs` | 实现 register_public/protected_routes | +| Modify | `crates/erp-plugin/src/module.rs` | 实现 register_public/protected_routes | +| Modify | `crates/erp-auth/src/service/user_service.rs:170-182` | N+1 查询改为批量 | +| Modify | `crates/erp-plugin/src/service.rs:370-395` | N+1 查询改为批量 | +| Create | `apps/web/src/components/ErrorBoundary.tsx` | 全局 + 页面级 Error Boundary | +| Modify | `apps/web/src/App.tsx` | 包裹 Error Boundary | +| Create | `apps/web/src/hooks/useCountUp.ts` | 提取计数动画 hook | +| Create | `apps/web/src/hooks/useDarkMode.ts` | 提取暗色模式判断 hook | +| Create | `apps/web/src/hooks/useDebouncedValue.ts` | 防抖 hook | +| Create | `apps/web/src/hooks/usePaginatedData.ts` | 统一分页数据管理 | +| Create | `apps/web/src/hooks/useApiRequest.ts` | 统一 API 错误处理 | +| Create | `apps/web/src/api/types.ts` | 提取 PaginatedResponse 共享类型 | +| Create | `apps/web/src/api/errors.ts` | 提取 extractErrorMessage | +| Modify | `apps/web/src/api/client.ts:30-39` | CancelToken → AbortController | +| Modify | `apps/web/src/api/users.ts` | 移除重复 PaginatedResponse,从 types.ts 导入 | +| Modify | `apps/web/src/api/plugins.ts` | 同上 | +| Install | `react-i18next`, `i18next` | i18n 基础设施 | +| Create | `apps/web/src/i18n/` | i18n 配置和翻译文件 | +| Modify | `apps/web/src/pages/Users.tsx` | 使用 useDebouncedValue | +| Modify | `crates/erp-auth/src/middleware/jwt_auth.rs:50-51` | department_ids 查询 | +| Modify | `crates/erp-plugin/src/handler/data_handler.rs:108-113` | data_scope 接线 | + +--- + +## Chunk 1: ErpModule Trait 重构 + +### Task 1: 扩展 ErpModule trait — 添加路由注册方法 + +**Files:** +- Modify: `crates/erp-core/src/module.rs:43-121` + +- [ ] **Step 1: 在 ErpModule trait 中添加两个路由注册方法** + +在 `crates/erp-core/src/module.rs` 的 `ErpModule` trait 中,在 `register_event_handlers` 之后添加: + +```rust +/// 注册公开路由(不需要 JWT 认证) +/// 默认实现返回未修改的 router +fn register_public_routes(&self, router: Router) -> Router { + router +} + +/// 注册受保护路由(需要 JWT 认证) +/// 默认实现返回未修改的 router +fn register_protected_routes(&self, router: Router) -> Router { + router +} +``` + +需要在文件顶部添加 `use axum::Router;` import(如果还没有)。 + +- [ ] **Step 2: 在 `ModuleRegistry` 中添加 `build()` 方法** + +在 `ModuleRegistry` impl 中添加: + +```rust +/// 构建路由:收集所有模块的公开路由和受保护路由 +/// 返回 (registry, public_routes, protected_routes) +pub fn build(self) -> (Self, Router, Router) { + let mut public_routes = Router::new(); + let mut protected_routes = Router::new(); + + for module in self.modules() { + public_routes = module.register_public_routes(public_routes); + protected_routes = module.register_protected_routes(protected_routes); + } + + (self, public_routes, protected_routes) +} +``` + +- [ ] **Step 3: 验证编译** + +```bash +cargo check -p erp-core +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/erp-core/src/module.rs +git commit -m "feat(core): ErpModule trait 添加 register_public/protected_routes 方法" +``` + +--- + +### Task 2: Auth 模块迁移到 trait 路由注册 + +**Files:** +- Modify: `crates/erp-auth/src/module.rs` + +- [ ] **Step 1: 在 AuthModule 的 ErpModule impl 中实现路由注册** + +将现有的 `AuthModule::public_routes()` 和 `AuthModule::protected_routes()` 静态方法逻辑搬到 trait 方法中: + +```rust +fn register_public_routes(&self, router: Router) -> Router { + router.merge(Self::public_routes()) +} + +fn register_protected_routes(&self, router: Router) -> Router { + router.merge(Self::protected_routes()) +} +``` + +保留静态方法作为内部实现(避免一次性改动过大),trait 方法调用静态方法。 + +- [ ] **Step 2: 验证编译** + +```bash +cargo check -p erp-auth +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/erp-auth/src/module.rs +git commit -m "refactor(auth): 实现 ErpModule trait 路由注册" +``` + +--- + +### Task 3: Config/Workflow/Message/Plugin 模块迁移 + +**Files:** +- Modify: `crates/erp-config/src/module.rs` +- Modify: `crates/erp-workflow/src/module.rs` +- Modify: `crates/erp-message/src/module.rs` +- Modify: `crates/erp-plugin/src/module.rs` + +- [ ] **Step 1: 对每个模块重复 Task 2 的模式** + +每个模块添加: +```rust +fn register_public_routes(&self, router: Router) -> Router { + router.merge(Self::public_routes()) +} + +fn register_protected_routes(&self, router: Router) -> Router { + router.merge(Self::protected_routes()) +} +``` + +- [ ] **Step 2: 验证全 workspace 编译** + +```bash +cargo check --workspace +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/erp-config/ crates/erp-workflow/ crates/erp-message/ crates/erp-plugin/ +git commit -m "refactor: 所有模块实现 ErpModule trait 路由注册" +``` + +--- + +### Task 4: main.rs 简化 — 使用 ModuleRegistry::build() + +**Files:** +- Modify: `crates/erp-server/src/main.rs:300-430` + +- [ ] **Step 1: 替换路由组装逻辑** + +将当前的手动路由组装(第 390-419 行)改为自动收集: + +```rust +// 替换 main.rs 中的路由组装部分 +let (registry, module_public_routes, module_protected_routes) = registry.build(); + +// Public routes: health + openapi + 模块公开路由 +let public_routes = Router::new() + .merge(handlers::health::health_check_router()) + .merge(module_public_routes) + .route("/docs/openapi.json", axum::routing::get(handlers::openapi::openapi_spec)) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_by_ip, + )) + .with_state(state.clone()); + +// Protected routes: 模块受保护路由 + 审计日志 +let protected_routes = module_protected_routes + .merge(handlers::audit_log::audit_log_router()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_by_user, + )) + .layer(axum_middleware::from_fn(move |req, next| { + let secret = jwt_secret.clone(); + async move { jwt_auth_middleware_fn(secret, req, next).await } + })) + .with_state(state.clone()); +``` + +注意:`handlers::health::health_check_router()` 和 `handlers::audit_log::audit_log_router()` 是 erp-server 自身的路由,不属于任何模块,所以保留手动添加。`/docs/openapi.json` 也是 server 自有路由。 + +- [ ] **Step 2: 验证编译和测试** + +```bash +cargo check -p erp-server && cargo test --workspace +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/erp-server/src/main.rs +git commit -m "refactor(server): main.rs 使用 ModuleRegistry::build() 自动收集路由" +``` + +--- + +## Chunk 2: N+1 查询优化 + +### Task 5: user_service N+1 查询优化 + +**Files:** +- Modify: `crates/erp-auth/src/service/user_service.rs:170-182` + +- [ ] **Step 1: 将循环内单独查询改为批量查询** + +将当前的 N+1 模式(第 174-180 行): + +```rust +// 当前:每个用户单独查询角色 +let mut resps = Vec::with_capacity(models.len()); +for m in models { + let roles: Vec = Self::fetch_user_role_resps(m.id, tenant_id, db) + .await + .unwrap_or_default(); + resps.push(model_to_resp(&m, roles)); +} +``` + +改为批量查询: + +```rust +// 优化:一次查询所有用户的角色 +let user_ids: Vec = models.iter().map(|m| m.id).collect(); +let role_map = Self::fetch_batch_user_role_resps(&user_ids, tenant_id, db).await; + +let resps: Vec = models + .iter() + .map(|m| model_to_resp(m, role_map.get(&m.id).cloned().unwrap_or_default())) + .collect(); +``` + +- [ ] **Step 2: 实现 `fetch_batch_user_role_resps` 方法** + +新增方法,使用 `user_id IN (...)` 批量查询: + +```rust +async fn fetch_batch_user_role_resps( + user_ids: &[Uuid], + tenant_id: Uuid, + db: &DatabaseConnection, +) -> std::collections::HashMap> { + if user_ids.is_empty() { + return std::collections::HashMap::new(); + } + + // 批量查询 user_role 关联 + let user_roles: Vec = user_role::Entity::find() + .filter(user_role::Column::UserId.is_in(user_ids.iter().copied())) + .all(db) + .await + .unwrap_or_default(); + + let role_ids: Vec = user_roles.iter().map(|ur| ur.role_id).collect(); + + // 批量查询角色 + let roles: Vec = if role_ids.is_empty() { + vec![] + } else { + role::Entity::find() + .filter(role::Column::Id.is_in(role_ids.iter().copied())) + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::DeletedAt.is_null()) + .all(db) + .await + .unwrap_or_default() + }; + + let role_map: std::collections::HashMap = roles + .iter() + .map(|r| (r.id, r)) + .collect(); + + // 按 user_id 分组 + let mut result = std::collections::HashMap::new(); + for ur in &user_roles { + let resp = role_map.get(&ur.role_id) + .map(|r| RoleResp { id: r.id, name: r.name.clone(), code: r.code.clone() }) + .unwrap_or_else(|| RoleResp { id: ur.role_id, name: "Unknown".into(), code: "unknown".into() }); + result.entry(ur.user_id).or_insert_with(Vec::new).push(resp); + } + result +} +``` + +注意:需要从 `user_role` entity 和 `role` entity 中导入正确的类型。调整 `RoleResp` 字段名与实际 struct 一致。 + +- [ ] **Step 3: 验证编译** + +```bash +cargo check -p erp-auth +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/erp-auth/src/service/user_service.rs +git commit -m "perf(auth): 用户列表 N+1 查询优化为批量查询" +``` + +--- + +### Task 6: plugin_service N+1 查询优化 + +**Files:** +- Modify: `crates/erp-plugin/src/service.rs:370-395` + +- [ ] **Step 1: 将 `find_plugin_entities` 循环调用改为批量查询** + +当前(第 393 行)每个 plugin 单独查询 entities。改为先收集所有 `plugin_id`,一次批量查询,再按 `plugin_id` 分组。 + +- [ ] **Step 2: 实现 `find_batch_plugin_entities`** + +使用 `WHERE plugin_id IN (...)` 一次查询所有 plugin 的 entities,返回 `HashMap>`。 + +- [ ] **Step 3: 验证编译** + +```bash +cargo check -p erp-plugin +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/erp-plugin/src/service.rs +git commit -m "perf(plugin): 插件列表 N+1 查询优化为批量查询" +``` + +--- + +## Chunk 3: 前端 Error Boundary + hooks 提取 + +### Task 7: 创建 ErrorBoundary 组件 + +**Files:** +- Create: `apps/web/src/components/ErrorBoundary.tsx` + +- [ ] **Step 1: 创建类组件 ErrorBoundary** + +```tsx +import { Component, ReactNode } from 'react'; +import { Button, Result } from 'antd'; + +interface Props { + children: ReactNode; + /** 是否为页面级(显示更详细的错误信息) */ + pageLevel?: boolean; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ( + + 重试 + , + , + ]} + /> + ); + } + return this.props.children; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/web/src/components/ErrorBoundary.tsx +git commit -m "feat(web): 添加 ErrorBoundary 组件" +``` + +--- + +### Task 8: App.tsx 包裹 Error Boundary + +**Files:** +- Modify: `apps/web/src/App.tsx` + +- [ ] **Step 1: 在 App.tsx 中添加全局和页面级 Error Boundary** + +在路由渲染外层包裹全局 ErrorBoundary,在每个 Suspense 内部包裹页面级 ErrorBoundary: + +```tsx +import ErrorBoundary from './components/ErrorBoundary'; + +// 全局包裹 — 在 HashRouter 内、Routes 外 + + + + + {/* 公开路由 */} + } /> + {/* 受保护路由 — 每个页面级 ErrorBoundary */} + + + + }> + + + + + + } /> + {/* ... 其他路由同样模式 ... */} + + + + +``` + +注意:为所有懒加载路由添加页面级 ErrorBoundary 包裹。可以创建一个辅助组件简化重复: + +```tsx +function LazyPage({ children }: { children: ReactNode }) { + return ( + + }> + {children} + + + ); +} +``` + +- [ ] **Step 2: 验证前端构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/App.tsx +git commit -m "feat(web): App.tsx 包裹全局 + 页面级 Error Boundary" +``` + +--- + +### Task 9: 提取 useCountUp hook + +**Files:** +- Create: `apps/web/src/hooks/useCountUp.ts` +- Modify: `apps/web/src/pages/Home.tsx` — 删除内联 useCountUp,改为 import +- Modify: `apps/web/src/pages/dashboard/DashboardWidgets.tsx` — 同上 + +- [ ] **Step 1: 从 Home.tsx 提取 useCountUp 到 hooks/ 目录** + +```ts +// apps/web/src/hooks/useCountUp.ts +import { useState, useEffect, useRef } from 'react'; + +export function useCountUp(end: number, duration = 800) { + const [count, setCount] = useState(0); + const prevEnd = useRef(end); + + useEffect(() => { + if (end === prevEnd.current && count > 0) return; + prevEnd.current = end; + if (end === 0) { setCount(0); return; } + + const startTime = performance.now(); + function tick(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setCount(Math.round(end * eased)); + if (progress < 1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }, [end, duration]); + + return count; +} +``` + +- [ ] **Step 2: 更新 Home.tsx 和 DashboardWidgets.tsx** + +在两个文件中: +- 删除内联的 `function useCountUp(...)` 定义 +- 添加 `import { useCountUp } from '../hooks/useCountUp';`(或对应相对路径) + +- [ ] **Step 3: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/hooks/useCountUp.ts apps/web/src/pages/Home.tsx apps/web/src/pages/dashboard/DashboardWidgets.tsx +git commit -m "refactor(web): 提取 useCountUp hook,消除 Home/Dashboard 重复" +``` + +--- + +### Task 10: 提取 useDarkMode hook + +**Files:** +- Create: `apps/web/src/hooks/useDarkMode.ts` + +- [ ] **Step 1: 创建 useDarkMode hook** + +```ts +// apps/web/src/hooks/useDarkMode.ts +import { theme } from 'antd'; + +export function useDarkMode(): boolean { + const { token } = theme.useToken(); + // 使用 colorBgBase(更可靠的基础背景色判断) + // 亮色主题 colorBgBase = '#ffffff',暗色主题 colorBgBase = '#141414' + const isDark = token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff'; + return isDark; +} +``` + +注意:此 hook 必须在 Ant Design `` 内部使用。当前 `token.colorBgContainer === '#111827'` 的字符串比较方式可被此 hook 替代。 + +- [ ] **Step 2: 在 8+ 个文件中替换暗色模式判断** + +搜索 `colorBgContainer === '#111827'` 或 `colorBgContainer === 'rgb(17, 24, 39)'` 的文件,替换为 `useDarkMode()` hook 调用。 + +- [ ] **Step 3: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/ +git commit -m "refactor(web): 提取 useDarkMode hook,替换 8+ 处字符串比较" +``` + +--- + +### Task 11: 提取 useDebouncedValue hook + +**Files:** +- Create: `apps/web/src/hooks/useDebouncedValue.ts` +- Modify: `apps/web/src/pages/Users.tsx` + +- [ ] **Step 1: 创建 useDebouncedValue hook** + +```ts +// apps/web/src/hooks/useDebouncedValue.ts +import { useState, useEffect } from 'react'; + +export function useDebouncedValue(value: T, delay = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} +``` + +- [ ] **Step 2: 在 Users.tsx 中应用防抖** + +```tsx +// Users.tsx — 替换直接使用 searchText 触发 fetchUsers +import { useDebouncedValue } from '../hooks/useDebouncedValue'; + +// 在组件内 +const debouncedSearch = useDebouncedValue(searchText, 300); + +const fetchUsers = useCallback(async (p = page) => { + setLoading(true); + try { + const result = await listUsers(p, 20, debouncedSearch); + setUsers(result.data); + setTotal(result.total); + } catch { + message.error('加载用户列表失败'); + } finally { + setLoading(false); + } + }, [page, debouncedSearch]); +``` + +- [ ] **Step 3: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/hooks/useDebouncedValue.ts apps/web/src/pages/Users.tsx +git commit -m "feat(web): 添加 useDebouncedValue hook,用户搜索防抖" +``` + +--- + +### Task 12: 提取 usePaginatedData 和 useApiRequest hooks + +**Files:** +- Create: `apps/web/src/hooks/usePaginatedData.ts` +- Create: `apps/web/src/hooks/useApiRequest.ts` + +- [ ] **Step 1: 创建 usePaginatedData hook** + +```ts +// apps/web/src/hooks/usePaginatedData.ts +import { useState, useCallback } from 'react'; +import { message } from 'antd'; + +interface PaginatedState { + data: T[]; + total: number; + page: number; + loading: boolean; +} + +export function usePaginatedData( + fetchFn: (page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>, + pageSize = 20, +) { + const [state, setState] = useState>({ + data: [], + total: 0, + page: 1, + loading: false, + }); + const [searchText, setSearchText] = useState(''); + + const refresh = useCallback(async (p?: number) => { + const targetPage = p ?? state.page; + setState(s => ({ ...s, loading: true })); + try { + const result = await fetchFn(targetPage, pageSize, searchText); + setState({ data: result.data, total: result.total, page: targetPage, loading: false }); + } catch { + message.error('加载数据失败'); + setState(s => ({ ...s, loading: false })); + } + }, [fetchFn, pageSize, searchText, state.page]); + + return { ...state, searchText, setSearchText, refresh }; +} +``` + +- [ ] **Step 2: 创建 useApiRequest hook** + +```ts +// apps/web/src/hooks/useApiRequest.ts +import { useCallback } from 'react'; +import { message } from 'antd'; + +export function useApiRequest() { + const execute = useCallback(async ( + fn: () => Promise, + successMsg?: string, + errorMsg = '操作失败', + ): Promise => { + try { + const result = await fn(); + if (successMsg) message.success(successMsg); + return result; + } catch (err) { + const msg = extractErrorMessage(err); + message.error(msg || errorMsg); + return null; + } + }, []); + + return { execute }; +} + +function extractErrorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { message?: string } } }).response; + return resp?.data?.message || ''; + } + if (err instanceof Error) return err.message; + return ''; +} +``` + +- [ ] **Step 3: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/hooks/ +git commit -m "feat(web): 添加 usePaginatedData + useApiRequest hooks" +``` + +--- + +## Chunk 4: 前端共享类型统一 + CancelToken 替换 + +### Task 13: 提取 PaginatedResponse 共享类型 + +**Files:** +- Create: `apps/web/src/api/types.ts` +- Modify: `apps/web/src/api/users.ts` +- Modify: `apps/web/src/api/plugins.ts` +- Modify: `apps/web/src/api/` 中所有导入 `PaginatedResponse` 的文件 + +- [ ] **Step 1: 创建 `api/types.ts`** + +```ts +// apps/web/src/api/types.ts +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} +``` + +- [ ] **Step 2: 从 users.ts 和 plugins.ts 删除重复定义,改为 import** + +```ts +// users.ts 和 plugins.ts 中 +import { PaginatedResponse } from './types'; +``` + +搜索所有从 `users.ts` 导入 `PaginatedResponse` 的文件,改为从 `./types` 导入。 + +- [ ] **Step 3: 创建 `api/errors.ts`** + +```ts +// apps/web/src/api/errors.ts +export function extractErrorMessage(err: unknown, fallback = '操作失败'): string { + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { message?: string } } }).response; + if (resp?.data?.message) return resp.data.message; + } + if (err instanceof Error) return err.message; + return fallback; +} +``` + +- [ ] **Step 4: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/api/ +git commit -m "refactor(web): 提取 PaginatedResponse 共享类型和 extractErrorMessage" +``` + +--- + +### Task 14: CancelToken → AbortController + +**Files:** +- Modify: `apps/web/src/api/client.ts:30-39` + +- [ ] **Step 1: 替换缓存实现中的 CancelToken** + +将缓存命中时的 `CancelToken.source()` 模式改为直接返回缓存数据: + +```ts +// 替换前(第 30-39 行) +if (config.method === 'get' && config.url) { + const key = getCacheKey(config); + const entry = requestCache.get(key); + if (entry && Date.now() - entry.timestamp < CACHE_TTL) { + const source = axios.CancelToken.source(); + config.cancelToken = source.token; + source.cancel(JSON.stringify({ __cached: true, data: entry.data })); + } +} + +// 替换后 +if (config.method === 'get' && config.url) { + const key = getCacheKey(config); + const entry = requestCache.get(key); + if (entry && Date.now() - entry.timestamp < CACHE_TTL) { + // 直接返回缓存的响应数据,不再使用 CancelToken hack + return Promise.resolve({ + data: entry.data, + status: 200, + statusText: 'OK (cached)', + headers: {} as any, + config, + } as AxiosResponse); + } +} +``` + +同时移除响应拦截器中的 `axios.isCancel` 处理逻辑。 + +- [ ] **Step 2: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/api/client.ts +git commit -m "refactor(web): 移除废弃的 CancelToken,改用直接返回缓存" +``` + +--- + +## Chunk 5: i18n 基础设施 + +### Task 15: 安装 react-i18next 并配置 + +**Files:** +- Install: `react-i18next`, `i18next` +- Create: `apps/web/src/i18n/index.ts` +- Create: `apps/web/src/i18n/locales/zh-CN.json` +- Modify: `apps/web/src/main.tsx` + +- [ ] **Step 1: 安装依赖** + +```bash +cd apps/web && pnpm add react-i18next i18next +``` + +- [ ] **Step 2: 创建 i18n 配置** + +```ts +// apps/web/src/i18n/index.ts +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import zhCN from './locales/zh-CN.json'; + +i18n.use(initReactI18next).init({ + resources: { 'zh-CN': { translation: zhCN } }, + lng: 'zh-CN', + fallbackLng: 'zh-CN', + interpolation: { escapeValue: false }, +}); + +export default i18n; +``` + +- [ ] **Step 3: 创建初始翻译文件** + +```json +// apps/web/src/i18n/locales/zh-CN.json +{ + "common": { + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "create": "新建", + "search": "搜索", + "confirm": "确认", + "loading": "加载中...", + "success": "操作成功", + "error": "操作失败" + }, + "auth": { + "login": { + "title": "登录", + "username": "用户名", + "password": "密码", + "submit": "登录", + "success": "登录成功", + "failed": "用户名或密码错误" + } + }, + "nav": { + "home": "首页", + "users": "用户管理", + "roles": "角色管理", + "organizations": "组织管理", + "workflow": "工作流", + "messages": "消息中心", + "settings": "系统设置", + "plugins": "插件管理" + } +} +``` + +- [ ] **Step 4: 在 main.tsx 中导入 i18n** + +在 `apps/web/src/main.tsx` 顶部添加: +```ts +import './i18n'; +``` + +- [ ] **Step 5: 验证构建** + +```bash +cd apps/web && pnpm build +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/i18n/ apps/web/src/main.tsx apps/web/package.json apps/web/pnpm-lock.yaml +git commit -m "feat(web): 搭建 i18n 基础设施 (react-i18next)" +``` + +--- + +## Chunk 6: 行级数据权限接线 + +### Task 16: JWT 中间件注入 department_ids + +**Files:** +- Modify: `crates/erp-auth/src/middleware/jwt_auth.rs:50-51` + +- [ ] **Step 1: 实现 department_ids 查询** + +将当前的: +```rust +// TODO: 待 user_positions 关联表建立后,从数据库查询用户所属部门 ID 列表 +department_ids: vec![], +``` + +改为实际查询。需要从 JWT 的 state 中获取 `DatabaseConnection`(可能需要修改中间件签名以传入 db): + +```rust +// 查询用户所属部门 ID 列表(通过 user_positions 关联表) +let department_ids = match find_user_departments(claims.sub, claims.tid, &db).await { + Ok(ids) => ids, + Err(e) => { + tracing::warn!(error = %e, "查询用户部门列表失败,默认为空"); + vec![] + } +}; + +let ctx = TenantContext { + tenant_id: claims.tid, + user_id: claims.sub, + roles: claims.roles, + permissions: claims.permissions, + department_ids, +}; +``` + +注意:需要实现 `find_user_departments` 函数,查询 `user_positions` 表。如果中间件签名无法传入 `db`,需要考虑通过 State 或 Extension 传递。参考 `data_handler.rs` 中的注释(第 108-113 行)。 + +- [ ] **Step 2: 验证编译** + +```bash +cargo check -p erp-auth +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/erp-auth/src/middleware/jwt_auth.rs +git commit -m "feat(auth): JWT 中间件注入 department_ids" +``` + +--- + +### Task 17: data_handler 接线 data_scope 过滤 + +**Files:** +- Modify: `crates/erp-plugin/src/handler/data_handler.rs:108-113` + +- [ ] **Step 1: 实现第 108 行 TODO 描述的 4 步逻辑** + +按照 TODO 注释中的 4 个步骤实现: + +```rust +// 1. 检查 entity 定义是否声明 data_scope +let entity_def = resolve_entity_def(&manifest_id, &entity, &state.db).await; +if entity_def.data_scope.unwrap_or(false) { + // 2. 获取当前用户的 data scope 等级 + let scope = get_data_scope(&ctx, &fine_perm, &state.db).await?; + + // 3. 若 scope != "all",获取部门成员列表 + if scope.level != "all" { + // 4. 将 scope 条件合并到 filter + let scope_filter = build_data_scope_condition( + &scope, + &ctx.department_ids, + ctx.user_id, + )?; + // 合并 scope_filter 到现有 filter + } +} +``` + +参考 `crates/erp-plugin/src/dynamic_table.rs` 中的 `build_data_scope_condition()` 函数。 + +- [ ] **Step 2: 同样修改 update/delete/count/aggregate 端点** + +所有涉及数据查询的端点都需要注入 data_scope 条件。 + +- [ ] **Step 3: 验证编译** + +```bash +cargo check -p erp-plugin +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/erp-plugin/src/handler/data_handler.rs +git commit -m "feat(plugin): data_handler 接线行级数据权限过滤" +``` + +--- + +## 验证清单 + +- [ ] **V1: 全 workspace 编译和测试** +```bash +cargo check && cargo test --workspace +``` + +- [ ] **V2: 前端构建** +```bash +cd apps/web && pnpm build +``` + +- [ ] **V3: ErpModule 路由自动化验证** +```bash +cargo run -p erp-server +``` +验证所有 API 端点仍然正常工作(路由未丢失) + +- [ ] **V4: Error Boundary 验证** +在浏览器中访问一个不存在的路由或触发一个渲染错误,验证 Error Boundary 捕获并显示友好提示 + +- [ ] **V5: i18n 验证** +在代码中使用 `useTranslation()` 输出一个翻译 key,验证显示正确的中文文案