feat(app): 管理端 Web 基座→暖记品牌迁移 + 日记管理页面
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

Phase 1 — 品牌替换:
- BRAND_DEFAULTS 回退值改为暖记品牌 (themes.ts)
- 登录页/侧边栏/底部回退文字 → 暖记 (Login, MainLayout)
- index.html title/meta/favicon → 暖记
- localStorage key → nuanji-theme, 默认主题 → warm
- 4 套主题色适配暖记设计系统 (珊瑚 #E07A5F / 蓝 / 深色 / 鼠尾草绿)
- 品牌信息通过系统设置配置,不硬编码

Phase 2 — 清理 HMS 模块:
- 删除 health/ai 页面 (~55+2)、API (~30+9)、组件、stores、hooks
- 重写 Home.tsx 为暖记 Dashboard
- 重写 NotificationPanel/MediaPicker 移除 health 依赖
- 清理 routeConfig 移除所有 health/ai 路由权限

Phase 3 — 暖记管理页面:
- API 层: api/diary/{types,journals,classes,topics,comments,stickers}.ts
- 班级管理: 班级列表+创建+成员查看+班级码复制 (ClassList)
- 日记审核: 日记列表+筛选+详情+老师点评 (JournalList)
- 主题管理: 班级选择+主题卡片+创建+过期标记 (TopicList)
- 贴纸管理: 贴纸包卡片+贴纸详情网格 (StickerPackList)
- 路由注册: /diary/classes, /diary/journals, /diary/topics, /diary/stickers

验证: tsc 0 error, vite build ✓, vitest 226/226 pass
This commit is contained in:
iven
2026-06-02 12:16:44 +08:00
parent 0a9e5b1cb3
commit 78018a9a64
204 changed files with 2573 additions and 35241 deletions

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ERP 平台底座 — 模块化 SaaS 企业资源管理系统,提供身份权限、工作流引擎、消息中心、系统配置等核心基础设施" />
<meta name="theme-color" content="#4F46E5" />
<meta name="description" content="暖记 Nuanji — 温暖治愈风格的手账日记管理后台,班级管理·日记审核·成长追踪" />
<meta name="theme-color" content="#E07A5F" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet" />
<title>HMS 健康管理</title>
<title>暖记管理</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -26,54 +26,11 @@ const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) =>
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
// 健康管理模块
const PatientList = lazy(() => import('./pages/health/PatientList'));
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage'));
const DoctorList = lazy(() => import('./pages/health/DoctorList'));
const AppointmentList = lazy(() => import('./pages/health/AppointmentList'));
const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule'));
const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList'));
const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList'));
const ConsultationList = lazy(() => import('./pages/health/ConsultationList'));
const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail'));
const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList'));
const PointsProductList = lazy(() => import('./pages/health/PointsProductList'));
const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList'));
const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList'));
const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard'));
const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
const RealtimeMonitor = lazy(() => import('./pages/health/RealtimeMonitor'));
const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList'));
const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList'));
const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList'));
const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail'));
const CriticalValueThresholdList = lazy(() => import('./pages/health/CriticalValueThresholdList'));
const FamilyProxyPage = lazy(() => import('./pages/health/FamilyProxyPage'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
const ArticleEditor = lazy(() => import('./pages/health/articleEditor/ArticleEditor'));
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
const BannerManage = lazy(() => import('./pages/health/BannerManage'));
const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
// 暖记日记模块
const ClassList = lazy(() => import('./pages/diary/ClassList'));
const JournalList = lazy(() => import('./pages/diary/JournalList'));
const TopicList = lazy(() => import('./pages/diary/TopicList'));
const StickerPackList = lazy(() => import('./pages/diary/StickerPackList'));
function FrozenRoute() {
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
@@ -126,7 +83,7 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
const baseToken = {
borderRadius: 10,
borderRadiusLG: 12,
borderRadiusLG: 16,
borderRadiusSM: 6,
fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
fontSize: 14,
@@ -147,6 +104,28 @@ const baseComponents = {
};
const themeConfigs: Record<ThemeName, { token: Record<string, unknown>; components: Record<string, Record<string, unknown>> }> = {
warm: {
token: {
...baseToken,
borderRadius: 16,
borderRadiusLG: 22,
borderRadiusSM: 10,
colorPrimary: '#E07A5F',
colorSuccess: '#81B29A',
colorWarning: '#F2CC8F',
colorError: '#E07A5F',
colorInfo: '#81B29A',
colorBgLayout: '#FFF8F0',
colorBgContainer: '#FFFFFF',
colorBgElevated: '#FFFFFF',
colorBorder: '#F0E8DF',
colorBorderSecondary: '#F5EDE5',
},
components: {
...baseComponents,
Table: { headerBg: '#FFF0E6', headerColor: '#8B7A6E', rowHoverBg: '#FFF8F0', fontSize: 14 },
},
},
blue: {
token: {
...baseToken,
@@ -166,56 +145,34 @@ const themeConfigs: Record<ThemeName, { token: Record<string, unknown>; componen
Table: { headerBg: '#f1f5f9', headerColor: '#475569', rowHoverBg: '#f1f5f9', fontSize: 14 },
},
},
warm: {
token: {
...baseToken,
borderRadius: 12,
borderRadiusLG: 14,
borderRadiusSM: 8,
colorPrimary: '#C4623A',
colorSuccess: '#5B7A5E',
colorWarning: '#C4873A',
colorError: '#B54A4A',
colorInfo: '#8B7A5E',
colorBgLayout: '#F5F0EB',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#E8E2DC',
colorBorderSecondary: '#F0EBE5',
},
components: {
...baseComponents,
Table: { headerBg: '#EDE8E2', headerColor: '#7A756E', rowHoverBg: '#F5F0EB', fontSize: 14 },
},
},
dark: {
token: {
...baseToken,
colorPrimary: '#60A5FA',
colorSuccess: '#34D399',
colorWarning: '#FBBF24',
colorError: '#F87171',
colorInfo: '#38BDF8',
colorBgLayout: '#0F172A',
colorBgContainer: '#1E293B',
colorBgElevated: '#334155',
colorBorder: '#334155',
colorPrimary: '#E8907A',
colorSuccess: '#8FBF9E',
colorWarning: '#D4B878',
colorError: '#E8907A',
colorInfo: '#8FBF9E',
colorBgLayout: '#1A1614',
colorBgContainer: '#2A2520',
colorBgElevated: '#352F2A',
colorBorder: '#3A3530',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
boxShadow: 'none',
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
},
components: {
...baseComponents,
Table: { headerBg: '#1E293B', headerColor: '#94A3B8', rowHoverBg: '#1E293B', fontSize: 14 },
Table: { headerBg: '#2A2520', headerColor: '#B0A89E', rowHoverBg: '#2A2520', fontSize: 14 },
},
},
emerald: {
token: {
...baseToken,
borderRadius: 10,
borderRadiusLG: 14,
borderRadiusLG: 16,
borderRadiusSM: 8,
colorPrimary: '#5B7A5E',
colorPrimary: '#81B29A',
colorSuccess: '#3D7A42',
colorWarning: '#B8863A',
colorError: '#A54A4A',
@@ -250,27 +207,12 @@ export default function App() {
validateRouteCoverage([
"/users", "/roles", "/organizations", "/workflow", "/messages", "/settings",
"/plugins/admin", "/plugins/market",
"/health/statistics", "/health/patients", "/health/tags", "/health/doctors",
"/health/appointments", "/health/schedules", "/health/follow-up-tasks",
"/health/follow-up-records", "/health/consultations",
"/health/points-rules", "/health/points-products", "/health/points-orders",
"/health/offline-events", "/health/ai-prompts", "/health/ai-analysis",
"/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/alerts", "/health/alert-dashboard",
"/ai/chat",
"/health/alert-rules", "/health/devices", "/health/realtime-monitor",
"/health/oauth-clients", "/health/dialysis", "/health/action-inbox",
"/health/follow-up-templates", "/health/care-plans", "/health/shifts",
"/health/medications", "/health/ble-gateways",
"/health/critical-value-thresholds", "/health/diagnoses",
"/health/family-proxy", "/health/consents",
"/health/articles", "/health/article-categories", "/health/article-tags",
"/health/banners", "/health/media-library",
"/health/medication-records",
"/diary/classes", "/diary/journals", "/diary/topics", "/diary/stickers",
]);
}, []);
const isDark = themeName === 'dark';
const antTheme = useMemo(() => themeConfigs[themeName] ?? themeConfigs.blue, [themeName]);
const antTheme = useMemo(() => themeConfigs[themeName] ?? themeConfigs.warm, [themeName]);
return (
<>
@@ -308,54 +250,11 @@ export default function App() {
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
{/* 健康管理 */}
<Route path="/health/statistics" element={<StatisticsDashboard />} />
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />
<Route path="/health/doctors" element={<DoctorList />} />
<Route path="/health/appointments" element={<AppointmentList />} />
<Route path="/health/schedules" element={<DoctorSchedule />} />
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
<Route path="/health/consultations" element={<ConsultationList />} />
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
<Route path="/health/points-rules" element={<PointsRuleList />} />
<Route path="/health/points-products" element={<PointsProductList />} />
<Route path="/health/points-orders" element={<PointsOrderList />} />
<Route path="/health/offline-events" element={<OfflineEventList />} />
<Route path="/health/ai-prompts" element={<AiPromptList />} />
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/ai-config" element={<AiConfigPage />} />
<Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
<Route path="/ai/chat" element={<AiChatPage />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
<Route path="/health/alert-rules" element={<AlertRuleList />} />
<Route path="/health/devices" element={<DeviceManage />} />
<Route path="/health/realtime-monitor" element={<RealtimeMonitor />} />
<Route path="/health/oauth-clients" element={<OAuthClientList />} />
<Route path="/health/dialysis" element={<DialysisManageList />} />
<Route path="/health/action-inbox" element={<ActionInbox />} />
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
<Route path="/health/care-plans" element={<CarePlanList />} />
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
<Route path="/health/shifts" element={<ShiftList />} />
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
<Route path="/health/medications" element={<MedicationRecordList />} />
<Route path="/health/ble-gateways" element={<BleGatewayList />} />
<Route path="/health/ble-gateways/:id" element={<BleGatewayDetail />} />
<Route path="/health/critical-value-thresholds" element={<CriticalValueThresholdList />} />
<Route path="/health/family-proxy" element={<FamilyProxyPage />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />
<Route path="/health/articles/:id/edit" element={<ArticleEditor />} />
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
<Route path="/health/article-tags" element={<ArticleTagManage />} />
<Route path="/health/banners" element={<BannerManage />} />
<Route path="/health/media-library" element={<MediaLibrary />} />
{/* 暖记日记模块 */}
<Route path="/diary/classes" element={<ClassList />} />
<Route path="/diary/journals" element={<JournalList />} />
<Route path="/diary/topics" element={<TopicList />} />
<Route path="/diary/stickers" element={<StickerPackList />} />
</Routes>
</Suspense>
</ErrorBoundary>

View File

@@ -1,127 +0,0 @@
/**
* AI 模块 API 契约测试analysis + prompts + suggestions + usage
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { analysisApi } from './analysis'
import { promptApi } from './prompts'
import { suggestionApi } from './suggestions'
import { usageApi } from './usage'
beforeEach(() => {
vi.clearAllMocks()
})
describe('analysisApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /ai/analysis/history 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await analysisApi.list({ patient_id: 'p-001', analysis_type: 'lab-report', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/ai/analysis/history', {
params: { patient_id: 'p-001', analysis_type: 'lab-report', page: 1, page_size: 10 },
})
})
it('get 应调用 GET /ai/analysis/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await analysisApi.get('ana-001')
expect(mockGet).toHaveBeenCalledWith('/ai/analysis/ana-001')
})
})
describe('promptApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /ai/prompts 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await promptApi.list({ category: 'analysis', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/ai/prompts', {
params: { category: 'analysis', page: 1, page_size: 10 },
})
})
it('create 应调用 POST /ai/prompts 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '化验解读', system_prompt: '你是专业医生', user_prompt_template: '解读: {report}', model_config: {}, category: 'analysis' }
await promptApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/ai/prompts', req)
})
it('activate 应调用 POST /ai/prompts/:id/activate', async () => {
mockPost.mockResolvedValue(fakeRes)
await promptApi.activate('prompt-001')
expect(mockPost).toHaveBeenCalledWith('/ai/prompts/prompt-001/activate')
})
it('rollback 应调用 POST /ai/prompts/:id/rollback', async () => {
mockPost.mockResolvedValue(fakeRes)
await promptApi.rollback('prompt-001')
expect(mockPost).toHaveBeenCalledWith('/ai/prompts/prompt-001/rollback')
})
})
describe('suggestionApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /ai/suggestions 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await suggestionApi.list({ analysis_id: 'ana-001', status: 'pending' })
expect(mockGet).toHaveBeenCalledWith('/ai/suggestions', {
params: { analysis_id: 'ana-001', status: 'pending' },
})
})
it('approve 应调用 POST /ai/suggestions/:id/approve 并传递 action', async () => {
mockPost.mockResolvedValue(fakeRes)
await suggestionApi.approve('sug-001', 'approve')
expect(mockPost).toHaveBeenCalledWith('/ai/suggestions/sug-001/approve', { action: 'approve' })
})
it('getComparison 应调用 GET /ai/suggestions/:id/comparison', async () => {
mockGet.mockResolvedValue(fakeRes)
await suggestionApi.getComparison('sug-001')
expect(mockGet).toHaveBeenCalledWith('/ai/suggestions/sug-001/comparison')
})
})
describe('usageApi', () => {
const fakeRes = { data: { data: {} } }
it('overview 应调用 GET /ai/usage/overview', async () => {
mockGet.mockResolvedValue(fakeRes)
await usageApi.overview()
expect(mockGet).toHaveBeenCalledWith('/ai/usage/overview')
})
it('byType 应调用 GET /ai/usage/by-type', async () => {
mockGet.mockResolvedValue(fakeRes)
await usageApi.byType()
expect(mockGet).toHaveBeenCalledWith('/ai/usage/by-type')
})
})

View File

@@ -1,47 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export interface AnalysisItem {
id: string;
patient_id: string;
patient_name?: 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 interface HealthSummaryResponse {
patient_id: string;
risk_level: 'low' | 'medium' | 'high' | 'critical';
active_insights_count: number;
recent_analyses_count: number;
latest_insight_title: string | null;
latest_analysis_type: string | null;
summary_items: Array<{
category: string;
title: string;
severity: string | null;
created_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;
},
getHealthSummary: async (patientId: string) => {
const resp = await client.get('/ai/health-summary', { params: { patient_id: patientId } });
return resp.data.data as HealthSummaryResponse;
},
};

View File

@@ -1,98 +0,0 @@
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary' | 'follow-up-summary';
interface AnalyzeBody {
report_id?: string;
patient_id?: string;
metrics?: string[];
source_id?: 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',
'follow-up-summary': '/ai/analyze/follow-up-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;
}

View File

@@ -1,118 +0,0 @@
import client from '../client';
export interface ChatHistoryItem {
role: 'user' | 'assistant';
content: string;
}
export type DisplayHint =
| {
type: 'vital_card';
indicator_type: string;
values: [string, number][];
unit: string;
}
| {
type: 'lab_report_card';
report_date: string;
abnormal_count: number;
}
| {
type: 'action_confirm';
action_type: string;
summary: string;
confirm_payload: unknown;
}
| {
type: 'risk_alert';
level: string;
message: string;
}
| {
type: 'trend_chart';
metrics: string[];
period: string;
summary: string;
}
| {
type: 'insight_card';
title: string;
severity: string;
items: string[];
}
| {
type: 'patient_profile';
chronic_conditions: string[];
medication_count: number;
}
| { type: 'text' };
export interface ChatResponse {
reply: string;
message_id: string;
iterations: number;
display_hints?: DisplayHint[];
}
export interface ChatSession {
id: string;
title: string | null;
patient_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export const aiChatApi = {
sendMessage: async (
message: string,
history: ChatHistoryItem[],
patientId?: string,
sessionId?: string
): Promise<ChatResponse> => {
const resp = await client.post('/ai/chat', {
message,
history,
...(patientId ? { patient_id: patientId } : {}),
...(sessionId ? { session_id: sessionId } : {}),
});
return resp.data.data as ChatResponse;
},
createSession: async (
patientId?: string,
title?: string
): Promise<ChatSession> => {
const resp = await client.post('/ai/chat/sessions', {
...(patientId ? { patient_id: patientId } : {}),
...(title ? { title } : {}),
});
return resp.data.data as ChatSession;
},
listSessions: async (): Promise<ChatSession[]> => {
const resp = await client.get('/ai/chat/sessions');
return resp.data.data as ChatSession[];
},
renameSession: async (
sessionId: string,
title: string
): Promise<void> => {
await client.put(`/ai/chat/sessions/${sessionId}/rename`, { title });
},
closeSession: async (sessionId: string): Promise<void> => {
await client.post(`/ai/chat/sessions/${sessionId}/close`);
},
getSessionMessages: async (sessionId: string): Promise<Array<{
id: string;
role: string;
content: string | null;
created_at: string;
}>> => {
const resp = await client.get(`/ai/chat/sessions/${sessionId}/messages`);
return resp.data.data;
},
};

View File

@@ -1,45 +0,0 @@
import client from '../client';
export interface AiAgentConfig {
model: string;
temperature: number;
max_tokens: number;
max_iterations: number;
system_prompt: string;
}
export interface AiAnalysisDefaults {
model: string;
temperature: number;
max_tokens: number;
}
export interface AiProviderConfig {
provider_type: string;
enabled: boolean;
base_url: string;
api_key: string;
model: string;
}
export interface AiConfig {
agent: AiAgentConfig;
analysis_defaults: AiAnalysisDefaults;
default_provider: string;
providers: Record<string, AiProviderConfig>;
}
export const aiConfigApi = {
get: async () => {
const resp = await client.get('/ai/config');
return resp.data.data as AiConfig;
},
getDefaults: async () => {
const resp = await client.get('/ai/config/defaults');
return resp.data.data as AiConfig;
},
update: async (config: AiConfig) => {
const resp = await client.put('/ai/config', { config });
return resp.data.data as AiConfig;
},
};

View File

@@ -1,23 +0,0 @@
import client from '../client';
export interface DialysisRiskRequest {
patient_id: string;
dialysis_session_id?: string;
}
export interface DialysisRiskAssessment {
id: string;
patient_id: string;
risk_level: string;
risk_factors: string[];
recommendations: string[];
kdigo_stage?: string;
created_at: string;
}
export const dialysisRiskApi = {
assess: async (data: DialysisRiskRequest) => {
const resp = await client.post('/ai/dialysis/risk-assessment', data);
return resp.data.data as DialysisRiskAssessment;
},
};

View File

@@ -1,188 +0,0 @@
import client from '../client';
// === Types ===
export interface KnowledgeBase {
id: string;
tenant_id: string;
name: string;
kb_type: string;
description: string | null;
icon: string | null;
chunk_strategy: Record<string, unknown>;
intent_keywords: Record<string, unknown>;
embedding_model: string | null;
is_enabled: boolean;
document_count: number;
chunk_count: number;
created_at: string;
updated_at: string;
}
export interface KnowledgeDocument {
id: string;
tenant_id: string;
knowledge_base_id: string;
title: string;
doc_type: string;
source_type: string;
source_url: string | null;
file_name: string | null;
file_size: number | null;
file_mime_type: string | null;
content: string | null;
status: string;
chunk_count: number;
embedded_count: number;
error_message: string | null;
processing_started_at: string | null;
processing_completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface SearchHit {
chunk_id: string;
document_id: string;
chunk_index: number;
content: string;
doc_title: string;
similarity: number;
metadata: Record<string, unknown>;
}
export interface CreateKnowledgeBaseReq {
name: string;
kb_type: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface UpdateKnowledgeBaseReq {
name?: string;
kb_type?: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface CreateDocumentReq {
kb_id: string;
title: string;
doc_type?: string;
source_type?: string;
source_url?: string;
content?: string;
}
// === API ===
export const knowledgeV2Api = {
// Knowledge Bases
listKnowledgeBases: async (params?: {
kb_type?: string;
is_enabled?: boolean;
page?: number;
page_size?: number;
}) => {
const resp = await client.get('/ai/knowledge-bases', { params });
return resp.data.data as {
data: KnowledgeBase[];
total: number;
page: number;
page_size: number;
};
},
getKnowledgeBase: async (id: string) => {
const resp = await client.get(`/ai/knowledge-bases/${id}`);
return resp.data.data as KnowledgeBase;
},
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
const resp = await client.post('/ai/knowledge-bases', data);
return resp.data.data as { id: string };
},
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
return resp.data.data as { id: string };
},
deleteKnowledgeBase: async (id: string) => {
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
return resp.data.data as { id: string };
},
// Documents
listDocuments: async (
kbId: string,
params?: { status?: string; page?: number; page_size?: number },
) => {
const resp = await client.get(
`/ai/knowledge-bases/${kbId}/documents`,
{ params },
);
return resp.data.data as {
data: KnowledgeDocument[];
total: number;
page: number;
page_size: number;
};
},
getDocument: async (id: string) => {
const resp = await client.get(`/ai/documents/${id}`);
return resp.data.data as KnowledgeDocument;
},
createManualDocument: async (data: CreateDocumentReq) => {
const resp = await client.post('/ai/documents/manual', data);
return resp.data.data as { id: string };
},
uploadDocument: async (
kbId: string,
file: File,
title?: string,
) => {
const formData = new FormData();
formData.append('kb_id', kbId);
formData.append('file', file);
if (title) {
formData.append('title', title);
}
const resp = await client.post('/ai/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return resp.data.data as { id: string };
},
deleteDocument: async (kbId: string, id: string) => {
const resp = await client.delete(
`/ai/knowledge-bases/${kbId}/documents/${id}`,
);
return resp.data.data as { id: string };
},
// Hit Test
hitTest: async (kbId: string, query: string, topK?: number) => {
const resp = await client.post('/ai/documents/hit-test', {
kb_id: kbId,
query,
top_k: topK,
});
return resp.data.data as {
query: string;
total: number;
hits: SearchHit[];
};
},
};

View File

@@ -1,54 +0,0 @@
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;
analysis_type: 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;
analysis_type: string;
}
export const promptApi = {
list: async (params?: { category?: string; analysis_type?: 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;
},
deactivate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
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;
},
delete: async (id: string) => {
await client.delete(`/ai/prompts/${id}`);
},
};

View File

@@ -1,38 +0,0 @@
import client from '../client';
export interface SuggestionItem {
id: string;
analysis_id: string;
suggestion_type: string;
risk_level: string;
params: Record<string, unknown> | null;
status: string;
created_at: string;
}
export interface ComparisonReport {
suggestion_id: string;
baseline: Record<string, unknown> | null;
current: Record<string, unknown> | null;
comparison_available: boolean;
message?: string;
}
export const suggestionApi = {
list: async (params?: { analysis_id?: string; status?: string }) => {
const resp = await client.get('/ai/suggestions', { params });
return resp.data.data as { data: SuggestionItem[]; total: number };
},
approve: async (id: string, action: 'approve' | 'reject') => {
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
return resp.data.data as { id: string; status: string };
},
execute: async (id: string) => {
const resp = await client.post(`/ai/suggestions/${id}/execute`);
return resp.data.data as { id: string; status: string };
},
getComparison: async (id: string) => {
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
return resp.data.data as ComparisonReport;
},
};

View File

@@ -1,107 +0,0 @@
import client from '../client';
export interface UsageOverview {
total_count: number;
}
export interface TypeDistribution {
analysis_type: string;
count: number;
}
export interface ProviderInfo {
id: string;
name: string;
provider_type: string;
is_active: boolean;
model_name?: string;
}
export interface ProviderHealth {
provider_id: string;
status: string;
latency_ms?: number;
last_checked_at?: string;
}
export interface QuotaSummary {
provider_id: string;
quota_limit: number;
quota_used: number;
quota_remaining: number;
period: string;
}
export interface BudgetStatus {
total_budget: number;
spent: number;
remaining: number;
period: string;
}
export interface CostEstimate {
analysis_type: string;
estimated_cost: number;
currency: string;
}
export interface DailyUsageRow {
date: string;
feature: string;
provider: string;
model: string;
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_cost_cents: number;
}
export interface FeatureFlag {
feature: string;
is_enabled: boolean;
}
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[];
},
listProviders: async () => {
const resp = await client.get('/ai/providers');
return resp.data.data as ProviderInfo[];
},
getProvidersHealth: async () => {
const resp = await client.get('/ai/providers/health');
return resp.data.data as ProviderHealth[];
},
getQuotaSummary: async () => {
const resp = await client.get('/ai/quota/summary');
return resp.data.data as QuotaSummary[];
},
getBudgetStatus: async () => {
const resp = await client.get('/ai/budget/status');
return resp.data.data as BudgetStatus;
},
getCostEstimate: async (params: { analysis_type: string }) => {
const resp = await client.get('/ai/cost/estimate', { params });
return resp.data.data as CostEstimate;
},
getDailyUsage: async (startDate: string, endDate: string) => {
const resp = await client.get('/ai/admin/daily-usage', {
params: { start_date: startDate, end_date: endDate },
});
return resp.data.data as DailyUsageRow[];
},
getFeatureFlags: async () => {
const resp = await client.get('/ai/admin/flags');
return resp.data.data as FeatureFlag[];
},
updateFeatureFlag: async (feature: string, enabled: boolean) => {
const resp = await client.post('/ai/admin/flags', { feature, enabled });
return resp.data.data as { feature: string; enabled: boolean };
},
};

View File

@@ -153,7 +153,7 @@ describe('numberingRules API', () => {
it('updateNumberingRule 应调用 PUT /config/numbering-rules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { prefix: 'HMS', version: 1 }
const req = { prefix: 'NJ', version: 1 }
await numberingApi.updateNumberingRule('nr-001', req)
expect(mockPut).toHaveBeenCalledWith('/config/numbering-rules/nr-001', req)
@@ -189,7 +189,7 @@ describe('themes API', () => {
it('updateTheme 应调用 PUT /config/themes', async () => {
mockPut.mockResolvedValue(fakeRes)
const theme = { primary_color: '#1890ff', brand_name: 'HMS' }
const theme = { primary_color: '#E07A5F', brand_name: 'Nuanji' }
await themesApi.updateTheme(theme)
expect(mockPut).toHaveBeenCalledWith('/config/themes', theme)

View File

@@ -0,0 +1,26 @@
import client from '../client';
import type { SchoolClass, CreateClassReq, ClassMember, PaginatedResponse } from './types';
export const classApi = {
list: (params?: { page?: number; page_size?: number }) =>
client.get<{ success: boolean; data: PaginatedResponse<SchoolClass> }>('/diary/classes', { params })
.then((r) => r.data.data),
myClasses: () =>
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes/my')
.then((r) => r.data.data),
get: (id: string) =>
client.get<{ success: boolean; data: SchoolClass }>(`/diary/classes/${id}`)
.then((r) => r.data.data),
create: (data: CreateClassReq) =>
client.post('/diary/classes', data).then((r) => r.data.data),
listMembers: (classId: string) =>
client.get<{ success: boolean; data: ClassMember[] }>(`/diary/classes/${classId}/members`)
.then((r) => r.data.data),
join: (classCode: string) =>
client.post('/diary/classes/join', { class_code: classCode }).then((r) => r.data.data),
};

View File

@@ -0,0 +1,14 @@
import client from '../client';
import type { Comment, CreateCommentReq } from './types';
export const commentApi = {
list: (journalId: string) =>
client.get<{ success: boolean; data: Comment[] }>(`/diary/journals/${journalId}/comments`)
.then((r) => r.data.data),
create: (journalId: string, data: CreateCommentReq) =>
client.post(`/diary/journals/${journalId}/comments`, data).then((r) => r.data.data),
delete: (commentId: string) =>
client.delete(`/diary/comments/${commentId}`).then((r) => r.data),
};

View File

@@ -0,0 +1,27 @@
import client from '../client';
import type { JournalEntry, CreateJournalReq, UpdateJournalReq, PaginatedResponse } from './types';
export const journalApi = {
list: (params?: {
page?: number;
page_size?: number;
author_id?: string;
mood?: string;
date_from?: string;
date_to?: string;
class_id?: string;
}) => client.get<{ success: boolean; data: PaginatedResponse<JournalEntry> }>('/diary/journals', { params })
.then((r) => r.data.data),
get: (id: string) => client.get<{ success: boolean; data: JournalEntry }>(`/diary/journals/${id}`)
.then((r) => r.data.data),
create: (data: CreateJournalReq) => client.post('/diary/journals', data)
.then((r) => r.data.data),
update: (id: string, data: UpdateJournalReq) => client.put(`/diary/journals/${id}`, data)
.then((r) => r.data.data),
delete: (id: string, version: number) => client.delete(`/diary/journals/${id}`, { data: { version } })
.then((r) => r.data),
};

View File

@@ -0,0 +1,22 @@
import client from '../client';
import type { StickerPack, Sticker, Template } from './types';
export const stickerApi = {
listPacks: (params?: { category?: string }) =>
client.get<{ success: boolean; data: StickerPack[] }>('/diary/sticker-packs', { params })
.then((r) => r.data.data),
listStickers: (packId: string) =>
client.get<{ success: boolean; data: Sticker[] }>(`/diary/sticker-packs/${packId}/stickers`)
.then((r) => r.data.data),
};
export const templateApi = {
list: (params?: { category?: string }) =>
client.get<{ success: boolean; data: Template[] }>('/diary/templates', { params })
.then((r) => r.data.data),
get: (id: string) =>
client.get<{ success: boolean; data: Template }>(`/diary/templates/${id}`)
.then((r) => r.data.data),
};

View File

@@ -0,0 +1,11 @@
import client from '../client';
import type { TopicAssignment, CreateTopicReq } from './types';
export const topicApi = {
list: (classId: string) =>
client.get<{ success: boolean; data: TopicAssignment[] }>(`/diary/classes/${classId}/topics`)
.then((r) => r.data.data),
assign: (classId: string, data: CreateTopicReq) =>
client.post(`/diary/classes/${classId}/topics`, data).then((r) => r.data.data),
};

View File

@@ -0,0 +1,128 @@
export interface JournalEntry {
id: string;
author_id: string;
class_id?: string;
title: string;
date: string;
mood: string;
weather: string;
tags: string[];
is_private: boolean;
shared_to_class: boolean;
assigned_topic_id?: string;
version: number;
created_at: string;
updated_at: string;
}
export interface CreateJournalReq {
title: string;
date: string;
mood: string;
weather: string;
tags?: string[];
is_private?: boolean;
class_id?: string;
}
export interface UpdateJournalReq {
title?: string;
mood?: string;
weather?: string;
tags?: string[];
is_private?: boolean;
shared_to_class?: boolean;
version: number;
}
export interface SchoolClass {
id: string;
name: string;
school_name?: string;
teacher_id: string;
class_code: string;
member_count: number;
is_active: boolean;
}
export interface CreateClassReq {
name: string;
school_name?: string;
}
export interface ClassMember {
user_id: string;
role: string;
nickname?: string;
joined_at: string;
}
export interface TopicAssignment {
id: string;
class_id: string;
teacher_id: string;
title: string;
description?: string;
due_date?: string;
is_active: boolean;
}
export interface CreateTopicReq {
title: string;
description?: string;
due_date?: string;
}
export interface Comment {
id: string;
journal_id: string;
author_id: string;
content: string;
created_at: string;
}
export interface CreateCommentReq {
content: string;
}
export interface StickerPack {
id: string;
name: string;
description?: string;
cover_image_url?: string;
sticker_count: number;
is_free: boolean;
category?: string;
}
export interface Sticker {
id: string;
pack_id: string;
name: string;
image_url: string;
category?: string;
}
export interface Template {
id: string;
name: string;
description?: string;
preview_url?: string;
template_data?: unknown;
category?: string;
is_free: boolean;
}
export interface MoodStats {
mood_counts: Array<{ mood: string; count: number; percentage: number }>;
streak_days: number;
total_journals: number;
dominant_mood?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
}

View File

@@ -1,128 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export type ActionType = 'ai_suggestion' | 'alert' | 'followup' | 'data_anomaly';
export type ActionPriority = 'urgent' | 'high' | 'medium' | 'low';
export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'dismissed';
export interface ActionItem {
id: string;
action_type: ActionType;
priority: ActionPriority;
status: ActionStatus;
title: string;
summary: string;
patient_id: string;
patient_name: string;
source_ref: string;
created_at: string;
updated_at: string;
}
export interface ThreadEvent {
step: string;
label: string;
status: ActionStatus;
detail?: string;
timestamp?: string;
operator?: string;
link_to?: string;
}
export interface ActionDefinition {
key: string;
label: string;
variant: 'primary' | 'danger' | 'default';
api_endpoint?: string;
}
export interface ThreadResponse {
action_item: ActionItem;
thread: ThreadEvent[];
available_actions: ActionDefinition[];
}
export interface WorkbenchStats {
total_pending: number;
ai_suggestion_pending: number;
urgent_alerts: number;
followup_due: number;
completion_rate: number | null;
}
export interface NursePatientSummary {
patient_id: string;
patient_name: string;
pending_actions: number;
highest_priority: ActionPriority;
}
export interface TeamMemberOverview {
user_id: string;
name: string;
title: string;
pending_count: number;
completed_count: number;
overdue_count: number;
completion_rate: number;
}
export interface TeamOverview {
members: TeamMemberOverview[];
risk_distribution: {
high: number;
medium: number;
low: number;
};
total_pending: number;
total_completed: number;
}
export const actionInboxApi = {
list: async (params?: {
status?: string;
type?: string;
page?: number;
page_size?: number;
assigned_to_me?: boolean;
patient_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<ActionItem>;
}>('/health/action-inbox', { params });
return data.data;
},
getThread: async (sourceRef: string) => {
const { data } = await client.get<{
success: boolean;
data: ThreadResponse;
}>(`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`);
return data.data;
},
stats: async (params?: { assigned_to_me?: boolean }) => {
const { data } = await client.get<{
success: boolean;
data: WorkbenchStats;
}>('/health/action-inbox/stats', { params });
return data.data;
},
myPatients: async () => {
const { data } = await client.get<{
success: boolean;
data: NursePatientSummary[];
}>('/health/action-inbox/my-patients');
return data.data;
},
team: async () => {
const { data } = await client.get<{
success: boolean;
data: TeamOverview;
}>('/health/action-inbox/team');
return data.data;
},
};

View File

@@ -1,100 +0,0 @@
/**
* alerts API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { alertApi, alertRuleApi } from './alerts'
beforeEach(() => {
vi.clearAllMocks()
})
describe('alertApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /health/alerts 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await alertApi.list({ patient_id: 'p-001', status: 'active', page: 1, page_size: 20 })
expect(mockGet).toHaveBeenCalledWith('/health/alerts', {
params: { patient_id: 'p-001', status: 'active', page: 1, page_size: 20 },
})
})
it('acknowledge 应调用 PUT /health/alerts/:id/acknowledge 并传递 version', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertApi.acknowledge('a-001', 2)
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/acknowledge', { version: 2 })
})
it('dismiss 应调用 PUT /health/alerts/:id/dismiss', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertApi.dismiss('a-001', 1)
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/dismiss', { version: 1 })
})
it('resolve 应调用 PUT /health/alerts/:id/resolve', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertApi.resolve('a-001', 3)
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/resolve', { version: 3 })
})
})
describe('alertRuleApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /health/alert-rules 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await alertRuleApi.list({ device_type: 'blood_pressure', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/alert-rules', {
params: { device_type: 'blood_pressure', page: 1, page_size: 10 },
})
})
it('create 应调用 POST /health/alert-rules 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
name: '血压偏高告警',
device_type: 'blood_pressure',
condition_type: 'threshold',
condition_params: { field: 'systolic', operator: '>', value: 140 },
severity: 'high',
}
await alertRuleApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/alert-rules', req)
})
it('update 应调用 PUT /health/alert-rules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { severity: 'critical', version: 1 }
await alertRuleApi.update('rule-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/alert-rules/rule-001', req)
})
it('deactivate 应调用 PUT /health/alert-rules/:id/deactivate', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertRuleApi.deactivate('rule-001', 2)
expect(mockPut).toHaveBeenCalledWith('/health/alert-rules/rule-001/deactivate', { version: 2 })
})
})

View File

@@ -1,118 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Alert {
id: string;
patient_id: string;
patient_name?: string;
rule_id: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_by_name?: string;
acknowledged_at?: string;
resolved_at?: string;
created_at: string;
version: number;
}
export interface AlertRule {
id: string;
name: string;
description?: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity: string;
is_active: boolean;
apply_tags?: Record<string, unknown>;
notify_roles: unknown[];
cooldown_minutes: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateAlertRuleReq {
name: string;
description?: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: unknown[];
cooldown_minutes?: number;
}
export interface UpdateAlertRuleReq {
name?: string;
description?: string;
condition_params?: Record<string, unknown>;
severity?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: unknown[];
cooldown_minutes?: number;
version: number;
}
// --- API ---
export const alertApi = {
list: (params?: { patient_id?: string; doctor_id?: string; status?: string; page?: number; page_size?: number }) =>
client.get('/health/alerts', { params }).then((r) => r.data.data as PaginatedResponse<Alert>),
acknowledge: (id: string, version: number) =>
client.put(`/health/alerts/${id}/acknowledge`, { version }).then((r) => r.data.data as Alert),
dismiss: (id: string, version: number) =>
client.put(`/health/alerts/${id}/dismiss`, { version }).then((r) => r.data.data as Alert),
resolve: (id: string, version: number) =>
client.put(`/health/alerts/${id}/resolve`, { version }).then((r) => r.data.data as Alert),
};
export const alertRuleApi = {
list: (params?: { device_type?: string; page?: number; page_size?: number }) =>
client.get('/health/alert-rules', { params }).then((r) => r.data.data as PaginatedResponse<AlertRule>),
create: (data: CreateAlertRuleReq) =>
client.post('/health/alert-rules', data).then((r) => r.data.data as AlertRule),
update: (id: string, data: UpdateAlertRuleReq) =>
client.put(`/health/alert-rules/${id}`, data).then((r) => r.data.data as AlertRule),
deactivate: (id: string, version: number) =>
client.put(`/health/alert-rules/${id}/deactivate`, { version }).then((r) => r.data.data as AlertRule),
};
// --- Critical Alerts API ---
export interface CriticalAlert {
id: string;
patient_id: string;
patient_name?: string;
alert_type: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_at?: string;
notes?: string;
created_at: string;
version: number;
}
export const criticalAlertApi = {
list: (params?: { page?: number; page_size?: number }) =>
client.get('/health/critical-alerts', { params }).then((r) => r.data.data as PaginatedResponse<CriticalAlert>),
get: (id: string) =>
client.get(`/health/critical-alerts/${id}`).then((r) => r.data.data as CriticalAlert),
acknowledge: (id: string, req: { notes?: string }) =>
client.post(`/health/critical-alerts/${id}/acknowledge`, req).then((r) => r.data),
};

View File

@@ -1,172 +0,0 @@
/**
* 健康模块新增 API 函数的契约测试
*
* 验证 dialysisApi / pointsAdminApi / healthDataApi 的日常监测与报告审核函数
* 是否调用了正确的 HTTP 方法、URL 路径和参数。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// --- Mock axios client ---
// 三个被测文件都 import client from '../client',相对路径一致
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
// 在 mock 生效后导入被测模块
import { dialysisApi } from './dialysis'
import { pointsAdminApi } from './points'
import { healthDataApi } from './healthData'
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// dialysisApi
// ============================================================
describe('dialysisApi', () => {
const fakeResponse = { data: { success: true, data: {} } }
it('listRecords 应调用 GET /health/patients/:id/dialysis-records 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeResponse)
await dialysisApi.listRecords('p-001', { page: 2, page_size: 20 })
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/health/patients/p-001/dialysis-records',
{ params: { page: 2, page_size: 20 } },
)
})
it('getRecord 应调用 GET /health/dialysis-records/:id', async () => {
mockGet.mockResolvedValue(fakeResponse)
await dialysisApi.getRecord('rec-123')
expect(mockGet).toHaveBeenCalledWith('/health/dialysis-records/rec-123')
})
it('createRecord 应调用 POST /health/dialysis-records 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeResponse)
const req = { patient_id: 'p-001', dialysis_date: '2026-04-30', dialysis_type: 'hemodialysis' }
await dialysisApi.createRecord(req)
expect(mockPost).toHaveBeenCalledWith('/health/dialysis-records', req)
})
it('updateRecord 应调用 PUT /health/dialysis-records/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { dry_weight: 65.0, version: 3 }
await dialysisApi.updateRecord('rec-123', req)
expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-123', req)
})
it('deleteRecord 应调用 DELETE /health/dialysis-records/:id 并在 body 中传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await dialysisApi.deleteRecord('rec-123', 3)
expect(mockDelete).toHaveBeenCalledWith('/health/dialysis-records/rec-123', {
data: { version: 3 },
})
})
it('reviewRecord 应调用 PUT /health/dialysis-records/:id/review', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { version: 2, doctor_notes: '指标正常' }
await dialysisApi.reviewRecord('rec-456', req)
expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-456/review', req)
})
})
// ============================================================
// pointsAdminApi
// ============================================================
describe('pointsAdminApi', () => {
const fakeResponse = { data: { success: true, data: {} } }
it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => {
mockGet.mockResolvedValue(fakeResponse)
await pointsAdminApi.getPatientAccount('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account')
})
it('listPatientTransactions 应调用 GET 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeResponse)
await pointsAdminApi.listPatientTransactions('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith(
'/health/admin/points/patients/p-001/transactions',
{ params: { page: 1, page_size: 10 } },
)
})
})
// ============================================================
// healthDataApi — 日常监测 + 报告审核
// ============================================================
describe('healthDataApi 日常监测', () => {
const fakeResponse = { data: { success: true, data: {} } }
it('listDailyMonitoring 应调用 GET /health/patients/:id/daily-monitoring 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeResponse)
await healthDataApi.listDailyMonitoring('p-001', { page: 1, page_size: 15 })
expect(mockGet).toHaveBeenCalledWith(
'/health/patients/p-001/daily-monitoring',
{ params: { page: 1, page_size: 15 } },
)
})
it('createDailyMonitoring 应调用 POST /health/daily-monitoring 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeResponse)
const req = {
patient_id: 'p-001',
record_date: '2026-04-30',
weight: 70.5,
blood_sugar: 5.2,
}
await healthDataApi.createDailyMonitoring(req)
expect(mockPost).toHaveBeenCalledWith('/health/daily-monitoring', req)
})
it('updateDailyMonitoring 应调用 PUT /health/daily-monitoring/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { weight: 71.0, version: 1 }
await healthDataApi.updateDailyMonitoring('dm-123', req)
expect(mockPut).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', req)
})
it('deleteDailyMonitoring 应调用 DELETE /health/daily-monitoring/:id 并在 body 中传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await healthDataApi.deleteDailyMonitoring('dm-123', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', {
data: { version: 2 },
})
})
it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { version: 1, doctor_notes: '指标略有异常,建议复查' }
await healthDataApi.reviewLabReport('p-001', 'lr-456', req)
expect(mockPut).toHaveBeenCalledWith(
'/health/patients/p-001/lab-reports/lr-456/review',
req,
)
})
})

View File

@@ -1,106 +0,0 @@
/**
* appointments API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { appointmentApi } from './appointments'
beforeEach(() => {
vi.clearAllMocks()
})
describe('appointmentApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/appointments 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.list({ page: 1, page_size: 20, status: 'confirmed', doctor_id: 'd-001' })
expect(mockGet).toHaveBeenCalledWith('/health/appointments', {
params: { page: 1, page_size: 20, status: 'confirmed', doctor_id: 'd-001' },
})
})
it('get 应调用 GET /health/appointments/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.get('appt-001')
expect(mockGet).toHaveBeenCalledWith('/health/appointments/appt-001')
})
it('create 应调用 POST /health/appointments 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
patient_id: 'p-001',
doctor_id: 'd-001',
appointment_date: '2026-05-10',
start_time: '09:00',
end_time: '09:30',
}
await appointmentApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/appointments', req)
})
it('updateStatus 应调用 PUT /health/appointments/:id/status', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { status: 'cancelled', cancel_reason: '时间冲突', version: 2 }
await appointmentApi.updateStatus('appt-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/appointments/appt-001/status', req)
})
it('listSchedules 应调用 GET /health/doctor-schedules 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.listSchedules({ doctor_id: 'd-001', date: '2026-05-10' })
expect(mockGet).toHaveBeenCalledWith('/health/doctor-schedules', {
params: { doctor_id: 'd-001', date: '2026-05-10' },
})
})
it('createSchedule 应调用 POST /health/doctor-schedules', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
doctor_id: 'd-001',
schedule_date: '2026-05-10',
start_time: '08:00',
end_time: '12:00',
max_appointments: 10,
}
await appointmentApi.createSchedule(req)
expect(mockPost).toHaveBeenCalledWith('/health/doctor-schedules', req)
})
it('updateSchedule 应调用 PUT /health/doctor-schedules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { max_appointments: 15, version: 1 }
await appointmentApi.updateSchedule('sch-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/doctor-schedules/sch-001', req)
})
it('calendar 应调用 GET /health/doctor-schedules/calendar', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.calendar({ start_date: '2026-05-01', end_date: '2026-05-31', doctor_id: 'd-001' })
expect(mockGet).toHaveBeenCalledWith('/health/doctor-schedules/calendar', {
params: { start_date: '2026-05-01', end_date: '2026-05-31', doctor_id: 'd-001' },
})
})
})

View File

@@ -1,164 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Appointment {
id: string;
patient_id: string;
doctor_id?: string;
appointment_type: string;
appointment_date: string;
start_time: string;
end_time: string;
status: string;
cancel_reason?: string;
notes?: string;
patient_name?: string;
doctor_name?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateAppointmentReq {
patient_id: string;
doctor_id?: string;
appointment_type?: string;
appointment_date: string;
start_time: string;
end_time: string;
notes?: string;
}
export interface UpdateAppointmentStatusReq {
status: string;
cancel_reason?: string;
}
export interface Schedule {
id: string;
doctor_id: string;
schedule_date: string;
period_type: string;
start_time: string;
end_time: string;
max_appointments: number;
current_appointments: number;
status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateScheduleReq {
doctor_id: string;
schedule_date: string;
period_type?: string;
start_time: string;
end_time: string;
max_appointments: number;
}
export interface UpdateScheduleReq {
start_time?: string;
end_time?: string;
max_appointments?: number;
status?: string;
}
export interface CalendarDay {
date: string;
schedules: Schedule[];
}
// --- API ---
export const appointmentApi = {
list: async (params: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
doctor_id?: string;
date?: string;
search?: string;
appointment_type?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Appointment>;
}>('/health/appointments', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Appointment;
}>(`/health/appointments/${id}`);
return data.data;
},
create: async (req: CreateAppointmentReq) => {
const { data } = await client.post<{
success: boolean;
data: Appointment;
}>('/health/appointments', req);
return data.data;
},
updateStatus: async (
id: string,
req: UpdateAppointmentStatusReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Appointment;
}>(`/health/appointments/${id}/status`, req);
return data.data;
},
// Schedules
listSchedules: async (params: {
page?: number;
page_size?: number;
doctor_id?: string;
date?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Schedule>;
}>('/health/doctor-schedules', { params });
return data.data;
},
createSchedule: async (req: CreateScheduleReq) => {
const { data } = await client.post<{
success: boolean;
data: Schedule;
}>('/health/doctor-schedules', req);
return data.data;
},
updateSchedule: async (
id: string,
req: UpdateScheduleReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Schedule;
}>(`/health/doctor-schedules/${id}`, req);
return data.data;
},
calendar: async (params: {
start_date: string;
end_date: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: CalendarDay[];
}>('/health/doctor-schedules/calendar', { params });
return data.data;
},
};

View File

@@ -1,173 +0,0 @@
/**
* articles API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { articleApi, articleCategoryApi, articleTagApi } from './articles'
beforeEach(() => {
vi.clearAllMocks()
})
describe('articleApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/articles 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleApi.list({ page: 1, page_size: 10, status: 'published', category_id: 'cat-001' })
expect(mockGet).toHaveBeenCalledWith('/health/articles', {
params: { page: 1, page_size: 10, status: 'published', category_id: 'cat-001' },
})
})
it('get 应调用 GET /health/articles/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleApi.get('art-001')
expect(mockGet).toHaveBeenCalledWith('/health/articles/art-001')
})
it('create 应调用 POST /health/articles', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { title: '健康饮食指南', content: '正文内容', content_type: 'markdown' as const }
await articleApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/articles', req)
})
it('update 应调用 PUT /health/articles/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { title: '健康饮食指南(修订)', version: 1 }
await articleApi.update('art-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/articles/art-001', req)
})
it('delete 应调用 DELETE /health/articles/:id', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleApi.delete('art-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/articles/art-001')
})
it('submit 应调用 POST /health/articles/:id/submit 并传递 version', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.submit('art-001', 2)
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/submit', { version: 2 })
})
it('approve 应调用 POST /health/articles/:id/approve', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.approve('art-001', 2)
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/approve', { version: 2 })
})
it('reject 应调用 POST /health/articles/:id/reject 并传递 review_note', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.reject('art-001', 2, '内容需要修改')
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/reject', {
version: 2,
review_note: '内容需要修改',
})
})
it('unpublish 应调用 POST /health/articles/:id/unpublish', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.unpublish('art-001', 3)
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/unpublish', { version: 3 })
})
it('view 应调用 POST /health/articles/:id/view', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.view('art-001')
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/view')
})
})
describe('articleCategoryApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/article-categories', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleCategoryApi.list()
expect(mockGet).toHaveBeenCalledWith('/health/article-categories')
})
it('create 应调用 POST /health/article-categories', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '营养健康', sort_order: 1 }
await articleCategoryApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/article-categories', req)
})
it('update 应调用 PUT /health/article-categories/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '营养健康(更新)' }
await articleCategoryApi.update('cat-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/article-categories/cat-001', req)
})
it('delete 应调用 DELETE /health/article-categories/:id', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleCategoryApi.delete('cat-001')
expect(mockDelete).toHaveBeenCalledWith('/health/article-categories/cat-001')
})
})
describe('articleTagApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/article-tags', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleTagApi.list()
expect(mockGet).toHaveBeenCalledWith('/health/article-tags')
})
it('create 应调用 POST /health/article-tags', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '高血压', color: '#ff0000' }
await articleTagApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/article-tags', req)
})
it('update 应调用 PUT /health/article-tags/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '高血压管理', version: 1 }
await articleTagApi.update('tag-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/article-tags/tag-001', req)
})
it('delete 应调用 DELETE /health/article-tags/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleTagApi.delete('tag-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/article-tags/tag-001', { data: { version: 2 } })
})
})

View File

@@ -1,283 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Article Types ---
export type ArticleStatus = 'draft' | 'pending_review' | 'published' | 'rejected';
export type ArticleContentType = 'rich_text' | 'markdown';
export interface ArticleListItem {
id: string;
title: string;
summary?: string;
cover_image?: string;
content_type: ArticleContentType;
status: ArticleStatus;
slug?: string;
category_id?: string;
category_name?: string;
tags?: ArticleTagItem[];
author?: string;
reviewed_by?: string;
reviewed_at?: string;
review_note?: string;
view_count: number;
sort_order: number;
is_public: boolean;
published_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface Article extends ArticleListItem {
content?: string;
}
export interface CreateArticleReq {
title: string;
summary?: string;
content?: string;
content_type?: ArticleContentType;
cover_image?: string;
slug?: string;
category_id?: string;
tag_ids?: string[];
sort_order?: number;
is_public?: boolean;
}
export interface UpdateArticleReq {
title?: string;
summary?: string;
content?: string;
content_type?: ArticleContentType;
cover_image?: string;
slug?: string;
category_id?: string;
tag_ids?: string[];
sort_order?: number;
is_public?: boolean;
version: number;
}
export interface ArticleListParams {
page?: number;
page_size?: number;
status?: ArticleStatus;
category_id?: string;
tag_id?: string;
keyword?: string;
}
// --- Category Types ---
export interface ArticleCategory {
id: string;
name: string;
slug?: string;
parent_id?: string;
parent_name?: string;
sort_order: number;
description?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateCategoryReq {
name: string;
slug?: string;
parent_id?: string;
sort_order?: number;
description?: string;
}
export interface UpdateCategoryReq {
name?: string;
slug?: string;
parent_id?: string;
sort_order?: number;
description?: string;
}
// --- Tag Types ---
export interface ArticleTagItem {
id: string;
name: string;
slug?: string;
color?: string;
created_at: string;
version?: number;
}
export interface CreateTagReq {
name: string;
slug?: string;
color?: string;
}
// --- Article API ---
export const articleApi = {
list: async (params: ArticleListParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<ArticleListItem>;
}>('/health/articles', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Article;
}>(`/health/articles/${id}`);
return data.data;
},
create: async (req: CreateArticleReq) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>('/health/articles', req);
return data.data;
},
update: async (id: string, req: UpdateArticleReq) => {
const { data } = await client.put<{
success: boolean;
data: Article;
}>(`/health/articles/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/articles/${id}`, { data: { version } });
return data.data;
},
submit: async (id: string, version: number) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/submit`, { version });
return data.data;
},
approve: async (id: string, version: number) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/approve`, { version });
return data.data;
},
reject: async (id: string, version: number, review_note: string) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/reject`, { version, review_note });
return data.data;
},
unpublish: async (id: string, version: number) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/unpublish`, { version });
return data.data;
},
view: async (id: string) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/view`);
return data.data;
},
listRevisions: async (id: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Record<string, unknown>>;
}>(`/health/articles/${id}/revisions`, { params });
return data.data;
},
};
// --- Category API ---
export const articleCategoryApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: ArticleCategory[];
}>('/health/article-categories');
return data.data;
},
create: async (req: CreateCategoryReq) => {
const { data } = await client.post<{
success: boolean;
data: ArticleCategory;
}>('/health/article-categories', req);
return data.data;
},
update: async (id: string, req: UpdateCategoryReq) => {
const { data } = await client.put<{
success: boolean;
data: ArticleCategory;
}>(`/health/article-categories/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/article-categories/${id}`, { data: { version } });
return data.data;
},
};
// --- Tag API ---
export const articleTagApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: ArticleTagItem[];
}>('/health/article-tags');
return data.data;
},
create: async (req: CreateTagReq) => {
const { data } = await client.post<{
success: boolean;
data: ArticleTagItem;
}>('/health/article-tags', req);
return data.data;
},
update: async (id: string, req: { name: string; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: ArticleTagItem;
}>(`/health/article-tags/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/article-tags/${id}`, { data: { version } });
return data.data;
},
};

View File

@@ -1,116 +0,0 @@
import client from '../client';
// ---------------------------------------------------------------------------
// 轮播图类型
// ---------------------------------------------------------------------------
export interface BannerItem {
id: string;
tenant_id: string;
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order: number;
status: string;
start_time?: string;
end_time?: string;
image_url?: string;
thumbnail_url?: string;
media_deleted: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
version: number;
}
export interface CreateBannerReq {
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
}
export interface UpdateBannerReq {
media_item_id?: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
version: number;
}
export interface SortBannerReq {
items: Array<{ id: string; sort_order: number }>;
}
// ---------------------------------------------------------------------------
// 轮播图 API
// ---------------------------------------------------------------------------
export const bannerApi = {
/** 获取轮播图列表(可按状态筛选) */
list: async (status?: string) => {
const { data } = await client.get<{
success: boolean;
data: BannerItem[];
}>('/health/banners', { params: status ? { status } : undefined });
return data.data;
},
/** 获取单个轮播图 */
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: BannerItem;
}>(`/health/banners/${id}`);
return data.data;
},
/** 创建轮播图 */
create: async (req: CreateBannerReq) => {
const { data } = await client.post<{
success: boolean;
data: BannerItem;
}>('/health/banners', req);
return data.data;
},
/** 更新轮播图 */
update: async (id: string, req: UpdateBannerReq) => {
const { data } = await client.put<{
success: boolean;
data: BannerItem;
}>(`/health/banners/${id}`, req);
return data.data;
},
/** 删除轮播图 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/banners/${id}`, { data: { version } });
return data.data;
},
/** 轮播图排序 */
sort: async (req: SortBannerReq) => {
const { data } = await client.put<{
success: boolean;
data: null;
}>('/health/banners/sort', req);
return data.data;
},
};

View File

@@ -1,170 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface BleGateway {
id: string;
tenant_id: string;
gateway_id: string;
name: string;
status: string;
firmware_version?: string;
ip_address?: string;
last_heartbeat_at?: string;
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
version: number;
api_key?: string;
patient_count?: number;
}
export interface GatewayBinding {
id: string;
tenant_id: string;
gateway_id: string;
patient_id: string;
peripheral_mac?: string;
device_type?: string;
status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateBleGatewayReq {
gateway_id: string;
name: string;
firmware_version?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateBleGatewayReq {
name?: string;
status?: string;
firmware_version?: string;
metadata?: Record<string, unknown>;
}
export interface ListBleGatewaysParams {
page?: number;
page_size?: number;
status?: string;
}
export interface CreateBindingReq {
patient_id: string;
peripheral_mac?: string;
device_type?: string;
}
export interface BatchBindReq {
bindings: CreateBindingReq[];
}
// --- Constants ---
export const GATEWAY_STATUS_OPTIONS = [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '未激活', value: 'inactive' },
{ label: '已禁用', value: 'disabled' },
];
export const GATEWAY_STATUS_COLOR: Record<string, string> = {
online: 'green',
offline: 'red',
inactive: 'default',
disabled: 'error',
};
export const GATEWAY_STATUS_LABEL: Record<string, string> = Object.fromEntries(
GATEWAY_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
export const BINDING_STATUS_COLOR: Record<string, string> = {
active: 'green',
inactive: 'default',
unbound: 'error',
};
// --- API ---
export const bleGatewayApi = {
// --- Gateways ---
list: async (params?: ListBleGatewaysParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<BleGateway>;
}>('/health/ble-gateways', { params });
return data.data;
},
get: async (gatewayId: string) => {
const { data } = await client.get<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}`);
return data.data;
},
create: async (req: CreateBleGatewayReq) => {
const { data } = await client.post<{
success: boolean;
data: BleGateway;
}>('/health/ble-gateways', req);
return data.data;
},
update: async (gatewayId: string, req: UpdateBleGatewayReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}`, req);
return data.data;
},
delete: async (gatewayId: string, version: number) => {
await client.delete(`/health/ble-gateways/${gatewayId}`, { data: { version } });
},
regenerateKey: async (gatewayId: string) => {
const { data } = await client.post<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}/regenerate-key`);
return data.data;
},
// --- Bindings ---
listBindings: async (gatewayId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<GatewayBinding>;
}>(`/health/ble-gateways/${gatewayId}/bindings`, { params });
return data.data;
},
bindPatient: async (gatewayId: string, req: CreateBindingReq) => {
const { data } = await client.post<{
success: boolean;
data: GatewayBinding;
}>(`/health/ble-gateways/${gatewayId}/bindings`, req);
return data.data;
},
batchBind: async (gatewayId: string, req: BatchBindReq) => {
const { data } = await client.post<{
success: boolean;
data: GatewayBinding[];
}>(`/health/ble-gateways/${gatewayId}/bindings/batch`, req);
return data.data;
},
unbindPatient: async (gatewayId: string, bindingId: string, version: number) => {
await client.delete(`/health/ble-gateways/${gatewayId}/bindings/${bindingId}`, { data: { version } });
},
};

View File

@@ -1,245 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface CarePlan {
id: string;
patient_id: string;
plan_type: string;
status: string;
title: string;
goals?: Record<string, unknown>;
start_date?: string;
end_date?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CarePlanItem {
id: string;
plan_id: string;
item_type: string;
title: string;
description?: string;
status: string;
schedule?: string;
sort_order?: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CarePlanOutcome {
id: string;
plan_id: string;
item_id?: string;
metric: string;
baseline_value: string;
target_value: string;
current_value?: string;
measured_at?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateCarePlanReq {
patient_id: string;
plan_type: string;
title: string;
goals?: Record<string, unknown>;
start_date?: string;
end_date?: string;
notes?: string;
}
export interface UpdateCarePlanReq {
plan_type?: string;
title?: string;
status?: string;
goals?: Record<string, unknown>;
start_date?: string;
end_date?: string;
notes?: string;
}
export interface CreateCarePlanItemReq {
item_type: string;
title: string;
description?: string;
schedule?: string;
sort_order?: number;
}
export interface UpdateCarePlanItemReq {
item_type?: string;
title?: string;
description?: string;
status?: string;
schedule?: string;
sort_order?: number;
}
export interface CreateCarePlanOutcomeReq {
item_id?: string;
metric: string;
baseline_value: string;
target_value: string;
current_value?: string;
measured_at?: string;
notes?: string;
}
export interface UpdateCarePlanOutcomeReq {
item_id?: string;
metric?: string;
baseline_value?: string;
target_value?: string;
current_value?: string;
measured_at?: string;
notes?: string;
}
export interface ListCarePlansParams {
page?: number;
page_size?: number;
patient_id?: string;
plan_type?: string;
status?: string;
}
// --- Constants ---
export const PLAN_TYPE_OPTIONS = [
{ label: '血液透析', value: 'hemodialysis' },
{ label: '腹膜透析', value: 'peritoneal' },
{ label: '慢性病管理', value: 'chronic_disease' },
{ label: '康复计划', value: 'rehabilitation' },
];
export const PLAN_STATUS_OPTIONS = [
{ label: '草稿', value: 'draft' },
{ label: '进行中', value: 'active' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
];
export const ITEM_TYPE_OPTIONS = [
{ label: '药物干预', value: 'medication' },
{ label: '饮食管理', value: 'diet' },
{ label: '运动计划', value: 'exercise' },
{ label: '监测项目', value: 'monitoring' },
{ label: '教育指导', value: 'education' },
{ label: '其他', value: 'other' },
];
export const PLAN_STATUS_COLOR: Record<string, string> = {
draft: 'default',
active: 'processing',
completed: 'success',
cancelled: 'error',
};
// --- API ---
export const carePlanApi = {
list: async (params: ListCarePlansParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<CarePlan>;
}>('/health/care-plans', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: CarePlan;
}>(`/health/care-plans/${id}`);
return data.data;
},
create: async (req: CreateCarePlanReq) => {
const { data } = await client.post<{
success: boolean;
data: CarePlan;
}>('/health/care-plans', req);
return data.data;
},
update: async (id: string, req: UpdateCarePlanReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: CarePlan;
}>(`/health/care-plans/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/care-plans/${id}`, { data: { version } });
},
// --- Items ---
listItems: async (planId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<CarePlanItem>;
}>(`/health/care-plans/${planId}/items`, { params });
return data.data;
},
createItem: async (planId: string, req: CreateCarePlanItemReq) => {
const { data } = await client.post<{
success: boolean;
data: CarePlanItem;
}>(`/health/care-plans/${planId}/items`, req);
return data.data;
},
updateItem: async (planId: string, itemId: string, req: UpdateCarePlanItemReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: CarePlanItem;
}>(`/health/care-plans/${planId}/items/${itemId}`, req);
return data.data;
},
deleteItem: async (planId: string, itemId: string, version: number) => {
await client.delete(`/health/care-plans/${planId}/items/${itemId}`, { data: { version } });
},
// --- Outcomes ---
listOutcomes: async (planId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<CarePlanOutcome>;
}>(`/health/care-plans/${planId}/outcomes`, { params });
return data.data;
},
createOutcome: async (planId: string, req: CreateCarePlanOutcomeReq) => {
const { data } = await client.post<{
success: boolean;
data: CarePlanOutcome;
}>(`/health/care-plans/${planId}/outcomes`, req);
return data.data;
},
updateOutcome: async (planId: string, outcomeId: string, req: UpdateCarePlanOutcomeReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: CarePlanOutcome;
}>(`/health/care-plans/${planId}/outcomes/${outcomeId}`, req);
return data.data;
},
deleteOutcome: async (planId: string, outcomeId: string, version: number) => {
await client.delete(`/health/care-plans/${planId}/outcomes/${outcomeId}`, { data: { version } });
},
};

View File

@@ -1,92 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Consent {
id: string;
patient_id: string;
consent_type: string;
consent_scope: string;
status: string;
granted_at?: string;
revoked_at?: string;
expiry_date?: string;
consent_method?: string;
witness_name?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateConsentReq {
patient_id: string;
consent_type: string;
consent_scope: string;
expiry_date?: string;
consent_method?: string;
witness_name?: string;
notes?: string;
}
export interface RevokeConsentReq {
notes?: string;
version: number;
}
// --- Constants ---
export const CONSENT_TYPE_OPTIONS = [
{ label: '治疗同意', value: 'treatment' },
{ label: '数据共享', value: 'data_sharing' },
{ label: '隐私政策', value: 'privacy' },
{ label: '研究参与', value: 'research' },
];
export const CONSENT_SCOPE_OPTIONS = [
{ label: '全部', value: 'all' },
{ label: '健康数据', value: 'health_data' },
{ label: '基本信息', value: 'basic_info' },
{ label: '体检报告', value: 'examination' },
];
export const CONSENT_STATUS_COLOR: Record<string, string> = {
active: 'green',
revoked: 'red',
expired: 'default',
};
export const CONSENT_STATUS_LABEL: Record<string, string> = {
active: '生效中',
revoked: '已撤销',
expired: '已过期',
};
// --- API ---
export const consentApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Consent>;
}>(`/health/patients/${patientId}/consents`, { params });
return data.data;
},
grant: async (req: CreateConsentReq) => {
const { data } = await client.post<{
success: boolean;
data: Consent;
}>('/health/consents', req);
return data.data;
},
revoke: async (consentId: string, req: RevokeConsentReq) => {
const { data } = await client.put<{
success: boolean;
data: Consent;
}>(`/health/consents/${consentId}/revoke`, req);
return data.data;
},
};

View File

@@ -1,98 +0,0 @@
/**
* consultations API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { consultationApi } from './consultations'
beforeEach(() => {
vi.clearAllMocks()
})
describe('consultationApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listSessions 应调用 GET /health/consultation-sessions 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await consultationApi.listSessions({ page: 1, page_size: 20, status: 'active', patient_id: 'p-001' })
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions', {
params: { page: 1, page_size: 20, status: 'active', patient_id: 'p-001' },
})
})
it('createSession 应调用 POST /health/consultation-sessions', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { patient_id: 'p-001', doctor_id: 'd-001', consultation_type: 'online' }
await consultationApi.createSession(req)
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions', req)
})
it('getSession 应调用 GET /health/consultation-sessions/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await consultationApi.getSession('sess-001')
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions/sess-001')
})
it('closeSession 应调用 PUT /health/consultation-sessions/:id/close', async () => {
mockPut.mockResolvedValue(fakeRes)
await consultationApi.closeSession('sess-001', { version: 1 })
expect(mockPut).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/close', { version: 1 })
})
it('listMessages 应调用 GET /health/consultation-sessions/:id/messages 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await consultationApi.listMessages('sess-001', { page: 2, page_size: 50, after_id: 'msg-100' })
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/messages', {
params: { page: 2, page_size: 50, after_id: 'msg-100' },
})
})
it('createMessage 应调用 POST /health/consultation-messages', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { session_id: 'sess-001', content_type: 'text', content: '你好' }
await consultationApi.createMessage(req)
expect(mockPost).toHaveBeenCalledWith('/health/consultation-messages', req)
})
it('createFollowUpFromSession 应调用 POST /health/consultation-sessions/:id/follow-up', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { follow_up_type: 'phone', planned_date: '2026-06-01' }
await consultationApi.createFollowUpFromSession('sess-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/follow-up', req)
})
it('triggerAiAnalysisFromSession 应调用 POST /health/consultation-sessions/:id/ai-analysis', async () => {
mockPost.mockResolvedValue(fakeRes)
await consultationApi.triggerAiAnalysisFromSession('sess-001')
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', {})
})
it('triggerAiAnalysisFromSession 传入 analysis_type 时应携带参数', async () => {
mockPost.mockResolvedValue(fakeRes)
await consultationApi.triggerAiAnalysisFromSession('sess-001', { analysis_type: 'trend' })
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', { analysis_type: 'trend' })
})
})

View File

@@ -1,185 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Session {
id: string;
patient_id: string;
doctor_id?: string;
patient_name?: string;
doctor_name?: string;
consultation_type: string;
status: string;
last_message_at?: string;
unread_count_patient: number;
unread_count_doctor: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateSessionReq {
patient_id: string;
doctor_id?: string;
consultation_type?: string;
}
export interface Message {
id: string;
session_id: string;
sender_id: string;
sender_role: string;
content_type: string;
content: string;
is_read: boolean;
created_at: string;
}
export interface CreateMessageReq {
session_id: string;
content_type?: string;
content: string;
}
// --- 咨询联动请求类型 ---
export interface CreateFollowUpFromConsultationReq {
follow_up_type: string;
planned_date: string;
assigned_to?: string;
content_template?: string;
}
export interface FollowUpFromConsultationResp {
task_id: string;
session_id: string;
patient_id: string;
}
export interface TriggerAiAnalysisReq {
analysis_type?: string;
}
export interface AiAnalysisTriggeredResp {
session_id: string;
patient_id: string;
analysis_type: string;
}
// --- API ---
export const consultationApi = {
listSessions: async (params: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Session>;
}>('/health/consultation-sessions', { params });
return data.data;
},
createSession: async (req: CreateSessionReq) => {
const { data } = await client.post<{
success: boolean;
data: Session;
}>('/health/consultation-sessions', req);
return data.data;
},
getSession: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Session;
}>(`/health/consultation-sessions/${id}`);
return data.data;
},
closeSession: async (
id: string,
req: { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Session;
}>(`/health/consultation-sessions/${id}/close`, req);
return data.data;
},
listMessages: async (
sessionId: string,
params: { page?: number; page_size?: number; after_id?: string },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Message>;
}>(`/health/consultation-sessions/${sessionId}/messages`, { params });
return data.data;
},
createMessage: async (req: CreateMessageReq) => {
const { data } = await client.post<{
success: boolean;
data: Message;
}>('/health/consultation-messages', req);
return data.data;
},
pollMessages: async (
sessionId: string,
afterId?: string,
) => {
const { data } = await client.get<{
success: boolean;
data: Message[];
}>(`/health/consultation-sessions/${sessionId}/messages/poll`, {
params: { after_id: afterId, timeout: 25 },
timeout: 30000,
});
return data.data;
},
markSessionRead: async (id: string) => {
await client.put(`/health/consultation-sessions/${id}/read`);
},
exportSessions: async (params?: {
status?: string;
patient_id?: string;
doctor_id?: string;
page?: number;
page_size?: number;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Session>;
}>('/health/consultation-sessions/export', { params });
return data.data;
},
/** 从咨询会话创建随访任务 */
createFollowUpFromSession: async (
sessionId: string,
req: CreateFollowUpFromConsultationReq,
) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpFromConsultationResp;
}>(`/health/consultation-sessions/${sessionId}/follow-up`, req);
return data.data;
},
/** 从咨询会话触发 AI 分析 */
triggerAiAnalysisFromSession: async (
sessionId: string,
req?: TriggerAiAnalysisReq,
) => {
const { data } = await client.post<{
success: boolean;
data: AiAnalysisTriggeredResp;
}>(`/health/consultation-sessions/${sessionId}/ai-analysis`, req ?? {});
return data.data;
},
};

View File

@@ -1,110 +0,0 @@
import client from '../client';
// --- Types ---
export interface CriticalValueThreshold {
id: string;
tenant_id: string;
indicator: string;
direction: string;
threshold_value: number;
level: string;
department?: string;
age_min?: number;
age_max?: number;
is_active: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateThresholdReq {
indicator: string;
direction: string;
threshold_value: number;
level?: string;
department?: string;
age_min?: number;
age_max?: number;
}
export interface UpdateThresholdReq {
threshold_value: number;
level?: string;
department?: string;
age_min?: number;
age_max?: number;
version: number;
}
// --- Constants ---
export const INDICATOR_OPTIONS = [
{ label: '收缩压', value: 'systolic_bp' },
{ label: '舒张压', value: 'diastolic_bp' },
{ label: '心率', value: 'heart_rate' },
{ label: '血糖', value: 'blood_sugar' },
{ label: '空腹血糖', value: 'blood_sugar_fasting' },
{ label: '餐后血糖', value: 'blood_sugar_postprandial' },
{ label: '血氧', value: 'blood_oxygen' },
{ label: '体温', value: 'temperature' },
];
export const DIRECTION_OPTIONS = [
{ label: '偏高', value: 'high' },
{ label: '偏低', value: 'low' },
];
export const LEVEL_OPTIONS = [
{ label: '危急', value: 'critical' },
{ label: '警告', value: 'warning' },
];
export const LEVEL_COLOR: Record<string, string> = {
critical: 'red',
warning: 'orange',
};
export const INDICATOR_LABEL: Record<string, string> = Object.fromEntries(
INDICATOR_OPTIONS.map((o) => [o.value, o.label]),
);
export const DIRECTION_LABEL: Record<string, string> = Object.fromEntries(
DIRECTION_OPTIONS.map((o) => [o.value, o.label]),
);
export const LEVEL_LABEL: Record<string, string> = Object.fromEntries(
LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const criticalValueThresholdApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: CriticalValueThreshold[];
}>('/health/critical-value-thresholds');
return data.data;
},
create: async (req: CreateThresholdReq) => {
const { data } = await client.post<{
success: boolean;
data: CriticalValueThreshold;
}>('/health/critical-value-thresholds', req);
return data.data;
},
update: async (id: string, req: UpdateThresholdReq) => {
const { data } = await client.put<{
success: boolean;
data: CriticalValueThreshold;
}>(`/health/critical-value-thresholds/${id}`, req);
return data.data;
},
delete: async (id: string) => {
await client.delete(`/health/critical-value-thresholds/${id}`);
},
};

View File

@@ -1,105 +0,0 @@
/**
* dashboard + actionInbox API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { dashboardApi } from './dashboard'
import { actionInboxApi } from './actionInbox'
beforeEach(() => {
vi.clearAllMocks()
})
describe('dashboardApi', () => {
const fakeRes = { data: { data: {} } }
it('getSystemHealth 应调用 GET /health/admin/system-health', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getSystemHealth()
expect(mockGet).toHaveBeenCalledWith('/health/admin/system-health')
})
it('getUserActivity 应调用 GET /health/admin/user-activity', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getUserActivity()
expect(mockGet).toHaveBeenCalledWith('/health/admin/user-activity')
})
it('getModuleStatus 应调用 GET /health/admin/modules', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getModuleStatus()
expect(mockGet).toHaveBeenCalledWith('/health/admin/modules')
})
it('getPointsRecentActivity 应调用 GET /health/points/recent-activity', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getPointsRecentActivity()
expect(mockGet).toHaveBeenCalledWith('/health/points/recent-activity')
})
it('getArticleStats 应调用 GET /health/articles/stats', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getArticleStats()
expect(mockGet).toHaveBeenCalledWith('/health/articles/stats')
})
})
describe('actionInboxApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/action-inbox 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.list({ status: 'pending', type: 'alert', page: 1, page_size: 20 })
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox', {
params: { status: 'pending', type: 'alert', page: 1, page_size: 20 },
})
})
it('getThread 应调用 GET /health/action-inbox/:sourceRef/thread', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.getThread('ref-001')
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/ref-001/thread')
})
it('getThread 应对特殊字符 URL 编码', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.getThread('ref/with:special')
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/ref%2Fwith%3Aspecial/thread')
})
it('stats 应调用 GET /health/action-inbox/stats', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.stats()
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/stats')
})
it('team 应调用 GET /health/action-inbox/team', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.team()
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/team')
})
})

View File

@@ -1,69 +0,0 @@
import client from '../client';
export interface ServiceHealthStatus {
name: string;
status: string;
message: string;
response_ms: number | null;
}
export interface SystemHealthResp {
services: ServiceHealthStatus[];
checked_at: string;
}
export interface RoleCount {
role: string;
count: number;
}
export interface UserActivityResp {
daily_active: number;
weekly_active: number;
monthly_active: number;
total_registered: number;
by_role: RoleCount[];
}
export interface ModuleStatusResp {
name: string;
display_name: string;
description: string;
active: boolean;
entity_count: number | null;
route_count: number | null;
}
export interface PointsActivityItem {
id: string;
user_name: string;
detail: string;
amount: string;
type: string;
created_at: string;
}
export interface ArticleStatsResp {
published: number;
draft: number;
pending_review: number;
rejected: number;
total_views: number;
}
export const dashboardApi = {
getSystemHealth: () =>
client.get('/health/admin/system-health').then((r) => r.data.data as SystemHealthResp),
getUserActivity: () =>
client.get('/health/admin/user-activity').then((r) => r.data.data as UserActivityResp),
getModuleStatus: () =>
client.get('/health/admin/modules').then((r) => r.data.data as ModuleStatusResp[]),
getPointsRecentActivity: () =>
client.get('/health/points/recent-activity').then((r) => r.data.data as PointsActivityItem[]),
getArticleStats: () =>
client.get('/health/articles/stats').then((r) => r.data.data as ArticleStatsResp),
};

View File

@@ -1,82 +0,0 @@
/**
* deviceReadings + devices API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { deviceReadingApi } from './deviceReadings'
import { deviceApi } from './devices'
beforeEach(() => {
vi.clearAllMocks()
})
describe('deviceReadingApi', () => {
const fakeRes = { data: { data: {} } }
it('batchCreate 应调用 POST /health/patients/:id/device-readings/batch', async () => {
mockPost.mockResolvedValue(fakeRes)
const data = {
device_id: 'dev-001',
readings: [
{ device_type: 'blood_pressure', values: { systolic: 130, diastolic: 85 }, measured_at: '2026-05-03T08:00:00Z' },
],
}
await deviceReadingApi.batchCreate('p-001', data)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/device-readings/batch', data)
})
it('query 应调用 GET /health/patients/:id/device-readings 并剥离 patient_id', async () => {
mockGet.mockResolvedValue(fakeRes)
await deviceReadingApi.query({ patient_id: 'p-001', device_type: 'blood_pressure', hours: 24 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/device-readings', {
params: { device_type: 'blood_pressure', hours: 24 },
})
})
it('queryHourly 应调用 GET /health/patients/:id/device-readings/hourly', async () => {
mockGet.mockResolvedValue(fakeRes)
await deviceReadingApi.queryHourly({ patient_id: 'p-001', device_type: 'blood_pressure', days: 7 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/device-readings/hourly', {
params: { device_type: 'blood_pressure', days: 7 },
})
})
})
describe('deviceApi', () => {
const fakeRes = { data: { data: {} } }
it('listDevices 应调用 GET /health/devices 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await deviceApi.listDevices({ patient_id: 'p-001', device_type: 'blood_pressure', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/devices', {
params: { patient_id: 'p-001', device_type: 'blood_pressure', page: 1, page_size: 10 },
})
})
it('unbindDevice 应调用 DELETE /health/devices/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(fakeRes)
await deviceApi.unbindDevice('dev-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/devices/dev-001', {
data: { version: 2 },
})
})
})

View File

@@ -1,72 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DeviceReading {
id: string;
device_id?: string;
device_type: string;
device_model?: string;
raw_value: Record<string, unknown>;
measured_at: string;
created_at: string;
}
export interface HourlyReading {
id: string;
device_type: string;
hour_start: string;
min_val?: number;
max_val?: number;
avg_val: number;
sample_count: number;
}
export interface DailyReading {
id: string;
device_type: string;
date_bucket: string;
min_val?: number;
max_val?: number;
avg_val: number;
sample_count: number;
percentile_95?: number;
}
export interface BatchReadingRequest {
device_id: string;
device_model?: string;
readings: {
device_type: string;
values: Record<string, unknown>;
measured_at: string;
}[];
}
export interface BatchResult {
accepted: number;
duplicates: number;
earliest?: string;
latest?: string;
}
// --- API ---
export const deviceReadingApi = {
batchCreate: (patientId: string, data: BatchReadingRequest) =>
client.post(`/health/patients/${patientId}/device-readings/batch`, data).then((r) => r.data.data as BatchResult),
query: (params: { patient_id: string; device_type?: string; hours?: number; page?: number; page_size?: number }) => {
const { patient_id, ...query } = params;
return client.get(`/health/patients/${patient_id}/device-readings`, { params: query }).then((r) => r.data.data as PaginatedResponse<DeviceReading>);
},
queryHourly: (params: { patient_id: string; device_type: string; days?: number; page?: number; page_size?: number }) => {
const { patient_id, ...query } = params;
return client.get(`/health/patients/${patient_id}/device-readings/hourly`, { params: query }).then((r) => r.data.data as PaginatedResponse<HourlyReading>);
},
queryDaily: (params: { patient_id: string; device_type?: string; from_date?: string; to_date?: string; page?: number; page_size?: number }) => {
const { patient_id, ...query } = params;
return client.get(`/health/vital-signs/daily`, { params: { ...query, patient_id } }).then((r) => r.data.data as PaginatedResponse<DailyReading>);
},
};

View File

@@ -1,37 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DeviceItem {
id: string;
patient_id: string;
device_id: string;
device_model: string;
device_type: string;
status?: string;
firmware_version?: string;
manufacturer?: string;
connection_type?: string;
metadata?: Record<string, unknown>;
bound_at: string;
last_sync_at: string;
version: number;
}
// --- API ---
export const deviceApi = {
listDevices: (params?: {
patient_id?: string;
device_type?: string;
page?: number;
page_size?: number;
}) =>
client
.get('/health/devices', { params })
.then((r) => r.data.data as PaginatedResponse<DeviceItem>),
unbindDevice: (id: string, version: number) =>
client
.delete(`/health/devices/${id}`, { data: { version } })
.then((r) => r.data.data as DeviceItem),
};

View File

@@ -1,108 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Diagnosis {
id: string;
patient_id: string;
health_record_id?: string;
icd_code: string;
diagnosis_name: string;
diagnosis_type: string;
diagnosed_date: string;
status: string;
diagnosed_by?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDiagnosisReq {
icd_code: string;
diagnosis_name: string;
diagnosis_type?: string;
diagnosed_date: string;
status?: string;
health_record_id?: string;
diagnosed_by?: string;
notes?: string;
}
export interface UpdateDiagnosisReq {
icd_code?: string;
diagnosis_name?: string;
diagnosis_type?: string;
diagnosed_date?: string;
status?: string;
health_record_id?: string;
diagnosed_by?: string;
notes?: string;
}
// --- Constants ---
export const DIAGNOSIS_TYPE_OPTIONS = [
{ label: '主要诊断', value: 'primary' },
{ label: '次要诊断', value: 'secondary' },
{ label: '合并症', value: 'comorbid' },
];
export const DIAGNOSIS_STATUS_OPTIONS = [
{ label: '活跃', value: 'active' },
{ label: '已缓解', value: 'resolved' },
{ label: '慢性', value: 'chronic' },
];
export const DIAGNOSIS_TYPE_COLOR: Record<string, string> = {
primary: 'red',
secondary: 'blue',
comorbid: 'orange',
};
export const DIAGNOSIS_STATUS_COLOR: Record<string, string> = {
active: 'green',
resolved: 'default',
chronic: 'orange',
};
export const DIAGNOSIS_TYPE_LABEL: Record<string, string> = Object.fromEntries(
DIAGNOSIS_TYPE_OPTIONS.map((o) => [o.value, o.label]),
);
export const DIAGNOSIS_STATUS_LABEL: Record<string, string> = Object.fromEntries(
DIAGNOSIS_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const diagnosisApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Diagnosis>;
}>(`/health/patients/${patientId}/diagnoses`, { params });
return data.data;
},
create: async (patientId: string, req: CreateDiagnosisReq) => {
const { data } = await client.post<{
success: boolean;
data: Diagnosis;
}>(`/health/patients/${patientId}/diagnoses`, req);
return data.data;
},
update: async (diagnosisId: string, req: UpdateDiagnosisReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Diagnosis;
}>(`/health/diagnoses/${diagnosisId}`, req);
return data.data;
},
delete: async (diagnosisId: string, version: number) => {
await client.delete(`/health/diagnoses/${diagnosisId}`, { data: { version } });
},
};

View File

@@ -1,116 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DialysisRecord {
id: string;
patient_id: string;
dialysis_date: string;
start_time?: string;
end_time?: string;
dry_weight?: number;
pre_weight?: number;
post_weight?: number;
pre_bp_systolic?: number;
pre_bp_diastolic?: number;
post_bp_systolic?: number;
post_bp_diastolic?: number;
pre_heart_rate?: number;
post_heart_rate?: number;
ultrafiltration_volume?: number;
dialysis_duration?: number;
blood_flow_rate?: number;
dialysis_type: string;
symptoms?: Record<string, unknown>;
complication_notes?: string;
status: string;
reviewed_by?: string;
reviewed_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDialysisRecordReq {
patient_id: string;
dialysis_date: string;
start_time?: string;
end_time?: string;
dry_weight?: number;
pre_weight?: number;
post_weight?: number;
pre_bp_systolic?: number;
pre_bp_diastolic?: number;
post_bp_systolic?: number;
post_bp_diastolic?: number;
pre_heart_rate?: number;
post_heart_rate?: number;
ultrafiltration_volume?: number;
dialysis_duration?: number;
blood_flow_rate?: number;
dialysis_type?: string;
complication_notes?: string;
}
// --- API ---
export const dialysisApi = {
listRecords: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<DialysisRecord>;
}>(`/health/patients/${patientId}/dialysis-records`, { params });
return data.data;
},
getRecord: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}`);
return data.data;
},
createRecord: async (req: CreateDialysisRecordReq) => {
const { data } = await client.post<{
success: boolean;
data: DialysisRecord;
}>('/health/dialysis-records', req);
return data.data;
},
updateRecord: async (
id: string,
req: Partial<CreateDialysisRecordReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}`, req);
return data.data;
},
deleteRecord: async (id: string, version: number) => {
await client.delete(`/health/dialysis-records/${id}`, { data: { version } });
},
reviewRecord: async (id: string, req: { version: number; doctor_notes?: string }) => {
const { data } = await client.put<{
success: boolean;
data: Record<string, unknown>;
}>(`/health/dialysis-records/${id}/review`, req);
return data.data;
},
completeRecord: async (id: string, version: number) => {
const { data } = await client.put<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}/complete`, { version });
return data.data;
},
};

View File

@@ -1,67 +0,0 @@
/**
* doctors API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { doctorApi } from './doctors'
beforeEach(() => {
vi.clearAllMocks()
})
describe('doctorApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/doctors 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await doctorApi.list({ page: 1, page_size: 10, search: '王', department: '内科' })
expect(mockGet).toHaveBeenCalledWith('/health/doctors', {
params: { page: 1, page_size: 10, search: '王', department: '内科' },
})
})
it('get 应调用 GET /health/doctors/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await doctorApi.get('d-001')
expect(mockGet).toHaveBeenCalledWith('/health/doctors/d-001')
})
it('create 应调用 POST /health/doctors 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '王医生', department: '内科', title: '主任医师' }
await doctorApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/doctors', req)
})
it('update 应调用 PUT /health/doctors/:id 并传递请求体含 version', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { title: '副主任医师', version: 1 }
await doctorApi.update('d-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/doctors/d-001', req)
})
it('delete 应调用 DELETE /health/doctors/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await doctorApi.delete('d-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/doctors/d-001')
})
})

View File

@@ -1,83 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Doctor {
id: string;
user_id?: string;
name: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
online_status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDoctorReq {
user_id?: string;
name: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
}
export interface UpdateDoctorReq {
name?: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
online_status?: string;
}
// --- API ---
export const doctorApi = {
list: async (params: {
page?: number;
page_size?: number;
search?: string;
department?: string;
title?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Doctor>;
}>('/health/doctors', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Doctor;
}>(`/health/doctors/${id}`);
return data.data;
},
create: async (req: CreateDoctorReq) => {
const { data } = await client.post<{
success: boolean;
data: Doctor;
}>('/health/doctors', req);
return data.data;
},
update: async (id: string, req: UpdateDoctorReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Doctor;
}>(`/health/doctors/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/doctors/${id}`, { data: { version } });
},
};

View File

@@ -1,109 +0,0 @@
import client from '../client';
// --- Types ---
export interface FamilyMember {
id: string;
patient_id: string;
name: string;
relationship: string;
phone?: string;
birth_date?: string;
notes?: string;
user_id?: string;
consent_status: string;
access_level: string;
consented_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface FamilyPatientSummary {
family_member_id: string;
patient_id: string;
patient_name: string;
relationship: string;
consent_status: string;
access_level: string;
consented_at?: string;
}
export interface FamilyHealthSummary {
patient_id: string;
patient_name: string;
latest_vital_signs?: Record<string, unknown>;
active_care_plan?: Record<string, unknown>;
recent_alerts_count: number;
next_appointment?: Record<string, unknown>;
}
export interface GrantAccessReq {
access_level: string;
}
// --- Constants ---
export const CONSENT_STATUS_OPTIONS = [
{ label: '已同意', value: 'granted' },
{ label: '待确认', value: 'pending' },
{ label: '已撤销', value: 'revoked' },
{ label: '已过期', value: 'expired' },
];
export const ACCESS_LEVEL_OPTIONS = [
{ label: '完全访问', value: 'full' },
{ label: '只读', value: 'read_only' },
{ label: '摘要', value: 'summary' },
];
export const CONSENT_STATUS_COLOR: Record<string, string> = {
granted: 'green',
pending: 'orange',
revoked: 'red',
expired: 'default',
};
export const ACCESS_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
ACCESS_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
export const CONSENT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
CONSENT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const familyProxyApi = {
grantAccess: async (patientId: string, familyMemberId: string, req: GrantAccessReq, version: number) => {
const { data } = await client.post<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access`, { ...req, version });
return data.data;
},
revokeAccess: async (patientId: string, familyMemberId: string, version: number) => {
const { data } = await client.put<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access`, { version });
return data.data;
},
listMyPatients: async () => {
const { data } = await client.get<{
success: boolean;
data: FamilyPatientSummary[];
}>('/health/family/patients');
return data.data;
},
getHealthSummary: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: FamilyHealthSummary;
}>(`/health/family/patients/${patientId}/health-summary`);
return data.data;
},
};

View File

@@ -1,97 +0,0 @@
/**
* followUp API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { followUpApi } from './followUp'
beforeEach(() => {
vi.clearAllMocks()
})
describe('followUpApi - Tasks', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listTasks 应调用 GET /health/follow-up-tasks 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpApi.listTasks({ page: 1, page_size: 20, patient_id: 'p-001', status: 'pending' })
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-tasks', {
params: { page: 1, page_size: 20, patient_id: 'p-001', status: 'pending' },
})
})
it('getTask 应调用 GET /health/follow-up-tasks/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpApi.getTask('task-001')
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-tasks/task-001')
})
it('createTask 应调用 POST /health/follow-up-tasks', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { patient_id: 'p-001', follow_up_type: 'phone', planned_date: '2026-05-10' }
await followUpApi.createTask(req)
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-tasks', req)
})
it('updateTask 应调用 PUT /health/follow-up-tasks/:id 并传递 version', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { status: 'completed', version: 1 }
await followUpApi.updateTask('task-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/follow-up-tasks/task-001', req)
})
it('deleteTask 应调用 DELETE /health/follow-up-tasks/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await followUpApi.deleteTask('task-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/follow-up-tasks/task-001', {
data: { version: 2 },
})
})
})
describe('followUpApi - Records', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listRecords 应调用 GET /health/follow-up-records 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpApi.listRecords({ page: 1, page_size: 10, task_id: 'task-001' })
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-records', {
params: { page: 1, page_size: 10, task_id: 'task-001' },
})
})
it('createRecord 应调用 POST /health/follow-up-tasks/:taskId/records 并注入 task_id', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
executed_date: '2026-05-10',
result: '已完成',
patient_condition: '良好',
}
await followUpApi.createRecord('task-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-tasks/task-001/records', {
...req,
task_id: 'task-001',
})
})
})

View File

@@ -1,133 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface FollowUpTask {
id: string;
patient_id: string;
assigned_to?: string;
patient_name?: string;
assigned_to_name?: string;
follow_up_type: string;
planned_date: string;
status: string;
content_template?: string;
related_appointment_id?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFollowUpTaskReq {
patient_id: string;
assigned_to?: string;
follow_up_type: string;
planned_date: string;
content_template?: string;
related_appointment_id?: string;
}
export interface UpdateFollowUpTaskReq {
assigned_to?: string;
follow_up_type?: string;
planned_date?: string;
content_template?: string;
status?: string;
}
export interface FollowUpRecord {
id: string;
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFollowUpRecordReq {
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
}
// --- API ---
export const followUpApi = {
// Tasks
listTasks: async (params: {
page?: number;
page_size?: number;
patient_id?: string;
assigned_to?: string;
status?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpTask>;
}>('/health/follow-up-tasks', { params });
return data.data;
},
getTask: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FollowUpTask;
}>(`/health/follow-up-tasks/${id}`);
return data.data;
},
createTask: async (req: CreateFollowUpTaskReq) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpTask;
}>('/health/follow-up-tasks', req);
return data.data;
},
updateTask: async (
id: string,
req: UpdateFollowUpTaskReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: FollowUpTask;
}>(`/health/follow-up-tasks/${id}`, req);
return data.data;
},
deleteTask: async (id: string, version: number) => {
await client.delete(`/health/follow-up-tasks/${id}`, {
data: { version },
});
},
// Records
listRecords: async (params: {
page?: number;
page_size?: number;
task_id?: string;
patient_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpRecord>;
}>('/health/follow-up-records', { params });
return data.data;
},
createRecord: async (taskId: string, req: Omit<CreateFollowUpRecordReq, 'task_id'>) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpRecord;
}>(`/health/follow-up-tasks/${taskId}/records`, { ...req, task_id: taskId });
return data.data;
},
};

View File

@@ -1,75 +0,0 @@
/**
* followUpTemplates API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { followUpTemplateApi } from './followUpTemplates'
beforeEach(() => {
vi.clearAllMocks()
})
describe('followUpTemplateApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/follow-up-templates 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpTemplateApi.list({ page: 1, page_size: 10, follow_up_type: 'phone', status: 'active' })
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-templates', {
params: { page: 1, page_size: 10, follow_up_type: 'phone', status: 'active' },
})
})
it('get 应调用 GET /health/follow-up-templates/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpTemplateApi.get('tpl-001')
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001')
})
it('create 应调用 POST /health/follow-up-templates 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
name: '电话随访模板',
follow_up_type: 'phone',
fields: [
{ label: '患者状态', field_key: 'patient_status', field_type: 'select', required: true, options: '良好,一般,较差' },
],
}
await followUpTemplateApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-templates', req)
})
it('update 应调用 PUT /health/follow-up-templates/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '更新后模板', status: 'active', version: 1 }
await followUpTemplateApi.update('tpl-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001', req)
})
it('delete 应调用 DELETE /health/follow-up-templates/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await followUpTemplateApi.delete('tpl-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001', {
data: { version: 2 },
})
})
})

View File

@@ -1,119 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export type FollowUpType = 'phone' | 'outpatient' | 'home_visit' | 'online' | 'wechat';
export type TemplateStatus = 'active' | 'draft' | 'archived';
export interface TemplateField {
id: string;
template_id: string;
label: string;
field_key: string;
field_type: string;
required: boolean;
options?: string;
placeholder?: string;
validation?: string;
sort_order: number;
created_at: string;
updated_at: string;
version: number;
}
export interface TemplateFieldReq {
label: string;
field_key: string;
field_type: string;
required?: boolean;
options?: string;
placeholder?: string;
validation?: string;
sort_order?: number;
}
export interface FollowUpTemplate {
id: string;
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
status: string;
fields: TemplateField[];
created_at: string;
updated_at: string;
version: number;
}
export interface FollowUpTemplateListItem {
id: string;
name: string;
description?: string;
follow_up_type: string;
status: string;
field_count: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateTemplateReq {
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
fields: TemplateFieldReq[];
}
export interface UpdateTemplateReq {
name?: string;
description?: string;
follow_up_type?: string;
applicable_scope?: string;
status?: string;
fields?: TemplateFieldReq[];
}
export const followUpTemplateApi = {
list: async (params?: {
page?: number;
page_size?: number;
follow_up_type?: string;
status?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpTemplateListItem>;
}>('/health/follow-up-templates', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FollowUpTemplate;
}>(`/health/follow-up-templates/${id}`);
return data.data;
},
create: async (req: CreateTemplateReq) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpTemplate;
}>('/health/follow-up-templates', req);
return data.data;
},
update: async (id: string, req: UpdateTemplateReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: FollowUpTemplate;
}>(`/health/follow-up-templates/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/follow-up-templates/${id}`, {
data: { version },
});
},
};

View File

@@ -1,135 +0,0 @@
/**
* healthData API 契约测试(体征/化验报告/健康记录/趋势/日常监测)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { healthDataApi } from './healthData'
beforeEach(() => {
vi.clearAllMocks()
})
describe('healthDataApi - Vital Signs', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listVitalSigns 应调用 GET /health/patients/:id/vital-signs 并传递分页', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listVitalSigns('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/vital-signs', {
params: { page: 1, page_size: 10 },
})
})
it('createVitalSigns 应调用 POST /health/patients/:id/vital-signs', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { record_date: '2026-05-03', systolic_bp_morning: 120, diastolic_bp_morning: 80 }
await healthDataApi.createVitalSigns('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/vital-signs', req)
})
it('updateVitalSigns 应调用 PUT /health/patients/:pid/vital-signs/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { systolic_bp_morning: 125, version: 1 }
await healthDataApi.updateVitalSigns('p-001', 'vs-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/vital-signs/vs-001', req)
})
it('deleteVitalSigns 应调用 DELETE /health/patients/:pid/vital-signs/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await healthDataApi.deleteVitalSigns('p-001', 'vs-001')
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/vital-signs/vs-001')
})
})
describe('healthDataApi - Lab Reports', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listLabReports 应调用 GET /health/patients/:id/lab-reports', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listLabReports('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/lab-reports', {
params: { page: 1, page_size: 10 },
})
})
it('createLabReport 应调用 POST /health/patients/:id/lab-reports', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { report_date: '2026-05-03', report_type: 'blood_test' }
await healthDataApi.createLabReport('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/lab-reports', req)
})
it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { version: 1, doctor_notes: '指标正常' }
await healthDataApi.reviewLabReport('p-001', 'lr-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/lab-reports/lr-001/review', req)
})
it('deleteLabReport 应调用 DELETE /health/patients/:pid/lab-reports/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await healthDataApi.deleteLabReport('p-001', 'lr-001')
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/lab-reports/lr-001')
})
})
describe('healthDataApi - Health Records', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listHealthRecords 应调用 GET /health/patients/:id/health-records', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listHealthRecords('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/health-records', {
params: { page: 1, page_size: 10 },
})
})
it('createHealthRecord 应调用 POST /health/patients/:id/health-records', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { record_type: 'checkup', record_date: '2026-05-03', content: '体检结果正常' }
await healthDataApi.createHealthRecord('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/health-records', req)
})
})
describe('healthDataApi - Trends', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listTrends 应调用 GET /health/patients/:id/trends', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listTrends('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/trends')
})
it('getIndicatorTimeseries 应调用 GET /health/patients/:id/trends/:indicator 并编码', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.getIndicatorTimeseries('p-001', 'blood_pressure/systolic')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/trends/blood_pressure%2Fsystolic')
})
})

View File

@@ -1,304 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface VitalSigns {
id: string;
patient_id: string;
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
systolic_bp_evening?: number;
diastolic_bp_evening?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateVitalSignsReq {
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
systolic_bp_evening?: number;
diastolic_bp_evening?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
}
export interface LabReport {
id: string;
patient_id: string;
report_date: string;
report_type: string;
items?: unknown;
image_urls?: string[];
doctor_notes?: string;
source?: string;
status: string;
reviewed_by?: string;
reviewed_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateLabReportReq {
report_date: string;
report_type: string;
items?: unknown;
image_urls?: string[];
doctor_notes?: string;
}
export interface HealthRecord {
id: string;
patient_id: string;
record_type: string;
record_date: string;
overall_assessment?: string;
report_file_url?: string;
source?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateHealthRecordReq {
record_type: string;
record_date: string;
overall_assessment?: string;
report_file_url?: string;
}
export interface DailyMonitoring {
id: string;
patient_id: string;
record_date: string;
morning_bp_systolic?: number;
morning_bp_diastolic?: number;
evening_bp_systolic?: number;
evening_bp_diastolic?: number;
weight?: number;
blood_sugar?: number;
fluid_intake?: number;
urine_output?: number;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDailyMonitoringReq {
patient_id: string;
record_date: string;
morning_bp_systolic?: number;
morning_bp_diastolic?: number;
evening_bp_systolic?: number;
evening_bp_diastolic?: number;
weight?: number;
blood_sugar?: number;
fluid_intake?: number;
urine_output?: number;
notes?: string;
}
export interface TrendData {
id: string;
patient_id: string;
indicator: string;
trend_data: { date: string; value: number }[];
generated_at: string;
}
// --- API ---
export const healthDataApi = {
// Vital Signs
listVitalSigns: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<VitalSigns>;
}>(`/health/patients/${patientId}/vital-signs`, { params });
return data.data;
},
createVitalSigns: async (patientId: string, req: CreateVitalSignsReq) => {
const { data } = await client.post<{
success: boolean;
data: VitalSigns;
}>(`/health/patients/${patientId}/vital-signs`, req);
return data.data;
},
updateVitalSigns: async (
patientId: string,
id: string,
req: Partial<CreateVitalSignsReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: VitalSigns;
}>(`/health/patients/${patientId}/vital-signs/${id}`, req);
return data.data;
},
deleteVitalSigns: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/vital-signs/${id}`);
},
// Lab Reports
listLabReports: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<LabReport>;
}>(`/health/patients/${patientId}/lab-reports`, { params });
return data.data;
},
createLabReport: async (patientId: string, req: CreateLabReportReq) => {
const { data } = await client.post<{
success: boolean;
data: LabReport;
}>(`/health/patients/${patientId}/lab-reports`, req);
return data.data;
},
updateLabReport: async (
patientId: string,
id: string,
req: Partial<CreateLabReportReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: LabReport;
}>(`/health/patients/${patientId}/lab-reports/${id}`, req);
return data.data;
},
deleteLabReport: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/lab-reports/${id}`);
},
reviewLabReport: async (patientId: string, reportId: string, req: { version: number; doctor_notes?: string }) => {
const { data } = await client.put<{
success: boolean;
data: Record<string, unknown>;
}>(`/health/patients/${patientId}/lab-reports/${reportId}/review`, req);
return data.data;
},
// Health Records
listHealthRecords: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<HealthRecord>;
}>(`/health/patients/${patientId}/health-records`, { params });
return data.data;
},
createHealthRecord: async (
patientId: string,
req: CreateHealthRecordReq,
) => {
const { data } = await client.post<{
success: boolean;
data: HealthRecord;
}>(`/health/patients/${patientId}/health-records`, req);
return data.data;
},
updateHealthRecord: async (
patientId: string,
id: string,
req: Partial<CreateHealthRecordReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: HealthRecord;
}>(`/health/patients/${patientId}/health-records/${id}`, req);
return data.data;
},
deleteHealthRecord: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/health-records/${id}`);
},
// Trends
listTrends: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: TrendData[];
}>(`/health/patients/${patientId}/trends`);
return data.data;
},
generateTrend: async (patientId: string, req: { indicator: string; start_date?: string; end_date?: string }) => {
const { data } = await client.post<{
success: boolean;
data: TrendData;
}>(`/health/patients/${patientId}/trends/generate`, req);
return data.data;
},
getIndicatorTimeseries: async (patientId: string, indicator: string) => {
const { data } = await client.get<{
success: boolean;
data: { date: string; value: number }[];
}>(`/health/patients/${patientId}/trends/${encodeURIComponent(indicator)}`);
return data.data;
},
// Daily Monitoring
listDailyMonitoring: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<DailyMonitoring>;
}>(`/health/patients/${patientId}/daily-monitoring`, { params });
return data.data;
},
createDailyMonitoring: async (req: CreateDailyMonitoringReq) => {
const { data } = await client.post<{
success: boolean;
data: DailyMonitoring;
}>('/health/daily-monitoring', req);
return data.data;
},
updateDailyMonitoring: async (
id: string,
req: Partial<CreateDailyMonitoringReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: DailyMonitoring;
}>(`/health/daily-monitoring/${id}`, req);
return data.data;
},
deleteDailyMonitoring: async (id: string, version: number) => {
await client.delete(`/health/daily-monitoring/${id}`, { data: { version } });
},
};

View File

@@ -1,208 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// ---------------------------------------------------------------------------
// 媒体文件类型
// ---------------------------------------------------------------------------
export interface MediaItem {
id: string;
tenant_id: string;
folder_id?: string;
filename: string;
storage_path: string;
thumbnail_path?: string;
content_type: string;
file_size: number;
width?: number;
height?: number;
alt_text?: string;
is_public: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
version: number;
}
export interface MediaListParams {
page?: number;
page_size?: number;
folder_id?: string;
content_type?: string;
keyword?: string;
is_public?: boolean;
}
export interface UpdateMediaReq {
filename?: string;
alt_text?: string;
is_public?: boolean;
folder_id?: string;
version: number;
}
export interface MoveMediaReq {
folder_id?: string;
version: number;
}
export interface CropReq {
x: number;
y: number;
width: number;
height: number;
version: number;
}
// ---------------------------------------------------------------------------
// 文件夹类型
// ---------------------------------------------------------------------------
export interface FolderItem {
id: string;
tenant_id: string;
name: string;
parent_id?: string;
sort_order: number;
children: FolderItem[];
item_count: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFolderReq {
name: string;
parent_id?: string;
sort_order?: number;
}
export interface UpdateFolderReq {
name?: string;
parent_id?: string;
sort_order?: number;
version: number;
}
// ---------------------------------------------------------------------------
// 媒体文件 API
// ---------------------------------------------------------------------------
export const mediaApi = {
/** 分页查询媒体文件列表 */
list: async (params: MediaListParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MediaItem>;
}>('/health/media', { params });
return data.data;
},
/** 上传媒体文件multipart/form-data */
upload: async (formData: FormData) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>('/health/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data;
},
/** 获取单个媒体文件详情 */
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}`);
return data.data;
},
/** 更新媒体文件信息 */
update: async (id: string, req: UpdateMediaReq) => {
const { data } = await client.put<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}`, req);
return data.data;
},
/** 删除媒体文件 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/media/${id}`, { data: { version } });
return data.data;
},
/** 移动媒体文件到指定文件夹 */
move: async (id: string, req: MoveMediaReq) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}/move`, req);
return data.data;
},
/** 批量删除媒体文件 */
batchDelete: async (ids: string[]) => {
const { data } = await client.post<{
success: boolean;
data: null;
}>('/health/media/batch-delete', { ids });
return data.data;
},
/** 裁剪媒体文件 */
crop: async (id: string, req: CropReq) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}/crop`, req);
return data.data;
},
};
// ---------------------------------------------------------------------------
// 文件夹 API
// ---------------------------------------------------------------------------
export const mediaFolderApi = {
/** 获取文件夹树形结构 */
tree: async () => {
const { data } = await client.get<{
success: boolean;
data: FolderItem[];
}>('/health/media-folders');
return data.data;
},
/** 创建文件夹 */
create: async (req: CreateFolderReq) => {
const { data } = await client.post<{
success: boolean;
data: FolderItem;
}>('/health/media-folders', req);
return data.data;
},
/** 更新文件夹 */
update: async (id: string, req: UpdateFolderReq) => {
const { data } = await client.put<{
success: boolean;
data: FolderItem;
}>(`/health/media-folders/${id}`, req);
return data.data;
},
/** 删除文件夹 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/media-folders/${id}`, { data: { version } });
return data.data;
},
};

View File

@@ -1,111 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface MedicationRecord {
id: string;
patient_id: string;
medication_name: string;
generic_name?: string;
dosage?: string;
unit?: string;
frequency?: string;
route?: string;
start_date?: string;
end_date?: string;
is_current: boolean;
prescribed_by?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateMedicationRecordReq {
patient_id: string;
medication_name: string;
generic_name?: string;
dosage?: string;
unit?: string;
frequency?: string;
route?: string;
start_date?: string;
end_date?: string;
is_current?: boolean;
prescribed_by?: string;
notes?: string;
}
export interface UpdateMedicationRecordReq {
medication_name?: string;
generic_name?: string;
dosage?: string;
unit?: string;
frequency?: string;
route?: string;
start_date?: string;
end_date?: string;
is_current?: boolean;
prescribed_by?: string;
notes?: string;
}
// --- Constants ---
export const FREQUENCY_OPTIONS = [
{ label: '每日一次', value: 'QD' },
{ label: '每日两次', value: 'BID' },
{ label: '每日三次', value: 'TID' },
{ label: '每晚一次', value: 'QN' },
{ label: '每周一次', value: 'QW' },
{ label: '必要时', value: 'PRN' },
];
export const ROUTE_OPTIONS = [
{ label: '口服', value: 'oral' },
{ label: '静脉注射', value: 'iv' },
{ label: '皮下注射', value: 'sc' },
{ label: '外用', value: 'topical' },
{ label: '吸入', value: 'inhalation' },
];
// --- API ---
export const medicationRecordApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MedicationRecord>;
}>(`/health/patients/${patientId}/medications`, { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: MedicationRecord;
}>(`/health/medications/${id}`);
return data.data;
},
create: async (req: CreateMedicationRecordReq) => {
const { data } = await client.post<{
success: boolean;
data: MedicationRecord;
}>('/health/medications', req);
return data.data;
},
update: async (id: string, req: UpdateMedicationRecordReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: MedicationRecord;
}>(`/health/medications/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/medications/${id}`, { data: { version } });
},
};

View File

@@ -1,75 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface MedicationReminder {
id: string;
patient_id: string;
medication_name: string;
dosage?: string;
frequency: string;
reminder_times: unknown;
start_date?: string;
end_date?: string;
is_active: boolean;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateMedicationReminderReq {
patient_id: string;
medication_name: string;
dosage?: string;
frequency?: string;
reminder_times?: unknown;
start_date?: string;
end_date?: string;
is_active?: boolean;
notes?: string;
}
export interface UpdateMedicationReminderReq {
medication_name?: string;
dosage?: string;
frequency?: string;
reminder_times?: unknown;
start_date?: string;
end_date?: string;
is_active?: boolean;
notes?: string;
}
// --- API ---
export const medicationReminderApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MedicationReminder>;
}>(`/health/patients/${patientId}/medication-reminders`, { params });
return data.data;
},
create: async (req: CreateMedicationReminderReq) => {
const { data } = await client.post<{
success: boolean;
data: MedicationReminder;
}>('/health/medication-reminders', req);
return data.data;
},
update: async (id: string, req: UpdateMedicationReminderReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: MedicationReminder;
}>(`/health/medication-reminders/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/medication-reminders/${id}`, { data: { version } });
},
};

View File

@@ -1,73 +0,0 @@
import client from '../client';
// --- Types ---
export interface OAuthClient {
id: string;
client_id: string;
client_name: string;
scopes: string[];
rate_limit_per_minute: number;
is_active: boolean;
token_lifetime_seconds: number;
created_at: string;
version: number;
}
export interface OAuthClientDetail extends OAuthClient {
tenant_id: string;
client_secret: string;
allowed_patient_ids?: string[];
}
export interface CreateOAuthClientReq {
client_name: string;
scopes: string[];
allowed_patient_ids?: string[];
rate_limit_per_minute?: number;
token_lifetime_seconds?: number;
}
export interface UpdateOAuthClientReq {
client_name?: string;
scopes?: string[];
allowed_patient_ids?: string[] | null;
rate_limit_per_minute?: number;
is_active?: boolean;
token_lifetime_seconds?: number;
version: number;
}
export interface RegenerateSecretResp {
client_id: string;
client_secret: string;
}
// --- FHIR Scope ---
export const FHIR_SCOPE_OPTIONS = [
{ value: 'Patient.read', label: 'Patient.read — 读取患者' },
{ value: 'Observation.read', label: 'Observation.read — 读取体征' },
{ value: 'Device.read', label: 'Device.read — 读取设备' },
{ value: 'DiagnosticReport.read', label: 'DiagnosticReport.read — 读取诊断报告' },
{ value: 'Encounter.read', label: 'Encounter.read — 读取就诊记录' },
{ value: 'Practitioner.read', label: 'Practitioner.read — 读取医护' },
{ value: 'Appointment.read', label: 'Appointment.read — 读取预约' },
{ value: 'Task.read', label: 'Task.read — 读取随访任务' },
];
// --- API ---
export const oauthClientApi = {
list: () =>
client.get('/health/oauth/clients').then((r) => r.data.data as OAuthClient[]),
create: (data: CreateOAuthClientReq) =>
client.post('/health/oauth/clients', data).then((r) => r.data.data as OAuthClientDetail),
update: (id: string, data: UpdateOAuthClientReq) =>
client.put(`/health/oauth/clients/${id}`, data).then((r) => r.data.data as OAuthClient),
delete: (id: string) =>
client.delete(`/health/oauth/clients/${id}`).then((r) => r.data),
regenerateSecret: (id: string) =>
client.post(`/health/oauth/clients/${id}/regenerate-secret`).then((r) => r.data.data as RegenerateSecretResp),
};

View File

@@ -1,126 +0,0 @@
/**
* patients API 契约测试
*
* 验证 patientApi 各函数调用正确的 HTTP 方法、URL 路径和参数序列化。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { patientApi } from './patients'
beforeEach(() => {
vi.clearAllMocks()
})
describe('patientApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/patients 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.list({ page: 1, page_size: 20, search: '张三', status: 'active' })
expect(mockGet).toHaveBeenCalledWith('/health/patients', {
params: { page: 1, page_size: 20, search: '张三', status: 'active' },
})
})
it('list 应支持 tag_id 过滤参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.list({ tag_id: 'tag-001' })
expect(mockGet).toHaveBeenCalledWith('/health/patients', {
params: { tag_id: 'tag-001' },
})
})
it('get 应调用 GET /health/patients/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.get('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001')
})
it('create 应调用 POST /health/patients 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '李四', gender: 'male', birth_date: '1990-01-01' }
await patientApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/patients', req)
})
it('update 应调用 PUT /health/patients/:id 并传递请求体含 version', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '李四改', version: 2 }
await patientApi.update('p-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001', req)
})
it('delete 应调用 DELETE /health/patients/:id 并在 body 中传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await patientApi.delete('p-001', 3)
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001', {
data: { version: 3 },
})
})
it('manageTags 应调用 POST /health/patients/:id/tags 并传递 tag_ids', async () => {
mockPost.mockResolvedValue(undefined)
await patientApi.manageTags('p-001', ['tag-1', 'tag-2'])
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/tags', {
tag_ids: ['tag-1', 'tag-2'],
})
})
it('listFamilyMembers 应调用 GET /health/patients/:id/family-members', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.listFamilyMembers('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/family-members')
})
it('createFamilyMember 应调用 POST /health/patients/:id/family-members', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '家属A', relationship: 'spouse', phone: '13800138000' }
await patientApi.createFamilyMember('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/family-members', req)
})
it('updateFamilyMember 应调用 PUT /health/patients/:pid/family-members/:mid', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '家属A改', version: 1 }
await patientApi.updateFamilyMember('p-001', 'fm-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/family-members/fm-001', req)
})
it('deleteFamilyMember 应调用 DELETE /health/patients/:pid/family-members/:mid', async () => {
mockDelete.mockResolvedValue(undefined)
await patientApi.deleteFamilyMember('p-001', 'fm-001')
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/family-members/fm-001')
})
it('listTags 应调用 GET /health/patient-tags', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.listTags()
expect(mockGet).toHaveBeenCalledWith('/health/patient-tags')
})
})

View File

@@ -1,175 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface PatientListItem {
id: string;
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
status: string;
verification_status: string;
source?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface PatientDetail {
id: string;
user_id?: string;
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
status: string;
verification_status: string;
source?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePatientReq {
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface UpdatePatientReq extends Partial<CreatePatientReq> {
status?: string;
verification_status?: string;
}
export interface FamilyMember {
id: string;
name: string;
relationship: string;
phone?: string;
id_number?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFamilyMemberReq {
name: string;
relationship: string;
phone?: string;
id_number?: string;
notes?: string;
}
export interface TagItem {
id: string;
name: string;
color: string | null;
description: string | null;
}
// --- API ---
export const patientApi = {
list: async (params: {
page?: number;
page_size?: number;
search?: string;
status?: string;
tag_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PatientListItem>;
}>('/health/patients', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: PatientDetail;
}>(`/health/patients/${id}`);
return data.data;
},
create: async (req: CreatePatientReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientDetail;
}>('/health/patients', req);
return data.data;
},
update: async (id: string, req: UpdatePatientReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PatientDetail;
}>(`/health/patients/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/patients/${id}`, { data: { version } });
},
manageTags: async (id: string, tagIds: string[]) => {
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
},
listFamilyMembers: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FamilyMember[];
}>(`/health/patients/${id}/family-members`);
return data.data;
},
createFamilyMember: async (id: string, req: CreateFamilyMemberReq) => {
const { data } = await client.post<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${id}/family-members`, req);
return data.data;
},
updateFamilyMember: async (
patientId: string,
memberId: string,
req: Partial<CreateFamilyMemberReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${memberId}`, req);
return data.data;
},
deleteFamilyMember: async (patientId: string, memberId: string) => {
await client.delete(
`/health/patients/${patientId}/family-members/${memberId}`,
);
},
listTags: async () => {
const { data } = await client.get<{
success: boolean;
data: TagItem[];
}>('/health/patient-tags');
return data.data;
},
};

View File

@@ -1,230 +0,0 @@
/**
* points API 契约测试(完整覆盖 pointsApi + pointsAdminApi
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { pointsApi, pointsAdminApi } from './points'
beforeEach(() => {
vi.clearAllMocks()
})
describe('pointsAdminApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsAdminApi.getPatientAccount('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account')
})
it('listPatientTransactions 应调用 GET 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsAdminApi.listPatientTransactions('p-001', { page: 2, page_size: 15 })
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/transactions', {
params: { page: 2, page_size: 15 },
})
})
})
describe('pointsApi - Rules', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listRules 应调用 GET /health/admin/points/rules', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listRules()
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/rules')
})
it('createRule 应调用 POST /health/admin/points/rules', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { event_type: 'daily_checkin', name: '每日签到', points_value: 10, daily_cap: 1 }
await pointsApi.createRule(req)
expect(mockPost).toHaveBeenCalledWith('/health/admin/points/rules', req)
})
it('updateRule 应调用 PUT /health/admin/points/rules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { points_value: 20, version: 1 }
await pointsApi.updateRule('rule-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/admin/points/rules/rule-001', {
data: req,
version: req.version,
})
})
it('deleteRule 应调用 DELETE /health/admin/points/rules/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await pointsApi.deleteRule('rule-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/admin/points/rules/rule-001', {
data: { version: 2 },
})
})
})
describe('pointsApi - Products', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listProducts 应调用 GET /health/points/products', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listProducts()
expect(mockGet).toHaveBeenCalledWith('/health/points/products', { params: undefined })
})
it('createProduct 应调用 POST /health/admin/points/products', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '体检优惠券', product_type: 'service', points_cost: 500, stock: 100 }
await pointsApi.createProduct(req)
expect(mockPost).toHaveBeenCalledWith('/health/admin/points/products', req)
})
it('updateProduct 应调用 PUT /health/admin/points/products/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { points_cost: 600, version: 1 }
await pointsApi.updateProduct('prod-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/admin/points/products/prod-001', {
data: req,
version: req.version,
})
})
it('deleteProduct 应调用 DELETE /health/admin/points/products/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await pointsApi.deleteProduct('prod-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/admin/points/products/prod-001', {
data: { version: 1 },
})
})
})
describe('pointsApi - Orders', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listOrders 应调用 GET /health/admin/points/orders', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listOrders()
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/orders', { params: undefined })
})
it('verifyOrder 应调用 POST /health/points/verify', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { qr_code: 'QR-123456' }
await pointsApi.verifyOrder(req)
expect(mockPost).toHaveBeenCalledWith('/health/points/verify', req)
})
})
describe('pointsApi - Offline Events', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listOfflineEvents 应调用 GET /health/admin/offline-events', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listOfflineEvents()
expect(mockGet).toHaveBeenCalledWith('/health/admin/offline-events', { params: undefined })
})
it('createOfflineEvent 应调用 POST /health/admin/offline-events', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { title: '健康讲座', event_date: '2026-05-20', points_reward: 50 }
await pointsApi.createOfflineEvent(req)
expect(mockPost).toHaveBeenCalledWith('/health/admin/offline-events', req)
})
it('updateOfflineEvent 应调用 PUT /health/admin/offline-events/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { title: '健康讲座(更新)', version: 1 }
await pointsApi.updateOfflineEvent('evt-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/admin/offline-events/evt-001', req)
})
it('deleteOfflineEvent 应调用 DELETE /health/admin/offline-events/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await pointsApi.deleteOfflineEvent('evt-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/admin/offline-events/evt-001', {
data: { version: 1 },
})
})
})
describe('pointsApi - Statistics', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getStatistics 应调用 GET /health/admin/points/statistics', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getStatistics()
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/statistics')
})
it('getPatientStats 应调用 GET /health/admin/statistics/patients', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getPatientStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/patients')
})
it('getConsultationStats 应调用 GET /health/admin/statistics/consultations', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getConsultationStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/consultations')
})
it('getFollowUpStats 应调用 GET /health/admin/statistics/follow-ups', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getFollowUpStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/follow-ups')
})
it('getHealthDataStats 应调用 GET /health/admin/statistics/health-data', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getHealthDataStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/health-data')
})
it('getDialysisStats 应调用 GET /health/admin/statistics/dialysis', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getDialysisStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/dialysis')
})
it('getPersonalStats 应调用 GET /health/admin/statistics/personal-stats', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getPersonalStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/personal-stats')
})
})

View File

@@ -1,446 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface PointsRule {
id: string;
event_type: string;
name: string;
description: string | null;
points_value: number;
daily_cap: number;
streak_7d_bonus: number;
streak_14d_bonus: number;
streak_30d_bonus: number;
is_active: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePointsRuleReq {
event_type: string;
name: string;
description?: string;
points_value: number;
daily_cap?: number;
streak_7d_bonus?: number;
streak_14d_bonus?: number;
streak_30d_bonus?: number;
}
export interface PointsProduct {
id: string;
name: string;
product_type: string; // physical / service / privilege
points_cost: number;
stock: number;
image_url: string | null;
description: string | null;
is_active: boolean;
sort_order: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePointsProductReq {
name: string;
product_type: string;
points_cost: number;
stock: number;
description?: string;
image_url?: string;
sort_order?: number;
}
export interface PointsOrder {
id: string;
patient_id: string;
product_id: string;
product_name: string | null;
points_cost: number;
status: string; // pending / verified / cancelled / expired
qr_code: string;
verified_by: string | null;
verified_at: string | null;
expires_at: string | null;
notes: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface VerifyOrderReq {
qr_code: string;
}
export interface OfflineEvent {
id: string;
title: string;
description: string | null;
event_date: string;
start_time: string | null;
end_time: string | null;
location: string | null;
points_reward: number;
max_participants: number;
current_participants: number;
status: string; // draft / published / ongoing / completed / cancelled
image_url: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateOfflineEventReq {
title: string;
description?: string;
event_date: string;
start_time?: string;
end_time?: string;
location?: string;
points_reward?: number;
max_participants?: number;
status?: string;
image_url?: string;
}
export interface PointsStatistics {
total_issued: number;
total_spent: number;
total_expired: number;
active_accounts: number;
top_earners: Array<{
account_id: string;
patient_id: string;
patient_name: string;
total_earned: number;
}>;
}
export interface PatientStatistics {
total_patients: number;
new_this_month: number;
new_this_week: number;
active_this_month: number;
}
export interface ConsultationStatistics {
total_sessions: number;
pending_reply: number;
avg_response_time_minutes: number | null;
this_month: number;
}
export interface FollowUpStatistics {
total_tasks: number;
completed: number;
pending: number;
overdue: number;
completion_rate: number;
}
export interface PersonalStats {
my_patients: number;
new_patients_this_month: number;
follow_up_rate: number;
consultations_this_month: number;
pending_consultations: number;
vital_signs_report_rate: number;
today_appointments: number;
overdue_follow_ups: number;
today_follow_ups: number;
abnormal_vital_signs: number;
vital_signs_reported: number;
vital_signs_total: number;
pending_lab_reviews: number;
yesterday_my_patients?: number;
yesterday_today_appointments?: number;
yesterday_consultations_this_month?: number;
yesterday_follow_up_rate?: number;
yesterday_today_follow_ups?: number;
yesterday_overdue_follow_ups?: number;
}
export interface OverviewStatistics {
patients: PatientStatistics;
consultations: ConsultationStatistics;
follow_ups: FollowUpStatistics;
points: PointsStatistics;
}
// --- Health Data Statistics Types ---
export interface NameValue {
name: string;
value: number;
}
export interface DialysisStatistics {
total_records: number;
this_month: number;
type_distribution: NameValue[];
complication_rate: number;
avg_ultrafiltration: number | null;
avg_duration: number | null;
pending_review: number;
}
export interface LabReportStatistics {
total_reports: number;
this_month: number;
type_distribution: NameValue[];
abnormal_items: number;
pending_review: number;
reviewed: number;
}
export interface AppointmentStatistics {
total_appointments: number;
this_month: number;
status_distribution: NameValue[];
type_distribution: NameValue[];
cancel_rate: number;
}
export interface DailyReportRate {
date: string;
reported: number;
total: number;
rate: number;
}
export interface VitalSignsReportRate {
total_patients: number;
reported_patients: number;
report_rate: number;
total_records: number;
daily_trend: DailyReportRate[];
}
export interface HealthDataStats {
lab_reports: LabReportStatistics;
appointments: AppointmentStatistics;
vital_signs_report_rate: VitalSignsReportRate;
}
// --- API ---
export interface PointsAccountDetail {
id: string;
patient_id: string;
balance: number;
total_earned: number;
total_spent: number;
total_expired: number;
}
export interface PointsTransactionDetail {
id: string;
account_id: string;
transaction_type: string;
amount: number;
remaining_amount: number;
status: string;
expires_at: string | null;
balance_after: number;
description: string | null;
created_at: string;
}
export const pointsAdminApi = {
getPatientAccount: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: PointsAccountDetail;
}>(`/health/admin/points/patients/${patientId}/account`);
return data.data;
},
listPatientTransactions: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PointsTransactionDetail>;
}>(`/health/admin/points/patients/${patientId}/transactions`, { params });
return data.data;
},
};
// --- API (original) ---
export const pointsApi = {
// Rules
listRules: async () => {
const { data } = await client.get<{
success: boolean;
data: PointsRule[];
}>('/health/admin/points/rules');
return data.data;
},
createRule: async (req: CreatePointsRuleReq) => {
const { data } = await client.post<{
success: boolean;
data: PointsRule;
}>('/health/admin/points/rules', req);
return data.data;
},
updateRule: async (id: string, req: Partial<CreatePointsRuleReq> & { is_active?: boolean; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PointsRule;
}>(`/health/admin/points/rules/${id}`, req);
return data.data;
},
deleteRule: async (id: string, version: number) => {
await client.delete(`/health/admin/points/rules/${id}`, {
data: { version },
});
},
// Products
listProducts: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PointsProduct>;
}>('/health/points/products', { params });
return data.data;
},
createProduct: async (req: CreatePointsProductReq) => {
const { data } = await client.post<{
success: boolean;
data: PointsProduct;
}>('/health/admin/points/products', req);
return data.data;
},
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
const { version, ...fields } = req;
const { data } = await client.put<{
success: boolean;
data: PointsProduct;
}>(`/health/admin/points/products/${id}`, { data: fields, version });
return data.data;
},
deleteProduct: async (id: string, version: number) => {
await client.delete(`/health/admin/points/products/${id}`, {
data: { version },
});
},
// Orders
listOrders: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PointsOrder>;
}>('/health/admin/points/orders', { params });
return data.data;
},
verifyOrder: async (req: VerifyOrderReq) => {
const { data } = await client.post<{
success: boolean;
data: PointsOrder;
}>('/health/points/verify', req);
return data.data;
},
// Offline Events
listOfflineEvents: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<OfflineEvent>;
}>('/health/admin/offline-events', { params });
return data.data;
},
createOfflineEvent: async (req: CreateOfflineEventReq) => {
const { data } = await client.post<{
success: boolean;
data: OfflineEvent;
}>('/health/admin/offline-events', req);
return data.data;
},
updateOfflineEvent: async (id: string, req: Partial<CreateOfflineEventReq> & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: OfflineEvent;
}>(`/health/admin/offline-events/${id}`, req);
return data.data;
},
deleteOfflineEvent: async (id: string, version: number) => {
await client.delete(`/health/admin/offline-events/${id}`, {
data: { version },
});
},
// Points Statistics
getStatistics: async (opts?: { silent?: boolean }) => {
const { data } = await client.get<{
success: boolean;
data: PointsStatistics;
}>('/health/admin/points/statistics', { skipGlobalError: opts?.silent });
return data.data;
},
// --- Dashboard Statistics ---
getPatientStats: async (opts?: { silent?: boolean }): Promise<PatientStatistics> => {
const { data } = await client.get<{
success: boolean;
data: PatientStatistics;
}>('/health/admin/statistics/patients', { skipGlobalError: opts?.silent });
return data.data;
},
getConsultationStats: async (opts?: { silent?: boolean }): Promise<ConsultationStatistics> => {
const { data } = await client.get<{
success: boolean;
data: ConsultationStatistics;
}>('/health/admin/statistics/consultations', { skipGlobalError: opts?.silent });
return data.data;
},
getFollowUpStats: async (opts?: { silent?: boolean }): Promise<FollowUpStatistics> => {
const { data } = await client.get<{
success: boolean;
data: FollowUpStatistics;
}>('/health/admin/statistics/follow-ups', { skipGlobalError: opts?.silent });
return data.data;
},
getHealthDataStats: async (opts?: { silent?: boolean }): Promise<HealthDataStats> => {
const { data } = await client.get<{
success: boolean;
data: HealthDataStats;
}>('/health/admin/statistics/health-data', { skipGlobalError: opts?.silent });
return data.data;
},
getDialysisStats: async (opts?: { silent?: boolean }): Promise<DialysisStatistics> => {
const { data } = await client.get<{
success: boolean;
data: DialysisStatistics;
}>('/health/admin/statistics/dialysis', { skipGlobalError: opts?.silent });
return data.data;
},
getPersonalStats: async (opts?: { silent?: boolean }): Promise<PersonalStats> => {
const { data } = await client.get<{
success: boolean;
data: PersonalStats;
}>('/health/admin/statistics/personal-stats', { skipGlobalError: opts?.silent });
return data.data;
},
};

View File

@@ -1,247 +0,0 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Shift {
id: string;
tenant_id: string;
shift_date: string;
period: string;
nurse_id?: string;
status: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
patient_count?: number;
critical_count?: number;
attention_count?: number;
}
export interface PatientAssignment {
id: string;
tenant_id: string;
shift_id: string;
patient_id: string;
care_level: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
patient_name?: string;
}
export interface HandoffLog {
id: string;
tenant_id: string;
from_shift_id: string;
to_shift_id: string;
patient_id: string;
notes?: string;
pending_items?: Record<string, unknown>;
created_at: string;
updated_at: string;
version: number;
patient_name?: string;
}
export interface CreateShiftReq {
shift_date: string;
period: string;
nurse_id?: string;
notes?: string;
}
export interface UpdateShiftReq {
shift_date?: string;
period?: string;
nurse_id?: string;
status?: string;
notes?: string;
}
export interface ListShiftsParams {
page?: number;
page_size?: number;
shift_date?: string;
period?: string;
nurse_id?: string;
status?: string;
}
export interface CreatePatientAssignmentReq {
patient_id: string;
care_level?: string;
notes?: string;
}
export interface BatchAssignReq {
patient_ids: string[];
care_level?: string;
}
export interface UpdatePatientAssignmentReq {
care_level?: string;
notes?: string;
}
export interface CreateHandoffReq {
from_shift_id: string;
to_shift_id: string;
patient_id: string;
notes?: string;
pending_items?: Record<string, unknown>;
}
export interface ListHandoffParams {
page?: number;
page_size?: number;
from_shift_id?: string;
to_shift_id?: string;
}
// --- Constants ---
export const PERIOD_OPTIONS = [
{ label: '上午班', value: 'morning' },
{ label: '下午班', value: 'afternoon' },
{ label: '晚班', value: 'evening' },
{ label: '夜班', value: 'night' },
];
export const SHIFT_STATUS_OPTIONS = [
{ label: '待开始', value: 'scheduled' },
{ label: '进行中', value: 'in_progress' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
];
export const CARE_LEVEL_OPTIONS = [
{ label: '稳定', value: 'stable' },
{ label: '需关注', value: 'attention' },
{ label: '危重', value: 'critical' },
];
export const PERIOD_LABEL: Record<string, string> = Object.fromEntries(
PERIOD_OPTIONS.map((o) => [o.value, o.label]),
);
export const SHIFT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
SHIFT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
export const SHIFT_STATUS_COLOR: Record<string, string> = {
scheduled: 'default',
in_progress: 'processing',
completed: 'success',
cancelled: 'error',
};
export const CARE_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
CARE_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
export const CARE_LEVEL_COLOR: Record<string, string> = {
stable: 'green',
attention: 'orange',
critical: 'red',
};
// --- API ---
export const shiftApi = {
// --- Shifts ---
list: async (params: ListShiftsParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Shift>;
}>('/health/shifts', { params });
return data.data;
},
get: async (shiftId: string) => {
const { data } = await client.get<{
success: boolean;
data: Shift;
}>(`/health/shifts/${shiftId}`);
return data.data;
},
create: async (req: CreateShiftReq) => {
const { data } = await client.post<{
success: boolean;
data: Shift;
}>('/health/shifts', req);
return data.data;
},
update: async (shiftId: string, req: UpdateShiftReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Shift;
}>(`/health/shifts/${shiftId}`, req);
return data.data;
},
delete: async (shiftId: string, version: number) => {
await client.delete(`/health/shifts/${shiftId}`, { data: { version } });
},
// --- Assignments ---
listAssignments: async (shiftId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PatientAssignment>;
}>(`/health/shifts/${shiftId}/assignments`, { params });
return data.data;
},
createAssignment: async (shiftId: string, req: CreatePatientAssignmentReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientAssignment;
}>(`/health/shifts/${shiftId}/assignments`, req);
return data.data;
},
batchAssign: async (shiftId: string, req: BatchAssignReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientAssignment[];
}>(`/health/shifts/${shiftId}/assignments/batch`, req);
return data.data;
},
updateAssignment: async (shiftId: string, assignmentId: string, req: UpdatePatientAssignmentReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PatientAssignment;
}>(`/health/shifts/${shiftId}/assignments/${assignmentId}`, req);
return data.data;
},
deleteAssignment: async (shiftId: string, assignmentId: string, version: number) => {
await client.delete(`/health/shifts/${shiftId}/assignments/${assignmentId}`, { data: { version } });
},
// --- Handoff Logs ---
listHandoffs: async (params?: ListHandoffParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<HandoffLog>;
}>('/health/handoff-logs', { params });
return data.data;
},
createHandoff: async (req: CreateHandoffReq) => {
const { data } = await client.post<{
success: boolean;
data: HandoffLog;
}>('/health/handoff-logs', req);
return data.data;
},
};

View File

@@ -33,10 +33,10 @@ export interface BrandConfig {
}
const BRAND_DEFAULTS: BrandConfig = {
brand_name: 'HMS 健康管理台',
brand_slogan: '新一代健康管理平台',
brand_features: '患者管理 · 健康监测 · 随访管理 · AI 智能分析',
brand_copyright: 'HMS 健康管理平台 · ©汕头市智界科技有限公司',
brand_name: '暖记管理台',
brand_slogan: '班级管理·日记审核·成长追踪',
brand_features: '班级管理 · 日记审核 · 老师点评 · 成长追踪',
brand_copyright: '© 暖记 Nuanji',
};
export async function getPublicBrand(): Promise<BrandConfig> {

View File

@@ -1,226 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Drawer, Timeline, Button, Spin, Result, Space, Tag } from 'antd';
import {
CheckCircleOutlined,
ClockCircleOutlined,
MinusCircleOutlined,
CloseCircleOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
actionInboxApi,
type ActionItem,
type ThreadResponse,
type ActionPriority,
} from '../api/health/actionInbox';
import client from '../api/client';
interface Props {
open: boolean;
item: ActionItem | null;
onClose: () => void;
onActionComplete?: () => void;
}
const PRIORITY_COLOR: Record<ActionPriority, string> = {
urgent: 'red',
high: 'orange',
medium: 'blue',
low: 'default',
};
const PRIORITY_LABEL: Record<ActionPriority, string> = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低',
};
export default function ActionThreadDrawer({
open,
item,
onClose,
onActionComplete,
}: Props) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const fetchThread = useCallback(async () => {
if (!item) return;
setLoading(true);
setError(null);
try {
const data = await actionInboxApi.getThread(item.source_ref);
setThreadData(data);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '获取线程失败';
setError(msg);
} finally {
setLoading(false);
}
}, [item]);
useEffect(() => {
if (open && item) fetchThread();
if (!open) setThreadData(null);
}, [open, item, fetchThread]);
const handleAction = async (endpoint: string, key: string) => {
setActionLoading(key);
try {
await client.post(endpoint, { action: key });
onActionComplete?.();
fetchThread();
} catch {
// 全局拦截器处理
} finally {
setActionLoading(null);
}
};
const handleLinkClick = (linkTo?: string) => {
if (linkTo) {
onClose();
navigate(linkTo);
}
};
const timelineDot = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
case 'in_progress':
return <ClockCircleOutlined style={{ color: '#faad14' }} />;
case 'dismissed':
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
default:
return <MinusCircleOutlined style={{ color: '#d9d9d9' }} />;
}
};
return (
<Drawer
title={null}
open={open}
onClose={onClose}
width={480}
styles={{ body: { padding: 0 } }}
>
{loading && (
<div style={{ padding: 40, textAlign: 'center' }}>
<Spin size="large" />
</div>
)}
{error && (
<div style={{ padding: 24 }}>
<Result status="error" title="加载失败" subTitle={error} />
</div>
)}
{threadData && !loading && (
<>
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid #f0f0f0',
}}
>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{threadData.action_item.title}
</div>
<div
style={{ marginTop: 4, color: '#8c8c8c', fontSize: 13 }}
>
<UserOutlined /> {threadData.action_item.patient_name}
<Tag
color={PRIORITY_COLOR[threadData.action_item.priority]}
style={{ marginLeft: 8 }}
>
{PRIORITY_LABEL[threadData.action_item.priority]}
</Tag>
</div>
</div>
<div style={{ padding: '20px 24px' }}>
<div style={{ fontWeight: 500, marginBottom: 16 }}>
</div>
<Timeline
items={threadData.thread.map((evt) => ({
color:
evt.status === 'completed'
? 'green'
: evt.status === 'in_progress'
? 'blue'
: 'gray',
dot: timelineDot(evt.status),
children: (
<div>
<div
style={{
fontWeight:
evt.status === 'in_progress' ? 600 : 400,
}}
>
{evt.label}
</div>
{evt.detail && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
{evt.detail}
</div>
)}
{evt.timestamp && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
{new Date(evt.timestamp).toLocaleString('zh-CN')}
</div>
)}
{evt.link_to && (
<a
onClick={() => handleLinkClick(evt.link_to)}
style={{ fontSize: 12 }}
>
</a>
)}
</div>
),
}))}
/>
</div>
{threadData.available_actions.length > 0 && (
<div
style={{
padding: '12px 24px',
borderTop: '1px solid #f0f0f0',
}}
>
<Space>
{threadData.available_actions.map((action) => (
<Button
key={action.key}
type={action.variant === 'primary' ? 'primary' : 'default'}
danger={action.variant === 'danger'}
loading={actionLoading === action.key}
onClick={() => {
if (action.api_endpoint) {
handleAction(action.api_endpoint, action.key);
}
}}
>
{action.label}
</Button>
))}
</Space>
</div>
)}
</>
)}
</Drawer>
);
}

View File

@@ -1,106 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Alert, Badge, List, Button, Space, Typography, Spin } from 'antd';
import { CheckOutlined } from '@ant-design/icons';
import { listAlerts, dismissInsight } from '../../api/copilot';
import type { CopilotInsight } from '../../api/copilot';
const severityConfig: Record<string, { type: 'success' | 'processing' | 'warning' | 'error'; label: string }> = {
critical: { type: 'error', label: '危急' },
warning: { type: 'warning', label: '警告' },
info: { type: 'processing', label: '提示' },
};
export function CopilotAlert() {
const [alerts, setAlerts] = useState<CopilotInsight[]>([]);
const [loading, setLoading] = useState(false);
const [dismissing, setDismissing] = useState<string | null>(null);
const fetchAlerts = useCallback(async () => {
setLoading(true);
try {
const res = await listAlerts({ page_size: 50 });
const payload = (res.data as { data?: CopilotInsight[] }).data ?? [];
setAlerts(payload);
} catch {
// 静默
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAlerts();
const timer = setInterval(fetchAlerts, 30_000);
return () => clearInterval(timer);
}, [fetchAlerts]);
const handleDismiss = async (id: string) => {
setDismissing(id);
try {
await dismissInsight(id);
setAlerts((prev) => prev.filter((a) => a.id !== id));
} finally {
setDismissing(null);
}
};
if (!alerts.length && !loading) return null;
const criticalCount = alerts.filter((a) => a.severity === 'critical').length;
return (
<div>
{criticalCount > 0 && (
<Alert
type="error"
showIcon
message={`${criticalCount} 条危急告警`}
banner
style={{ marginBottom: 16 }}
/>
)}
{loading && alerts.length === 0 ? (
<Spin />
) : (
<List
size="small"
dataSource={alerts}
renderItem={(item) => {
const config = severityConfig[item.severity] ?? severityConfig.info;
return (
<List.Item
actions={[
<Button
key="dismiss"
size="small"
icon={<CheckOutlined />}
loading={dismissing === item.id}
onClick={() => handleDismiss(item.id)}
>
</Button>,
]}
>
<List.Item.Meta
title={
<Space>
<Badge status={config.type} />
<Typography.Text>{item.title}</Typography.Text>
</Space>
}
description={
item.content?.suggestion ? (
<Typography.Text type="secondary">
{item.content.suggestion as string}
</Typography.Text>
) : undefined
}
/>
</List.Item>
);
}}
/>
)}
</div>
);
}

View File

@@ -1,28 +0,0 @@
import { Tag, Tooltip } from 'antd';
import type { RiskLevel } from '../../api/copilot';
import { useCopilotRisk } from './useCopilotRisk';
const levelConfig: Record<RiskLevel, { color: string; label: string }> = {
low: { color: 'green', label: '低风险' },
medium: { color: 'orange', label: '中风险' },
high: { color: 'red', label: '高风险' },
critical: { color: '#cf1322', label: '危急' },
};
interface CopilotBadgeProps {
patientId: string | undefined;
}
export function CopilotBadge({ patientId }: CopilotBadgeProps) {
const { data, loading } = useCopilotRisk(patientId);
if (!data || loading) return null;
const config = levelConfig[data.level as RiskLevel] ?? levelConfig.low;
return (
<Tooltip title={`Copilot 风险评分: ${data.score}/10 — ${config.label}`}>
<Tag color={config.color}>{config.label} {data.score}/10</Tag>
</Tooltip>
);
}

View File

@@ -1,76 +0,0 @@
import { Card, List, Tag, Button, Empty, Spin, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { useState } from 'react';
import { dismissInsight } from '../../api/copilot';
import type { CopilotInsight } from '../../api/copilot';
import { useCopilotInsights } from './useCopilotInsights';
const severityColor: Record<string, string> = {
info: 'blue',
warning: 'orange',
critical: 'red',
};
interface CopilotCardProps {
patientId: string | undefined;
}
export function CopilotCard({ patientId }: CopilotCardProps) {
const { data, loading, refresh } = useCopilotInsights(patientId);
const [dismissing, setDismissing] = useState<string | null>(null);
const handleDismiss = async (id: string) => {
setDismissing(id);
try {
await dismissInsight(id);
refresh();
} finally {
setDismissing(null);
}
};
return (
<Card title="Copilot 洞察" size="small" style={{ marginBottom: 16 }}>
{loading ? (
<Spin />
) : data.length === 0 ? (
<Empty description="暂无洞察" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={data}
renderItem={(item: CopilotInsight) => (
<List.Item
actions={[
<Button
key="dismiss"
type="text"
size="small"
icon={<CloseOutlined />}
loading={dismissing === item.id}
onClick={() => handleDismiss(item.id)}
/>,
]}
>
<List.Item.Meta
title={
<span>
<Tag color={severityColor[item.severity] ?? 'default'}>{item.severity}</Tag>
{item.title}
</span>
}
description={
item.llm_supplement ? (
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }}>
{item.llm_supplement}
</Typography.Paragraph>
) : undefined
}
/>
</List.Item>
)}
/>
)}
</Card>
);
}

View File

@@ -1,5 +0,0 @@
export { CopilotBadge } from './CopilotBadge';
export { CopilotCard } from './CopilotCard';
export { CopilotAlert } from './CopilotAlert';
export { useCopilotRisk } from './useCopilotRisk';
export { useCopilotInsights } from './useCopilotInsights';

View File

@@ -1,31 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { listInsights } from '../../api/copilot';
import type { CopilotInsight } from '../../api/copilot';
export function useCopilotInsights(patientId: string | undefined) {
const [data, setData] = useState<CopilotInsight[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const fetch = useCallback(async () => {
if (!patientId) return;
setLoading(true);
try {
const res = await listInsights({ patient_id: patientId, page_size: 20 });
const payload = (res.data as { data?: CopilotInsight[]; total?: number }).data ?? [];
const total = (res.data as { total?: number }).total ?? 0;
setData(payload);
setTotal(total);
} catch {
// 静默失败
} finally {
setLoading(false);
}
}, [patientId]);
useEffect(() => {
fetch();
}, [fetch]);
return { data, total, loading, refresh: fetch };
}

View File

@@ -1,29 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { getPatientRisk } from '../../api/copilot';
import type { RiskScore } from '../../api/copilot';
export function useCopilotRisk(patientId: string | undefined) {
const [data, setData] = useState<RiskScore | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetch = useCallback(async () => {
if (!patientId) return;
setLoading(true);
setError(null);
try {
const res = await getPatientRisk(patientId);
setData(res ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : '加载风险评分失败');
} finally {
setLoading(false);
}
}, [patientId]);
useEffect(() => {
fetch();
}, [fetch]);
return { data, loading, error, refresh: fetch };
}

View File

@@ -2,8 +2,17 @@ import { useState, useEffect, useCallback } from 'react';
import { Modal, Input, Upload, Image, Empty, Spin, message } from 'antd';
import { SearchOutlined, UploadOutlined } from '@ant-design/icons';
import { resolveMediaUrl } from '../../utils/media';
import { mediaApi, type MediaItem } from '../../api/health/media';
import { uploadFile } from '../../api/upload';
import client from '../../api/client';
export interface MediaItem {
id: string;
filename: string;
storage_path: string;
thumbnail_path?: string;
content_type: string;
alt_text?: string;
}
interface MediaPickerProps {
open: boolean;
@@ -25,14 +34,16 @@ export default function MediaPicker({ open, onClose, onSelect, accept = 'image/*
const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await mediaApi.list({
const { data } = await client.get('/media', {
params: {
page,
page_size: PAGE_SIZE,
keyword: keyword || undefined,
content_type: accept === 'image/*' ? 'image' : undefined,
},
});
setItems(result.data);
setTotal(result.total);
setItems(data?.data ?? []);
setTotal(data?.total ?? 0);
} catch {
setItems([]);
} finally {
@@ -130,7 +141,7 @@ export default function MediaPicker({ open, onClose, onSelect, accept = 'image/*
background: '#fafafa',
transition: 'border-color 0.2s',
}}
onMouseEnter={(e) => { (e.currentTarget.style.borderColor = '#1677ff'); }}
onMouseEnter={(e) => { (e.currentTarget.style.borderColor = 'var(--ant-color-primary)'); }}
onMouseLeave={(e) => { (e.currentTarget.style.borderColor = '#f0f0f0'); }}
>
<div style={{ width: '100%', height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>

View File

@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Badge, Divider, List, Popover, Button, Empty, Typography } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useMessageStore } from '../stores/message';
import { useThemeMode } from '../hooks/useThemeMode';
import { actionInboxApi, type ActionItem } from '../api/health/actionInbox';
const { Text } = Typography;
@@ -16,42 +15,33 @@ export default function NotificationPanel() {
const isDark = useThemeMode();
const initializedRef = useRef(false);
const [pendingActions, setPendingActions] = useState<ActionItem[]>([]);
const fetchPendingActions = useCallback(async () => {
try {
const resp = await actionInboxApi.list({ status: 'pending', page_size: 3 });
setPendingActions(resp.data);
} catch {
// 静默失败,不影响通知面板
}
}, []);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const init = useCallback(async () => {
const { fetchUnreadCount, fetchRecentMessages, connectSSE } = useMessageStore.getState();
fetchUnreadCount();
fetchRecentMessages();
fetchPendingActions();
const disconnectSSE = connectSSE();
const interval = setInterval(() => {
fetchUnreadCount();
fetchRecentMessages();
fetchPendingActions();
}, 60000);
return () => {
clearInterval(interval);
disconnectSSE();
};
}, []);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const cleanup = init();
return () => {
cleanup.then((fn) => fn?.());
initializedRef.current = false;
};
}, [fetchPendingActions]);
const totalBadge = unreadCount + pendingActions.length;
}, [init]);
const content = (
<div style={{ width: 360 }}>
@@ -67,7 +57,7 @@ export default function NotificationPanel() {
<Button
type="text"
size="small"
style={{ fontSize: 12, color: '#2563eb' }}
style={{ fontSize: 12, color: 'var(--ant-color-primary)' }}
onClick={() => navigate('/messages')}
>
@@ -127,7 +117,7 @@ export default function NotificationPanel() {
width: 6,
height: 6,
borderRadius: '50%',
background: '#2563eb',
background: 'var(--ant-color-primary)',
flexShrink: 0,
}} />
)}
@@ -155,14 +145,14 @@ export default function NotificationPanel() {
<Button
type="text"
onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
style={{ fontSize: 13, color: 'var(--ant-color-primary)', fontWeight: 500 }}
>
</Button>
</div>
)}
{/* 待办预览区域 */}
{/* 待办预览 */}
<Divider style={{ margin: '8px 0' }} />
<div style={{ padding: '4px 0' }}>
<div style={{
@@ -175,37 +165,15 @@ export default function NotificationPanel() {
<Button
type="link"
size="small"
onClick={() => navigate('/health/action-inbox')}
onClick={() => navigate('/workflow')}
style={{ fontSize: 12, padding: 0 }}
>
</Button>
</div>
{pendingActions.length === 0 ? (
<Text type="secondary" style={{ fontSize: 12, display: 'block', textAlign: 'center', padding: '8px 0' }}>
</Text>
) : (
<List
size="small"
dataSource={pendingActions}
renderItem={(item) => (
<List.Item
style={{ padding: '4px 0', cursor: 'pointer', border: 'none' }}
onClick={() => navigate('/health/action-inbox')}
>
<List.Item.Meta
title={<Text style={{ fontSize: 12 }}>{item.title}</Text>}
description={
<Text type="secondary" style={{ fontSize: 11 }}>
{item.patient_name}
</Text>
}
/>
</List.Item>
)}
/>
)}
</div>
</div>
);
@@ -236,7 +204,7 @@ export default function NotificationPanel() {
e.currentTarget.style.background = 'transparent';
}}
>
<Badge count={totalBadge} size="small" offset={[4, -4]}>
<Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{
fontSize: 16,
color: isDark ? '#94a3b8' : '#475569',

View File

@@ -1,145 +0,0 @@
import { useState, useCallback } from 'react';
import { Button, Card, Spin, Empty, Alert } from 'antd';
import { ThunderboltOutlined, ReloadOutlined } from '@ant-design/icons';
import { startAnalysis, type AnalysisType } from '../../api/ai/analysisSse';
import { AuthButton } from '../AuthButton';
export interface AiAnalysisCardProps {
analysisType: AnalysisType;
sourceRef: string;
patientId?: string;
triggerLabel?: string;
permission?: string;
metrics?: string[];
taskId?: string;
}
type AnalysisState = 'idle' | 'loading' | 'success' | 'error';
export function AiAnalysisCard({
analysisType,
sourceRef,
triggerLabel = 'AI 分析',
permission = 'ai.analysis.manage',
metrics,
taskId,
}: AiAnalysisCardProps) {
const [state, setState] = useState<AnalysisState>('idle');
const [content, setContent] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const handleStart = useCallback(async () => {
setState('loading');
setContent('');
setErrorMsg('');
const body: Record<string, unknown> = {};
if (analysisType === 'lab-report' || analysisType === 'report-summary') {
body.report_id = sourceRef;
}
if (analysisType === 'trends' || analysisType === 'checkup-plan') {
body.patient_id = sourceRef;
}
if (analysisType === 'follow-up-summary') {
body.source_id = taskId || sourceRef;
}
if (metrics) {
body.metrics = metrics;
}
try {
await startAnalysis(analysisType, body, {
onChunk: (chunk) => setContent(prev => prev + chunk),
onError: (msg) => {
setErrorMsg(msg);
setState('error');
},
onDone: () => setState('success'),
});
} catch {
setErrorMsg('分析请求失败');
setState('error');
}
}, [analysisType, sourceRef, metrics, taskId]);
const handleReset = useCallback(() => {
setState('idle');
setContent('');
setErrorMsg('');
}, []);
const TriggerButton = permission ? (
<AuthButton code={permission}>
<Button
icon={<ThunderboltOutlined />}
loading={state === 'loading'}
onClick={handleStart}
size="small"
>
{triggerLabel}
</Button>
</AuthButton>
) : (
<Button
icon={<ThunderboltOutlined />}
loading={state === 'loading'}
onClick={handleStart}
size="small"
>
{triggerLabel}
</Button>
);
if (state === 'idle') {
return TriggerButton;
}
if (state === 'loading') {
return (
<Card size="small" style={{ marginTop: 12 }}>
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<Spin indicator={<ThunderboltOutlined spin style={{ fontSize: 24 }} />} />
<div style={{ marginTop: 8, color: 'var(--ant-color-text-secondary)' }}>
AI ...
</div>
</div>
</Card>
);
}
if (state === 'error') {
return (
<Card size="small" style={{ marginTop: 12 }}>
<Alert
type="error"
message={errorMsg}
showIcon
action={
<Button size="small" icon={<ReloadOutlined />} onClick={handleStart}>
</Button>
}
/>
</Card>
);
}
if (!content) {
return <Empty description="分析结果为空" />;
}
return (
<Card
title={<><ThunderboltOutlined /> {triggerLabel}</>}
size="small"
style={{ marginTop: 12 }}
extra={
<Button size="small" onClick={handleReset}></Button>
}
>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, fontSize: 14 }}>
{content}
</div>
</Card>
);
}

View File

@@ -1,415 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Drawer,
Input,
Button,
Space,
Typography,
Spin,
Tag,
Card,
theme,
} from 'antd';
import {
SendOutlined,
RobotOutlined,
DeleteOutlined,
WarningOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useLocation } from 'react-router-dom';
import { aiChatApi, type ChatHistoryItem, type DisplayHint } from '../../api/ai/chat';
import { analysisApi, type HealthSummaryResponse } from '../../api/ai/analysis';
import { useAuthStore } from '../../stores/auth';
import RichMessage from './RichMessage';
const { Text } = Typography;
const { TextArea } = Input;
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
displayHints?: DisplayHint[];
}
function extractPatientId(pathname: string): string | null {
const match = pathname.match(/\/health\/patients\/([0-9a-f-]+)/i);
return match?.[1] ?? null;
}
const RISK_CONFIG: Record<string, { color: string; label: string }> = {
low: { color: 'green', label: '低风险' },
medium: { color: 'orange', label: '中风险' },
high: { color: 'red', label: '高风险' },
critical: { color: '#cf1322', label: '严重' },
};
export default function AiSidebar({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<HealthSummaryResponse | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const { token } = theme.useToken();
const patientId = extractPatientId(location.pathname);
const permissions = useAuthStore((s) => s.permissions);
const canChat = permissions.includes('ai.chat.send');
const canViewSummary = permissions.includes('ai.analysis.list');
// 欢迎消息
useEffect(() => {
if (open && messages.length === 0) {
setMessages([
{
id: 'welcome',
role: 'assistant',
content: patientId
? '你好!我是 AI 健康助手。当前已关联患者档案,你可以问我关于该患者的体征、化验报告、用药等信息。'
: '你好!我是 AI 健康助手。你可以向我咨询健康相关问题,或打开患者详情页查看患者数据。',
},
]);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// 自动加载患者健康摘要
useEffect(() => {
if (!open || !patientId || !canViewSummary) {
setSummary(null);
return;
}
let cancelled = false;
setSummaryLoading(true);
analysisApi
.getHealthSummary(patientId)
.then((data) => {
if (!cancelled) setSummary(data);
})
.catch(() => {
if (!cancelled) setSummary(null);
})
.finally(() => {
if (!cancelled) setSummaryLoading(false);
});
return () => {
cancelled = true;
};
}, [open, patientId, canViewSummary]);
const scrollToBottom = useCallback(() => {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const handleSend = async () => {
const text = input.trim();
if (!text || loading || !canChat) return;
const userMsg: ChatMessage = {
id: `u-${Date.now()}`,
role: 'user',
content: text,
};
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setLoading(true);
try {
const history: ChatHistoryItem[] = newMessages
.filter((m) => m.id !== 'welcome')
.map((m) => ({
role: m.role,
content: m.content,
}));
const resp = await aiChatApi.sendMessage(
text,
history,
patientId ?? undefined
);
setMessages((prev) => [
...prev,
{
id: resp.message_id,
role: 'assistant' as const,
content: resp.reply,
displayHints: resp.display_hints,
},
]);
} catch {
setMessages((prev) => [
...prev,
{
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉AI 服务暂时不可用,请稍后再试。',
},
]);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClear = () => {
setMessages([
{
id: 'welcome',
role: 'assistant',
content: '对话已清空。有什么可以帮你的?',
},
]);
};
const riskInfo = summary ? RISK_CONFIG[summary.risk_level] ?? RISK_CONFIG.low : null;
return (
<Drawer
title={
<Space>
<RobotOutlined style={{ color: token.colorPrimary }} />
<span>AI </span>
{patientId && (
<Tag color="blue" style={{ marginLeft: 8, fontSize: 11 }}>
</Tag>
)}
</Space>
}
placement="right"
size={400}
open={open}
onClose={onClose}
styles={{
body: { display: 'flex', flexDirection: 'column', padding: 0 },
}}
extra={
<Button
size="small"
icon={<DeleteOutlined />}
onClick={handleClear}
title="清空对话"
/>
}
>
{/* 患者健康摘要卡片 */}
{patientId && canViewSummary && (
<div style={{ padding: '12px 16px 0' }}>
{summaryLoading ? (
<Card size="small" style={{ textAlign: 'center' }}>
<Spin size="small" /> <Text type="secondary">...</Text>
</Card>
) : summary ? (
<Card
size="small"
title={
<Space size={4}>
<SafetyCertificateOutlined />
<span style={{ fontSize: 13 }}></span>
{riskInfo && (
<Tag
color={riskInfo.color}
style={{ marginLeft: 4, fontSize: 11 }}
>
{riskInfo.label}
</Tag>
)}
</Space>
}
style={{ marginBottom: 4 }}
styles={{ body: { padding: '8px 12px' } }}
>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
<div style={{ marginBottom: 4 }}>
{summary.active_insights_count}
{summary.recent_analyses_count > 0 &&
` | 近期分析 ${summary.recent_analyses_count}`}
</div>
{summary.summary_items.length > 0 && (
<div>
{summary.summary_items.slice(0, 3).map((item, i) => (
<div key={i} style={{ marginTop: 3 }}>
<Tag
color={
item.severity === 'high'
? 'red'
: item.severity === 'medium'
? 'orange'
: 'blue'
}
style={{ fontSize: 10, marginRight: 4 }}
>
{item.category}
</Tag>
<span>{item.title}</span>
</div>
))}
{summary.summary_items.length > 3 && (
<Text
type="secondary"
style={{ fontSize: 11, marginTop: 2, display: 'block' }}
>
{summary.summary_items.length - 3} ...
</Text>
)}
</div>
)}
{summary.latest_insight_title && (
<div
style={{
marginTop: 4,
paddingTop: 4,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<WarningOutlined style={{ marginRight: 4, color: token.colorWarning }} />
: {summary.latest_insight_title}
</div>
)}
</div>
</Card>
) : null}
</div>
)}
{/* 消息列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 16px',
background: token.colorBgLayout,
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
justifyContent:
msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: 12,
}}
>
<div
style={{
maxWidth: '85%',
padding: '8px 12px',
borderRadius: 12,
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background:
msg.role === 'user'
? token.colorPrimary
: token.colorBgContainer,
color:
msg.role === 'user'
? token.colorTextLightSolid
: token.colorText,
borderBottomRightRadius: msg.role === 'user' ? 4 : 12,
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : 12,
}}
>
{msg.content}
{msg.displayHints && msg.displayHints.length > 0 && (
<RichMessage hints={msg.displayHints} />
)}
</div>
</div>
))}
{loading && (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
marginBottom: 12,
}}
>
<div
style={{
padding: '8px 16px',
borderRadius: 12,
borderBottomLeftRadius: 4,
background: token.colorBgContainer,
}}
>
<Spin size="small" />{' '}
<Text type="secondary">...</Text>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div
style={{
padding: 12,
borderTop: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Space.Compact style={{ width: '100%' }}>
<TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
canChat
? '输入消息... (Enter 发送, Shift+Enter 换行)'
: '无 AI 聊天权限'
}
disabled={loading || !canChat}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ borderRadius: '8px 0 0 8px' }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={loading}
disabled={!input.trim() || !canChat}
style={{
height: 'auto',
borderRadius: '0 8px 8px 0',
minHeight: 40,
}}
/>
</Space.Compact>
{!canChat && (
<Text
type="warning"
style={{ fontSize: 12, marginTop: 4, display: 'block' }}
>
AI
</Text>
)}
</div>
</Drawer>
);
}

View File

@@ -1,180 +0,0 @@
import { Card, Tag, Typography, theme } from 'antd';
import {
WarningOutlined,
HeartOutlined,
LineChartOutlined,
ExperimentOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { DisplayHint } from '../../api/ai/chat';
const { Text } = Typography;
const SEVERITY_COLOR: Record<string, string> = {
high: 'red',
medium: 'orange',
low: 'green',
};
const RISK_LEVEL_COLOR: Record<string, string> = {
critical: '#cf1322',
high: 'red',
medium: 'orange',
low: 'green',
};
export default function RichMessage({ hints }: { hints: DisplayHint[] }) {
const { token } = theme.useToken();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
{hints.map((hint, i) => (
<RichHint key={i} hint={hint} token={token} />
))}
</div>
);
}
function RichHint({ hint, token }: { hint: DisplayHint; token: { colorBorderSecondary: string; colorTextSecondary: string; colorPrimary: string } }) {
switch (hint.type) {
case 'insight_card':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<HeartOutlined style={{ marginRight: 4, color: token.colorPrimary }} />
{hint.title}
</span>
}
styles={{ body: { padding: '6px 12px' } }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{hint.items.map((item, j) => (
<Tag key={j} color={SEVERITY_COLOR[hint.severity] ?? 'blue'} style={{ fontSize: 11, margin: 0 }}>
{item}
</Tag>
))}
</div>
</Card>
);
case 'risk_alert':
return (
<div
style={{
padding: '8px 12px',
borderRadius: 8,
border: `1px solid ${RISK_LEVEL_COLOR[hint.level] ?? token.colorBorderSecondary}`,
background: `${RISK_LEVEL_COLOR[hint.level] ?? token.colorBorderSecondary}10`,
fontSize: 13,
}}
>
<WarningOutlined style={{ color: RISK_LEVEL_COLOR[hint.level] ?? token.colorPrimary, marginRight: 6 }} />
{hint.message}
</div>
);
case 'lab_report_card':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<ExperimentOutlined style={{ marginRight: 4 }} />
{hint.report_date}
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
{hint.abnormal_count > 0 ? (
<Tag color="red" style={{ fontSize: 11 }}>
{hint.abnormal_count}
</Tag>
) : (
<Tag color="green" style={{ fontSize: 11 }}></Tag>
)}
</Card>
);
case 'trend_chart':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<LineChartOutlined style={{ marginRight: 4 }} />
{hint.period}
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
<div style={{ marginBottom: 4 }}>
{hint.metrics.map((m, j) => (
<Tag key={j} style={{ fontSize: 11, margin: '0 4px 2px 0' }}>{m}</Tag>
))}
</div>
<Text type="secondary" style={{ fontSize: 11 }}>{hint.summary}</Text>
</Card>
);
case 'patient_profile':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<UserOutlined style={{ marginRight: 4 }} />
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
{hint.chronic_conditions.length > 0 && (
<div style={{ marginBottom: 4 }}>
{hint.chronic_conditions.map((c, j) => (
<Tag key={j} color="orange" style={{ fontSize: 11, margin: '0 4px 2px 0' }}>{c}</Tag>
))}
</div>
)}
{hint.medication_count > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {hint.medication_count} </Text>
)}
</Card>
);
case 'vital_card':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<HeartOutlined style={{ marginRight: 4, color: token.colorPrimary }} />
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{hint.values.slice(-5).map(([date, val], j) => (
<span key={j}>
<Text type="secondary" style={{ fontSize: 11 }}>{date.slice(5)}:</Text>{' '}
{val} {hint.unit}
</span>
))}
</div>
</Card>
);
case 'action_confirm':
return (
<Card size="small" styles={{ body: { padding: '8px 12px', fontSize: 13 } }}>
<Text>{hint.summary}</Text>
</Card>
);
case 'text':
default:
return null;
}
}

View File

@@ -1,85 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
GENDER_OPTIONS,
BLOOD_TYPE_OPTIONS,
STATUS_OPTIONS,
} from './health'
describe('GENDER_OPTIONS', () => {
it('should be an array with 3 items', () => {
expect(GENDER_OPTIONS).toBeInstanceOf(Array)
expect(GENDER_OPTIONS).toHaveLength(3)
})
it('each item should have value and label string properties', () => {
for (const opt of GENDER_OPTIONS) {
expect(opt).toHaveProperty('value')
expect(opt).toHaveProperty('label')
expect(typeof opt.value).toBe('string')
expect(typeof opt.label).toBe('string')
}
})
it('should contain expected gender values', () => {
const values = GENDER_OPTIONS.map((o) => o.value)
expect(values).toEqual(['male', 'female', 'other'])
})
it('should contain expected Chinese labels', () => {
const labels = GENDER_OPTIONS.map((o) => o.label)
expect(labels).toEqual(['男', '女', '其他'])
})
})
describe('BLOOD_TYPE_OPTIONS', () => {
it('should be an array with 4 items', () => {
expect(BLOOD_TYPE_OPTIONS).toBeInstanceOf(Array)
expect(BLOOD_TYPE_OPTIONS).toHaveLength(4)
})
it('each item should have value and label string properties', () => {
for (const opt of BLOOD_TYPE_OPTIONS) {
expect(opt).toHaveProperty('value')
expect(opt).toHaveProperty('label')
expect(typeof opt.value).toBe('string')
expect(typeof opt.label).toBe('string')
}
})
it('should contain expected blood type values', () => {
const values = BLOOD_TYPE_OPTIONS.map((o) => o.value)
expect(values).toEqual(['A', 'B', 'AB', 'O'])
})
it('should contain expected Chinese labels', () => {
const labels = BLOOD_TYPE_OPTIONS.map((o) => o.label)
expect(labels).toEqual(['A 型', 'B 型', 'AB 型', 'O 型'])
})
})
describe('STATUS_OPTIONS', () => {
it('should be an array with 4 items', () => {
expect(STATUS_OPTIONS).toBeInstanceOf(Array)
expect(STATUS_OPTIONS).toHaveLength(4)
})
it('each item should have value and label string properties', () => {
for (const opt of STATUS_OPTIONS) {
expect(opt).toHaveProperty('value')
expect(opt).toHaveProperty('label')
expect(typeof opt.value).toBe('string')
expect(typeof opt.label).toBe('string')
}
})
it('should include an empty-string value for "all statuses" filter', () => {
const allOption = STATUS_OPTIONS.find((o) => o.value === '')
expect(allOption).toBeDefined()
expect(allOption!.label).toBe('全部状态')
})
it('should contain expected status values', () => {
const values = STATUS_OPTIONS.map((o) => o.value)
expect(values).toEqual(['', 'active', 'inactive', 'deceased'])
})
})

View File

@@ -1,200 +0,0 @@
/**
* 健康管理模块共享常量
*
* 集中定义性别、血型、患者状态、严重度、告警状态、设备类型等映射,
* 供各健康模块页面复用。避免在组件中重复定义。
*/
// --- 性别 ---
export const GENDER_OPTIONS = [
{ value: "male", label: "男" },
{ value: "female", label: "女" },
{ value: "other", label: "其他" },
];
export const GENDER_LABEL: Record<string, string> = {
male: "男",
female: "女",
other: "其他",
};
// --- 血型 ---
export const BLOOD_TYPE_OPTIONS = [
{ value: "A", label: "A 型" },
{ value: "B", label: "B 型" },
{ value: "AB", label: "AB 型" },
{ value: "O", label: "O 型" },
];
// --- 患者状态 ---
export const STATUS_OPTIONS = [
{ value: "", label: "全部状态" },
{ value: "active", label: "活跃" },
{ value: "inactive", label: "停用" },
{ value: "deceased", label: "已故" },
];
// --- 严重度(统一 5 处重复定义: AlertDashboard, AlertList, AlertRuleList, DoctorDashboard ---
export const SEVERITY_COLOR: Record<string, string> = {
info: "default",
warning: "orange",
critical: "red",
urgent: "magenta",
high: "red",
medium: "orange",
};
export const SEVERITY_LABEL: Record<string, string> = {
info: "提示",
warning: "警告",
critical: "严重",
urgent: "紧急",
high: "严重",
medium: "中等",
};
export const SEVERITY_OPTIONS = [
{ value: "info", label: "提示" },
{ value: "warning", label: "警告" },
{ value: "medium", label: "中等" },
{ value: "critical", label: "严重" },
{ value: "high", label: "严重" },
{ value: "urgent", label: "紧急" },
];
// --- 告警状态(统一 3 处: AlertDashboard, AlertList ---
export const ALERT_STATUS_COLOR: Record<string, string> = {
pending: "orange",
active: "gold",
acknowledged: "blue",
resolved: "green",
dismissed: "default",
};
export const ALERT_STATUS_LABEL: Record<string, string> = {
pending: "待处理",
active: "活跃",
acknowledged: "已确认",
resolved: "已恢复",
dismissed: "已忽略",
};
export const ALERT_STATUS_OPTIONS = [
{ value: "", label: "全部状态" },
{ value: "active", label: "活跃" },
{ value: "pending", label: "待处理" },
{ value: "acknowledged", label: "已确认" },
{ value: "resolved", label: "已恢复" },
{ value: "dismissed", label: "已忽略" },
];
// --- 设备类型(统一 3 处: DeviceManage, DeviceReadingsTab, AlertRuleList ---
export const DEVICE_TYPE_OPTIONS = [
{ value: "blood_pressure", label: "血压" },
{ value: "blood_glucose", label: "血糖" },
{ value: "heart_rate", label: "心率" },
{ value: "blood_oxygen", label: "血氧" },
{ value: "temperature", label: "体温" },
{ value: "steps", label: "步数" },
{ value: "sleep", label: "睡眠" },
{ value: "stress", label: "压力" },
];
export const DEVICE_TYPE_COLOR: Record<string, string> = {
blood_pressure: "red",
blood_glucose: "purple",
heart_rate: "volcano",
blood_oxygen: "blue",
temperature: "orange",
steps: "green",
sleep: "cyan",
stress: "geekblue",
};
// --- 告警规则条件类型 ---
export const CONDITION_TYPE_OPTIONS = [
{ value: "single_threshold", label: "单次阈值" },
{ value: "consecutive", label: "连续触发" },
{ value: "trend", label: "趋势变化" },
];
// --- 设备连接状态 ---
export const DEVICE_STATUS_OPTIONS = [
{ value: "", label: "全部状态" },
{ value: "online", label: "在线" },
{ value: "offline", label: "离线" },
{ value: "paired", label: "已配对" },
{ value: "error", label: "异常" },
];
export const DEVICE_STATUS_COLOR: Record<string, string> = {
online: "green",
offline: "default",
paired: "blue",
error: "red",
};
// --- 设备连接类型 ---
export const CONNECTION_TYPE_OPTIONS = [
{ value: "ble", label: "蓝牙" },
{ value: "gateway", label: "网关" },
{ value: "manual", label: "手动录入" },
];
// --- 实时监控卡片指标 ---
export const VITAL_CARD_METRICS = [
{ key: "heart_rate", label: "心率", unit: "bpm", color: "#ff4d4f" },
{ key: "blood_oxygen", label: "血氧", unit: "%", color: "#1890ff" },
{ key: "blood_pressure", label: "血压", unit: "mmHg", color: "#f5222d" },
{ key: "blood_glucose", label: "血糖", unit: "mg/dL", color: "#722ed1" },
{ key: "temperature", label: "体温", unit: "°C", color: "#fa8c16" },
{ key: "steps", label: "步数", unit: "步", color: "#52c41a" },
] as const;
// --- 告警标题中英文映射 ---
export const ALERT_TITLE_MAP: Record<string, string> = {
"BP Critical High": "血压严重偏高",
"BP Critical Low": "血压严重偏低",
"Heart Rate Abnormal": "心率异常",
"Blood Sugar Elevated": "血糖偏高",
"Blood Sugar Critical": "血糖危急值",
"Blood Sugar Low": "血糖偏低",
"Weight Gain Alert": "体重增长异常",
"Missed Medication": "漏服药物",
"SpO2 Low": "血氧偏低",
"Temperature High": "体温偏高",
"Temperature Low": "体温偏低",
"BP Trending High": "血压趋势偏高",
"BP Trending Low": "血压趋势偏低",
"Heart Rate High": "心率偏高",
"Heart Rate Low": "心率偏低",
};
/** 翻译告警标题:优先精确匹配,其次回退原文 */
export function translateAlertTitle(title: string): string {
return ALERT_TITLE_MAP[title] ?? title;
}
// --- 通用状态标签StatusTag 组件统一引用) ---
export const STATUS_TAG_CONFIG: Record<
string,
{ color: string; label: string }
> = {
// 预约状态
pending: { color: "gold", label: "待确认" },
confirmed: { color: "blue", label: "已确认" },
completed: { color: "green", label: "已完成" },
cancelled: { color: "default", label: "已取消" },
no_show: { color: "red", label: "未到诊" },
// 随访状态
overdue: { color: "red", label: "逾期" },
in_progress: { color: "processing", label: "进行中" },
// 咨询状态
waiting: { color: "gold", label: "等待中" },
active: { color: "green", label: "进行中" },
closed: { color: "default", label: "已关闭" },
// 患者状态
inactive: { color: "default", label: "停用" },
deceased: { color: "default", label: "已故" },
verified: { color: "green", label: "已认证" },
};

View File

@@ -1,153 +0,0 @@
import { useEffect, useRef, useCallback, useState } from 'react';
/** SSE 事件数据结构 — alert.triggered 事件 */
export interface AlertSSEEvent {
alert_id: string;
patient_id: string;
rule_name: string;
severity: string;
detail?: Record<string, unknown>;
schema_version?: string;
occurred_at?: string;
}
/** SSE 事件数据结构 — device.readings.synced 事件 */
export interface VitalUpdateSSEEvent {
patient_id: string;
count: number;
device_model?: string;
date_range?: {
from?: string;
to?: string;
};
schema_version?: string;
occurred_at?: string;
}
type ConnectionState = 'connecting' | 'connected' | 'disconnected';
interface UseAlertSSEOptions {
enabled?: boolean;
onAlert?: (data: AlertSSEEvent) => void;
onVitalUpdate?: (data: VitalUpdateSSEEvent) => void;
}
interface UseAlertSSEReturn {
connected: boolean;
connectionState: ConnectionState;
recentAlerts: AlertSSEEvent[];
reconnect: () => void;
}
const MAX_RECENT_ALERTS = 100;
const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 30_000;
const MAX_RETRIES = 10;
export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn {
const { enabled = true, onAlert, onVitalUpdate } = options;
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountRef = useRef(0);
const reconnectKeyRef = useRef(0);
const [connected, setConnected] = useState(false);
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [recentAlerts, setRecentAlerts] = useState<AlertSSEEvent[]>([]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const connect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
clearReconnectTimer();
setConnected(false);
setConnectionState('disconnected');
if (!enabled) return;
const token = localStorage.getItem('access_token');
if (!token) return;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`;
setConnectionState('connecting');
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => {
retryCountRef.current = 0;
setConnected(true);
setConnectionState('connected');
};
es.addEventListener('alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as AlertSSEEvent;
onAlert?.(data);
setRecentAlerts((prev) => {
const next = [data, ...prev];
return next.length > MAX_RECENT_ALERTS ? next.slice(0, MAX_RECENT_ALERTS) : next;
});
} catch { /* ignore parse errors */ }
});
es.addEventListener('vital_update', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as VitalUpdateSSEEvent;
onVitalUpdate?.(data);
} catch { /* ignore parse errors */ }
});
es.onerror = () => {
setConnected(false);
setConnectionState('disconnected');
es.close();
eventSourceRef.current = null;
retryCountRef.current += 1;
if (retryCountRef.current > MAX_RETRIES) {
return;
}
const delay = Math.min(
INITIAL_DELAY_MS * Math.pow(2, retryCountRef.current - 1),
MAX_DELAY_MS,
);
const jitter = delay * (0.5 + Math.random() * 0.5);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
const tokenNow = localStorage.getItem('access_token');
if (!tokenNow || !enabled) return;
connect();
}, jitter);
};
}, [enabled, onAlert, onVitalUpdate, clearReconnectTimer]);
useEffect(() => {
connect();
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
clearReconnectTimer();
setConnected(false);
setConnectionState('disconnected');
};
}, [connect, reconnectKeyRef.current]);
const reconnect = useCallback(() => {
retryCountRef.current = 0;
reconnectKeyRef.current += 1;
}, []);
return { connected, connectionState, recentAlerts, reconnect };
}

View File

@@ -1,19 +0,0 @@
import { useAuthStore } from '../stores/auth';
type DashboardRole = 'doctor' | 'health_manager' | 'nurse' | 'admin' | 'operator';
const ROLE_PRIORITY: DashboardRole[] = ['doctor', 'health_manager', 'nurse', 'admin', 'operator'];
export function useDashboardRole(): DashboardRole {
const user = useAuthStore(s => s.user);
if (!user?.roles?.length) return 'admin';
const codes = user.roles.map(r => r.code);
for (const role of ROLE_PRIORITY) {
if (codes.some(c => c === role || c.startsWith(role))) return role;
}
return 'admin';
}
export type { DashboardRole };

View File

@@ -1,69 +0,0 @@
import { useState, useCallback } from 'react';
import { useAlertSSE, type VitalUpdateSSEEvent } from './useAlertSSE';
export type VitalUpdateEvent = VitalUpdateSSEEvent;
interface PatientVital {
patient_id: string;
device_type: string;
latest_value?: number;
updated_at: string;
}
interface UseVitalSSEOptions {
enabled?: boolean;
patientIds?: string[];
onUpdate?: (data: VitalUpdateEvent) => void;
}
interface UseVitalSSEReturn {
connected: boolean;
patientVitals: Map<string, PatientVital>;
lastUpdate: VitalUpdateEvent | null;
}
/**
* 实时体征 hook — 复用全局 SSE 连接的 vital_update 事件。
*
* 内部调用 useAlertSSE共享 /messages/stream 连接),
* 聚合患者最新体征数据到 Map。
*/
export function useVitalSSE(options: UseVitalSSEOptions = {}): UseVitalSSEReturn {
const { enabled = true, patientIds, onUpdate } = options;
const [patientVitals, setPatientVitals] = useState<Map<string, PatientVital>>(new Map());
const [lastUpdate, setLastUpdate] = useState<VitalUpdateEvent | null>(null);
const handleVitalUpdate = useCallback(
(data: VitalUpdateEvent) => {
if (patientIds && patientIds.length > 0 && !patientIds.includes(data.patient_id)) {
return;
}
setPatientVitals((prev) => {
const next = new Map(prev);
const key = `${data.patient_id}_${data.device_model ?? 'unknown'}`;
const existing = next.get(key);
next.set(key, {
patient_id: data.patient_id,
device_type: data.device_model ?? 'unknown',
// 批量同步事件不含单值,用累计 count 表示活跃度
latest_value: data.count > 0
? (existing?.latest_value ?? 0) + data.count
: existing?.latest_value,
updated_at: data.occurred_at ?? new Date().toISOString(),
});
return next;
});
setLastUpdate(data);
onUpdate?.(data);
},
[patientIds, onUpdate],
);
const { connected } = useAlertSSE({
enabled,
onVitalUpdate: handleVitalUpdate,
});
return { connected, patientVitals, lastUpdate };
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useState, useEffect, useMemo } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu } from 'antd';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu, message } from 'antd';
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
import {
MenuFoldOutlined,
@@ -8,7 +8,6 @@ import {
SearchOutlined,
AppstoreOutlined,
UserOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
@@ -19,7 +18,6 @@ import { getMenusForUser, type MenuInfo } from '../api/menus';
import { getIcon } from '../utils/iconRegistry';
import NotificationPanel from '../components/NotificationPanel';
import ThemeSwitcher from '../components/ThemeSwitcher';
import AiSidebar from '../components/ai/AiSidebar';
const { Header, Sider, Content, Footer } = Layout;
@@ -27,20 +25,9 @@ const { Header, Sider, Content, Footer } = Layout;
// 1. 动态参数路由(:id/:id/edit— 菜单表不会存储这些路径
// 2. 无后端菜单记录的静态页面路由
const routeTitleFallback: Record<string, string> = {
// 动态参数路由
'/health/patients/:id': '患者详情',
'/health/consultations/:id': '咨询详情',
'/health/articles/new': '新建文章',
'/health/articles/:id/edit': '编辑文章',
'/health/care-plans/:id': '护理计划详情',
'/health/shifts/:id': '班次详情',
'/health/ble-gateways/:id': '网关详情',
// 无后端菜单的静态路由
'/health/follow-up-records': '随访记录',
'/health/article-categories': '分类管理',
'/health/article-tags': '标签管理',
'/health/schedules': '排班管理',
'/health/appointments': '预约管理',
// 暖记管理端 — 动态参数路由
'/diary/journals/:id': '日记详情',
'/diary/classes/:id': '班级详情',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
@@ -127,7 +114,6 @@ export default function MainLayout({ children }: { children: React.ReactNode })
// 动态菜单状态
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
const [menuLoading, setMenuLoading] = useState(true);
const [aiSidebarOpen, setAiSidebarOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -252,9 +238,9 @@ export default function MainLayout({ children }: { children: React.ReactNode })
>
{/* Logo 区域 */}
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
<div className="erp-sidebar-logo-icon">H</div>
<div className="erp-sidebar-logo-icon">N</div>
{!sidebarCollapsed && (
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || '暖记 Nuanji'}</span>
)}
</div>
@@ -333,14 +319,14 @@ export default function MainLayout({ children }: { children: React.ReactNode })
{/* 底部 */}
<Footer className="erp-footer">
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
{themeConfig?.brand_copyright || '© 暖记 Nuanji'}
</Footer>
</Layout>
{/* AI 助手浮动按钮 + 侧边栏 */}
<Tooltip title="AI 健康助手" placement="left">
{/* AI 助手浮动按钮(暖记未来功能) */}
<Tooltip title="AI 助手" placement="left">
<div
onClick={() => setAiSidebarOpen(true)}
onClick={() => message.info('AI 助手功能即将上线')}
style={{
position: 'fixed',
right: 24,
@@ -348,28 +334,27 @@ export default function MainLayout({ children }: { children: React.ReactNode })
width: 48,
height: 48,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
background: 'linear-gradient(135deg, #E07A5F 0%, #81B29A 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(22, 119, 255, 0.4)',
boxShadow: '0 4px 12px rgba(224, 122, 95, 0.4)',
zIndex: 1000,
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(22, 119, 255, 0.6)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(224, 122, 95, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(22, 119, 255, 0.4)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(224, 122, 95, 0.4)';
}}
>
<RobotOutlined style={{ color: '#fff', fontSize: 22 }} />
<span style={{ color: '#fff', fontSize: 22 }}>🤖</span>
</div>
</Tooltip>
<AiSidebar open={aiSidebarOpen} onClose={() => setAiSidebarOpen(false)} />
</Layout>
);
}

View File

@@ -1,17 +1,11 @@
import { useEffect, useState, useCallback } from 'react';
import { Row, Col, Spin, Empty } from 'antd';
import {
UserOutlined,
TeamOutlined,
CalendarOutlined,
BookOutlined,
HeartOutlined,
MedicineBoxOutlined,
SafetyCertificateOutlined,
MessageOutlined,
BellOutlined,
AlertOutlined,
TrophyOutlined,
ShoppingOutlined,
FileTextOutlined,
RightOutlined,
PartitionOutlined,
@@ -19,25 +13,17 @@ import {
CheckCircleOutlined,
ThunderboltOutlined,
SettingOutlined,
UserOutlined,
BellOutlined,
ApartmentOutlined,
SmileOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useThemeMode } from '../hooks/useThemeMode';
import { useDashboardRole, type DashboardRole } from '../hooks/useDashboardRole';
import { useMessageStore } from '../stores/message';
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
import { pointsApi, type PersonalStats } from '../api/health/points';
import { useStatsData } from './health/StatisticsDashboard/useStatsData';
import { useCountUp } from '../hooks/useCountUp';
import ActionDetailDrawer from './health/components/workbench/ActionDetailDrawer';
import TaskQueue from './health/components/workbench/TaskQueue';
import TaskDetail from './health/components/workbench/TaskDetail';
import DoctorWorkbench from './health/components/workbench/DoctorWorkbench';
import NurseWorkbench from './health/components/workbench/NurseWorkbench';
import OperatorWorkbench from './health/components/workbench/OperatorWorkbench';
import AdminDashboard from './health/components/workbench/AdminDashboard';
import type { ActionItem } from '../api/health/actionInbox';
// --- Shared utilities ---
@@ -55,17 +41,15 @@ function formatTimeAgo(dateStr: string): string {
const ACTION_LABELS: Record<string, string> = {
create: '创建', created: '创建', update: '更新', updated: '更新', delete: '删除', deleted: '删除',
login: '登录', 'user.create': '创建', 'user.update': '更新', 'user.delete': '删除',
'patient.create': '创建', 'patient.update': '更新', 'appointment.create': '创建',
};
const RESOURCE_LABELS: Record<string, string> = {
user: '用户', role: '角色', patient: '患者', doctor: '医护', appointment: '预约',
follow_up_task: '随访', consultation_session: '咨询', message: '消息', plugin: '插件',
process_instance: '流程实例', organization: '组织',
user: '用户', role: '角色', organization: '组织',
message: '消息', plugin: '插件', process_instance: '流程实例',
};
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
patient: <UserOutlined />, organization: <ApartmentOutlined />,
process_instance: <FileTextOutlined />, message: <BellOutlined />,
organization: <ApartmentOutlined />, process_instance: <FileTextOutlined />,
message: <BellOutlined />,
};
function formatActionLabel(action: string): string {
@@ -75,17 +59,7 @@ function formatResourceLabel(resource: string): string {
return RESOURCE_LABELS[resource] || RESOURCE_LABELS[resource.split('.').pop() || ''] || resource;
}
// --- Role configs ---
interface StatCardDef {
key: string;
title: string;
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => number;
getDiff?: (p: PersonalStats | null) => number | undefined;
icon: React.ReactNode;
suffix?: string;
path: string;
}
// --- Dashboard config ---
interface QuickActionDef {
icon: React.ReactNode;
@@ -93,97 +67,37 @@ interface QuickActionDef {
path: string;
}
const ROLE_WELCOME: Record<DashboardRole, { title: string; subtitle: string }> = {
doctor: { title: '今日工作台', subtitle: '患者概览与待办事项' },
health_manager: { title: '任务工作台', subtitle: '待处理任务与患者管理' },
nurse: { title: '随访监控台', subtitle: '今日随访与体征上报' },
admin: { title: '管理中心', subtitle: '平台运营数据概览' },
operator: { title: '运营中心', subtitle: '积分、内容与活动' },
};
const QUICK_ACTIONS: QuickActionDef[] = [
{ icon: <TeamOutlined />, label: '用户管理', path: '/users' },
{ icon: <SafetyCertificateOutlined />, label: '角色权限', path: '/roles' },
{ icon: <CalendarOutlined />, label: '工作流', path: '/workflow' },
{ icon: <BellOutlined />, label: '消息管理', path: '/messages' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
{ icon: <BookOutlined />, label: '审计日志', path: '/settings' },
];
const STAT_BAR_COLORS: string[] = [
'linear-gradient(90deg, #2563EB, #60A5FA)',
'linear-gradient(90deg, #7C3AED, #A78BFA)',
'linear-gradient(90deg, #DC2626, #F87171)',
'linear-gradient(90deg, #D97706, #FBBF24)',
'linear-gradient(90deg, #E07A5F, #E8907A)',
'linear-gradient(90deg, #81B29A, #8FBF9E)',
'linear-gradient(90deg, #F2CC8F, #D4B878)',
'linear-gradient(90deg, #D4A5A5, #C4A0A0)',
];
const STAT_TEXT_COLORS: string[] = ['#2563EB', '#7C3AED', '#DC2626', '#D97706'];
const STAT_TEXT_COLORS: string[] = ['#E07A5F', '#81B29A', '#F2CC8F', '#D4A5A5'];
const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
doctor: [
{ key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, getDiff: (p) => { const c = p?.my_patients, y = p?.yesterday_my_patients; return c != null && y != null ? c - y : undefined; }, icon: <TeamOutlined />, path: '/health/patients' },
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: <CalendarOutlined />, path: '/health/appointments' },
{ key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, getDiff: (p) => { const c = p?.consultations_this_month, y = p?.yesterday_consultations_this_month; return c != null && y != null ? c - y : undefined; }, icon: <MessageOutlined />, path: '/health/consultations' },
{ key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <HeartOutlined />, suffix: '%', path: '/health/follow-ups' },
],
health_manager: [
{ key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: <HeartOutlined />, path: '/health/follow-up-tasks' },
{ key: 'vital-anomaly', title: '体征异常', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: <AlertOutlined />, path: '/health/alert-dashboard' },
{ key: 'ai-pending', title: 'AI 建议待审', getValue: (p) => p?.consultations_this_month ?? 0, icon: <MedicineBoxOutlined />, path: '/health/ai-analysis' },
{ key: 'followup-rate', title: '处理率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <CheckCircleOutlined />, suffix: '%', path: '/health/follow-up-tasks' },
],
nurse: [
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: <CalendarOutlined />, path: '/health/appointments' },
{ key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: <HeartOutlined />, path: '/health/follow-ups' },
{ key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, getDiff: (p) => { const c = p?.overdue_follow_ups, y = p?.yesterday_overdue_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: <AlertOutlined />, path: '/health/follow-ups' },
{ key: 'vital-rate', title: '体征上报率', getValue: (p) => p?.vital_signs_report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
],
admin: [
{ key: 'patients', title: '患者总数', getValue: (_p, s) => s.patientStats?.total_patients ?? 0, icon: <TeamOutlined />, path: '/health/patients' },
{ key: 'appointments', title: '本月预约', getValue: (_p, s) => s.healthDataStats?.appointments?.this_month ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
{ key: 'followup-rate', title: '随访完成率', getValue: (_p, s) => s.followUpStats?.completion_rate ?? 0, icon: <SafetyCertificateOutlined />, suffix: '%', path: '/health/follow-ups' },
{ key: 'vital-rate', title: '体征上报率', getValue: (_p, s) => s.healthDataStats?.vital_signs_report_rate?.report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
],
operator: [
{ key: 'issued', title: '积分发放', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: <TrophyOutlined />, path: '/health/points' },
{ key: 'spent', title: '积分消费', getValue: (_p, s) => s.pointsStats?.total_spent ?? 0, icon: <ShoppingOutlined />, path: '/health/mall' },
{ key: 'active', title: '活跃账户', getValue: (_p, s) => s.pointsStats?.active_accounts ?? 0, icon: <TeamOutlined />, path: '/health/points' },
{ key: 'articles', title: '内容发布', getValue: (_p, s) => s.patientStats?.total_patients ?? 0, icon: <FileTextOutlined />, path: '/health/content' },
],
};
interface StatCardDef {
key: string;
title: string;
icon: React.ReactNode;
value: number;
path: string;
}
const ROLE_ACTIONS: Record<DashboardRole, QuickActionDef[]> = {
doctor: [
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
{ icon: <MessageOutlined />, label: '咨询管理', path: '/health/consultations' },
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/statistics' },
],
health_manager: [
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-up-tasks' },
{ icon: <MedicineBoxOutlined />, label: '体征监测', path: '/health/alert-dashboard' },
{ icon: <MessageOutlined />, label: '患者咨询', path: '/health/consultations' },
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <TrophyOutlined />, label: '积分商城', path: '/health/points-products' },
{ icon: <FileTextOutlined />, label: '统计报表', path: '/health/statistics' },
],
nurse: [
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <SafetyCertificateOutlined />, label: '健康统计', path: '/health/statistics' },
],
admin: [
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
{ icon: <TrophyOutlined />, label: '积分商城', path: '/health/points' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
],
operator: [
{ icon: <TrophyOutlined />, label: '积分管理', path: '/health/points' },
{ icon: <FileTextOutlined />, label: '内容管理', path: '/health/content' },
{ icon: <CalendarOutlined />, label: '线下活动', path: '/health/events' },
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <SafetyCertificateOutlined />, label: '健康统计', path: '/health/statistics' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
],
};
const STATS: StatCardDef[] = [
{ key: 'pending-tasks', title: '待办任务', icon: <PartitionOutlined />, value: 0, path: '/workflow' },
{ key: 'users', title: '系统用户', icon: <TeamOutlined />, value: 0, path: '/users' },
{ key: 'messages', title: '消息通知', icon: <BellOutlined />, value: 0, path: '/messages' },
{ key: 'logs', title: '操作日志', icon: <FileTextOutlined />, value: 0, path: '/settings' },
];
// --- Components ---
@@ -195,52 +109,37 @@ function StatValue({ value, loading }: { value: number; loading: boolean }) {
export default function Home() {
const navigate = useNavigate();
const role = useDashboardRole();
const isDark = useThemeMode();
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
const [personalStats, setPersonalStats] = useState<PersonalStats | null>(null);
const [personalLoading, setPersonalLoading] = useState(true);
const [pendingTasks, setPendingTasks] = useState<TaskInfo[]>([]);
const [recentActivities, setRecentActivities] = useState<AuditLogItem[]>([]);
const [activitiesLoading, setActivitiesLoading] = useState(true);
const [drawerItem, setDrawerItem] = useState<ActionItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const statsData = useStatsData();
const loading = personalLoading || statsData.loading;
const welcome = ROLE_WELCOME[role];
const statDefs = ROLE_STATS[role];
const quickActions = ROLE_ACTIONS[role];
const [statsLoading, setStatsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUnreadCount();
if (role === 'doctor' || role === 'nurse') {
pointsApi.getPersonalStats()
.then((data) => { if (!cancelled) setPersonalStats(data); })
.catch((err) => console.warn('[Home] 获取个人积分统计失败:', err))
.finally(() => { if (!cancelled) setPersonalLoading(false); });
} else {
setPersonalLoading(false);
}
listPendingTasks(1, 5)
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
.catch((err) => console.warn('[Home] 获取待办任务失败:', err));
.catch((err) => console.warn('[Home] 获取待办任务失败:', err))
.finally(() => { if (!cancelled) setStatsLoading(false); });
listAuditLogs({ page: 1, page_size: 5 })
.then((result) => {
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
if (!cancelled) STATS[3].value = result.total;
})
.catch((err) => console.warn('[Home] 获取审计日志失败:', err))
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [role]);
}, []);
// 更新统计值
STATS[0].value = pendingTasks.length;
const handleNavigate = useCallback((path: string) => {
navigate(path);
@@ -248,20 +147,6 @@ export default function Home() {
return (
<div>
{/* 角色工作台路由 */}
{role === 'doctor' ? (
<DoctorWorkbench />
) : role === 'health_manager' ? (
<div style={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden', margin: -20 }}>
<TaskQueue />
<TaskDetail />
</div>
) : role === 'operator' ? (
<OperatorWorkbench />
) : role === 'admin' ? (
<AdminDashboard />
) : (
<>
{/* 欢迎语 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<h2 style={{
@@ -271,19 +156,17 @@ export default function Home() {
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}>
{welcome.title}
<SmileOutlined style={{ marginRight: 8, color: '#E07A5F' }} />
</h2>
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
{welcome.subtitle}
· ·
</p>
</div>
{/* 统计卡片行 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{statDefs.map((def, i) => {
const value = def.getValue(personalStats, statsData);
const diff = def.getDiff?.(personalStats);
return (
{STATS.map((def, i) => (
<div
key={def.key}
className={`erp-fade-in erp-fade-in-delay-${i + 1}`}
@@ -306,24 +189,14 @@ export default function Home() {
<div style={{ padding: '16px 20px' }}>
<div style={{ fontSize: 12, color: '#94A3B8', marginBottom: 6 }}>{def.title}</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1.2, color: STAT_TEXT_COLORS[i] || STAT_TEXT_COLORS[0] }}>
<StatValue value={value} loading={loading} />
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2 }}>{def.suffix}</span>}
</div>
{diff != null && (
<div style={{ fontSize: 11, marginTop: 4, color: diff > 0 ? '#16A34A' : diff < 0 ? '#DC2626' : '#94A3B8' }}>
{diff === 0 ? '与昨日持平' : `较昨日 ${diff > 0 ? '+' : ''}${diff}`}
</div>
)}
<StatValue value={def.value} loading={statsLoading} />
</div>
</div>
);
})}
</div>
))}
</div>
{/* 护士专属工作台 */}
{role === 'nurse' ? (
<NurseWorkbench />
) : (
{/* 待办任务 + 最近动态 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
@@ -395,7 +268,6 @@ export default function Home() {
</div>
</Col>
</Row>
)}
{/* 快捷入口 */}
<Row gutter={[16, 16]}>
@@ -406,7 +278,7 @@ export default function Home() {
<span className="erp-section-title"></span>
</div>
<Row gutter={[12, 12]}>
{quickActions.map((action) => (
{QUICK_ACTIONS.map((action) => (
<Col xs={12} sm={8} md={4} key={action.path}>
<div
className="erp-quick-action"
@@ -426,18 +298,33 @@ export default function Home() {
</Col>
</Row>
{/* 行动详情抽屉 */}
<ActionDetailDrawer
item={drawerItem}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDrawerItem(null); }}
onActionComplete={() => {
setDrawerOpen(false);
setDrawerItem(null);
}}
/>
</>
)}
{/* 暖记介绍卡片 */}
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
<Col span={24}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{
background: 'linear-gradient(135deg, #FFF8F0 0%, #FFE8DE 100%)',
border: '1px solid #F0D5C8',
borderRadius: 16,
padding: '24px 32px',
}}>
<Row align="middle" gutter={24}>
<Col flex="auto">
<h3 style={{ fontSize: 18, fontWeight: 600, color: '#2D2420', margin: '0 0 8px' }}>
<HeartOutlined style={{ marginRight: 8, color: '#E07A5F' }} />
使
</h3>
<p style={{ fontSize: 14, color: '#6B5E52', margin: 0, lineHeight: 1.6 }}>
App/
</p>
</Col>
<Col>
<BookOutlined style={{ fontSize: 48, color: '#E07A5F', opacity: 0.3 }} />
</Col>
</Row>
</div>
</Col>
</Row>
</div>
);
}

View File

@@ -42,15 +42,15 @@ export default function Login() {
<SafetyCertificateOutlined />
</div>
<h1 className="brand-title">{brand?.brand_name || 'HMS 健康管理台'}</h1>
<p className="brand-desc">{brand?.brand_slogan || '新一代健康管理平台'}</p>
<p className="brand-sub-desc">{brand?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}</p>
<h1 className="brand-title">{brand?.brand_name || '暖记管理台'}</h1>
<p className="brand-desc">{brand?.brand_slogan || '班级管理·日记审核·成长追踪'}</p>
<p className="brand-sub-desc">{brand?.brand_features || '班级管理 · 日记审核 · 老师点评 · 成长追踪'}</p>
<div style={{ marginTop: 48, display: 'flex', gap: 32, justifyContent: 'center' }}>
{[
{ label: '多租户架构', value: 'SaaS' },
{ label: '模块化设计', value: '可插拔' },
{ label: '事件驱动', value: '可扩展' },
{ label: '手写日记', value: '核心' },
{ label: '班级管理', value: '互动' },
{ label: '成长追踪', value: '记录' },
].map((item) => (
<div key={item.label} style={{ textAlign: 'center' }}>
<div className="feature-item-value">{item.value}</div>
@@ -108,7 +108,7 @@ export default function Login() {
</Form>
<div className="form-footer">
{brand?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
{brand?.brand_copyright || '© 暖记 Nuanji'}
</div>
</div>
</main>

View File

@@ -1,406 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
List,
Button,
Input,
Typography,
Spin,
theme,
Modal,
Space,
} from 'antd';
import {
SendOutlined,
RobotOutlined,
PlusOutlined,
MessageOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons';
import {
aiChatApi,
type ChatHistoryItem,
type ChatSession,
type DisplayHint,
} from '../../api/ai/chat';
import RichMessage from '../../components/ai/RichMessage';
import { useAuthStore } from '../../stores/auth';
const { Text } = Typography;
const { TextArea } = Input;
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
displayHints?: DisplayHint[];
}
export default function ChatPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [sessionsLoading, setSessionsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { token } = theme.useToken();
const permissions = useAuthStore((s) => s.permissions);
const canManage = permissions.includes('ai.chat.session.manage');
const loadSessions = useCallback(async () => {
setSessionsLoading(true);
try {
const list = await aiChatApi.listSessions();
setSessions(list);
} catch {
/* ignore */
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
const scrollToBottom = useCallback(() => {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const handleNewSession = async () => {
try {
const session = await aiChatApi.createSession();
setSessions((prev) => [session, ...prev]);
setActiveId(session.id);
setMessages([
{
id: 'welcome',
role: 'assistant',
content: '你好!我是 AI 健康助手。有什么可以帮你的?',
},
]);
} catch {
/* ignore */
}
};
const handleSelectSession = async (id: string) => {
setActiveId(id);
setMessages([]);
try {
const msgs = await aiChatApi.getSessionMessages(id);
const loaded: ChatMessage[] = msgs
.filter((m) => m.content)
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content ?? '',
}));
if (loaded.length === 0) {
loaded.push({
id: 'welcome',
role: 'assistant',
content: '你好!我是 AI 健康助手。有什么可以帮你的?',
});
}
setMessages(loaded);
} catch {
setMessages([
{
id: 'welcome',
role: 'assistant',
content: '你好!我是 AI 健康助手。有什么可以帮你的?',
},
]);
}
};
const handleSend = async () => {
const text = input.trim();
if (!text || loading || !activeId) return;
const userMsg: ChatMessage = {
id: `u-${Date.now()}`,
role: 'user',
content: text,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const history: ChatHistoryItem[] = messages
.filter((m) => m.id !== 'welcome')
.map((m) => ({ role: m.role, content: m.content }));
const resp = await aiChatApi.sendMessage(text, history, undefined, activeId);
setMessages((prev) => [
...prev,
{
id: resp.message_id,
role: 'assistant' as const,
content: resp.reply,
displayHints: resp.display_hints,
},
]);
} catch {
setMessages((prev) => [
...prev,
{
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉AI 服务暂时不可用,请稍后再试。',
},
]);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleRename = (session: ChatSession) => {
Modal.confirm({
title: '重命名会话',
content: (
<Input
id="rename-input"
defaultValue={session.title ?? ''}
placeholder="输入新名称"
/>
),
onOk: async () => {
const input = document.querySelector('#rename-input') as HTMLInputElement;
const newTitle = input?.value?.trim();
if (newTitle) {
await aiChatApi.renameSession(session.id, newTitle);
setSessions((prev) =>
prev.map((s) => (s.id === session.id ? { ...s, title: newTitle } : s))
);
}
},
});
};
const handleClose = async (id: string) => {
await aiChatApi.closeSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (activeId === id) {
setActiveId(null);
setMessages([]);
}
};
const activeSession = sessions.find((s) => s.id === activeId);
return (
<div style={{ display: 'flex', height: 'calc(100vh - 64px)', background: token.colorBgContainer }}>
{/* 左侧会话列表 */}
<div
style={{
width: 260,
flexShrink: 0,
background: token.colorBgLayout,
borderRight: `1px solid ${token.colorBorderSecondary}`,
overflowY: 'auto',
}}
>
<div style={{ padding: 12 }}>
<Button
type="primary"
icon={<PlusOutlined />}
block
onClick={handleNewSession}
disabled={!canManage}
>
</Button>
</div>
<List
loading={sessionsLoading}
dataSource={sessions}
renderItem={(session) => (
<List.Item
style={{
padding: '8px 12px',
cursor: 'pointer',
background:
activeId === session.id
? token.colorPrimaryBg
: 'transparent',
}}
onClick={() => handleSelectSession(session.id)}
actions={
canManage
? [
<EditOutlined
key="rename"
onClick={(e) => {
e.stopPropagation();
handleRename(session);
}}
/>,
<DeleteOutlined
key="close"
onClick={(e) => {
e.stopPropagation();
handleClose(session.id);
}}
/>,
]
: []
}
>
<List.Item.Meta
avatar={<MessageOutlined style={{ color: token.colorPrimary, marginTop: 4 }} />}
title={
<Text ellipsis style={{ fontSize: 13 }}>
{session.title ?? '新会话'}
</Text>
}
description={
<Text type="secondary" style={{ fontSize: 11 }}>
{new Date(session.updated_at).toLocaleDateString()}
</Text>
}
/>
</List.Item>
)}
/>
</div>
{/* 右侧聊天区 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* 标题栏 */}
<div
style={{
padding: '12px 20px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Space>
<RobotOutlined style={{ color: token.colorPrimary }} />
<Text strong>{activeSession?.title ?? '选择或创建一个会话'}</Text>
</Space>
</div>
{/* 消息区 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px 24px',
background: token.colorBgLayout,
}}
>
{!activeId ? (
<div style={{ textAlign: 'center', marginTop: 80 }}>
<RobotOutlined style={{ fontSize: 48, color: token.colorTextQuaternary }} />
<div style={{ marginTop: 16 }}>
<Text type="secondary"></Text>
</div>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: 12,
}}
>
<div
style={{
maxWidth: '75%',
padding: '10px 14px',
borderRadius: 12,
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background:
msg.role === 'user'
? token.colorPrimary
: token.colorBgContainer,
color:
msg.role === 'user'
? token.colorTextLightSolid
: token.colorText,
borderBottomRightRadius: msg.role === 'user' ? 4 : 12,
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : 12,
}}
>
{msg.content}
{msg.displayHints && msg.displayHints.length > 0 && (
<RichMessage hints={msg.displayHints} />
)}
</div>
</div>
))
)}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 12 }}>
<div
style={{
padding: '10px 18px',
borderRadius: 12,
borderBottomLeftRadius: 4,
background: token.colorBgContainer,
}}
>
<Spin size="small" /> <Text type="secondary">...</Text>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区 */}
{activeId && (
<div
style={{
padding: 16,
borderTop: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Space.Compact style={{ width: '100%' }}>
<TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
disabled={loading}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ borderRadius: '8px 0 0 8px' }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={loading}
disabled={!input.trim()}
style={{ height: 'auto', borderRadius: '0 8px 8px 0', minHeight: 40 }}
/>
</Space.Compact>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,566 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tag,
Upload,
Progress,
Drawer,
List,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UploadOutlined,
SearchOutlined,
FileTextOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import {
knowledgeV2Api,
type KnowledgeBase,
type KnowledgeDocument,
type SearchHit,
type CreateKnowledgeBaseReq,
} from '../../api/ai/knowledgeV2';
const KB_TYPES = [
{ label: '临床指南', value: 'clinical_guide' },
{ label: '操作规程', value: 'sop' },
{ label: 'FAQ', value: 'faq' },
{ label: '产品知识', value: 'product' },
{ label: '通用', value: 'general' },
];
export default function KnowledgeV2Page() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
const [form] = Form.useForm();
// Document drawer state
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadKbId, setUploadKbId] = useState<string>('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
// Hit test state
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
const [hitTestQuery, setHitTestQuery] = useState('');
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
const [hitTestLoading, setHitTestLoading] = useState(false);
const loadKbs = useCallback(async () => {
setLoading(true);
try {
const res = await knowledgeV2Api.listKnowledgeBases({
page,
page_size: 20,
});
setKbs(res.data);
setTotal(res.total);
} catch {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
loadKbs();
}, [loadKbs]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
const req: CreateKnowledgeBaseReq = {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled ?? true,
};
await knowledgeV2Api.createKnowledgeBase(req);
message.success('知识库创建成功');
setCreateModalOpen(false);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleUpdate = async () => {
if (!editKb) return;
try {
const values = await form.validateFields();
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled,
});
message.success('知识库更新成功');
setEditKb(null);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeV2Api.deleteKnowledgeBase(id);
message.success('知识库已删除');
loadKbs();
} catch {
message.error('删除失败');
}
};
const loadDocuments = async (kb: KnowledgeBase) => {
setDocDrawerKb(kb);
setDocsLoading(true);
try {
const res = await knowledgeV2Api.listDocuments(kb.id, {
page: 1,
page_size: 50,
});
setDocs(res.data);
} catch {
message.error('加载文档列表失败');
} finally {
setDocsLoading(false);
}
};
const handleUpload = async () => {
if (!uploadKbId || fileList.length === 0) return;
try {
const file = fileList[0].originFileObj;
if (!file) return;
await knowledgeV2Api.uploadDocument(uploadKbId, file);
message.success('文档上传成功,正在处理...');
setUploadModalOpen(false);
setFileList([]);
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('上传失败');
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
try {
await knowledgeV2Api.deleteDocument(kbId, docId);
message.success('文档已删除');
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('删除失败');
}
};
const handleHitTest = async () => {
if (!hitTestKb || !hitTestQuery.trim()) return;
setHitTestLoading(true);
try {
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
setHitResults(res.hits);
} catch {
message.error('搜索失败');
setHitResults([]);
} finally {
setHitTestLoading(false);
}
};
const statusTag = (status: string) => {
const map: Record<string, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
processing: { color: 'processing', label: '处理中' },
completed: { color: 'success', label: '已完成' },
failed: { color: 'error', label: '失败' },
};
const info = map[status] || { color: 'default', label: status };
return <Tag color={info.color}>{info.label}</Tag>;
};
const kbColumns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: KnowledgeBase) => (
<Button type="link" onClick={() => loadDocuments(record)}>
{name}
</Button>
),
},
{
title: '类型',
dataIndex: 'kb_type',
key: 'kb_type',
render: (type: string) => {
const found = KB_TYPES.find((t) => t.value === type);
return found?.label || type;
},
},
{
title: '文档数',
dataIndex: 'document_count',
key: 'document_count',
width: 90,
},
{
title: '切片数',
dataIndex: 'chunk_count',
key: 'chunk_count',
width: 90,
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: KnowledgeBase) => (
<Space size="small">
<Button
size="small"
icon={<UploadOutlined />}
onClick={() => {
setUploadKbId(record.id);
setUploadModalOpen(true);
}}
>
</Button>
<Button
size="small"
icon={<SearchOutlined />}
onClick={() => {
setHitTestKb(record);
setHitResults([]);
setHitTestQuery('');
}}
>
</Button>
<Button
size="small"
onClick={() => {
setEditKb(record);
form.setFieldsValue({
name: record.name,
kb_type: record.kb_type,
description: record.description,
is_enabled: record.is_enabled,
});
}}
>
</Button>
<Popconfirm
title="确定删除此知识库?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const kbFormContent = (
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="知识库名称"
rules={[{ required: true, message: '请输入知识库名称' }]}
>
<Input placeholder="例:高血压临床指南" />
</Form.Item>
<Form.Item
name="kb_type"
label="知识库类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select options={KB_TYPES} placeholder="选择类型" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Form>
);
const docColumns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'doc_type',
key: 'doc_type',
width: 80,
},
{
title: '来源',
dataIndex: 'source_type',
key: 'source_type',
width: 80,
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: statusTag,
},
{
title: '切片/嵌入',
key: 'progress',
width: 130,
render: (_: unknown, record: KnowledgeDocument) => {
if (record.chunk_count === 0) return '-';
const pct = Math.round(
(record.embedded_count / record.chunk_count) * 100,
);
return <Progress percent={pct} size="small" />;
},
},
{
title: '操作',
key: 'actions',
width: 70,
render: (_: unknown, record: KnowledgeDocument) => (
<Popconfirm
title="确定删除此文档?"
onConfirm={() =>
handleDeleteDoc(record.knowledge_base_id, record.id)
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<DatabaseOutlined />
V2
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setCreateModalOpen(true);
}}
>
</Button>
}
>
<Table
rowKey="id"
columns={kbColumns}
dataSource={kbs}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
/>
</Card>
{/* 创建知识库 Modal */}
<Modal
title="新建知识库"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => {
setCreateModalOpen(false);
form.resetFields();
}}
okText="创建"
>
{kbFormContent}
</Modal>
{/* 编辑知识库 Modal */}
<Modal
title="编辑知识库"
open={!!editKb}
onOk={handleUpdate}
onCancel={() => {
setEditKb(null);
form.resetFields();
}}
okText="保存"
>
{kbFormContent}
</Modal>
{/* 文档列表 Drawer */}
<Drawer
title={
docDrawerKb
? `${docDrawerKb.name} — 文档列表`
: '文档列表'
}
open={!!docDrawerKb}
onClose={() => setDocDrawerKb(null)}
width={720}
>
<Table
rowKey="id"
columns={docColumns}
dataSource={docs}
loading={docsLoading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="暂无文档" /> }}
/>
</Drawer>
{/* 上传文档 Modal */}
<Modal
title="上传文档"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => {
setUploadModalOpen(false);
setFileList([]);
}}
okText="上传"
>
<Upload
beforeUpload={() => false}
maxCount={1}
fileList={fileList}
onChange={({ fileList: fl }) => setFileList(fl)}
accept=".pdf,.txt,.md,.docx,.xlsx"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
PDFTXTMarkdownDOCXXLSX 20MB
</div>
</Modal>
{/* Hit Test Drawer */}
<Drawer
title={
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
}
open={!!hitTestKb}
onClose={() => {
setHitTestKb(null);
setHitResults([]);
}}
width={600}
>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Input
placeholder="输入搜索文本..."
value={hitTestQuery}
onChange={(e) => setHitTestQuery(e.target.value)}
onPressEnter={handleHitTest}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={hitTestLoading}
onClick={handleHitTest}
>
</Button>
</Space.Compact>
<List
dataSource={hitResults}
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
renderItem={(item) => (
<List.Item key={item.chunk_id}>
<List.Item.Meta
avatar={
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
}
title={
<Space>
<span>{item.doc_title}</span>
<Tag color="blue"> #{item.chunk_index}</Tag>
<Tag color="green">
{(item.similarity * 100).toFixed(1)}%
</Tag>
</Space>
}
description={
<div
style={{
maxHeight: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
color: '#666',
}}
>
{item.content}
</div>
}
/>
</List.Item>
)}
/>
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,365 @@
import { useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Form,
Input,
Tag,
Drawer,
Modal,
Badge,
Typography,
message,
Tooltip,
} from 'antd';
import {
PlusOutlined,
CopyOutlined,
TeamOutlined,
UserOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { classApi } from '../../api/diary/classes';
import type { SchoolClass, ClassMember } from '../../api/diary/types';
import { PageContainer } from '../../components/PageContainer';
import { DrawerForm } from '../../components/DrawerForm';
import { useCrudDrawer } from '../../hooks/useCrudDrawer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode';
const { Text } = Typography;
export default function ClassList() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
const {
data: classes,
total,
page,
loading,
refresh,
} = usePaginatedData<SchoolClass>(async (p, pageSize) => {
const result = await classApi.list({ page: p, page_size: pageSize });
return { data: result.data, total: result.total };
}, 20);
// --- Create/Edit drawer ---
const classDrawer = useCrudDrawer<SchoolClass>({
getId: (r) => r.id,
onCreate: async (values) => {
await classApi.create({ name: values.name as string, school_name: values.school_name as string | undefined });
},
onUpdate: async () => {
// Class update API not yet available; refresh list silently
},
onSuccess: refresh,
});
// --- Member drawer ---
const [memberDrawerOpen, setMemberDrawerOpen] = useState(false);
const [memberClass, setMemberClass] = useState<SchoolClass | null>(null);
const [members, setMembers] = useState<ClassMember[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const openMemberDrawer = useCallback(async (cls: SchoolClass) => {
setMemberClass(cls);
setMemberDrawerOpen(true);
setMembersLoading(true);
try {
const result = await classApi.listMembers(cls.id);
setMembers(result);
} catch {
message.error('加载班级成员失败');
setMembers([]);
} finally {
setMembersLoading(false);
}
}, []);
// --- Copy class code ---
const handleCopyCode = useCallback((code: string) => {
navigator.clipboard.writeText(code).then(
() => message.success('班级码已复制'),
() => message.error('复制失败,请手动复制'),
);
}, []);
// --- Table columns ---
const columns = [
{
title: '班级名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: SchoolClass) => (
<Button
type="link"
style={{ padding: 0, height: 'auto', fontWeight: 500, fontSize: 14 }}
onClick={() => openMemberDrawer(record)}
>
{name}
</Button>
),
},
{
title: '学校',
dataIndex: 'school_name',
key: 'school_name',
render: (v?: string) => v || <Text type="secondary">-</Text>,
},
{
title: '班级码',
dataIndex: 'class_code',
key: 'class_code',
width: 180,
render: (code: string) => (
<Space size={6}>
<Text
code
style={{
fontFamily: 'JetBrains Mono, Consolas, monospace',
fontSize: 14,
letterSpacing: 1,
}}
>
{code}
</Text>
<Tooltip title="复制班级码">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(code)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
</Tooltip>
</Space>
),
},
{
title: '成员数',
dataIndex: 'member_count',
key: 'member_count',
width: 100,
align: 'center' as const,
render: (count: number) => (
<Badge
count={count}
showZero
style={{
backgroundColor: isDark ? '#1f1f1f' : '#f0f0f0',
color: isDark ? '#e0e0e0' : '#333',
fontWeight: 500,
}}
overflowCount={9999}
/>
),
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
align: 'center' as const,
render: (isActive: boolean) => (
<Tag
color={isActive ? 'success' : 'error'}
style={{ fontWeight: 500, border: 'none' }}
>
{isActive ? '活跃' : '已停用'}
</Tag>
),
},
{
title: '教师',
dataIndex: 'teacher_id',
key: 'teacher_id',
render: (teacherId: string) =>
teacherId ? (
<Space size={4}>
<UserOutlined style={{ color: isDark ? '#94a3b8' : '#475569' }} />
<Text type="secondary">{teacherId}</Text>
</Space>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record: SchoolClass) => (
<Space size={4}>
<Tooltip title="查看成员">
<Button
size="small"
type="text"
icon={<TeamOutlined />}
onClick={() => openMemberDrawer(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
</Tooltip>
</Space>
),
},
];
// --- Member table columns ---
const memberColumns = [
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
render: (v?: string) => v || <Text type="secondary"></Text>,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: string) => {
const roleMap: Record<string, { label: string; color: string }> = {
teacher: { label: '教师', color: 'blue' },
student: { label: '学生', color: 'green' },
parent: { label: '家长', color: 'orange' },
};
const info = roleMap[role] || { label: role, color: 'default' };
return <Tag color={info.color}>{info.label}</Tag>;
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (v: string) => (v ? new Date(v).toLocaleDateString('zh-CN') : '-'),
},
];
return (
<PageContainer
title="班级管理"
subtitle="管理班级信息、班级码和成员"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => refresh()}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => classDrawer.openCreate()}
>
</Button>
</Space>
}
>
<Table
columns={columns}
dataSource={classes}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => refresh(p),
showTotal: (t) => `${t} 条记录`,
}}
onRow={(record) => ({
onClick: () => openMemberDrawer(record),
style: { cursor: 'pointer' },
})}
/>
{/* Create class drawer */}
<DrawerForm
title={classDrawer.editingRecord ? '编辑班级' : '创建班级'}
open={classDrawer.open}
onClose={classDrawer.close}
onSubmit={classDrawer.handleSubmit}
initialValues={classDrawer.initialValues}
loading={classDrawer.loading}
width={480}
columns={1}
>
<Form.Item
name="name"
label="班级名称"
rules={[{ required: true, message: '请输入班级名称' }]}
>
<Input placeholder="例如:三年级二班" maxLength={50} />
</Form.Item>
<Form.Item
name="school_name"
label="学校名称"
>
<Input placeholder="例如:阳光小学" maxLength={100} />
</Form.Item>
</DrawerForm>
{/* Member drawer */}
<Drawer
title={
<Space>
<TeamOutlined />
<span>{memberClass ? `${memberClass.name} - 班级成员` : '班级成员'}</span>
{memberClass && (
<Tag
color={memberClass.is_active ? 'success' : 'error'}
style={{ marginLeft: 8 }}
>
{memberClass.is_active ? '活跃' : '已停用'}
</Tag>
)}
</Space>
}
open={memberDrawerOpen}
onClose={() => {
setMemberDrawerOpen(false);
setMemberClass(null);
setMembers([]);
}}
width={600}
styles={{
body: { background: isDark ? '#141414' : undefined, padding: 0 },
}}
extra={
memberClass ? (
<Space>
<Text type="secondary"></Text>
<Text
code
style={{
fontFamily: 'JetBrains Mono, Consolas, monospace',
fontSize: 14,
letterSpacing: 1,
}}
>
{memberClass.class_code}
</Text>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(memberClass.class_code)}
>
</Button>
</Space>
) : null
}
>
<Table
columns={memberColumns}
dataSource={members}
rowKey="user_id"
loading={membersLoading}
pagination={false}
size="middle"
locale={{ emptyText: '暂无成员' }}
/>
</Drawer>
</PageContainer>
);
}

View File

@@ -0,0 +1,615 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import {
Table,
Button,
Drawer,
Form,
Input,
Tag,
Select,
DatePicker,
Space,
message,
Descriptions,
List,
Divider,
Empty,
Tooltip,
Typography,
} from 'antd';
import {
LockOutlined,
UnlockOutlined,
ReloadOutlined,
SendOutlined,
EyeOutlined,
UserOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import { journalApi } from '../../api/diary/journals';
import { commentApi } from '../../api/diary/comments';
import { classApi } from '../../api/diary/classes';
import type { JournalEntry, Comment, SchoolClass } from '../../api/diary/types';
import { PageContainer } from '../../components/PageContainer';
import { FilterBar } from '../../components/FilterBar';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode';
const { TextArea } = Input;
const { RangePicker } = DatePicker;
const { Text } = Typography;
// --- Mood configuration ---
const MOOD_CONFIG: Record<string, { emoji: string; label: string; color: string }> = {
happy: { emoji: '😊', label: '开心', color: 'success' },
calm: { emoji: '😌', label: '平静', color: 'blue' },
sad: { emoji: '😢', label: '难过', color: 'default' },
angry: { emoji: '😤', label: '生气', color: 'error' },
thinking: { emoji: '🤔', label: '思考', color: 'purple' },
};
// --- Weather configuration ---
const WEATHER_CONFIG: Record<string, { emoji: string; label: string }> = {
sunny: { emoji: '☀️', label: '晴天' },
cloudy: { emoji: '☁️', label: '多云' },
rainy: { emoji: '🌧️', label: '雨天' },
snowy: { emoji: '❄️', label: '雪天' },
windy: { emoji: '💨', label: '大风' },
};
// --- Filter state ---
interface JournalFilters {
mood: string | undefined;
dateRange: [string, string] | undefined;
classId: string | undefined;
}
const DEFAULT_FILTERS: JournalFilters = {
mood: undefined,
dateRange: undefined,
classId: undefined,
};
export default function JournalList() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
// --- Data state ---
const [journals, setJournals] = useState<JournalEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<JournalFilters>(DEFAULT_FILTERS);
// --- Class list for filter dropdown ---
const [classes, setClasses] = useState<SchoolClass[]>([]);
// --- Detail drawer state ---
const [drawerOpen, setDrawerOpen] = useState(false);
const [currentJournal, setCurrentJournal] = useState<JournalEntry | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentSubmitting, setCommentSubmitting] = useState(false);
// --- Comment form ---
const [commentForm] = Form.useForm<{ content: string }>();
const pageSize = 20;
// --- Fetch journals ---
const fetchJournals = useCallback(async (targetPage: number, currentFilters: JournalFilters) => {
setLoading(true);
try {
const params: Record<string, unknown> = {
page: targetPage,
page_size: pageSize,
};
if (currentFilters.mood) {
params.mood = currentFilters.mood;
}
if (currentFilters.classId) {
params.class_id = currentFilters.classId;
}
if (currentFilters.dateRange) {
params.date_from = currentFilters.dateRange[0];
params.date_to = currentFilters.dateRange[1];
}
const result = await journalApi.list(params as Parameters<typeof journalApi.list>[0]);
setJournals(result.data);
setTotal(result.total);
setPage(targetPage);
} catch {
message.error('加载日记列表失败');
} finally {
setLoading(false);
}
}, []);
// --- Fetch classes for filter ---
const fetchClasses = useCallback(async () => {
try {
const result = await classApi.list({ page: 1, page_size: 200 });
setClasses(result.data);
} catch {
// Silently fail — class filter is optional
}
}, []);
// --- Initial load ---
useEffect(() => {
fetchJournals(1, DEFAULT_FILTERS);
fetchClasses();
}, [fetchJournals, fetchClasses]);
// --- Handle filter change ---
const handleFilterChange = useCallback((key: keyof JournalFilters, value: unknown) => {
setFilters((prev) => {
const next = { ...prev, [key]: value };
fetchJournals(1, next);
return next;
});
}, [fetchJournals]);
// --- Reset filters ---
const handleResetFilters = useCallback(() => {
const reset = { ...DEFAULT_FILTERS };
setFilters(reset);
fetchJournals(1, reset);
}, [fetchJournals]);
// --- Open detail drawer ---
const openDetail = useCallback(async (journal: JournalEntry) => {
setCurrentJournal(journal);
setDrawerOpen(true);
setCommentsLoading(true);
try {
const result = await commentApi.list(journal.id);
setComments(result);
} catch {
message.error('加载评论失败');
setComments([]);
} finally {
setCommentsLoading(false);
}
}, []);
// --- Close detail drawer ---
const closeDetail = useCallback(() => {
setDrawerOpen(false);
setCurrentJournal(null);
setComments([]);
commentForm.resetFields();
}, [commentForm]);
// --- Submit teacher comment ---
const handleCommentSubmit = useCallback(async () => {
if (!currentJournal) return;
const values = await commentForm.validateFields();
setCommentSubmitting(true);
const result = await execute(
() => commentApi.create(currentJournal.id, { content: values.content }),
'评论已发送',
'评论发送失败',
);
setCommentSubmitting(false);
if (result) {
setComments((prev) => [...prev, result]);
commentForm.resetFields();
}
}, [currentJournal, commentForm, execute]);
// --- Render helpers ---
const renderMood = useCallback((mood: string) => {
const config = MOOD_CONFIG[mood];
if (!config) return <Tag>{mood}</Tag>;
return (
<Tag color={config.color} style={{ fontWeight: 500, border: 'none' }}>
{config.emoji} {config.label}
</Tag>
);
}, []);
const renderWeather = useCallback((weather: string) => {
const config = WEATHER_CONFIG[weather];
if (!config) return <span>{weather}</span>;
return (
<Tooltip title={config.label}>
<span style={{ fontSize: 18 }}>{config.emoji}</span>
</Tooltip>
);
}, []);
const renderPrivacy = useCallback((isPrivate: boolean) => {
if (isPrivate) {
return (
<Tooltip title="仅自己可见">
<LockOutlined style={{ color: isDark ? '#94a3b8' : '#8c8c8c', fontSize: 16 }} />
</Tooltip>
);
}
return (
<Tooltip title="公开可见">
<UnlockOutlined style={{ color: '#81B29A', fontSize: 16 }} />
</Tooltip>
);
}, [isDark]);
const renderSharedToClass = useCallback((shared: boolean) => (
<Tag
color={shared ? 'success' : 'default'}
style={{ fontWeight: 500, border: 'none' }}
>
{shared ? '已分享' : '未分享'}
</Tag>
), []);
// --- Table columns ---
const columns = useMemo(() => [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (title: string, record: JournalEntry) => (
<Button
type="link"
style={{ padding: 0, height: 'auto', fontWeight: 500, fontSize: 14, textAlign: 'left' }}
onClick={(e) => { e.stopPropagation(); openDetail(record); }}
>
{title || '无标题'}
</Button>
),
},
{
title: '作者',
dataIndex: 'author_id',
key: 'author_id',
width: 160,
render: (authorId: string) => (
<Space size={4}>
<UserOutlined style={{ color: isDark ? '#94a3b8' : '#475569' }} />
<Text
type="secondary"
style={{ fontFamily: 'JetBrains Mono, Consolas, monospace', fontSize: 12 }}
>
{authorId.length > 12 ? `${authorId.slice(0, 12)}...` : authorId}
</Text>
</Space>
),
},
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 120,
render: (date: string) => (
<Space size={4}>
<CalendarOutlined style={{ color: isDark ? '#94a3b8' : '#475569' }} />
<span>{date ? new Date(date).toLocaleDateString('zh-CN') : '-'}</span>
</Space>
),
},
{
title: '心情',
dataIndex: 'mood',
key: 'mood',
width: 110,
align: 'center' as const,
render: (mood: string) => renderMood(mood),
},
{
title: '天气',
dataIndex: 'weather',
key: 'weather',
width: 70,
align: 'center' as const,
render: (weather: string) => renderWeather(weather),
},
{
title: '隐私',
dataIndex: 'is_private',
key: 'is_private',
width: 70,
align: 'center' as const,
render: (isPrivate: boolean) => renderPrivacy(isPrivate),
},
{
title: '班级分享',
dataIndex: 'shared_to_class',
key: 'shared_to_class',
width: 100,
align: 'center' as const,
render: (shared: boolean) => renderSharedToClass(shared),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
{v ? new Date(v).toLocaleString('zh-CN') : '-'}
</Text>
),
},
{
title: '操作',
key: 'actions',
width: 80,
render: (_: unknown, record: JournalEntry) => (
<Tooltip title="查看详情">
<Button
size="small"
type="text"
icon={<EyeOutlined />}
onClick={(e) => { e.stopPropagation(); openDetail(record); }}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
</Tooltip>
),
},
], [isDark, openDetail, renderMood, renderWeather, renderPrivacy, renderSharedToClass]);
// --- Mood filter options ---
const moodOptions = useMemo(() =>
Object.entries(MOOD_CONFIG).map(([value, config]) => ({
value,
label: `${config.emoji} ${config.label}`,
})),
[],
);
// --- Class filter options ---
const classOptions = useMemo(() =>
classes.map((cls) => ({
value: cls.id,
label: cls.name,
})),
[classes],
);
// --- Detail drawer content ---
const drawerContent = useMemo(() => {
if (!currentJournal) return null;
const moodConfig = MOOD_CONFIG[currentJournal.mood];
const weatherConfig = WEATHER_CONFIG[currentJournal.weather];
return (
<>
<Descriptions
column={2}
size="middle"
bordered
style={{ marginBottom: 24 }}
labelStyle={{ fontWeight: 500, whiteSpace: 'nowrap' }}
>
<Descriptions.Item label="标题" span={2}>
<Text strong style={{ fontSize: 16 }}>{currentJournal.title || '无标题'}</Text>
</Descriptions.Item>
<Descriptions.Item label="日期">
{currentJournal.date ? new Date(currentJournal.date).toLocaleDateString('zh-CN') : '-'}
</Descriptions.Item>
<Descriptions.Item label="作者">
<Space size={4}>
<UserOutlined />
<Text
code
style={{ fontFamily: 'JetBrains Mono, Consolas, monospace', fontSize: 12 }}
>
{currentJournal.author_id}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label="心情">
{moodConfig
? (
<Tag color={moodConfig.color} style={{ fontWeight: 500, border: 'none', fontSize: 14 }}>
{moodConfig.emoji} {moodConfig.label}
</Tag>
)
: <Tag>{currentJournal.mood}</Tag>}
</Descriptions.Item>
<Descriptions.Item label="天气">
{weatherConfig
? <span style={{ fontSize: 20 }}>{weatherConfig.emoji} {weatherConfig.label}</span>
: <span>{currentJournal.weather}</span>}
</Descriptions.Item>
<Descriptions.Item label="标签" span={2}>
{currentJournal.tags.length > 0
? currentJournal.tags.map((tag) => (
<Tag key={tag} style={{ marginBottom: 4 }}>{tag}</Tag>
))
: <Text type="secondary"></Text>}
</Descriptions.Item>
<Descriptions.Item label="隐私设置">
<Space size={8}>
{renderPrivacy(currentJournal.is_private)}
<Text type="secondary" style={{ fontSize: 13 }}>
{currentJournal.is_private ? '仅自己可见' : '公开可见'}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label="班级分享">
{renderSharedToClass(currentJournal.shared_to_class)}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
<Text type="secondary">
{currentJournal.created_at ? new Date(currentJournal.created_at).toLocaleString('zh-CN') : '-'}
</Text>
</Descriptions.Item>
</Descriptions>
<Divider orientation="left" style={{ fontSize: 15, fontWeight: 500 }}>
({comments.length})
</Divider>
<List
dataSource={comments}
loading={commentsLoading}
locale={{ emptyText: <Empty description="暂无评论" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
renderItem={(comment) => (
<List.Item
style={{
padding: '12px 0',
borderBottom: `1px solid ${isDark ? '#3A3530' : '#f0f0f0'}`,
}}
>
<List.Item.Meta
title={
<Space size={8}>
<UserOutlined style={{ color: isDark ? '#94a3b8' : '#475569' }} />
<Text
style={{ fontFamily: 'JetBrains Mono, Consolas, monospace', fontSize: 12 }}
>
{comment.author_id.length > 16
? `${comment.author_id.slice(0, 16)}...`
: comment.author_id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{comment.created_at
? new Date(comment.created_at).toLocaleString('zh-CN')
: ''}
</Text>
</Space>
}
description={
<div style={{ marginTop: 8, lineHeight: 1.6 }}>
{comment.content}
</div>
}
/>
</List.Item>
)}
style={{ marginBottom: 16, maxHeight: 360, overflowY: 'auto' }}
/>
<Divider style={{ margin: '8px 0 16px' }} />
<Form form={commentForm} layout="vertical">
<Form.Item
name="content"
rules={[
{ required: true, message: '请输入评论内容' },
{ max: 500, message: '评论不能超过 500 字' },
]}
>
<TextArea
rows={3}
placeholder="写下你的点评..."
maxLength={500}
showCount
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={commentSubmitting}
onClick={handleCommentSubmit}
>
</Button>
</Form.Item>
</Form>
</>
);
}, [
currentJournal, comments, commentsLoading, commentSubmitting,
isDark, commentForm, handleCommentSubmit,
renderPrivacy, renderSharedToClass,
]);
return (
<PageContainer
title="日记审阅"
subtitle="查看与点评学生日记"
filters={
<Space wrap size="middle">
<Select
allowClear
placeholder="筛选心情"
options={moodOptions}
value={filters.mood}
onChange={(value) => handleFilterChange('mood', value || undefined)}
style={{ minWidth: 130 }}
/>
<RangePicker
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
handleFilterChange('dateRange', [
dates[0].format('YYYY-MM-DD'),
dates[1].format('YYYY-MM-DD'),
]);
} else {
handleFilterChange('dateRange', undefined);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Select
allowClear
placeholder="筛选班级"
options={classOptions}
value={filters.classId}
onChange={(value) => handleFilterChange('classId', value || undefined)}
style={{ minWidth: 150 }}
showSearch
optionFilterProp="label"
/>
</Space>
}
onResetFilters={handleResetFilters}
actions={
<Button
icon={<ReloadOutlined />}
onClick={() => fetchJournals(page, filters)}
>
</Button>
}
>
<Table
columns={columns}
dataSource={journals}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize,
onChange: (p) => fetchJournals(p, filters),
showTotal: (t) => `${t} 条记录`,
showSizeChanger: false,
}}
onRow={(record) => ({
onClick: () => openDetail(record),
style: { cursor: 'pointer' },
})}
locale={{ emptyText: <Empty description="暂无日记" /> }}
/>
{/* Detail drawer */}
<Drawer
title={
<Space>
<EyeOutlined />
<span></span>
</Space>
}
open={drawerOpen}
onClose={closeDetail}
width={640}
styles={{
body: { background: isDark ? '#141414' : undefined },
}}
destroyOnClose
>
{drawerContent}
</Drawer>
</PageContainer>
);
}

View File

@@ -0,0 +1,470 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import {
Card,
Row,
Col,
Tag,
Space,
Empty,
Modal,
Image,
Button,
Spin,
Select,
Typography,
Tooltip,
} from 'antd';
import {
ReloadOutlined,
AppstoreOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { stickerApi } from '../../api/diary/stickers';
import type { StickerPack, Sticker } from '../../api/diary/types';
import { PageContainer } from '../../components/PageContainer';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode';
const { Text, Paragraph } = Typography;
// --- Category color mapping ---
const CATEGORY_COLORS: Record<string, string> = {
animal: '#E07A5F',
food: '#81B29A',
nature: '#6DB1BF',
emoji: '#F2CC8F',
school: '#9B8EC4',
holiday: '#D4A5A5',
travel: '#5F9EA0',
sport: '#E8A87C',
};
const CATEGORY_LABELS: Record<string, string> = {
animal: '动物',
food: '美食',
nature: '自然',
emoji: '表情',
school: '校园',
holiday: '节日',
travel: '旅行',
sport: '运动',
};
// --- Filter state ---
interface StickerFilters {
category: string | undefined;
}
const DEFAULT_FILTERS: StickerFilters = {
category: undefined,
};
export default function StickerPackList() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
// --- Data state ---
const [packs, setPacks] = useState<StickerPack[]>([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<StickerFilters>(DEFAULT_FILTERS);
// --- Pack detail modal state ---
const [modalOpen, setModalOpen] = useState(false);
const [currentPack, setCurrentPack] = useState<StickerPack | null>(null);
const [stickers, setStickers] = useState<Sticker[]>([]);
const [stickersLoading, setStickersLoading] = useState(false);
// --- Fetch sticker packs ---
const fetchPacks = useCallback(async (currentFilters: StickerFilters) => {
setLoading(true);
try {
const params: { category?: string } = {};
if (currentFilters.category) {
params.category = currentFilters.category;
}
const result = await stickerApi.listPacks(params);
setPacks(result);
} catch {
// Error handled by useApiRequest if used via execute, or silently here
setPacks([]);
} finally {
setLoading(false);
}
}, []);
// --- Initial load ---
useEffect(() => {
fetchPacks(DEFAULT_FILTERS);
}, [fetchPacks]);
// --- Handle filter change ---
const handleFilterChange = useCallback((value: string | undefined) => {
const next = { category: value };
setFilters(next);
fetchPacks(next);
}, [fetchPacks]);
// --- Reset filters ---
const handleResetFilters = useCallback(() => {
const reset = { ...DEFAULT_FILTERS };
setFilters(reset);
fetchPacks(reset);
}, [fetchPacks]);
// --- Open pack detail modal ---
const openPackDetail = useCallback(async (pack: StickerPack) => {
setCurrentPack(pack);
setModalOpen(true);
setStickersLoading(true);
setStickers([]);
const result = await execute(
() => stickerApi.listStickers(pack.id),
undefined,
'加载贴纸失败',
);
setStickersLoading(false);
if (result) {
setStickers(result);
}
}, [execute]);
// --- Close pack detail modal ---
const closePackDetail = useCallback(() => {
setModalOpen(false);
setCurrentPack(null);
setStickers([]);
}, []);
// --- Category filter options ---
const categoryOptions = useMemo(() =>
Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
value,
label,
})),
[],
);
// --- Render helpers ---
const renderCategoryTag = useCallback((category?: string) => {
if (!category) return null;
const color = CATEGORY_COLORS[category] || '#8c8c8c';
const label = CATEGORY_LABELS[category] || category;
return (
<Tag
color={color}
style={{ fontWeight: 500, border: 'none', color: '#fff' }}
>
{label}
</Tag>
);
}, []);
const renderPriceTag = useCallback((isFree: boolean) => {
if (isFree) {
return (
<Tag
color="success"
style={{ fontWeight: 600, border: 'none' }}
>
</Tag>
);
}
return (
<Tag
color="gold"
style={{ fontWeight: 600, border: 'none' }}
>
</Tag>
);
}, []);
// --- Pack card style ---
const cardStyle = useMemo(() => ({
borderRadius: 16,
overflow: 'hidden' as const,
border: `1px solid ${isDark ? '#3A3530' : '#f0e8e0'}`,
background: isDark ? '#1f1f1f' : '#ffffff',
cursor: 'pointer' as const,
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
height: '100%',
}), [isDark]);
const cardHoverShadow = '0 8px 24px rgba(224, 122, 95, 0.15)';
// --- Pack cards ---
const packCards = useMemo(() => {
if (packs.length === 0 && !loading) {
return (
<Col span={24}>
<Empty
description="暂无贴纸包"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '60px 0' }}
/>
</Col>
);
}
return packs.map((pack) => (
<Col xs={24} sm={12} md={8} lg={6} key={pack.id}>
<Card
hoverable
style={cardStyle}
styles={{
body: { padding: 0 },
}}
onClick={() => openPackDetail(pack)}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.boxShadow = cardHoverShadow;
(e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
}}
>
{/* Cover image */}
<div
style={{
height: 160,
background: isDark
? 'linear-gradient(135deg, #2A2520 0%, #3A3530 100%)'
: 'linear-gradient(135deg, #FFF8F0 0%, #FFE8D6 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
position: 'relative',
}}
>
{pack.cover_image_url ? (
<img
src={pack.cover_image_url}
alt={pack.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<PictureOutlined
style={{
fontSize: 48,
color: isDark ? '#5A5550' : '#D4B896',
}}
/>
)}
{/* Price badge overlay */}
<div style={{ position: 'absolute', top: 8, right: 8 }}>
{renderPriceTag(pack.is_free)}
</div>
</div>
{/* Pack info */}
<div style={{ padding: '12px 16px 16px' }}>
<Space size={6} align="center" style={{ marginBottom: 6 }}>
<Text
strong
ellipsis
style={{
fontSize: 15,
maxWidth: 160,
color: isDark ? '#F0E8DF' : '#2D2420',
}}
>
{pack.name}
</Text>
</Space>
{pack.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{
fontSize: 13,
marginBottom: 8,
lineHeight: 1.5,
color: isDark ? '#94a3b8' : '#6B6560',
}}
>
{pack.description}
</Paragraph>
)}
<Space size={8} wrap>
{renderCategoryTag(pack.category)}
<Tooltip title={`${pack.sticker_count} 个贴纸`}>
<Tag
style={{
fontWeight: 500,
border: 'none',
background: isDark ? '#2A2520' : '#FFF0E5',
color: isDark ? '#D4B896' : '#E07A5F',
}}
>
<Space size={4}>
<AppstoreOutlined />
<span>{pack.sticker_count}</span>
</Space>
</Tag>
</Tooltip>
</Space>
</div>
</Card>
</Col>
));
}, [packs, loading, isDark, cardStyle, openPackDetail, cardHoverShadow, renderPriceTag, renderCategoryTag]);
return (
<PageContainer
title="贴纸包管理"
subtitle="管理暖记贴纸素材包"
filters={
<Select
allowClear
placeholder="筛选分类"
options={categoryOptions}
value={filters.category}
onChange={handleFilterChange}
style={{ minWidth: 130 }}
/>
}
onResetFilters={handleResetFilters}
actions={
<Button
icon={<ReloadOutlined />}
onClick={() => fetchPacks(filters)}
>
</Button>
}
>
<Spin spinning={loading}>
<Row gutter={[16, 16]} style={{ padding: 24 }}>
{packCards}
</Row>
</Spin>
{/* Pack detail modal */}
<Modal
title={
<Space>
<AppstoreOutlined style={{ color: '#E07A5F' }} />
<span>{currentPack?.name ?? '贴纸包详情'}</span>
{currentPack && renderPriceTag(currentPack.is_free)}
</Space>
}
open={modalOpen}
onCancel={closePackDetail}
footer={null}
width={720}
destroyOnClose
styles={{
body: {
background: isDark ? '#141414' : undefined,
paddingTop: 20,
},
}}
>
{currentPack && (
<>
{/* Pack meta */}
<div style={{ marginBottom: 20 }}>
<Space size={12} wrap>
{renderCategoryTag(currentPack.category)}
<Tag
style={{
border: 'none',
background: isDark ? '#2A2520' : '#FFF0E5',
color: isDark ? '#D4B896' : '#E07A5F',
fontWeight: 500,
}}
>
{currentPack.sticker_count}
</Tag>
</Space>
{currentPack.description && (
<Paragraph
type="secondary"
style={{ marginTop: 8, marginBottom: 0, fontSize: 14 }}
>
{currentPack.description}
</Paragraph>
)}
</div>
{/* Sticker grid */}
<Spin spinning={stickersLoading}>
{stickers.length > 0 ? (
<Row gutter={[12, 12]}>
{stickers.map((sticker) => (
<Col span={6} key={sticker.id}>
<div
style={{
borderRadius: 12,
border: `1px solid ${isDark ? '#3A3530' : '#f0e8e0'}`,
background: isDark ? '#1f1f1f' : '#FFF8F0',
padding: 12,
textAlign: 'center',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
}}
>
<Image
src={sticker.image_url}
alt={sticker.name}
preview={{
mask: (
<span style={{ fontSize: 12 }}>
</span>
),
}}
style={{
maxWidth: '100%',
maxHeight: 120,
objectFit: 'contain',
}}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHJ4PSI4IiBmaWxsPSIjRjVGNUY1Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNDQ0MiIGZvbnQtc2l6ZT0iMTQiPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD48L3N2Zz4="
/>
<Text
ellipsis
style={{
display: 'block',
marginTop: 6,
fontSize: 12,
color: isDark ? '#94a3b8' : '#6B6560',
}}
>
{sticker.name}
</Text>
</div>
</Col>
))}
</Row>
) : (
!stickersLoading && (
<Empty
description="该贴纸包暂无贴纸"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '40px 0' }}
/>
)
)}
</Spin>
</>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,557 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import {
Card,
Button,
Modal,
Form,
Input,
DatePicker,
Select,
Tag,
Space,
message,
Empty,
Row,
Col,
Badge,
Typography,
Tooltip,
} from 'antd';
import {
PlusOutlined,
ReloadOutlined,
CalendarOutlined,
FileTextOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { topicApi } from '../../api/diary/topics';
import { classApi } from '../../api/diary/classes';
import type { TopicAssignment, SchoolClass } from '../../api/diary/types';
import { PageContainer } from '../../components/PageContainer';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
export default function TopicList() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
// --- Class selector state ---
const [classes, setClasses] = useState<SchoolClass[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string | undefined>(undefined);
const [classesLoading, setClassesLoading] = useState(false);
// --- Topics state ---
const [topics, setTopics] = useState<TopicAssignment[]>([]);
const [topicsLoading, setTopicsLoading] = useState(false);
// --- Create modal state ---
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
// --- Fetch classes ---
const fetchClasses = useCallback(async () => {
setClassesLoading(true);
try {
const result = await classApi.list({ page: 1, page_size: 200 });
const classList = result.data;
setClasses(classList);
// Auto-select first active class
if (classList.length > 0 && !selectedClassId) {
const firstActive = classList.find((c) => c.is_active);
setSelectedClassId(firstActive?.id ?? classList[0].id);
}
} catch {
message.error('加载班级列表失败');
} finally {
setClassesLoading(false);
}
}, [selectedClassId]);
// --- Fetch topics for selected class ---
const fetchTopics = useCallback(async (classId: string) => {
setTopicsLoading(true);
try {
const result = await topicApi.list(classId);
setTopics(result);
} catch {
message.error('加载主题列表失败');
setTopics([]);
} finally {
setTopicsLoading(false);
}
}, []);
// --- Initial load ---
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
// --- Fetch topics when class changes ---
useEffect(() => {
if (selectedClassId) {
fetchTopics(selectedClassId);
} else {
setTopics([]);
}
}, [selectedClassId, fetchTopics]);
// --- Class selector options ---
const classOptions = useMemo(
() =>
classes.map((cls) => ({
value: cls.id,
label: (
<Space size={6}>
<span>{cls.name}</span>
{cls.is_active ? (
<Tag color="success" style={{ fontSize: 11, lineHeight: '18px', padding: '0 4px', marginRight: 0 }}>
</Tag>
) : (
<Tag color="default" style={{ fontSize: 11, lineHeight: '18px', padding: '0 4px', marginRight: 0 }}>
</Tag>
)}
</Space>
),
})),
[classes],
);
// --- Open create modal ---
const openCreateModal = useCallback(() => {
if (!selectedClassId) {
message.warning('请先选择一个班级');
return;
}
form.resetFields();
setModalOpen(true);
}, [selectedClassId, form]);
// --- Submit create topic ---
const handleCreate = useCallback(async () => {
if (!selectedClassId) return;
const values = await form.validateFields();
setSubmitting(true);
const result = await execute(
() =>
topicApi.assign(selectedClassId, {
title: values.title as string,
description: values.description as string | undefined,
due_date: values.due_date ? (values.due_date as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
}),
'主题已发布',
'发布主题失败',
);
setSubmitting(false);
if (result) {
setModalOpen(false);
form.resetFields();
fetchTopics(selectedClassId);
}
}, [selectedClassId, form, execute, fetchTopics]);
// --- Check overdue ---
const isOverdue = useCallback((dueDate?: string) => {
if (!dueDate) return false;
return dayjs(dueDate).isBefore(dayjs(), 'day');
}, []);
// --- Format date display ---
const formatDate = useCallback((dateStr?: string) => {
if (!dateStr) return null;
return dayjs(dateStr).format('YYYY-MM-DD');
}, []);
// --- Selected class info ---
const selectedClass = useMemo(
() => classes.find((c) => c.id === selectedClassId),
[classes, selectedClassId],
);
// --- Active/inactive topic counts ---
const topicCounts = useMemo(() => {
const active = topics.filter((t) => t.is_active).length;
const overdue = topics.filter((t) => isOverdue(t.due_date)).length;
return { active, overdue, total: topics.length };
}, [topics, isOverdue]);
return (
<PageContainer
title="主题管理"
subtitle="为班级布置写作主题,引导学生创作"
actions={
<Space>
<Button
icon={<ReloadOutlined />}
onClick={() => {
fetchClasses();
if (selectedClassId) fetchTopics(selectedClassId);
}}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
disabled={!selectedClassId}
>
</Button>
</Space>
}
>
{/* Class selector bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
marginBottom: 20,
padding: '12px 16px',
background: isDark ? '#1A1614' : '#FFF8F0',
borderRadius: 10,
border: `1px solid ${isDark ? '#3A3530' : '#F0E8DF'}`,
}}
>
<Text
strong
style={{ whiteSpace: 'nowrap', color: isDark ? '#B0A89E' : '#8B7A6E' }}
>
</Text>
<Select
value={selectedClassId}
onChange={setSelectedClassId}
options={classOptions}
loading={classesLoading}
placeholder="请选择班级"
style={{ minWidth: 240 }}
showSearch
optionFilterProp="label"
allowClear
onClear={() => {
setSelectedClassId(undefined);
setTopics([]);
}}
/>
{selectedClass && (
<Space size={12} style={{ marginLeft: 'auto' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{topicCounts.total}
</Text>
{topicCounts.active > 0 && (
<Badge
count={topicCounts.active}
style={{ backgroundColor: '#81B29A' }}
overflowCount={99}
>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
</Badge>
)}
{topicCounts.overdue > 0 && (
<Badge
count={topicCounts.overdue}
style={{ backgroundColor: '#E07A5F' }}
overflowCount={99}
>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
</Badge>
)}
</Space>
)}
</div>
{/* Topic card grid */}
{!selectedClassId ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Space direction="vertical" size={4} align="center">
<Text type="secondary"></Text>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</Space>
}
/>
) : topicsLoading ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Text type="secondary">...</Text>
</div>
) : topics.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Space direction="vertical" size={4} align="center">
<Text type="secondary"></Text>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
</Button>
</Space>
}
/>
) : (
<Row gutter={[16, 16]}>
{topics.map((topic) => {
const overdue = isOverdue(topic.due_date);
const dueDateStr = formatDate(topic.due_date);
return (
<Col key={topic.id} xs={24} sm={12} md={8} lg={8} xl={6}>
<Card
hoverable
style={{
borderRadius: 16,
border: `1px solid ${isDark ? '#3A3530' : '#F0E8DF'}`,
background: isDark ? '#2A2520' : '#FFFFFF',
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 0.2s, transform 0.2s',
}}
styles={{
body: {
padding: 20,
flex: 1,
display: 'flex',
flexDirection: 'column',
},
}}
>
{/* Header: status badge + overdue tag */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<Badge
status={topic.is_active ? 'success' : 'default'}
text={
<Text
style={{
fontSize: 12,
color: topic.is_active
? '#81B29A'
: isDark
? '#666'
: '#999',
fontWeight: 500,
}}
>
{topic.is_active ? '进行中' : '已结束'}
</Text>
}
/>
{overdue && (
<Tag
color="error"
style={{
fontSize: 11,
lineHeight: '18px',
padding: '0 6px',
border: 'none',
fontWeight: 500,
}}
>
<ExclamationCircleOutlined />
</Tag>
)}
</div>
{/* Title */}
<div style={{ marginBottom: 8 }}>
<Space size={6} align="center">
<FileTextOutlined
style={{
color: '#E07A5F',
fontSize: 16,
}}
/>
<Text
strong
style={{
fontSize: 15,
color: isDark ? '#F0E8DF' : '#2D2420',
lineHeight: 1.4,
}}
>
{topic.title}
</Text>
</Space>
</div>
{/* Description */}
{topic.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 3, tooltip: topic.description }}
style={{
fontSize: 13,
lineHeight: 1.6,
marginBottom: 16,
flex: 1,
}}
>
{topic.description}
</Paragraph>
)}
{/* Footer: due date */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
borderTop: `1px solid ${isDark ? '#3A3530' : '#F5EDE5'}`,
marginTop: 'auto',
}}
>
{dueDateStr ? (
<Tooltip title={`截止日期:${dueDateStr}`}>
<Space size={4}>
<CalendarOutlined
style={{
fontSize: 13,
color: overdue ? '#E07A5F' : isDark ? '#94a3b8' : '#8B7A6E',
}}
/>
<Text
style={{
fontSize: 12,
color: overdue ? '#E07A5F' : isDark ? '#94a3b8' : '#8B7A6E',
}}
>
{overdue ? `已过期 (${dueDateStr})` : dueDateStr}
</Text>
</Space>
</Tooltip>
) : (
<Text
type="secondary"
style={{ fontSize: 12 }}
>
</Text>
)}
<Text
type="secondary"
style={{ fontSize: 11 }}
>
{topic.teacher_id.length > 10
? `${topic.teacher_id.slice(0, 10)}...`
: topic.teacher_id}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
)}
{/* Create topic modal */}
<Modal
title={
<Space>
<FileTextOutlined style={{ color: '#E07A5F' }} />
<span></span>
</Space>
}
open={modalOpen}
onOk={handleCreate}
onCancel={() => {
setModalOpen(false);
form.resetFields();
}}
confirmLoading={submitting}
okText="发布"
cancelText="取消"
okButtonProps={{
style: {
background: '#E07A5F',
borderColor: '#E07A5F',
},
}}
destroyOnClose
width={520}
>
<Form
form={form}
layout="vertical"
style={{ marginTop: 16 }}
>
<Form.Item
name="title"
label="主题标题"
rules={[{ required: true, message: '请输入主题标题' }]}
>
<Input
placeholder="例如:我最喜欢的季节"
maxLength={100}
showCount
/>
</Form.Item>
<Form.Item
name="description"
label="主题描述"
>
<TextArea
placeholder="描述写作要求或提示,帮助学生理解主题..."
maxLength={500}
showCount
rows={4}
/>
</Form.Item>
<Form.Item
name="due_date"
label="截止日期"
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择截止日期(可选)"
disabledDate={(current) => current && current < dayjs().startOf('day')}
/>
</Form.Item>
</Form>
<div
style={{
padding: '10px 12px',
background: isDark ? '#1A1614' : '#FFF8F0',
borderRadius: 10,
marginTop: 4,
}}
>
<Text
type="secondary"
style={{ fontSize: 12, lineHeight: 1.6 }}
>
{selectedClass?.name ?? '未选择'}
</Text>
</div>
</Modal>
</PageContainer>
);
}

View File

@@ -1,170 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Tag, List, Badge, Tabs, Spin, Empty } from 'antd';
import { PageContainer } from '../../components/PageContainer';
import ActionThreadDrawer from '../../components/ActionThreadDrawer';
import {
actionInboxApi,
type ActionItem,
type ActionType,
type ActionPriority,
} from '../../api/health/actionInbox';
import { formatRelative } from '../../utils/format';
const TYPE_CONFIG: Record<ActionType, { label: string; color: string }> = {
ai_suggestion: { label: 'AI建议', color: '#722ed1' },
alert: { label: '告警', color: '#f5222d' },
followup: { label: '随访', color: '#1890ff' },
data_anomaly: { label: '异常', color: '#fa8c16' },
};
const PRIORITY_LABEL: Record<ActionPriority, string> = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低',
};
const PRIORITY_COLOR: Record<ActionPriority, string> = {
urgent: 'red',
high: 'orange',
medium: 'blue',
low: 'default',
};
const STATUS_TABS = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
];
const BADGE_STATUS: Record<string, 'error' | 'processing' | 'default'> = {
pending: 'error',
in_progress: 'processing',
completed: 'default',
};
export default function ActionInbox() {
const [items, setItems] = useState<ActionItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState('all');
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<ActionItem | null>(null);
const fetchData = useCallback(
async (p: number, status?: string) => {
setLoading(true);
try {
const resp = await actionInboxApi.list({
page: p,
page_size: 20,
status: status === 'all' ? undefined : status,
});
setItems(resp.data);
setTotal(resp.total);
setPage(p);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
fetchData(1, 'all');
}, [fetchData]);
const handleTabChange = (key: string) => {
setStatusFilter(key);
fetchData(1, key);
};
const handleItemClick = (item: ActionItem) => {
setSelectedItem(item);
setDrawerOpen(true);
};
const handleActionComplete = () => {
fetchData(page, statusFilter);
};
return (
<PageContainer
title="行动收件箱"
subtitle={`${total} 项待办`}
>
<Tabs
activeKey={statusFilter}
onChange={handleTabChange}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
/>
<Spin spinning={loading}>
{items.length === 0 && !loading ? (
<Empty description="暂无待办事项" />
) : (
<List
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => fetchData(p, statusFilter),
showTotal: (t) => `${t}`,
}}
renderItem={(item) => {
const typeConf =
TYPE_CONFIG[item.action_type] ??
({ label: '未知', color: '#999' } as {
label: string;
color: string;
});
return (
<List.Item
style={{ cursor: 'pointer', padding: '12px 16px' }}
onClick={() => handleItemClick(item)}
>
<List.Item.Meta
avatar={
<Badge
status={BADGE_STATUS[item.status] ?? 'default'}
/>
}
title={
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Tag color={typeConf.color}>{typeConf.label}</Tag>
<span>{item.title}</span>
<Tag color={PRIORITY_COLOR[item.priority]}>
{PRIORITY_LABEL[item.priority]}
</Tag>
</div>
}
description={`${item.patient_name} · ${formatRelative(item.created_at)}`}
/>
</List.Item>
);
}}
/>
)}
</Spin>
<ActionThreadDrawer
open={drawerOpen}
item={selectedItem}
onClose={() => setDrawerOpen(false)}
onActionComplete={handleActionComplete}
/>
</PageContainer>
);
}

View File

@@ -1,54 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createAnalysisFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import AiAnalysisList from './AiAnalysisList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock suggestion API (used by sub-component)
vi.mock('../../api/ai/suggestions', () => ({
suggestionApi: {
list: vi.fn().mockResolvedValue({ data: [], total: 0 }),
approve: vi.fn(),
},
}));
const mockAnalyses = createFixtureList(createAnalysisFixture, 6, [
{ id: 'analysis-1', analysis_type: 'lab_report_interpretation', status: 'completed', patient_name: '张三' },
{ id: 'analysis-2', analysis_type: 'health_trend_analysis', status: 'streaming', patient_name: '李四' },
]);
createListPageTests({
Component: AiAnalysisList,
apiPath: '/api/v1/ai/analysis/history',
columns: ['分析类型', '患者', '模型', '状态'],
firstRowTexts: ['化验单解读'],
totalItems: 6,
hasCreateButton: false,
hasSearch: true,
hasPagination: true,
mockItems: mockAnalyses as Record<string, unknown>[],
});
describe('AiAnalysisList extra tests', () => {
it('renders page header with title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AiAnalysisList />);
await waitFor(() => {
expect(screen.getByText('AI 分析历史')).toBeInTheDocument();
});
});
it('shows analysis type filter dropdown', async () => {
renderWithProviders(<AiAnalysisList />);
await waitFor(() => {
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,482 +0,0 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { Table, Select, Tag, Space, Button, message, Result, Typography } from 'antd';
import {
RobotOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useThemeMode } from '../../hooks/useThemeMode';
import { usePermission } from '../../hooks/usePermission';
import { analysisApi, type AnalysisItem } from '../../api/ai/analysis';
import { suggestionApi, type SuggestionItem } from '../../api/ai/suggestions';
import { EntityName } from '../../components/EntityName';
const { Text } = Typography;
const ANALYSIS_TYPE_MAP: Record<string, string> = {
lab_report_interpretation: '化验单解读',
health_trend_analysis: '趋势分析',
personalized_checkup_plan: '体检方案',
report_summary_generation: '报告摘要',
};
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
completed: { color: 'green', text: '已完成' },
failed: { color: 'red', text: '失败' },
streaming: { color: 'blue', text: '进行中' },
pending: { color: 'orange', text: '等待中' },
};
const TYPE_OPTIONS = Object.entries(ANALYSIS_TYPE_MAP).map(([value, label]) => ({
value,
label,
}));
const RISK_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
low: { color: 'green', text: '低风险', icon: <CheckCircleOutlined /> },
medium: { color: 'orange', text: '中风险', icon: <ExclamationCircleOutlined /> },
high: { color: 'red', text: '高风险', icon: <WarningOutlined /> },
};
const SUGGESTION_TYPE_MAP: Record<string, string> = {
followup: '随访建议',
appointment: '预约建议',
alert: '预警通知',
};
const SUGGESTION_STATUS_CONFIG: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待审批' },
approved: { color: 'green', text: '已批准' },
rejected: { color: 'red', text: '已拒绝' },
executed: { color: 'blue', text: '已执行' },
expired: { color: 'default', text: '已过期' },
parse_failed: { color: 'red', text: '解析失败' },
};
// ---------------------------------------------------------------------------
// 分析结果渲染Markdown 风格)
// ---------------------------------------------------------------------------
/** 递归提取 JSON 嵌套中的实际文本内容 */
function extractPlainText(raw: string): string {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null && typeof parsed.content === 'string') {
return extractPlainText(parsed.content);
}
return raw;
} catch {
return raw;
}
}
function AnalysisContent({ content, isDark }: { content: string; isDark: boolean }) {
const text = extractPlainText(content);
// 简单的 Markdown 风格渲染
const lines = text.split('\n');
const rendered = lines.map((line, i) => {
// 标题行
if (line.startsWith('### ')) {
return (
<Text key={i} strong style={{ display: 'block', fontSize: 14, marginTop: 12, marginBottom: 4 }}>
{line.slice(4)}
</Text>
);
}
if (line.startsWith('## ')) {
return (
<Text key={i} strong style={{ display: 'block', fontSize: 15, marginTop: 16, marginBottom: 6, borderBottom: '1px solid ' + (isDark ? '#1e293b' : '#f0f0f0'), paddingBottom: 4 }}>
{line.slice(3)}
</Text>
);
}
// 列表项
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<div key={i} style={{ paddingLeft: 16, position: 'relative', lineHeight: 1.8 }}>
<span style={{ position: 'absolute', left: 4, color: '#3b82f6' }}></span>
{renderInlineStyles(line.slice(2))}
</div>
);
}
// 有序列表
const orderedMatch = line.match(/^(\d+)\.\s/);
if (orderedMatch) {
return (
<div key={i} style={{ paddingLeft: 16, lineHeight: 1.8 }}>
<Text type="secondary" style={{ marginRight: 4 }}>{orderedMatch[1]}.</Text>
{renderInlineStyles(line.slice(orderedMatch[0].length))}
</div>
);
}
// 空行
if (line.trim() === '') {
return <div key={i} style={{ height: 8 }} />;
}
// 普通段落
return <div key={i} style={{ lineHeight: 1.8 }}>{renderInlineStyles(line)}</div>;
});
return <div style={{ fontSize: 13 }}>{rendered}</div>;
}
/** 简单行内样式渲染(粗体、代码) */
function renderInlineStyles(text: string) {
// 拆分 **bold** 和 `code` 模式
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <Text key={i} strong>{part.slice(2, -2)}</Text>;
}
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={i} style={{
background: 'rgba(59, 130, 246, 0.1)',
padding: '1px 4px',
borderRadius: 3,
fontSize: 12,
fontFamily: 'monospace',
}}>
{part.slice(1, -1)}
</code>
);
}
return <span key={i}>{part}</span>;
});
}
// ---------------------------------------------------------------------------
// AI 建议面板
// ---------------------------------------------------------------------------
function SuggestionPanel({ analysisId, isDark }: { analysisId: string; isDark: boolean }) {
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const fetchSuggestions = useCallback(async () => {
setLoading(true);
try {
const result = await suggestionApi.list({ analysis_id: analysisId });
setSuggestions(result.data || []);
} catch {
// 静默处理
} finally {
setLoading(false);
}
}, [analysisId]);
useEffect(() => {
if (analysisId) fetchSuggestions();
}, [analysisId, fetchSuggestions]);
const handleAction = async (id: string, action: 'approve' | 'reject') => {
setActionLoading(id);
try {
await suggestionApi.approve(id, action);
message.success(action === 'approve' ? '已批准' : '已拒绝');
fetchSuggestions();
} catch {
message.error('操作失败');
} finally {
setActionLoading(null);
}
};
if (loading) return <div style={{ padding: '8px 0' }}>...</div>;
if (suggestions.length === 0) return null;
return (
<div style={{
marginTop: 16,
padding: 12,
background: isDark ? '#0f172a' : '#f8fafc',
borderRadius: 8,
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
}}>
<Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
AI ({suggestions.length})
</Text>
{suggestions.map((s) => {
const risk = RISK_CONFIG[s.risk_level] || { color: 'default', text: s.risk_level, icon: null };
const status = SUGGESTION_STATUS_CONFIG[s.status] || { color: 'default', text: s.status };
const typeLabel = SUGGESTION_TYPE_MAP[s.suggestion_type] || s.suggestion_type;
const isPending = s.status === 'pending';
const params = s.params as Record<string, unknown> | null;
const reason = params?.reason as string || params?.message as string || '';
return (
<div
key={s.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
marginBottom: 6,
background: isDark ? '#111827' : '#fff',
borderRadius: 6,
border: `1px solid ${isDark ? '#1e293b' : '#f0f0f0'}`,
}}
>
<Space size={8}>
<Tag color={risk.color} style={{ margin: 0 }}>
{risk.icon} {risk.text}
</Tag>
<Tag style={{ margin: 0 }}>{typeLabel}</Tag>
{reason && <Text type="secondary" style={{ fontSize: 12, maxWidth: 300 }} ellipsis>{reason}</Text>}
<Tag color={status.color} style={{ margin: 0, fontSize: 11 }}>{status.text}</Tag>
</Space>
{isPending && (
<Space size={4}>
<Button
type="primary"
size="small"
icon={<CheckCircleOutlined />}
loading={actionLoading === s.id}
onClick={() => handleAction(s.id, 'approve')}
>
</Button>
<Button
danger
size="small"
icon={<CloseCircleOutlined />}
loading={actionLoading === s.id}
onClick={() => handleAction(s.id, 'reject')}
>
</Button>
</Space>
)}
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// 主组件
// ---------------------------------------------------------------------------
export default function AiAnalysisList() {
const { hasPermission } = usePermission('ai.analysis.list');
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看 AI 分析的权限" />;
const [searchParams] = useSearchParams();
const urlPatientId = searchParams.get('patient_id');
const [data, setData] = useState<AnalysisItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<{ page: number; page_size: number; analysis_type?: string; patient_id?: string }>({
page: 1,
page_size: 20,
patient_id: urlPatientId || undefined,
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detail, setDetail] = useState<AnalysisItem | null>(null);
const isDark = useThemeMode();
const fetchData = useCallback(
async (params: { page: number; page_size: number; analysis_type?: string }) => {
setLoading(true);
try {
const result = await analysisApi.list(params);
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载分析历史失败');
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
fetchData(query);
}, [query, fetchData]);
const handleExpand = async (expanded: boolean, record: AnalysisItem) => {
if (expanded && record.id !== expandedId) {
try {
const item = await analysisApi.get(record.id);
setDetail(item);
setExpandedId(record.id);
} catch {
// 展开失败不阻塞
}
} else if (!expanded) {
setExpandedId(null);
setDetail(null);
}
};
// 解析 metadata 中的趋势统计信息
const trendMetrics = useMemo(() => {
if (!detail?.result_metadata) return null;
const meta = detail.result_metadata as Record<string, unknown>;
// 自动分析结果中可能包含 metrics 统计
// 也从 sanitized_input 中解析(如果有的话)
return meta;
}, [detail]);
const columns = useMemo(() => [
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 130,
render: (v: string) => (
<Tag color="blue">{ANALYSIS_TYPE_MAP[v] || v}</Tag>
),
},
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (_: unknown, record: AnalysisItem) => (
<Link to={`/health/patients/${record.patient_id}`}>
<EntityName name={record.patient_name} id={record.patient_id} />
</Link>
),
},
{
title: '模型',
dataIndex: 'model_used',
key: 'model_used',
width: 130,
render: (v: string) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (v: string) => {
const cfg = STATUS_CONFIG[v] || { color: 'default', text: v };
return <Tag color={cfg.color}>{cfg.text}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
},
], []);
return (
<div>
<div className="erp-page-header">
<div>
<h4>AI </h4>
<div className="erp-page-subtitle"> AI </div>
</div>
<Space size={8}>
<Select
placeholder="筛选类型"
value={query.analysis_type}
onChange={(v) => setQuery((prev) => ({ ...prev, analysis_type: v, page: 1 }))}
options={TYPE_OPTIONS}
allowClear
style={{ width: 150 }}
/>
</Space>
</div>
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
expandable={{
expandedRowKeys: expandedId ? [expandedId] : [],
onExpand: handleExpand,
expandedRowRender: () => {
if (!detail) return null;
const isTrendAnalysis = detail.analysis_type === 'trend';
return (
<div style={{ padding: '8px 0' }}>
{/* 自动分析标记 */}
{trendMetrics && (trendMetrics as Record<string, unknown>).auto_analysis === true && (
<div style={{ marginBottom: 8 }}>
<Tag color="purple" style={{ fontSize: 11 }}>
<RobotOutlined style={{ marginRight: 4 }} />
</Tag>
</div>
)}
{detail.error_message && (
<div style={{ marginBottom: 12 }}>
<Text type="danger">: {detail.error_message}</Text>
</div>
)}
{detail.result_content && (
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
</Text>
<div
style={{
background: isDark ? '#0f172a' : '#f8fafc',
padding: 16,
borderRadius: 8,
maxHeight: 600,
overflow: 'auto',
}}
>
<AnalysisContent content={detail.result_content} isDark={isDark} />
</div>
{/* 趋势分析类型显示统计摘要提示 */}
{isTrendAnalysis && (
<div style={{ marginTop: 12, padding: '8px 12px', background: isDark ? '#0f172a' : '#fffbeb', borderRadius: 6, border: `1px solid ${isDark ? '#1e293b' : '#fde68a'}` }}>
<Text type="secondary" style={{ fontSize: 11 }}>
线 2 R² 1
</Text>
</div>
)}
</div>
)}
{!detail.result_content && !detail.error_message && (
<Text type="secondary"></Text>
)}
{/* AI 建议面板 */}
{detail.id && (
<SuggestionPanel analysisId={detail.id} isDark={isDark} />
)}
</div>
);
},
}}
pagination={{
current: query.page,
total,
pageSize: query.page_size,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => setQuery((prev) => ({ ...prev, page: p, page_size: ps })),
}}
/>
</div>
</div>
);
}

View File

@@ -1,418 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card,
Form,
Input,
InputNumber,
Button,
Space,
message,
Divider,
Spin,
Tabs,
Select,
Switch,
Collapse,
Typography,
} from 'antd';
import { SaveOutlined, UndoOutlined } from '@ant-design/icons';
import { aiConfigApi, type AiConfig, type AiProviderConfig } from '../../api/ai/config';
import { AuthButton } from '../../components/AuthButton';
import { useThemeMode } from '../../hooks/useThemeMode';
const { TextArea } = Input;
const { Text } = Typography;
const PROVIDER_LABELS: Record<string, string> = {
claude: 'Claude (Anthropic)',
openai: 'OpenAI 兼容',
ollama: 'Ollama (本地)',
};
export default function AiConfigPage() {
const [config, setConfig] = useState<AiConfig | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const isDark = useThemeMode();
const fetchConfig = useCallback(async () => {
setLoading(true);
try {
const data = await aiConfigApi.get();
setConfig(data);
form.setFieldsValue({
agentModel: data.agent.model,
agentTemperature: data.agent.temperature,
agentMaxTokens: data.agent.max_tokens,
agentMaxIterations: data.agent.max_iterations,
agentSystemPrompt: data.agent.system_prompt,
analysisModel: data.analysis_defaults.model,
analysisTemperature: data.analysis_defaults.temperature,
analysisMaxTokens: data.analysis_defaults.max_tokens,
defaultProvider: data.default_provider || 'claude',
// Provider fields
...buildProviderFields(data.providers),
});
} catch {
message.error('加载 AI 配置失败');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const providers: Record<string, AiProviderConfig> = {};
for (const name of ['claude', 'openai', 'ollama']) {
providers[name] = {
provider_type: name,
enabled: values[`provider_${name}_enabled`] ?? false,
base_url: values[`provider_${name}_base_url`] ?? '',
api_key: values[`provider_${name}_api_key`] ?? '',
model: values[`provider_${name}_model`] ?? '',
};
}
const updated: AiConfig = {
agent: {
model: values.agentModel,
temperature: values.agentTemperature,
max_tokens: values.agentMaxTokens,
max_iterations: values.agentMaxIterations,
system_prompt: values.agentSystemPrompt,
},
analysis_defaults: {
model: values.analysisModel,
temperature: values.analysisTemperature,
max_tokens: values.analysisMaxTokens,
},
default_provider: values.defaultProvider || 'claude',
providers,
};
const result = await aiConfigApi.update(updated);
setConfig(result);
message.success('AI 配置已保存');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) {
return;
}
message.error('保存 AI 配置失败');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
try {
const defaults = await aiConfigApi.getDefaults();
form.setFieldsValue({
agentModel: defaults.agent.model,
agentTemperature: defaults.agent.temperature,
agentMaxTokens: defaults.agent.max_tokens,
agentMaxIterations: defaults.agent.max_iterations,
agentSystemPrompt: defaults.agent.system_prompt,
analysisModel: defaults.analysis_defaults.model,
analysisTemperature: defaults.analysis_defaults.temperature,
analysisMaxTokens: defaults.analysis_defaults.max_tokens,
});
message.info('已恢复为系统默认值(未保存)');
} catch {
message.error('加载默认配置失败');
}
};
if (loading && !config) {
return (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
);
}
const cardStyle = isDark
? { background: '#1f1f1f', borderColor: '#333' }
: {};
return (
<div style={{ padding: 24, maxWidth: 960, margin: '0 auto' }}>
<h2 style={{ marginBottom: 24 }}>AI </h2>
<Form form={form} layout="vertical">
<Tabs
items={[
{
key: 'agent',
label: 'Agent 对话配置',
forceRender: true,
children: (
<Card
title="AI 客服 Agent 参数"
size="small"
style={cardStyle}
>
<Form.Item
label="模型名称"
name="agentModel"
rules={[{ required: true, message: '请输入模型名称' }]}
extra="如 claude-sonnet-4-6、gpt-4o 等"
>
<Input placeholder="claude-sonnet-4-6" />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item
label="温度 (Temperature)"
name="agentTemperature"
rules={[{ required: true, message: '请输入温度参数' }]}
extra="0.0 ~ 2.0,越高越随机"
style={{ width: 200 }}
>
<InputNumber
min={0}
max={2}
step={0.1}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="最大 Token"
name="agentMaxTokens"
rules={[{ required: true, message: '请输入最大 Token' }]}
extra="1 ~ 65536"
style={{ width: 200 }}
>
<InputNumber
min={1}
max={65536}
step={256}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="最大迭代次数"
name="agentMaxIterations"
rules={[
{ required: true, message: '请输入最大迭代次数' },
]}
extra="1 ~ 20Agent ReAct 循环上限"
style={{ width: 200 }}
>
<InputNumber
min={1}
max={20}
style={{ width: '100%' }}
/>
</Form.Item>
</Space>
<Divider style={{ margin: '8px 0 16px' }} />
<Form.Item
label="系统提示词 (System Prompt)"
name="agentSystemPrompt"
rules={[{ required: true, message: '请输入系统提示词' }]}
extra="定义 AI 客服的角色和行为策略"
>
<TextArea rows={12} placeholder="你是 HMS 健康管理平台的 AI 健康顾问..." />
</Form.Item>
</Card>
),
},
{
key: 'analysis',
label: '分析任务默认配置',
forceRender: true,
children: (
<Card
title="AI 分析任务默认参数"
size="small"
style={cardStyle}
extra="当 Prompt 模板未指定模型参数时使用的默认值"
>
<Form.Item
label="默认模型"
name="analysisModel"
rules={[{ required: true, message: '请输入默认模型' }]}
extra="化验单解读、趋势分析等分析任务的默认模型"
>
<Input placeholder="claude-sonnet-4-6" />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item
label="默认温度"
name="analysisTemperature"
rules={[{ required: true, message: '请输入默认温度' }]}
extra="分析任务建议用较低温度"
style={{ width: 200 }}
>
<InputNumber
min={0}
max={2}
step={0.1}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="默认最大 Token"
name="analysisMaxTokens"
rules={[
{ required: true, message: '请输入默认最大 Token' },
]}
style={{ width: 200 }}
>
<InputNumber
min={1}
max={65536}
step={256}
style={{ width: '100%' }}
/>
</Form.Item>
</Space>
</Card>
),
},
{
key: 'providers',
label: '模型供应商配置',
forceRender: true,
children: (
<Card
title="AI 模型供应商"
size="small"
style={cardStyle}
extra="配置各 AI 供应商的连接参数,保存后立即生效"
>
<Form.Item
label="默认供应商"
name="defaultProvider"
extra="系统优先使用的 AI 供应商"
>
<Select style={{ width: 240 }}>
<Select.Option value="claude">Claude (Anthropic)</Select.Option>
<Select.Option value="openai">OpenAI </Select.Option>
<Select.Option value="ollama">Ollama ()</Select.Option>
</Select>
</Form.Item>
<Divider style={{ margin: '8px 0 16px' }} />
<Collapse
items={(['claude', 'openai', 'ollama'] as const).map((name) => ({
key: name,
forceRender: true,
label: (
<Space>
<Form.Item
name={`provider_${name}_enabled`}
valuePropName="checked"
noStyle
>
<Switch size="small" />
</Form.Item>
<span>{PROVIDER_LABELS[name]}</span>
{config?.providers?.[name]?.enabled && (
<Text type="success"></Text>
)}
</Space>
),
children: (
<>
<Form.Item
label="Base URL"
name={`provider_${name}_base_url`}
>
<Input placeholder={
name === 'claude' ? 'https://api.anthropic.com' :
name === 'openai' ? 'https://api.openai.com' :
'http://localhost:11434'
} />
</Form.Item>
{name !== 'ollama' && (
<Form.Item
label="API Key"
name={`provider_${name}_api_key`}
extra="已保存的 Key 会显示为掩码格式(****xxxx输入新值即可覆盖"
>
<Input.Password
placeholder="sk-..."
visibilityToggle
/>
</Form.Item>
)}
<Form.Item
label="默认模型"
name={`provider_${name}_model`}
>
<Input placeholder={
name === 'claude' ? 'claude-sonnet-4-6' :
name === 'openai' ? 'gpt-4o' :
'qwen3:8b'
} />
</Form.Item>
</>
),
}))}
/>
</Card>
),
},
]}
/>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Space>
<Button icon={<UndoOutlined />} onClick={handleReset}>
</Button>
<AuthButton code="ai.config.manage">
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
</Button>
</AuthButton>
</Space>
</div>
</Form>
</div>
);
}
const PROVIDER_DEFAULTS: Record<string, { base_url: string; model: string }> = {
claude: { base_url: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
openai: { base_url: 'https://api.openai.com', model: 'gpt-4o' },
ollama: { base_url: 'http://localhost:11434', model: 'qwen3:8b' },
};
function buildProviderFields(
providers: Record<string, AiProviderConfig> | undefined,
): Record<string, unknown> {
if (!providers) return {};
const fields: Record<string, unknown> = {};
for (const [name, cfg] of Object.entries(providers)) {
const defaults = PROVIDER_DEFAULTS[name];
fields[`provider_${name}_enabled`] = cfg.enabled;
fields[`provider_${name}_base_url`] = cfg.base_url || defaults?.base_url || '';
fields[`provider_${name}_api_key`] = cfg.api_key;
fields[`provider_${name}_model`] = cfg.model || defaults?.model || '';
}
return fields;
}

View File

@@ -1,476 +0,0 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import {
Table,
Button,
Space,
Form,
Input,
Select,
Tag,
Badge,
message,
Drawer,
Descriptions,
Typography,
Slider,
InputNumber,
Modal,
} from 'antd';
import {
PlusOutlined,
UndoOutlined,
CheckOutlined,
EyeOutlined,
StopOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { promptApi, type PromptItem } from '../../api/ai/prompts';
import { AuthButton } from '../../components/AuthButton';
import { DrawerForm } from '../../components/DrawerForm';
import type { FormSection } from '../../components/DrawerForm';
import { PageContainer } from '../../components/PageContainer';
import { useThemeMode } from '../../hooks/useThemeMode';
import { formatDateTime } from '../../utils/format';
// --- 分析类型定义(与后端 AnalysisType::prompt_name() 一一对应) ---
const ANALYSIS_TYPES = [
{ value: 'lab_report_interpretation', label: '化验单解读', api: '化验单解读 API' },
{ value: 'health_trend_analysis', label: '趋势分析', api: '趋势分析 API' },
{ value: 'personalized_checkup_plan', label: '体检方案', api: '体检方案 API' },
{ value: 'report_summary_generation', label: '报告摘要', api: '报告摘要 API' },
{ value: 'follow_up_summary_generation', label: '随访摘要', api: '随访摘要 API' },
] as const;
const ANALYSIS_TYPE_MAP = Object.fromEntries(
ANALYSIS_TYPES.map((t) => [t.value, t]),
);
const MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'qwen-plus', label: 'Qwen Plus' },
];
const DEFAULT_MODEL_CONFIG = { model: 'deepseek-chat', temperature: 0.7, max_tokens: 4096 };
export default function AiPromptList() {
const [data, setData] = useState<PromptItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [analysisTypeFilter, setAnalysisTypeFilter] = useState<string | undefined>();
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [viewing, setViewing] = useState<PromptItem | null>(null);
const [form] = Form.useForm();
const isDark = useThemeMode();
const fetchData = useCallback(
async (p = page) => {
setLoading(true);
try {
const result = await promptApi.list({
page: p,
page_size: 20,
analysis_type: analysisTypeFilter,
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载 Prompt 列表失败');
} finally {
setLoading(false);
}
},
[page, analysisTypeFilter],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleCreate = async (values: Record<string, unknown>) => {
const model = String(values.model ?? 'deepseek-chat');
const temperature = Number(values.temperature ?? 0.7);
const max_tokens = Number(values.max_tokens ?? 4096);
try {
await promptApi.create({
name: String(values.name ?? ''),
analysis_type: String(values.analysis_type ?? ''),
category: String(values.analysis_type ?? ''),
description: values.description ? String(values.description) : undefined,
system_prompt: String(values.system_prompt ?? ''),
user_prompt_template: String(values.user_prompt_template ?? ''),
model_config: { model, temperature, max_tokens },
});
message.success('Prompt 创建成功');
setDrawerOpen(false);
form.resetFields();
fetchData();
} catch {
message.error('创建失败');
}
};
const handleActivate = useCallback(async (id: string) => {
try {
await promptApi.activate(id);
message.success('已激活');
fetchData();
} catch {
message.error('激活失败');
}
}, [fetchData]);
const handleDeactivate = useCallback(async (id: string) => {
try {
await promptApi.deactivate(id);
message.success('已停用');
fetchData();
} catch {
message.error('停用失败');
}
}, [fetchData]);
const handleRollback = useCallback(async (id: string) => {
try {
await promptApi.rollback(id);
message.success('已回滚');
fetchData();
} catch {
message.error('回滚失败');
}
}, [fetchData]);
const handleDelete = useCallback((record: PromptItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除 Prompt「${record.name}」(v${record.version}) 吗?`,
okType: 'danger',
onOk: async () => {
try {
await promptApi.delete(record.id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
},
});
}, [fetchData]);
const openDetail = (record: PromptItem) => {
setViewing(record);
setDetailOpen(true);
};
// 按 analysis_type 汇总当前激活版本
const activeVersionMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of data) {
if (item.is_active) {
map.set(item.analysis_type, item.version);
}
}
return map;
}, [data]);
const columns = useMemo(() => [
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 160,
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
},
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 130,
render: (v: string) => {
const cfg = ANALYSIS_TYPE_MAP[v];
return cfg ? <Tag color="blue">{cfg.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '调用链路',
dataIndex: 'analysis_type',
key: 'api_route',
width: 160,
render: (v: string) => {
const cfg = ANALYSIS_TYPE_MAP[v];
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>{cfg?.api ?? v}</Typography.Text>;
},
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 70,
render: (v: number, record: PromptItem) => {
const isActive = activeVersionMap.get(record.analysis_type) === v && record.is_active;
return isActive ? <Tag color="green">v{v}</Tag> : <span>v{v}</span>;
},
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 80,
render: (v: boolean) => (
<Badge status={v ? 'success' : 'default'} text={v ? '启用' : '停用'} />
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
key: 'actions',
width: 220,
render: (_: unknown, record: PromptItem) => (
<AuthButton code="ai.prompt.manage">
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openDetail(record)}>
</Button>
{!record.is_active && (
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleActivate(record.id)}>
</Button>
)}
{record.is_active && (
<Button type="link" size="small" icon={<StopOutlined />} onClick={() => handleDeactivate(record.id)}>
</Button>
)}
<Button type="link" size="small" icon={<UndoOutlined />} onClick={() => handleRollback(record.id)}>
</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
</Button>
</Space>
</AuthButton>
),
},
], [handleActivate, handleDeactivate, handleRollback, handleDelete, activeVersionMap]);
const formSections: FormSection[] = [
{
title: '基本信息',
fields: (
<>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
extra="选择后自动填充名称,决定该 Prompt 被哪条分析链路调用"
>
<Select
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
placeholder="选择分析类型"
/>
</Form.Item>
<Form.Item
name="name"
label="标识符"
rules={[
{ required: true, message: '请输入标识符' },
{ pattern: /^[a-z0-9_]{3,64}$/, message: '仅允许小写字母、数字、下划线3-64位' },
]}
extra="后端按此标识符查找 Prompt通常与分析类型一致非必要勿改"
>
<Input placeholder="如 lab_report_interpretation" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="Prompt 用途说明(仅展示,不影响选择)" />
</Form.Item>
</>
),
},
{
title: '模型配置',
fields: (
<>
<Form.Item name="model" label="模型" rules={[{ required: true, message: '请选择模型' }]}>
<Select options={MODEL_OPTIONS} placeholder="选择 AI 模型" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" extra="越低越确定,越高越多样">
<Slider min={0} max={2} step={0.1} />
</Form.Item>
<Form.Item name="max_tokens" label="Max Tokens">
<InputNumber min={256} max={8192} step={256} style={{ width: '100%' }} placeholder="4096" />
</Form.Item>
</>
),
},
{
title: '提示词模板',
fields: (
<>
<Form.Item
name="system_prompt"
label="System Prompt"
rules={[{ required: true, message: '请输入 System Prompt' }]}
>
<Input.TextArea rows={6} placeholder="系统提示词,定义 AI 的角色和行为规则" />
</Form.Item>
<Form.Item
name="user_prompt_template"
label="User Prompt 模板"
rules={[{ required: true, message: '请输入 User Prompt 模板' }]}
extra="支持 Handlebars {{变量}} 语法,如 {{patient_name}}、{{report_date}}"
>
<Input.TextArea rows={6} placeholder="用户提示词模板,可用 {{变量}} 占位" />
</Form.Item>
</>
),
},
];
return (
<PageContainer
title="AI Prompt 管理"
subtitle="管理 AI 分析提示词模板和版本"
filters={
<Select
placeholder="筛选分析类型"
value={analysisTypeFilter}
onChange={(v) => {
setAnalysisTypeFilter(v);
setPage(1);
}}
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
allowClear
style={{ width: 160 }}
/>
}
actions={
<AuthButton code="ai.prompt.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
form.setFieldsValue(DEFAULT_MODEL_CONFIG);
setDrawerOpen(true);
}}
>
Prompt
</Button>
</AuthButton>
}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchData(p);
},
showTotal: (t) => `${t} 条记录`,
}}
/>
{/* 新建 Prompt Drawer */}
<DrawerForm
title="新建 Prompt"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleCreate}
form={form}
onValuesChange={(changed) => {
if ('analysis_type' in changed && changed.analysis_type) {
form.setFieldValue('name', changed.analysis_type);
}
}}
initialValues={{
...DEFAULT_MODEL_CONFIG,
category: '',
analysis_type: undefined,
name: '',
description: '',
}}
width={720}
columns={1}
sections={formSections}
/>
{/* 查看 Prompt 详情 */}
<Drawer
title={viewing ? `${viewing.name} (v${viewing.version})` : 'Prompt 详情'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setViewing(null); }}
width={640}
styles={{ body: { background: isDark ? '#141414' : undefined } }}
>
{viewing && (
<>
<Descriptions column={2} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="分析类型">
<Tag color="blue">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.label ?? viewing.analysis_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="标识符">
<Typography.Text code style={{ fontSize: 12 }}>{viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={viewing.is_active ? 'success' : 'default'} text={viewing.is_active ? '启用' : '停用'} />
</Descriptions.Item>
<Descriptions.Item label="版本">v{viewing.version}</Descriptions.Item>
<Descriptions.Item label="调用链路" span={2}>
<Typography.Text type="secondary">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.api ?? viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatDateTime(viewing.updated_at)}</Descriptions.Item>
</Descriptions>
{viewing.description && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<div style={{ marginTop: 4 }}>{viewing.description}</div>
</div>
)}
{[
{ label: 'System Prompt', content: viewing.system_prompt },
{ label: 'User Prompt 模板', content: viewing.user_prompt_template },
{ label: '模型配置', content: JSON.stringify(viewing.model_config, null, 2) },
].map(({ label, content }) => (
<div key={label} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{label}</Typography.Text>
<pre style={{
marginTop: 4,
padding: 12,
background: isDark ? '#1e293b' : '#f8fafc',
borderRadius: 8,
fontSize: 13,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 300,
overflow: 'auto',
}}>
{content}
</pre>
</div>
))}
</>
)}
</Drawer>
</PageContainer>
);
}

View File

@@ -1,46 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
import AiUsageDashboard from './AiUsageDashboard';
describe('AiUsageDashboard', () => {
it('renders page title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('AI 用量统计')).toBeInTheDocument();
});
});
it('shows statistics cards', async () => {
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('总分析次数')).toBeInTheDocument();
});
expect(screen.getByText('分析类型数')).toBeInTheDocument();
expect(screen.getByText('本月分析')).toBeInTheDocument();
});
it('shows analysis type distribution section', async () => {
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('分析类型分布')).toBeInTheDocument();
});
});
it('renders type distribution labels', async () => {
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('化验单解读')).toBeInTheDocument();
});
expect(screen.getByText('趋势分析')).toBeInTheDocument();
expect(screen.getByText('体检方案')).toBeInTheDocument();
expect(screen.getByText('报告摘要')).toBeInTheDocument();
});
});

View File

@@ -1,356 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card, Spin, Statistic, message, Empty, Result, Row, Col, Tabs, Table, Switch, Tag,
} from 'antd';
import {
ThunderboltOutlined,
ExperimentOutlined,
DollarOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useThemeMode } from '../../hooks/useThemeMode';
import { usePermission } from '../../hooks/usePermission';
import {
usageApi,
type UsageOverview,
type TypeDistribution,
type DailyUsageRow,
type FeatureFlag,
} from '../../api/ai/usage';
const ANALYSIS_TYPE_MAP: Record<string, string> = {
lab_report_interpretation: '化验单解读',
health_trend_analysis: '趋势分析',
personalized_checkup_plan: '体检方案',
report_summary_generation: '报告摘要',
chat: 'AI 聊天',
};
const TYPE_COLORS: Record<string, string> = {
lab_report_interpretation: '#1890ff',
health_trend_analysis: '#52c41a',
personalized_checkup_plan: '#722ed1',
report_summary_generation: '#fa8c16',
chat: '#eb2f96',
};
const FEATURE_LABELS: Record<string, string> = {
'ai.analysis': 'AI 分析',
'ai.chat': 'AI 聊天',
'ai.trend': '趋势分析',
'ai.report': '报告摘要',
'ai.checkup': '体检方案',
'ai.copilot': 'Copilot 辅助',
'ai.alert.push': 'AI 预警推送',
'ai.rag': 'RAG 知识检索',
'ai.voice': '语音交互',
'ai.suggestion': 'AI 建议',
'ai.lab': '化验解读',
'ai.summary': '综合摘要',
};
export default function AiUsageDashboard() {
const { hasPermission } = usePermission('ai.usage.list');
const adminFlags = usePermission('ai.admin.flags');
const [overview, setOverview] = useState<UsageOverview | null>(null);
const [types, setTypes] = useState<TypeDistribution[]>([]);
const [dailyUsage, setDailyUsage] = useState<DailyUsageRow[]>([]);
const [flags, setFlags] = useState<FeatureFlag[]>([]);
const [loading, setLoading] = useState(true);
const isDark = useThemeMode();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [ov, tp] = await Promise.all([
usageApi.overview(),
usageApi.byType(),
]);
setOverview(ov);
setTypes(tp);
} catch {
message.error('加载用量统计失败');
} finally {
setLoading(false);
}
}, []);
const fetchDailyUsage = useCallback(async () => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
const fmt = (d: Date) => d.toISOString().slice(0, 10);
try {
const rows = await usageApi.getDailyUsage(fmt(start), fmt(end));
setDailyUsage(rows);
} catch {
message.error('加载日聚合用量失败');
}
}, []);
const fetchFlags = useCallback(async () => {
try {
const data = await usageApi.getFeatureFlags();
setFlags(data);
} catch {
message.error('加载功能开关失败');
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有查看 AI 用量的权限" />;
}
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
const cardStyle = {
borderRadius: 12,
background: isDark ? '#111827' : '#FFFFFF',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
const totalCount = types.reduce((sum, t) => sum + t.count, 0);
// 日聚合汇总
const totalCalls = dailyUsage.reduce((s, r) => s + r.total_calls, 0);
const totalTokens = dailyUsage.reduce(
(s, r) => s + r.total_input_tokens + r.total_output_tokens,
0,
);
const totalCost = dailyUsage.reduce((s, r) => s + r.total_cost_cents, 0);
const handleToggleFlag = async (feature: string, enabled: boolean) => {
try {
await usageApi.updateFeatureFlag(feature, enabled);
setFlags((prev) =>
prev.map((f) => (f.feature === feature ? { ...f, is_enabled: enabled } : f)),
);
message.success(`${FEATURE_LABELS[feature] || feature}${enabled ? '启用' : '禁用'}`);
} catch {
message.error('更新功能开关失败');
}
};
const tabItems = [
{
key: 'overview',
label: '用量概览',
icon: <ThunderboltOutlined />,
children: (
<>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card style={cardStyle}>
<Statistic
title="总分析次数"
value={overview?.total_count ?? 0}
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
styles={{ content: { fontWeight: 600 } }}
/>
</Card>
</Col>
<Col span={8}>
<Card style={cardStyle}>
<Statistic
title="分析类型数"
value={types.length}
prefix={<ExperimentOutlined style={{ color: '#52c41a' }} />}
styles={{ content: { fontWeight: 600 } }}
/>
</Card>
</Col>
<Col span={8}>
<Card style={cardStyle}>
<Statistic
title="本月分析"
value={totalCount}
prefix={<ThunderboltOutlined style={{ color: '#fa8c16' }} />}
styles={{ content: { fontWeight: 600 } }}
/>
</Card>
</Col>
</Row>
<Card style={cardStyle} title="分析类型分布">
{types.length === 0 ? (
<Empty description="暂无分析数据" />
) : (
<Row gutter={[16, 16]}>
{types.map((t) => {
const pct = totalCount > 0 ? Math.round((t.count / totalCount) * 100) : 0;
const label = ANALYSIS_TYPE_MAP[t.analysis_type] || t.analysis_type;
const color = TYPE_COLORS[t.analysis_type] || '#1890ff';
return (
<Col span={6} key={t.analysis_type}>
<div
style={{
padding: 16,
borderRadius: 8,
background: isDark ? '#0f172a' : '#f8fafc',
textAlign: 'center',
}}
>
<div style={{ fontSize: 28, fontWeight: 700, color }}>{t.count}</div>
<div style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#475569', marginTop: 4 }}>
{label}
</div>
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8', marginTop: 4 }}>
{pct}%
</div>
</div>
</Col>
);
})}
</Row>
)}
</Card>
</>
),
},
{
key: 'cost',
label: '成本分析',
icon: <DollarOutlined />,
children: (
<>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card style={cardStyle}>
<Statistic
title="30 天总调用"
value={totalCalls}
styles={{ content: { fontWeight: 600 } }}
/>
</Card>
</Col>
<Col span={8}>
<Card style={cardStyle}>
<Statistic
title="30 天总 Token"
value={totalTokens}
styles={{ content: { fontWeight: 600 } }}
/>
</Card>
</Col>
<Col span={8}>
<Card style={cardStyle}>
<Statistic
title="30 天成本"
value={(totalCost / 100).toFixed(2)}
prefix="¥"
styles={{ content: { fontWeight: 600 } }}
/>
</Card>
</Col>
</Row>
<Card style={cardStyle} title="日用量明细(近 30 天)">
<Table
dataSource={dailyUsage}
rowKey={(r) => `${r.date}-${r.feature}`}
size="small"
pagination={{ pageSize: 15 }}
columns={[
{ title: '日期', dataIndex: 'date', width: 120 },
{ title: '功能', dataIndex: 'feature', width: 150 },
{ title: '调用次数', dataIndex: 'total_calls', width: 100 },
{
title: '输入 Token',
dataIndex: 'total_input_tokens',
width: 120,
render: (v: number) => v.toLocaleString(),
},
{
title: '输出 Token',
dataIndex: 'total_output_tokens',
width: 120,
render: (v: number) => v.toLocaleString(),
},
{
title: '成本',
dataIndex: 'total_cost_cents',
width: 100,
render: (v: number) => `¥${(v / 100).toFixed(2)}`,
},
]}
/>
</Card>
</>
),
},
{
key: 'flags',
label: '功能开关',
icon: <SettingOutlined />,
children: (
<Card style={cardStyle} title="AI 功能开关">
{flags.length === 0 ? (
<Empty description="暂无功能开关配置" />
) : (
<Table
dataSource={flags}
rowKey="feature"
size="small"
pagination={false}
columns={[
{
title: '功能',
dataIndex: 'feature',
render: (f: string) => FEATURE_LABELS[f] || f,
},
{
title: '状态',
dataIndex: 'is_enabled',
width: 200,
render: (enabled: boolean, record: FeatureFlag) =>
adminFlags.hasPermission ? (
<Switch
checked={enabled}
onChange={(checked) => handleToggleFlag(record.feature, checked)}
checkedChildren="启用"
unCheckedChildren="禁用"
/>
) : (
<Tag color={enabled ? 'green' : 'default'}>
{enabled ? '启用' : '禁用'}
</Tag>
),
},
]}
/>
)}
</Card>
),
},
];
return (
<div>
<div className="erp-page-header">
<div>
<h4>AI </h4>
<div className="erp-page-subtitle">AI </div>
</div>
</div>
<Tabs
defaultActiveKey="overview"
items={tabItems}
onChange={(key) => {
if (key === 'cost') fetchDailyUsage();
if (key === 'flags') fetchFlags();
}}
/>
</div>
);
}

View File

@@ -1,63 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock SSE hook
vi.mock('../../hooks/useAlertSSE', () => ({
useAlertSSE: () => ({ connected: false, connectionState: 'disconnected', recentAlerts: [], reconnect: vi.fn() }),
}));
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
import AlertDashboard from './AlertDashboard';
describe('AlertDashboard', () => {
it('renders the dashboard title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('告警仪表盘')).toBeInTheDocument();
});
});
it('shows statistics cards', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('待处理')).toBeInTheDocument();
});
expect(screen.getByText('已确认')).toBeInTheDocument();
expect(screen.getByText('危急值')).toBeInTheDocument();
});
it('shows alert list card', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('告警列表')).toBeInTheDocument();
});
});
it('shows SSE connection status indicator', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('连接断开')).toBeInTheDocument();
});
});
it('shows status filter dropdown', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('告警仪表盘')).toBeInTheDocument();
});
// The status filter Select should be present
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThan(0);
});
});

View File

@@ -1,330 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import {
Row,
Col,
Card,
Statistic,
Tag,
List,
Select,
Badge,
Typography,
Spin,
Space,
Flex,
Result,
message,
} from "antd";
import {
AlertOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
WarningOutlined,
WifiOutlined,
} from "@ant-design/icons";
import { alertApi, type Alert } from "../../api/health/alerts";
import { usePermission } from "../../hooks/usePermission";
import {
SEVERITY_COLOR,
SEVERITY_LABEL,
ALERT_STATUS_COLOR,
ALERT_STATUS_LABEL,
ALERT_STATUS_OPTIONS,
translateAlertTitle,
} from "../../constants/health";
import { useAlertSSE, type AlertSSEEvent } from "../../hooks/useAlertSSE";
import { AlertDetailPanel } from "./components/AlertDetailPanel";
import { PageContainer } from "../../components/PageContainer";
import { EntityName } from "../../components/EntityName";
/**
* 实时告警仪表盘 — 医生端。
*
* 功能:
* - SSE 实时接收新告警推送
* - 按状态/严重程度筛选
* - 告警列表 + 详情面板
* - 统计摘要(待处理/已确认/危急值)
* - 确认/忽略/恢复操作
*/
export default function AlertDashboard() {
const { hasPermission } = usePermission("health.alerts.list");
const [alerts, setAlerts] = useState<Alert[]>([]);
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("");
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [total, setTotal] = useState(0);
// 加载告警列表
const fetchAlerts = useCallback(async (status?: string) => {
try {
setLoading(true);
const params: Record<string, string | number> = {
page: 1,
page_size: 50,
};
if (status) {
params.status = status;
}
const result = await alertApi.list(params);
setAlerts(result.data);
setTotal(result.total);
} catch {
// 静默降级
} finally {
setLoading(false);
}
}, []);
// SSE 实时推送
const handleNewAlert = useCallback((event: AlertSSEEvent) => {
// 将 SSE 事件转换为 Alert 对象并插入列表头部
const newAlert: Alert = {
id: event.alert_id,
patient_id: event.patient_id,
rule_id: "",
severity: event.severity,
title: event.rule_name ?? "新告警",
detail: event.detail,
status: "pending",
created_at: event.occurred_at ?? new Date().toISOString(),
version: 1,
};
setAlerts((prev) => [newAlert, ...prev]);
setTotal((prev) => prev + 1);
}, []);
const { connected } = useAlertSSE({
enabled: true,
onAlert: handleNewAlert,
});
// 初始加载
useEffect(() => {
fetchAlerts(statusFilter || undefined);
}, [fetchAlerts, statusFilter]);
// 操作回调
const handleAcknowledge = useCallback(async (id: string, version: number) => {
setActionLoading(true);
try {
const updated = await alertApi.acknowledge(id, version);
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
} catch {
message.error("确认告警失败,请重试");
} finally {
setActionLoading(false);
}
}, []);
const handleDismiss = useCallback(async (id: string, version: number) => {
setActionLoading(true);
try {
const updated = await alertApi.dismiss(id, version);
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
} catch {
message.error("忽略告警失败,请重试");
} finally {
setActionLoading(false);
}
}, []);
const handleResolve = useCallback(async (id: string, version: number) => {
setActionLoading(true);
try {
const updated = await alertApi.resolve(id, version);
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
} catch {
message.error("恢复告警失败,请重试");
} finally {
setActionLoading(false);
}
}, []);
// 统计
if (!hasPermission)
return (
<Result
status="403"
title="权限不足"
subTitle="您没有查看告警面板的权限"
/>
);
const pendingCount = alerts.filter((a) => a.status === "pending").length;
const acknowledgedCount = alerts.filter(
(a) => a.status === "acknowledged",
).length;
const criticalCount = alerts.filter(
(a) => a.severity === "critical" || a.severity === "urgent",
).length;
return (
<PageContainer
title="告警仪表盘"
subtitle={`${total} 条告警`}
filters={
<Space>
<Select
value={statusFilter}
onChange={setStatusFilter}
options={ALERT_STATUS_OPTIONS}
style={{ width: 120 }}
placeholder="按状态筛选"
/>
<Badge
status={connected ? "success" : "error"}
text={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<WifiOutlined style={{ marginRight: 4 }} />
{connected ? "实时连接" : "连接断开"}
</Typography.Text>
}
/>
</Space>
}
>
<div style={{ padding: 16 }}>
{/* 统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={8}>
<Card size="small">
<Statistic
title="待处理"
value={pendingCount}
prefix={<ExclamationCircleOutlined />}
styles={{ content: { color: pendingCount > 0 ? "#fa8c16" : undefined } }}
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small">
<Statistic
title="已确认"
value={acknowledgedCount}
prefix={<CheckCircleOutlined />}
styles={{ content: { color: "#1890ff" } }}
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small">
<Statistic
title="危急值"
value={criticalCount}
prefix={<WarningOutlined />}
styles={{
content: { color: criticalCount > 0 ? "#ff4d4f" : undefined },
}}
/>
</Card>
</Col>
</Row>
{/* 告警列表 + 详情 */}
<Row gutter={[16, 16]}>
<Col xs={24} md={14}>
<Card
title={
<Space>
<AlertOutlined />
<span></span>
<Badge count={pendingCount} />
</Space>
}
size="small"
style={{ maxHeight: 600, overflow: "auto" }}
>
<Spin spinning={loading}>
<List
size="small"
dataSource={alerts}
locale={{ emptyText: "暂无告警" }}
renderItem={(alert) => (
<List.Item
onClick={() => setSelectedAlert(alert)}
style={{
cursor: "pointer",
background:
selectedAlert?.id === alert.id
? "var(--ant-color-primary-bg)"
: undefined,
padding: "8px 12px",
borderRadius: 6,
transition: "background 0.2s",
}}
>
<List.Item.Meta
avatar={
<Tag
color={SEVERITY_COLOR[alert.severity] || "default"}
style={{
margin: 0,
minWidth: 48,
textAlign: "center",
}}
>
{SEVERITY_LABEL[alert.severity] ?? alert.severity}
</Tag>
}
title={
<Flex justify="space-between" align="center">
<span>{translateAlertTitle(alert.title)}</span>
<Tag
color={
ALERT_STATUS_COLOR[alert.status] || "default"
}
style={{ fontSize: 11 }}
>
{ALERT_STATUS_LABEL[alert.status] ?? alert.status}
</Tag>
</Flex>
}
description={
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
>
:{" "}
<EntityName
name={alert.patient_name}
id={alert.patient_id}
/>
{" · "}
{new Date(alert.created_at).toLocaleString("zh-CN")}
</Typography.Text>
}
/>
</List.Item>
)}
/>
</Spin>
</Card>
</Col>
<Col xs={24} md={10}>
<Card title="告警详情" size="small">
{selectedAlert ? (
<AlertDetailPanel
alert={selectedAlert}
onAcknowledge={handleAcknowledge}
onDismiss={handleDismiss}
onResolve={handleResolve}
loading={actionLoading}
/>
) : (
<div style={{ padding: 40, textAlign: "center" }}>
<Typography.Text type="secondary">
</Typography.Text>
</div>
)}
</Card>
</Col>
</Row>
</div>
</PageContainer>
);
}

View File

@@ -1,20 +0,0 @@
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createAlertFixture } from '../../test/fixtures';
import AlertList from './AlertList';
const mockAlerts = createFixtureList(createAlertFixture, 12, [
{ id: 'alert-1', severity: 'high', status: 'active', message: '血压异常偏高' },
{ id: 'alert-2', severity: 'medium', status: 'active', message: '心率偏高' },
]);
createListPageTests({
Component: AlertList,
apiPath: '/api/v1/health/alerts',
columns: ['严重程度', '状态'],
firstRowTexts: ['血压异常偏高'],
totalItems: 12,
hasCreateButton: false,
hasSearch: true,
hasPagination: false,
mockItems: mockAlerts as Record<string, unknown>[],
});

View File

@@ -1,310 +0,0 @@
import { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
Table,
Select,
Button,
Input,
Tag,
Space,
Popconfirm,
DatePicker,
message,
} from 'antd';
import { CheckOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import {
alertApi,
type Alert,
} from '../../api/health/alerts';
import { SEVERITY_COLOR, SEVERITY_LABEL, ALERT_STATUS_COLOR, ALERT_STATUS_LABEL, SEVERITY_OPTIONS, ALERT_STATUS_OPTIONS as STATUS_OPTS } from '../../constants/health';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { formatRelative, formatDateTime } from '../../utils/format';
// --- 常量映射 ---
// 状态选项保留给筛选下拉使用(使用常量)
// --- 筛选器结构 ---
interface AlertFilters {
search: string;
status: string;
severity: string;
dateRange: [string, string] | null;
}
const DEFAULT_FILTERS: AlertFilters = {
search: '',
status: '',
severity: '',
dateRange: null,
};
// --- 辅助函数 ---
/** 从 detail 中提取规则名称 */
function extractRuleName(
detail: Record<string, unknown> | undefined,
): string {
if (!detail) return '-';
const ruleName = detail.rule_name;
return typeof ruleName === 'string' && ruleName ? ruleName : '-';
}
export default function AlertList() {
const [actionLoading, setActionLoading] = useState<string | null>(null);
// ---- 分页数据 Hook ----
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<Alert, AlertFilters>(
async (p, pageSize, f) => {
const result = await alertApi.list({
page: p,
page_size: pageSize,
status: f.status || undefined,
});
return result;
},
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
);
// ---- 筛选回调 ----
const handleFilterChange = useCallback(
(key: keyof AlertFilters, value: string | [string, string] | null) => {
setFilters((prev) => ({ ...prev, [key]: value }));
refresh(1);
},
[setFilters, refresh],
);
const handleResetFilters = useCallback(() => {
setFilters({ ...DEFAULT_FILTERS });
refresh(1);
}, [setFilters, refresh]);
// ---- 分页 ----
const handleTableChange = (pagination: TablePaginationConfig) => {
refresh(pagination.current ?? 1);
};
// ---- 操作 ----
const handleAcknowledge = async (record: Alert) => {
setActionLoading(record.id);
try {
await alertApi.acknowledge(record.id, record.version);
message.success('告警已确认');
refresh();
} catch {
message.error('确认告警失败');
} finally {
setActionLoading(null);
}
};
const handleDismiss = async (record: Alert) => {
setActionLoading(record.id);
try {
await alertApi.dismiss(record.id, record.version);
message.success('告警已忽略');
refresh();
} catch {
message.error('忽略告警失败');
} finally {
setActionLoading(null);
}
};
// ---- 列定义 ----
const columns: ColumnsType<Alert> = [
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (_: unknown, record: Alert) => (
<Link to={`/health/patients/${record.patient_id}`}>
<EntityName
name={record.patient_name}
id={record.patient_id}
/>
</Link>
),
},
{
title: '规则名称',
key: 'rule_name',
width: 160,
render: (_: unknown, record: Alert) =>
extractRuleName(record.detail),
},
{
title: '告警标题',
dataIndex: 'title',
key: 'title',
width: 200,
ellipsis: true,
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (val: string) => (
<Tag color={SEVERITY_COLOR[val] || 'default'}>
{SEVERITY_LABEL[val] || val}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (val: string) => (
<Tag color={ALERT_STATUS_COLOR[val] || 'default'}>
{ALERT_STATUS_LABEL[val] || val}
</Tag>
),
},
{
title: '触发时间',
dataIndex: 'created_at',
key: 'created_at',
width: 140,
render: (val: string) => (
<span title={formatDateTime(val)} style={{ fontSize: 13 }}>
{formatRelative(val)}
</span>
),
},
{
title: '操作',
key: 'actions',
width: 160,
render: (_: unknown, record: Alert) => (
<AuthButton code="health.alerts.manage">
<Space size={4}>
{(record.status === 'pending' || record.status === 'active') && (
<Popconfirm
title="确认处理该告警?"
onConfirm={() => handleAcknowledge(record)}
okText="确认"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<CheckOutlined />}
loading={actionLoading === record.id}
>
</Button>
</Popconfirm>
)}
{(record.status === 'pending' ||
record.status === 'active' ||
record.status === 'acknowledged') && (
<Popconfirm
title="确认忽略该告警?"
onConfirm={() => handleDismiss(record)}
okText="确认"
cancelText="取消"
>
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
loading={actionLoading === record.id}
>
</Button>
</Popconfirm>
)}
</Space>
</AuthButton>
),
},
];
return (
<PageContainer
title="告警列表"
subtitle="查看和管理患者健康告警"
filters={
<>
<Input
placeholder="搜索告警..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
allowClear
style={{ width: 200 }}
/>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 140 }}
options={STATUS_OPTS}
value={filters.status || undefined}
onChange={(v) => handleFilterChange('status', v ?? '')}
/>
<Select
allowClear
placeholder="严重程度"
style={{ width: 120 }}
options={SEVERITY_OPTIONS}
value={filters.severity || undefined}
onChange={(v) => handleFilterChange('severity', v ?? '')}
/>
<DatePicker.RangePicker
placeholder={['开始日期', '结束日期']}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
handleFilterChange('dateRange', [
dates[0].format('YYYY-MM-DD'),
dates[1].format('YYYY-MM-DD'),
]);
} else {
handleFilterChange('dateRange', null);
}
}}
/>
</>
}
onResetFilters={handleResetFilters}
loading={loading}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 970 }}
/>
</PageContainer>
);
}

View File

@@ -1,40 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createAlertRuleFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import AlertRuleList from './AlertRuleList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockRules = createFixtureList(createAlertRuleFixture, 5, [
{ id: 'rule-1', name: '血压偏高告警' },
{ id: 'rule-2', name: '心率异常告警' },
]);
createListPageTests({
Component: AlertRuleList,
apiPath: '/api/v1/health/alert-rules',
columns: ['规则名称', '指标类型', '条件类型', '严重程度'],
firstRowTexts: ['血压偏高告警'],
totalItems: 5,
hasCreateButton: true,
createButtonText: '新建规则',
hasSearch: false,
hasPagination: false,
mockItems: mockRules as Record<string, unknown>[],
});
describe('AlertRuleList extra tests', () => {
it('renders severity tags in the table', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AlertRuleList />);
await waitFor(() => {
const tags = document.querySelectorAll('.ant-tag');
expect(tags.length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,223 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Form, Input, InputNumber, message, Modal, Result, Select, Space, Switch, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
alertRuleApi,
type AlertRule,
type CreateAlertRuleReq,
type UpdateAlertRuleReq,
} from '../../api/health/alerts';
import { SEVERITY_COLOR, SEVERITY_OPTIONS, DEVICE_TYPE_OPTIONS, CONDITION_TYPE_OPTIONS } from '../../constants/health';
import { usePermission } from '../../hooks/usePermission';
export default function AlertRuleList() {
const { hasPermission } = usePermission('health.alerts.list');
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看告警规则的权限" />;
const [data, setData] = useState<AlertRule[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
const [form] = Form.useForm();
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const res = await alertRuleApi.list({ page, page_size: 20 });
setData(res.data);
setTotal(res.total);
} catch {
message.error('加载规则列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchRules();
}, [fetchRules]);
const openCreateModal = () => {
setEditingRule(null);
form.resetFields();
form.setFieldsValue({
severity: 'warning',
cooldown_minutes: 60,
});
setModalOpen(true);
};
const openEditModal = (rule: AlertRule) => {
setEditingRule(rule);
form.setFieldsValue({
name: rule.name,
description: rule.description,
device_type: rule.device_type,
condition_type: rule.condition_type,
condition_params: JSON.stringify(rule.condition_params, null, 2),
severity: rule.severity,
cooldown_minutes: rule.cooldown_minutes,
});
setModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const conditionParams = JSON.parse(values.condition_params);
if (editingRule) {
const req: UpdateAlertRuleReq = {
name: values.name,
description: values.description,
condition_params: conditionParams,
severity: values.severity,
cooldown_minutes: values.cooldown_minutes,
version: editingRule.version,
};
await alertRuleApi.update(editingRule.id, req);
message.success('规则已更新');
} else {
const req: CreateAlertRuleReq = {
name: values.name,
description: values.description,
device_type: values.device_type,
condition_type: values.condition_type,
condition_params: conditionParams,
severity: values.severity,
cooldown_minutes: values.cooldown_minutes,
};
await alertRuleApi.create(req);
message.success('规则已创建');
}
setModalOpen(false);
fetchRules();
} catch (e) {
if (e instanceof SyntaxError) {
message.error('条件参数 JSON 格式无效');
}
}
};
const handleToggle = async (rule: AlertRule, active: boolean) => {
try {
if (!active) {
await alertRuleApi.deactivate(rule.id, rule.version);
message.success('规则已禁用');
}
fetchRules();
} catch {
message.error('操作失败');
}
};
const columns: ColumnsType<AlertRule> = [
{ title: '规则名称', dataIndex: 'name', width: 180 },
{
title: '指标类型',
dataIndex: 'device_type',
width: 100,
render: (v: string) => DEVICE_TYPE_OPTIONS.find((d) => d.value === v)?.label || v,
},
{
title: '条件类型',
dataIndex: 'condition_type',
width: 120,
render: (v: string) => CONDITION_TYPE_OPTIONS.find((c) => c.value === v)?.label || v,
},
{
title: '严重程度',
dataIndex: 'severity',
width: 90,
render: (v: string) => <Tag color={SEVERITY_COLOR[v] || 'default'}>{v}</Tag>,
},
{
title: '启用',
dataIndex: 'is_active',
width: 80,
render: (v: boolean, record) => (
<Switch checked={v} onChange={(checked) => handleToggle(record, checked)} />
),
},
{
title: '冷却(分)',
dataIndex: 'cooldown_minutes',
width: 90,
},
{
title: '操作',
width: 80,
render: (_, record) => (
<Button size="small" type="link" onClick={() => openEditModal(record)}>
</Button>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0 }}></h2>
<Button type="primary" onClick={openCreateModal}></Button>
</div>
<Table<AlertRule>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
<Modal
title={editingRule ? '编辑规则' : '新建规则'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item name="device_type" label="指标类型" rules={[{ required: true }]}>
<Select style={{ width: 160 }} options={DEVICE_TYPE_OPTIONS} disabled={!!editingRule} />
</Form.Item>
<Form.Item name="condition_type" label="条件类型" rules={[{ required: true }]}>
<Select style={{ width: 160 }} options={CONDITION_TYPE_OPTIONS} disabled={!!editingRule} />
</Form.Item>
</Space>
<Form.Item
name="condition_params"
label="条件参数 (JSON)"
rules={[{ required: true }]}
extra='例如: {"direction":"above","value":100}'
>
<Input.TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item name="severity" label="严重程度">
<Select style={{ width: 140 }} options={SEVERITY_OPTIONS} />
</Form.Item>
<Form.Item name="cooldown_minutes" label="冷却时间(分钟)">
<InputNumber min={1} max={1440} style={{ width: 140 }} />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More