docs(reports): 更新报表接口接入文档与实现说明,报表分析接口接入

This commit is contained in:
yelan
2026-09-02 16:51:35 +08:00
parent e52f203123
commit 612e43aeff
10 changed files with 1474 additions and 795 deletions
@@ -3,15 +3,26 @@ import type { ReportTask, ReportTaskStatus } from '../types'
defineProps<{
rows: ReportTask[]
page: number
pageSize: number
total: number
pages: number
loading: boolean
error: string
pageSizeOptions: number[]
}>()
const emit = defineEmits<{
view: [row: ReportTask]
'page-change': [page: number]
'page-size-change': [size: number]
retry: []
}>()
const statusLabels: Record<ReportTaskStatus, string> = {
signed: '已签署',
pending: '待签署',
signing: '签署中',
expired: '已超时',
void: '已作废',
failed: '处理失败',
@@ -29,52 +40,146 @@ function getStatusClass(status: ReportTaskStatus) {
return `status-${status}`
}
function formatTime(date: Date) {
function formatTime(date: Date | undefined) {
if (!date) {
return '—'
}
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function formatDuration(row: ReportTask) {
const seconds =
row.durationSeconds !== undefined
? row.durationSeconds
: row.durationMinutes !== undefined
? row.durationMinutes * 60
: undefined
if (seconds === undefined || !Number.isFinite(seconds)) {
return '—'
}
const roundedSeconds = Math.round(seconds)
const minutes = Math.floor(roundedSeconds / 60)
const restSeconds = roundedSeconds % 60
if (!minutes) {
return String(restSeconds) + '秒'
}
return restSeconds
? String(minutes) + '分' + String(restSeconds) + '秒'
: String(minutes) + '分钟'
}
function getPageCount(pages: number) {
return Math.max(pages, 1)
}
function handlePreviousPage(page: number) {
if (page > 1) {
emit('page-change', page - 1)
}
}
function handleNextPage(page: number, pages: number) {
if (page < getPageCount(pages)) {
emit('page-change', page + 1)
}
}
function handlePageSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value)) {
emit('page-size-change', value)
}
}
</script>
<template>
<div class="table-scroll">
<table class="detail-table">
<thead>
<tr>
<th>任务号</th>
<th>患者</th>
<th>文档</th>
<th>科室</th>
<th>方式</th>
<th>状态</th>
<th>发起时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td>
<strong>{{ row.patientName }}</strong>
<span class="patient-id">{{ row.patientId }}</span>
</td>
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
<td>{{ row.department }}</td>
<td>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
<td>
<span class="status-tag" :class="getStatusClass(row.status)">
{{ getStatusLabel(row) }}
</span>
</td>
<td>{{ formatTime(row.createdAt) }}</td>
<td>
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty-cell">暂无符合条件的签署明细</td>
</tr>
</tbody>
</table>
<div v-if="error" class="table-error" role="alert">
<span>{{ error }}</span>
<button type="button" @click="emit('retry')">重试</button>
</div>
<div v-else class="table-container" :class="{ 'is-loading': loading }" :aria-busy="loading">
<div class="table-scroll">
<table class="detail-table">
<thead>
<tr>
<th>任务号</th>
<th>患者</th>
<th>文档</th>
<th>科室</th>
<th>方式</th>
<th>状态</th>
<th>发起时间</th>
<th>签署完成</th>
<th>耗时</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td>
<strong>{{ row.patientName }}</strong>
<span class="patient-id">{{ row.patientId }}</span>
</td>
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
<td>{{ row.department }}</td>
<td>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
<td>
<span class="status-tag" :class="getStatusClass(row.status)">
{{ getStatusLabel(row) }}
</span>
</td>
<td>{{ formatTime(row.createdAt) }}</td>
<td>{{ formatTime(row.signedAt) }}</td>
<td>{{ formatDuration(row) }}</td>
<td>
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="10" class="empty-cell">暂无符合条件的签署明细</td>
</tr>
</tbody>
</table>
</div>
<div v-if="total" class="table-footer">
<span>共 {{ total }} 条</span>
<label>
每页
<select :value="pageSize" aria-label="选择每页条数" @change="handlePageSizeChange">
<option v-for="option in pageSizeOptions" :key="option" :value="option">
{{ option }}
</option>
</select>
条
</label>
<button
type="button"
class="page-button"
:disabled="page <= 1 || loading"
@click="handlePreviousPage(page)"
>
上一页
</button>
<span>第 {{ page }} / {{ getPageCount(pages) }} 页</span>
<button
type="button"
class="page-button"
:disabled="page >= getPageCount(pages) || loading"
@click="handleNextPage(page, pages)"
>
下一页
</button>
</div>
</div>
</template>
@@ -84,6 +189,16 @@ function formatTime(date: Date) {
overflow-x: auto;
}
.table-container {
position: relative;
}
.table-container.is-loading {
min-height: 120px;
pointer-events: none;
opacity: 0.55;
}
.detail-table {
width: 100%;
min-width: 1000px;
@@ -145,6 +260,11 @@ function formatTime(date: Date) {
background: var(--brand-l);
}
.status-signing {
color: var(--info);
background: var(--info-l);
}
.status-expired {
color: var(--err);
background: var(--err-l);
@@ -178,4 +298,53 @@ function formatTime(date: Date) {
color: var(--mut);
text-align: center;
}
.table-error {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 90px;
color: var(--err);
font-size: 12px;
}
.table-error button,
.page-button {
padding: 5px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.table-footer {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
padding-top: 12px;
color: var(--mut);
font-size: 12px;
}
.table-footer label {
display: inline-flex;
gap: 4px;
align-items: center;
}
.table-footer select {
padding: 3px 5px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 4px;
}
.page-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
</style>
@@ -2,6 +2,7 @@
import type {
ReportCampus,
ReportDocumentCategory,
ReportMethodFilter,
ReportPeriodOption,
ReportSelectOption,
} from '../types'
@@ -11,12 +12,17 @@ defineProps<{
campus: ReportCampus
department: string
category: ReportDocumentCategory
method: ReportMethodFilter
keyword: string
customFrom: string
customTo: string
periodOptions: ReportSelectOption<ReportPeriodOption>[]
campusOptions: ReportSelectOption<ReportCampus>[]
departmentOptions: ReportSelectOption[]
categoryOptions: ReportSelectOption<ReportDocumentCategory>[]
methodOptions: ReportSelectOption<ReportMethodFilter>[]
exportLoading: boolean
exportStatus: string
}>()
const emit = defineEmits<{
@@ -24,29 +30,39 @@ const emit = defineEmits<{
'update:campus': [value: ReportCampus]
'update:department': [value: string]
'update:category': [value: ReportDocumentCategory]
'update:method': [value: ReportMethodFilter]
'update:keyword': [value: string]
'update:customFrom': [value: string]
'update:customTo': [value: string]
export: []
}>()
function readSelectValue(event: Event) {
function readValue(event: Event) {
return (event.target as HTMLSelectElement).value
}
function handlePeriodChange(event: Event) {
emit('update:period', readSelectValue(event) as ReportPeriodOption)
emit('update:period', readValue(event) as ReportPeriodOption)
}
function handleCampusChange(event: Event) {
emit('update:campus', readSelectValue(event) as ReportCampus)
emit('update:campus', readValue(event) as ReportCampus)
}
function handleDepartmentChange(event: Event) {
emit('update:department', readSelectValue(event))
emit('update:department', readValue(event))
}
function handleCategoryChange(event: Event) {
emit('update:category', readSelectValue(event) as ReportDocumentCategory)
emit('update:category', readValue(event) as ReportDocumentCategory)
}
function handleMethodChange(event: Event) {
emit('update:method', readValue(event) as ReportMethodFilter)
}
function handleKeywordChange(event: Event) {
emit('update:keyword', readValue(event))
}
</script>
@@ -89,6 +105,27 @@ function handleCategoryChange(event: Event) {
</select>
</label>
<label class="filter-field">
<span>方式</span>
<select :value="method" aria-label="选择签署方式" @change="handleMethodChange">
<option v-for="option in methodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field keyword-field">
<span>关键词</span>
<input
type="search"
:value="keyword"
maxlength="128"
placeholder="任务号/患者/文档"
aria-label="搜索报表关键词"
@change="handleKeywordChange"
/>
</label>
<template v-if="period === 'custom'">
<label class="filter-field date-field">
<span>起始</span>
@@ -96,7 +133,7 @@ function handleCategoryChange(event: Event) {
type="date"
:value="customFrom"
aria-label="选择报表起始日期"
@change="emit('update:customFrom', readSelectValue($event))"
@change="emit('update:customFrom', readValue($event))"
/>
</label>
<label class="filter-field date-field">
@@ -105,13 +142,16 @@ function handleCategoryChange(event: Event) {
type="date"
:value="customTo"
aria-label="选择报表结束日期"
@change="emit('update:customTo', readSelectValue($event))"
@change="emit('update:customTo', readValue($event))"
/>
</label>
</template>
<span class="filter-spacer" />
<button type="button" class="export-button" @click="emit('export')">导出</button>
<span v-if="exportStatus" class="export-status" role="status">{{ exportStatus }}</span>
<button type="button" class="export-button" :disabled="exportLoading" @click="emit('export')">
{{ exportLoading ? '导出中…' : '导出' }}
</button>
</div>
</section>
</template>
@@ -161,6 +201,10 @@ function handleCategoryChange(event: Event) {
border-radius: 6px;
}
.keyword-field input {
width: 180px;
}
.filter-field input:focus {
outline: none;
border-color: var(--brand);
@@ -190,6 +234,20 @@ function handleCategoryChange(event: Event) {
background: var(--brand-l);
}
.export-button:disabled {
cursor: wait;
opacity: 0.6;
}
.export-status {
max-width: 240px;
overflow: hidden;
color: var(--mut);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.filter-spacer {
display: none;
@@ -49,7 +49,10 @@ defineProps<{
</div>
<div>
<div class="metric-value">
{{ metric.value }}<span v-if="metric.unit" class="metric-unit">{{ metric.unit }}</span>
{{ metric.value
}}<span v-if="metric.unit && metric.value !== '—'" class="metric-unit">{{
metric.unit
}}</span>
</div>
<div class="metric-label">{{ metric.label }}</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -3,13 +3,17 @@ export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
/** 院区由后端字典提供,保留 all 作为全院筛选值。 */
export type ReportCampus = 'all' | (string & {})
export type ReportDocumentCategory = 'all' | 'consent' | 'risk' | 'admission'
export type ReportDocumentCategory = 'all' | (string & {})
export type ReportMethodFilter = 'all' | 'pad' | 'sms'
export interface ReportFilterForm {
period: ReportPeriodOption
campus: ReportCampus
department: string
category: ReportDocumentCategory
method: ReportMethodFilter
keyword: string
customFrom: string
customTo: string
}
@@ -24,7 +28,7 @@ export interface ReportMetricCard {
tone: ReportMetricTone
}
export type ReportTaskStatus = 'signed' | 'pending' | 'expired' | 'void' | 'failed'
export type ReportTaskStatus = 'signed' | 'pending' | 'signing' | 'expired' | 'void' | 'failed'
export type ReportSigningMethod = 'pad' | 'sms'
@@ -40,8 +44,13 @@ export interface ReportTask {
method: ReportSigningMethod
status: ReportTaskStatus
createdAt: Date
deadlineAt?: Date
signStartedAt?: Date
signedAt?: Date
expiredAt?: Date
voidedAt?: Date
onTime?: boolean
durationSeconds?: number
durationMinutes?: number
}