17 个 Task 覆盖:ErpModule trait 重构(自动化路由)、N+1 查询优化、 Error Boundary、5 个 hooks 提取、i18n 基础设施、行级数据权限接线
30 KiB
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 之后添加:
/// 注册公开路由(不需要 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 中添加:
/// 构建路由:收集所有模块的公开路由和受保护路由
/// 返回 (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: 验证编译
cargo check -p erp-core
- Step 4: Commit
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 方法中:
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: 验证编译
cargo check -p erp-auth
- Step 3: Commit
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 的模式
每个模块添加:
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 编译
cargo check --workspace
- Step 3: Commit
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 行)改为自动收集:
// 替换 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: 验证编译和测试
cargo check -p erp-server && cargo test --workspace
- Step 3: Commit
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 行):
// 当前:每个用户单独查询角色
let mut resps = Vec::with_capacity(models.len());
for m in models {
let roles: Vec<RoleResp> = Self::fetch_user_role_resps(m.id, tenant_id, db)
.await
.unwrap_or_default();
resps.push(model_to_resp(&m, roles));
}
改为批量查询:
// 优化:一次查询所有用户的角色
let user_ids: Vec<Uuid> = 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<UserResp> = 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 (...) 批量查询:
async fn fetch_batch_user_role_resps(
user_ids: &[Uuid],
tenant_id: Uuid,
db: &DatabaseConnection,
) -> std::collections::HashMap<Uuid, Vec<RoleResp>> {
if user_ids.is_empty() {
return std::collections::HashMap::new();
}
// 批量查询 user_role 关联
let user_roles: Vec<user_role::Model> = 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<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
// 批量查询角色
let roles: Vec<role::Model> = 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<Uuid, &role::Model> = 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: 验证编译
cargo check -p erp-auth
- Step 4: Commit
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<Uuid, Vec<EntityModel>>。
- Step 3: 验证编译
cargo check -p erp-plugin
- Step 4: Commit
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
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<Props, State> {
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 (
<Result
status="error"
title={this.props.pageLevel ? '页面加载出错' : '出了点问题'}
subTitle={this.props.pageLevel
? `错误信息:${this.state.error?.message || '未知错误'}`
: '请刷新页面重试'}
extra={[
<Button key="retry" type="primary" onClick={this.handleReset}>
重试
</Button>,
<Button key="home" onClick={() => window.location.hash = '/'}>
返回首页
</Button>,
]}
/>
);
}
return this.props.children;
}
}
- Step 2: Commit
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:
import ErrorBoundary from './components/ErrorBoundary';
// 全局包裹 — 在 HashRouter 内、Routes 外
<HashRouter>
<ErrorBoundary>
<ConfigProvider ...>
<Routes>
{/* 公开路由 */}
<Route path="/login" element={<Login />} />
{/* 受保护路由 — 每个页面级 ErrorBoundary */}
<Route path="/" element={
<PrivateRoute>
<MainLayout>
<ErrorBoundary pageLevel>
<Suspense fallback={<Spin size="large" />}>
<Home />
</Suspense>
</ErrorBoundary>
</MainLayout>
</PrivateRoute>
} />
{/* ... 其他路由同样模式 ... */}
</Routes>
</ConfigProvider>
</ErrorBoundary>
</HashRouter>
注意:为所有懒加载路由添加页面级 ErrorBoundary 包裹。可以创建一个辅助组件简化重复:
function LazyPage({ children }: { children: ReactNode }) {
return (
<ErrorBoundary pageLevel>
<Suspense fallback={<Spin size="large" className="page-loading" />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
- Step 2: 验证前端构建
cd apps/web && pnpm build
- Step 3: Commit
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/ 目录
// 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: 验证构建
cd apps/web && pnpm build
- Step 4: Commit
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
// 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 <ConfigProvider> 内部使用。当前 token.colorBgContainer === '#111827' 的字符串比较方式可被此 hook 替代。
- Step 2: 在 8+ 个文件中替换暗色模式判断
搜索 colorBgContainer === '#111827' 或 colorBgContainer === 'rgb(17, 24, 39)' 的文件,替换为 useDarkMode() hook 调用。
- Step 3: 验证构建
cd apps/web && pnpm build
- Step 4: Commit
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
// apps/web/src/hooks/useDebouncedValue.ts
import { useState, useEffect } from 'react';
export function useDebouncedValue<T>(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 中应用防抖
// 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: 验证构建
cd apps/web && pnpm build
- Step 4: Commit
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
// apps/web/src/hooks/usePaginatedData.ts
import { useState, useCallback } from 'react';
import { message } from 'antd';
interface PaginatedState<T> {
data: T[];
total: number;
page: number;
loading: boolean;
}
export function usePaginatedData<T>(
fetchFn: (page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>,
pageSize = 20,
) {
const [state, setState] = useState<PaginatedState<T>>({
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
// apps/web/src/hooks/useApiRequest.ts
import { useCallback } from 'react';
import { message } from 'antd';
export function useApiRequest() {
const execute = useCallback(async <T>(
fn: () => Promise<T>,
successMsg?: string,
errorMsg = '操作失败',
): Promise<T | null> => {
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: 验证构建
cd apps/web && pnpm build
- Step 4: Commit
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
// apps/web/src/api/types.ts
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
- Step 2: 从 users.ts 和 plugins.ts 删除重复定义,改为 import
// users.ts 和 plugins.ts 中
import { PaginatedResponse } from './types';
搜索所有从 users.ts 导入 PaginatedResponse 的文件,改为从 ./types 导入。
- Step 3: 创建
api/errors.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: 验证构建
cd apps/web && pnpm build
- Step 5: Commit
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() 模式改为直接返回缓存数据:
// 替换前(第 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: 验证构建
cd apps/web && pnpm build
- Step 3: Commit
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: 安装依赖
cd apps/web && pnpm add react-i18next i18next
- Step 2: 创建 i18n 配置
// 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: 创建初始翻译文件
// 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 顶部添加:
import './i18n';
- Step 5: 验证构建
cd apps/web && pnpm build
- Step 6: Commit
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 查询
将当前的:
// TODO: 待 user_positions 关联表建立后,从数据库查询用户所属部门 ID 列表
department_ids: vec![],
改为实际查询。需要从 JWT 的 state 中获取 DatabaseConnection(可能需要修改中间件签名以传入 db):
// 查询用户所属部门 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: 验证编译
cargo check -p erp-auth
- Step 3: Commit
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 个步骤实现:
// 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: 验证编译
cargo check -p erp-plugin
- Step 4: Commit
git add crates/erp-plugin/src/handler/data_handler.rs
git commit -m "feat(plugin): data_handler 接线行级数据权限过滤"
验证清单
- V1: 全 workspace 编译和测试
cargo check && cargo test --workspace
- V2: 前端构建
cd apps/web && pnpm build
- V3: ErpModule 路由自动化验证
cargo run -p erp-server
验证所有 API 端点仍然正常工作(路由未丢失)
-
V4: Error Boundary 验证 在浏览器中访问一个不存在的路由或触发一个渲染错误,验证 Error Boundary 捕获并显示友好提示
-
V5: i18n 验证 在代码中使用
useTranslation()输出一个翻译 key,验证显示正确的中文文案