docs(plan): 切片 2 AI 管理端 3 页面实施计划
10 个 Task,3 个 Chunk: - Chunk 1: 后端 API 补全(PromptService/AnalysisService/UsageService/Handler) - Chunk 2: 前端 API 封装(3 个 service 文件) - Chunk 3: 前端 3 管理页面 + 菜单路由 + 集成验证
This commit is contained in:
884
docs/superpowers/plans/2026-04-25-slice2-ai-admin-pages.md
Normal file
884
docs/superpowers/plans/2026-04-25-slice2-ai-admin-pages.md
Normal file
@@ -0,0 +1,884 @@
|
||||
# 切片 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<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, 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<String>,
|
||||
user_prompt_template: Option<String>,
|
||||
model_config: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
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<ai_prompt::Model> {
|
||||
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<ai_prompt::Model> {
|
||||
// 回滚 = 激活指定版本
|
||||
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<Uuid>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_analysis::Model>, 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::Model> {
|
||||
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<ai_analysis::Model> {
|
||||
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<UsageOverview> {
|
||||
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::<UsageOverview>()
|
||||
.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<Vec<TypeCount>> {
|
||||
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::<TypeCount>()
|
||||
.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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListAnalysisQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_analysis::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_prompts<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListPromptsQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<String>,
|
||||
pub system_prompt: String,
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
pub async fn create_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreatePromptBody>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<serde_json::Value> = 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<string, unknown>;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
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<PromptItem>;
|
||||
},
|
||||
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<string, unknown> | 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<AnalysisItem>;
|
||||
},
|
||||
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 定义
|
||||
// 新建/激活/回滚按钮用 <AuthButton code="ai.prompt.manage"> 包裹
|
||||
// 表格渲染 + 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
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
```
|
||||
Reference in New Issue
Block a user