1306 lines
33 KiB
Vue
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>
|