|
|
|
@@ -3,9 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
|
|
|
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
|
|
|
|
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing'
|
|
|
|
|
import type { SigningTaskRecord, SigningTemplate, WorkbenchCampus } from '@/api/workbench/types'
|
|
|
|
|
import { getReportTaskData, isReportsMockEnabled } from '@/api/management/reports'
|
|
|
|
|
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
|
|
|
|
|
|
|
|
|
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
|
|
|
|
import ReportDetailTable from './components/ReportDetailTable.vue'
|
|
|
|
@@ -33,9 +32,13 @@ import type {
|
|
|
|
|
ReportTrendPoint,
|
|
|
|
|
} from './types'
|
|
|
|
|
|
|
|
|
|
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000
|
|
|
|
|
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
const errorMessage = ref('')
|
|
|
|
|
const tasks = ref<ReportTask[]>([])
|
|
|
|
|
const departmentOptions = ref<ReportSelectOption[]>(reportDepartmentOptions)
|
|
|
|
|
const campusOptions = ref<ReportSelectOption<ReportCampus>[]>(reportCampusOptions)
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
|
|
|
|
const filters = reactive<ReportFilterForm>({
|
|
|
|
@@ -43,53 +46,143 @@ const filters = reactive<ReportFilterForm>({
|
|
|
|
|
campus: 'all',
|
|
|
|
|
department: '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 startDate = getStartDate(filters.period)
|
|
|
|
|
const range = selectedDateRange.value
|
|
|
|
|
|
|
|
|
|
if (!range) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tasks.value.filter((task) => {
|
|
|
|
|
const matchesDate = !startDate || task.createdAt >= startDate
|
|
|
|
|
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
|
|
|
|
|
return isDateWithin(task.createdAt, range) && matchesTaskScope(task)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 list = filteredTasks.value
|
|
|
|
|
const signedTasks = list.filter((task) => task.status === 'signed')
|
|
|
|
|
const completionRate = list.length ? Math.round((signedTasks.length / list.length) * 100) : 0
|
|
|
|
|
const averageMinutes = signedTasks.length
|
|
|
|
|
? Math.round(
|
|
|
|
|
signedTasks.reduce((total, task) => total + (task.durationMinutes ?? 7), 0) /
|
|
|
|
|
signedTasks.length,
|
|
|
|
|
)
|
|
|
|
|
: 0
|
|
|
|
|
const completed = completedTasks.value
|
|
|
|
|
const terminalTasks = [...completed, ...expiredTasks.value]
|
|
|
|
|
const onTimeCount = completed.filter(isOnTimeTask).length
|
|
|
|
|
const completionRate = terminalTasks.length
|
|
|
|
|
? `${Math.round((onTimeCount / terminalTasks.length) * 100)}%`
|
|
|
|
|
: '—'
|
|
|
|
|
const durations = completed
|
|
|
|
|
.map((task) => task.durationMinutes)
|
|
|
|
|
.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 [
|
|
|
|
|
{ key: 'total', label: '累计发起任务', value: list.length, tone: 'brand' },
|
|
|
|
|
{ key: 'signed', label: '签署完成', value: signedTasks.length, tone: 'ok' },
|
|
|
|
|
{ key: 'rate', label: '按时签署率', value: `${completionRate}%`, tone: 'info' },
|
|
|
|
|
{ key: 'total', label: '累计发起任务', value: filteredTasks.value.length, tone: 'brand' },
|
|
|
|
|
{ key: 'signed', label: '签署完成', value: completed.length, tone: 'ok' },
|
|
|
|
|
{ key: 'rate', label: '按时签署率', value: completionRate, tone: 'info' },
|
|
|
|
|
{ key: 'average', label: '平均签署用时(分钟)', value: averageMinutes, tone: 'warn' },
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const trendPoints = computed<ReportTrendPoint[]>(() => {
|
|
|
|
|
const days = filters.period === '7d' ? 7 : 14
|
|
|
|
|
const today = startOfDay(new Date())
|
|
|
|
|
const signedTasks = filteredTasks.value.filter((task) => task.status === 'signed')
|
|
|
|
|
const range = selectedDateRange.value
|
|
|
|
|
|
|
|
|
|
return Array.from({ length: days }, (_, index) => {
|
|
|
|
|
const date = new Date(today)
|
|
|
|
|
date.setDate(date.getDate() - (days - index - 1))
|
|
|
|
|
if (!range) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
date: date.toISOString(),
|
|
|
|
|
date: formatDateKey(date),
|
|
|
|
|
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 counts = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
filteredTasks.value.forEach((task) => {
|
|
|
|
|
completedTasks.value.forEach((task) => {
|
|
|
|
|
counts.set(task.department, (counts.get(task.department) ?? 0) + 1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
@@ -116,29 +209,63 @@ function startOfDay(date: Date) {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStartDate(period: ReportPeriodOption) {
|
|
|
|
|
if (period === 'custom') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (period === 'month') {
|
|
|
|
|
const result = startOfDay(new Date())
|
|
|
|
|
result.setDate(1)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const days = period === '7d' ? 7 : 14
|
|
|
|
|
const result = startOfDay(new Date())
|
|
|
|
|
result.setDate(result.getDate() - days + 1)
|
|
|
|
|
function endOfDay(date: Date) {
|
|
|
|
|
const result = new Date(date)
|
|
|
|
|
result.setHours(23, 59, 59, 999)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseDateInput(value: string, end = false) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = new Date(`${value}T${end ? '23:59:59.999' : '00:00:00'}`)
|
|
|
|
|
return Number.isNaN(date.getTime()) ? null : date
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDateWithin(value: Date | undefined, range: ReportDateRange) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timestamp = value.getTime()
|
|
|
|
|
return timestamp >= range.start.getTime() && timestamp <= range.end.getTime()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
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) {
|
|
|
|
@@ -153,9 +280,27 @@ function updateCategory(value: ReportDocumentCategory) {
|
|
|
|
|
filters.category = value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateCustomFrom(value: string) {
|
|
|
|
|
filters.customFrom = value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateCustomTo(value: string) {
|
|
|
|
|
filters.customTo = value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleExport() {
|
|
|
|
|
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
|
|
|
|
|
const header = ['任务号', '文档', '科室', '院区', '方式', '状态', '发起时间']
|
|
|
|
|
const header = [
|
|
|
|
|
'任务号',
|
|
|
|
|
'文档',
|
|
|
|
|
'科室',
|
|
|
|
|
'院区',
|
|
|
|
|
'方式',
|
|
|
|
|
'状态',
|
|
|
|
|
'发起时间',
|
|
|
|
|
'签署完成时间',
|
|
|
|
|
'过期时间',
|
|
|
|
|
]
|
|
|
|
|
const rows = filteredTasks.value.map((task) => [
|
|
|
|
|
task.id,
|
|
|
|
|
task.documentName,
|
|
|
|
@@ -164,14 +309,18 @@ function handleExport() {
|
|
|
|
|
task.method === 'pad' ? '手写板' : '短信',
|
|
|
|
|
getReportStatusLabel(task.status),
|
|
|
|
|
formatExportDate(task.createdAt),
|
|
|
|
|
formatExportDate(task.signedAt),
|
|
|
|
|
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' })
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
const link = document.createElement('a')
|
|
|
|
|
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.remove()
|
|
|
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
|
|
|
ElMessage.success('报表已导出')
|
|
|
|
|
}
|
|
|
|
@@ -186,12 +335,17 @@ function getReportStatusLabel(status: ReportTaskStatus) {
|
|
|
|
|
pending: '待签署',
|
|
|
|
|
expired: '已超时',
|
|
|
|
|
void: '已作废',
|
|
|
|
|
failed: '处理失败',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return labels[status]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatExportDate(value: Date) {
|
|
|
|
|
function formatExportDate(value: Date | undefined) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pad = (part: number) => String(part).padStart(2, '0')
|
|
|
|
|
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(
|
|
|
|
|
value.getHours(),
|
|
|
|
@@ -215,20 +369,44 @@ function mapCategory(
|
|
|
|
|
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(
|
|
|
|
|
(item) => item.versionId === task.templateVersionId || item.id === task.documentId,
|
|
|
|
|
)
|
|
|
|
|
const createdAt = new Date((task.createdAt ?? task.updatedAt).replace(' ', 'T'))
|
|
|
|
|
const normalizedCreatedAt = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt
|
|
|
|
|
const createdAt = parseApiDate(task.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'] =
|
|
|
|
|
task.status === 'signed'
|
|
|
|
|
? 'signed'
|
|
|
|
|
: task.status === 'expired'
|
|
|
|
|
? 'expired'
|
|
|
|
|
: task.status === 'void' || task.status === 'failed'
|
|
|
|
|
: task.status === 'void'
|
|
|
|
|
? 'void'
|
|
|
|
|
: 'pending'
|
|
|
|
|
: task.status === 'failed'
|
|
|
|
|
? 'failed'
|
|
|
|
|
: 'pending'
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: task.id,
|
|
|
|
@@ -236,74 +414,75 @@ function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): R
|
|
|
|
|
patientId: task.patientId,
|
|
|
|
|
documentName: task.documentName,
|
|
|
|
|
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),
|
|
|
|
|
method: task.method,
|
|
|
|
|
status,
|
|
|
|
|
createdAt: normalizedCreatedAt,
|
|
|
|
|
createdAt,
|
|
|
|
|
signedAt,
|
|
|
|
|
expiredAt,
|
|
|
|
|
durationMinutes,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
|
|
|
|
return value === '本部院区' || value === '东院区' || value === '西院区'
|
|
|
|
|
function getDepartmentOptions(
|
|
|
|
|
departments: Array<{ name: string }>,
|
|
|
|
|
reportTaskRecords: ReportTask[],
|
|
|
|
|
) {
|
|
|
|
|
const names = new Set(departments.map((department) => department.name))
|
|
|
|
|
reportTaskRecords.forEach((task) => names.add(task.department))
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
{ value: 'all', label: '全部科室' },
|
|
|
|
|
...[...names]
|
|
|
|
|
.filter((name) => name && name !== '未指定科室')
|
|
|
|
|
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
|
|
|
|
.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 (isSigningMockEnabled) {
|
|
|
|
|
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 [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 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)
|
|
|
|
|
|
|
|
|
|
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: '全部科室' },
|
|
|
|
|
...[...departmentNames]
|
|
|
|
|
.filter((name) => name && name !== '未指定科室')
|
|
|
|
|
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
|
|
|
|
.map((name) => ({ value: name, label: name })),
|
|
|
|
|
]
|
|
|
|
|
tasks.value = reportTaskRecords
|
|
|
|
|
departmentOptions.value = getDepartmentOptions(data.departments, reportTaskRecords)
|
|
|
|
|
campusOptions.value = getCampusOptions(data.campuses)
|
|
|
|
|
} catch {
|
|
|
|
|
tasks.value = []
|
|
|
|
|
ElMessage.error('报表数据加载失败,请稍后重试')
|
|
|
|
|
errorMessage.value = '报表数据加载失败,请稍后重试'
|
|
|
|
|
ElMessage.error(errorMessage.value)
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
@@ -319,21 +498,32 @@ onMounted(loadReports)
|
|
|
|
|
:campus="filters.campus"
|
|
|
|
|
:department="filters.department"
|
|
|
|
|
:category="filters.category"
|
|
|
|
|
:custom-from="filters.customFrom"
|
|
|
|
|
:custom-to="filters.customTo"
|
|
|
|
|
:period-options="reportPeriodOptions"
|
|
|
|
|
:campus-options="reportCampusOptions"
|
|
|
|
|
:campus-options="campusOptions"
|
|
|
|
|
:department-options="departmentOptions"
|
|
|
|
|
:category-options="reportCategoryOptions"
|
|
|
|
|
@update:period="updatePeriod"
|
|
|
|
|
@update:campus="updateCampus"
|
|
|
|
|
@update:department="updateDepartment"
|
|
|
|
|
@update:category="updateCategory"
|
|
|
|
|
@update:custom-from="updateCustomFrom"
|
|
|
|
|
@update:custom-to="updateCustomTo"
|
|
|
|
|
@export="handleExport"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<p v-if="customDateError" class="filter-error" role="alert">{{ customDateError }}</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" />
|
|
|
|
@@ -426,6 +616,15 @@ onMounted(loadReports)
|
|
|
|
|
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 {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
@@ -442,6 +641,33 @@ onMounted(loadReports)
|
|
|
|
|
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;
|
|
|
|
|