# 切片 2: AI 管理端 3 页面 实施计划 > **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:** 实现 AI 模块的 PC 管理端 — Prompt 管理、分析历史、用量统计 3 个页面,以及对应的后端 API 补全。 **Architecture:** 后端 4 个 SSE 端点已可用,但 Prompt CRUD / 分析历史查询 / 用量统计端点为空壳或缺失。先补全后端 API(handler + service 方法),再实现前端 API 封装和 3 个管理页面,最后注册菜单和路由。 **Tech Stack:** Rust/Axum (后端) + React 19/TypeScript/Ant Design 6 (前端) **设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §3 --- ## Chunk 1: 后端 API 补全 ### Task 1: PromptService — 补全 CRUD 方法 **Files:** - Modify: `crates/erp-ai/src/service/prompt.rs` **现状:** 仅有 `get_active_prompt` + `create_prompt`。需新增 `list_prompts`、`update_prompt`、`activate_prompt`、`rollback_prompt`。 - [ ] **Step 1: 添加 list_prompts 方法** 在 `PromptService` impl 中追加: ```rust use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set}; use erp_core::types::Pagination; pub async fn list_prompts( &self, tenant_id: Uuid, category: Option, pagination: Pagination, ) -> AiResult<(Vec, u64)> { let mut query = ai_prompt::Entity::find() .filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::DeletedAt.is_null()); if let Some(cat) = category { query = query.filter(ai_prompt::Column::Category.eq(cat)); } let total = query.clone().count(&self.db).await?; let items = query .order_by_desc(ai_prompt::Column::UpdatedAt) .offset(pagination.offset()) .limit(pagination.limit()) .all(&self.db) .await?; Ok((items, total)) } ``` - [ ] **Step 2: 添加 update_prompt 方法** ```rust pub async fn update_prompt( &self, id: Uuid, tenant_id: Uuid, user_id: Uuid, system_prompt: Option, user_prompt_template: Option, model_config: Option, description: Option, ) -> AiResult { let entity = ai_prompt::Entity::find_by_id(id) .one(&self.db) .await? .ok_or_else(|| AiError::PromptNotFound(id.to_string()))?; if entity.tenant_id != tenant_id { return Err(AiError::Validation("跨租户操作".into())); } // 创建新版本 let new_id = Uuid::now_v7(); let now = chrono::Utc::now(); let active = ai_prompt::ActiveModel { id: Set(new_id), tenant_id: Set(tenant_id), name: Set(entity.name.clone()), description: Set(description.unwrap_or(entity.description.clone())), system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())), user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())), variables_schema: Set(entity.variables_schema.clone()), model_config: Set(model_config.unwrap_or(entity.model_config.clone())), version: Set(entity.version + 1), is_active: Set(entity.is_active), category: Set(entity.category.clone()), tags: Set(entity.tags.clone()), created_at: Set(now), updated_at: Set(now), created_by: Set(Some(user_id)), updated_by: Set(Some(user_id)), deleted_at: Set(None), version_lock: Set(1), }; Ok(active.insert(&self.db).await?) } ``` - [ ] **Step 3: 添加 activate_prompt 方法** ```rust pub async fn activate_prompt( &self, id: Uuid, tenant_id: Uuid, ) -> AiResult { let entity = ai_prompt::Entity::find_by_id(id) .one(&self.db) .await? .ok_or_else(|| AiError::PromptNotFound(id.to_string()))?; if entity.tenant_id != tenant_id { return Err(AiError::Validation("跨租户操作".into())); } // 停用同 name + category 的其他版本 let siblings = ai_prompt::Entity::find() .filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::Name.eq(&entity.name)) .filter(ai_prompt::Column::Category.eq(&entity.category)) .filter(ai_prompt::Column::IsActive.eq(true)) .filter(ai_prompt::Column::DeletedAt.is_null()) .all(&self.db) .await?; for sibling in siblings { let mut active: ai_prompt::ActiveModel = sibling.into(); active.is_active = Set(false); active.updated_at = Set(chrono::Utc::now()); active.update(&self.db).await?; } // 激活目标 let mut active: ai_prompt::ActiveModel = entity.into(); active.is_active = Set(true); active.updated_at = Set(chrono::Utc::now()); Ok(active.update(&self.db).await?) } ``` - [ ] **Step 4: 添加 rollback_prompt(激活指定旧版本)** ```rust pub async fn rollback_prompt( &self, id: Uuid, tenant_id: Uuid, ) -> AiResult { // 回滚 = 激活指定版本 self.activate_prompt(id, tenant_id).await } ``` - [ ] **Step 5: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 6: 提交** ```bash git add crates/erp-ai/src/service/prompt.rs git commit -m "feat(ai): PromptService 补全 list/update/activate/rollback 方法" ``` --- ### Task 2: AnalysisService — 补全查询方法 **Files:** - Modify: `crates/erp-ai/src/service/analysis.rs` **现状:** 有 `stream_analyze`、`complete_analysis`、`fail_analysis`、`find_cached`。需新增 `list_analysis`、`get_analysis`。 - [ ] **Step 1: 添加 list_analysis 方法** 在 `AnalysisService` impl 中追加: ```rust use erp_core::types::Pagination; pub async fn list_analysis( &self, tenant_id: Uuid, patient_id: Option, analysis_type: Option, pagination: Pagination, ) -> AiResult<(Vec, u64)> { let mut query = ai_analysis::Entity::find() .filter(ai_analysis::Column::TenantId.eq(tenant_id)) .filter(ai_analysis::Column::DeletedAt.is_null()); if let Some(pid) = patient_id { query = query.filter(ai_analysis::Column::PatientId.eq(pid)); } if let Some(at) = analysis_type { query = query.filter(ai_analysis::Column::AnalysisType.eq(at)); } let total = query.clone().count(&self.db).await?; let items = query .order_by_desc(ai_analysis::Column::CreatedAt) .offset(pagination.offset()) .limit(pagination.limit()) .all(&self.db) .await?; Ok((items, total)) } ``` - [ ] **Step 2: 添加 get_analysis 方法** ```rust pub async fn get_analysis( &self, id: Uuid, tenant_id: Uuid, ) -> AiResult { ai_analysis::Entity::find_by_id(id) .one(&self.db) .await? .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) .ok_or_else(|| AiError::AnalysisNotFound(id.to_string())) } ``` 注意:上面 filter 需改写为 match 式: ```rust pub async fn get_analysis( &self, id: Uuid, tenant_id: Uuid, ) -> AiResult { let model = ai_analysis::Entity::find_by_id(id) .one(&self.db) .await? .ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?; if model.tenant_id != tenant_id { return Err(AiError::AnalysisNotFound(id.to_string())); } Ok(model) } ``` - [ ] **Step 3: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-ai/src/service/analysis.rs git commit -m "feat(ai): AnalysisService 补全 list/get 查询方法" ``` --- ### Task 3: UsageService — 补全聚合查询方法 **Files:** - Modify: `crates/erp-ai/src/service/usage.rs` **现状:** 仅有 `log_usage`。需新增 `get_overview`、`get_trend`、`get_by_type`。 **注意:** `ai_usage_logs` 表无 `created_by` 字段,用户排行从 `ai_analysis_results.created_by` 聚合。 - [ ] **Step 1: 添加 get_overview 方法** ```rust use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, FromQueryResult, QuerySelect, Func}; use crate::entity::ai_analysis; #[derive(Debug, FromQueryResult)] pub struct UsageOverview { pub total_count: i64, pub total_input_tokens: i64, pub total_output_tokens: i64, } pub async fn get_overview( &self, tenant_id: Uuid, ) -> AiResult { let result = ai_analysis::Entity::find() .filter(ai_analysis::Column::TenantId.eq(tenant_id)) .filter(ai_analysis::Column::Status.eq("completed")) .filter(ai_analysis::Column::DeletedAt.is_null()) .select_only() .column_as(ai_analysis::Column::Id.count(), "total_count") .into_model::() .one(&self.db) .await? .unwrap_or(UsageOverview { total_count: 0, total_input_tokens: 0, total_output_tokens: 0, }); Ok(result) } ``` - [ ] **Step 2: 添加 get_by_type 方法** ```rust #[derive(Debug, FromQueryResult)] pub struct TypeCount { pub analysis_type: String, pub count: i64, } pub async fn get_by_type( &self, tenant_id: Uuid, ) -> AiResult> { let result = ai_analysis::Entity::find() .filter(ai_analysis::Column::TenantId.eq(tenant_id)) .filter(ai_analysis::Column::Status.eq("completed")) .filter(ai_analysis::Column::DeletedAt.is_null()) .select_only() .column(ai_analysis::Column::AnalysisType) .column_as(ai_analysis::Column::Id.count(), "count") .group_by(ai_analysis::Column::AnalysisType) .into_model::() .all(&self.db) .await?; Ok(result) } ``` - [ ] **Step 3: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-ai/src/service/usage.rs git commit -m "feat(ai): UsageService 补全 get_overview/get_by_type 聚合方法" ``` --- ### Task 4: Handler — 补全路由端点 **Files:** - Modify: `crates/erp-ai/src/handler/mod.rs` - Modify: `crates/erp-ai/src/module.rs` (路由注册) **现状:** - 4 个 SSE 端点:可用 - `list_analysis` / `get_analysis`:空壳(返回 `ApiResponse::ok(())`) - Prompt CRUD、用量统计:完全缺失 - [ ] **Step 1: 实现 list_analysis 真实查询** 替换 `handler/mod.rs` 中的 `list_analysis` 函数(第 272-283 行): ```rust pub async fn list_analysis( State(state): State, Extension(ctx): Extension, Query(params): Query, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.analysis.list")?; let pagination = erp_core::types::Pagination::new( params.page.unwrap_or(1), params.page_size.unwrap_or(20), ); let (items, total) = state.analysis.list_analysis( ctx.tenant_id, params.patient_id, params.analysis_type, pagination, ).await?; let data = serde_json::json!({ "data": items, "total": total, "page": pagination.page, "page_size": pagination.page_size, }); Ok(Json(ApiResponse::ok(data))) } ``` - [ ] **Step 2: 实现 get_analysis 真实查询** 替换 `get_analysis` 函数(第 285-296 行): ```rust pub async fn get_analysis( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.analysis.list")?; let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(analysis))) } ``` 需在文件顶部添加 `use crate::entity::ai_analysis;`。 - [ ] **Step 3: 新增 Prompt CRUD handler 函数** 在 handler/mod.rs 中添加以下函数(分析历史之后): ```rust // === Prompt 管理 === #[derive(Debug, Deserialize)] pub struct ListPromptsQuery { pub category: Option, pub page: Option, pub page_size: Option, } pub async fn list_prompts( State(state): State, Extension(ctx): Extension, Query(params): Query, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.prompt.list")?; let pagination = erp_core::types::Pagination::new( params.page.unwrap_or(1), params.page_size.unwrap_or(20), ); let (items, total) = state.prompt.list_prompts( ctx.tenant_id, params.category, pagination, ).await?; Ok(Json(ApiResponse::ok(serde_json::json!({ "data": items, "total": total, "page": pagination.page, "page_size": pagination.page_size, })))) } #[derive(Debug, Deserialize)] pub struct CreatePromptBody { pub name: String, pub description: Option, pub system_prompt: String, pub user_prompt_template: String, pub model_config: serde_json::Value, pub category: String, } pub async fn create_prompt( State(state): State, Extension(ctx): Extension, Json(body): Json, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.prompt.manage")?; let prompt = state.prompt.create_prompt( ctx.tenant_id, ctx.user_id, body.name, body.system_prompt, body.user_prompt_template, body.model_config, body.category, ).await?; Ok(Json(ApiResponse::ok(prompt))) } pub async fn activate_prompt( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.prompt.manage")?; let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(prompt))) } pub async fn rollback_prompt( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.prompt.manage")?; let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(prompt))) } ``` 需在文件顶部添加 `use crate::entity::ai_prompt;`。 - [ ] **Step 4: 新增用量统计 handler 函数** ```rust // === 用量统计 === pub async fn usage_overview( State(state): State, Extension(ctx): Extension, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.usage.list")?; let overview = state.usage.get_overview(ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(serde_json::json!({ "total_count": overview.total_count, })))) } pub async fn usage_by_type( State(state): State, Extension(ctx): Extension, ) -> Result>>, erp_core::error::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.usage.list")?; let types = state.usage.get_by_type(ctx.tenant_id).await?; let result: Vec = types.into_iter().map(|t| { serde_json::json!({ "analysis_type": t.analysis_type, "count": t.count, }) }).collect(); Ok(Json(ApiResponse::ok(result))) } ``` - [ ] **Step 5: 在 module.rs 注册新路由** 修改 `AiModule::protected_routes`,在现有路由后追加: ```rust .route("/ai/prompts", axum::routing::get(crate::handler::list_prompts)) .route("/ai/prompts", axum::routing::post(crate::handler::create_prompt)) .route("/ai/prompts/{id}/activate", axum::routing::post(crate::handler::activate_prompt)) .route("/ai/prompts/{id}/rollback", axum::routing::post(crate::handler::rollback_prompt)) .route("/ai/usage/overview", axum::routing::get(crate::handler::usage_overview)) .route("/ai/usage/by-type", axum::routing::get(crate::handler::usage_by_type)) ``` - [ ] **Step 6: 编译验证** Run: `cargo check -p erp-ai && cargo check -p erp-server` Expected: 编译通过 - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs git commit -m "feat(ai): 补全 Prompt CRUD + 分析历史 + 用量统计 handler 和路由" ``` --- ## Chunk 2: 前端 API 封装 ### Task 5: 创建 AI API service 文件 **Files:** - Create: `apps/web/src/api/ai/prompts.ts` - Create: `apps/web/src/api/ai/analysis.ts` - Create: `apps/web/src/api/ai/usage.ts` - [ ] **Step 1: 创建 prompts.ts** `apps/web/src/api/ai/prompts.ts`: ```typescript import client from '../client'; import type { PaginatedResponse } from '../types'; export interface PromptItem { id: string; name: string; description: string; system_prompt: string; user_prompt_template: string; model_config: Record; version: number; is_active: boolean; category: string; tags: Record | null; created_at: string; updated_at: string; } export interface CreatePromptReq { name: string; description?: string; system_prompt: string; user_prompt_template: string; model_config: Record; category: string; } export const promptApi = { list: async (params?: { category?: string; page?: number; page_size?: number }) => { const resp = await client.get('/ai/prompts', { params }); return resp.data.data as PaginatedResponse; }, create: async (data: CreatePromptReq) => { const resp = await client.post('/ai/prompts', data); return resp.data.data as PromptItem; }, activate: async (id: string) => { const resp = await client.post(`/ai/prompts/${id}/activate`); return resp.data.data as PromptItem; }, rollback: async (id: string) => { const resp = await client.post(`/ai/prompts/${id}/rollback`); return resp.data.data as PromptItem; }, }; ``` - [ ] **Step 2: 创建 analysis.ts** `apps/web/src/api/ai/analysis.ts`: ```typescript import client from '../client'; import type { PaginatedResponse } from '../types'; export interface AnalysisItem { id: string; patient_id: string; analysis_type: string; source_ref: string; model_used: string; status: string; result_content: string | null; result_metadata: Record | null; error_message: string | null; created_at: string; updated_at: string; } export const analysisApi = { list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => { const resp = await client.get('/ai/analysis/history', { params }); return resp.data.data as PaginatedResponse; }, get: async (id: string) => { const resp = await client.get(`/ai/analysis/${id}`); return resp.data.data as AnalysisItem; }, }; ``` - [ ] **Step 3: 创建 usage.ts** `apps/web/src/api/ai/usage.ts`: ```typescript import client from '../client'; export interface UsageOverview { total_count: number; } export interface TypeDistribution { analysis_type: string; count: number; } export const usageApi = { overview: async () => { const resp = await client.get('/ai/usage/overview'); return resp.data.data as UsageOverview; }, byType: async () => { const resp = await client.get('/ai/usage/by-type'); return resp.data.data as TypeDistribution[]; }, }; ``` - [ ] **Step 4: 验证编译** Run: `cd apps/web && npx tsc --noEmit` Expected: 无错误 - [ ] **Step 5: 提交** ```bash git add apps/web/src/api/ai/ git commit -m "feat(web): AI API 前端封装 — prompts/analysis/usage" ``` --- ## Chunk 3: 前端管理页面 ### Task 6: AI Prompt 管理页面 **Files:** - Create: `apps/web/src/pages/health/AiPromptList.tsx` - [ ] **Step 1: 创建 AiPromptList 页面** 使用 Ant Design Table + Modal 模式(参考 PatientList.tsx 结构): 核心功能: - 表格列:名称 / 类别 / 版本 / 状态(active/inactive) / 更新时间 - 新建 Prompt 按钮 → Modal 表单 - 操作列:激活 / 回滚 - AuthButton 权限控制(`ai.prompt.manage`) 页面大致结构(骨架,实现时根据 Ant Design 6 API 细化): ```tsx import { useEffect, useState, useCallback } from 'react'; import { Table, Button, Space, Modal, Form, Input, Select, Tag, message } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts'; import { AuthButton } from '../../components/AuthButton'; import { useThemeMode } from '../../hooks/useThemeMode'; const CATEGORIES = [ { value: 'lab_report_interpretation', label: '化验单解读' }, { value: 'health_trend_analysis', label: '趋势分析' }, { value: 'personalized_checkup_plan', label: '体检方案' }, { value: 'report_summary_generation', label: '报告摘要' }, ]; export default function AiPromptList() { // ... useState, fetchPrompts, columns 定义 // 新建/激活/回滚按钮用 包裹 // 表格渲染 + Modal 表单 } ``` - [ ] **Step 2: 验证编译 + 提交** ```bash cd apps/web && npx tsc --noEmit git add apps/web/src/pages/health/AiPromptList.tsx git commit -m "feat(web): AI Prompt 管理页面" ``` --- ### Task 7: AI 分析历史页面 **Files:** - Create: `apps/web/src/pages/health/AiAnalysisList.tsx` - [ ] **Step 1: 创建 AiAnalysisList 页面** 核心功能: - 表格列:分析类型 / 患者 ID / 状态(streaming/completed/failed) / 模型 / 创建时间 - 状态 Tag:completed=绿色, failed=红色, streaming=蓝色 - 详情查看:点击行展开,显示 result_content(Markdown 渲染) - 筛选:分析类型下拉 + 时间范围 - AuthButton 权限控制(`ai.analysis.list`) - [ ] **Step 2: 验证编译 + 提交** ```bash cd apps/web && npx tsc --noEmit git add apps/web/src/pages/health/AiAnalysisList.tsx git commit -m "feat(web): AI 分析历史页面" ``` --- ### Task 8: AI 用量统计页面 **Files:** - Create: `apps/web/src/pages/health/AiUsageDashboard.tsx` - [ ] **Step 1: 创建 AiUsageDashboard 页面** 核心功能: - 顶部 StatCard:总分析次数 - 饼图:分析类型分布(使用 Ant Design Charts Pie) - AuthButton 权限控制(`ai.usage.list`) - [ ] **Step 2: 验证编译 + 提交** ```bash cd apps/web && npx tsc --noEmit git add apps/web/src/pages/health/AiUsageDashboard.tsx git commit -m "feat(web): AI 用量统计页面" ``` --- ### Task 9: 菜单注册 + 路由配置 **Files:** - Modify: `apps/web/src/layouts/MainLayout.tsx` - Modify: `apps/web/src/App.tsx` - [ ] **Step 1: 在 MainLayout 添加菜单项** 在 `healthMenuItems` 数组中追加 3 项: ```typescript { key: '/health/ai-prompts', label: 'AI Prompt 管理', icon: ... }, { key: '/health/ai-analysis', label: 'AI 分析历史', icon: ... }, { key: '/health/ai-usage', label: 'AI 用量统计', icon: ... }, ``` - [ ] **Step 2: 在 App.tsx 添加路由** 在健康模块路由区域追加: ```tsx } /> } /> } /> ``` - [ ] **Step 3: 验证编译 + 提交** ```bash cd apps/web && npx tsc --noEmit git add apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx git commit -m "feat(web): AI 管理端菜单注册 + 路由配置" ``` --- ### Task 10: 集成验证 - [ ] **Step 1: 后端编译 + 启动** Run: `cargo check --workspace && cd crates/erp-server && cargo run` 验证: - `/api/v1/ai/prompts` 返回空列表(200) - `/api/v1/ai/analysis/history` 返回空列表(200) - `/api/v1/ai/usage/overview` 返回 0 计数(200) - [ ] **Step 2: 前端编译 + 启动** Run: `cd apps/web && pnpm build && pnpm dev` 验证: - 3 个新页面在菜单中可见 - 页面正常加载,无白屏/报错 - Prompt 列表为空时显示空状态 - 分析历史列表为空时显示空状态 - [ ] **Step 3: 生产构建** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 4: 推送** ```bash git push ```