- TODO_LIMIT 由 5 调整为 3,同步更新接口文档默认值 - 趋势图 viewBox 与画布尺寸对齐原型 560×190 - 趋势卡改为弹性布局,图表贴底避免留白
491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
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))
|
||
}
|