feat(management): align reports with signing APIs

This commit is contained in:
yelan
2026-09-02 09:04:24 +08:00
parent eaae2b624e
commit ac581801eb
7 changed files with 469 additions and 165 deletions
+68 -30
View File
@@ -1,38 +1,76 @@
import { request } from '@/utils/request' import { getCampuses, getDepartments } from './organization'
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing'
import type { WorkbenchCampus } from '@/api/workbench/types'
import type { ReportOverviewResponse, ReportQuery } from './types' import type { ReportTaskData } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' const REPORT_PAGE_SIZE = 200
const mockOverview: ReportOverviewResponse = { export const isReportsMockEnabled = isSigningMockEnabled
metrics: [
{ key: 'total', label: '签署总量', value: 1268, unit: '份', trend: 12.8 }, function isWorkbenchCampus(value: string): value is WorkbenchCampus {
{ key: 'signed', label: '已完成', value: 1142, unit: '份', trend: 8.6 }, return value === '本部院区' || value === '东院区' || value === '西院区'
{ key: 'rate', label: '完成率', value: 90.1, unit: '%', trend: 3.2 },
{ key: 'average', label: '平均耗时', value: 6.4, unit: '分钟', trend: -4.5 },
],
trend: [
{ date: '08-21', signed: 146, rejected: 5, pending: 12 },
{ date: '08-22', signed: 158, rejected: 4, pending: 10 },
{ date: '08-23', signed: 132, rejected: 7, pending: 15 },
{ date: '08-24', signed: 176, rejected: 3, pending: 9 },
{ date: '08-25', signed: 188, rejected: 6, pending: 13 },
{ date: '08-26', signed: 172, rejected: 5, pending: 11 },
{ date: '08-27', signed: 170, rejected: 4, pending: 8 },
],
departments: [
{ department: '消化内科', total: 328, signed: 305, completionRate: 93.0 },
{ department: '儿科急诊', total: 286, signed: 249, completionRate: 87.1 },
{ department: '普外科', total: 264, signed: 243, completionRate: 92.0 },
],
} }
export function getReportOverview(query: ReportQuery): Promise<ReportOverviewResponse> { function buildSigningApiOptions(data: ReportTaskData) {
if (useMockData) { return {
return Promise.resolve(mockOverview) templates: data.templates,
campusNames: Object.fromEntries(
data.campuses
.filter((campus) => isWorkbenchCampus(campus.name))
.map((campus) => [campus.id, campus.name]),
) as Record<string, WorkbenchCampus>,
}
} }
return request.get<ReportOverviewResponse>('/management/reports/overview', { async function getAllReportTasks(data: ReportTaskData) {
params: query, const options = buildSigningApiOptions(data)
}) const query = {
page: 1,
pageSize: REPORT_PAGE_SIZE,
status: 'all' as const,
dateRange: 'all' as const,
campus: 'all' as const,
method: 'all' as const,
}
const firstPage = await getSigningTasks(query, options)
const records = [...firstPage.records]
const pageSize = Math.max(firstPage.pageSize, 1)
const totalPages = Math.ceil(firstPage.total / pageSize)
for (let page = 2; page <= totalPages; page += 1) {
const nextPage = await getSigningTasks({ ...query, page }, options)
records.push(...nextPage.records)
if (!nextPage.records.length) {
break
}
}
return records
}
/**
* 查询报表所需的全部可见任务。
* 当前后端没有聚合报表接口,因此这里按最大页大小读取签署任务;聚合接口上线后只需替换本函数。
*/
export async function getReportTaskData(): Promise<ReportTaskData> {
if (isReportsMockEnabled) {
return { tasks: [], templates: [], campuses: [], departments: [] }
}
const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([
getSigningTemplates(),
getCampuses(),
getDepartments(),
])
const data: ReportTaskData = {
tasks: [],
templates: templatesResult.status === 'fulfilled' ? templatesResult.value : [],
campuses: campusesResult.status === 'fulfilled' ? campusesResult.value : [],
departments: departmentsResult.status === 'fulfilled' ? departmentsResult.value : [],
}
data.tasks = await getAllReportTasks(data)
return data
} }
+10 -33
View File
@@ -1,4 +1,5 @@
import type { PageQuery, PageResult } from '@/types/common' import type { PageQuery, PageResult } from '@/types/common'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review' export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review'
@@ -260,39 +261,15 @@ export interface OrganizationResponseDto {
departmentName?: string | null departmentName?: string | null
} }
export type ReportPeriod = 'today' | 'week' | 'month' | 'year' /**
* 报表当前由签署任务列表、模板和组织字典在前端聚合。
export interface ReportQuery { * 后端提供聚合报表接口后,可在 reports.ts 的边界替换为对应 DTO,页面无需感知。
period: ReportPeriod */
department?: string export interface ReportTaskData {
} tasks: SigningTaskRecord[]
templates: SigningTemplate[]
export interface ReportMetric { campuses: CampusRecord[]
key: string departments: DepartmentRecord[]
label: string
value: number
unit: string
trend: number
}
export interface ReportTrendPoint {
date: string
signed: number
rejected: number
pending: number
}
export interface ReportDepartment {
department: string
total: number
signed: number
completionRate: number
}
export interface ReportOverviewResponse {
metrics: ReportMetric[]
trend: ReportTrendPoint[]
departments: ReportDepartment[]
} }
export type UserStatus = 'enabled' | 'disabled' export type UserStatus = 'enabled' | 'disabled'
@@ -14,6 +14,7 @@ const statusLabels: Record<ReportTaskStatus, string> = {
pending: '待签署', pending: '待签署',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败',
} }
function getStatusLabel(row: ReportTask) { function getStatusLabel(row: ReportTask) {
@@ -154,6 +155,11 @@ function formatTime(date: Date) {
background: #eceef0; background: #eceef0;
} }
.status-failed {
color: var(--err);
background: #fff0f0;
}
.view-button { .view-button {
padding: 4px 10px; padding: 4px 10px;
color: var(--brand); color: var(--brand);
@@ -11,6 +11,8 @@ defineProps<{
campus: ReportCampus campus: ReportCampus
department: string department: string
category: ReportDocumentCategory category: ReportDocumentCategory
customFrom: string
customTo: string
periodOptions: ReportSelectOption<ReportPeriodOption>[] periodOptions: ReportSelectOption<ReportPeriodOption>[]
campusOptions: ReportSelectOption<ReportCampus>[] campusOptions: ReportSelectOption<ReportCampus>[]
departmentOptions: ReportSelectOption[] departmentOptions: ReportSelectOption[]
@@ -22,6 +24,8 @@ const emit = defineEmits<{
'update:campus': [value: ReportCampus] 'update:campus': [value: ReportCampus]
'update:department': [value: string] 'update:department': [value: string]
'update:category': [value: ReportDocumentCategory] 'update:category': [value: ReportDocumentCategory]
'update:customFrom': [value: string]
'update:customTo': [value: string]
export: [] export: []
}>() }>()
@@ -85,6 +89,27 @@ function handleCategoryChange(event: Event) {
</select> </select>
</label> </label>
<template v-if="period === 'custom'">
<label class="filter-field date-field">
<span>起始</span>
<input
type="date"
:value="customFrom"
aria-label="选择报表起始日期"
@change="emit('update:customFrom', readSelectValue($event))"
/>
</label>
<label class="filter-field date-field">
<span>结束</span>
<input
type="date"
:value="customTo"
aria-label="选择报表结束日期"
@change="emit('update:customTo', readSelectValue($event))"
/>
</label>
</template>
<span class="filter-spacer" /> <span class="filter-spacer" />
<button type="button" class="export-button" @click="emit('export')">导出</button> <button type="button" class="export-button" @click="emit('export')">导出</button>
</div> </div>
@@ -126,6 +151,21 @@ function handleCategoryChange(event: Event) {
border-radius: 6px; border-radius: 6px;
} }
.filter-field input {
width: 132px;
padding: 7px 8px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.filter-field input:focus {
outline: none;
border-color: var(--brand);
}
.filter-field select:focus { .filter-field select:focus {
outline: none; outline: none;
border-color: var(--brand); border-color: var(--brand);
@@ -3,9 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getCampuses, getDepartments } from '@/api/management/organization' import { getReportTaskData, isReportsMockEnabled } from '@/api/management/reports'
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing' import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import type { SigningTaskRecord, SigningTemplate, WorkbenchCampus } from '@/api/workbench/types'
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue' import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
import ReportDetailTable from './components/ReportDetailTable.vue' import ReportDetailTable from './components/ReportDetailTable.vue'
@@ -33,9 +32,13 @@ import type {
ReportTrendPoint, ReportTrendPoint,
} from './types' } from './types'
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000
const loading = ref(true) const loading = ref(true)
const errorMessage = ref('')
const tasks = ref<ReportTask[]>([]) const tasks = ref<ReportTask[]>([])
const departmentOptions = ref<ReportSelectOption[]>(reportDepartmentOptions) const departmentOptions = ref<ReportSelectOption[]>(reportDepartmentOptions)
const campusOptions = ref<ReportSelectOption<ReportCampus>[]>(reportCampusOptions)
const router = useRouter() const router = useRouter()
const filters = reactive<ReportFilterForm>({ const filters = reactive<ReportFilterForm>({
@@ -43,53 +46,143 @@ const filters = reactive<ReportFilterForm>({
campus: 'all', campus: 'all',
department: 'all', department: 'all',
category: 'all', category: 'all',
customFrom: '',
customTo: '',
})
interface ReportDateRange {
start: Date
end: Date
}
const customDateError = computed(() => {
if (filters.period !== 'custom') {
return ''
}
if (!filters.customFrom || !filters.customTo) {
return '请选择完整的自定义日期范围'
}
if (filters.customFrom > filters.customTo) {
return '起始日期不能晚于结束日期'
}
return ''
})
const selectedDateRange = computed<ReportDateRange | null>(() => {
if (customDateError.value) {
return null
}
const now = new Date()
if (filters.period === 'custom') {
const start = parseDateInput(filters.customFrom)
const end = parseDateInput(filters.customTo, true)
return start && end ? { start, end } : null
}
const start = startOfDay(now)
if (filters.period === 'month') {
start.setDate(1)
} else {
const days = filters.period === '7d' ? 7 : 14
start.setDate(start.getDate() - days + 1)
}
return { start, end: endOfDay(now) }
}) })
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
const startDate = getStartDate(filters.period) const range = selectedDateRange.value
if (!range) {
return []
}
return tasks.value.filter((task) => { return tasks.value.filter((task) => {
const matchesDate = !startDate || task.createdAt >= startDate return isDateWithin(task.createdAt, range) && matchesTaskScope(task)
const matchesCampus = filters.campus === 'all' || task.campus === filters.campus
const matchesDepartment = filters.department === 'all' || task.department === filters.department
const matchesCategory = filters.category === 'all' || task.category === filters.category
return matchesDate && matchesCampus && matchesDepartment && matchesCategory
}) })
}) })
const completedTasks = computed(() => {
const range = selectedDateRange.value
if (!range) {
return []
}
return tasks.value.filter(
(task) =>
task.status === 'signed' &&
task.signedAt &&
isDateWithin(task.signedAt, range) &&
matchesTaskScope(task),
)
})
const expiredTasks = computed(() => {
const range = selectedDateRange.value
if (!range) {
return []
}
return tasks.value.filter(
(task) =>
task.status === 'expired' &&
task.expiredAt &&
isDateWithin(task.expiredAt, range) &&
matchesTaskScope(task),
)
})
const metrics = computed<ReportMetricCardData[]>(() => { const metrics = computed<ReportMetricCardData[]>(() => {
const list = filteredTasks.value const completed = completedTasks.value
const signedTasks = list.filter((task) => task.status === 'signed') const terminalTasks = [...completed, ...expiredTasks.value]
const completionRate = list.length ? Math.round((signedTasks.length / list.length) * 100) : 0 const onTimeCount = completed.filter(isOnTimeTask).length
const averageMinutes = signedTasks.length const completionRate = terminalTasks.length
? Math.round( ? `${Math.round((onTimeCount / terminalTasks.length) * 100)}%`
signedTasks.reduce((total, task) => total + (task.durationMinutes ?? 7), 0) / : '—'
signedTasks.length, const durations = completed
) .map((task) => task.durationMinutes)
: 0 .filter((value): value is number => value !== undefined && Number.isFinite(value))
const averageMinutes = durations.length
? Math.round(durations.reduce((total, value) => total + value, 0) / durations.length)
: '—'
return [ return [
{ key: 'total', label: '累计发起任务', value: list.length, tone: 'brand' }, { key: 'total', label: '累计发起任务', value: filteredTasks.value.length, tone: 'brand' },
{ key: 'signed', label: '签署完成', value: signedTasks.length, tone: 'ok' }, { key: 'signed', label: '签署完成', value: completed.length, tone: 'ok' },
{ key: 'rate', label: '按时签署率', value: `${completionRate}%`, tone: 'info' }, { key: 'rate', label: '按时签署率', value: completionRate, tone: 'info' },
{ key: 'average', label: '平均签署用时(分钟)', value: averageMinutes, tone: 'warn' }, { key: 'average', label: '平均签署用时(分钟)', value: averageMinutes, tone: 'warn' },
] ]
}) })
const trendPoints = computed<ReportTrendPoint[]>(() => { const trendPoints = computed<ReportTrendPoint[]>(() => {
const days = filters.period === '7d' ? 7 : 14 const range = selectedDateRange.value
const today = startOfDay(new Date())
const signedTasks = filteredTasks.value.filter((task) => task.status === 'signed')
return Array.from({ length: days }, (_, index) => { if (!range) {
const date = new Date(today) return []
date.setDate(date.getDate() - (days - index - 1)) }
const start = startOfDay(range.start)
const end = startOfDay(range.end)
const days = Math.floor((end.getTime() - start.getTime()) / DAY_IN_MILLISECONDS) + 1
return Array.from({ length: Math.max(days, 0) }, (_, index) => {
const date = new Date(start)
date.setDate(date.getDate() + index)
return { return {
date: date.toISOString(), date: formatDateKey(date),
label: `${date.getMonth() + 1}/${date.getDate()}`, label: `${date.getMonth() + 1}/${date.getDate()}`,
value: signedTasks.filter((task) => isSameDay(task.createdAt, date)).length, value: completedTasks.value.filter((task) => task.signedAt && isSameDay(task.signedAt, date))
.length,
} }
}) })
}) })
@@ -97,7 +190,7 @@ const trendPoints = computed<ReportTrendPoint[]>(() => {
const departmentPoints = computed<ReportDepartmentPoint[]>(() => { const departmentPoints = computed<ReportDepartmentPoint[]>(() => {
const counts = new Map<string, number>() const counts = new Map<string, number>()
filteredTasks.value.forEach((task) => { completedTasks.value.forEach((task) => {
counts.set(task.department, (counts.get(task.department) ?? 0) + 1) counts.set(task.department, (counts.get(task.department) ?? 0) + 1)
}) })
@@ -116,29 +209,63 @@ function startOfDay(date: Date) {
return result return result
} }
function getStartDate(period: ReportPeriodOption) { function endOfDay(date: Date) {
if (period === 'custom') { const result = new Date(date)
result.setHours(23, 59, 59, 999)
return result
}
function parseDateInput(value: string, end = false) {
if (!value) {
return null return null
} }
if (period === 'month') { const date = new Date(`${value}T${end ? '23:59:59.999' : '00:00:00'}`)
const result = startOfDay(new Date()) return Number.isNaN(date.getTime()) ? null : date
result.setDate(1)
return result
} }
const days = period === '7d' ? 7 : 14 function isDateWithin(value: Date | undefined, range: ReportDateRange) {
const result = startOfDay(new Date()) if (!value) {
result.setDate(result.getDate() - days + 1) return false
return result }
const timestamp = value.getTime()
return timestamp >= range.start.getTime() && timestamp <= range.end.getTime()
} }
function isSameDay(left: Date, right: Date) { function isSameDay(left: Date, right: Date) {
return startOfDay(left).getTime() === startOfDay(right).getTime() return formatDateKey(left) === formatDateKey(right)
}
function formatDateKey(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function matchesTaskScope(task: ReportTask) {
const matchesCampus = filters.campus === 'all' || task.campus === filters.campus
const matchesDepartment = filters.department === 'all' || task.department === filters.department
const matchesCategory = filters.category === 'all' || task.category === filters.category
return matchesCampus && matchesDepartment && matchesCategory
}
function isOnTimeTask(task: ReportTask) {
return Boolean(
task.signedAt && task.expiredAt && task.signedAt.getTime() <= task.expiredAt.getTime(),
)
} }
function updatePeriod(value: ReportPeriodOption) { function updatePeriod(value: ReportPeriodOption) {
filters.period = value filters.period = value
if (value === 'custom' && (!filters.customFrom || !filters.customTo)) {
const end = new Date()
const start = new Date(end)
start.setDate(start.getDate() - 13)
filters.customFrom = formatDateKey(start)
filters.customTo = formatDateKey(end)
}
} }
function updateCampus(value: ReportCampus) { function updateCampus(value: ReportCampus) {
@@ -153,9 +280,27 @@ function updateCategory(value: ReportDocumentCategory) {
filters.category = value filters.category = value
} }
function updateCustomFrom(value: string) {
filters.customFrom = value
}
function updateCustomTo(value: string) {
filters.customTo = value
}
function handleExport() { function handleExport() {
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"` const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const header = ['任务号', '文档', '科室', '院区', '方式', '状态', '发起时间'] const header = [
'任务号',
'文档',
'科室',
'院区',
'方式',
'状态',
'发起时间',
'签署完成时间',
'过期时间',
]
const rows = filteredTasks.value.map((task) => [ const rows = filteredTasks.value.map((task) => [
task.id, task.id,
task.documentName, task.documentName,
@@ -164,14 +309,18 @@ function handleExport() {
task.method === 'pad' ? '手写板' : '短信', task.method === 'pad' ? '手写板' : '短信',
getReportStatusLabel(task.status), getReportStatusLabel(task.status),
formatExportDate(task.createdAt), formatExportDate(task.createdAt),
formatExportDate(task.signedAt),
formatExportDate(task.expiredAt),
]) ])
const csv = `\uFEFF${[header, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n')}` const csv = `\uFEFF${[header, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n')}`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `医签通签署报表-${new Date().toISOString().slice(0, 10)}.csv` link.download = `医签通签署报表-${formatDateKey(new Date())}.csv`
document.body.append(link)
link.click() link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0) window.setTimeout(() => URL.revokeObjectURL(url), 0)
ElMessage.success('报表已导出') ElMessage.success('报表已导出')
} }
@@ -186,12 +335,17 @@ function getReportStatusLabel(status: ReportTaskStatus) {
pending: '待签署', pending: '待签署',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败',
} }
return labels[status] return labels[status]
} }
function formatExportDate(value: Date) { function formatExportDate(value: Date | undefined) {
if (!value) {
return ''
}
const pad = (part: number) => String(part).padStart(2, '0') const pad = (part: number) => String(part).padStart(2, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad( return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(
value.getHours(), value.getHours(),
@@ -215,19 +369,43 @@ function mapCategory(
return 'consent' return 'consent'
} }
function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): ReportTask { function parseApiDate(value: string | undefined) {
if (!value) {
return undefined
}
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
return Number.isNaN(date.getTime()) ? undefined : date
}
function mapReportTask(
task: SigningTaskRecord,
templates: SigningTemplate[],
campusNames: Record<string, string>,
): ReportTask | null {
const template = templates.find( const template = templates.find(
(item) => item.versionId === task.templateVersionId || item.id === task.documentId, (item) => item.versionId === task.templateVersionId || item.id === task.documentId,
) )
const createdAt = new Date((task.createdAt ?? task.updatedAt).replace(' ', 'T')) const createdAt = parseApiDate(task.createdAt)
const normalizedCreatedAt = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt
if (!createdAt) {
return null
}
const signedAt = parseApiDate(task.signedAt)
const expiredAt = parseApiDate(task.expiresAt)
const durationMinutes = signedAt
? Math.max(0, Math.round((signedAt.getTime() - createdAt.getTime()) / 60_000))
: undefined
const status: ReportTask['status'] = const status: ReportTask['status'] =
task.status === 'signed' task.status === 'signed'
? 'signed' ? 'signed'
: task.status === 'expired' : task.status === 'expired'
? 'expired' ? 'expired'
: task.status === 'void' || task.status === 'failed' : task.status === 'void'
? 'void' ? 'void'
: task.status === 'failed'
? 'failed'
: 'pending' : 'pending'
return { return {
@@ -236,74 +414,75 @@ function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): R
patientId: task.patientId, patientId: task.patientId,
documentName: task.documentName, documentName: task.documentName,
department: task.department, department: task.department,
campus: task.campus as Exclude<ReportCampus, 'all'>, campus: task.campusId ? (campusNames[task.campusId] ?? task.campus) : task.campus,
campusId: task.campusId,
category: mapCategory(template?.category ?? '', task.documentName), category: mapCategory(template?.category ?? '', task.documentName),
method: task.method, method: task.method,
status, status,
createdAt: normalizedCreatedAt, createdAt,
signedAt,
expiredAt,
durationMinutes,
} }
} }
function isWorkbenchCampus(value: string): value is WorkbenchCampus { function getDepartmentOptions(
return value === '本部院区' || value === '东院区' || value === '西院区' departments: Array<{ name: string }>,
} reportTaskRecords: ReportTask[],
) {
const names = new Set(departments.map((department) => department.name))
reportTaskRecords.forEach((task) => names.add(task.department))
async function loadReports() { return [
loading.value = true
try {
if (isSigningMockEnabled) {
tasks.value = reportTasks.map((task) => ({
...task,
createdAt: new Date(task.createdAt),
}))
departmentOptions.value = reportDepartmentOptions
return
}
const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([
getSigningTemplates(),
getCampuses(),
getDepartments(),
])
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : []
const campusNames: Record<string, WorkbenchCampus> =
campusesResult.status === 'fulfilled'
? (Object.fromEntries(
campusesResult.value
.filter((campus) => isWorkbenchCampus(campus.name))
.map((campus) => [campus.id, campus.name]),
) as Record<string, WorkbenchCampus>)
: {}
const taskResponse = await getSigningTasks(
{
page: 1,
pageSize: 200,
status: 'all',
dateRange: 'all',
campus: 'all',
method: 'all',
},
{ templates, campusNames },
)
tasks.value = taskResponse.records.map((task) => mapReportTask(task, templates))
const departmentNames = new Set<string>(
departmentsResult.status === 'fulfilled'
? departmentsResult.value.map((department) => department.name)
: [],
)
tasks.value.forEach((task) => departmentNames.add(task.department))
departmentOptions.value = [
{ value: 'all', label: '全部科室' }, { value: 'all', label: '全部科室' },
...[...departmentNames] ...[...names]
.filter((name) => name && name !== '未指定科室') .filter((name) => name && name !== '未指定科室')
.sort((left, right) => left.localeCompare(right, 'zh-CN')) .sort((left, right) => left.localeCompare(right, 'zh-CN'))
.map((name) => ({ value: name, label: name })), .map((name) => ({ value: name, label: name })),
] ]
}
function getCampusOptions(campuses: Array<{ name: string }>) {
const names = [...new Set(campuses.map((campus) => campus.name).filter(Boolean))]
return names.length
? [
{ value: 'all' as ReportCampus, label: '全部院区' },
...names.map((name) => ({ value: name as ReportCampus, label: name })),
]
: reportCampusOptions
}
async function loadReports() {
loading.value = true
errorMessage.value = ''
try {
if (isReportsMockEnabled) {
tasks.value = reportTasks.map((task) => ({
...task,
createdAt: new Date(task.createdAt),
signedAt: task.signedAt ? new Date(task.signedAt) : undefined,
expiredAt: task.expiredAt ? new Date(task.expiredAt) : undefined,
}))
departmentOptions.value = reportDepartmentOptions
campusOptions.value = reportCampusOptions
return
}
const data = await getReportTaskData()
const campusNames = Object.fromEntries(data.campuses.map((campus) => [campus.id, campus.name]))
const reportTaskRecords = data.tasks
.map((task) => mapReportTask(task, data.templates, campusNames))
.filter((task): task is ReportTask => task !== null)
tasks.value = reportTaskRecords
departmentOptions.value = getDepartmentOptions(data.departments, reportTaskRecords)
campusOptions.value = getCampusOptions(data.campuses)
} catch { } catch {
tasks.value = [] tasks.value = []
ElMessage.error('报表数据加载失败,请稍后重试') errorMessage.value = '报表数据加载失败,请稍后重试'
ElMessage.error(errorMessage.value)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -319,21 +498,32 @@ onMounted(loadReports)
:campus="filters.campus" :campus="filters.campus"
:department="filters.department" :department="filters.department"
:category="filters.category" :category="filters.category"
:custom-from="filters.customFrom"
:custom-to="filters.customTo"
:period-options="reportPeriodOptions" :period-options="reportPeriodOptions"
:campus-options="reportCampusOptions" :campus-options="campusOptions"
:department-options="departmentOptions" :department-options="departmentOptions"
:category-options="reportCategoryOptions" :category-options="reportCategoryOptions"
@update:period="updatePeriod" @update:period="updatePeriod"
@update:campus="updateCampus" @update:campus="updateCampus"
@update:department="updateDepartment" @update:department="updateDepartment"
@update:category="updateCategory" @update:category="updateCategory"
@update:custom-from="updateCustomFrom"
@update:custom-to="updateCustomTo"
@export="handleExport" @export="handleExport"
/> />
<p v-if="customDateError" class="filter-error" role="alert">{{ customDateError }}</p>
<div v-if="loading" class="loading-card" aria-label="正在加载报表"> <div v-if="loading" class="loading-card" aria-label="正在加载报表">
<span v-for="index in 4" :key="index" /> <span v-for="index in 4" :key="index" />
</div> </div>
<div v-else-if="errorMessage" class="error-card" role="alert">
<p>{{ errorMessage }}</p>
<button type="button" @click="loadReports">重试</button>
</div>
<template v-else> <template v-else>
<div class="metric-grid"> <div class="metric-grid">
<ReportMetricCard v-for="metric in metrics" :key="metric.key" :metric="metric" /> <ReportMetricCard v-for="metric in metrics" :key="metric.key" :metric="metric" />
@@ -426,6 +616,15 @@ onMounted(loadReports)
font-size: 12px; font-size: 12px;
} }
.filter-error {
padding: 8px 12px;
margin: -8px 0 16px;
color: var(--err);
font-size: 12px;
background: var(--err-l);
border-radius: 6px;
}
.loading-card { .loading-card {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -442,6 +641,33 @@ onMounted(loadReports)
animation: skeleton-shimmer 1.4s ease infinite; animation: skeleton-shimmer 1.4s ease infinite;
} }
.error-card {
display: flex;
gap: 16px;
align-items: center;
justify-content: center;
min-height: 160px;
padding: 24px;
color: var(--err);
background: var(--card);
border: 1px solid var(--err-l);
border-radius: var(--r);
box-shadow: var(--sh);
}
.error-card p {
margin: 0;
}
.error-card button {
padding: 6px 14px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
@keyframes skeleton-shimmer { @keyframes skeleton-shimmer {
0% { 0% {
background-position: 100% 0; background-position: 100% 0;
@@ -56,6 +56,8 @@ export const reportTasks: ReportTask[] = [
method: 'pad', method: 'pad',
status: 'signed', status: 'signed',
createdAt: daysAgo(0, 9, 12), createdAt: daysAgo(0, 9, 12),
signedAt: daysAgo(0, 9, 19),
expiredAt: daysAgo(-1, 9, 12),
durationMinutes: 7, durationMinutes: 7,
}, },
{ {
@@ -93,6 +95,8 @@ export const reportTasks: ReportTask[] = [
method: 'pad', method: 'pad',
status: 'signed', status: 'signed',
createdAt: daysAgo(1, 15, 40), createdAt: daysAgo(1, 15, 40),
signedAt: daysAgo(1, 15, 47),
expiredAt: daysAgo(0, 15, 40),
durationMinutes: 7, durationMinutes: 7,
}, },
{ {
@@ -106,6 +110,8 @@ export const reportTasks: ReportTask[] = [
method: 'sms', method: 'sms',
status: 'signed', status: 'signed',
createdAt: daysAgo(1, 9, 30), createdAt: daysAgo(1, 9, 30),
signedAt: daysAgo(1, 9, 37),
expiredAt: daysAgo(0, 9, 30),
durationMinutes: 7, durationMinutes: 7,
}, },
{ {
@@ -119,6 +125,8 @@ export const reportTasks: ReportTask[] = [
method: 'pad', method: 'pad',
status: 'signed', status: 'signed',
createdAt: daysAgo(2, 10, 15), createdAt: daysAgo(2, 10, 15),
signedAt: daysAgo(2, 10, 22),
expiredAt: daysAgo(1, 10, 15),
durationMinutes: 7, durationMinutes: 7,
}, },
{ {
@@ -132,6 +140,8 @@ export const reportTasks: ReportTask[] = [
method: 'pad', method: 'pad',
status: 'signed', status: 'signed',
createdAt: daysAgo(3, 14, 25), createdAt: daysAgo(3, 14, 25),
signedAt: daysAgo(3, 14, 32),
expiredAt: daysAgo(2, 14, 25),
durationMinutes: 7, durationMinutes: 7,
}, },
{ {
@@ -145,6 +155,7 @@ export const reportTasks: ReportTask[] = [
method: 'sms', method: 'sms',
status: 'expired', status: 'expired',
createdAt: daysAgo(4, 16, 0), createdAt: daysAgo(4, 16, 0),
expiredAt: daysAgo(4, 16, 0),
}, },
{ {
id: 'T0814001', id: 'T0814001',
@@ -1,6 +1,7 @@
export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom' export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
export type ReportCampus = 'all' | '本部院区' | '东院区' | '西院区' /** 院区由后端字典提供,保留 all 作为全院筛选值。 */
export type ReportCampus = 'all' | (string & {})
export type ReportDocumentCategory = 'all' | 'consent' | 'risk' | 'admission' export type ReportDocumentCategory = 'all' | 'consent' | 'risk' | 'admission'
@@ -9,6 +10,8 @@ export interface ReportFilterForm {
campus: ReportCampus campus: ReportCampus
department: string department: string
category: ReportDocumentCategory category: ReportDocumentCategory
customFrom: string
customTo: string
} }
export type ReportMetricTone = 'brand' | 'ok' | 'info' | 'warn' export type ReportMetricTone = 'brand' | 'ok' | 'info' | 'warn'
@@ -21,7 +24,7 @@ export interface ReportMetricCard {
tone: ReportMetricTone tone: ReportMetricTone
} }
export type ReportTaskStatus = 'signed' | 'pending' | 'expired' | 'void' export type ReportTaskStatus = 'signed' | 'pending' | 'expired' | 'void' | 'failed'
export type ReportSigningMethod = 'pad' | 'sms' export type ReportSigningMethod = 'pad' | 'sms'
@@ -32,10 +35,13 @@ export interface ReportTask {
documentName: string documentName: string
department: string department: string
campus: Exclude<ReportCampus, 'all'> campus: Exclude<ReportCampus, 'all'>
campusId?: string
category: Exclude<ReportDocumentCategory, 'all'> category: Exclude<ReportDocumentCategory, 'all'>
method: ReportSigningMethod method: ReportSigningMethod
status: ReportTaskStatus status: ReportTaskStatus
createdAt: Date createdAt: Date
signedAt?: Date
expiredAt?: Date
durationMinutes?: number durationMinutes?: number
} }