diff --git a/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md b/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md new file mode 100644 index 0000000..12b7f59 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md @@ -0,0 +1,789 @@ +# 三端联调审计问题修复实施计划 + +> **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:** 修复三端联调审计发现的 15 项问题,恢复系统统一性和可用性。 + +**Architecture:** 按优先级分 3 个 Phase 执行。Phase 1 快速修复基础设施和统一性问题(#1-#6),Phase 2 补全用户体验(#7-#11),Phase 3 实现功能闭环(#12-#15)。每个 Task 独立可提交。 + +**Tech Stack:** Rust/Axum/SeaORM (后端) · React 19/Ant Design 6/Zustand (Web 前端) · Taro 4.2 (小程序) · PostgreSQL (数据库) + +**Spec:** `docs/superpowers/specs/2026-05-01-tri-platform-audit-fix-design.md` + +--- + +## Chunk 1: Phase 1 — 快速修复 (#1-#6) + +### Task 1: erp-plugin 测试编译失败修复 + +**Files:** +- Modify: `crates/erp-plugin/src/plugin_validator.rs:227` + +- [ ] **Step 1: 添加缺失的 import** + +在第 227 行 `use super::*;` 之后添加一行: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::parse_manifest; +``` + +- [ ] **Step 2: 验证编译通过** + +Run: `cargo test -p erp-plugin --no-run` +Expected: 编译成功,无错误 + +- [ ] **Step 3: 运行测试** + +Run: `cargo test -p erp-plugin` +Expected: 所有测试通过 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin/src/plugin_validator.rs +git commit -m "fix(plugin): 修复测试编译失败 — 补充 parse_manifest 导入" +``` + +--- + +### Task 2: 后端 ThemeResp 增加品牌字段 + +**Files:** +- Modify: `crates/erp-config/src/dto.rs:220-227` +- Modify: `crates/erp-config/src/handler/theme_handler.rs:15-21` +- Modify: `crates/erp-config/src/handler/theme_handler.rs:110-135`(测试) + +- [ ] **Step 1: 扩展 ThemeResp DTO** + +在 `crates/erp-config/src/dto.rs` 第 227 行 `sidebar_style` 字段之后添加 4 个品牌字段: + +```rust +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct ThemeResp { + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sidebar_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_slogan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_features: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_copyright: Option, +} +``` + +- [ ] **Step 2: 更新 default_theme 函数** + +在 `crates/erp-config/src/handler/theme_handler.rs` 第 15-21 行,更新 `default_theme()` 函数: + +```rust +fn default_theme() -> ThemeResp { + ThemeResp { + primary_color: None, + logo_url: None, + sidebar_style: None, + brand_name: Some("HMS 健康管理平台".into()), + brand_slogan: Some("新一代健康管理平台".into()), + brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), + brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()), + } +} +``` + +- [ ] **Step 3: 更新测试** + +在 `crates/erp-config/src/handler/theme_handler.rs` 的测试模块中,更新 `default_theme_all_fields_none` 测试(因为 default_theme 现在品牌字段有值了)和 `theme_resp_serde_roundtrip` 测试: + +```rust +#[test] +fn default_theme_has_brand_defaults() { + let theme = default_theme(); + assert!(theme.primary_color.is_none()); + assert!(theme.logo_url.is_none()); + assert!(theme.sidebar_style.is_none()); + assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string())); + assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string())); + assert!(theme.brand_features.is_some()); + assert!(theme.brand_copyright.is_some()); +} + +#[test] +fn theme_resp_serde_roundtrip() { + let theme = ThemeResp { + primary_color: Some("#1890ff".to_string()), + logo_url: None, + sidebar_style: Some("dark".to_string()), + brand_name: Some("测试平台".to_string()), + brand_slogan: None, + brand_features: None, + brand_copyright: None, + }; + let json = serde_json::to_string(&theme).unwrap(); + let back: ThemeResp = serde_json::from_str(&json).unwrap(); + assert_eq!(back.primary_color, Some("#1890ff".to_string())); + assert_eq!(back.brand_name, Some("测试平台".to_string())); + assert!(back.brand_slogan.is_none()); +} +``` + +- [ ] **Step 4: 添加公开品牌信息端点** + +在 `crates/erp-config/src/handler/theme_handler.rs` 中新增公开端点 handler(无需认证): + +```rust +/// 品牌信息公开响应(不含内部配置) +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct PublicBrandResp { + pub brand_name: String, + pub brand_slogan: String, + pub brand_features: String, + pub brand_copyright: String, +} + +/// GET /api/v1/public/brand — 公开品牌信息(无需认证) +pub async fn get_public_brand() -> JsonResponse> { + let defaults = default_theme(); + JsonResponse(ApiResponse::ok(PublicBrandResp { + brand_name: defaults.brand_name.unwrap_or_else(|| "HMS 健康管理平台".into()), + brand_slogan: defaults.brand_slogan.unwrap_or_else(|| "新一代健康管理平台".into()), + brand_features: defaults.brand_features.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), + brand_copyright: defaults.brand_copyright.unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()), + })) +} +``` + +> **注意**: 此端点需要在 `erp-server` 的公开路由中注册。先完成此 Task 的代码修改,路由注册在单独步骤中处理。公开端点暂时返回默认值,后续可通过 settings 读取租户自定义配置。 + +- [ ] **Step 5: 验证编译** + +Run: `cargo check` +Expected: 编译通过 + +- [ ] **Step 6: 注册公开路由** + +在 `crates/erp-server/src/main.rs`(或对应的路由注册文件)中,找到公开路由组(通常包含 `/login`、`/register` 等),添加: + +```rust +// 在公开路由区域添加 +.route("/api/v1/public/brand", get(config_handler::get_public_brand)) +``` + +需要导入 handler:`use erp_config::handler as config_handler;`(或按项目已有模式导入)。 + +> 在 `erp-config/src/module.rs` 的 `ErpModule::public_routes()` 中注册此路由。 + +- [ ] **Step 7: 验证端点可用** + +Run: `cargo run`(启动后端) + +Run: `curl http://localhost:3000/api/v1/public/brand` +Expected: `{"success":true,"data":{"brand_name":"HMS 健康管理平台",...}}` + +- [ ] **Step 8: 提交** + +```bash +git add crates/erp-config/src/dto.rs crates/erp-config/src/handler/theme_handler.rs +git commit -m "feat(config): ThemeResp 增加品牌字段 + 公开品牌信息端点" +``` + +--- + +### Task 3: 前端主题设置联动 — API + Store + 主题设置页面 + +**Files:** +- Modify: `apps/web/src/api/themes.ts` +- Modify: `apps/web/src/stores/app.ts` +- Modify: `apps/web/src/pages/settings/ThemeSettings.tsx` + +- [ ] **Step 1: 扩展 ThemeConfig 接口** + +修改 `apps/web/src/api/themes.ts`: + +```typescript +import client from './client'; + +export interface ThemeConfig { + primary_color?: string; + logo_url?: string; + sidebar_style?: 'light' | 'dark'; + brand_name?: string; + brand_slogan?: string; + brand_features?: string; + brand_copyright?: string; +} + +export async function getTheme() { + const { data } = await client.get<{ success: boolean; data: ThemeConfig }>( + '/config/themes', + ); + return data.data; +} + +export async function updateTheme(theme: ThemeConfig) { + const { data } = await client.put<{ success: boolean; data: ThemeConfig }>( + '/config/themes', + theme, + ); + return data.data; +} + +export interface BrandConfig { + brand_name: string; + brand_slogan: string; + brand_features: string; + brand_copyright: string; +} + +const BRAND_DEFAULTS: BrandConfig = { + brand_name: 'HMS 健康管理平台', + brand_slogan: '新一代健康管理平台', + brand_features: '患者管理 · 健康监测 · 随访管理 · AI 智能分析', + brand_copyright: 'HMS 健康管理平台 · ©汕头市智界科技有限公司', +}; + +export async function getPublicBrand(): Promise { + try { + const res = await fetch('/api/v1/public/brand'); + const json = await res.json(); + if (json?.success && json?.data) return json.data; + } catch {} + return BRAND_DEFAULTS; +} +``` + +- [ ] **Step 2: 扩展 app store — 添加 themeConfig 缓存** + +修改 `apps/web/src/stores/app.ts`,添加 themeConfig 和 loadThemeConfig: + +```typescript +import { create } from 'zustand'; +import type { ThemeConfig } from '../api/themes'; +import { getTheme } from '../api/themes'; + +export type ThemeName = 'blue' | 'warm' | 'dark' | 'emerald'; + +// ... (THEME_OPTIONS 保持不变) + +const STORAGE_KEY = 'hms-theme'; +const THEME_CONFIG_KEY = 'hms-theme-config'; + +function loadTheme(): ThemeName { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved && THEME_OPTIONS.some((t) => t.key === saved)) return saved as ThemeName; + } catch {} + return 'blue'; +} + +interface AppState { + theme: ThemeName; + sidebarCollapsed: boolean; + themeConfig: ThemeConfig | null; + toggleSidebar: () => void; + setTheme: (theme: ThemeName) => void; + loadThemeConfig: () => Promise; +} + +export const useAppStore = create((set) => ({ + theme: loadTheme(), + sidebarCollapsed: false, + themeConfig: null, + toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), + setTheme: (theme) => { + try { localStorage.setItem(STORAGE_KEY, theme); } catch {} + set({ theme }); + }, + loadThemeConfig: async () => { + try { + const config = await getTheme(); + try { localStorage.setItem(THEME_CONFIG_KEY, JSON.stringify(config)); } catch {} + set({ themeConfig: config }); + } catch {} + }, +})); +``` + +- [ ] **Step 3: 在 MainLayout mount 时加载 themeConfig** + +修改 `apps/web/src/layouts/MainLayout.tsx`,在组件中添加 loadThemeConfig 调用: + +在组件函数体内,现有 useEffect 附近添加: + +```typescript +const loadThemeConfig = useAppStore((s) => s.loadThemeConfig); + +useEffect(() => { + loadThemeConfig(); +}, [loadThemeConfig]); +``` + +- [ ] **Step 4: 扩展 ThemeSettings 页面** + +修改 `apps/web/src/pages/settings/ThemeSettings.tsx`,在现有表单的保存按钮之前,添加品牌信息区域: + +```tsx +import { useEffect, useState, useCallback } from 'react'; +import { Form, Input, Select, Button, ColorPicker, message, Typography, Divider } from 'antd'; +import { getTheme, updateTheme } from '../../api/themes'; + +export default function ThemeSettings() { + const [form] = Form.useForm(); + const [, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const fetchTheme = useCallback(async () => { + setLoading(true); + try { + const theme = await getTheme(); + form.setFieldsValue({ + primary_color: theme.primary_color || '#1677ff', + logo_url: theme.logo_url || '', + sidebar_style: theme.sidebar_style || 'light', + brand_name: theme.brand_name || '', + brand_slogan: theme.brand_slogan || '', + brand_features: theme.brand_features || '', + brand_copyright: theme.brand_copyright || '', + }); + } catch { + form.setFieldsValue({ + primary_color: '#1677ff', + logo_url: '', + sidebar_style: 'light', + }); + } + setLoading(false); + }, [form]); + + useEffect(() => { fetchTheme(); }, [fetchTheme]); + + const handleSave = async (values: Record) => { + setSaving(true); + try { + const primaryColor = typeof values.primary_color === 'string' + ? values.primary_color + : (values.primary_color as { toHexString?: () => string })?.toHexString?.() ?? String(values.primary_color); + await updateTheme({ + primary_color: primaryColor, + logo_url: values.logo_url as string, + sidebar_style: values.sidebar_style as 'light' | 'dark', + brand_name: (values.brand_name as string) || undefined, + brand_slogan: (values.brand_slogan as string) || undefined, + brand_features: (values.brand_features as string) || undefined, + brand_copyright: (values.brand_copyright as string) || undefined, + }); + message.success('主题设置已保存'); + } catch (err: unknown) { + const errorMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || '保存失败'; + message.error(errorMsg); + } + setSaving(false); + }; + + return ( +
+ 主题设置 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} +``` + +- [ ] **Step 5: 验证前端编译** + +Run: `cd apps/web && pnpm build` +Expected: 构建成功 + +- [ ] **Step 6: 提交** + +```bash +git add apps/web/src/api/themes.ts apps/web/src/stores/app.ts apps/web/src/pages/settings/ThemeSettings.tsx +git commit -m "feat(web): 主题设置增加品牌信息字段 + store 缓存 + 公开品牌 API" +``` + +--- + +### Task 4: Login.tsx 和 MainLayout.tsx 从主题配置读取品牌信息 + +**Files:** +- Modify: `apps/web/src/pages/Login.tsx:35-56,105-107` +- Modify: `apps/web/src/layouts/MainLayout.tsx:434,526-528` + +- [ ] **Step 1: 修改 Login.tsx — 从公开端点读取品牌信息** + +在 `apps/web/src/pages/Login.tsx` 中: + +1. 添加 `useEffect, useState` 导入(如尚未导入) +2. 添加 brandConfig 状态和 useEffect 获取逻辑 +3. 替换硬编码品牌文字 + +在组件函数体开头添加: + +```typescript +const [brandConfig, setBrandConfig] = useState<{ + brand_name?: string; + brand_slogan?: string; + brand_features?: string; + brand_copyright?: string; +} | null>(null); + +useEffect(() => { + const cached = localStorage.getItem('hms-theme-config'); + if (cached) { + try { setBrandConfig(JSON.parse(cached)); } catch {} + } + fetch('/api/v1/public/brand') + .then(res => res.json()) + .then(data => { + if (data?.success && data?.data) { + setBrandConfig(data.data); + localStorage.setItem('hms-theme-config', JSON.stringify(data.data)); + } + }) + .catch(() => {}); +}, []); +``` + +替换第 40-42 行硬编码文字: + +```tsx +

{brandConfig?.brand_name || 'HMS 健康管理平台'}

+

{brandConfig?.brand_slogan || '新一代健康管理平台'}

+

{brandConfig?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}

+``` + +替换第 106 行版权文字: + +```tsx +
+ {brandConfig?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'} +
+``` + +- [ ] **Step 2: 修改 MainLayout.tsx — Footer 和 Logo 从配置读取** + +在 `apps/web/src/layouts/MainLayout.tsx` 中: + +1. 获取 themeConfig:在组件函数体内添加: +```typescript +const themeConfig = useAppStore((s) => s.themeConfig); +``` + +2. 替换第 434 行侧边栏 Logo 文字: +```tsx +{themeConfig?.brand_name || 'HMS 健康'} +``` + +3. 替换第 527 行 Footer 文字: +```tsx +
+ {themeConfig?.brand_copyright || 'HMS 健康管理平台'} +
+``` + +- [ ] **Step 3: 验证前端编译** + +Run: `cd apps/web && pnpm build` +Expected: 构建成功 + +- [ ] **Step 4: 浏览器验证** + +1. 打开登录页,确认显示 "HMS 健康管理平台"(默认值) +2. 登录后进入系统设置 → 主题设置 +3. 修改品牌名称为 "XX 医院健康管理" +4. 保存后刷新页面,确认侧边栏/页脚更新 +5. 登出后确认登录页显示新品牌名称 + +- [ ] **Step 5: 提交** + +```bash +git add apps/web/src/pages/Login.tsx apps/web/src/layouts/MainLayout.tsx +git commit -m "feat(web): Login/MainLayout 从主题配置读取品牌信息" +``` + +--- + +### Task 5: 告警和行动收件箱侧边栏菜单入口 + +**Files:** +- Modify: `apps/web/src/layouts/MainLayout.tsx:1-32,51-75` + +- [ ] **Step 1: 在 MainLayout.tsx 补充图标导入** + +在 `apps/web/src/layouts/MainLayout.tsx` 的 import 区域(第 31 行 `BarChartOutlined` 之后)添加: + +```typescript + AlertOutlined, + BellOutlined, + ControlOutlined, + InboxOutlined, + ApiOutlined, + ReadOutlined, + ExperimentOutlined, +``` + +- [ ] **Step 2: 在 iconMap 中补充图标映射** + +在 `iconMap` 对象(第 74 行 `BarChartOutlined` 之后)添加: + +```typescript + AlertOutlined: , + BellOutlined: , + ControlOutlined: , + InboxOutlined: , + ApiOutlined: , + ReadOutlined: , + ExperimentOutlined: , +``` + +- [ ] **Step 3: 验证前端编译** + +Run: `cd apps/web && pnpm build` +Expected: 构建成功 + +- [ ] **Step 4: 提交** + +```bash +git add apps/web/src/layouts/MainLayout.tsx +git commit -m "fix(web): 补充告警/行动收件箱/设备等菜单图标映射" +``` + +--- + +### Task 6: 行动收件箱菜单种子迁移 + +**Files:** +- Create: `crates/erp-server/migration/src/m20260501_000100_seed_action_inbox_menu.rs` +- Modify: `crates/erp-server/migration/src/lib.rs:207` + +- [ ] **Step 1: 创建迁移文件** + +创建 `crates/erp-server/migration/src/m20260501_000100_seed_action_inbox_menu.rs`: + +```rust +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 插入行动收件箱菜单 + db.execute_unprepared( + r#" + INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, + created_at, updated_at, is_active) + SELECT + 'b0000003-0000-7000-8000-000000000020'::uuid, + t.id, + (SELECT id FROM menus WHERE path = '/health' AND tenant_id = t.id LIMIT 1), + '行动收件箱', + '/health/action-inbox', + 'InboxOutlined', + 36, + NOW(), NOW(), true + FROM tenants t + WHERE NOT EXISTS ( + SELECT 1 FROM menus + WHERE path = '/health/action-inbox' AND tenant_id = t.id + ) + "#, + ) + .await?; + + // 关联权限码 + db.execute_unprepared( + r#" + INSERT INTO role_permissions (role_id, permission_code, tenant_id) + SELECT r.id, 'health.action-inbox.list', t.id + FROM tenants t + JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' + WHERE NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.permission_code = 'health.action-inbox.list' + AND rp.role_id = r.id + ) + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + db.execute_unprepared( + "DELETE FROM menus WHERE path = '/health/action-inbox'", + ) + .await?; + Ok(()) + } +} +``` + +- [ ] **Step 2: 在 lib.rs 中注册迁移** + +在 `crates/erp-server/migration/src/lib.rs` 中: + +1. 添加模块声明(在文件末尾 `mod` 声明区域): +```rust +mod m20260501_000100_seed_action_inbox_menu; +``` + +2. 在 migration list 的最后一项 `m20260501_000099_create_ai_risk_threshold::Migration` 之后添加: +```rust +Box::new(m20260501_000100_seed_action_inbox_menu::Migration), +``` + +- [ ] **Step 3: 验证编译** + +Run: `cargo check` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-server/migration/src/m20260501_000100_seed_action_inbox_menu.rs crates/erp-server/migration/src/lib.rs +git commit -m "feat(migration): 行动收件箱菜单种子数据 + 权限关联" +``` + +--- + +### Task 7: 危急值告警端点 500 修复 + +**Files:** +- Modify: `crates/erp-health/src/service/critical_alert_service.rs` + +- [ ] **Step 1: 添加 tracing 日志** + +在 `crates/erp-health/src/service/critical_alert_service.rs` 的 `list_pending_alerts` 函数中,找到查询执行的 `.await`,在错误处理链中添加 tracing: + +```rust +// 找到类似以下代码,添加 tracing::error! +.all(&state.db) +.await +.map_err(|e| { + tracing::error!(error = %e, "查询危急值告警列表失败"); + HealthError::DbError(e.to_string()) +})?; +``` + +对 `get_alert` 函数同理添加。 + +- [ ] **Step 2: 验证数据库状态并测试** + +启动后端 `cargo run`,用 curl 测试端点,查看 tracing 日志确认根因。 + +根据日志输出决定下一步: +- 如果是表不存在 → 确认迁移 m000090 是否执行 +- 如果是 RLS 策略缺失 → 创建 Task 7b RLS 补齐迁移 +- 如果是其他错误 → 根据日志修复 + +> **此步骤需要运行时验证,根据实际错误决定是否需要创建 RLS 补齐迁移。** + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-health/src/service/critical_alert_service.rs +git commit -m "fix(health): 危急值告警查询添加 tracing 日志" +``` + +--- + +### Task 8: domain_events 堆积清理 + +**Files:** +- Modify: `crates/erp-server/src/tasks.rs:26` + +- [ ] **Step 1: 修改事件保留期** + +在 `crates/erp-server/src/tasks.rs` 第 26 行,将 90 天改为 7 天: + +```rust +// 修改前 +"SELECT cleanup_old_published_events(90, 1000)" +// 修改后 +"SELECT cleanup_old_published_events(7, 1000)" +``` + +同时更新第 6 行注释: +```rust +/// - 调用 `cleanup_old_published_events()` 归档 >7 天的已发布事件 +``` + +- [ ] **Step 2: 手动清理当前堆积** + +在数据库中执行 SQL 清理已发布事件: + +```sql +INSERT INTO domain_events_archive (id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at) +SELECT id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at +FROM domain_events WHERE status = 'published'; + +DELETE FROM domain_events WHERE status = 'published' + AND id IN (SELECT id FROM domain_events_archive); +``` + +> **此步骤需要手动执行 SQL,不在代码中自动化。** + +- [ ] **Step 3: 验证编译** + +Run: `cargo check` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-server/src/tasks.rs +git commit -m "fix(server): 事件清理保留期从 90 天调整为 7 天" +``` + +--- + +## Phase 1 验证清单 + +完成所有 Task 后执行: + +- [ ] `cargo check` — 全 workspace 编译通过 +- [ ] `cargo test -p erp-plugin` — 测试通过 +- [ ] `cd apps/web && pnpm build` — 前端构建通过 +- [ ] 浏览器验证:登录页品牌信息、侧边栏菜单图标、主题设置品牌编辑 +- [ ] `git push` — 推送到远程