fix: Phase 1.3 完善修复 — 管理端对接 + HMS清理 + 编辑器加载
- feat(web): ClassList.tsx 对接 update/deactivate/reset-code API - 编辑班级: PUT /diary/classes/:id - 停用班级: PATCH /diary/classes/:id/deactivate (Popconfirm 确认) - 重置班级码: POST /diary/classes/:id/reset-code (Popconfirm 确认) - 数据源改用 listAll() 获取所有班级 - fix(web): JournalList.tsx 班级筛选改用 classApi.listAll() - fix(app): EditorPage 加载已有日记数据 (journalId 非空时) - 从 Isar 恢复笔画/元素/标签/心情/标题 - _EditorView 改为 StatefulWidget + initState 加载 - chore(web): HMS 遗留代码清理 - 删除 api/copilot.ts, healthFixtures.ts, healthHandlers.ts - AuditLogViewer 资源类型替换为日记模块类型 - auth.test.ts / renderWithProviders 权限码 health.* → diary.* - docs: 确认 M6 NotificationService 为误报 (已在 3 处调用)
This commit is contained in:
@@ -254,7 +254,7 @@ class EditorPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorView extends StatelessWidget {
|
||||
class _EditorView extends StatefulWidget {
|
||||
final String? journalId;
|
||||
final String? templateId;
|
||||
final String? savedJournalId;
|
||||
@@ -269,6 +269,67 @@ class _EditorView extends StatelessWidget {
|
||||
required this.onSaveComplete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EditorView> createState() => _EditorViewState();
|
||||
}
|
||||
|
||||
class _EditorViewState extends State<_EditorView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 当 journalId 非空时,从 Isar 加载已有日记数据
|
||||
if (widget.journalId != null) {
|
||||
_loadExistingJournal(widget.journalId!);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Isar 加载已有日记的笔画、元素、标签、心情、标题
|
||||
Future<void> _loadExistingJournal(String id) async {
|
||||
try {
|
||||
// 加载日记元数据
|
||||
final entry = await widget.repo.getJournal(id);
|
||||
if (entry == null || !mounted) return;
|
||||
|
||||
final bloc = context.read<EditorBloc>();
|
||||
|
||||
// 加载标题和心情
|
||||
bloc.add(TitleChanged(entry.title));
|
||||
bloc.add(MoodChanged(entry.mood));
|
||||
|
||||
// 加载标签
|
||||
if (entry.tags.isNotEmpty) {
|
||||
bloc.add(TagsLoaded(entry.tags));
|
||||
}
|
||||
|
||||
// 加载元素(含笔画)
|
||||
final elements = await widget.repo.getElements(id);
|
||||
if (!mounted) return;
|
||||
|
||||
for (final element in elements) {
|
||||
if (element.elementType == ElementType.handwritingRef) {
|
||||
// 从 handwriting_ref 元素中恢复笔画
|
||||
final strokesData = element.content['strokes'];
|
||||
if (strokesData is List) {
|
||||
final strokes = strokesData
|
||||
.map((s) => Stroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
bloc.add(StrokesLoaded(strokes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载非笔画元素(贴纸/文字/图片)
|
||||
final nonStrokeElements = elements
|
||||
.where((e) => e.elementType != ElementType.handwritingRef)
|
||||
.toList();
|
||||
if (nonStrokeElements.isNotEmpty) {
|
||||
bloc.add(ElementsLoaded(nonStrokeElements));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载日记数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -288,7 +349,7 @@ class _EditorView extends StatelessWidget {
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return _EditorStack(state: state, journalId: journalId);
|
||||
return _EditorStack(state: state, journalId: widget.journalId);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -404,7 +465,7 @@ class _EditorView extends StatelessWidget {
|
||||
|
||||
/// 保存处理
|
||||
void _handleSave(BuildContext context, EditorState state) {
|
||||
onSaveComplete();
|
||||
widget.onSaveComplete();
|
||||
}
|
||||
|
||||
/// 格式化日期显示
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type InsightType = 'risk_score' | 'anomaly' | 'follow_up_hint' | 'consult_hint';
|
||||
export type InsightSource = 'rule' | 'llm' | 'hybrid';
|
||||
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface MatchedRule {
|
||||
rule_id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
severity: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface RiskScore {
|
||||
score: number;
|
||||
level: RiskLevel;
|
||||
matched_rules: MatchedRule[];
|
||||
}
|
||||
|
||||
export interface CopilotInsight {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
insight_type: InsightType;
|
||||
source: InsightSource;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
title: string;
|
||||
content: Record<string, unknown>;
|
||||
rule_matches?: MatchedRule[];
|
||||
llm_supplement?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
export async function getPatientRisk(patientId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: RiskScore }>(`/copilot/patients/${patientId}/risk`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listInsights(params: {
|
||||
patient_id?: string;
|
||||
insight_type?: string;
|
||||
severity?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<CopilotInsight> }>('/copilot/insights', { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export function dismissInsight(id: string) {
|
||||
return client.post(`/copilot/insights/${id}/dismiss`);
|
||||
}
|
||||
|
||||
export function listAlerts(params?: { severity?: string; page?: number; page_size?: number }) {
|
||||
return listInsights({ insight_type: 'anomaly', ...params });
|
||||
}
|
||||
|
||||
export async function listRules(params?: { page?: number; page_size?: number }) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<Record<string, unknown>> }>('/copilot/rules', { params });
|
||||
return data.data;
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import client from '../client';
|
||||
import type { SchoolClass, CreateClassReq, ClassMember, PaginatedResponse } from './types';
|
||||
import type { SchoolClass, CreateClassReq, UpdateClassReq, ClassMember, PaginatedResponse } from './types';
|
||||
|
||||
export const classApi = {
|
||||
/** 班级列表 — 后端返回纯数组,前端转换为 PaginatedResponse 格式 */
|
||||
/** 班级列表 — 当前用户加入的班级 (GET /diary/classes → my_classes) */
|
||||
list: (params?: { page?: number; page_size?: number }) =>
|
||||
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes', { params })
|
||||
.then((r) => {
|
||||
const raw = r.data.data;
|
||||
// 后端返回纯数组,包装为分页格式
|
||||
if (Array.isArray(raw)) {
|
||||
return { data: raw, total: raw.length, page: params?.page ?? 1, page_size: params?.page_size ?? 20 } as PaginatedResponse<SchoolClass>;
|
||||
}
|
||||
// 兼容:如果后端已升级为分页格式
|
||||
return raw as unknown as PaginatedResponse<SchoolClass>;
|
||||
}),
|
||||
|
||||
/** 所有班级 — 管理端用,需要 diary.class.manage 权限 (GET /diary/classes/all) */
|
||||
listAll: () =>
|
||||
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes/all')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
myClasses: () =>
|
||||
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes/my')
|
||||
.then((r) => r.data.data),
|
||||
@@ -26,6 +29,18 @@ export const classApi = {
|
||||
create: (data: CreateClassReq) =>
|
||||
client.post('/diary/classes', data).then((r) => r.data.data),
|
||||
|
||||
/** 更新班级信息 (PUT /diary/classes/:id) */
|
||||
update: (id: string, data: UpdateClassReq) =>
|
||||
client.put(`/diary/classes/${id}`, data).then((r) => r.data.data),
|
||||
|
||||
/** 停用班级 (PATCH /diary/classes/:id/deactivate) */
|
||||
deactivate: (id: string) =>
|
||||
client.patch(`/diary/classes/${id}/deactivate`).then((r) => r.data.data),
|
||||
|
||||
/** 重置班级码 (POST /diary/classes/:id/reset-code) */
|
||||
resetCode: (id: string) =>
|
||||
client.post(`/diary/classes/${id}/reset-code`).then((r) => r.data.data),
|
||||
|
||||
listMembers: (classId: string) =>
|
||||
client.get<{ success: boolean; data: ClassMember[] }>(`/diary/classes/${classId}/members`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
@@ -50,6 +50,17 @@ export interface CreateClassReq {
|
||||
school_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateClassReq {
|
||||
name?: string;
|
||||
school_name?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ResetClassCodeResp {
|
||||
class_id: string;
|
||||
new_class_code: string;
|
||||
}
|
||||
|
||||
export interface ClassMember {
|
||||
user_id: string;
|
||||
role: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
ReloadOutlined,
|
||||
StopOutlined,
|
||||
SyncOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { classApi } from '../../api/diary/classes';
|
||||
import type { SchoolClass, ClassMember } from '../../api/diary/types';
|
||||
@@ -39,8 +43,12 @@ export default function ClassList() {
|
||||
loading,
|
||||
refresh,
|
||||
} = usePaginatedData<SchoolClass>(async (p: number, pageSize: number) => {
|
||||
const result = await classApi.list({ page: p, page_size: pageSize });
|
||||
return { data: result.data, total: result.total };
|
||||
// 管理端使用 listAll 获取所有班级
|
||||
const result = await classApi.listAll();
|
||||
// 前端分页
|
||||
const start = (p - 1) * pageSize;
|
||||
const sliced = result.slice(start, start + pageSize);
|
||||
return { data: sliced, total: result.length };
|
||||
}, 20);
|
||||
|
||||
// --- Create/Edit drawer ---
|
||||
@@ -49,8 +57,12 @@ export default function ClassList() {
|
||||
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
|
||||
onUpdate: async (record, values) => {
|
||||
await classApi.update(record.id, {
|
||||
name: values.name as string,
|
||||
school_name: values.school_name as string | undefined,
|
||||
version: record.version,
|
||||
});
|
||||
},
|
||||
onSuccess: refresh,
|
||||
});
|
||||
@@ -84,6 +96,28 @@ export default function ClassList() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
// --- Deactivate class ---
|
||||
const handleDeactivate = useCallback(async (record: SchoolClass) => {
|
||||
try {
|
||||
await classApi.deactivate(record.id);
|
||||
message.success(`班级「${record.name}」已停用`);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('停用班级失败');
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// --- Reset class code ---
|
||||
const handleResetCode = useCallback(async (record: SchoolClass) => {
|
||||
try {
|
||||
const result = await classApi.resetCode(record.id);
|
||||
message.success(`班级码已重置为 ${result.new_class_code}`);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('重置班级码失败');
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// --- Table columns ---
|
||||
const columns = [
|
||||
{
|
||||
@@ -186,7 +220,7 @@ export default function ClassList() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
width: 200,
|
||||
render: (_: unknown, record: SchoolClass) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="查看成员">
|
||||
@@ -198,6 +232,50 @@ export default function ClassList() {
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑班级">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => classDrawer.openEdit(record)}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{record.is_active && (
|
||||
<Popconfirm
|
||||
title="确认停用此班级?"
|
||||
description="停用后学生将无法通过班级码加入"
|
||||
onConfirm={() => handleDeactivate(record)}
|
||||
okText="停用"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="停用班级">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<StopOutlined />}
|
||||
style={{ color: '#E07A5F' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认重置班级码?"
|
||||
description="重置后旧班级码立即失效"
|
||||
onConfirm={() => handleResetCode(record)}
|
||||
okText="重置"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="重置班级码">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<SyncOutlined />}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -270,7 +348,7 @@ export default function ClassList() {
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Create class drawer */}
|
||||
{/* Create/Edit class drawer */}
|
||||
<DrawerForm
|
||||
title={classDrawer.editingRecord ? '编辑班级' : '创建班级'}
|
||||
open={classDrawer.open}
|
||||
|
||||
@@ -128,8 +128,9 @@ export default function JournalList() {
|
||||
// --- Fetch classes for filter ---
|
||||
const fetchClasses = useCallback(async () => {
|
||||
try {
|
||||
const result = await classApi.list({ page: 1, page_size: 200 });
|
||||
setClasses(result.data);
|
||||
// 管理端使用 listAll 获取所有班级
|
||||
const result = await classApi.listAll();
|
||||
setClasses(result);
|
||||
} catch {
|
||||
// Silently fail — class filter is optional
|
||||
}
|
||||
|
||||
@@ -13,11 +13,9 @@ const RESOURCE_TYPE_OPTIONS = [
|
||||
{ value: 'process_definition', label: '流程定义' }, { value: 'task', label: '流程任务' },
|
||||
{ value: 'dictionary', label: '字典' }, { value: 'menu', label: '菜单' },
|
||||
{ value: 'setting', label: '设置' }, { value: 'numbering_rule', label: '编号规则' },
|
||||
{ value: 'patient', label: '患者' }, { value: 'patient_tag', label: '患者标签' },
|
||||
{ value: 'patient_family_member', label: '家庭成员' }, { value: 'patient_doctor_relation', label: '医患关系' },
|
||||
{ value: 'points_transaction', label: '积分流水' }, { value: 'points_product', label: '积分商品' },
|
||||
{ value: 'points_order', label: '积分订单' }, { value: 'points_rule', label: '积分规则' },
|
||||
{ value: 'offline_event', label: '线下活动' }, { value: 'offline_event_registration', label: '活动签到' },
|
||||
{ value: 'journal_entry', label: '日记' }, { value: 'school_class', label: '班级' },
|
||||
{ value: 'class_member', label: '班级成员' }, { value: 'topic_assignment', label: '主题布置' },
|
||||
{ value: 'comment', label: '评论' }, { value: 'sticker_pack', label: '贴纸包' },
|
||||
];
|
||||
|
||||
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
|
||||
|
||||
@@ -85,7 +85,7 @@ function createFakeUser(overrides: Partial<UserInfo> = {}): UserInfo {
|
||||
|
||||
function createFakeLoginResponse(overrides: Partial<LoginResponse> = {}): LoginResponse {
|
||||
return {
|
||||
access_token: createFakeToken(['health.patient.list', 'health.alerts.manage']),
|
||||
access_token: createFakeToken(['diary.journal.read', 'diary.class.manage']),
|
||||
refresh_token: 'refresh-token-xxx',
|
||||
expires_in: 3600,
|
||||
user: createFakeUser(),
|
||||
@@ -157,7 +157,7 @@ describe('useAuthStore', () => {
|
||||
expect(state.user).toEqual(fakeUser);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.permissions).toEqual(['health.patient.list', 'health.alerts.manage']);
|
||||
expect(state.permissions).toEqual(['diary.journal.read', 'diary.class.manage']);
|
||||
|
||||
// API 被正确调用
|
||||
expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' });
|
||||
@@ -254,7 +254,7 @@ describe('useAuthStore', () => {
|
||||
useAuthStore.setState({
|
||||
user: createFakeUser(),
|
||||
isAuthenticated: true,
|
||||
permissions: ['health.patient.list'],
|
||||
permissions: ['diary.journal.read'],
|
||||
});
|
||||
localStorageStore['access_token'] = 'some-token';
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('useAuthStore', () => {
|
||||
// =========================================================================
|
||||
describe('权限提取', () => {
|
||||
it('登录后 permissions 应从 JWT token 中正确解析', async () => {
|
||||
const permissions = ['health.patient.list', 'health.alerts.manage', 'health.report.review'];
|
||||
const permissions = ['diary.journal.read', 'diary.class.manage', 'diary.topic.assign'];
|
||||
const token = createFakeToken(permissions);
|
||||
const fakeResponse = createFakeLoginResponse({ access_token: token });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
@@ -336,7 +336,7 @@ describe('useAuthStore', () => {
|
||||
describe('loadFromStorage', () => {
|
||||
it('localStorage 有有效 token 和 user 时应恢复认证状态', () => {
|
||||
const fakeUser = createFakeUser();
|
||||
const permissions = ['health.patient.list'];
|
||||
const permissions = ['diary.journal.read'];
|
||||
const token = createFakeToken(permissions);
|
||||
|
||||
localStorageStore['access_token'] = token;
|
||||
@@ -362,7 +362,7 @@ describe('useAuthStore', () => {
|
||||
});
|
||||
|
||||
it('localStorage 无 user 时应保持未认证', () => {
|
||||
localStorageStore['access_token'] = createFakeToken(['health.patient.list']);
|
||||
localStorageStore['access_token'] = createFakeToken(['diary.journal.read']);
|
||||
// 不设置 user
|
||||
|
||||
useAuthStore.getState().loadFromStorage();
|
||||
|
||||
255
apps/web/src/test/fixtures/healthFixtures.ts
vendored
255
apps/web/src/test/fixtures/healthFixtures.ts
vendored
@@ -1,255 +0,0 @@
|
||||
// --- 患者 ---
|
||||
export function createPatientFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'patient-1',
|
||||
name: '张三',
|
||||
gender: 'male',
|
||||
birth_date: '1990-01-15',
|
||||
blood_type: 'A',
|
||||
status: 'active',
|
||||
verification_status: 'verified',
|
||||
source: 'manual',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 医生 ---
|
||||
export function createDoctorFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'doctor-1',
|
||||
user_id: 'user-doc-1',
|
||||
name: '李医生',
|
||||
department: '内科',
|
||||
title: '主治医师',
|
||||
specialization: '心血管内科',
|
||||
phone: '13800000001',
|
||||
email: 'doctor1@test.com',
|
||||
status: 'online',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 告警 ---
|
||||
export function createAlertFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'alert-1',
|
||||
patient_id: 'patient-1',
|
||||
patient_name: '张三',
|
||||
alert_type: 'vital_sign',
|
||||
severity: 'high',
|
||||
status: 'active',
|
||||
message: '血压异常偏高',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 预约 ---
|
||||
export function createAppointmentFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'appt-1',
|
||||
patient_id: 'patient-1',
|
||||
patient_name: '张三',
|
||||
doctor_id: 'doctor-1',
|
||||
doctor_name: '李医生',
|
||||
appointment_date: '2026-05-10',
|
||||
start_time: '09:00',
|
||||
end_time: '09:30',
|
||||
status: 'pending',
|
||||
type: 'follow_up',
|
||||
notes: '',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 设备 ---
|
||||
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: 'enabled',
|
||||
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,
|
||||
count: number,
|
||||
overridesList: Record<string, unknown>[] = [],
|
||||
): T[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const autoId = { id: `${i + 1}` };
|
||||
return factory({ ...autoId, ...overridesList[i] });
|
||||
});
|
||||
}
|
||||
|
||||
// --- 分页响应包装 ---
|
||||
export function wrapPaginated<T>(items: T[], total?: number) {
|
||||
return {
|
||||
data: items,
|
||||
total: total ?? items.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_pages: Math.ceil((total ?? items.length) / 20),
|
||||
};
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
function paginatedResponse<T>(items: T[], total: number, page: number, pageSize = DEFAULT_PAGE_SIZE) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
data: items,
|
||||
total,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: Math.ceil(total / pageSize),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- 患者列表 ---
|
||||
const mockPatients = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `patient-${i + 1}`,
|
||||
name: `测试患者${i + 1}`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
birth_date: '1990-01-15',
|
||||
blood_type: 'A',
|
||||
status: 'active',
|
||||
verification_status: i < 20 ? 'verified' : 'pending',
|
||||
source: 'manual',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
}));
|
||||
|
||||
export const patientHandlers = [
|
||||
http.get('/api/v1/health/patients', 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);
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = mockPatients.slice(start, start + pageSize);
|
||||
return paginatedResponse(items, mockPatients.length, page, pageSize);
|
||||
}),
|
||||
|
||||
http.get('/api/v1/health/patients/:id', async ({ params }) => {
|
||||
await delay(50);
|
||||
const patient = mockPatients.find((p) => p.id === params.id);
|
||||
if (!patient) return HttpResponse.json({ success: false, error: 'Not found' }, { status: 404 });
|
||||
return HttpResponse.json({ success: true, data: { ...patient, notes: '', allergy_history: '', medical_history_summary: '' } });
|
||||
}),
|
||||
];
|
||||
|
||||
// --- 告警列表 ---
|
||||
const mockAlerts = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `alert-${i + 1}`,
|
||||
patient_id: `patient-${i + 1}`,
|
||||
patient_name: `测试患者${i + 1}`,
|
||||
alert_type: i % 3 === 0 ? 'vital_sign' : i % 3 === 1 ? 'lab_result' : 'overdue_followup',
|
||||
severity: (['low', 'medium', 'high'] as const)[i % 3],
|
||||
status: i < 8 ? 'active' : 'resolved',
|
||||
message: `告警消息 ${i + 1}`,
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
}));
|
||||
|
||||
export const alertHandlers = [
|
||||
http.get('/api/v1/health/alerts', 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(mockAlerts.slice(0, pageSize), mockAlerts.length, page, pageSize);
|
||||
}),
|
||||
];
|
||||
|
||||
// --- 预约列表 ---
|
||||
const mockAppointments = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `appt-${i + 1}`,
|
||||
patient_id: `patient-${i + 1}`,
|
||||
patient_name: `测试患者${i + 1}`,
|
||||
doctor_id: `doctor-${(i % 5) + 1}`,
|
||||
doctor_name: `测试医生${(i % 5) + 1}`,
|
||||
appointment_date: '2026-05-10',
|
||||
start_time: '09:00',
|
||||
end_time: '09:30',
|
||||
status: (['pending', 'confirmed', 'completed', 'cancelled'] as const)[i % 4],
|
||||
type: 'follow_up',
|
||||
notes: '',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
}));
|
||||
|
||||
export const appointmentHandlers = [
|
||||
http.get('/api/v1/health/appointments', 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(mockAppointments.slice(0, pageSize), mockAppointments.length, page, pageSize);
|
||||
}),
|
||||
];
|
||||
|
||||
// --- 医生列表 ---
|
||||
const mockDoctors = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `doctor-${i + 1}`,
|
||||
user_id: `user-doc-${i + 1}`,
|
||||
name: `测试医生${i + 1}`,
|
||||
department: '内科',
|
||||
title: '主治医师',
|
||||
specialization: '心血管内科',
|
||||
phone: `1380000${String(i + 1).padStart(4, '0')}`,
|
||||
email: `doctor${i + 1}@test.com`,
|
||||
status: 'online',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
}));
|
||||
|
||||
export const doctorHandlers = [
|
||||
http.get('/api/v1/health/doctors', 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(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: 'enabled',
|
||||
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);
|
||||
}),
|
||||
];
|
||||
@@ -16,45 +16,22 @@ interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
}>;
|
||||
}
|
||||
|
||||
const ALL_HEALTH_PERMISSIONS = [
|
||||
'health.patient.manage',
|
||||
'health.patient.list',
|
||||
'health.doctor.manage',
|
||||
'health.doctor.list',
|
||||
'health.appointment.manage',
|
||||
'health.appointment.list',
|
||||
'health.alerts.manage',
|
||||
'health.alerts.list',
|
||||
'health.follow-up.manage',
|
||||
'health.follow-up.list',
|
||||
'health.follow-up-templates.manage',
|
||||
'health.follow-up-templates.list',
|
||||
'health.consultation.manage',
|
||||
'health.consultation.list',
|
||||
'health.dialysis.manage',
|
||||
'health.dialysis.list',
|
||||
'health.articles.manage',
|
||||
'health.articles.list',
|
||||
'health.articles.review',
|
||||
'health.points.manage',
|
||||
'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',
|
||||
const ALL_DIARY_PERMISSIONS = [
|
||||
'diary.journal.create',
|
||||
'diary.journal.read',
|
||||
'diary.journal.update',
|
||||
'diary.journal.delete',
|
||||
'diary.class.manage',
|
||||
'diary.topic.assign',
|
||||
'diary.comment.write',
|
||||
'diary.comment.delete',
|
||||
'diary.parent.bind',
|
||||
];
|
||||
|
||||
const DEFAULT_AUTH = {
|
||||
user: { id: 'test-user-id', username: 'admin', tenant_id: 'test-tenant-id' },
|
||||
isAuthenticated: true,
|
||||
permissions: ALL_HEALTH_PERMISSIONS,
|
||||
permissions: ALL_DIARY_PERMISSIONS,
|
||||
};
|
||||
|
||||
// 简单 JWT payload — 不会过期
|
||||
|
||||
Reference in New Issue
Block a user