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

30 KiB
Raw Blame History

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