1704 lines
50 KiB
Markdown
1704 lines
50 KiB
Markdown
# 三端联调审计问题修复实施计划
|
||
|
||
> **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<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()` 函数:
|
||
|
||
```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<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` 等),添加:
|
||
|
||
```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<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:
|
||
|
||
```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<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 附近添加:
|
||
|
||
```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<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: 提交**
|
||
|
||
```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
|
||
<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 行版权文字:
|
||
|
||
```tsx
|
||
<div className="form-footer">
|
||
{brandConfig?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **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
|
||
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
|
||
```
|
||
|
||
3. 替换第 527 行 Footer 文字:
|
||
```tsx
|
||
<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: 提交**
|
||
|
||
```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: <AlertOutlined />,
|
||
BellOutlined: <BellOutlined />,
|
||
ControlOutlined: <ControlOutlined />,
|
||
InboxOutlined: <InboxOutlined />,
|
||
ApiOutlined: <ApiOutlined />,
|
||
ReadOutlined: <ReadOutlined />,
|
||
ExperimentOutlined: <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` — 推送到远程
|
||
|
||
---
|
||
|
||
## 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>` 开始标签)之间插入:
|
||
|
||
```tsx
|
||
{/* 快捷导航 */}
|
||
<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>
|
||
```
|
||
|
||
确认 `Button`、`Space`、`Text` 已在文件顶部 import(通常已导入)。确认 `navigate` 来自 `useNavigate()`。
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `cd apps/web && pnpm build`
|
||
Expected: 构建成功
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
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`,设为筛选器默认值。
|
||
|
||
```typescript
|
||
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: 提交**
|
||
|
||
```bash
|
||
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 自动筛选"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: AI 分析列表添加患者 Link
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/pages/health/AiAnalysisList.tsx:1-2,316-323`
|
||
|
||
- [ ] **Step 1: 添加 Link 导入**
|
||
|
||
在 `apps/web/src/pages/health/AiAnalysisList.tsx` 顶部 import 区域添加:
|
||
|
||
```typescript
|
||
import { Link } from 'react-router-dom';
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 patient_id 列渲染**
|
||
|
||
将第 316-323 行的列定义从:
|
||
|
||
```tsx
|
||
{
|
||
title: '患者 ID',
|
||
dataIndex: 'patient_id',
|
||
key: 'patient_id',
|
||
width: 120,
|
||
render: (v: string) => (
|
||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v.slice(0, 8)}</span>
|
||
),
|
||
},
|
||
```
|
||
|
||
改为:
|
||
|
||
```tsx
|
||
{
|
||
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: 提交**
|
||
|
||
```bash
|
||
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 行,将:
|
||
|
||
```tsx
|
||
<View className='ai-suggestion-card' onClick={() => Taro.navigateTo({ url: '/pages/pkg-profile/settings/index' })}>
|
||
```
|
||
|
||
改为:
|
||
|
||
```tsx
|
||
<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: 提交**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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. 在顶部添加导入:
|
||
```typescript
|
||
import { notificationService } from '../../services/notification';
|
||
```
|
||
|
||
2. 将第 35-38 行的 `else` 分支从:
|
||
```typescript
|
||
} else {
|
||
// 通知目前从咨询中提取状态变化作为示例
|
||
// 后续可对接独立通知 API
|
||
setNotifications([]);
|
||
}
|
||
```
|
||
|
||
改为:
|
||
```typescript
|
||
} 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: 提交**
|
||
|
||
```bash
|
||
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.tsx` 的 `MENU_ITEMS` 数组(第 21 行 "设置" 之前)添加:
|
||
|
||
```typescript
|
||
{ label: '我的预约', icon: '📅', bg: '#E8F0F8' },
|
||
{ label: '在线咨询', icon: '💬', bg: '#E8F0E8' },
|
||
```
|
||
|
||
- [ ] **Step 2: 在 MENU_PATHS 添加路径映射**
|
||
|
||
在 `MENU_PATHS`(第 24-38 行)添加对应路径:
|
||
|
||
```typescript
|
||
'我的预约': '/pages/pkg-appointment/index',
|
||
'在线咨询': '/pages/consultation/index',
|
||
```
|
||
|
||
> **注意**: 需确认 `/pages/pkg-appointment/index` 路径存在。如果预约页面路径不同,按实际路径填写。
|
||
|
||
- [ ] **Step 3: 验证小程序编译**
|
||
|
||
Run: `cd apps/miniprogram && pnpm build:weapp`
|
||
Expected: 构建成功
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
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` — 推送到远程
|
||
|
||
---
|
||
|
||
## Chunk 3: Phase 3 — 功能闭环 (#12-#15)
|
||
|
||
### Task 15: SSE 分析 API 封装
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/api/ai/analysisSse.ts`
|
||
|
||
- [ ] **Step 1: 创建 SSE 分析 API 文件**
|
||
|
||
创建 `apps/web/src/api/ai/analysisSse.ts`:
|
||
|
||
```typescript
|
||
import client from '../client';
|
||
|
||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary';
|
||
|
||
interface AnalyzeBody {
|
||
report_id?: string;
|
||
patient_id?: string;
|
||
metrics?: string[];
|
||
}
|
||
|
||
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||
'lab-report': '/ai/analyze/lab-report',
|
||
'trends': '/ai/analyze/trends',
|
||
'checkup-plan': '/ai/analyze/checkup-plan',
|
||
'report-summary': '/ai/analyze/report-summary',
|
||
};
|
||
|
||
export interface SseCallbacks {
|
||
onChunk: (content: string, index: number) => void;
|
||
onError: (message: string) => void;
|
||
onDone: (analysisId: string) => void;
|
||
}
|
||
|
||
export async function startAnalysis(
|
||
type: AnalysisType,
|
||
body: AnalyzeBody,
|
||
callbacks: SseCallbacks,
|
||
): Promise<AbortController> {
|
||
const controller = new AbortController();
|
||
const endpoint = ENDPOINT_MAP[type];
|
||
|
||
const token = localStorage.getItem('hms-token');
|
||
const resp = await fetch(`/api/v1${endpoint}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({ message: '分析请求失败' }));
|
||
callbacks.onError(err?.message || `HTTP ${resp.status}`);
|
||
return controller;
|
||
}
|
||
|
||
const reader = resp.body?.getReader();
|
||
if (!reader) {
|
||
callbacks.onError('无法读取响应流');
|
||
return controller;
|
||
}
|
||
|
||
const decoder = new TextDecoder();
|
||
let chunkIndex = 0;
|
||
let buffer = '';
|
||
|
||
(async () => {
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
const data = line.slice(6);
|
||
if (data === '[DONE]') {
|
||
continue;
|
||
}
|
||
try {
|
||
const event = JSON.parse(data);
|
||
if (event.type === 'chunk' && event.content) {
|
||
callbacks.onChunk(event.content, chunkIndex++);
|
||
} else if (event.type === 'done' && event.analysis_id) {
|
||
callbacks.onDone(event.analysis_id);
|
||
} else if (event.type === 'error') {
|
||
callbacks.onError(event.message || '分析出错');
|
||
}
|
||
} catch {
|
||
// 非 JSON 行,跳过
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (!controller.signal.aborted) {
|
||
callbacks.onError(err instanceof Error ? err.message : '连接中断');
|
||
}
|
||
}
|
||
})();
|
||
|
||
return controller;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `cd apps/web && npx tsc --noEmit`
|
||
Expected: 无类型错误
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/api/ai/analysisSse.ts
|
||
git commit -m "feat(web): SSE 分析 API 封装 — fetch ReadableStream 解析"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: AI 分析触发按钮 — 化验报告页
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/pages/health/components/LabReportsTab.tsx:138-161`
|
||
|
||
- [ ] **Step 1: 在操作列 Space 中添加 "AI 解读" 按钮**
|
||
|
||
在 `LabReportsTab.tsx` 操作列(第 138-161 行)的 `<Space>` 内,在"审核"按钮之前添加:
|
||
|
||
```tsx
|
||
<AuthButton code="ai.analysis.manage">
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<ThunderboltOutlined />}
|
||
loading={analyzingReportId === record.id}
|
||
onClick={(e) => { e.stopPropagation(); handleAiAnalysis(record.id); }}
|
||
>
|
||
AI 解读
|
||
</Button>
|
||
</AuthButton>
|
||
```
|
||
|
||
- [ ] **Step 2: 添加分析状态和处理函数**
|
||
|
||
在组件函数体中添加:
|
||
|
||
```typescript
|
||
import { startAnalysis } from '../../../api/ai/analysisSse';
|
||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||
|
||
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(null);
|
||
const [analysisContent, setAnalysisContent] = useState('');
|
||
|
||
const handleAiAnalysis = async (reportId: string) => {
|
||
setAnalyzingReportId(reportId);
|
||
setAnalysisContent('');
|
||
await startAnalysis('lab-report', { report_id: reportId }, {
|
||
onChunk: (content) => setAnalysisContent(prev => prev + content),
|
||
onError: (msg) => { message.error(msg); setAnalyzingReportId(null); },
|
||
onDone: (id) => {
|
||
message.success('AI 分析完成');
|
||
setAnalyzingReportId(null);
|
||
},
|
||
});
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: 添加 AI 分析结果展示**
|
||
|
||
在 Table 下方添加条件渲染区域:
|
||
|
||
```tsx
|
||
{analysisContent && (
|
||
<Card title="AI 解读结果" style={{ marginTop: 16 }} size="small">
|
||
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
|
||
</Card>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 4: 验证编译**
|
||
|
||
Run: `cd apps/web && pnpm build`
|
||
Expected: 构建成功
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/components/LabReportsTab.tsx
|
||
git commit -m "feat(web): 化验报告页添加 AI 解读按钮 — SSE 流式分析"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: AI 分析触发按钮 — 患者详情页
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/pages/health/PatientDetail.tsx`
|
||
|
||
- [ ] **Step 1: 在 AI 建议标签页上方添加分析触发按钮**
|
||
|
||
在 `PatientDetail.tsx` 的 Tabs 卡片中,AI 建议标签页(key='ai')的 children 内,或通过修改 `AiSuggestionTab` 组件添加操作按钮。
|
||
|
||
推荐方案:在 PatientDetail 的 tabs items 中,AI 标签页之前添加操作:
|
||
|
||
在 `ai` tab 的 children 中包裹一个带按钮的 Fragment:
|
||
|
||
```tsx
|
||
{
|
||
key: 'ai',
|
||
label: 'AI 建议',
|
||
children: id ? (
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
<Space>
|
||
<AuthButton code="ai.analysis.manage">
|
||
<Button size="small" onClick={() => triggerAnalysis('trends')}>
|
||
趋势分析
|
||
</Button>
|
||
<Button size="small" onClick={() => triggerAnalysis('checkup-plan')}>
|
||
体检方案
|
||
</Button>
|
||
</AuthButton>
|
||
</Space>
|
||
<AiSuggestionTab patientId={id} />
|
||
{analysisResult && (
|
||
<Card title="分析结果" size="small">
|
||
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisResult}</div>
|
||
</Card>
|
||
)}
|
||
</Space>
|
||
) : null,
|
||
},
|
||
```
|
||
|
||
添加对应的状态和处理函数:
|
||
|
||
```typescript
|
||
import { startAnalysis, type AnalysisType } from '../../api/ai/analysisSse';
|
||
|
||
const [analysisResult, setAnalysisResult] = useState('');
|
||
const [analyzing, setAnalyzing] = useState(false);
|
||
|
||
const triggerAnalysis = async (type: AnalysisType) => {
|
||
if (!id) return;
|
||
setAnalyzing(true);
|
||
setAnalysisResult('');
|
||
await startAnalysis(type, { patient_id: id }, {
|
||
onChunk: (content) => setAnalysisResult(prev => prev + content),
|
||
onError: (msg) => { message.error(msg); setAnalyzing(false); },
|
||
onDone: () => { message.success('分析完成'); setAnalyzing(false); },
|
||
});
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `cd apps/web && pnpm build`
|
||
Expected: 构建成功
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/PatientDetail.tsx
|
||
git commit -m "feat(web): 患者详情 AI 标签页添加趋势分析+体检方案触发按钮"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: 家属管理 Tab 组件
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/components/FamilyMembersTab.tsx`
|
||
- Modify: `apps/web/src/pages/health/PatientDetail.tsx:228-306`
|
||
|
||
- [ ] **Step 1: 创建 FamilyMembersTab 组件**
|
||
|
||
创建 `apps/web/src/pages/health/components/FamilyMembersTab.tsx`:
|
||
|
||
```tsx
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { Table, Button, Form, Input, Select, Drawer, message, Popconfirm, Space } from 'antd';
|
||
import { PlusOutlined } from '@ant-design/icons';
|
||
import { patientApi, type FamilyMember, type CreateFamilyMemberReq } from '../../../api/health/patients';
|
||
import { AuthButton } from '../../../components/AuthButton';
|
||
|
||
const RELATIONSHIP_OPTIONS = [
|
||
{ label: '父母', value: 'parent' },
|
||
{ label: '配偶', value: 'spouse' },
|
||
{ label: '子女', value: 'child' },
|
||
{ label: '兄弟姐妹', value: 'sibling' },
|
||
{ label: '其他', value: 'other' },
|
||
];
|
||
|
||
interface Props {
|
||
patientId: string;
|
||
}
|
||
|
||
export function FamilyMembersTab({ patientId }: Props) {
|
||
const [members, setMembers] = useState<FamilyMember[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [editingMember, setEditingMember] = useState<FamilyMember | null>(null);
|
||
const [form] = Form.useForm();
|
||
|
||
const fetchMembers = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await patientApi.listFamilyMembers(patientId);
|
||
setMembers(data);
|
||
} catch {
|
||
message.error('加载家属列表失败');
|
||
}
|
||
setLoading(false);
|
||
}, [patientId]);
|
||
|
||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||
|
||
const handleSubmit = async (values: CreateFamilyMemberReq) => {
|
||
try {
|
||
if (editingMember) {
|
||
await patientApi.updateFamilyMember(patientId, editingMember.id, { ...values, version: editingMember.version });
|
||
message.success('家属信息已更新');
|
||
} else {
|
||
await patientApi.createFamilyMember(patientId, values);
|
||
message.success('家属已添加');
|
||
}
|
||
setDrawerOpen(false);
|
||
setEditingMember(null);
|
||
form.resetFields();
|
||
fetchMembers();
|
||
} catch (err: unknown) {
|
||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||
message.error(msg);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (memberId: string) => {
|
||
try {
|
||
await patientApi.deleteFamilyMember(patientId, memberId);
|
||
message.success('已删除');
|
||
fetchMembers();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
};
|
||
|
||
const openCreate = () => {
|
||
setEditingMember(null);
|
||
form.resetFields();
|
||
setDrawerOpen(true);
|
||
};
|
||
|
||
const openEdit = (member: FamilyMember) => {
|
||
setEditingMember(member);
|
||
form.setFieldsValue({
|
||
name: member.name,
|
||
relationship: member.relationship,
|
||
phone: member.phone,
|
||
id_number: member.id_number,
|
||
notes: member.notes,
|
||
});
|
||
setDrawerOpen(true);
|
||
};
|
||
|
||
const columns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||
{
|
||
title: '关系', dataIndex: 'relationship', key: 'relationship',
|
||
render: (v: string) => RELATIONSHIP_OPTIONS.find(r => r.value === v)?.label || v,
|
||
},
|
||
{ title: '电话', dataIndex: 'phone', key: 'phone' },
|
||
{ title: '身份证号', dataIndex: 'id_number', key: 'id_number' },
|
||
{ title: '备注', dataIndex: 'notes', key: 'notes', ellipsis: true },
|
||
{
|
||
title: '操作', key: 'actions', width: 150,
|
||
render: (_: unknown, record: FamilyMember) => (
|
||
<Space>
|
||
<AuthButton code="health.patient.manage">
|
||
<Button type="link" size="small" onClick={() => openEdit(record)}>编辑</Button>
|
||
</AuthButton>
|
||
<AuthButton code="health.patient.manage">
|
||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button type="link" size="small" danger>删除</Button>
|
||
</Popconfirm>
|
||
</AuthButton>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<AuthButton code="health.patient.manage">
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>添加家属</Button>
|
||
</AuthButton>
|
||
</div>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={members}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={false}
|
||
size="small"
|
||
/>
|
||
<Drawer
|
||
title={editingMember ? '编辑家属' : '添加家属'}
|
||
open={drawerOpen}
|
||
onClose={() => { setDrawerOpen(false); setEditingMember(null); }}
|
||
width={400}
|
||
>
|
||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="relationship" label="关系" rules={[{ required: true, message: '请选择关系' }]}>
|
||
<Select options={RELATIONSHIP_OPTIONS} placeholder="选择关系" />
|
||
</Form.Item>
|
||
<Form.Item name="phone" label="电话">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="id_number" label="身份证号">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">{editingMember ? '更新' : '添加'}</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 PatientDetail.tsx 注册家属管理标签**
|
||
|
||
在 `PatientDetail.tsx` 的 Tabs items 数组中,在 "基本信息"(key='info')之后添加:
|
||
|
||
```typescript
|
||
import { FamilyMembersTab } from './components/FamilyMembersTab';
|
||
|
||
// 在 items 数组中 key='info' 项之后添加
|
||
{
|
||
key: 'family',
|
||
label: '家属管理',
|
||
children: id ? <FamilyMembersTab patientId={id} /> : null,
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: 验证编译**
|
||
|
||
Run: `cd apps/web && pnpm build`
|
||
Expected: 构建成功
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/components/FamilyMembersTab.tsx apps/web/src/pages/health/PatientDetail.tsx
|
||
git commit -m "feat(web): 家属管理 Tab — 列表+添加/编辑/删除家属"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: E2E 测试数据清理
|
||
|
||
**Files:**
|
||
- Create: `apps/web/e2e/fixtures/cleanup.ts`
|
||
- Modify: `apps/web/e2e/flows/patient-journey.spec.ts`
|
||
|
||
- [ ] **Step 1: 创建清理 fixture**
|
||
|
||
创建 `apps/web/e2e/fixtures/cleanup.ts`:
|
||
|
||
```typescript
|
||
import type { ApiClient } from './api-client';
|
||
|
||
export async function cleanupE2EData(api: ApiClient): Promise<void> {
|
||
try {
|
||
const res = await api.get('/api/v1/health/patients?keyword=E2E');
|
||
const patients = res?.data?.data || [];
|
||
for (const p of patients) {
|
||
if (p.name?.startsWith('E2E')) {
|
||
await api.delete(`/api/v1/health/patients/${p.id}`);
|
||
}
|
||
}
|
||
} catch {
|
||
// 静默失败,不阻塞测试
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 patient-journey.spec.ts 中添加 afterAll**
|
||
|
||
在 `apps/web/e2e/flows/patient-journey.spec.ts` 中添加清理导入和 afterAll 钩子:
|
||
|
||
```typescript
|
||
import { cleanupE2EData } from '../fixtures/cleanup';
|
||
|
||
afterAll(async () => {
|
||
// 使用测试中已有的 api client 实例
|
||
if (apiClient) await cleanupE2EData(apiClient);
|
||
});
|
||
```
|
||
|
||
> 对其他创建 E2E 数据的 spec 文件(follow-up-flow、appointment-flow 等)同样添加 afterAll 清理。
|
||
|
||
- [ ] **Step 3: 手动清理现有污染数据**
|
||
|
||
在数据库中执行:
|
||
|
||
```sql
|
||
UPDATE patients SET deleted_at = NOW(), updated_at = NOW()
|
||
WHERE name LIKE 'E2E患者_%' AND deleted_at IS NULL;
|
||
```
|
||
|
||
> 此步骤需要手动执行 SQL。
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add apps/web/e2e/fixtures/cleanup.ts apps/web/e2e/flows/patient-journey.spec.ts
|
||
git commit -m "test(e2e): 添加 E2E 测试数据清理 fixture — afterAll 自动 teardown"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: 统计仪表盘消费验证
|
||
|
||
**Files:**
|
||
- Read-only verification (可能修改 `DoctorDashboard.tsx` 或 `NurseDashboard.tsx`)
|
||
|
||
- [ ] **Step 1: 对比后端 personal_stats DTO 与前端展示**
|
||
|
||
读取后端 `crates/erp-health/src/handler/points_handler.rs`(或 stats_handler.rs)中 `personal_stats` 返回的字段列表。
|
||
|
||
对比 `DoctorDashboard.tsx`(第 43 行起)和 `NurseDashboard.tsx`(第 20 行起)实际展示的字段。
|
||
|
||
记录哪些字段被展示、哪些被遗漏。
|
||
|
||
- [ ] **Step 2: 补充遗漏字段(如有)**
|
||
|
||
如果有遗漏字段,在对应仪表盘组件中补充展示。例如:
|
||
|
||
```tsx
|
||
// 如果 abnormal_vital_signs 被遗漏
|
||
<Statistic title="异常体征患者" value={personalStats.abnormal_vital_signs || 0} />
|
||
```
|
||
|
||
- [ ] **Step 3: 提交(如有修改)**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/StatisticsDashboard/
|
||
git commit -m "feat(web): 补充统计仪表盘遗漏的个人统计指标"
|
||
```
|
||
|
||
如果无遗漏则无需提交,记录验证结果即可。
|
||
|
||
---
|
||
|
||
## Phase 3 验证清单
|
||
|
||
完成所有 Task 后执行:
|
||
|
||
- [ ] `cd apps/web && pnpm build` — Web 前端构建通过
|
||
- [ ] 浏览器验证:化验报告页"AI 解读"按钮 → SSE 流式显示分析结果
|
||
- [ ] 浏览器验证:患者详情"趋势分析"按钮 → SSE 分析完成
|
||
- [ ] 浏览器验证:患者详情"家属管理"Tab → 添加/编辑/删除家属
|
||
- [ ] 浏览器验证:统计报表页所有指标正常展示
|
||
- [ ] E2E 清理 fixture 可正常工作
|
||
- [ ] `git push` — 推送到远程
|
||
|
||
---
|
||
|
||
## 总工作量汇总
|
||
|
||
| Phase | Tasks | 工作量 |
|
||
|-------|-------|--------|
|
||
| Phase 1 | #1-#6 (Task 1-8) | ~5h |
|
||
| Phase 2 | #7-#11 (Task 9-14) | ~5.5h |
|
||
| Phase 3 | #12-#15 (Task 15-20) | ~9h |
|
||
| **总计** | **15 项修复 / 20 个 Task** | **~19.5h** |
|