Files
xh-medical-sign-web/clinical-web/src/views/management/reports/index.vue
T
2026-09-29 18:49:45 +08:00

1306 lines
33 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import {
createReportExportJob,
downloadReportExportFile,
getReportExportJob,
getReportOptions,
getReportOverview,
getReportTasks,
isReportsMockEnabled,
} from '@/api/management/reports'
import type {
ReportExportJobResponseDto,
ReportExportRequestDto,
ReportFilterDto,
ReportOptionsResponseDto,
ReportOverviewResponseDto,
ReportTaskResponseDto,
ReportTasksQueryDto,
} from '@/api/management/types'
import { useLoginStore } from '@/stores/login'
import {
formatSigningMethodTitle,
REPORT_METHOD_FILTERS,
toBackendSigningMethod,
} from '@/utils/signing-method'
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
import ReportDetailTable from './components/ReportDetailTable.vue'
import ReportFilterBar from './components/ReportFilterBar.vue'
import ReportMetricCard from './components/ReportMetricCard.vue'
import SignatureTrendChart from './components/SignatureTrendChart.vue'
import {
reportCampusOptions,
reportCategoryOptions,
reportDepartmentColors,
reportDepartmentOptions,
reportPeriodOptions,
reportTasks,
} from './mock'
import type {
ReportCampus,
ReportDepartmentPoint,
ReportDocumentCategory,
ReportFilterForm,
ReportMethodFilter,
ReportMetricCard as ReportMetricCardData,
ReportPeriodOption,
ReportSelectOption,
ReportTask,
ReportTaskStatus,
ReportTrendPoint,
} from './types'
const REPORT_TIMEZONE = 'Asia/Shanghai'
const REPORT_PAGE_SIZE_OPTIONS = [20, 50, 100]
const EXPORT_POLL_INTERVAL_MS = 1000
const EXPORT_MAX_POLLS = 120
const loading = ref(true)
const errorMessage = ref('')
const tableErrorMessage = ref('')
const optionsErrorMessage = ref('')
const tasks = ref<ReportTask[]>([])
const reportOptions = ref<ReportOptionsResponseDto>({
campuses: [],
departments: [],
templateCategories: [],
})
const remoteOverview = ref<ReportOverviewResponseDto | null>(null)
const remoteTaskRows = ref<ReportTask[]>([])
const currentPage = ref(1)
const pageSize = ref(REPORT_PAGE_SIZE_OPTIONS[0])
const remoteTaskTotal = ref(0)
const remoteTaskPages = ref(0)
const taskLoading = ref(false)
const optionsLoaded = ref(false)
const exportLoading = ref(false)
const exportDownloadLoading = ref(false)
const exportJob = ref<ReportExportJobResponseDto | null>(null)
const exportErrorMessage = ref('')
const router = useRouter()
const loginStore = useLoginStore()
const canExportReport = computed(
() =>
!loginStore.isReadOnly &&
(isReportsMockEnabled || loginStore.hasWritePermission('report:sign:export')),
)
let reportRequestId = 0
let taskRequestId = 0
let exportPollId = 0
const filters = reactive<ReportFilterForm>({
period: '14d',
campus: 'all',
department: 'all',
category: 'all',
method: 'all',
keyword: '',
customFrom: '',
customTo: '',
})
const methodOptions: ReportSelectOption<ReportMethodFilter>[] = [
{ value: 'all', label: '全部方式' },
...REPORT_METHOD_FILTERS.map((option) => ({ value: option.value, label: option.label })),
]
interface ReportDateRange {
from: string
to: string
}
const customDateError = computed(() => {
if (filters.period !== 'custom') {
return ''
}
if (!filters.customFrom || !filters.customTo) {
return '请选择完整的自定义日期范围'
}
if (filters.customFrom > filters.customTo) {
return '起始日期不能晚于结束日期'
}
return ''
})
const campusOptions = computed<ReportSelectOption<ReportCampus>[]>(() => {
if (isReportsMockEnabled) {
return reportCampusOptions
}
return [
{ value: 'all', label: '全部院区' },
...reportOptions.value.campuses
.filter((campus) => campus.id && campus.name)
.map((campus) => ({ value: campus.id as ReportCampus, label: campus.name })),
]
})
const departmentOptions = computed<ReportSelectOption[]>(() => {
if (isReportsMockEnabled) {
return reportDepartmentOptions
}
const departments = reportOptions.value.departments.filter(
(department) => filters.campus === 'all' || department.campusId === filters.campus,
)
return [
{ value: 'all', label: '全部科室' },
...departments
.filter((department) => department.id && department.name)
.map((department) => ({ value: department.id, label: department.name })),
]
})
const categoryOptions = computed<ReportSelectOption<ReportDocumentCategory>[]>(() => {
if (isReportsMockEnabled) {
return reportCategoryOptions
}
return [
{ value: 'all', label: '全部类别' },
...reportOptions.value.templateCategories.filter(Boolean).map((category) => ({
value: category as ReportDocumentCategory,
label: category,
})),
]
})
const filteredMockTasks = computed(() => {
const range = getReportDateRange()
if (!range) {
return []
}
return tasks.value.filter((task) => isDateWithin(task.createdAt, range) && matchesTaskScope(task))
})
const completedMockTasks = computed(() => {
const range = getReportDateRange()
if (!range) {
return []
}
return tasks.value.filter(
(task) =>
task.status === 'signed' &&
task.signedAt &&
isDateWithin(task.signedAt, range) &&
matchesTaskScope(task),
)
})
const metrics = computed<ReportMetricCardData[]>(() => {
const remoteMetrics = remoteOverview.value?.metrics
if (!isReportsMockEnabled && remoteMetrics) {
return [
{
key: 'total',
label: '累计发起任务',
value: remoteMetrics.initiatedCount,
tone: 'brand',
},
{ key: 'signed', label: '签署完成', value: remoteMetrics.signedCount, tone: 'ok' },
{
key: 'rate',
label: '按时签署率',
value: formatRate(remoteMetrics.onTimeRate),
tone: 'info',
},
{
key: 'average',
label: '平均签署用时',
value: formatAverageMinutes(remoteMetrics.averageSignDurationSeconds),
unit: '分钟',
tone: 'warn',
},
]
}
const completed = completedMockTasks.value
const onTimeSamples = completed.filter((task) => task.signedAt && task.expiredAt)
const onTimeCount = onTimeSamples.filter(isOnTimeTask).length
const completionRate = onTimeSamples.length
? `${Math.round((onTimeCount / onTimeSamples.length) * 100)}%`
: '—'
const durations = completed
.map(getTaskDurationSeconds)
.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 / 60) * 10,
) / 10
: '—'
return [
{ key: 'total', label: '累计发起任务', value: filteredMockTasks.value.length, tone: 'brand' },
{ key: 'signed', label: '签署完成', value: completed.length, tone: 'ok' },
{ key: 'rate', label: '按时签署率', value: completionRate, tone: 'info' },
{ key: 'average', label: '平均签署用时', value: averageMinutes, unit: '分钟', tone: 'warn' },
]
})
const trendPoints = computed<ReportTrendPoint[]>(() => {
if (!isReportsMockEnabled && remoteOverview.value) {
return remoteOverview.value.trend.map((point) => ({
date: point.date,
label: point.label,
value: point.signedCount,
}))
}
const range = getReportDateRange()
if (!range) {
return []
}
return getDateKeys(range).map((date) => ({
date,
label: formatDateLabel(date),
value: completedMockTasks.value.filter(
(task) => task.signedAt && getShanghaiDateKey(task.signedAt) === date,
).length,
}))
})
const departmentPoints = computed<ReportDepartmentPoint[]>(() => {
if (!isReportsMockEnabled && remoteOverview.value) {
return remoteOverview.value.departments
.map((department) => ({
department: department.departmentName || '未指定科室',
value: department.signedCount ?? 0,
color: getDepartmentColor(department.departmentName || '未指定科室'),
}))
.filter((point) => point.value > 0)
.sort((left, right) => right.value - left.value)
}
const counts = new Map<string, number>()
completedMockTasks.value.forEach((task) => {
counts.set(task.department, (counts.get(task.department) ?? 0) + 1)
})
return [...counts.entries()]
.map(([department, value]) => ({
department,
value,
color: getDepartmentColor(department),
}))
.sort((left, right) => right.value - left.value)
})
const mockPageRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredMockTasks.value.slice(start, start + pageSize.value)
})
const visibleRows = computed(() =>
isReportsMockEnabled ? mockPageRows.value : remoteTaskRows.value,
)
const totalRows = computed(() =>
isReportsMockEnabled ? filteredMockTasks.value.length : remoteTaskTotal.value,
)
const totalPages = computed(() =>
isReportsMockEnabled
? Math.ceil(filteredMockTasks.value.length / pageSize.value)
: remoteTaskPages.value,
)
const reportSnapshotText = computed(() => {
const snapshotDate = parseApiDate(remoteOverview.value?.snapshotAt)
return snapshotDate ? `数据快照:${formatExportDate(snapshotDate)}` : ''
})
const exportStatusText = computed(() => {
if (exportErrorMessage.value) {
return `导出失败:${exportErrorMessage.value}`
}
if (exportDownloadLoading.value) {
return '正在下载报表…'
}
const status = exportJob.value?.status
if (status === 'PENDING' || status === 'RUNNING') {
return status === 'PENDING' ? '导出任务排队中…' : '正在生成报表…'
}
if (status === 'SUCCEEDED') {
const resultCount = exportJob.value?.resultCount
return resultCount === undefined || resultCount === null
? '报表已导出'
: `报表已导出(${resultCount} 条)`
}
if (status === 'EXPIRED') {
return '导出文件已过期'
}
return ''
})
function getReportDateRange(): ReportDateRange | null {
if (customDateError.value) {
return null
}
if (filters.period === 'custom') {
return {
from: filters.customFrom,
to: shiftDateKey(filters.customTo, 1),
}
}
const endDate = getShanghaiDateKey(new Date())
const startDate =
filters.period === 'month'
? `${endDate.slice(0, 8)}01`
: shiftDateKey(endDate, -(filters.period === '7d' ? 6 : 13))
return { from: startDate, to: shiftDateKey(endDate, 1) }
}
function getDateKeys(range: ReportDateRange) {
const dates: string[] = []
let current = range.from
while (current < range.to && dates.length <= 366) {
dates.push(current)
current = shiftDateKey(current, 1)
}
return dates
}
function shiftDateKey(dateKey: string, days: number) {
const [year, month, day] = dateKey.split('-').map(Number)
const date = new Date(Date.UTC(year, month - 1, day))
date.setUTCDate(date.getUTCDate() + days)
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
function getShanghaiDateKey(date: Date) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: REPORT_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date)
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return `${values.year}-${values.month}-${values.day}`
}
function formatDateLabel(dateKey: string) {
const [, month, day] = dateKey.split('-')
return `${Number(month)}/${Number(day)}`
}
function toApiDateTime(dateKey: string) {
return new Date(`${dateKey}T00:00:00+08:00`).toISOString()
}
function isDateWithin(value: Date | undefined, range: ReportDateRange) {
if (!value) {
return false
}
const timestamp = value.getTime()
return (
timestamp >= Date.parse(`${range.from}T00:00:00+08:00`) &&
timestamp < Date.parse(`${range.to}T00:00:00+08:00`)
)
}
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
const matchesMethod = filters.method === 'all' || task.method === filters.method
const keyword = filters.keyword.trim().toLowerCase()
const matchesKeyword =
!keyword ||
[task.id, task.patientName, task.patientId, task.documentName, task.department, task.campus]
.join(' ')
.toLowerCase()
.includes(keyword)
return matchesCampus && matchesDepartment && matchesCategory && matchesMethod && matchesKeyword
}
function isOnTimeTask(task: ReportTask) {
return Boolean(
task.signedAt && task.expiredAt && task.signedAt.getTime() <= task.expiredAt.getTime(),
)
}
function getTaskDurationSeconds(task: ReportTask) {
if (task.durationSeconds !== undefined && Number.isFinite(task.durationSeconds)) {
return task.durationSeconds
}
if (task.durationMinutes !== undefined && Number.isFinite(task.durationMinutes)) {
return task.durationMinutes * 60
}
if (task.signStartedAt && task.signedAt) {
return Math.max(0, (task.signedAt.getTime() - task.signStartedAt.getTime()) / 1000)
}
return undefined
}
function formatRate(rate: number | null) {
return rate === null || !Number.isFinite(rate) ? '—' : `${Math.round(rate * 100)}%`
}
function formatAverageMinutes(seconds: number | null) {
if (seconds === null || !Number.isFinite(seconds)) {
return '—'
}
return Math.round((seconds / 60) * 10) / 10
}
function getDepartmentColor(department: string) {
return reportDepartmentColors[department] ?? '#0e6e8c'
}
function updatePeriod(value: ReportPeriodOption) {
filters.period = value
if (value === 'custom' && (!filters.customFrom || !filters.customTo)) {
const end = getShanghaiDateKey(new Date())
filters.customFrom = shiftDateKey(end, -13)
filters.customTo = end
}
refreshAfterFilterChange()
}
function updateCampus(value: ReportCampus) {
filters.campus = value
if (
filters.department !== 'all' &&
!departmentOptions.value.some((option) => option.value === filters.department)
) {
filters.department = 'all'
}
refreshAfterFilterChange()
}
function updateDepartment(value: string) {
filters.department = value
refreshAfterFilterChange()
}
function updateCategory(value: ReportDocumentCategory) {
filters.category = value
refreshAfterFilterChange()
}
function updateMethod(value: ReportMethodFilter) {
filters.method = value
refreshAfterFilterChange()
}
function updateKeyword(value: string) {
filters.keyword = value
refreshAfterFilterChange()
}
function updateCustomFrom(value: string) {
filters.customFrom = value
refreshAfterFilterChange()
}
function updateCustomTo(value: string) {
filters.customTo = value
refreshAfterFilterChange()
}
function refreshAfterFilterChange() {
currentPage.value = 1
if (customDateError.value) {
reportRequestId += 1
taskRequestId += 1
remoteOverview.value = null
remoteTaskRows.value = []
remoteTaskTotal.value = 0
remoteTaskPages.value = 0
tableErrorMessage.value = ''
loading.value = false
taskLoading.value = false
return
}
if (!isReportsMockEnabled) {
void loadRemoteReport()
}
}
function getScopeFilter(): ReportFilterDto {
const filter: ReportFilterDto = { timezone: REPORT_TIMEZONE }
if (filters.campus !== 'all') {
filter.campusId = filters.campus
}
if (filters.department !== 'all') {
filter.departmentId = filters.department
}
if (filters.category !== 'all') {
filter.templateCategory = filters.category
}
if (filters.method !== 'all') {
filter.signMethod = toBackendSigningMethod(filters.method)
}
const keyword = filters.keyword.trim()
if (keyword) {
filter.keyword = keyword
}
return filter
}
function getOverviewFilter(): ReportFilterDto | null {
const range = getReportDateRange()
if (!range) {
return null
}
return {
...getScopeFilter(),
createdFrom: toApiDateTime(range.from),
createdTo: toApiDateTime(range.to),
signedFrom: toApiDateTime(range.from),
signedTo: toApiDateTime(range.to),
expiredFrom: toApiDateTime(range.from),
expiredTo: toApiDateTime(range.to),
}
}
function getTasksQuery(): ReportTasksQueryDto | null {
const range = getReportDateRange()
if (!range) {
return null
}
return {
...getScopeFilter(),
createdFrom: toApiDateTime(range.from),
createdTo: toApiDateTime(range.to),
page: currentPage.value,
size: pageSize.value,
timeDimension: 'CREATED',
sort: 'createdAt',
direction: 'desc',
}
}
function getExportPayload(): ReportExportRequestDto | null {
const range = getReportDateRange()
if (!range) {
return null
}
return {
...getScopeFilter(),
createdFrom: toApiDateTime(range.from),
createdTo: toApiDateTime(range.to),
format: 'CSV',
}
}
async function loadReports() {
loading.value = true
errorMessage.value = ''
tableErrorMessage.value = ''
if (isReportsMockEnabled) {
reportRequestId += 1
taskRequestId += 1
tasks.value = reportTasks.map((task) => ({
...task,
createdAt: new Date(task.createdAt),
deadlineAt: task.deadlineAt ? new Date(task.deadlineAt) : undefined,
signStartedAt: task.signStartedAt ? new Date(task.signStartedAt) : undefined,
signedAt: task.signedAt ? new Date(task.signedAt) : undefined,
expiredAt: task.expiredAt ? new Date(task.expiredAt) : undefined,
voidedAt: task.voidedAt ? new Date(task.voidedAt) : undefined,
}))
remoteOverview.value = null
remoteTaskRows.value = []
remoteTaskTotal.value = 0
remoteTaskPages.value = 0
optionsLoaded.value = true
optionsErrorMessage.value = ''
loading.value = false
return
}
await loadRemoteReport()
}
async function loadRemoteReport() {
const overviewFilter = getOverviewFilter()
const tasksQuery = getTasksQuery()
if (!overviewFilter || !tasksQuery) {
return
}
const currentReportRequestId = ++reportRequestId
const currentTaskRequestId = ++taskRequestId
loading.value = true
taskLoading.value = true
errorMessage.value = ''
tableErrorMessage.value = ''
remoteOverview.value = null
remoteTaskRows.value = []
const optionsRequest = optionsLoaded.value
? Promise.resolve(reportOptions.value)
: getReportOptions({ timezone: REPORT_TIMEZONE })
const [optionsResult, overviewResult, tasksResult] = await Promise.allSettled([
optionsRequest,
getReportOverview(overviewFilter),
getReportTasks(tasksQuery),
])
if (currentReportRequestId !== reportRequestId) {
return
}
if (optionsResult.status === 'fulfilled') {
reportOptions.value = optionsResult.value
optionsLoaded.value = true
optionsErrorMessage.value = ''
} else {
optionsErrorMessage.value = '筛选字典加载失败,暂时只能使用全部范围'
}
if (overviewResult.status === 'fulfilled') {
remoteOverview.value = overviewResult.value
} else {
errorMessage.value = '报表概览加载失败,请稍后重试'
ElMessage.error(errorMessage.value)
}
if (currentTaskRequestId === taskRequestId) {
if (tasksResult.status === 'fulfilled') {
applyTaskPage(tasksResult.value)
} else {
tableErrorMessage.value = '签署明细加载失败,请重试'
}
taskLoading.value = false
}
loading.value = false
}
async function loadRemoteTasks() {
const query = getTasksQuery()
if (!query) {
return
}
const currentTaskRequestId = ++taskRequestId
taskLoading.value = true
tableErrorMessage.value = ''
try {
const page = await getReportTasks(query)
if (currentTaskRequestId !== taskRequestId) {
return
}
applyTaskPage(page)
} catch {
if (currentTaskRequestId === taskRequestId) {
tableErrorMessage.value = '签署明细加载失败,请重试'
}
} finally {
if (currentTaskRequestId === taskRequestId) {
taskLoading.value = false
}
}
}
function applyTaskPage(page: {
records: ReportTaskResponseDto[]
page: number
size: number
total: number
pages: number
}) {
remoteTaskRows.value = page.records
.map(mapReportTask)
.filter((task): task is ReportTask => task !== null)
currentPage.value = page.page
pageSize.value = page.size
remoteTaskTotal.value = page.total
remoteTaskPages.value = page.pages
}
function mapReportTask(task: ReportTaskResponseDto): ReportTask | null {
const createdAt = parseApiDate(task.createdAt)
if (!createdAt) {
return null
}
const durationSeconds =
task.signDurationSeconds !== null &&
task.signDurationSeconds !== undefined &&
Number.isFinite(task.signDurationSeconds)
? Math.max(0, task.signDurationSeconds)
: undefined
return {
id: task.taskId,
patientName: task.patientNameMasked || '—',
patientId: task.patientDisplayId || '—',
documentName: task.templateName || `模板版本 ${task.templateVersionNumber ?? '—'}`,
department: task.departmentName || '未指定科室',
campus: task.campusName || '未指定院区',
campusId: task.campusId,
category: task.templateCategory || '未分类',
method: task.signMethod === 'SMS' ? 'sms' : 'pad',
status: mapReportTaskStatus(task.status),
createdAt,
deadlineAt: parseApiDate(task.deadlineAt),
signStartedAt: parseApiDate(task.signStartedAt),
signedAt: parseApiDate(task.signedAt),
expiredAt: parseApiDate(task.expiredAt),
voidedAt: parseApiDate(task.voidedAt),
onTime: task.onTime ?? undefined,
durationSeconds,
durationMinutes:
durationSeconds === undefined ? undefined : Math.round((durationSeconds / 60) * 10) / 10,
}
}
function mapReportTaskStatus(status: ReportTaskResponseDto['status']): ReportTaskStatus {
const statusMap: Record<ReportTaskResponseDto['status'], ReportTaskStatus> = {
CREATED: 'pending',
WAITING_SIGN: 'pending',
SIGNED: 'signed',
WAITING_CONFIRM: 'confirming',
EXPIRED: 'expired',
VOIDED: 'void',
GENERATING: 'signing',
FAILED: 'failed',
}
return statusMap[status]
}
function parseApiDate(value: string | null | undefined) {
if (!value) {
return undefined
}
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
return Number.isNaN(date.getTime()) ? undefined : date
}
function handlePageChange(page: number) {
const maxPage = totalPages.value || 1
currentPage.value = Math.min(Math.max(page, 1), maxPage)
if (!isReportsMockEnabled) {
void loadRemoteTasks()
}
}
function handlePageSizeChange(size: number) {
if (!REPORT_PAGE_SIZE_OPTIONS.includes(size)) {
return
}
pageSize.value = size
currentPage.value = 1
if (!isReportsMockEnabled) {
void loadRemoteTasks()
}
}
function handleRetryTasks() {
if (!isReportsMockEnabled) {
void loadRemoteTasks()
}
}
async function handleExport() {
if (!canExportReport.value) {
ElMessage.warning('当前账号没有导出签署报表的权限')
return
}
if (exportLoading.value) {
return
}
if (customDateError.value) {
ElMessage.warning(customDateError.value)
return
}
if (isReportsMockEnabled) {
downloadMockReport()
return
}
const payload = getExportPayload()
if (!payload) {
return
}
const currentExportPollId = ++exportPollId
exportLoading.value = true
exportDownloadLoading.value = false
exportErrorMessage.value = ''
exportJob.value = null
try {
const createdJob = await createReportExportJob(payload)
if (currentExportPollId !== exportPollId) {
return
}
exportJob.value = createdJob
await pollReportExport(createdJob.jobId, currentExportPollId)
} catch (error) {
if (currentExportPollId === exportPollId) {
exportErrorMessage.value = getExportErrorMessage(error)
ElMessage.error(exportErrorMessage.value)
}
} finally {
if (currentExportPollId === exportPollId) {
exportLoading.value = false
exportDownloadLoading.value = false
}
}
}
async function pollReportExport(jobId: string, currentExportPollId: number) {
for (let attempt = 0; attempt < EXPORT_MAX_POLLS; attempt += 1) {
if (currentExportPollId !== exportPollId) {
return
}
if (attempt > 0) {
await wait(EXPORT_POLL_INTERVAL_MS)
}
const job = await getReportExportJob(jobId)
if (currentExportPollId !== exportPollId) {
return
}
exportJob.value = job
if (job.status === 'SUCCEEDED') {
exportDownloadLoading.value = true
const blob = await downloadReportExportFile(job.jobId)
if (currentExportPollId !== exportPollId) {
return
}
saveReportBlob(blob, job.format)
ElMessage.success(
job.resultCount === undefined || job.resultCount === null
? '报表已导出'
: `报表已导出,共 ${job.resultCount} 条`,
)
return
}
if (job.status === 'FAILED' || job.status === 'EXPIRED') {
throw new Error(
job.errorMessage || (job.status === 'EXPIRED' ? '导出文件已过期' : '报表生成失败'),
)
}
}
throw new Error('报表生成超时,请稍后在导出列表中重试')
}
function wait(duration: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, duration))
}
function saveReportBlob(blob: Blob, format?: string | null) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
const extension = format?.toUpperCase() === 'XLSX' ? 'xlsx' : 'csv'
link.href = url
link.download = `医签通签署报表-${getShanghaiDateKey(new Date())}.${extension}`
document.body.append(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
function getExportErrorMessage(error: unknown) {
return error instanceof Error && error.message ? error.message : '报表导出失败,请稍后重试'
}
function downloadMockReport() {
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const header = [
'任务号',
'文档',
'科室',
'院区',
'方式',
'状态',
'发起时间',
'签署完成时间',
'耗时(秒)',
'过期时间',
]
const rows = filteredMockTasks.value.map((task) => [
task.id,
task.documentName,
task.department,
task.campus,
formatSigningMethodTitle(task.method),
getReportStatusLabel(task.status),
formatExportDate(task.createdAt),
formatExportDate(task.signedAt),
getTaskDurationSeconds(task) ?? '',
formatExportDate(task.expiredAt),
])
const csv = `\uFEFF${[header, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n')}`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
saveReportBlob(blob, 'CSV')
ElMessage.success('报表已导出')
}
function handleView(row: ReportTask) {
void router.push({ path: '/sign/workbench', query: { taskId: row.id } })
}
function getReportStatusLabel(status: ReportTaskStatus) {
const labels: Record<ReportTaskStatus, string> = {
signed: '已签署',
confirming: '待科室确认',
pending: '待签署',
signing: '签署中',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
return labels[status]
}
function formatExportDate(value: Date | undefined) {
if (!value) {
return ''
}
return new Intl.DateTimeFormat('sv-SE', {
timeZone: REPORT_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(value)
}
onMounted(() => {
void loadReports()
})
onUnmounted(() => {
reportRequestId += 1
taskRequestId += 1
exportPollId += 1
})
</script>
<template>
<section class="reports-page">
<ReportFilterBar
:period="filters.period"
:campus="filters.campus"
:department="filters.department"
:category="filters.category"
:method="filters.method"
:keyword="filters.keyword"
:custom-from="filters.customFrom"
:custom-to="filters.customTo"
:period-options="reportPeriodOptions"
:campus-options="campusOptions"
:department-options="departmentOptions"
:category-options="categoryOptions"
:method-options="methodOptions"
:export-loading="exportLoading"
:export-status="exportStatusText"
:can-export="canExportReport"
@update:period="updatePeriod"
@update:campus="updateCampus"
@update:department="updateDepartment"
@update:category="updateCategory"
@update:method="updateMethod"
@update:keyword="updateKeyword"
@update:custom-from="updateCustomFrom"
@update:custom-to="updateCustomTo"
@export="handleExport"
/>
<p v-if="customDateError" class="filter-error" role="alert">{{ customDateError }}</p>
<p v-if="optionsErrorMessage" class="options-error" role="status">
{{ optionsErrorMessage }}
</p>
<div v-if="loading" class="loading-card" aria-label="正在加载报表">
<span v-for="index in 4" :key="index" />
</div>
<div v-else-if="errorMessage" class="error-card" role="alert">
<p>{{ errorMessage }}</p>
<button type="button" @click="loadReports">重试</button>
</div>
<template v-else>
<div class="metric-grid">
<ReportMetricCard v-for="metric in metrics" :key="metric.key" :metric="metric" />
</div>
<div class="chart-grid">
<section class="page-card trend-card">
<div class="card-heading">
<h2>每日签署量趋势</h2>
<span v-if="reportSnapshotText">{{ reportSnapshotText }}</span>
</div>
<SignatureTrendChart :points="trendPoints" />
</section>
<section class="page-card department-chart-card">
<div class="card-heading">
<h2>科室签署分布</h2>
</div>
<DepartmentDistributionChart :points="departmentPoints" />
</section>
</div>
<section class="page-card detail-card">
<div class="card-heading">
<h2>签署明细</h2>
<span>点击行可在工作台查看原件</span>
</div>
<ReportDetailTable
:rows="visibleRows"
:page="currentPage"
:page-size="pageSize"
:total="totalRows"
:pages="totalPages"
:loading="taskLoading"
:error="tableErrorMessage"
:page-size-options="REPORT_PAGE_SIZE_OPTIONS"
@view="handleView"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@retry="handleRetryTasks"
/>
</section>
</template>
</section>
</template>
<style scoped>
.reports-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.chart-grid {
display: flex;
gap: 16px;
margin-top: 16px;
}
.page-card {
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.trend-card {
flex: 1.5;
min-width: 0;
}
.department-chart-card {
flex: 1;
min-width: 0;
}
.detail-card {
margin-top: 16px;
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.card-heading span {
margin-left: auto;
color: var(--mut);
font-size: 12px;
}
.filter-error,
.options-error {
padding: 8px 12px;
margin: -8px 0 16px;
font-size: 12px;
border-radius: 6px;
}
.filter-error {
color: var(--err);
background: var(--err-l);
}
.options-error {
color: var(--warn);
background: #fff8e8;
}
.loading-card {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.loading-card span {
display: block;
height: 80px;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
border-radius: var(--r);
box-shadow: var(--sh);
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 {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-grid {
flex-direction: column;
}
}
@media (max-width: 600px) {
.metric-grid,
.loading-card {
grid-template-columns: 1fr;
}
.card-heading {
align-items: flex-start;
flex-direction: column;
}
.card-heading span {
margin-left: 0;
}
}
</style>