Files
erp/docs/superpowers/plans/2026-04-17-platform-maturity-q3-plan.md
iven d6dc47ab6a docs: 添加 Q3 架构强化 + 前端体验实施计划
17 个 Task 覆盖:ErpModule trait 重构(自动化路由)、N+1 查询优化、
Error Boundary、5 个 hooks 提取、i18n 基础设施、行级数据权限接线
2026-04-17 17:28:02 +08:00

1113 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<RoleResp> = 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<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 (...)` 批量查询:
```rust
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: 验证编译**
```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<Uuid, Vec<EntityModel>>`
- [ ] **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<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**
```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 外
<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 包裹。可以创建一个辅助组件简化重复:
```tsx
function LazyPage({ children }: { children: ReactNode }) {
return (
<ErrorBoundary pageLevel>
<Suspense fallback={<Spin size="large" className="page-loading" />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
```
- [ ] **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 `<ConfigProvider>` 内部使用。当前 `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<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 中应用防抖**
```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<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**
```ts
// 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: 验证构建**
```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<T> {
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验证显示正确的中文文案