Files
hms/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md
iven 73119fe026 docs: 三端审计修复实施计划 Phase 2 — 6 个 Task (#7-#11)
Chunk 2: 体验补全阶段
- Task 9: 患者详情快捷导航卡片
- Task 10: 5 个列表页支持 URL patient_id 过滤
- Task 11: AI 分析列表患者 Link
- Task 12: 小程序 AI 建议跳转修复
- Task 13: 小程序通知 Tab 对接 erp-message API
- Task 14: 小程序咨询功能入口
2026-05-01 17:20:45 +08:00

33 KiB
Raw Blame History

三端联调审计问题修复实施计划

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-#6Phase 2 补全用户体验(#7-#11Phase 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::*; 之后添加一行:

#[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: 提交
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 个品牌字段:

#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
pub struct ThemeResp {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub primary_color: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub logo_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sidebar_style: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub brand_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub brand_slogan: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub brand_features: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub brand_copyright: Option<String>,
}
  • Step 2: 更新 default_theme 函数

crates/erp-config/src/handler/theme_handler.rs 第 15-21 行,更新 default_theme() 函数:

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 测试:

#[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无需认证

/// 品牌信息公开响应(不含内部配置)
#[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<ApiResponse<PublicBrandResp>> {
    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 等),添加:

// 在公开路由区域添加
.route("/api/v1/public/brand", get(config_handler::get_public_brand))

需要导入 handleruse erp_config::handler as config_handler;(或按项目已有模式导入)。

erp-config/src/module.rsErpModule::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: 提交
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

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<BrandConfig> {
  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

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<void>;
}

export const useAppStore = create<AppState>((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 附近添加:

const loadThemeConfig = useAppStore((s) => s.loadThemeConfig);

useEffect(() => {
  loadThemeConfig();
}, [loadThemeConfig]);
  • Step 4: 扩展 ThemeSettings 页面

修改 apps/web/src/pages/settings/ThemeSettings.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<string, unknown>) => {
    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 (
    <div>
      <Typography.Title level={5} style={{ marginBottom: 16 }}>主题设置</Typography.Title>
      <Form form={form} onFinish={handleSave} layout="vertical" style={{ maxWidth: 480 }}>
        <Form.Item name="primary_color" label="主色调">
          <ColorPicker format="hex" />
        </Form.Item>
        <Form.Item name="logo_url" label="Logo URL">
          <Input placeholder="https://example.com/logo.png" />
        </Form.Item>
        <Form.Item name="sidebar_style" label="侧边栏风格">
          <Select options={[{ label: '亮色', value: 'light' }, { label: '暗色', value: 'dark' }]} />
        </Form.Item>

        <Divider>品牌信息</Divider>
        <Form.Item name="brand_name" label="品牌名称">
          <Input placeholder="HMS 健康管理平台" />
        </Form.Item>
        <Form.Item name="brand_slogan" label="品牌标语">
          <Input placeholder="新一代健康管理平台" />
        </Form.Item>
        <Form.Item name="brand_features" label="功能亮点">
          <Input placeholder="患者管理 · 健康监测 · 随访管理 · AI 智能分析" />
        </Form.Item>
        <Form.Item name="brand_copyright" label="版权信息">
          <Input placeholder="HMS 健康管理平台 · ©汕头市智界科技有限公司" />
        </Form.Item>

        <Form.Item>
          <Button type="primary" htmlType="submit" loading={saving}>保存</Button>
        </Form.Item>
      </Form>
    </div>
  );
}
  • Step 5: 验证前端编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 6: 提交
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. 替换硬编码品牌文字

在组件函数体开头添加:

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 行硬编码文字:

<h1 className="brand-title">{brandConfig?.brand_name || 'HMS 健康管理平台'}</h1>
<p className="brand-desc">{brandConfig?.brand_slogan || '新一代健康管理平台'}</p>
<p className="brand-sub-desc">{brandConfig?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}</p>

替换第 106 行版权文字:

<div className="form-footer">
  {brandConfig?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
</div>
  • Step 2: 修改 MainLayout.tsx — Footer 和 Logo 从配置读取

apps/web/src/layouts/MainLayout.tsx 中:

  1. 获取 themeConfig在组件函数体内添加
const themeConfig = useAppStore((s) => s.themeConfig);
  1. 替换第 434 行侧边栏 Logo 文字:
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
  1. 替换第 527 行 Footer 文字:
<Footer className="erp-footer">
  {themeConfig?.brand_copyright || 'HMS 健康管理平台'}
</Footer>
  • Step 3: 验证前端编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 4: 浏览器验证
  1. 打开登录页,确认显示 "HMS 健康管理平台"(默认值)
  2. 登录后进入系统设置 → 主题设置
  3. 修改品牌名称为 "XX 医院健康管理"
  4. 保存后刷新页面,确认侧边栏/页脚更新
  5. 登出后确认登录页显示新品牌名称
  • Step 5: 提交
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 之后)添加:

  AlertOutlined,
  BellOutlined,
  ControlOutlined,
  InboxOutlined,
  ApiOutlined,
  ReadOutlined,
  ExperimentOutlined,
  • Step 2: 在 iconMap 中补充图标映射

iconMap 对象(第 74 行 BarChartOutlined 之后)添加:

  AlertOutlined: <AlertOutlined />,
  BellOutlined: <BellOutlined />,
  ControlOutlined: <ControlOutlined />,
  InboxOutlined: <InboxOutlined />,
  ApiOutlined: <ApiOutlined />,
  ReadOutlined: <ReadOutlined />,
  ExperimentOutlined: <ExperimentOutlined />,
  • Step 3: 验证前端编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 4: 提交
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

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 声明区域):
mod m20260501_000100_seed_action_inbox_menu;
  1. 在 migration list 的最后一项 m20260501_000099_create_ai_risk_threshold::Migration 之后添加:
Box::new(m20260501_000100_seed_action_inbox_menu::Migration),
  • Step 3: 验证编译

Run: cargo check Expected: 编译通过

  • Step 4: 提交
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.rslist_pending_alerts 函数中,找到查询执行的 .await,在错误处理链中添加 tracing

// 找到类似以下代码,添加 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: 提交
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 天:

// 修改前
"SELECT cleanup_old_published_events(90, 1000)"
// 修改后
"SELECT cleanup_old_published_events(7, 1000)"

同时更新第 6 行注释:

/// - 调用 `cleanup_old_published_events()` 归档 >7 天的已发布事件
  • Step 2: 手动清理当前堆积

在数据库中执行 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: 提交
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 — 推送到远程

Chunk 2: Phase 2 — 体验补全 (#7-#11)

Task 9: 患者详情快捷导航卡片

Files:

  • Modify: apps/web/src/pages/health/PatientDetail.tsx:222-224

  • Step 1: 在 PatientDetail.tsx 插入快捷导航卡片

在第 222 行(患者信息卡片 </Card> 闭合标签)和第 224 行Tabs <Card> 开始标签)之间插入:

      {/* 快捷导航 */}
      <Card style={{ ...cardStyle, marginBottom: 16, padding: '12px 16px' }}>
        <Space size={8} wrap>
          <Text type="secondary" style={{ marginRight: 8 }}>快捷跳转:</Text>
          <Button type="link" size="small" onClick={() => navigate(`/health/appointments?patient_id=${id}`)}>预约记录</Button>
          <Button type="link" size="small" onClick={() => navigate(`/health/consultations?patient_id=${id}`)}>咨询记录</Button>
          <Button type="link" size="small" onClick={() => navigate(`/health/dialysis?patient_id=${id}`)}>透析记录</Button>
          <Button type="link" size="small" onClick={() => navigate(`/health/follow-up-tasks?patient_id=${id}`)}>随访任务</Button>
          <Button type="link" size="small" onClick={() => navigate(`/health/ai-analysis?patient_id=${id}`)}>AI 分析</Button>
        </Space>
      </Card>

确认 ButtonSpaceText 已在文件顶部 import通常已导入。确认 navigate 来自 useNavigate()

  • Step 2: 验证编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 3: 提交
git add apps/web/src/pages/health/PatientDetail.tsx
git commit -m "feat(web): 患者详情页增加快捷导航卡片 — 预约/咨询/透析/随访/AI"

Task 10: 列表页面支持 URL 参数 patient_id 过滤

Files:

  • Modify: apps/web/src/pages/health/AppointmentList.tsx
  • Modify: apps/web/src/pages/health/ConsultationList.tsx
  • Modify: apps/web/src/pages/health/DialysisManageList.tsx
  • Modify: apps/web/src/pages/health/FollowUpTaskList.tsx
  • Modify: apps/web/src/pages/health/AiAnalysisList.tsx

这 5 个页面需要支持从 URL 读取 patient_id 参数作为默认筛选条件。统一模式如下:

  • Step 1: 逐一检查各页面当前的筛选逻辑

对每个页面,确认:

  • 是否已使用 useSearchParams()useLocation() 读取 URL 参数
  • 筛选器状态如何初始化useState 初始值)
  • API 调用的参数传递方式

此步骤为调研,不修改代码。根据调研结果决定每个页面的具体修改方案。

  • Step 2: 为每个页面添加 URL patient_id 读取

统一模式:在组件初始化时从 URL 读取 patient_id,设为筛选器默认值。

import { useSearchParams } from 'react-router-dom';

// 在组件函数体内
const [searchParams] = useSearchParams();
const urlPatientId = searchParams.get('patient_id');

// 在筛选器状态中设置默认值
const [filters, setFilters] = useState({
  // ... 已有筛选器
  patient_id: urlPatientId || undefined,
});

如果页面使用 PatientSelect 组件,将 urlPatientId 作为初始值传入。

  • Step 3: 验证编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 4: 提交
git add apps/web/src/pages/health/AppointmentList.tsx \
        apps/web/src/pages/health/ConsultationList.tsx \
        apps/web/src/pages/health/DialysisManageList.tsx \
        apps/web/src/pages/health/FollowUpTaskList.tsx \
        apps/web/src/pages/health/AiAnalysisList.tsx
git commit -m "feat(web): 5 个列表页支持 URL 参数 patient_id 自动筛选"

Files:

  • Modify: apps/web/src/pages/health/AiAnalysisList.tsx:1-2,316-323

  • Step 1: 添加 Link 导入

apps/web/src/pages/health/AiAnalysisList.tsx 顶部 import 区域添加:

import { Link } from 'react-router-dom';
  • Step 2: 修改 patient_id 列渲染

将第 316-323 行的列定义从:

{
  title: '患者 ID',
  dataIndex: 'patient_id',
  key: 'patient_id',
  width: 120,
  render: (v: string) => (
    <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v.slice(0, 8)}</span>
  ),
},

改为:

{
  title: '患者',
  dataIndex: 'patient_id',
  key: 'patient_id',
  width: 140,
  render: (v: string) => (
    <Link to={`/health/patients/${v}`} style={{ fontFamily: 'monospace', fontSize: 12 }}>
      {v.slice(0, 8)}
    </Link>
  ),
},
  • Step 3: 验证编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 4: 提交
git add apps/web/src/pages/health/AiAnalysisList.tsx
git commit -m "feat(web): AI 分析列表患者 ID 改为可点击 Link 跳转详情"

Task 12: 小程序 AI 建议跳转修复

Files:

  • Modify: apps/miniprogram/src/pages/health/index.tsx:178

  • Step 1: 修改 AI 建议卡片 onClick

apps/miniprogram/src/pages/health/index.tsx 第 178 行,将:

<View className='ai-suggestion-card' onClick={() => Taro.navigateTo({ url: '/pages/pkg-profile/settings/index' })}>

改为:

<View className='ai-suggestion-card' onClick={() => {
  const firstSuggestion = aiSuggestions[0];
  if (firstSuggestion?.suggestion_type === 'appointment') {
    Taro.navigateTo({ url: `/pages/pkg-appointment/create/index?patientId=${firstSuggestion.patient_id}` });
  } else if (firstSuggestion?.suggestion_type === 'followup') {
    Taro.navigateTo({ url: `/pages/pkg-profile/followups/index` });
  } else {
    Taro.navigateTo({ url: '/pages/health/index' });
  }
}}>

注意: 卡片整体只有一个 onClick跳转基于第一条建议的类型。如果需要每条建议单独点击需要将 onClick 移到 ai-suggestion-item 上并传入具体 item。

  • Step 2: 验证小程序编译

Run: cd apps/miniprogram && pnpm build:weapp Expected: 构建成功

  • Step 3: 提交
git add apps/miniprogram/src/pages/health/index.tsx
git commit -m "fix(miniprogram): AI 建议卡片按 suggestion_type 跳转 — 而非统一跳设置页"

Task 13: 小程序通知 Tab 对接消息 API

Files:

  • Create: apps/miniprogram/src/services/notification.ts

  • Modify: apps/miniprogram/src/pages/messages/index.tsx:34-38

  • Step 1: 创建通知服务

创建 apps/miniprogram/src/services/notification.ts

import { api } from './request';

export const notificationService = {
  list: (params?: { page?: number; page_size?: number }) =>
    api.get('/messages', params as Record<string, string | number | undefined>),
  markRead: (id: string) =>
    api.put(`/messages/${id}/read`),
  markAllRead: () =>
    api.put('/messages/read-all'),
  getUnreadCount: () =>
    api.get('/messages/unread-count'),
};
  • Step 2: 替换硬编码空数组

apps/miniprogram/src/pages/messages/index.tsx 中:

  1. 在顶部添加导入:
import { notificationService } from '../../services/notification';
  1. 将第 35-38 行的 else 分支从:
} else {
  // 通知目前从咨询中提取状态变化作为示例
  // 后续可对接独立通知 API
  setNotifications([]);
}

改为:

} else {
  const res = await notificationService.list({ page: 1, page_size: 20 });
  setNotifications(res?.data || []);
}
  • Step 3: 验证小程序编译

Run: cd apps/miniprogram && pnpm build:weapp Expected: 构建成功

  • Step 4: 提交
git add apps/miniprogram/src/services/notification.ts apps/miniprogram/src/pages/messages/index.tsx
git commit -m "feat(miniprogram): 通知 Tab 对接 erp-message 消息 API — 替换空壳"

Task 14: 小程序咨询功能入口

Files:

  • Modify: apps/miniprogram/src/pages/profile/index.tsx:8-22,24-38

  • Step 1: 在 MENU_ITEMS 添加在线咨询

apps/miniprogram/src/pages/profile/index.tsxMENU_ITEMS 数组(第 21 行 "设置" 之前)添加:

  { label: '我的预约', icon: '📅', bg: '#E8F0F8' },
  { label: '在线咨询', icon: '💬', bg: '#E8F0E8' },
  • Step 2: 在 MENU_PATHS 添加路径映射

MENU_PATHS(第 24-38 行)添加对应路径:

'我的预约': '/pages/pkg-appointment/index',
'在线咨询': '/pages/consultation/index',

注意: 需确认 /pages/pkg-appointment/index 路径存在。如果预约页面路径不同,按实际路径填写。

  • Step 3: 验证小程序编译

Run: cd apps/miniprogram && pnpm build:weapp Expected: 构建成功

  • Step 4: 提交
git add apps/miniprogram/src/pages/profile/index.tsx
git commit -m "feat(miniprogram): 个人中心添加我的预约+在线咨询入口"

Phase 2 验证清单

完成所有 Task 后执行:

  • cd apps/web && pnpm build — Web 前端构建通过
  • cd apps/miniprogram && pnpm build:weapp — 小程序构建通过
  • 浏览器验证:患者详情快捷导航 → 目标页面自动过滤
  • 浏览器验证AI 分析列表患者 Link 跳转正常
  • 小程序验证AI 建议卡片跳转到正确页面(非设置页)
  • 小程序验证:消息页通知 Tab 展示后端消息数据
  • 小程序验证:个人中心出现"在线咨询"入口
  • git push — 推送到远程