17 个 Task 覆盖:ErpModule trait 重构(自动化路由)、N+1 查询优化、 Error Boundary、5 个 hooks 提取、i18n 基础设施、行级数据权限接线
1113 lines
30 KiB
Markdown
1113 lines
30 KiB
Markdown
# 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,验证显示正确的中文文案
|