test(web): 核心健康管理页面测试 — 12 个页面 51 个测试用例
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

新增测试覆盖:
- PatientDetail: 5 测试(渲染/标签页/数据展示)
- AlertDashboard: 5 测试(渲染/统计卡片/告警列表)
- AlertRuleList: 5 测试(渲染/规则表格/创建按钮)
- DeviceManage: 5 测试(渲染/设备列表/筛选)
- AiAnalysisList: 6 测试(渲染/分析记录/分页)
- AiUsageDashboard: 4 测试(渲染/统计/类型分布)
- ArticleManageList: 5 测试(渲染/文章表格/分类筛选)
- PointsProductList: 5 测试(渲染/商品表格/上下架)
- PointsRuleList: 4 测试(渲染/规则表格)
- PointsOrderList: 5 测试(渲染/订单表格/状态筛选)
- StatisticsDashboard: 2 测试(渲染/权限守卫)
- DoctorSchedule: 3 测试(渲染/排班日历/科室筛选)

测试基础设施:
- 8 个新 fixture 工厂(device/analysis/points/article/alert/schedule)
- 10 组新 MSW handlers
- 5 个新权限码(devices/dashboard/oauth/ai.usage)

前端测试:527/530 通过(3 个预存失败未受影响)
This commit is contained in:
iven
2026-05-04 18:02:55 +08:00
parent f54fb336dc
commit c35ea83799
16 changed files with 1063 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, 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

@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createArticleFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import ArticleManageList from './ArticleManageList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockArticles = createFixtureList(createArticleFixture, 10, [
{ id: 'article-1', title: '健康饮食指南', status: 'published', category_name: '营养健康' },
{ id: 'article-2', title: '运动与健康', status: 'draft', category_name: '运动健身' },
]);
createListPageTests({
Component: ArticleManageList,
apiPath: '/api/v1/health/articles',
columns: ['标题', '分类', '状态'],
firstRowTexts: ['健康饮食指南'],
totalItems: 10,
hasCreateButton: true,
createButtonText: '新建文章',
hasSearch: true,
hasPagination: true,
mockItems: mockArticles as Record<string, unknown>[],
});
describe('ArticleManageList extra tests', () => {
it('renders status tab options after data loads', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<ArticleManageList />);
// Wait for the table to render (data loaded)
await waitFor(() => {
const table = document.querySelector('.ant-table');
expect(table).toBeInTheDocument();
});
// Check tab items exist — Ant Design Tabs render tab labels
const tabItems = document.querySelectorAll('[role="tab"]');
const tabTexts = Array.from(tabItems).map((t) => t.textContent?.trim());
// At least some tab text should be present
expect(tabTexts.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createDeviceFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import DeviceManage from './DeviceManage';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockDevices = createFixtureList(createDeviceFixture, 10, [
{ id: 'device-1', device_model: 'BP Monitor Pro', device_type: 'blood_pressure', status: 'online' },
{ id: 'device-2', device_model: 'GlucoSense Lite', device_type: 'blood_glucose', status: 'offline' },
]);
createListPageTests({
Component: DeviceManage,
apiPath: '/api/v1/health/devices',
columns: ['设备 ID', '设备型号', '设备类型', '状态'],
firstRowTexts: ['BP Monitor Pro'],
totalItems: 10,
hasCreateButton: false,
hasSearch: true,
hasPagination: true,
mockItems: mockDevices as Record<string, unknown>[],
});
describe('DeviceManage extra tests', () => {
it('shows filter controls for device type and status', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<DeviceManage />);
await waitFor(() => {
expect(screen.getByText('设备管理')).toBeInTheDocument();
});
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,43 @@
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,
}));
// Mock CalendarView since it uses complex dayjs/calendar logic
vi.mock('./components/CalendarView', () => ({
CalendarView: () => <div data-testid="calendar-view"></div>,
}));
import DoctorSchedule from './DoctorSchedule';
describe('DoctorSchedule', () => {
it('renders the schedule page with doctor select', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<DoctorSchedule />);
await waitFor(() => {
expect(screen.getByText('选择医护:')).toBeInTheDocument();
});
});
it('shows empty state when no doctor selected', async () => {
renderWithProviders(<DoctorSchedule />);
await waitFor(() => {
expect(screen.getByText('请先选择医护以查看排班')).toBeInTheDocument();
});
});
it('has a card container', async () => {
renderWithProviders(<DoctorSchedule />);
await waitFor(() => {
const card = document.querySelector('.ant-card');
expect(card).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import PatientDetail from './PatientDetail';
// Mock useParams to return patient ID
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useParams: () => ({ id: 'patient-1' }),
};
});
// Mock SSE hook
vi.mock('../../hooks/useAlertSSE', () => ({
useAlertSSE: () => ({ connected: false, connectionState: 'disconnected', recentAlerts: [], reconnect: vi.fn() }),
}));
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock AI SSE analysis
vi.mock('../../api/ai/analysisSse', () => ({
startAnalysis: vi.fn(),
}));
const mockPatient = {
id: 'patient-1',
name: '张三',
gender: 'male',
birth_date: '1990-01-15',
blood_type: 'A',
id_number: '110101199001150001',
status: 'active',
verification_status: 'verified',
source: 'manual',
allergy_history: '青霉素过敏',
medical_history_summary: '高血压',
emergency_contact_name: '李四',
emergency_contact_phone: '13800000001',
notes: '定期复查',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
};
function setupPatientDetail(patientId = 'patient-1') {
server.use(
http.get(`/api/v1/health/patients/${patientId}`, async () => {
return HttpResponse.json({ success: true, data: mockPatient });
}),
);
}
describe('PatientDetail', () => {
it('renders patient detail with basic info', async () => {
vi.setConfig({ testTimeout: 15000 });
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('返回列表')).toBeInTheDocument();
});
// 张三 appears multiple times (header + description)
const nameElements = screen.getAllByText('张三');
expect(nameElements.length).toBeGreaterThanOrEqual(1);
});
it('displays patient description fields', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('青霉素过敏')).toBeInTheDocument();
});
expect(screen.getByText('高血压')).toBeInTheDocument();
expect(screen.getByText('李四')).toBeInTheDocument();
expect(screen.getByText('13800000001')).toBeInTheDocument();
});
it('shows quick navigation links', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('预约记录')).toBeInTheDocument();
});
expect(screen.getByText('咨询记录')).toBeInTheDocument();
expect(screen.getByText('透析记录')).toBeInTheDocument();
expect(screen.getByText('随访任务')).toBeInTheDocument();
});
it('shows edit button for authorized users', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('编辑信息')).toBeInTheDocument();
});
});
it('shows tab labels', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('基本信息')).toBeInTheDocument();
});
expect(screen.getByText('家属管理')).toBeInTheDocument();
expect(screen.getByText('健康数据')).toBeInTheDocument();
expect(screen.getByText('随访记录')).toBeInTheDocument();
expect(screen.getByText('积分账户')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createPointsOrderFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import PointsOrderList from './PointsOrderList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock Zustand store
vi.mock('../../stores/health', () => ({
useHealthStore: () => ({
batchResolvePatientNames: vi.fn(),
getPatientName: (id: string) => `患者-${id}`,
}),
}));
const mockOrders = createFixtureList(createPointsOrderFixture, 8, [
{ id: 'order-00000001-abcd', product_name: '体检套餐兑换券', status: 'pending', points_cost: 100 },
{ id: 'order-00000002-abcd', product_name: '健康手环', status: 'verified', points_cost: 200 },
]);
createListPageTests({
Component: PointsOrderList,
apiPath: '/api/v1/health/points/orders',
columns: ['订单号', '商品', '积分', '状态'],
firstRowTexts: ['体检套餐兑换券'],
totalItems: 8,
hasCreateButton: true,
createButtonText: '核销订单',
hasSearch: true,
hasPagination: true,
mockItems: mockOrders as Record<string, unknown>[],
});
describe('PointsOrderList extra tests', () => {
it('renders the page title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<PointsOrderList />);
await waitFor(() => {
expect(screen.getByText('积分订单')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createPointsProductFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import PointsProductList from './PointsProductList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockProducts = createFixtureList(createPointsProductFixture, 5, [
{ id: 'product-1', name: '体检套餐兑换券', product_type: 'service', points_cost: 100 },
{ id: 'product-2', name: '健康手环', product_type: 'physical', points_cost: 200 },
]);
createListPageTests({
Component: PointsProductList,
apiPath: '/api/v1/health/points/products',
columns: ['商品名称', '类型', '积分', '库存', '状态'],
firstRowTexts: ['体检套餐兑换券'],
totalItems: 5,
hasCreateButton: true,
createButtonText: '新建商品',
hasSearch: true,
hasPagination: true,
mockItems: mockProducts as Record<string, unknown>[],
});
describe('PointsProductList extra tests', () => {
it('renders the page title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<PointsProductList />);
await waitFor(() => {
expect(screen.getByText('积分商品')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,49 @@
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 PointsRuleList from './PointsRuleList';
describe('PointsRuleList', () => {
it('renders page title and table', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
expect(screen.getByText('积分规则')).toBeInTheDocument();
});
});
it('shows create button', async () => {
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /新建规则/ })).toBeInTheDocument();
});
});
it('renders table with column headers', async () => {
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
const table = document.querySelector('.ant-table');
expect(table).toBeInTheDocument();
});
const headers = document.querySelectorAll('th');
const headerTexts = Array.from(headers).map((h) => h.textContent?.trim());
expect(headerTexts.some((t) => t?.includes('规则名称'))).toBe(true);
expect(headerTexts.some((t) => t?.includes('事件类型'))).toBe(true);
expect(headerTexts.some((t) => t?.includes('积分值'))).toBe(true);
});
it('shows filter controls', async () => {
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,46 @@
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,
}));
// Mock the sub-dashboard components to simplify testing
vi.mock('./StatisticsDashboard/DoctorDashboard', () => ({
DoctorDashboard: () => <div data-testid="doctor-dashboard"></div>,
}));
vi.mock('./StatisticsDashboard/NurseDashboard', () => ({
NurseDashboard: () => <div data-testid="nurse-dashboard"></div>,
}));
vi.mock('./StatisticsDashboard/AdminDashboard', () => ({
AdminDashboard: () => <div data-testid="admin-dashboard"></div>,
}));
vi.mock('./StatisticsDashboard/OperatorDashboard', () => ({
OperatorDashboard: () => <div data-testid="operator-dashboard"></div>,
}));
import StatisticsDashboard from './StatisticsDashboard';
describe('StatisticsDashboard', () => {
it('renders a dashboard component based on role', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<StatisticsDashboard />);
await waitFor(() => {
// Default role is 'admin' when no user roles are set
const adminDashboard = screen.queryByTestId('admin-dashboard');
const doctorDashboard = screen.queryByTestId('doctor-dashboard');
// One of these should render (depends on default role)
expect(adminDashboard || doctorDashboard).toBeTruthy();
});
});
it('does not render 403 when user has permission', async () => {
renderWithProviders(<StatisticsDashboard />);
await waitFor(() => {
expect(screen.queryByText('权限不足')).not.toBeInTheDocument();
});
});
});

View File

@@ -73,6 +73,164 @@ export function createAppointmentFixture(overrides: Record<string, unknown> = {}
};
}
// --- 设备 ---
export function createDeviceFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'device-1',
patient_id: 'patient-1',
device_id: 'DEV-20260401-0001',
device_model: 'BP Monitor Pro',
device_type: 'blood_pressure',
status: 'online',
firmware_version: '1.2.3',
manufacturer: 'HealthTech',
connection_type: 'ble',
bound_at: '2026-04-01T10:00:00Z',
last_sync_at: '2026-04-02T08:30:00Z',
version: 1,
...overrides,
};
}
// --- AI 分析 ---
export function createAnalysisFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'analysis-1',
patient_id: 'patient-1',
patient_name: '张三',
analysis_type: 'lab_report_interpretation',
source_ref: 'lab-report-1',
model_used: 'gpt-4',
status: 'completed',
result_content: '## 分析结果\n- 血压正常范围\n- 血糖略高',
result_metadata: null,
error_message: null,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
...overrides,
};
}
// --- 积分商品 ---
export function createPointsProductFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'product-1',
name: '体检套餐兑换券',
product_type: 'service',
points_cost: 100,
stock: 50,
is_active: true,
description: '可兑换基础体检套餐',
image_url: null,
sort_order: 0,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
...overrides,
};
}
// --- 积分规则 ---
export function createPointsRuleFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'rule-1',
event_type: 'checkin',
name: '每日打卡',
description: '每日健康打卡获得积分',
points_value: 10,
daily_cap: 1,
streak_7d_bonus: 20,
streak_14d_bonus: 50,
streak_30d_bonus: 100,
is_active: true,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
...overrides,
};
}
// --- 积分订单 ---
export function createPointsOrderFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'order-00112233-4455',
patient_id: 'patient-1',
product_id: 'product-1',
product_name: '体检套餐兑换券',
points_cost: 100,
status: 'pending',
qr_code: 'QR-ORDER-001',
verified_at: null,
verified_by: null,
expires_at: '2026-06-01T00:00:00Z',
notes: null,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
...overrides,
};
}
// --- 文章 ---
export function createArticleFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'article-1',
title: '健康饮食指南',
summary: '如何通过饮食改善健康状况',
content: '文章内容...',
cover_image: null,
category_id: 'cat-1',
category_name: '营养健康',
tags: [{ id: 'tag-1', name: '饮食' }],
status: 'published',
author: '李医生',
view_count: 128,
published_at: '2026-04-01T10:00:00Z',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
...overrides,
};
}
// --- 告警规则 ---
export function createAlertRuleFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'rule-1',
name: '血压偏高告警',
description: '收缩压超过 140 时触发告警',
device_type: 'blood_pressure',
condition_type: 'threshold',
condition_params: { direction: 'above', value: 140 },
severity: 'high',
cooldown_minutes: 60,
is_active: true,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
...overrides,
};
}
// --- 排班 ---
export function createScheduleFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'schedule-1',
doctor_id: 'doctor-1',
schedule_date: '2026-05-10',
period_type: 'am',
start_time: '08:00',
end_time: '12:00',
max_appointments: 10,
current_appointments: 3,
status: 'active',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
...overrides,
};
}
// --- 批量生成 ---
export function createFixtureList<T>(
factory: (overrides?: Record<string, unknown>) => T,

View File

@@ -4,6 +4,15 @@ import {
alertHandlers,
appointmentHandlers,
doctorHandlers,
deviceHandlers,
analysisHandlers,
usageHandlers,
pointsProductHandlers,
pointsRuleHandlers,
pointsOrderHandlers,
articleHandlers,
alertRuleHandlers,
scheduleHandlers,
} from './healthHandlers';
const TOKEN_EXPIRES = 3600;
@@ -39,4 +48,13 @@ export const handlers = [
...alertHandlers,
...appointmentHandlers,
...doctorHandlers,
...deviceHandlers,
...analysisHandlers,
...usageHandlers,
...pointsProductHandlers,
...pointsRuleHandlers,
...pointsOrderHandlers,
...articleHandlers,
...alertRuleHandlers,
...scheduleHandlers,
];

View File

@@ -126,3 +126,251 @@ export const doctorHandlers = [
return paginatedResponse(mockDoctors.slice(0, pageSize), mockDoctors.length, page, pageSize);
}),
];
// --- 设备列表 ---
const mockDevices = Array.from({ length: 10 }, (_, i) => ({
id: `device-${i + 1}`,
patient_id: `patient-${(i % 5) + 1}`,
device_id: `DEV-20260401-${String(i + 1).padStart(4, '0')}`,
device_model: i % 2 === 0 ? 'BP Monitor Pro' : 'GlucoSense Lite',
device_type: i % 2 === 0 ? 'blood_pressure' : 'blood_glucose',
status: i < 7 ? 'online' : 'offline',
firmware_version: '1.2.3',
manufacturer: 'HealthTech',
connection_type: i % 3 === 0 ? 'ble' : 'gateway',
bound_at: '2026-04-01T10:00:00Z',
last_sync_at: '2026-04-02T08:30:00Z',
version: 1,
}));
export const deviceHandlers = [
http.get('/api/v1/health/devices', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockDevices.slice(0, pageSize), mockDevices.length, page, pageSize);
}),
];
// --- AI 分析历史 ---
const mockAnalyses = Array.from({ length: 6 }, (_, i) => ({
id: `analysis-${i + 1}`,
patient_id: `patient-${(i % 3) + 1}`,
patient_name: `测试患者${(i % 3) + 1}`,
analysis_type: (['lab_report_interpretation', 'health_trend_analysis', 'personalized_checkup_plan', 'report_summary_generation'] as const)[i % 4],
source_ref: `ref-${i + 1}`,
model_used: 'gpt-4',
status: (['completed', 'completed', 'streaming', 'failed', 'pending', 'completed'] as const)[i],
result_content: i % 2 === 0 ? `## 分析结果\n- 指标 ${i + 1} 正常\n- 建议定期复查` : null,
result_metadata: null,
error_message: i === 3 ? '分析超时' : null,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
}));
export const analysisHandlers = [
http.get('/api/v1/ai/analysis/history', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockAnalyses.slice(0, pageSize), mockAnalyses.length, page, pageSize);
}),
http.get('/api/v1/ai/analysis/:id', async ({ params }) => {
await delay(50);
const analysis = mockAnalyses.find((a) => a.id === params.id);
if (!analysis) return HttpResponse.json({ success: false, error: 'Not found' }, { status: 404 });
return HttpResponse.json({ success: true, data: analysis });
}),
];
// --- AI 用量统计 ---
export const usageHandlers = [
http.get('/api/v1/ai/usage/overview', async () => {
await delay(50);
return HttpResponse.json({ success: true, data: { total_count: 128 } });
}),
http.get('/api/v1/ai/usage/by-type', async () => {
await delay(50);
return HttpResponse.json({
success: true,
data: [
{ analysis_type: 'lab_report_interpretation', count: 45 },
{ analysis_type: 'health_trend_analysis', count: 38 },
{ analysis_type: 'personalized_checkup_plan', count: 25 },
{ analysis_type: 'report_summary_generation', count: 20 },
],
});
}),
];
// --- 积分商品 ---
const mockProducts = Array.from({ length: 5 }, (_, i) => ({
id: `product-${i + 1}`,
name: ['体检套餐兑换券', '健康手环', 'VIP 问诊权益', '营养咨询券', '运动课程'][i],
product_type: (['service', 'physical', 'privilege', 'service', 'service'] as const)[i],
points_cost: [100, 200, 150, 80, 120][i],
stock: [50, 20, -1, 100, 30][i],
is_active: i < 4,
description: `商品描述 ${i + 1}`,
image_url: null,
sort_order: i,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
}));
export const pointsProductHandlers = [
http.get('/api/v1/health/points/products', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockProducts.slice(0, pageSize), mockProducts.length, page, pageSize);
}),
];
// --- 积分规则 ---
const mockRules = Array.from({ length: 4 }, (_, i) => ({
id: `rule-${i + 1}`,
event_type: (['checkin', 'data_report', 'lab_upload', 'event_checkin'] as const)[i],
name: ['每日打卡', '数据上报', '化验上传', '活动签到'][i],
description: `规则描述 ${i + 1}`,
points_value: [10, 5, 20, 15][i],
daily_cap: [1, 3, 1, 1][i],
streak_7d_bonus: [20, 0, 0, 10][i],
streak_14d_bonus: [50, 0, 0, 25][i],
streak_30d_bonus: [100, 0, 0, 50][i],
is_active: i < 3,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
}));
export const pointsRuleHandlers = [
http.get('/api/v1/health/points/rules', async () => {
await delay(50);
return HttpResponse.json({ success: true, data: mockRules });
}),
];
// --- 积分订单 ---
const mockOrders = Array.from({ length: 8 }, (_, i) => ({
id: `order-${String(i + 1).padStart(12, '0')}-abcd`,
patient_id: `patient-${(i % 3) + 1}`,
product_id: `product-${(i % 3) + 1}`,
product_name: ['体检套餐兑换券', '健康手环', 'VIP 问诊权益'][i % 3],
points_cost: [100, 200, 150][i % 3],
status: (['pending', 'verified', 'cancelled', 'expired'] as const)[i % 4],
qr_code: `QR-${i + 1}`,
verified_at: i % 4 === 1 ? '2026-04-02T10:00:00Z' : null,
verified_by: i % 4 === 1 ? 'admin-1' : null,
expires_at: '2026-06-01T00:00:00Z',
notes: null,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
}));
export const pointsOrderHandlers = [
http.get('/api/v1/health/points/orders', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockOrders.slice(0, pageSize), mockOrders.length, page, pageSize);
}),
];
// --- 文章管理 ---
const mockArticles = Array.from({ length: 10 }, (_, i) => ({
id: `article-${i + 1}`,
title: ['健康饮食指南', '运动与健康', '睡眠质量提升', '血压管理手册', '糖尿病预防', '心脏健康', '心理健康', '老年人保健', '儿童营养', '体检常识'][i],
summary: `文章摘要 ${i + 1}`,
content: `<p>文章内容 ${i + 1}</p>`,
cover_image: null,
category_id: `cat-${(i % 3) + 1}`,
category_name: ['营养健康', '运动健身', '疾病预防'][i % 3],
tags: [{ id: `tag-${(i % 4) + 1}`, name: ['饮食', '运动', '血压', '睡眠'][i % 4] }],
status: (['draft', 'pending_review', 'published', 'rejected'] as const)[i % 4],
author: `作者${(i % 2) + 1}`,
view_count: [128, 256, 64, 32, 512, 96, 192, 48, 384, 16][i],
published_at: i % 4 === 2 ? '2026-04-01T10:00:00Z' : null,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
}));
export const articleHandlers = [
http.get('/api/v1/health/articles', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockArticles.slice(0, pageSize), mockArticles.length, page, pageSize);
}),
http.get('/api/v1/health/article-categories', async () => {
await delay(50);
return HttpResponse.json({
success: true,
data: [
{ id: 'cat-1', name: '营养健康' },
{ id: 'cat-2', name: '运动健身' },
{ id: 'cat-3', name: '疾病预防' },
],
});
}),
];
// --- 告警规则 ---
const mockAlertRules = Array.from({ length: 5 }, (_, i) => ({
id: `alert-rule-${i + 1}`,
name: ['血压偏高告警', '心率异常告警', '血糖偏低告警', '体温异常告警', '血氧偏低告警'][i],
description: `告警规则描述 ${i + 1}`,
device_type: (['blood_pressure', 'heart_rate', 'blood_glucose', 'temperature', 'spo2'] as const)[i],
condition_type: 'threshold',
condition_params: { direction: 'above', value: [140, 100, 3.9, 38.5, 90][i] },
severity: (['low', 'medium', 'high', 'critical', 'medium'] as const)[i],
cooldown_minutes: 60,
is_active: i < 4,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
}));
export const alertRuleHandlers = [
http.get('/api/v1/health/alert-rules', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockAlertRules.slice(0, pageSize), mockAlertRules.length, page, pageSize);
}),
];
// --- 排班 ---
const mockSchedules = Array.from({ length: 8 }, (_, i) => ({
id: `schedule-${i + 1}`,
doctor_id: 'doctor-1',
schedule_date: `2026-05-${String(10 + i).padStart(2, '0')}`,
period_type: i % 2 === 0 ? 'am' : 'pm',
start_time: i % 2 === 0 ? '08:00' : '14:00',
end_time: i % 2 === 0 ? '12:00' : '17:00',
max_appointments: 10,
current_appointments: i % 3,
status: 'active',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
}));
export const scheduleHandlers = [
http.get('/api/v1/health/schedules', async ({ request }) => {
await delay(50);
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
return paginatedResponse(mockSchedules.slice(0, pageSize), mockSchedules.length, page, pageSize);
}),
];

View File

@@ -40,8 +40,13 @@ const ALL_HEALTH_PERMISSIONS = [
'health.points.list',
'health.health-data.manage',
'health.health-data.list',
'health.devices.manage',
'health.devices.list',
'health.dashboard.manage',
'health.oauth.manage',
'ai.analysis.manage',
'ai.analysis.list',
'ai.usage.list',
'ai.prompt.manage',
'ai.prompt.list',
];