Files
xh-medical-sign-web/clinical-web/src/api/workbench/home.ts
T
yelan 5ed6779ec6 fix(workbench): 首页待办上限改为 3 条并修正趋势图尺寸
- TODO_LIMIT 由 5 调整为 3,同步更新接口文档默认值
- 趋势图 viewBox 与画布尺寸对齐原型 560×190
- 趋势卡改为弹性布局,图表贴底避免留白
2026-09-30 18:05:27 +08:00

491 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getCampuses } from '@/api/management/organization'
import { unwrapApiResponse } from '@/utils/api-response'
import { mockFlags } from '@/utils/mock-flags'
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/common'
import type {
BackendSigningMethod,
BackendSigningTaskStatus,
HomeRankingPeriod,
SigningMethod,
WorkbenchCampus,
WorkbenchDocumentRanking,
WorkbenchOverviewQuery,
WorkbenchOverviewRequestDto,
WorkbenchOverviewResponse,
WorkbenchOverviewResponseDto,
WorkbenchTodoStatus,
WorkbenchTodoTask,
WorkbenchTrendPoint,
} from './types'
const useMockData = mockFlags.workbenchHome
interface MockCampusOverview {
summary: WorkbenchOverviewResponse['summary']
/** 趋势的日均人次基数(按「院区 + 日期」播种出每天的具体值,见 createTrend) */
trendBase: number
todos: WorkbenchTodoTask[]
ranking: Array<Omit<WorkbenchDocumentRanking, 'signedCount'> & { baseCount: number }>
}
const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
本部院区: {
summary: {
todaySigned: 36,
todaySignedChange: 2,
pendingPatientSigning: 18,
todayOverdue: 2,
availableTemplates: 128,
coveredDepartments: 9,
},
trendBase: 32,
todos: [
{
id: 'T0818003',
patientId: 'P202610005',
patientName: '陈志强',
documentName: '住院须知及告知书',
department: '消化内科',
status: 'pending',
method: 'pad',
updatedAt: '2026-08-27 11:20',
},
{
id: 'T0818002',
patientId: 'P202610002',
patientName: '李淑芬',
documentName: 'MRI 磁共振检查知情同意书',
department: '放射科',
status: 'pending',
method: 'sms',
updatedAt: '2026-08-27 10:05',
},
{
id: 'T0815001',
patientId: 'P202610001',
patientName: '王建国',
documentName: '心包穿刺知情同意书',
department: '心内科',
status: 'overdue',
method: 'sms',
updatedAt: '2026-08-27 09:40',
},
{
// Pad 移动端:待办里要四种方式都有样本,徽标配色才看得出区别
id: 'T0818004',
patientId: 'P202610003',
patientName: '赵某某',
documentName: '住院须知及告知书',
department: '消化内科',
status: 'pending',
method: 'ipad',
updatedAt: '2026-08-27 09:05',
},
{
id: 'T0818005',
patientId: 'P202610007',
patientName: '孙浩然',
documentName: '内镜检查知情同意书',
department: '普外科',
status: 'pending',
method: 'wx',
updatedAt: '2026-08-27 08:50',
},
],
ranking: [
{ id: 'doc-001', documentName: '住院患者知情同意书', department: '全院通用', baseCount: 58 },
{ id: 'doc-002', documentName: '入院须知及告知书', department: '全院通用', baseCount: 51 },
{
id: 'doc-003',
documentName: 'MRI 磁共振检查知情同意书',
department: '放射科',
baseCount: 43,
},
{ id: 'doc-004', documentName: '手术知情同意书', department: '普外科', baseCount: 39 },
{ id: 'doc-005', documentName: '麻醉知情同意书', department: '麻醉科', baseCount: 35 },
{ id: 'doc-006', documentName: '患者授权委托书', department: '医务处', baseCount: 31 },
{ id: 'doc-007', documentName: '输血治疗知情同意书', department: '血液科', baseCount: 27 },
{ id: 'doc-008', documentName: '内镜检查知情同意书', department: '消化内科', baseCount: 24 },
{ id: 'doc-009', documentName: '介入治疗知情同意书', department: '心内科', baseCount: 21 },
{ id: 'doc-010', documentName: '特殊检查风险告知书', department: '影像科', baseCount: 18 },
],
},
东院区: {
summary: {
todaySigned: 22,
todaySignedChange: 1,
pendingPatientSigning: 11,
todayOverdue: 1,
availableTemplates: 76,
coveredDepartments: 6,
},
trendBase: 20,
todos: [
{
id: 'T0818001',
patientId: 'P202610004',
patientName: '赵美英',
documentName: '体检报告发放及知情同意书',
department: '健康管理中心',
status: 'pending',
method: 'pad',
updatedAt: '2026-08-27 09:12',
},
{
id: 'T0818011',
patientId: 'P202610011',
patientName: '周雅琴',
documentName: '检查前注意事项告知书',
department: '体检中心',
status: 'overdue',
method: 'sms',
updatedAt: '2026-08-27 08:30',
},
{
id: 'T0818012',
patientId: 'P202610003',
patientName: '赵某某',
documentName: '住院须知及告知书',
department: '消化内科',
status: 'pending',
method: 'ipad',
updatedAt: '2026-08-27 09:05',
},
{
id: 'T0818013',
patientId: 'P202610007',
patientName: '孙浩然',
documentName: '内镜检查知情同意书',
department: '普外科',
status: 'pending',
method: 'wx',
updatedAt: '2026-08-27 08:50',
},
],
ranking: [
{
id: 'doc-011',
documentName: '体检报告发放及知情同意书',
department: '健康管理中心',
baseCount: 46,
},
{ id: 'doc-012', documentName: '检前告知书', department: '健康管理中心', baseCount: 39 },
{
id: 'doc-013',
documentName: '超声检查知情同意书',
department: '超声医学科',
baseCount: 32,
},
{
id: 'doc-014',
documentName: '胃肠镜检查知情同意书',
department: '消化内科',
baseCount: 28,
},
{ id: 'doc-015', documentName: '采血及检验告知书', department: '检验科', baseCount: 25 },
{ id: 'doc-016', documentName: '影像检查告知书', department: '影像科', baseCount: 21 },
{ id: 'doc-017', documentName: '健康体检知情同意书', department: '体检中心', baseCount: 19 },
{ id: 'doc-018', documentName: '隐私保护告知书', department: '健康管理中心', baseCount: 16 },
{ id: 'doc-019', documentName: '麻醉检查知情同意书', department: '麻醉科', baseCount: 14 },
{ id: 'doc-020', documentName: '报告领取授权书', department: '健康管理中心', baseCount: 12 },
],
},
西院区: {
summary: {
todaySigned: 17,
todaySignedChange: 2,
pendingPatientSigning: 9,
todayOverdue: 2,
availableTemplates: 54,
coveredDepartments: 5,
},
trendBase: 15,
todos: [
{
id: 'T0818021',
patientId: 'P202610021',
patientName: '孙浩然',
documentName: '心脏介入手术知情同意书',
department: '心内科',
status: 'pending',
method: 'pad',
updatedAt: '2026-08-27 10:15',
},
{
id: 'T0818022',
patientId: 'P202610022',
patientName: '刘晓琴',
documentName: '骨科手术知情同意书',
department: '骨科',
status: 'pending',
method: 'sms',
updatedAt: '2026-08-27 09:52',
},
{
id: 'T0818023',
patientId: 'P202610023',
patientName: '何文博',
documentName: '术前麻醉访视告知书',
department: '麻醉科',
status: 'overdue',
method: 'pad',
updatedAt: '2026-08-27 08:05',
},
{
id: 'T0818024',
patientId: 'P202610003',
patientName: '赵某某',
documentName: '住院须知及告知书',
department: '消化内科',
status: 'pending',
method: 'ipad',
updatedAt: '2026-08-27 09:05',
},
{
id: 'T0818025',
patientId: 'P202610007',
patientName: '孙浩然',
documentName: '内镜检查知情同意书',
department: '普外科',
status: 'pending',
method: 'wx',
updatedAt: '2026-08-27 08:50',
},
],
ranking: [
{
id: 'doc-021',
documentName: '心脏介入手术知情同意书',
department: '心内科',
baseCount: 37,
},
{ id: 'doc-022', documentName: '骨科手术知情同意书', department: '骨科', baseCount: 33 },
{ id: 'doc-023', documentName: '术前麻醉访视告知书', department: '麻醉科', baseCount: 28 },
{ id: 'doc-024', documentName: '住院患者知情同意书', department: '全院通用', baseCount: 25 },
{ id: 'doc-025', documentName: '输血治疗知情同意书', department: '血液科', baseCount: 22 },
{ id: 'doc-026', documentName: '介入治疗知情同意书', department: '心内科', baseCount: 20 },
{ id: 'doc-027', documentName: '术后康复告知书', department: '骨科', baseCount: 18 },
{ id: 'doc-028', documentName: '特殊检查风险告知书', department: '影像科', baseCount: 16 },
{ id: 'doc-029', documentName: '患者授权委托书', department: '医务处', baseCount: 14 },
{ id: 'doc-030', documentName: '手术部位标识确认书', department: '手术部', baseCount: 12 },
],
},
}
function getPeriodFactor(period: HomeRankingPeriod) {
return period === 7 ? 1 : period === 14 ? 2 : 3.5
}
/** 「院区 + 日期」→ 固定种子:同一天同一院区的数值不随刷新、切换时段或翻页而变 */
function hashSeed(text: string) {
let hash = 2166136261
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
/** mulberry32:小、快、同种子可复现(与 api/workbench/report-analytics.ts 同一套做法) */
function createRandom(seed: number) {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* 演示趋势:近 period 天(含今天)的已签署人次。
*
* 为什么不用写死的日期数组:写死 8/21–8/27 之后,演示日一过「近 7 天」窗口就全是过期日期
* (仓库的演示数据铁律:**日期相对** + **确定性**,不要 Math.random)。
* 末点取今日已签署,与指标卡同源 —— 原型里趋势最后一点就是今天已签署的任务数。
*/
function createTrend(
campus: WorkbenchCampus,
summary: WorkbenchOverviewResponse['summary'],
period: HomeRankingPeriod,
): WorkbenchTrendPoint[] {
const points: WorkbenchTrendPoint[] = []
const base = (mockCampusOverviews[campus] ?? mockCampusOverviews['本部院区']).trendBase
for (let offset = period - 1; offset >= 0; offset -= 1) {
const day = new Date()
day.setDate(day.getDate() - offset)
const label = `${day.getMonth() + 1}/${day.getDate()}`
const value =
offset === 0
? summary.todaySigned
: Math.max(base + Math.round(createRandom(hashSeed(`${campus}-${label}`))() * 12 - 5), 0)
points.push({ label, value })
}
return points
}
function createRanking(
rows: MockCampusOverview['ranking'],
period: HomeRankingPeriod,
): WorkbenchDocumentRanking[] {
const factor = getPeriodFactor(period)
return rows
.map((row, index) => ({
id: row.id,
documentName: row.documentName,
department: row.department,
signedCount: Math.round(row.baseCount * factor + (index % 3)),
}))
.sort((left, right) => right.signedCount - left.signedCount)
}
/**
* 首页「我的待办」最多几条。
*
* 原型 `renderDash()` 是 `tasks.slice(0, 5)`(最多 5 条)。
* 仓库收成 **3 条**(2026-09-30 定):趋势卡与待办卡同行,待办越多行高越高,
* 图是按宽度定高的、贴底后会在标题与图之间留大片空白 —— 3 条时两张卡高度基本齐平。
* 该上限同时作为 `todoLimit` 传给后端,避免取回一批再丢掉。
*/
const TODO_LIMIT = 3
const RANKING_LIMIT = 10
let campusIds: Map<string, string> | null = null
let campusIdsRequest: Promise<Map<string, string>> | null = null
function mapTodoStatus(status: BackendSigningTaskStatus): WorkbenchTodoStatus {
switch (status) {
case 'CREATED':
return 'created'
case 'WAITING_SIGN':
return 'pending'
case 'GENERATING':
return 'generating'
case 'EXPIRED':
return 'overdue'
case 'SIGNED':
return 'signed'
case 'VOIDED':
return 'voided'
case 'FAILED':
default:
return 'failed'
}
}
const METHOD_BY_BACKEND: Record<BackendSigningMethod, SigningMethod> = {
PAD: 'pad',
SMS: 'sms',
IPAD: 'ipad',
WX: 'wx',
}
function mapSigningMethod(method: BackendSigningMethod): SigningMethod {
// 后端理论上只回这四个;未知取值按手写板兜底,避免整行渲染不出来
return METHOD_BY_BACKEND[method] ?? 'pad'
}
function mapWorkbenchOverview(dto: WorkbenchOverviewResponseDto): WorkbenchOverviewResponse {
const summary = dto.summary
return {
summary: {
todaySigned: summary?.todaySigned ?? 0,
todaySignedChange: summary?.todaySignedChange ?? 0,
pendingPatientSigning: summary?.pendingPatientSigning ?? 0,
todayOverdue: summary?.todayOverdue ?? 0,
availableTemplates: summary?.availableTemplates ?? 0,
coveredDepartments: summary?.coveredDepartments ?? 0,
},
trend: (dto.trend ?? []).map<WorkbenchTrendPoint>((point) => ({
label: point.label || point.date,
value: point.signedCount,
})),
todos: (dto.todos ?? []).map<WorkbenchTodoTask>((task) => ({
id: task.taskId,
patientId: task.patientId,
patientName: task.patientName,
documentName: task.documentName,
department: task.departmentName,
status: mapTodoStatus(task.status),
method: mapSigningMethod(task.signMethod),
updatedAt: task.updatedAt,
})),
documentRanking: (dto.documentRanking ?? []).map<WorkbenchDocumentRanking>((item) => ({
id: item.templateId,
documentName: item.documentName,
department: item.departmentName,
signedCount: item.signedCount,
})),
}
}
function getCampusIds() {
if (campusIds) {
return Promise.resolve(campusIds)
}
if (!campusIdsRequest) {
campusIdsRequest = getCampuses()
.then((campuses) => {
campusIds = new Map(campuses.map((campus) => [campus.name, campus.id]))
return campusIds
})
.finally(() => {
campusIdsRequest = null
})
}
return campusIdsRequest
}
async function resolveCampusId(campusName: WorkbenchCampus) {
const campusId = (await getCampusIds()).get(campusName)
if (!campusId) {
throw new Error('未找到院区:' + campusName)
}
return campusId
}
export async function getWorkbenchOverview(
query: WorkbenchOverviewQuery,
): Promise<WorkbenchOverviewResponse> {
if (useMockData) {
const overview = mockCampusOverviews[query.campus] ?? mockCampusOverviews['本部院区']
const summary = { ...overview.summary }
return {
summary,
trend: createTrend(query.campus, summary, query.rankingPeriod),
todos: overview.todos.slice(0, TODO_LIMIT).map((task) => ({ ...task })),
documentRanking: createRanking(overview.ranking, query.rankingPeriod),
}
}
const campusId = query.campusId || (await resolveCampusId(query.campus))
const params: WorkbenchOverviewRequestDto = {
campusId,
period: query.rankingPeriod,
todoLimit: TODO_LIMIT,
rankingLimit: RANKING_LIMIT,
}
const response = await request.get<ApiResponse<WorkbenchOverviewResponseDto>>(
'/v1/workbench/overview',
{ params },
)
return mapWorkbenchOverview(unwrapApiResponse(response))
}