docs(reports): 更新报表接口接入文档与实现说明,报表分析接口接入
This commit is contained in:
@@ -1,72 +1,121 @@
|
||||
import { getCampuses, getDepartments } from './organization'
|
||||
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types/common'
|
||||
|
||||
import type { ReportTaskData } from './types'
|
||||
import type {
|
||||
ReportExportJobResponseDto,
|
||||
ReportExportRequestDto,
|
||||
ReportFilterDto,
|
||||
ReportOptionsResponseDto,
|
||||
ReportOverviewResponseDto,
|
||||
ReportPageResponseDto,
|
||||
ReportTaskResponseDto,
|
||||
ReportTasksQueryDto,
|
||||
} from './types'
|
||||
|
||||
const REPORT_PAGE_SIZE = 200
|
||||
export const isReportsMockEnabled = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
|
||||
export const isReportsMockEnabled = isSigningMockEnabled
|
||||
function compactQuery(values: object) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(
|
||||
([, value]) => value !== undefined && value !== null && value !== '',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function buildSigningApiOptions(data: ReportTaskData) {
|
||||
function normalizeReportOptions(data: ReportOptionsResponseDto): ReportOptionsResponseDto {
|
||||
return {
|
||||
templates: data.templates,
|
||||
campusNames: Object.fromEntries(
|
||||
data.campuses
|
||||
.filter((campus) => Boolean(campus.name.trim()))
|
||||
.map((campus) => [campus.id, campus.name]),
|
||||
) as Record<string, WorkbenchCampus>,
|
||||
campuses: data.campuses ?? [],
|
||||
departments: data.departments ?? [],
|
||||
templateCategories: data.templateCategories ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllReportTasks(data: ReportTaskData) {
|
||||
const options = buildSigningApiOptions(data)
|
||||
const query = {
|
||||
page: 1,
|
||||
pageSize: REPORT_PAGE_SIZE,
|
||||
status: 'all' as const,
|
||||
dateRange: 'all' as const,
|
||||
campus: 'all' as const,
|
||||
method: 'all' as const,
|
||||
function normalizeReportOverview(data: ReportOverviewResponseDto): ReportOverviewResponseDto {
|
||||
return {
|
||||
timezone: data.timezone ?? 'Asia/Shanghai',
|
||||
generatedAt: data.generatedAt ?? '',
|
||||
snapshotAt: data.snapshotAt ?? '',
|
||||
metrics: {
|
||||
initiatedCount: data.metrics?.initiatedCount ?? 0,
|
||||
signedCount: data.metrics?.signedCount ?? 0,
|
||||
onTimeSignedCount: data.metrics?.onTimeSignedCount ?? 0,
|
||||
onTimeSampleCount: data.metrics?.onTimeSampleCount ?? 0,
|
||||
onTimeRate: data.metrics?.onTimeRate ?? null,
|
||||
averageSignDurationSeconds: data.metrics?.averageSignDurationSeconds ?? null,
|
||||
durationSampleCount: data.metrics?.durationSampleCount ?? 0,
|
||||
},
|
||||
trend: data.trend ?? [],
|
||||
departments: data.departments ?? [],
|
||||
}
|
||||
const firstPage = await getSigningTasks(query, options)
|
||||
const records = [...firstPage.records]
|
||||
const pageSize = Math.max(firstPage.pageSize, 1)
|
||||
const totalPages = Math.ceil(firstPage.total / pageSize)
|
||||
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
const nextPage = await getSigningTasks({ ...query, page }, options)
|
||||
records.push(...nextPage.records)
|
||||
|
||||
if (!nextPage.records.length) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询报表所需的全部可见任务。
|
||||
* 当前后端没有聚合报表接口,因此这里按最大页大小读取签署任务;聚合接口上线后只需替换本函数。
|
||||
* ReportFilter/ReportTasksQuery are object query parameters in OpenAPI's default
|
||||
* form/explode representation, so fields are sent flat (createdFrom=..., etc.).
|
||||
*/
|
||||
export async function getReportTaskData(): Promise<ReportTaskData> {
|
||||
if (isReportsMockEnabled) {
|
||||
return { tasks: [], templates: [], campuses: [], departments: [] }
|
||||
}
|
||||
export async function getReportOptions(
|
||||
filter: ReportFilterDto = {},
|
||||
): Promise<ReportOptionsResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportOptionsResponseDto>>(
|
||||
'/v1/reports/signing/options',
|
||||
{ params: compactQuery(filter) },
|
||||
)
|
||||
|
||||
const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||
getSigningTemplates(),
|
||||
getCampuses(),
|
||||
getDepartments(),
|
||||
])
|
||||
const data: ReportTaskData = {
|
||||
tasks: [],
|
||||
templates: templatesResult.status === 'fulfilled' ? templatesResult.value : [],
|
||||
campuses: campusesResult.status === 'fulfilled' ? campusesResult.value : [],
|
||||
departments: departmentsResult.status === 'fulfilled' ? departmentsResult.value : [],
|
||||
}
|
||||
|
||||
data.tasks = await getAllReportTasks(data)
|
||||
return data
|
||||
return normalizeReportOptions(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getReportOverview(
|
||||
filter: ReportFilterDto,
|
||||
): Promise<ReportOverviewResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportOverviewResponseDto>>(
|
||||
'/v1/reports/signing/overview',
|
||||
{ params: compactQuery(filter) },
|
||||
)
|
||||
|
||||
return normalizeReportOverview(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getReportTasks(
|
||||
query: ReportTasksQueryDto,
|
||||
): Promise<ReportPageResponseDto<ReportTaskResponseDto>> {
|
||||
const response = await request.get<ApiResponse<ReportPageResponseDto<ReportTaskResponseDto>>>(
|
||||
'/v1/reports/signing/tasks',
|
||||
{ params: compactQuery(query) },
|
||||
)
|
||||
const page = unwrapApiResponse(response)
|
||||
|
||||
return {
|
||||
records: page.records ?? [],
|
||||
page: page.page,
|
||||
size: page.size,
|
||||
total: page.total,
|
||||
pages: page.pages,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReportExportJob(
|
||||
payload: ReportExportRequestDto,
|
||||
): Promise<ReportExportJobResponseDto> {
|
||||
const response = await request.post<ApiResponse<ReportExportJobResponseDto>>(
|
||||
'/v1/reports/signing/export-jobs',
|
||||
payload,
|
||||
)
|
||||
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getReportExportJob(jobId: string): Promise<ReportExportJobResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportExportJobResponseDto>>(
|
||||
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`,
|
||||
)
|
||||
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadReportExportFile(jobId: string): Promise<Blob> {
|
||||
return request.get<Blob>(
|
||||
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PageQuery, PageResult } from '@/types/common'
|
||||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||||
|
||||
export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review'
|
||||
|
||||
@@ -261,15 +260,135 @@ export interface OrganizationResponseDto {
|
||||
departmentName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 报表当前由签署任务列表、模板和组织字典在前端聚合。
|
||||
* 后端提供聚合报表接口后,可在 reports.ts 的边界替换为对应 DTO,页面无需感知。
|
||||
*/
|
||||
export interface ReportTaskData {
|
||||
tasks: SigningTaskRecord[]
|
||||
templates: SigningTemplate[]
|
||||
campuses: CampusRecord[]
|
||||
departments: DepartmentRecord[]
|
||||
export type ReportBackendTaskStatus =
|
||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
|
||||
|
||||
export type ReportBackendSignMethod = 'PAD' | 'SMS'
|
||||
|
||||
export type ReportTimeDimension = 'CREATED' | 'SIGNED' | 'EXPIRED'
|
||||
|
||||
export type ReportSortDirection = 'asc' | 'desc'
|
||||
|
||||
export type ReportExportFormat = 'CSV' | 'XLSX'
|
||||
|
||||
/** /api/v1/reports/signing 的公共过滤 DTO;时间区间为左闭右开。 */
|
||||
export interface ReportFilterDto {
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
signedFrom?: string
|
||||
signedTo?: string
|
||||
expiredFrom?: string
|
||||
expiredTo?: string
|
||||
campusId?: string
|
||||
departmentId?: string
|
||||
templateVersionId?: string
|
||||
templateCategory?: string
|
||||
signMethod?: ReportBackendSignMethod
|
||||
keyword?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface ReportTasksQueryDto extends ReportFilterDto {
|
||||
page?: number
|
||||
size?: number
|
||||
timeDimension?: ReportTimeDimension
|
||||
sort?: string
|
||||
direction?: ReportSortDirection
|
||||
}
|
||||
|
||||
export interface ReportExportRequestDto extends ReportTasksQueryDto {
|
||||
format: ReportExportFormat
|
||||
columns?: string[]
|
||||
}
|
||||
|
||||
export interface ReportMetricsDto {
|
||||
initiatedCount: number
|
||||
signedCount: number
|
||||
onTimeSignedCount: number
|
||||
onTimeSampleCount: number
|
||||
onTimeRate: number | null
|
||||
averageSignDurationSeconds: number | null
|
||||
durationSampleCount: number
|
||||
}
|
||||
|
||||
export interface ReportTrendPointDto {
|
||||
date: string
|
||||
label: string
|
||||
signedCount: number
|
||||
}
|
||||
|
||||
export interface ReportDepartmentBreakdownDto {
|
||||
departmentId?: string | null
|
||||
departmentName?: string | null
|
||||
initiatedCount?: number | null
|
||||
signedCount?: number | null
|
||||
onTimeSignedCount?: number | null
|
||||
onTimeSampleCount?: number | null
|
||||
onTimeRate?: number | null
|
||||
}
|
||||
|
||||
export interface ReportOverviewResponseDto {
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
snapshotAt: string
|
||||
metrics: ReportMetricsDto
|
||||
trend: ReportTrendPointDto[]
|
||||
departments: ReportDepartmentBreakdownDto[]
|
||||
}
|
||||
|
||||
export interface ReportTaskResponseDto {
|
||||
taskId: string
|
||||
patientDisplayId: string
|
||||
patientNameMasked: string
|
||||
patientMobileMasked?: string | null
|
||||
status: ReportBackendTaskStatus
|
||||
signMethod: ReportBackendSignMethod
|
||||
createdAt: string
|
||||
deadlineAt?: string | null
|
||||
signStartedAt?: string | null
|
||||
signedAt?: string | null
|
||||
expiredAt?: string | null
|
||||
voidedAt?: string | null
|
||||
signDurationSeconds?: number | null
|
||||
onTime?: boolean | null
|
||||
templateVersionId: string
|
||||
templateVersionNumber?: number | null
|
||||
templateName: string
|
||||
templateCategory: string
|
||||
campusId: string
|
||||
campusName: string
|
||||
departmentId?: string | null
|
||||
departmentName: string
|
||||
}
|
||||
|
||||
export interface ReportOptionsResponseDto {
|
||||
campuses: Array<{ id: string; name: string }>
|
||||
departments: Array<{ id: string; campusId: string; name: string }>
|
||||
templateCategories: string[]
|
||||
}
|
||||
|
||||
export interface ReportPageResponseDto<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export type ReportExportJobStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
|
||||
|
||||
export interface ReportExportJobResponseDto {
|
||||
jobId: string
|
||||
status: ReportExportJobStatus
|
||||
format?: ReportExportFormat | string | null
|
||||
requestedAt?: string | null
|
||||
startedAt?: string | null
|
||||
completedAt?: string | null
|
||||
expiresAt?: string | null
|
||||
resultCount?: number | null
|
||||
errorCode?: string | null
|
||||
errorMessage?: string | null
|
||||
downloadPath?: string | null
|
||||
}
|
||||
|
||||
export type UserStatus = 'enabled' | 'disabled'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user