# 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,验证显示正确的中文文案