feat(web): 透析 API + 积分账户组件 + 工作台 store + 统计页修复
- dialysis.ts: 新增透析管理 API 模块 - PointsAccountTab.tsx: 积分账户标签页组件 - workbenchStore.ts: 工作台状态管理 - StatisticsDashboard.tsx: 统计页空列表修复 - auth.test.ts: 修复权限码拼写 health.alert → health.alerts - api.test.ts: API 契约测试
This commit is contained in:
172
apps/web/src/api/health/api.test.ts
Normal file
172
apps/web/src/api/health/api.test.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 健康模块新增 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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
108
apps/web/src/api/health/dialysis.ts
Normal file
108
apps/web/src/api/health/dialysis.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,12 +4,13 @@ import { NurseDashboard } from './StatisticsDashboard/NurseDashboard';
|
|||||||
import { AdminDashboard } from './StatisticsDashboard/AdminDashboard';
|
import { AdminDashboard } from './StatisticsDashboard/AdminDashboard';
|
||||||
import { OperatorDashboard } from './StatisticsDashboard/OperatorDashboard';
|
import { OperatorDashboard } from './StatisticsDashboard/OperatorDashboard';
|
||||||
|
|
||||||
const DASHBOARD_MAP = {
|
const DASHBOARD_MAP: Record<string, React.FC> = {
|
||||||
doctor: DoctorDashboard,
|
doctor: DoctorDashboard,
|
||||||
|
health_manager: NurseDashboard,
|
||||||
nurse: NurseDashboard,
|
nurse: NurseDashboard,
|
||||||
admin: AdminDashboard,
|
admin: AdminDashboard,
|
||||||
operator: OperatorDashboard,
|
operator: OperatorDashboard,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export default function StatisticsDashboard() {
|
export default function StatisticsDashboard() {
|
||||||
const role = useDashboardRole();
|
const role = useDashboardRole();
|
||||||
|
|||||||
141
apps/web/src/pages/health/components/PointsAccountTab.tsx
Normal file
141
apps/web/src/pages/health/components/PointsAccountTab.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Table, Tag, Statistic, Row, Col, Card, Empty } from 'antd';
|
||||||
|
import {
|
||||||
|
pointsAdminApi,
|
||||||
|
type PointsAccountDetail,
|
||||||
|
type PointsTransactionDetail,
|
||||||
|
} from '../../../api/health/points';
|
||||||
|
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||||
|
import { handleApiError } from '../../../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
patientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, { color: string; label: string }> = {
|
||||||
|
earn: { color: 'green', label: '获得' },
|
||||||
|
spend: { color: 'orange', label: '消费' },
|
||||||
|
expire: { color: 'default', label: '过期' },
|
||||||
|
adjust: { color: 'blue', label: '调整' },
|
||||||
|
checkin: { color: 'cyan', label: '签到' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PointsAccountTab({ patientId }: Props) {
|
||||||
|
const [account, setAccount] = useState<PointsAccountDetail | null>(null);
|
||||||
|
const [accountLoading, setAccountLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchTransactions = useCallback(
|
||||||
|
async (page: number, pageSize: number) => {
|
||||||
|
if (!account) {
|
||||||
|
try {
|
||||||
|
const acc = await pointsAdminApi.getPatientAccount(patientId);
|
||||||
|
setAccount(acc);
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err, '加载积分账户失败');
|
||||||
|
} finally {
|
||||||
|
setAccountLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pointsAdminApi.listPatientTransactions(patientId, { page, page_size: pageSize });
|
||||||
|
},
|
||||||
|
[patientId, account],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, total, page, loading, refresh } = usePaginatedData<PointsTransactionDetail>(
|
||||||
|
fetchTransactions,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'transaction_type',
|
||||||
|
key: 'transaction_type',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => {
|
||||||
|
const m = TYPE_MAP[v];
|
||||||
|
return m ? <Tag color={m.color}>{m.label}</Tag> : <Tag>{v}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
key: 'amount',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => (
|
||||||
|
<span style={{ color: v > 0 ? '#52c41a' : v < 0 ? '#ff4d4f' : undefined }}>
|
||||||
|
{v > 0 ? `+${v}` : v}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '余额',
|
||||||
|
dataIndex: 'balance_after',
|
||||||
|
key: 'balance_after',
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '说明',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small" loading={accountLoading}>
|
||||||
|
<Statistic title="当前余额" value={account?.balance ?? 0} suffix="分" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small" loading={accountLoading}>
|
||||||
|
<Statistic title="累计获得" value={account?.total_earned ?? 0} suffix="分" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small" loading={accountLoading}>
|
||||||
|
<Statistic title="累计消费" value={account?.total_spent ?? 0} suffix="分" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small" loading={accountLoading}>
|
||||||
|
<Statistic title="累计过期" value={account?.total_expired ?? 0} suffix="分" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{!account && !accountLoading ? (
|
||||||
|
<Empty description="暂无积分记录" />
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 10,
|
||||||
|
onChange: (p) => refresh(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ function createFakeUser(overrides: Partial<UserInfo> = {}): UserInfo {
|
|||||||
|
|
||||||
function createFakeLoginResponse(overrides: Partial<LoginResponse> = {}): LoginResponse {
|
function createFakeLoginResponse(overrides: Partial<LoginResponse> = {}): LoginResponse {
|
||||||
return {
|
return {
|
||||||
access_token: createFakeToken(['health.patient.list', 'health.alert.manage']),
|
access_token: createFakeToken(['health.patient.list', 'health.alerts.manage']),
|
||||||
refresh_token: 'refresh-token-xxx',
|
refresh_token: 'refresh-token-xxx',
|
||||||
expires_in: 3600,
|
expires_in: 3600,
|
||||||
user: createFakeUser(),
|
user: createFakeUser(),
|
||||||
@@ -157,7 +157,7 @@ describe('useAuthStore', () => {
|
|||||||
expect(state.user).toEqual(fakeUser);
|
expect(state.user).toEqual(fakeUser);
|
||||||
expect(state.isAuthenticated).toBe(true);
|
expect(state.isAuthenticated).toBe(true);
|
||||||
expect(state.loading).toBe(false);
|
expect(state.loading).toBe(false);
|
||||||
expect(state.permissions).toEqual(['health.patient.list', 'health.alert.manage']);
|
expect(state.permissions).toEqual(['health.patient.list', 'health.alerts.manage']);
|
||||||
|
|
||||||
// API 被正确调用
|
// API 被正确调用
|
||||||
expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' });
|
expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' });
|
||||||
@@ -283,7 +283,7 @@ describe('useAuthStore', () => {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
describe('权限提取', () => {
|
describe('权限提取', () => {
|
||||||
it('登录后 permissions 应从 JWT token 中正确解析', async () => {
|
it('登录后 permissions 应从 JWT token 中正确解析', async () => {
|
||||||
const permissions = ['health.patient.list', 'health.alert.manage', 'health.report.review'];
|
const permissions = ['health.patient.list', 'health.alerts.manage', 'health.report.review'];
|
||||||
const token = createFakeToken(permissions);
|
const token = createFakeToken(permissions);
|
||||||
const fakeResponse = createFakeLoginResponse({ access_token: token });
|
const fakeResponse = createFakeLoginResponse({ access_token: token });
|
||||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||||
|
|||||||
58
apps/web/src/stores/workbenchStore.ts
Normal file
58
apps/web/src/stores/workbenchStore.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { actionInboxApi, type ActionItem, type WorkbenchStats } from '../api/health/actionInbox';
|
||||||
|
|
||||||
|
interface WorkbenchState {
|
||||||
|
tasks: ActionItem[];
|
||||||
|
selectedTaskId: string | null;
|
||||||
|
tab: 'pending' | 'completed';
|
||||||
|
loading: boolean;
|
||||||
|
stats: WorkbenchStats | null;
|
||||||
|
|
||||||
|
selectTask: (id: string | null) => void;
|
||||||
|
setTab: (tab: 'pending' | 'completed') => void;
|
||||||
|
refreshTasks: () => Promise<void>;
|
||||||
|
refreshStats: () => Promise<void>;
|
||||||
|
completeTask: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkbenchStore = create<WorkbenchState>((set, get) => ({
|
||||||
|
tasks: [],
|
||||||
|
selectedTaskId: null,
|
||||||
|
tab: 'pending',
|
||||||
|
loading: false,
|
||||||
|
stats: null,
|
||||||
|
|
||||||
|
selectTask: (id) => set({ selectedTaskId: id }),
|
||||||
|
|
||||||
|
setTab: (tab) => {
|
||||||
|
set({ tab, selectedTaskId: null });
|
||||||
|
get().refreshTasks();
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshTasks: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const status = get().tab === 'pending' ? 'pending' : 'completed';
|
||||||
|
const resp = await actionInboxApi.list({ status, page: 1, page_size: 50 });
|
||||||
|
const tasks = Array.isArray(resp?.data) ? resp.data : [];
|
||||||
|
set({ tasks, loading: false });
|
||||||
|
} catch {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshStats: async () => {
|
||||||
|
try {
|
||||||
|
const stats = await actionInboxApi.stats();
|
||||||
|
set({ stats: stats ?? null });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
completeTask: (id) => {
|
||||||
|
const { tasks } = get();
|
||||||
|
const remaining = tasks.filter(t => t.id !== id);
|
||||||
|
const nextId = remaining.length > 0 ? remaining[0].id : null;
|
||||||
|
set({ tasks: remaining, selectedTaskId: nextId });
|
||||||
|
get().refreshStats();
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user