feat(reports): 接入真实报表分析及分组明细与异步导出
This commit is contained in:
@@ -1,60 +1,17 @@
|
||||
/**
|
||||
* 报表分析的聚合逻辑:全部是纯函数,输入「任务明细 + 筛选条件」,输出页面直接渲染的结构。
|
||||
*
|
||||
* 为什么单独一份而不是写进各页面:同一个「按科室的完成率」在总览的汇总矩阵、
|
||||
* 时效分析的分组条形榜、患者结构分析的院区分布里都要用;各页各写一遍,
|
||||
* 迟早出现「同一个筛选条件,两个页面算出不同的数」——这种不一致在界面上
|
||||
* 没有任何提示,只能靠人盯着两个页面比对才能发现。
|
||||
*
|
||||
* 纯函数还有个直接好处:不依赖 DOM 与 Vue,可以用 Node 直接跑断言(见验证脚本)。
|
||||
*/
|
||||
/** 展示层使用的报表标签、日期窗口和格式化函数。聚合指标由后端计算。 */
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types'
|
||||
import { formatSigningMethodTitle, SIGNING_METHODS } from '@/utils/signing-method'
|
||||
|
||||
import type {
|
||||
ReportBarItem,
|
||||
ReportFilterForm,
|
||||
ReportGroupRow,
|
||||
ReportPatientStructure,
|
||||
ReportStatusCounts,
|
||||
ReportSummaryDimension,
|
||||
ReportTimelinessMetrics,
|
||||
ReportTrendPoint,
|
||||
} from './types'
|
||||
import type { ReportBarItem, ReportFilterForm, ReportSummaryDimension } from './types'
|
||||
|
||||
/** 超 24 小时(分钟):时效分析的「24 小时内完成率」分界 */
|
||||
export const WITHIN_24H_MINUTES = 1440
|
||||
/** 超 48 小时(分钟):超过签署链接有效期,算「超期完成」 */
|
||||
/** 超 48 小时(分钟):超期完成任务的展示阈值。 */
|
||||
export const OVERDUE_MINUTES = 2880
|
||||
|
||||
/** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */
|
||||
const MAX_TREND_DAYS = 60
|
||||
|
||||
/**
|
||||
* 签署方式维度文案。
|
||||
* 口径统一走 utils/signing-method:四种方式各成一行,不能只列 pad / sms——
|
||||
* 报表维度是按任务实际取值聚合的,缺一行会让 Pad / 公众号的任务在图表里显示成空白。
|
||||
* 文案取原型的 methodLabel(`✍️ 手写板签署` 这种带图标的完整写法),
|
||||
* 与报表明细表的「方式」列一致。
|
||||
*
|
||||
* ⚠️ 这是**查表**,没有 `readSigningMethod` 那样的未知取值兜底:`METHOD_LABELS[未知值]`
|
||||
* 会得到 undefined,汇总矩阵会多出一行没有名字的分组。今天不会触发(`SigningMethod`
|
||||
* 是封闭联合,取值只来自本仓库的演示数据),但接真实接口时必须在边界上先把后端取值
|
||||
* 归一到这四个(见 api/workbench/signing.ts 的 normalizeSigningMethod),不要指望这里兜底。
|
||||
*/
|
||||
export const METHOD_LABELS: Record<SigningMethod, string> = Object.fromEntries(
|
||||
SIGNING_METHODS.map((option) => [option.key, formatSigningMethodTitle(option.key)]),
|
||||
) as Record<SigningMethod, string>
|
||||
|
||||
/**
|
||||
* 状态文案。七种状态与后端枚举一一对应。
|
||||
*
|
||||
* `confirming`(待科室确认)是**节点状态**:患者已经签完、只差科室复核签章。
|
||||
* 它在报表里单列一栏而不是并进「已签署」—— 并进去会让「已签署」虚高,
|
||||
* 而科室复核本来就是这类文书的关键卡点,混掉就看不见卡在哪了。
|
||||
*/
|
||||
export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
|
||||
pending: '待签署',
|
||||
signing: '签署中',
|
||||
@@ -65,7 +22,6 @@ export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */
|
||||
export const STATUS_COLUMNS: SigningTaskStatus[] = [
|
||||
'signed',
|
||||
'confirming',
|
||||
@@ -83,65 +39,15 @@ export const SUMMARY_DIMENSION_LABELS: Record<ReportSummaryDimension, string> =
|
||||
campus: '院区',
|
||||
}
|
||||
|
||||
/** 年龄段分档:顺序固定,不按数量排 —— 年龄天然有序,重排反而看不出分布形状 */
|
||||
const AGE_BUCKETS: { label: string; match: (age: number) => boolean }[] = [
|
||||
{ label: '0-17 岁', match: (age) => age < 18 },
|
||||
{ label: '18-40 岁', match: (age) => age >= 18 && age <= 40 },
|
||||
{ label: '41-60 岁', match: (age) => age >= 41 && age <= 60 },
|
||||
{ label: '61 岁以上', match: (age) => age > 60 },
|
||||
]
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** 把 `YYYY-MM-DD HH:mm` 截成日期键 */
|
||||
export function dateKeyOf(moment: string): string {
|
||||
return moment.slice(0, 10)
|
||||
}
|
||||
|
||||
function dateKeyOfDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** 日期键 → 坐标轴标签 `M/D` */
|
||||
export function axisLabelOf(dateKey: string): string {
|
||||
const [, month, day] = dateKey.split('-')
|
||||
|
||||
return `${Number(month)}/${Number(day)}`
|
||||
}
|
||||
|
||||
/** 任务状态 → 计数里的字段名:状态枚举与计数字段一一对应,直接索引即可 */
|
||||
export function emptyCounts(): ReportStatusCounts {
|
||||
return {
|
||||
total: 0,
|
||||
signed: 0,
|
||||
confirming: 0,
|
||||
pending: 0,
|
||||
signing: 0,
|
||||
expired: 0,
|
||||
void: 0,
|
||||
failed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function addToCounts(counts: ReportStatusCounts, status: ReportTaskRow['status']): void {
|
||||
counts.total += 1
|
||||
counts[status] += 1
|
||||
}
|
||||
|
||||
/** 完成率:已签署 ÷ 发起。发起为 0 时按 0 计,不返回 NaN */
|
||||
export function completionRateOf(counts: ReportStatusCounts): number {
|
||||
return counts.total ? Math.round((counts.signed / counts.total) * 100) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析筛选的时间窗口,返回闭区间 `{ from, to }`(都是 `YYYY-MM-DD`)。
|
||||
*
|
||||
* 返回 null 表示「不做时间过滤」:自定义区间只填了一头时属于「条件还没填完」,
|
||||
* 这时既不该按半截条件筛,也不该悄悄套一个默认窗口 —— 由页面禁用查询按钮,
|
||||
* 这里只是给出一个确定的行为。
|
||||
*/
|
||||
/** 解析报表筛选的闭区间日期;时间戳边界在 API 层转换为左闭右开。 */
|
||||
export function resolveDateWindow(
|
||||
filter: ReportFilterForm,
|
||||
today = new Date(),
|
||||
@@ -149,361 +55,45 @@ export function resolveDateWindow(
|
||||
const to = dateKeyOfDate(today)
|
||||
|
||||
if (filter.dateRange === 'custom') {
|
||||
if (!filter.customFrom || !filter.customTo) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 起点晚于终点时按用户填的两个端点反着理解,比返回空结果友好
|
||||
if (!filter.customFrom || !filter.customTo) return null
|
||||
return filter.customFrom <= filter.customTo
|
||||
? { from: filter.customFrom, to: filter.customTo }
|
||||
: { from: filter.customTo, to: filter.customFrom }
|
||||
}
|
||||
|
||||
const from = new Date(today)
|
||||
|
||||
if (filter.dateRange === 'month') {
|
||||
from.setDate(1)
|
||||
} else {
|
||||
const days = filter.dateRange === '7d' ? 7 : 14
|
||||
from.setDate(from.getDate() - (days - 1))
|
||||
from.setDate(from.getDate() - (filter.dateRange === '7d' ? 6 : 13))
|
||||
}
|
||||
|
||||
from.setHours(0, 0, 0, 0)
|
||||
|
||||
return { from: dateKeyOfDate(from), to }
|
||||
}
|
||||
|
||||
/** 按筛选条件过滤任务明细 */
|
||||
export function filterReportTasks(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
filter: ReportFilterForm,
|
||||
today = new Date(),
|
||||
): ReportTaskRow[] {
|
||||
const window = resolveDateWindow(filter, today)
|
||||
|
||||
return tasks.filter((task) => {
|
||||
const day = dateKeyOf(task.createdAt)
|
||||
|
||||
if (window && (day < window.from || day > window.to)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.campus !== 'all' && task.campus !== filter.campus) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.department && task.department !== filter.department) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.category && task.category !== filter.category) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.method !== 'all' && task.method !== filter.method) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
/** 日期键 → 坐标轴标签 `M/D`。 */
|
||||
export function axisLabelOf(dateKey: string): string {
|
||||
const [, month, day] = dateKey.split('-')
|
||||
return `${Number(month)}/${Number(day)}`
|
||||
}
|
||||
|
||||
export function countByStatus(tasks: readonly ReportTaskRow[]): ReportStatusCounts {
|
||||
const counts = emptyCounts()
|
||||
|
||||
tasks.forEach((task) => addToCounts(counts, task.status))
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日趋势:同一天分别统计发起量与完成量。
|
||||
*
|
||||
* 窗口天数由 `window` 决定,而不是「有数据的那几天」—— 中间没有任务的日期
|
||||
* 也要占一根空柱,否则横轴会被压缩,趋势的疏密就看不出真实节奏了。
|
||||
*/
|
||||
export function buildTrend(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
window: { from: string; to: string } | null,
|
||||
): ReportTrendPoint[] {
|
||||
const createdMap = new Map<string, number>()
|
||||
const signedMap = new Map<string, number>()
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const created = dateKeyOf(task.createdAt)
|
||||
createdMap.set(created, (createdMap.get(created) ?? 0) + 1)
|
||||
|
||||
if (task.signedAt) {
|
||||
const signed = dateKeyOf(task.signedAt)
|
||||
signedMap.set(signed, (signedMap.get(signed) ?? 0) + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const keys = window ? enumerateDays(window.from, window.to) : [...createdMap.keys()].sort()
|
||||
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
label: axisLabelOf(key),
|
||||
created: createdMap.get(key) ?? 0,
|
||||
signed: signedMap.get(key) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 枚举闭区间内的每一天,超过 MAX_TREND_DAYS 时只保留最后 N 天 */
|
||||
function enumerateDays(from: string, to: string): string[] {
|
||||
const keys: string[] = []
|
||||
const cursor = new Date(`${from}T00:00:00`)
|
||||
const end = new Date(`${to}T00:00:00`)
|
||||
|
||||
// 上限兜底:自定义区间传个跨年的范围时,别把浏览器画死
|
||||
while (cursor <= end && keys.length < 400) {
|
||||
keys.push(dateKeyOfDate(cursor))
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
|
||||
return keys.length > MAX_TREND_DAYS ? keys.slice(-MAX_TREND_DAYS) : keys
|
||||
}
|
||||
|
||||
/** 分组键:四种维度共用一套取值口径 */
|
||||
export function groupKeyOf(task: ReportTaskRow, dimension: ReportSummaryDimension): string {
|
||||
if (dimension === 'category') {
|
||||
return task.category || '其他'
|
||||
}
|
||||
|
||||
if (dimension === 'method') {
|
||||
return METHOD_LABELS[task.method]
|
||||
}
|
||||
|
||||
if (dimension === 'campus') {
|
||||
return task.campus || '其他'
|
||||
}
|
||||
|
||||
return task.department || '其他'
|
||||
}
|
||||
|
||||
/** 按维度分组统计,按发起量倒序(原型口径:最大的分组排最前) */
|
||||
export function groupTasks(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
dimension: ReportSummaryDimension,
|
||||
): ReportGroupRow[] {
|
||||
const buckets = new Map<string, ReportStatusCounts>()
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const key = groupKeyOf(task, dimension)
|
||||
const counts = buckets.get(key) ?? emptyCounts()
|
||||
|
||||
addToCounts(counts, task.status)
|
||||
buckets.set(key, counts)
|
||||
})
|
||||
|
||||
return [...buckets.entries()]
|
||||
.map(([key, counts]) => ({
|
||||
key,
|
||||
...counts,
|
||||
completionRate: completionRateOf(counts),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total || a.key.localeCompare(b.key, 'zh-Hans-CN'))
|
||||
}
|
||||
|
||||
/** 排行条形榜:按数量倒序,可截断(文档 TOP10) */
|
||||
export function buildRanking(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
keyOf: (task: ReportTaskRow) => string,
|
||||
limit?: number,
|
||||
): ReportBarItem[] {
|
||||
const buckets = new Map<string, number>()
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const key = keyOf(task)
|
||||
buckets.set(key, (buckets.get(key) ?? 0) + 1)
|
||||
})
|
||||
|
||||
const items = [...buckets.entries()]
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label, 'zh-Hans-CN'))
|
||||
|
||||
return limit === undefined ? items : items.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时效指标。
|
||||
*
|
||||
* 样本口径:只有**已签署**的任务参与平均时长与两个比率的分母。
|
||||
* 把未完成任务也算进去会把平均值稀释成一个没有意义的数 —— 它们不是「签得快」,
|
||||
* 而是「还没签完」。所以样本为 0 时三个指标返回 null,页面显示「—」而不是 0。
|
||||
*/
|
||||
export function buildTimeliness(tasks: readonly ReportTaskRow[]): ReportTimelinessMetrics {
|
||||
const signed = tasks.filter((task) => task.signMinutes !== undefined)
|
||||
|
||||
if (!signed.length) {
|
||||
return {
|
||||
averageMinutes: null,
|
||||
within24hRate: null,
|
||||
overdueRate: null,
|
||||
expiredTaskCount: tasks.filter((task) => task.status === 'expired').length,
|
||||
signedSampleCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const total = signed.reduce((sum, task) => sum + (task.signMinutes ?? 0), 0)
|
||||
const within24h = signed.filter((task) => (task.signMinutes ?? 0) <= WITHIN_24H_MINUTES).length
|
||||
const overdue = signed.filter((task) => (task.signMinutes ?? 0) > OVERDUE_MINUTES).length
|
||||
|
||||
return {
|
||||
averageMinutes: Math.round(total / signed.length),
|
||||
within24hRate: Math.round((within24h / signed.length) * 100),
|
||||
overdueRate: Math.round((overdue / signed.length) * 100),
|
||||
expiredTaskCount: tasks.filter((task) => task.status === 'expired').length,
|
||||
signedSampleCount: signed.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 各科室平均签署时长,按均值倒序;颜色按 24h / 48h 阈值分级 */
|
||||
export function buildDurationByDepartment(tasks: readonly ReportTaskRow[]): ReportBarItem[] {
|
||||
const sum = new Map<string, { total: number; count: number }>()
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (task.signMinutes === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = task.department || '其他'
|
||||
const bucket = sum.get(key) ?? { total: 0, count: 0 }
|
||||
|
||||
bucket.total += task.signMinutes
|
||||
bucket.count += 1
|
||||
sum.set(key, bucket)
|
||||
})
|
||||
|
||||
return [...sum.entries()]
|
||||
.map(([label, bucket]) => {
|
||||
const average = Math.round(bucket.total / bucket.count)
|
||||
|
||||
return {
|
||||
label,
|
||||
value: average,
|
||||
display: formatDuration(average),
|
||||
color: durationColor(average),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label, 'zh-Hans-CN'))
|
||||
}
|
||||
|
||||
/** 每日平均签署时长(分钟),按完成日期归集 */
|
||||
export function buildDailyAverageDuration(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
window: { from: string; to: string } | null,
|
||||
): ReportBarItem[] {
|
||||
const sum = new Map<string, { total: number; count: number }>()
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (task.signMinutes === undefined || !task.signedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = dateKeyOf(task.signedAt)
|
||||
const bucket = sum.get(key) ?? { total: 0, count: 0 }
|
||||
|
||||
bucket.total += task.signMinutes
|
||||
bucket.count += 1
|
||||
sum.set(key, bucket)
|
||||
})
|
||||
|
||||
const keys = window ? enumerateDays(window.from, window.to) : [...sum.keys()].sort()
|
||||
|
||||
return keys.map((key) => {
|
||||
const bucket = sum.get(key)
|
||||
|
||||
return {
|
||||
label: axisLabelOf(key),
|
||||
value: bucket ? Math.round(bucket.total / bucket.count) : 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 超期完成任务:完成用时超过 48 小时的已签署任务,用时长的排前面 */
|
||||
export function buildOverdueTasks(tasks: readonly ReportTaskRow[]): ReportTaskRow[] {
|
||||
return tasks
|
||||
.filter((task) => (task.signMinutes ?? 0) > OVERDUE_MINUTES)
|
||||
.sort((a, b) => (b.signMinutes ?? 0) - (a.signMinutes ?? 0))
|
||||
}
|
||||
|
||||
/** 患者结构:年龄段固定顺序,其余按数量倒序(与原型一致) */
|
||||
export function buildPatientStructure(tasks: readonly ReportTaskRow[]): ReportPatientStructure {
|
||||
// 年龄段只扫一遍:四档各 filter 一次是 4 倍工作量,而且四档之间还容易写歪边界
|
||||
const ageCounts = AGE_BUCKETS.map(() => 0)
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const index = AGE_BUCKETS.findIndex((bucket) => bucket.match(task.age))
|
||||
|
||||
if (index >= 0) {
|
||||
ageCounts[index] += 1
|
||||
}
|
||||
})
|
||||
|
||||
const age = AGE_BUCKETS.map((bucket, index) => ({
|
||||
label: bucket.label,
|
||||
value: ageCounts[index] ?? 0,
|
||||
display: `${ageCounts[index] ?? 0} 人次`,
|
||||
}))
|
||||
|
||||
const total = tasks.length || 1
|
||||
|
||||
const sex = buildRanking(tasks, (task) => task.sex).map((item) => ({
|
||||
...item,
|
||||
display: `${item.value} 人次 · ${Math.round((item.value / total) * 100)}%`,
|
||||
}))
|
||||
|
||||
const visitType = buildRanking(tasks, (task) => task.visitType).map((item) => ({
|
||||
...item,
|
||||
display: `${item.value} 人次`,
|
||||
}))
|
||||
|
||||
const campus = buildRanking(tasks, (task) => task.campus || '其他').map((item) => ({
|
||||
...item,
|
||||
display: `${item.value} 人次`,
|
||||
}))
|
||||
|
||||
return { age, sex, visitType, campus }
|
||||
}
|
||||
|
||||
/** 时长展示:对齐原型口径(不足 1 小时用分钟,超过用小时且保留一位小数) */
|
||||
/** 不足一小时显示分钟,超过一小时显示小时。 */
|
||||
export function formatDuration(minutes: number | null | undefined): string {
|
||||
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} 分钟`
|
||||
}
|
||||
|
||||
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return '—'
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
const hours = minutes / 60
|
||||
|
||||
return `${hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours)} 小时`
|
||||
}
|
||||
|
||||
/** 时长分级配色:24 小时内绿、48 小时内橙、超过红 */
|
||||
export function durationColor(minutes: number): string {
|
||||
if (minutes <= WITHIN_24H_MINUTES) {
|
||||
return '#0f9d6c'
|
||||
}
|
||||
|
||||
return minutes <= OVERDUE_MINUTES ? '#d9821f' : '#d0534f'
|
||||
}
|
||||
|
||||
/** 完成率配色:≥90% 绿、≥80% 橙、其余红(原型口径) */
|
||||
/** 完成率配色:≥90% 绿、≥80% 橙、其余红。 */
|
||||
export function completionRateColor(rate: number): string {
|
||||
if (rate >= 90) {
|
||||
return '#0f9d6c'
|
||||
}
|
||||
|
||||
if (rate >= 90) return '#0f9d6c'
|
||||
return rate >= 80 ? '#d9821f' : '#d0534f'
|
||||
}
|
||||
|
||||
/** 条形榜调色板(与原型一致) */
|
||||
export const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f']
|
||||
const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f']
|
||||
|
||||
/** 取条形颜色:显式给了就用,否则按调色板轮换 */
|
||||
/** 横向条形图优先使用数据指定颜色,否则循环使用主题调色板。 */
|
||||
export function barColorOf(item: ReportBarItem, index: number): string {
|
||||
return item.color ?? BAR_PALETTE[index % BAR_PALETTE.length] ?? '#0e6e8c'
|
||||
}
|
||||
|
||||
@@ -2,33 +2,34 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { downloadCsv, fileStamp } from '@/utils/csv-export'
|
||||
import { REPORT_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
import {
|
||||
buildReportAnalyticsQuery,
|
||||
createReportExportJob,
|
||||
downloadReportExport,
|
||||
getReportExportJob,
|
||||
type ReportExportJob,
|
||||
} from '@/api/workbench/report-analytics'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
|
||||
import { filterReportTasks, formatDuration, METHOD_LABELS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportDateRange, ReportExportColumn, ReportFilterForm, ReportFilterOption } from '../types'
|
||||
import { resolveDateWindow } from '../aggregate'
|
||||
import type { ReportDateRange, ReportFilterForm, ReportFilterOption } from '../types'
|
||||
|
||||
/**
|
||||
* 导出弹窗。
|
||||
* 报表导出通过服务端异步任务完成,任务创建、下载都会由后端记录审计。
|
||||
*
|
||||
* 「导出范围」在弹窗里可以独立调整,而不是固定沿用页面上的筛选:导出通常是
|
||||
* 「把范围放宽一点拿全量数据」,如果只能导出当前所见,用户得先改页面筛选、
|
||||
* 导出、再改回来。
|
||||
*
|
||||
* ⚠️ 前端导出只是权宜之计:导出行为、导出人与导出范围必须写入审计日志,
|
||||
* 浏览器里做不到。后端提供导出任务接口后应整体替换(参考签署记录查询的同类说明)。
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** 未筛选的全量明细:弹窗自己按导出范围过滤 */
|
||||
tasks: ReportTaskRow[]
|
||||
/** 弹窗打开时用于初始化导出范围 */
|
||||
filter: ReportFilterForm
|
||||
campuses: ReportFilterOption<WorkbenchCampus | 'all'>[]
|
||||
departments: ReportFilterOption[]
|
||||
departments: (ReportFilterOption & { campusId?: string })[]
|
||||
categories: ReportFilterOption[]
|
||||
}>()
|
||||
|
||||
@@ -43,9 +44,10 @@ const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
]
|
||||
|
||||
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
const methodOptions: ReportFilterOption<ReportFilterForm['method']>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -54,28 +56,19 @@ const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
* 前九项与原型一致,默认勾选;其余是台账里常要的补充字段,默认不勾 ——
|
||||
* 默认全勾会让导出的表宽到没法看,而默认只给最少字段又要用户每次手动加。
|
||||
*/
|
||||
const EXPORT_COLUMNS: ReportExportColumn[] = [
|
||||
{ key: 'id', label: '任务号', value: (row) => row.id },
|
||||
{ key: 'patientName', label: '患者姓名', value: (row) => row.patientName },
|
||||
{ key: 'documentName', label: '文档名称', value: (row) => row.documentName },
|
||||
{ key: 'department', label: '科室', value: (row) => row.department },
|
||||
{ key: 'method', label: '签署方式', value: (row) => METHOD_LABELS[row.method] },
|
||||
{ key: 'status', label: '状态', value: (row) => STATUS_LABELS[row.status] },
|
||||
{ key: 'createdAt', label: '发起时间', value: (row) => row.createdAt },
|
||||
{ key: 'signedAt', label: '完成时间', value: (row) => row.signedAt ?? '' },
|
||||
{
|
||||
key: 'signMinutes',
|
||||
label: '签署用时',
|
||||
// 表格里空值显示「—」,但 CSV 给空单元格:表格里的破折号进到 Excel 会变成文本
|
||||
value: (row) => (row.signMinutes === undefined ? '' : formatDuration(row.signMinutes)),
|
||||
},
|
||||
{ key: 'patientId', label: '患者ID', value: (row) => row.patientId },
|
||||
{ key: 'sex', label: '性别', value: (row) => row.sex },
|
||||
{ key: 'age', label: '年龄', value: (row) => String(row.age) },
|
||||
{ key: 'visitNo', label: '就诊号', value: (row) => row.visitNo },
|
||||
{ key: 'visitType', label: '就诊类型', value: (row) => row.visitType },
|
||||
{ key: 'campus', label: '院区', value: (row) => row.campus },
|
||||
{ key: 'category', label: '文档类别', value: (row) => row.category },
|
||||
const EXPORT_COLUMNS = [
|
||||
{ key: 'taskId', label: '任务号' },
|
||||
{ key: 'patientDisplayId', label: '患者脱敏标识' },
|
||||
{ key: 'patientNameMasked', label: '患者姓名(脱敏)' },
|
||||
{ key: 'templateName', label: '文档名称' },
|
||||
{ key: 'departmentName', label: '科室' },
|
||||
{ key: 'signMethod', label: '签署方式' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'createdAt', label: '发起时间' },
|
||||
{ key: 'signedAt', label: '完成时间' },
|
||||
{ key: 'signDurationSeconds', label: '签署用时(秒)' },
|
||||
{ key: 'campusName', label: '院区' },
|
||||
{ key: 'templateCategory', label: '文档类别' },
|
||||
]
|
||||
|
||||
const DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key)
|
||||
@@ -83,6 +76,17 @@ const DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key)
|
||||
const draft = ref<ReportFilterForm>({ ...props.filter })
|
||||
const checkedKeys = ref<string[]>([...DEFAULT_CHECKED])
|
||||
const exporting = ref(false)
|
||||
const activeJobId = ref('')
|
||||
const activeJob = ref<ReportExportJob | null>(null)
|
||||
|
||||
const draftDepartmentOptions = computed(() =>
|
||||
props.departments.filter(
|
||||
(department) =>
|
||||
!department.value ||
|
||||
draft.value.campus === 'all' ||
|
||||
department.campusId === draft.value.campus,
|
||||
),
|
||||
)
|
||||
|
||||
// 每次打开都按「当前页面筛选」重置:上次调过的导出范围留到下一次会让人导错数据
|
||||
watch(
|
||||
@@ -95,7 +99,17 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const scopedTasks = computed(() => filterReportTasks(props.tasks, draft.value))
|
||||
watch(
|
||||
() => draft.value.campus,
|
||||
() => {
|
||||
if (
|
||||
draft.value.department &&
|
||||
!draftDepartmentOptions.value.some((option) => option.value === draft.value.department)
|
||||
) {
|
||||
draft.value.department = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const customRangeIncomplete = computed(
|
||||
() => draft.value.dateRange === 'custom' && (!draft.value.customFrom || !draft.value.customTo),
|
||||
@@ -104,43 +118,86 @@ const customRangeIncomplete = computed(
|
||||
const selectedColumns = computed(() =>
|
||||
EXPORT_COLUMNS.filter((column) => checkedKeys.value.includes(column.key)),
|
||||
)
|
||||
|
||||
const canExport = computed(
|
||||
() => !customRangeIncomplete.value && scopedTasks.value.length > 0 && selectedColumns.value.length > 0,
|
||||
() =>
|
||||
!exporting.value &&
|
||||
(Boolean(activeJobId.value) ||
|
||||
(!customRangeIncomplete.value && selectedColumns.value.length > 0)),
|
||||
)
|
||||
|
||||
function wait(milliseconds: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
async function pollExportJob(jobId: string): Promise<ReportExportJob> {
|
||||
let latest = activeJob.value
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
latest = await getReportExportJob(jobId)
|
||||
activeJob.value = latest
|
||||
if (latest.status === 'SUCCEEDED' || latest.status === 'FAILED' || latest.status === 'EXPIRED')
|
||||
return latest
|
||||
if (attempt < 44) await wait(2000)
|
||||
}
|
||||
return latest!
|
||||
}
|
||||
|
||||
async function finishExport(job: ReportExportJob) {
|
||||
if (job.status === 'SUCCEEDED' && job.downloadPath) {
|
||||
await downloadReportExport(job.downloadPath, `签署报表_${job.jobId}.csv`)
|
||||
ElMessage.success(`已导出 ${job.resultCount ?? 0} 条签署任务`)
|
||||
activeJobId.value = ''
|
||||
activeJob.value = null
|
||||
emit('close')
|
||||
} else if (job.status === 'FAILED' || job.status === 'EXPIRED') {
|
||||
activeJobId.value = ''
|
||||
activeJob.value = null
|
||||
ElMessage.error(job.errorMessage || '导出失败,请稍后重试')
|
||||
} else {
|
||||
ElMessage.info('导出任务仍在后台处理,可稍后再次点击继续查看')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (customRangeIncomplete.value) {
|
||||
async function handleExport() {
|
||||
if (!activeJobId.value && customRangeIncomplete.value) {
|
||||
ElMessage.info('自定义时间范围需要同时填写起止日期')
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedColumns.value.length) {
|
||||
if (!activeJobId.value && !selectedColumns.value.length) {
|
||||
ElMessage.info('请至少选择一个导出字段')
|
||||
return
|
||||
}
|
||||
|
||||
if (!scopedTasks.value.length) {
|
||||
ElMessage.info('当前导出范围内没有可导出的签署任务')
|
||||
return
|
||||
}
|
||||
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
exporting.value = true
|
||||
|
||||
try {
|
||||
downloadCsv(selectedColumns.value, scopedTasks.value, `签署报表_${fileStamp()}.csv`)
|
||||
ElMessage.success(`已导出 ${scopedTasks.value.length} 条签署任务`)
|
||||
emit('close')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请稍后重试')
|
||||
if (!activeJobId.value) {
|
||||
const dateWindow = resolveDateWindow(draft.value)
|
||||
if (!dateWindow) throw new Error('自定义时间范围需要同时填写起止日期')
|
||||
const query = buildReportAnalyticsQuery(draft.value, dateWindow)
|
||||
const job = await createReportExportJob({
|
||||
...query,
|
||||
page: 1,
|
||||
size: 200,
|
||||
timeDimension: 'CREATED',
|
||||
sort: 'createdAt',
|
||||
direction: 'desc',
|
||||
format: 'CSV',
|
||||
columns: selectedColumns.value.map((column) => column.key),
|
||||
})
|
||||
activeJobId.value = job.jobId
|
||||
activeJob.value = job
|
||||
}
|
||||
await finishExport(await pollExportJob(activeJobId.value))
|
||||
} catch (error) {
|
||||
ElMessage.error(toUserMessage(error, '导出任务创建或下载失败'))
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
@@ -188,7 +245,11 @@ function handleExport() {
|
||||
<label class="field">
|
||||
<span>科室</span>
|
||||
<select v-model="draft.department" aria-label="导出科室">
|
||||
<option v-for="option in departments" :key="option.value" :value="option.value">
|
||||
<option
|
||||
v-for="option in draftDepartmentOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -226,7 +287,10 @@ function handleExport() {
|
||||
|
||||
<p class="hint" :class="{ 'is-warn': customRangeIncomplete }">
|
||||
<template v-if="customRangeIncomplete">自定义时间范围需要同时填写起止日期。</template>
|
||||
<template v-else>本次将导出 <b>{{ scopedTasks.length }}</b> 条签署任务。</template>
|
||||
<template v-else-if="activeJobId">
|
||||
导出任务 {{ activeJobId }} · {{ activeJob?.status ?? '处理中' }}
|
||||
</template>
|
||||
<template v-else>由后端按当前筛选生成 CSV,导出过程会写入审计记录。</template>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -241,13 +305,11 @@ function handleExport() {
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
文件格式:CSV(UTF-8,带 BOM,Excel 可直接打开)。Excel / PDF 需后端导出任务支持,当前未接入。
|
||||
文件格式:CSV(UTF-8)。患者姓名和标识按后端安全字段脱敏;人口统计原始字段不提供导出。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="note">
|
||||
导出行为、导出人与导出范围均需写入审计日志,可在「审计日志」模块追溯;当前为前端导出,尚未留痕。
|
||||
</p>
|
||||
<p class="note">后端异步生成文件。任务仍在处理中时可再次打开此弹窗继续查看。</p>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -260,7 +322,7 @@ function handleExport() {
|
||||
:disabled="!canExport || exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
{{ exporting ? '导出中…' : '开始导出' }}
|
||||
{{ exporting ? '正在处理…' : activeJobId ? '继续查看导出' : '开始导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { REPORT_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
|
||||
import { reportFilter, resetReportFilter } from '../filter-store'
|
||||
import type { ReportDateRange, ReportFilterOption } from '../types'
|
||||
@@ -20,6 +19,7 @@ defineProps<{
|
||||
campuses: ReportFilterOption<WorkbenchCampus | 'all'>[]
|
||||
departments: ReportFilterOption[]
|
||||
categories: ReportFilterOption[]
|
||||
canExport: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,10 +33,11 @@ const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
]
|
||||
|
||||
/** 原型 #repMethod 是 4 项(手写板 / Pad / 短信 / 公众号),与记录查询的 3 项不同 */
|
||||
/** 后端任务目前只支持 PAD 与 SMS 两种签署方式。 */
|
||||
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -111,7 +112,12 @@ function handleReset() {
|
||||
<button type="button" class="action-button action-button--ghost" @click="handleReset">
|
||||
重置
|
||||
</button>
|
||||
<button type="button" class="action-button action-button--ghost" @click="emit('export')">
|
||||
<button
|
||||
v-if="canExport"
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('export')"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { mockCatalogCategories, mockCatalogDepartments } from '@/api/workbench/mock-catalog'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
|
||||
import { reportFilter } from '../filter-store'
|
||||
import type { ReportFilterOption } from '../types'
|
||||
import { loadReportDataset, useReportDataset } from '../use-report-dataset'
|
||||
import { loadReportDataset, loadReportOptions, useReportDataset } from '../use-report-dataset'
|
||||
import ReportExportDialog from './ReportExportDialog.vue'
|
||||
import ReportFilterBar from './ReportFilterBar.vue'
|
||||
|
||||
/**
|
||||
* 报表页面外壳:标题 + 筛选条 + 数据可用性闸门 + 导出弹窗,四个菜单共用。
|
||||
*
|
||||
* 为什么把闸门和导出也放进来:这两件事四个页面一字不差。各写一遍的话,
|
||||
* 「后端没有接口」的说明文案会在四个页面里慢慢分叉,导出列也会各自漂移 ——
|
||||
* 到时候同一个任务在总览导出和时效导出里字段都不一样。
|
||||
* 为什么把数据加载和导出也放进来:四个页面共用同一套筛选项、请求状态和受审计的
|
||||
* 服务端导出入口,避免各页的筛选与权限行为出现差异。
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,31 +22,51 @@ const props = defineProps<{
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { allTasks, tasks, available, generatedAt, loading, errorMessage, customRangeIncomplete } =
|
||||
useReportDataset()
|
||||
const loginStore = useLoginStore()
|
||||
const canExport = computed(() => loginStore.hasPermission('report:sign:export'))
|
||||
const {
|
||||
dataset,
|
||||
options,
|
||||
available,
|
||||
generatedAt,
|
||||
loading,
|
||||
errorMessage,
|
||||
optionsError,
|
||||
customRangeIncomplete,
|
||||
} = useReportDataset()
|
||||
|
||||
const exportVisible = ref(false)
|
||||
|
||||
const campusOptions = computed<ReportFilterOption<WorkbenchCampus | 'all'>[]>(() => [
|
||||
{ label: '全部院区', value: 'all' },
|
||||
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
|
||||
...(options.value?.campuses ?? []).map((campus) => ({ label: campus.name, value: campus.id })),
|
||||
])
|
||||
|
||||
/**
|
||||
* 科室与类别取演示词表:报表的演示数据本身就来自这份词表,
|
||||
* 用组织接口的科室清单会出现「筛了某个科室但一条数据都没有」的假空态。
|
||||
* 真实模式下面板不渲染(没有数据可筛),这里无需再区分口径。
|
||||
*/
|
||||
const departmentOptions: ReportFilterOption[] = [
|
||||
{ label: '全部科室', value: '' },
|
||||
...mockCatalogDepartments.map((name) => ({ label: name, value: name })),
|
||||
]
|
||||
const allDepartmentOptions = computed(() => [
|
||||
{ label: '全部科室', value: '', campusId: '' },
|
||||
...(options.value?.departments ?? []).map((department) => ({
|
||||
label: department.name,
|
||||
value: department.id,
|
||||
campusId: department.campusId,
|
||||
})),
|
||||
])
|
||||
|
||||
const categoryOptions: ReportFilterOption[] = [
|
||||
const departmentOptions = computed<ReportFilterOption[]>(() =>
|
||||
allDepartmentOptions.value.filter(
|
||||
(department) =>
|
||||
!department.value ||
|
||||
reportFilter.campus === 'all' ||
|
||||
department.campusId === reportFilter.campus,
|
||||
),
|
||||
)
|
||||
|
||||
const categoryOptions = computed<ReportFilterOption[]>(() => [
|
||||
{ label: '全部类别', value: '' },
|
||||
...mockCatalogCategories.map((name) => ({ label: name, value: name })),
|
||||
]
|
||||
...(options.value?.templateCategories ?? []).map((category) => ({
|
||||
label: category,
|
||||
value: category,
|
||||
})),
|
||||
])
|
||||
|
||||
const metaText = computed(() =>
|
||||
generatedAt.value ? `数据截至 ${generatedAt.value}` : '正在读取数据…',
|
||||
@@ -61,9 +79,21 @@ const metaText = computed(() =>
|
||||
const showPageIntro = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.loadCampuses()
|
||||
await loadReportOptions()
|
||||
await loadReportDataset()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => reportFilter.campus,
|
||||
() => {
|
||||
if (
|
||||
reportFilter.department &&
|
||||
!departmentOptions.value.some((option) => option.value === reportFilter.department)
|
||||
) {
|
||||
reportFilter.department = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,7 +104,7 @@ onMounted(async () => {
|
||||
<p>{{ props.description }}</p>
|
||||
</div>
|
||||
<div class="page-intro__meta">
|
||||
<span v-if="available">当前筛选 {{ tasks.length }} 条任务</span>
|
||||
<span v-if="available">当前筛选 {{ dataset?.statusCounts.total ?? 0 }} 条任务</span>
|
||||
<span>{{ metaText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,20 +113,15 @@ onMounted(async () => {
|
||||
<div class="report-state">正在读取报表数据…</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="errorMessage">
|
||||
<div class="report-state report-state--error">{{ errorMessage }}</div>
|
||||
<template v-else-if="errorMessage || optionsError">
|
||||
<div class="report-state report-state--error">{{ errorMessage || optionsError }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!available">
|
||||
<template v-else-if="!available || !dataset">
|
||||
<div class="report-state">
|
||||
<strong>后端尚未提供报表分析接口</strong>
|
||||
<p>
|
||||
报表分析目前只有演示数据。真实模式(关闭演示数据)下这里没有可用的聚合结果,
|
||||
因此不渲染任何统计数字 —— 显示一张全 0 的报表会让人以为「今天真的没人签」。
|
||||
</p>
|
||||
<p class="report-state__hint">
|
||||
需要查看演示数据时,把 <code>VITE_MOCK_REPORT_ANALYTICS</code> 设为 <code>true</code> 后重启开发服务。
|
||||
</p>
|
||||
<strong>暂无报表数据</strong>
|
||||
<p v-if="customRangeIncomplete">请补齐自定义时间范围的起止日期后查看统计。</p>
|
||||
<p v-else>当前筛选范围内没有签署任务。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +130,7 @@ onMounted(async () => {
|
||||
:campuses="campusOptions"
|
||||
:departments="departmentOptions"
|
||||
:categories="categoryOptions"
|
||||
:can-export="canExport"
|
||||
@export="exportVisible = true"
|
||||
/>
|
||||
|
||||
@@ -115,11 +141,11 @@ onMounted(async () => {
|
||||
<slot />
|
||||
|
||||
<ReportExportDialog
|
||||
v-if="canExport"
|
||||
:visible="exportVisible"
|
||||
:tasks="allTasks"
|
||||
:filter="reportFilter"
|
||||
:campuses="campusOptions"
|
||||
:departments="departmentOptions"
|
||||
:departments="allDepartmentOptions"
|
||||
:categories="categoryOptions"
|
||||
@close="exportVisible = false"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { ReportGroupRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
import { completionRateColor, groupTasks, STATUS_COLUMNS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportGroupRow, ReportStatusCounts, ReportSummaryDimension } from '../types'
|
||||
import { completionRateColor, METHOD_LABELS, STATUS_COLUMNS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportStatusCounts, ReportSummaryDimension } from '../types'
|
||||
|
||||
/**
|
||||
* 签署汇总矩阵:把当前筛选下的任务按一个维度汇总,并支持下钻。
|
||||
@@ -15,7 +16,7 @@ import type { ReportGroupRow, ReportStatusCounts, ReportSummaryDimension } from
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: ReportTaskRow[]
|
||||
rows: ReportGroupRow[]
|
||||
/** 当前选中的分组维度(由页面持有,便于切换维度时重置下钻) */
|
||||
dimension: ReportSummaryDimension
|
||||
}>()
|
||||
@@ -33,7 +34,7 @@ const dimensionOptions: { label: string; value: ReportSummaryDimension }[] = [
|
||||
{ label: '按院区', value: 'campus' },
|
||||
]
|
||||
|
||||
const rows = computed(() => groupTasks(props.tasks, props.dimension))
|
||||
const rows = computed(() => props.rows)
|
||||
|
||||
const totals = computed<ReportStatusCounts>(() =>
|
||||
rows.value.reduce<ReportStatusCounts>(
|
||||
@@ -70,6 +71,10 @@ const hoveredKey = ref<string | null>(null)
|
||||
function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]): number {
|
||||
return row[status]
|
||||
}
|
||||
|
||||
function keyLabel(key: string) {
|
||||
return props.dimension === 'method' ? (METHOD_LABELS[key as SigningMethod] ?? key) : key
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,7 +87,12 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
<select
|
||||
:value="dimension"
|
||||
aria-label="选择汇总口径"
|
||||
@change="emit('update:dimension', ($event.target as HTMLSelectElement).value as ReportSummaryDimension)"
|
||||
@change="
|
||||
emit(
|
||||
'update:dimension',
|
||||
($event.target as HTMLSelectElement).value as ReportSummaryDimension,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="option in dimensionOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
@@ -103,9 +113,7 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!rows.length">
|
||||
<td :colspan="STATUS_COLUMNS.length + 3" class="matrix__empty">
|
||||
当前条件下暂无数据
|
||||
</td>
|
||||
<td :colspan="STATUS_COLUMNS.length + 3" class="matrix__empty">当前条件下暂无数据</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
@@ -117,7 +125,9 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
@click="emit('drill', null)"
|
||||
>
|
||||
<td><b>合计</b></td>
|
||||
<td><b>{{ totals.total }}</b></td>
|
||||
<td>
|
||||
<b>{{ totals.total }}</b>
|
||||
</td>
|
||||
<td v-for="status in STATUS_COLUMNS" :key="status">{{ totals[status] }}</td>
|
||||
<td>
|
||||
<b :style="{ color: completionRateColor(totalRate) }">{{ totalRate }}%</b>
|
||||
@@ -129,12 +139,14 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
:key="row.key"
|
||||
class="matrix__row"
|
||||
:class="{ 'is-hover': hoveredKey === row.key }"
|
||||
:title="`点击查看「${row.key}」的明细`"
|
||||
:title="`点击查看「${keyLabel(row.key)}」的明细`"
|
||||
@mouseenter="hoveredKey = row.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
@click="emit('drill', row.key)"
|
||||
>
|
||||
<td><b>{{ row.key }}</b></td>
|
||||
<td>
|
||||
<b>{{ keyLabel(row.key) }}</b>
|
||||
</td>
|
||||
<td>{{ row.total }}</td>
|
||||
<td v-for="status in STATUS_COLUMNS" :key="status">{{ valueOf(row, status) }}</td>
|
||||
<td>
|
||||
@@ -148,7 +160,8 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
</div>
|
||||
|
||||
<p class="matrix__note">
|
||||
完成率 = 已签署 ÷ 发起;低于 80% 标红提示。下钻明细只读,需要查看文书原文请到「签署记录查询」。
|
||||
完成率 = 已签署 ÷ 发起;低于 80%
|
||||
标红提示。下钻明细只读,需要查看文书原文请到「签署记录查询」。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
buildReportAnalyticsQuery,
|
||||
getReportAnalyticsTasks,
|
||||
type ReportTaskRow,
|
||||
} from '@/api/workbench/report-analytics'
|
||||
|
||||
import {
|
||||
buildTrend,
|
||||
countByStatus,
|
||||
formatDuration,
|
||||
groupKeyOf,
|
||||
METHOD_LABELS,
|
||||
STATUS_LABELS,
|
||||
SUMMARY_DIMENSION_LABELS,
|
||||
@@ -18,6 +19,7 @@ import ReportPageShell from '../components/ReportPageShell.vue'
|
||||
import ReportStatTiles from '../components/ReportStatTiles.vue'
|
||||
import ReportSummaryMatrix from '../components/ReportSummaryMatrix.vue'
|
||||
import type { ReportChartPoint, ReportSummaryDimension } from '../types'
|
||||
import { reportFilter } from '../filter-store'
|
||||
import { useReportDataset } from '../use-report-dataset'
|
||||
|
||||
/**
|
||||
@@ -27,29 +29,28 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 汇总矩阵回答「问题出在谁身上」。挤在一张图里就哪个问题都回答不清楚。
|
||||
*/
|
||||
|
||||
const { tasks, window: dateWindow } = useReportDataset()
|
||||
|
||||
const counts = computed(() => countByStatus(tasks.value))
|
||||
const { dataset, window: dateWindow } = useReportDataset()
|
||||
const counts = computed(() => dataset.value?.statusCounts)
|
||||
|
||||
/**
|
||||
* 六个状态全列出(含处理失败)。
|
||||
* 只挑几个显示会让各块加起来对不上「累计发起」,看到的人会先怀疑统计错了。
|
||||
*/
|
||||
const tiles = computed(() => [
|
||||
{ label: '累计发起任务', value: counts.value.total, color: '#0e6e8c' },
|
||||
{ label: '已签署', value: counts.value.signed, color: '#0f9d6c' },
|
||||
{ label: '待签署', value: counts.value.pending, color: '#3a7bd5' },
|
||||
{ label: '累计发起任务', value: counts.value?.total ?? 0, color: '#0e6e8c' },
|
||||
{ label: '已签署', value: counts.value?.signed ?? 0, color: '#0f9d6c' },
|
||||
{ label: '待签署', value: counts.value?.pending ?? 0, color: '#3a7bd5' },
|
||||
// 待科室确认:患者已签、等科室复核签章。取色与任务徽标(原型 .tg-cfm)一致,
|
||||
// 不沿用原型的 #7a4dbb —— 那个色在本仓库已经给了「签署中」,同色两块会看串
|
||||
{ label: '待科室确认', value: counts.value.confirming, color: '#c07f00' },
|
||||
{ label: '签署中', value: counts.value.signing, color: '#7a4dbb' },
|
||||
{ label: '已超时', value: counts.value.expired, color: '#d9821f' },
|
||||
{ label: '已作废', value: counts.value.void, color: '#d0534f' },
|
||||
{ label: '处理失败', value: counts.value.failed, color: '#b4475f' },
|
||||
{ label: '待科室确认', value: counts.value?.confirming ?? 0, color: '#c07f00' },
|
||||
{ label: '签署中', value: counts.value?.signing ?? 0, color: '#7a4dbb' },
|
||||
{ label: '已超时', value: counts.value?.expired ?? 0, color: '#d9821f' },
|
||||
{ label: '已作废', value: counts.value?.void ?? 0, color: '#d0534f' },
|
||||
{ label: '处理失败', value: counts.value?.failed ?? 0, color: '#b4475f' },
|
||||
])
|
||||
|
||||
const trendPoints = computed<ReportChartPoint[]>(() =>
|
||||
buildTrend(tasks.value, dateWindow.value).map((point) => ({
|
||||
(dataset.value?.trend ?? []).map((point) => ({
|
||||
label: point.label,
|
||||
values: [point.created, point.signed],
|
||||
})),
|
||||
@@ -61,6 +62,20 @@ const trendSeries = [
|
||||
]
|
||||
|
||||
const dimension = ref<ReportSummaryDimension>('department')
|
||||
const groupRows = computed(() => {
|
||||
const data = dataset.value
|
||||
if (!data) return []
|
||||
switch (dimension.value) {
|
||||
case 'category':
|
||||
return data.categories
|
||||
case 'method':
|
||||
return data.methods
|
||||
case 'campus':
|
||||
return data.campuses
|
||||
default:
|
||||
return data.departments
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 下钻状态拆成「是否展开」+「哪一组」两个值。
|
||||
@@ -70,37 +85,67 @@ const dimension = ref<ReportSummaryDimension>('department')
|
||||
*/
|
||||
const drillVisible = ref(false)
|
||||
const drillKey = ref<string | null>(null)
|
||||
const drillRows = ref<ReportTaskRow[]>([])
|
||||
const drillTotal = ref(0)
|
||||
const drillPage = ref(1)
|
||||
const drillLoading = ref(false)
|
||||
const drillError = ref('')
|
||||
const drillPageSize = 25
|
||||
|
||||
function handleDimensionChange(next: ReportSummaryDimension) {
|
||||
dimension.value = next
|
||||
drillVisible.value = false
|
||||
drillKey.value = null
|
||||
drillPage.value = 1
|
||||
}
|
||||
|
||||
async function loadDrill() {
|
||||
const window = dateWindow.value
|
||||
if (!window || !drillVisible.value) return
|
||||
drillLoading.value = true
|
||||
drillError.value = ''
|
||||
try {
|
||||
const query = buildReportAnalyticsQuery(reportFilter, window)
|
||||
const result = await getReportAnalyticsTasks({
|
||||
...query,
|
||||
page: drillPage.value,
|
||||
size: drillPageSize,
|
||||
...(drillKey.value === null
|
||||
? {}
|
||||
: { dimension: dimension.value, groupValue: drillKey.value }),
|
||||
})
|
||||
drillRows.value = result.records
|
||||
drillTotal.value = result.total
|
||||
} catch {
|
||||
drillError.value = '下钻明细加载失败,请重试'
|
||||
} finally {
|
||||
drillLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrill(key: string | null) {
|
||||
drillKey.value = key
|
||||
drillVisible.value = true
|
||||
drillPage.value = 1
|
||||
void loadDrill()
|
||||
}
|
||||
|
||||
const drillRows = computed<ReportTaskRow[]>(() => {
|
||||
if (drillKey.value === null) {
|
||||
return tasks.value
|
||||
}
|
||||
|
||||
return tasks.value.filter((task) => groupKeyOf(task, dimension.value) === drillKey.value)
|
||||
watch([dimension, drillPage, () => dataset.value?.generatedAt], () => {
|
||||
if (drillVisible.value) void loadDrill()
|
||||
})
|
||||
|
||||
const drillTitle = computed(() =>
|
||||
drillKey.value ? `明细 · ${drillKey.value}` : '全部任务明细',
|
||||
)
|
||||
const drillTitle = computed(() => (drillKey.value ? `明细 · ${drillKey.value}` : '全部任务明细'))
|
||||
|
||||
const drillMeta = computed(() =>
|
||||
`${drillKey.value ? `${SUMMARY_DIMENSION_LABELS[dimension.value]} = ${drillKey.value}` : '当前筛选条件'} · 共 ${drillRows.value.length} 条`,
|
||||
const drillMeta = computed(
|
||||
() =>
|
||||
`${drillKey.value ? `${SUMMARY_DIMENSION_LABELS[dimension.value]} = ${drillKey.value}` : '当前筛选条件'} · 共 ${drillTotal.value} 条`,
|
||||
)
|
||||
|
||||
function durationText(row: ReportTaskRow) {
|
||||
return row.signMinutes === undefined ? '—' : formatDuration(row.signMinutes)
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(drillTotal.value / drillPageSize)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,13 +155,17 @@ function durationText(row: ReportTaskRow) {
|
||||
>
|
||||
<ReportStatTiles :items="tiles" />
|
||||
|
||||
<ReportCard title="每日发起与完成趋势" subtitle="发起按任务创建日统计,完成按签署完成日统计" class="gap-top">
|
||||
<ReportCard
|
||||
title="每日发起与完成趋势"
|
||||
subtitle="发起按任务创建日统计,完成按签署完成日统计"
|
||||
class="gap-top"
|
||||
>
|
||||
<ReportBarChart :points="trendPoints" :series="trendSeries" unit="单" />
|
||||
</ReportCard>
|
||||
|
||||
<ReportCard title="签署汇总矩阵" class="gap-top">
|
||||
<ReportSummaryMatrix
|
||||
:tasks="tasks"
|
||||
:rows="groupRows"
|
||||
:dimension="dimension"
|
||||
@update:dimension="handleDimensionChange"
|
||||
@drill="handleDrill"
|
||||
@@ -130,6 +179,7 @@ function durationText(row: ReportTaskRow) {
|
||||
<button type="button" class="link-button" @click="drillVisible = false">收起</button>
|
||||
</template>
|
||||
|
||||
<p v-if="drillError" class="drill-note">{{ drillError }}</p>
|
||||
<div class="drill-scroll">
|
||||
<table class="drill-table">
|
||||
<thead>
|
||||
@@ -145,12 +195,17 @@ function durationText(row: ReportTaskRow) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!drillRows.length">
|
||||
<tr v-if="drillLoading">
|
||||
<td colspan="8" class="drill-empty">正在读取明细…</td>
|
||||
</tr>
|
||||
<tr v-else-if="!drillRows.length">
|
||||
<td colspan="8" class="drill-empty">该分组在当前筛选条件下暂无任务</td>
|
||||
</tr>
|
||||
<tr v-for="row in drillRows" :key="row.id">
|
||||
<td class="mono">{{ row.id }}</td>
|
||||
<td><b>{{ row.patientName }}</b></td>
|
||||
<td>
|
||||
<b>{{ row.patientName }}</b>
|
||||
</td>
|
||||
<td>{{ row.documentName }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ METHOD_LABELS[row.method] }}</td>
|
||||
@@ -162,6 +217,20 @@ function durationText(row: ReportTaskRow) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="drillTotal > drillPageSize" class="drill-pagination">
|
||||
<button type="button" :disabled="drillPage <= 1 || drillLoading" @click="drillPage--">
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {{ drillPage }} / {{ totalPages }} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="drillPage >= totalPages || drillLoading"
|
||||
@click="drillPage++"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="drill-note">
|
||||
下钻明细只读,用于核对数字来源;需要查看文书原文请到「签署记录查询」。
|
||||
</p>
|
||||
@@ -237,4 +306,27 @@ function durationText(row: ReportTaskRow) {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.drill-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drill-pagination button {
|
||||
padding: 5px 10px;
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.drill-pagination button:disabled {
|
||||
color: #9aa8ae;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { buildPatientStructure } from '../aggregate'
|
||||
import ReportBarList from '../components/ReportBarList.vue'
|
||||
import ReportCard from '../components/ReportCard.vue'
|
||||
import ReportPageShell from '../components/ReportPageShell.vue'
|
||||
@@ -16,19 +15,49 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 120 个患者,进而拿去做人群占比,结论就偏了。
|
||||
*/
|
||||
|
||||
const { tasks } = useReportDataset()
|
||||
|
||||
const structure = computed(() => buildPatientStructure(tasks.value))
|
||||
const { dataset } = useReportDataset()
|
||||
const structure = computed(() => dataset.value?.patientStructure)
|
||||
|
||||
function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
|
||||
return items.map((item, index) => ({ ...item, color: colors[index % colors.length] }))
|
||||
}
|
||||
|
||||
const ageItems = computed(() =>
|
||||
withColors(structure.value.age, ['#18a0c0', '#0e6e8c', '#0f9d6c', '#7a4dbb']),
|
||||
withColors(
|
||||
(structure.value?.age ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次`,
|
||||
})),
|
||||
['#18a0c0', '#0e6e8c', '#0f9d6c', '#7a4dbb', '#9aa8ae'],
|
||||
),
|
||||
)
|
||||
|
||||
const sexItems = computed(() => withColors(structure.value.sex, ['#3a7bd5', '#d4537e']))
|
||||
const total = computed(() => dataset.value?.statusCounts.total ?? 0)
|
||||
const sexItems = computed(() =>
|
||||
withColors(
|
||||
(structure.value?.sex ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次 · ${total.value ? Math.round((item.value / total.value) * 100) : 0}%`,
|
||||
})),
|
||||
['#3a7bd5', '#d4537e', '#9aa8ae'],
|
||||
),
|
||||
)
|
||||
const visitTypeItems = computed(() =>
|
||||
(structure.value?.visitType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次`,
|
||||
})),
|
||||
)
|
||||
const campusItems = computed(() =>
|
||||
(structure.value?.campus ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次`,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,17 +77,17 @@ const sexItems = computed(() => withColors(structure.value.sex, ['#3a7bd5', '#d4
|
||||
|
||||
<div class="pat-row">
|
||||
<ReportCard title="就诊类型分布" subtitle="门诊 / 住院 / 体检 / 其他">
|
||||
<ReportBarList :items="structure.visitType" :label-width="70" />
|
||||
<ReportBarList :items="visitTypeItems" :label-width="70" />
|
||||
</ReportCard>
|
||||
|
||||
<ReportCard title="院区患者分布" subtitle="按患者所属院区统计">
|
||||
<ReportBarList :items="structure.campus" :label-width="100" />
|
||||
<ReportBarList :items="campusItems" :label-width="100" />
|
||||
</ReportCard>
|
||||
</div>
|
||||
|
||||
<p class="pat-note">
|
||||
四个维度统计的都是<b>人次</b>而非人数:同一患者在当前筛选条件下有多次签署任务会重复计数,
|
||||
因此各维度合计等于任务数,不等于去重后的患者数。需要按人去重请由后端提供患者维度聚合。
|
||||
因此各维度合计等于任务数,不等于去重后的患者数。本页仅返回聚合计数,不在浏览器传输患者明细。
|
||||
</p>
|
||||
</ReportPageShell>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
buildDailyAverageDuration,
|
||||
buildDurationByDepartment,
|
||||
buildOverdueTasks,
|
||||
buildTimeliness,
|
||||
formatDuration,
|
||||
OVERDUE_MINUTES,
|
||||
} from '../aggregate'
|
||||
import { axisLabelOf, formatDuration, OVERDUE_MINUTES } from '../aggregate'
|
||||
import ReportBarChart from '../components/ReportBarChart.vue'
|
||||
import ReportBarList from '../components/ReportBarList.vue'
|
||||
import ReportCard from '../components/ReportCard.vue'
|
||||
@@ -25,60 +18,77 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 指标显示「—」而不是 0,0 会被读成「表现极差」。
|
||||
*/
|
||||
|
||||
const { tasks, window: dateWindow } = useReportDataset()
|
||||
const { dataset } = useReportDataset()
|
||||
|
||||
const metrics = computed(() => buildTimeliness(tasks.value))
|
||||
const metrics = computed(() => dataset.value?.timeliness)
|
||||
|
||||
const kpis = computed(() => [
|
||||
{
|
||||
label: '平均签署时长',
|
||||
value:
|
||||
metrics.value.averageMinutes === null ? null : formatDuration(metrics.value.averageMinutes),
|
||||
metrics.value?.averageMinutes == null ? null : formatDuration(metrics.value.averageMinutes),
|
||||
icon: 'clock' as const,
|
||||
color: '#3a7bd5',
|
||||
softColor: 'var(--info-l)',
|
||||
},
|
||||
{
|
||||
label: '24 小时内完成率',
|
||||
value: metrics.value.within24hRate === null ? null : `${metrics.value.within24hRate}%`,
|
||||
value: metrics.value?.within24hRate == null ? null : `${metrics.value.within24hRate}%`,
|
||||
icon: 'check' as const,
|
||||
color: '#0f9d6c',
|
||||
softColor: 'var(--ok-l)',
|
||||
},
|
||||
{
|
||||
label: '超期完成占比',
|
||||
value: metrics.value.overdueRate === null ? null : `${metrics.value.overdueRate}%`,
|
||||
value: metrics.value?.overdueRate == null ? null : `${metrics.value.overdueRate}%`,
|
||||
icon: 'alert' as const,
|
||||
color: '#d9821f',
|
||||
softColor: 'var(--warn-l)',
|
||||
},
|
||||
{
|
||||
label: '超时未签任务',
|
||||
value: metrics.value.expiredTaskCount,
|
||||
value: metrics.value?.expiredTaskCount ?? 0,
|
||||
icon: 'warning' as const,
|
||||
color: '#d0534f',
|
||||
softColor: 'var(--err-l)',
|
||||
},
|
||||
])
|
||||
|
||||
const departmentDuration = computed(() => buildDurationByDepartment(tasks.value))
|
||||
const departmentDuration = computed(() =>
|
||||
(metrics.value?.departmentAverages ?? []).map((item) => ({
|
||||
...item,
|
||||
display: formatDuration(item.value),
|
||||
color:
|
||||
item.value > OVERDUE_MINUTES
|
||||
? '#d0534f'
|
||||
: item.value > OVERDUE_MINUTES / 2
|
||||
? '#d9821f'
|
||||
: undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const dailyDurationPoints = computed<ReportChartPoint[]>(() =>
|
||||
buildDailyAverageDuration(tasks.value, dateWindow.value).map((item) => ({
|
||||
label: item.label,
|
||||
values: [item.value],
|
||||
(metrics.value?.dailyAverages ?? []).map((item) => ({
|
||||
label: axisLabelOf(item.date),
|
||||
values: [item.averageMinutes],
|
||||
})),
|
||||
)
|
||||
|
||||
const dailyDurationSeries = [{ label: '平均签署时长', color: '#18a0c0' }]
|
||||
|
||||
const overdueTasks = computed(() => buildOverdueTasks(tasks.value))
|
||||
const overdueTasks = computed(() => metrics.value?.overdueTasks ?? [])
|
||||
|
||||
const sampleNote = computed(() =>
|
||||
metrics.value.signedSampleCount
|
||||
metrics.value?.signedSampleCount
|
||||
? `基于 ${metrics.value.signedSampleCount} 条已签署任务`
|
||||
: '当前条件下没有已签署任务',
|
||||
)
|
||||
|
||||
const overdueNote = computed(() => {
|
||||
const total = metrics.value?.overdueTaskCount ?? 0
|
||||
const shown = overdueTasks.value.length
|
||||
return total > shown ? ` · 展示用时最长的 ${shown} 条,共 ${total} 条` : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,18 +103,17 @@ const sampleNote = computed(() =>
|
||||
<ReportBarList :items="departmentDuration" />
|
||||
</ReportCard>
|
||||
|
||||
<ReportCard title="每日平均签署时长" :subtitle="`近 ${dailyDurationPoints.length} 天完成任务均值(分钟)`">
|
||||
<ReportBarChart
|
||||
:points="dailyDurationPoints"
|
||||
:series="dailyDurationSeries"
|
||||
unit="分钟"
|
||||
/>
|
||||
<ReportCard
|
||||
title="每日平均签署时长"
|
||||
:subtitle="`近 ${dailyDurationPoints.length} 天完成任务均值(分钟)`"
|
||||
>
|
||||
<ReportBarChart :points="dailyDurationPoints" :series="dailyDurationSeries" unit="分钟" />
|
||||
</ReportCard>
|
||||
</div>
|
||||
|
||||
<ReportCard
|
||||
title="超期完成任务"
|
||||
:subtitle="`完成用时超过 48 小时链接有效期(${OVERDUE_MINUTES / 60} 小时)的已签署任务 · ${sampleNote}`"
|
||||
:subtitle="`完成用时超过 48 小时链接有效期(${OVERDUE_MINUTES / 60} 小时)的已签署任务 · ${sampleNote}${overdueNote}`"
|
||||
class="gap-top"
|
||||
>
|
||||
<div class="overdue-scroll">
|
||||
@@ -126,12 +135,16 @@ const sampleNote = computed(() =>
|
||||
</tr>
|
||||
<tr v-for="row in overdueTasks" :key="row.id">
|
||||
<td class="mono">{{ row.id }}</td>
|
||||
<td><b>{{ row.patientName }}</b></td>
|
||||
<td>
|
||||
<b>{{ row.patientName }}</b>
|
||||
</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.documentName }}</td>
|
||||
<td>{{ row.createdAt }}</td>
|
||||
<td>{{ row.signedAt }}</td>
|
||||
<td><span class="overdue-tag">{{ formatDuration(row.signMinutes) }}</span></td>
|
||||
<td>
|
||||
<span class="overdue-tag">{{ formatDuration(row.signMinutes) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,91 +1,111 @@
|
||||
/**
|
||||
* 报表分析的数据装载与筛选:四个菜单共用同一个数据集与同一份筛选条件。
|
||||
*
|
||||
* 数据集放在模块级而不是每个页面各取一次:四个菜单的数据源完全一样,
|
||||
* 各取一次会生成四份互不相干的演示数据 —— 从「签署总览」切到「签署量分析」,
|
||||
* 累计发起数会变,看起来像统计口径不同,其实只是随机数不同。
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
getReportAnalyticsDataset,
|
||||
buildReportAnalyticsQuery,
|
||||
getReportAnalytics,
|
||||
getReportOptions,
|
||||
type ReportAnalyticsDataset,
|
||||
type ReportTaskRow,
|
||||
type ReportOptions,
|
||||
} from '@/api/workbench/report-analytics'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
|
||||
import { filterReportTasks, resolveDateWindow } from './aggregate'
|
||||
import { resolveDateWindow } from './aggregate'
|
||||
import { reportFilter } from './filter-store'
|
||||
|
||||
const dataset = ref<ReportAnalyticsDataset | null>(null)
|
||||
const options = ref<ReportOptions | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const optionsError = ref('')
|
||||
|
||||
let pending: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* 装载数据集。
|
||||
*
|
||||
* 并发去重:页面挂载与筛选条刷新可能同时触发,重复请求没有意义。
|
||||
* 只取一次成功后就不再重取 —— 演示数据是确定性的,重取只会换掉「数据截至」的时刻。
|
||||
*/
|
||||
export function loadReportDataset(): Promise<void> {
|
||||
if (dataset.value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pending ??= (async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
let requestSequence = 0
|
||||
let pendingOptions: Promise<void> | null = null
|
||||
let filterWatcherInstalled = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function loadReportOptions(): Promise<void> {
|
||||
if (options.value) return Promise.resolve()
|
||||
pendingOptions ??= (async () => {
|
||||
optionsError.value = ''
|
||||
try {
|
||||
dataset.value = await getReportAnalyticsDataset()
|
||||
} catch {
|
||||
errorMessage.value = '报表数据加载失败,请稍后重试'
|
||||
options.value = await getReportOptions()
|
||||
} catch (error) {
|
||||
optionsError.value = toUserMessage(error, '报表筛选项加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
pending = null
|
||||
pendingOptions = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pending
|
||||
return pendingOptions
|
||||
}
|
||||
|
||||
export async function loadReportDataset(): Promise<void> {
|
||||
const sequence = ++requestSequence
|
||||
dataset.value = null
|
||||
const dateWindow = resolveDateWindow(reportFilter)
|
||||
if (!dateWindow) {
|
||||
errorMessage.value = ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const query = buildReportAnalyticsQuery(reportFilter, dateWindow)
|
||||
const result = await getReportAnalytics(query)
|
||||
if (sequence === requestSequence) dataset.value = result
|
||||
} catch (error) {
|
||||
if (sequence === requestSequence) errorMessage.value = toUserMessage(error, '报表数据加载失败')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function installFilterWatcher() {
|
||||
if (filterWatcherInstalled) return
|
||||
filterWatcherInstalled = true
|
||||
watch(
|
||||
() => [
|
||||
reportFilter.dateRange,
|
||||
reportFilter.customFrom,
|
||||
reportFilter.customTo,
|
||||
reportFilter.campus,
|
||||
reportFilter.department,
|
||||
reportFilter.category,
|
||||
reportFilter.method,
|
||||
],
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => void loadReportDataset(), 250)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 仅供演示 / 验证脚本:清掉缓存后重新装载 */
|
||||
export function resetReportDataset(): void {
|
||||
dataset.value = null
|
||||
pending = null
|
||||
requestSequence++
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
export function useReportDataset() {
|
||||
/** 当前筛选对应的时间窗口;null 表示自定义区间还没填完 */
|
||||
installFilterWatcher()
|
||||
const window = computed(() => resolveDateWindow(reportFilter))
|
||||
|
||||
const allTasks = computed<ReportTaskRow[]>(() => dataset.value?.tasks ?? [])
|
||||
|
||||
/** 当前筛选下的任务明细:四个页面的所有数字都从这里算 */
|
||||
const tasks = computed<ReportTaskRow[]>(() => filterReportTasks(allTasks.value, reportFilter))
|
||||
|
||||
const available = computed(() => dataset.value?.available ?? false)
|
||||
const available = computed(() => dataset.value !== null)
|
||||
const generatedAt = computed(() => dataset.value?.generatedAt ?? '')
|
||||
|
||||
/**
|
||||
* 自定义区间只填了一头时为 true。
|
||||
* 这种半截条件既不该按半截筛,也不该悄悄套默认窗口,页面据此提示用户补全。
|
||||
*/
|
||||
const customRangeIncomplete = computed(
|
||||
() =>
|
||||
reportFilter.dateRange === 'custom' && (!reportFilter.customFrom || !reportFilter.customTo),
|
||||
)
|
||||
|
||||
return {
|
||||
allTasks,
|
||||
tasks,
|
||||
dataset,
|
||||
options,
|
||||
window,
|
||||
available,
|
||||
generatedAt,
|
||||
loading,
|
||||
errorMessage,
|
||||
optionsError,
|
||||
customRangeIncomplete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { buildRanking, METHOD_LABELS } from '../aggregate'
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
import { METHOD_LABELS } from '../aggregate'
|
||||
import ReportBarList from '../components/ReportBarList.vue'
|
||||
import ReportCard from '../components/ReportCard.vue'
|
||||
import ReportPageShell from '../components/ReportPageShell.vue'
|
||||
@@ -15,9 +17,7 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 「签署量」问的是真的签掉了多少,把待签署、已作废一起算进来会让排行失去意义。
|
||||
*/
|
||||
|
||||
const { tasks } = useReportDataset()
|
||||
|
||||
const signedTasks = computed(() => tasks.value.filter((task) => task.status === 'signed'))
|
||||
const { dataset } = useReportDataset()
|
||||
|
||||
/** 按原型的分色口径给榜单配色:同一个榜单里颜色只是区分项,不表达好坏 */
|
||||
function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
|
||||
@@ -25,33 +25,44 @@ function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
|
||||
}
|
||||
|
||||
const byDepartment = computed(() =>
|
||||
buildRanking(signedTasks.value, (task) => task.department || '其他'),
|
||||
(dataset.value?.departments ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({ label: row.key, value: row.signed })),
|
||||
)
|
||||
|
||||
const byMethod = computed(() =>
|
||||
withColors(
|
||||
buildRanking(signedTasks.value, (task) => METHOD_LABELS[task.method]),
|
||||
(dataset.value?.methods ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({
|
||||
label: METHOD_LABELS[row.key as SigningMethod] ?? row.key,
|
||||
value: row.signed,
|
||||
})),
|
||||
['#0e6e8c', '#18a0c0', '#7a4dbb'],
|
||||
),
|
||||
)
|
||||
|
||||
const byCategory = computed(() =>
|
||||
withColors(
|
||||
buildRanking(signedTasks.value, (task) => task.category || '其他'),
|
||||
(dataset.value?.categories ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({ label: row.key, value: row.signed })),
|
||||
['#0f9d6c', '#d9821f', '#0e6e8c'],
|
||||
),
|
||||
)
|
||||
|
||||
const topDocuments = computed(() =>
|
||||
withColors(
|
||||
buildRanking(signedTasks.value, (task) => task.documentName, 10),
|
||||
(dataset.value?.documents ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({ label: row.key, value: row.signed })),
|
||||
['#18a0c0', '#0e6e8c', '#0f9d6c'],
|
||||
),
|
||||
)
|
||||
|
||||
const summary = computed(() => {
|
||||
const total = tasks.value.length
|
||||
const signed = signedTasks.value.length
|
||||
const total = dataset.value?.statusCounts.total ?? 0
|
||||
const signed = dataset.value?.statusCounts.signed ?? 0
|
||||
|
||||
return `已签署 ${signed} 单,占当前筛选 ${total} 单的 ${total ? Math.round((signed / total) * 100) : 0}%`
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user