feat(workbench): 接入工作台首页概览聚合接口

This commit is contained in:
yelan
2026-09-02 13:17:40 +08:00
parent e0e98ec74f
commit e52f203123
13 changed files with 398 additions and 358 deletions
@@ -25,7 +25,9 @@ export function getCampuses(): Promise<CampusRecord[]> {
}
return request
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses')
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses', {
params: { page: 1, size: 200 },
})
.then(unwrapApiResponse)
.then((data) =>
getRecords(data).map((item) => ({
+1 -5
View File
@@ -8,16 +8,12 @@ const REPORT_PAGE_SIZE = 200
export const isReportsMockEnabled = isSigningMockEnabled
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return value === '本部院区' || value === '东院区' || value === '西院区'
}
function buildSigningApiOptions(data: ReportTaskData) {
return {
templates: data.templates,
campusNames: Object.fromEntries(
data.campuses
.filter((campus) => isWorkbenchCampus(campus.name))
.filter((campus) => Boolean(campus.name.trim()))
.map((campus) => [campus.id, campus.name]),
) as Record<string, WorkbenchCampus>,
}
+104 -200
View File
@@ -1,21 +1,23 @@
import { getCampuses } from '@/api/management/organization'
import { getSigningTasks, getSigningTemplates } from './signing'
import { unwrapApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/common'
import type {
BackendSigningMethod,
BackendSigningTaskStatus,
HomeRankingPeriod,
SigningMethod,
WorkbenchCampus,
WorkbenchDocumentRanking,
WorkbenchOverviewQuery,
WorkbenchOverviewRequestDto,
WorkbenchOverviewResponse,
SigningApiOptions,
SigningTemplate,
SigningTaskRecord,
SigningTaskQuery,
WorkbenchOverviewResponseDto,
WorkbenchTodoStatus,
WorkbenchTodoTask,
WorkbenchTrendPoint,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
interface MockCampusOverview {
@@ -252,222 +254,124 @@ function createRanking(
.sort((left, right) => right.signedCount - left.signedCount)
}
function countAvailableTemplates(templates: SigningTemplate[]) {
return new Set(templates.map((template) => template.id)).size
}
const TODO_LIMIT = 8
const RANKING_LIMIT = 10
let campusIds: Map<string, string> | null = null
let campusIdsRequest: Promise<Map<string, string>> | null = null
function countCoveredDepartments(templates: SigningTemplate[]) {
return new Set(
templates
.map((template) => {
if (template.departmentId) {
return `id:${template.departmentId}`
}
return template.department && template.department !== '全院通用'
? `name:${template.department}`
: null
})
.filter((department): department is string => Boolean(department)),
).size
}
async function getAllSigningTasks(
query: SigningTaskQuery,
options: SigningApiOptions,
): Promise<SigningTaskRecord[]> {
const pageSize = 200
const firstPage = await getSigningTasks({ ...query, page: 1, pageSize }, options)
const totalPages = Math.ceil(firstPage.total / Math.max(firstPage.pageSize, 1))
if (totalPages <= 1) {
return firstPage.records
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 remainingPages = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, index) =>
getSigningTasks({ ...query, page: index + 2, pageSize }, options),
),
)
return [firstPage, ...remainingPages].flatMap((page) => page.records)
}
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return value === '本部院区' || value === '东院区' || value === '西院区'
function mapSigningMethod(method: BackendSigningMethod): SigningMethod {
return method === 'SMS' ? 'sms' : 'pad'
}
function parseDate(value: string | undefined) {
if (!value) {
return null
}
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
return Number.isNaN(date.getTime()) ? null : date
}
function isSameDay(left: Date | null, right: Date) {
return Boolean(
left &&
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate(),
)
}
function getSignedDate(task: SigningTaskRecord) {
return parseDate(task.signedAt)
}
function getExpiredDate(task: SigningTaskRecord) {
return parseDate(task.expiresAt)
}
function getUpdatedDate(task: SigningTaskRecord) {
return parseDate(task.updatedAt) ?? parseDate(task.createdAt)
}
function isWithinPeriod(value: Date | null, today: Date, period: HomeRankingPeriod) {
if (!value) {
return false
}
const start = new Date(today)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - period + 1)
const end = new Date(today)
end.setHours(23, 59, 59, 999)
return value >= start && value <= end
}
function isPendingTask(task: SigningTaskRecord) {
return task.status === 'pending' || task.status === 'signing'
}
function createLiveOverview(
tasks: SigningTaskRecord[],
templateCount: number,
coveredDepartments: number,
rankingPeriod: HomeRankingPeriod,
): WorkbenchOverviewResponse {
const today = new Date()
const signedTasks = tasks.filter((task) => task.status === 'signed')
const todaySigned = signedTasks.filter((task) => isSameDay(getSignedDate(task), today)).length
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const yesterdaySigned = signedTasks.filter((task) =>
isSameDay(getSignedDate(task), yesterday),
).length
const pendingTasks = tasks.filter(isPendingTask)
const todayOverdue = tasks.filter(
(task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today),
).length
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
const date = new Date(today)
date.setHours(0, 0, 0, 0)
date.setDate(today.getDate() - rankingPeriod + index + 1)
return {
label: `${date.getMonth() + 1}/${date.getDate()}`,
value: signedTasks.filter((task) => isSameDay(getSignedDate(task), date)).length,
}
})
const todos = tasks
.filter((task) => isPendingTask(task) || task.status === 'expired')
.sort((left, right) => {
const leftDate = getUpdatedDate(left)?.getTime() ?? 0
const rightDate = getUpdatedDate(right)?.getTime() ?? 0
return rightDate - leftDate
})
.slice(0, 8)
.map((task) => ({
id: task.id,
patientId: task.patientId,
patientName: task.patientName,
documentName: task.documentName,
department: task.department,
status: task.status === 'expired' ? ('overdue' as const) : ('pending' as const),
method: task.method,
updatedAt: task.updatedAt,
}))
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
signedTasks
.filter((task) => isWithinPeriod(getSignedDate(task), today, rankingPeriod))
.forEach((task) => {
const current = rankingMap.get(task.documentId)
rankingMap.set(task.documentId, {
id: current?.id ?? task.documentId,
documentName: task.documentName,
department: task.department,
signedCount: (current?.signedCount ?? 0) + 1,
})
})
function mapWorkbenchOverview(dto: WorkbenchOverviewResponseDto): WorkbenchOverviewResponse {
const summary = dto.summary
return {
summary: {
todaySigned,
todaySignedChange: todaySigned - yesterdaySigned,
pendingPatientSigning: pendingTasks.length,
todayOverdue,
availableTemplates: templateCount,
coveredDepartments,
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,
todos,
documentRanking: [...rankingMap.values()]
.sort((left, right) => right.signedCount - left.signedCount)
.slice(0, 10),
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,
})),
}
}
export function getWorkbenchOverview(
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['本部院区']
return Promise.resolve({
return {
summary: { ...overview.summary },
trend: overview.trend.map((point) => ({ ...point })),
todos: overview.todos.map((task) => ({ ...task })),
documentRanking: createRanking(overview.ranking, query.rankingPeriod),
})
}
}
return getCampuses().then(async (campuses) => {
const campus = campuses.find((item) => item.name === query.campus)
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 },
)
if (!campus) {
throw new Error(`未找到院区:${query.campus}`)
}
const campusIds = Object.fromEntries(campuses.map((item) => [item.name, item.id]))
const campusNames = Object.fromEntries(
campuses.filter((item) => isWorkbenchCampus(item.name)).map((item) => [item.id, item.name]),
) as Record<string, WorkbenchCampus>
const templates = await getSigningTemplates({ campusId: campus.id })
const tasks = await getAllSigningTasks(
{
page: 1,
pageSize: 200,
status: 'all',
dateRange: 'all',
campus: query.campus,
method: 'all',
},
{ templates, campusIds, campusNames },
)
return createLiveOverview(
tasks,
countAvailableTemplates(templates),
countCoveredDepartments(templates),
query.rankingPeriod,
)
})
return mapWorkbenchOverview(unwrapApiResponse(response))
}
+48 -2
View File
@@ -1,14 +1,23 @@
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
export type WorkbenchCampus = string
export type HomeRankingPeriod = 7 | 14 | 30
export interface WorkbenchOverviewQuery {
campus: WorkbenchCampus
campusId?: string
rankingPeriod: HomeRankingPeriod
}
/** /api/v1/workbench/overview 的请求 DTO。 */
export interface WorkbenchOverviewRequestDto {
campusId: string
period: HomeRankingPeriod
todoLimit?: number
rankingLimit?: number
}
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
export type PatientSex = '男' | '女' | '未知'
@@ -72,7 +81,8 @@ export interface WorkbenchTask {
updatedAt: string
}
export type WorkbenchTodoStatus = 'pending' | 'overdue'
export type WorkbenchTodoStatus =
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
export type SigningMethod = 'pad' | 'sms'
@@ -106,6 +116,42 @@ export interface WorkbenchOverviewResponse {
documentRanking: WorkbenchDocumentRanking[]
}
/** 后端工作台概览原始响应,组件只使用 WorkbenchOverviewResponse。 */
export interface WorkbenchOverviewResponseDto {
campusId: string
timezone: string
generatedAt: string
summary?: Partial<WorkbenchSummary> | null
trend?: WorkbenchOverviewTrendPointDto[] | null
todos?: WorkbenchOverviewTodoItemDto[] | null
documentRanking?: WorkbenchDocumentRankingDto[] | null
}
export interface WorkbenchOverviewTrendPointDto {
date: string
label: string
signedCount: number
}
export interface WorkbenchOverviewTodoItemDto {
taskId: string
patientId: string
patientName: string
documentName: string
departmentName: string
status: BackendSigningTaskStatus
signMethod: BackendSigningMethod
updatedAt: string
expiredAt?: string | null
}
export interface WorkbenchDocumentRankingDto {
templateId: string
documentName: string
departmentName: string
signedCount: number
}
export interface SigningTaskQuery extends PageQuery {
keyword?: string
status?: SigningTaskStatus | 'all'