chore(report): 下线「系统管理 → 报表分析」菜单及其接口与开关
This commit is contained in:
@@ -20,11 +20,8 @@ VITE_API_BASE_URL=/api
|
||||
# VITE_MOCK_SIGN_RECORDS=true
|
||||
# VITE_MOCK_DOCUMENTS=false
|
||||
# VITE_MOCK_DOCUMENT_PERMISSIONS=false
|
||||
# 系统管理 → 报表分析(签署报表与导出任务,走 reports/signing/* 接口)
|
||||
# VITE_MOCK_REPORTS=false
|
||||
# 报表分析目录下的四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。
|
||||
# 后端目前没有任何分析聚合接口,缺省开启演示数据;关掉只会得到「后端尚未提供接口」的空态。
|
||||
# 与上面的 VITE_MOCK_REPORTS 是两套菜单、两套后端面,不要合并成一个开关。
|
||||
# VITE_MOCK_REPORT_ANALYTICS=true
|
||||
# VITE_MOCK_USERS=false
|
||||
# VITE_MOCK_ORGANIZATION=false
|
||||
|
||||
@@ -111,7 +111,6 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
|
||||
├─ /system/org 组织架构 / 员工角色绑定
|
||||
├─ /system/role 角色权限与数据范围
|
||||
├─ /doc/library 文档库管理
|
||||
├─ /report/index 报表分析(签署报表与导出任务,走 reports/signing/* 接口)
|
||||
├─ /report/sign/overview 报表分析 / 签署总览
|
||||
├─ /report/sign/volume 报表分析 / 签署量分析
|
||||
├─ /report/sign/timeliness 报表分析 / 签署时效分析
|
||||
@@ -119,7 +118,9 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
|
||||
└─ /system/setting 系统设置 / 菜单设置
|
||||
```
|
||||
|
||||
注意 `/report/index` 与另外四个报表页是**两套菜单、两套后端面**:前者是「系统管理 → 报表分析」下的签署报表与导出任务(真实接口已接入,见 [reports-api.md](./docs/reports-api.md));后者是「报表分析」目录下的四个独立菜单,后端目前没有任何分析聚合接口,因此只有演示数据(`VITE_MOCK_REPORT_ANALYTICS`)。两者的路由与 `componentPath` 都以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。
|
||||
「系统管理 → 报表分析」(`/report/index`,签署报表与导出任务,走 `reports/signing/*` 接口)已整体下线:页面、接口模块、`VITE_MOCK_REPORTS` 开关与路由别名一并移除。后端菜单资源树暂时仍会下发该菜单,前端在 `stores/login.ts` 的 `RETIRED_MENU_PATHS` 里统一屏蔽,侧边栏不展示、路由守卫也会重定向走,不会出现「前端暂未接入组件」的死链;后端下架该菜单资源后可以删掉这个集合。
|
||||
|
||||
`/report/sign/overview` 等四个是**另一套**:「报表分析」目录下的四个独立菜单,后端目前没有任何分析聚合接口,因此只有演示数据(`VITE_MOCK_REPORT_ANALYTICS`)。路由与 `componentPath` 都以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。
|
||||
|
||||
布局拆分为 `Menu`、`Header` 和 `Container` 三个公共组件,页面内容由各自的 View 负责。
|
||||
|
||||
@@ -169,7 +170,6 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
- `api/workbench/artifacts.ts`
|
||||
- `api/workbench/deliveries.ts`
|
||||
- `api/management/documents.ts`
|
||||
- `api/management/reports.ts`
|
||||
- `api/management/users.ts`
|
||||
- `api/management/roles.ts`
|
||||
- `api/management/permissions.ts`
|
||||
@@ -226,7 +226,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
|
||||
文档权限页面的新接口契约、继承规则、批量保存行为和联调验收标准见 [`docs/document-permissions-api.md`](./docs/document-permissions-api.md)。
|
||||
|
||||
报表页真实模式已接入签署报表的 options、overview、tasks 和异步 export-jobs 接口,支持权限范围内的动态筛选、服务端分页、趋势/科室聚合和完整结果导出;Mock 模式仍可用于演示。接口矩阵、时间口径和联调验收见 [reports-api.md](./docs/reports-api.md),后端联调要求见 [reports-backend-requirements.md](./docs/reports-backend-requirements.md)。首页真实模式已接入 workbench overview。
|
||||
首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [docs/workbench-home-api.md](./docs/workbench-home-api.md)。
|
||||
|
||||
首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [docs/workbench-home-api.md](./docs/workbench-home-api.md)。
|
||||
|
||||
@@ -258,7 +258,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
|
||||
**边界**:① 时效指标只统计**已签署**任务(未完成任务不是「签得快」而是「还没签完」,计入会稀释均值),样本为 0 时显示「—」而不是 0;② 患者结构统计的是**人次**而非人数,同一患者多次签署会重复计数,页面上已写明;③ 导出是前端按条件生成 CSV(UTF-8 带 BOM、CRLF),与签署记录查询共用 `utils/csv-export.ts`。**后端提供导出任务后应改为服务端导出**:导出范围与导出人必须进审计日志,浏览器里做不到。真实接入聚合接口时,后端应按页面出四个聚合接口,**不要**照搬现在的形状把全量任务明细拉到浏览器里算。
|
||||
|
||||
演示数据缺省开启(`VITE_MOCK_REPORT_ANALYTICS`,与 `/report/index` 的 `VITE_MOCK_REPORTS` 分开)。
|
||||
演示数据缺省开启(`VITE_MOCK_REPORT_ANALYTICS`)。
|
||||
|
||||
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# 报表分析接口核验与接入说明
|
||||
|
||||
> 核验基准:用户于 2026-09-02 提供的 default_OpenAPI.json,OpenAPI 3.1.0,MEDISIGN 患者电子签 API v1。
|
||||
>
|
||||
> 页面实现:src/views/management/reports/index.vue;API 封装:src/api/management/reports.ts。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
最新接口文档已经提供完整的签署报表资源。本轮已将报表页从“读取全部签署任务后前端聚合”切换为服务端报表接口:
|
||||
|
||||
- 概览指标、签署趋势和科室分布由 overview 返回;
|
||||
- 明细由 tasks 服务端分页返回;
|
||||
- 院区、科室和模板分类由 options 返回;
|
||||
- 导出改为服务端异步任务,完成后下载文件;
|
||||
- 打开 `VITE_MOCK_REPORTS` 时仍保留原有 Mock 演示数据;该开关缺省关闭,报表默认走服务端报表接口。
|
||||
|
||||
## 2. 接入矩阵
|
||||
|
||||
| 页面功能 | HTTP 接口 | 接入状态 | 前端使用方式 |
|
||||
| -------------- | -------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 筛选字典 | GET /api/v1/reports/signing/options | 已接入 | 使用当前操作者有权访问的院区、科室和模板分类;科室随院区筛选联动 |
|
||||
| 概览指标 | GET /api/v1/reports/signing/overview | 已接入 | 使用 initiatedCount、signedCount、onTimeRate、averageSignDurationSeconds |
|
||||
| 签署趋势 | GET /api/v1/reports/signing/overview | 已接入 | 使用 trend[].date/label/signedCount,不再用 createdAt 代替 signedAt |
|
||||
| 科室分布 | GET /api/v1/reports/signing/overview | 已接入 | 使用 departments[].departmentName/signedCount |
|
||||
| 明细列表 | GET /api/v1/reports/signing/tasks | 已接入 | 使用 records/page/size/total/pages,翻页和每页条数均请求服务端 |
|
||||
| 明细状态和时间 | GET /api/v1/reports/signing/tasks | 已接入 | 映射全部 CREATED/WAITING_SIGN/SIGNED/EXPIRED/VOIDED/GENERATING/FAILED,展示脱敏患者、发起时间、完成时间和耗时 |
|
||||
| 创建导出任务 | POST /api/v1/reports/signing/export-jobs | 已接入 | 复用当前筛选条件,默认请求 CSV,不传分页参数以导出全部匹配结果 |
|
||||
| 导出状态 | GET /api/v1/reports/signing/export-jobs/{jobId} | 已接入 | 轮询 PENDING/RUNNING,处理 SUCCEEDED/FAILED/EXPIRED |
|
||||
| 下载导出文件 | GET /api/v1/reports/signing/export-jobs/{jobId}/download | 已接入 | 以 Blob 下载服务端生成文件 |
|
||||
| 明细查看 | 工作台签署任务路由 | 已保留 | 点击行跳转工作台,由工作台查询任务详情和签署文件 |
|
||||
|
||||
所有真实请求都通过 src/utils/request.ts 的单例发送,路径使用 /v1/...,由默认 /api 前缀形成 /api/v1/...,鉴权沿用 X-Token。
|
||||
|
||||
## 3. 参数和统计口径
|
||||
|
||||
页面固定传 timezone=Asia/Shanghai,日期区间按左闭右开 [from,to) 转成带 +08:00 的 UTC ISO 时间:
|
||||
|
||||
- 7d、14d、month 和自定义日期都生成明确的起止时间;
|
||||
- overview 同时传 createdFrom/To、signedFrom/To、expiredFrom/To,让发起、签署完成和超时统计使用各自业务时间;
|
||||
- tasks 和导出只传 createdFrom/To,保证待签署任务不会因缺少 signedAt 或 expiredAt 被时间条件排除;
|
||||
- 院区、科室、模板分类、签署方式和关键词分别映射为 campusId、departmentId、templateCategory、signMethod、keyword;
|
||||
- tasks 使用 page、size、timeDimension=CREATED、sort=createdAt、direction=desc。
|
||||
|
||||
后端返回的 onTimeRate 按契约是 0-1 小数,前端展示为百分比;无样本的 onTimeRate 和无可靠样本的平均耗时保持为 —。真实签署耗时优先使用 signDurationSeconds,不会用固定演示数字代替。
|
||||
|
||||
## 4. 状态和脱敏映射
|
||||
|
||||
| 后端状态 | 页面状态 |
|
||||
| --------------------- | -------- |
|
||||
| CREATED、WAITING_SIGN | 待签署 |
|
||||
| GENERATING | 签署中 |
|
||||
| SIGNED | 已签署 |
|
||||
| EXPIRED | 已超时 |
|
||||
| VOIDED | 已作废 |
|
||||
| FAILED | 处理失败 |
|
||||
|
||||
明细使用 patientNameMasked 和 patientDisplayId,不补查或展示身份证号、完整手机号、完整病历或签名原图。模板、院区和科室直接使用报表 DTO 返回的快照字段,不再通过当前可见模板列表猜测历史分类。
|
||||
|
||||
## 5. 联调时需要确认的事项
|
||||
|
||||
接口已经按文档接入,但页面能否显示真实数据仍取决于后端环境:
|
||||
|
||||
- 六个报表路径都已部署在 /api/v1,并允许当前角色访问;
|
||||
- 成功响应使用统一包装,code=0 或 "0",业务数据位于 data;
|
||||
- options、overview 和 tasks 的对象查询参数按 OpenAPI 默认 form/explode 方式接收为平铺参数;
|
||||
- overview 的 trend 按 signedAt、超时按 expiredAt,并返回包含零值日期的趋势;
|
||||
- tasks 的权限过滤在分页前执行,total/pages 与 records 一致,排序稳定;
|
||||
- 导出任务创建、状态查询和下载都重新校验权限,文件有效期和过期错误符合契约;
|
||||
- 真实环境的 Network 请求需确认 401/403/400 能被网关和统一响应正确返回。
|
||||
|
||||
当前页面的导出按钮只暴露 CSV;API DTO 已支持 CSV/XLSX,若产品需要 XLSX,可在筛选栏增加格式选择而无需新增接口。
|
||||
@@ -1,72 +0,0 @@
|
||||
# 报表分析后端联调与验收说明
|
||||
|
||||
> 事实基准:用户于 2026-09-02 提供的 default_OpenAPI.json,OpenAPI 3.1.0,MEDISIGN 患者电子签 API v1。
|
||||
>
|
||||
> 本文记录当前契约的前端接入要求和联调检查项,不再把已经存在的报表接口标记为“建议新增”。
|
||||
|
||||
## 1. 当前契约与页面使用
|
||||
|
||||
| 接口 | 页面用途 | 状态 |
|
||||
| -------------------------------------------------------- | ------------------------------------ | ------------------------ |
|
||||
| GET /api/v1/reports/signing/options | 权限范围内的院区、科室、模板分类字典 | 前端已接入 |
|
||||
| GET /api/v1/reports/signing/overview | 指标、签署趋势、科室分布 | 前端已接入 |
|
||||
| GET /api/v1/reports/signing/tasks | 脱敏明细和服务端分页 | 前端已接入 |
|
||||
| POST /api/v1/reports/signing/export-jobs | 创建 CSV/XLSX 异步导出任务 | 前端已接入,页面默认 CSV |
|
||||
| GET /api/v1/reports/signing/export-jobs/{jobId} | 查询导出状态 | 前端已接入并轮询 |
|
||||
| GET /api/v1/reports/signing/export-jobs/{jobId}/download | 下载已完成文件 | 前端已接入 |
|
||||
|
||||
页面真实模式通过统一 request 单例发送 /v1/...,由 /api base URL 形成完整路径,并自动携带 X-Token。Mock 模式不调用以上接口,继续使用报表页面自己的演示数据。
|
||||
|
||||
## 2. 后端必须保持的统计口径
|
||||
|
||||
- 时间区间为左闭右开 [from,to),页面传 timezone=Asia/Shanghai;
|
||||
- overview 的 createdFrom/To 只约束发起统计,signedFrom/To 约束签署完成和趋势,expiredFrom/To 约束超时统计;
|
||||
- 页面明细和导出只传 createdFrom/To,以便待签署任务仍能出现在明细中;
|
||||
- trend 必须按 signedAt 的业务时区自然日聚合,并包含零值日期;
|
||||
- departments 必须按任务就诊科室返回,不能改用模板归属科室;
|
||||
- onTimeRate 是 0-1 小数;无可靠样本返回 null;
|
||||
- averageSignDurationSeconds 必须基于可靠的 signStartedAt/signedAt 或等价审计字段;无样本返回 null;
|
||||
- CREATED、WAITING_SIGN、SIGNED、EXPIRED、VOIDED、GENERATING、FAILED 均需保持可区分。
|
||||
|
||||
## 3. 明细分页验收
|
||||
|
||||
tasks 请求使用 page、size、timeDimension=CREATED、sort=createdAt、direction=desc。后端应保证:
|
||||
|
||||
- page 从 1 开始,size 在 1-200 范围内;
|
||||
- records、page、size、total、pages 在权限过滤后保持一致;
|
||||
- 默认或指定排序稳定,不能在翻页时重复或遗漏;
|
||||
- taskId、templateVersionId、campusId 等标识与返回展示字段属于同一条任务快照;
|
||||
- patientNameMasked、patientDisplayId、patientMobileMasked 只能是报表级脱敏字段;
|
||||
- templateName、templateCategory、campusName、departmentName 和 deadlineAt/signedAt/signDurationSeconds 能够覆盖历史任务,不能要求前端再查当前可见模板列表补齐。
|
||||
|
||||
## 4. 导出任务验收
|
||||
|
||||
创建导出任务时页面传当前筛选条件和 format=CSV,不传 page/size,因此结果应覆盖全部匹配记录,而不是当前明细页。后端需要:
|
||||
|
||||
- 创建、查询、下载时均重新检查操作人有效性、报表权限和数据范围;
|
||||
- 支持 PENDING、RUNNING、SUCCEEDED、FAILED、EXPIRED;
|
||||
- 文件生成完成后返回有效期和 resultCount;文件过期后拒绝下载;
|
||||
- 下载响应返回文件流,不暴露 storage key;
|
||||
- 导出列只包含当前角色允许查看的脱敏字段;
|
||||
- 记录操作者、筛选条件摘要、结果数量、生成时间、过期时间和 traceId,但不记录身份证号、完整手机号、完整病历或签名原图。
|
||||
|
||||
## 5. 联调阻塞项与前端边界
|
||||
|
||||
代码已经按当前 OpenAPI 完成接入,以下事项需要实际后端环境确认:
|
||||
|
||||
- 六个报表路径已部署且当前角色有查询/导出权限;
|
||||
- 统一响应的成功 code 为 0 或 "0",业务数据放在 data;
|
||||
- 对象 query 参数按 OpenAPI 默认 form/explode 方式接收为平铺字段;
|
||||
- 401、403、400、404、429 和 500 的 HTTP/业务错误能够被网关正确转发;
|
||||
- download 接口的响应类型为文件流,且允许当前前端通过同源 /api 访问。
|
||||
|
||||
当前页面没有新增报表详情接口:明细“查看”继续跳转签署工作台,由工作台已有任务详情和签署文件能力处理。页面筛选栏只暴露 CSV,若需要 XLSX 选择器,仅需增加前端控件并复用同一导出接口。
|
||||
|
||||
## 6. 建议联调用例
|
||||
|
||||
1. 全院近 14 天:校验 overview、tasks 的请求参数和三类时间统计。
|
||||
2. 选择一个院区/科室/模板分类/签署方式并输入关键词:校验 options 联动和所有接口参数一致。
|
||||
3. 构造跨创建日完成签署的任务:确认趋势落在 signedAt 日期,而不是 createdAt 日期。
|
||||
4. 翻页、改变每页条数:确认 total/pages、排序和记录不重复。
|
||||
5. 创建导出任务并等待完成:确认状态轮询、文件下载、resultCount 和过期下载错误。
|
||||
6. 用无权限角色访问:确认查询和导出均得到 403,前端不显示越权数据。
|
||||
@@ -1,122 +0,0 @@
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types/common'
|
||||
|
||||
import type {
|
||||
ReportExportJobResponseDto,
|
||||
ReportExportRequestDto,
|
||||
ReportFilterDto,
|
||||
ReportOptionsResponseDto,
|
||||
ReportOverviewResponseDto,
|
||||
ReportPageResponseDto,
|
||||
ReportTaskResponseDto,
|
||||
ReportTasksQueryDto,
|
||||
} from './types'
|
||||
|
||||
export const isReportsMockEnabled = mockFlags.reports
|
||||
|
||||
function compactQuery(values: object) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(
|
||||
([, value]) => value !== undefined && value !== null && value !== '',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeReportOptions(data: ReportOptionsResponseDto): ReportOptionsResponseDto {
|
||||
return {
|
||||
campuses: data.campuses ?? [],
|
||||
departments: data.departments ?? [],
|
||||
templateCategories: data.templateCategories ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportFilter/ReportTasksQuery are object query parameters in OpenAPI's default
|
||||
* form/explode representation, so fields are sent flat (createdFrom=..., etc.).
|
||||
*/
|
||||
export async function getReportOptions(
|
||||
filter: ReportFilterDto = {},
|
||||
): Promise<ReportOptionsResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportOptionsResponseDto>>(
|
||||
'/v1/reports/signing/options',
|
||||
{ params: compactQuery(filter) },
|
||||
)
|
||||
|
||||
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' },
|
||||
)
|
||||
}
|
||||
@@ -341,146 +341,6 @@ export interface OrganizationResponseDto {
|
||||
departmentName?: string | null
|
||||
}
|
||||
|
||||
export type ReportBackendTaskStatus =
|
||||
| 'CREATED'
|
||||
| 'WAITING_SIGN'
|
||||
| 'SIGNED'
|
||||
| 'EXPIRED'
|
||||
| 'VOIDED'
|
||||
| 'GENERATING'
|
||||
| 'FAILED'
|
||||
/** 患者已签、等科室复核签章(与 workbench 的 WAITING_CONFIRM 同口径) */
|
||||
| 'WAITING_CONFIRM'
|
||||
|
||||
/** 与 workbench 的 BackendSigningMethod 同口径:四种方式平铺,无通道归并 */
|
||||
export type ReportBackendSignMethod = 'PAD' | 'SMS' | 'IPAD' | 'WX'
|
||||
|
||||
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' | 'locked'
|
||||
|
||||
export interface UserQuery extends PageQuery {
|
||||
|
||||
@@ -8,8 +8,7 @@ const loginStore = useLoginStore()
|
||||
const router = useRouter()
|
||||
|
||||
const visibleMenuGroups = computed(() =>
|
||||
(loginStore.authorization?.navigation ?? [])
|
||||
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||
loginStore.visibleNavigation
|
||||
.map((directory) => ({
|
||||
label: directory.name,
|
||||
items: (directory.children ?? [])
|
||||
|
||||
Vendored
-1
@@ -12,7 +12,6 @@ interface ImportMetaEnv {
|
||||
readonly VITE_MOCK_SIGNING?: string
|
||||
readonly VITE_MOCK_DOCUMENTS?: string
|
||||
readonly VITE_MOCK_DOCUMENT_PERMISSIONS?: string
|
||||
readonly VITE_MOCK_REPORTS?: string
|
||||
readonly VITE_MOCK_USERS?: string
|
||||
readonly VITE_MOCK_ORGANIZATION?: string
|
||||
readonly VITE_MOCK_SETTINGS?: string
|
||||
|
||||
@@ -68,12 +68,10 @@ const router = createRouter({
|
||||
{ path: '/management/users', redirect: '/system/org' },
|
||||
{ path: '/management/roles', redirect: '/system/role' },
|
||||
{ path: '/management/documents', redirect: '/doc/library' },
|
||||
{ path: '/management/reports', redirect: '/report/index' },
|
||||
{ path: '/management/settings', redirect: '/system/setting' },
|
||||
{ path: '/management/document-permissions', redirect: '/system/role' },
|
||||
{ path: '/consent-tasks', redirect: '/sign/workbench' },
|
||||
{ path: '/documents', redirect: '/doc/library' },
|
||||
{ path: '/reports', redirect: '/report/index' },
|
||||
{ path: '/settings', redirect: '/system/setting' },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -48,11 +48,36 @@ export const useLoginStore = defineStore('login', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const accessibleMenus = computed(() =>
|
||||
(authorization.value?.navigation ?? [])
|
||||
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||
.flatMap((directory) => directory.children ?? []),
|
||||
)
|
||||
/**
|
||||
* 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。
|
||||
*
|
||||
* 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架,
|
||||
* 所以在前端统一屏蔽:侧边栏不展示,路由守卫也会把它当成无权限菜单重定向走,
|
||||
* 避免出现点进去只有「前端暂未接入组件」占位页的死链。
|
||||
* 后端下架该菜单资源后,这个集合可以删掉。
|
||||
*/
|
||||
const RETIRED_MENU_PATHS = new Set(['/report/index'])
|
||||
|
||||
function isRetiredMenu(menu: PermissionResourceNode) {
|
||||
return RETIRED_MENU_PATHS.has(menu.routePath?.replace(/\/$/, '') ?? '')
|
||||
}
|
||||
|
||||
const accessibleMenus = computed(() =>
|
||||
(authorization.value?.navigation ?? [])
|
||||
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||
.flatMap((directory) => directory.children ?? [])
|
||||
.filter((menu) => !isRetiredMenu(menu)),
|
||||
)
|
||||
|
||||
/** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */
|
||||
const visibleNavigation = computed(() =>
|
||||
(authorization.value?.navigation ?? [])
|
||||
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||
.map((directory) => ({
|
||||
...directory,
|
||||
children: (directory.children ?? []).filter((menu) => !isRetiredMenu(menu)),
|
||||
})),
|
||||
)
|
||||
|
||||
function menuForPath(path: string): PermissionResourceNode | null {
|
||||
const normalizedPath = path.replace(/\/$/, '') || '/'
|
||||
@@ -187,6 +212,7 @@ export const useLoginStore = defineStore('login', () => {
|
||||
user,
|
||||
authorization,
|
||||
accessibleMenus,
|
||||
visibleNavigation,
|
||||
isLoggedIn,
|
||||
isReadOnly,
|
||||
userName,
|
||||
|
||||
@@ -41,17 +41,14 @@ export const mockFlags = {
|
||||
documents: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENTS, false),
|
||||
/** 文档权限:模板权限配置与权限矩阵 */
|
||||
documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false),
|
||||
/** 报表分析 */
|
||||
reports: resolveMockFlag(import.meta.env.VITE_MOCK_REPORTS, false),
|
||||
/** 审计日志:已有只读后端查询接口,直接读取数据库记录 */
|
||||
audit: resolveMockFlag(import.meta.env.VITE_MOCK_AUDIT, false),
|
||||
/**
|
||||
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。
|
||||
*
|
||||
* 与上面的 `reports` 分开:`reports` 管的是「系统管理 → 报表分析」那个签署报表
|
||||
* 与导出任务页(走 reports/signing/* 接口),这里是「报表分析」目录下另外四个
|
||||
* 独立菜单。两者菜单不同、后端接口也不同,共用一个开关会让其中一个先落地时
|
||||
* 被另一个的缺省值带走。
|
||||
* 「系统管理 → 报表分析」(原 `reports` 开关,走 reports/signing/* 接口)已整体下线,
|
||||
* 开关与页面一并移除;这里是「报表分析」目录下的四个独立菜单,是另一套菜单、
|
||||
* 另一套后端面,不受影响。
|
||||
*
|
||||
* 缺省开着:后端目前没有任何分析聚合接口,真实模式只会得到空态。
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,6 @@ const componentRegistry: Record<string, Component> = {
|
||||
'system/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')),
|
||||
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
||||
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
||||
'report/index': defineAsyncComponent(() => import('@/views/management/reports/index.vue')),
|
||||
'monitor/audit/index': defineAsyncComponent(() => import('@/views/management/audit/index.vue')),
|
||||
'report/sign/overview/index': defineAsyncComponent(
|
||||
() => import('@/views/reports/overview/index.vue'),
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ReportDepartmentPoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: ReportDepartmentPoint[]
|
||||
}>()
|
||||
|
||||
const maxValue = computed(() => Math.max(...props.points.map((point) => point.value), 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="points.length" class="distribution-list" aria-label="科室签署分布">
|
||||
<div v-for="point in points" :key="point.department" class="distribution-row">
|
||||
<span class="department-name" :title="point.department">{{ point.department }}</span>
|
||||
<div class="distribution-track" aria-hidden="true">
|
||||
<span
|
||||
class="distribution-value"
|
||||
:style="{
|
||||
width: `${(point.value / maxValue) * 100}%`,
|
||||
backgroundColor: point.color,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<strong>{{ point.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-chart">暂无科室数据</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.distribution-list {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.distribution-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.department-name {
|
||||
flex: 0 0 86px;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-track {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
background: #eef4f6;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.distribution-value {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.distribution-row strong {
|
||||
flex: 0 0 30px;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-chart {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,353 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { formatSigningMethod, readSigningMethod } from '@/utils/signing-method'
|
||||
|
||||
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: '已签署',
|
||||
confirming: '待科室确认',
|
||||
pending: '待签署',
|
||||
signing: '签署中',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
function getStatusLabel(row: ReportTask) {
|
||||
if (row.status === 'pending') {
|
||||
return `${statusLabels[row.status]}·${readSigningMethod(row.method).short}`
|
||||
}
|
||||
|
||||
return statusLabels[row.status]
|
||||
}
|
||||
|
||||
function getStatusClass(status: ReportTaskStatus) {
|
||||
return `status-${status}`
|
||||
}
|
||||
|
||||
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 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>{{ formatSigningMethod(row.method) }}</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>
|
||||
|
||||
<style scoped>
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
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;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
padding: 9px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: 9px 12px;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover td {
|
||||
background: #f7fbfc;
|
||||
}
|
||||
|
||||
.patient-id {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.document-name {
|
||||
max-width: 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-signed {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.status-signing {
|
||||
color: var(--info);
|
||||
background: var(--info-l);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
}
|
||||
|
||||
.status-void {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: var(--err);
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
.view-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.view-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
padding: 40px 12px !important;
|
||||
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>
|
||||
@@ -1,263 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDocumentCategory,
|
||||
ReportMethodFilter,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
} from '../types'
|
||||
|
||||
defineProps<{
|
||||
period: ReportPeriodOption
|
||||
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
|
||||
canExport: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:period': [value: ReportPeriodOption]
|
||||
'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 readValue(event: Event) {
|
||||
return (event.target as HTMLSelectElement).value
|
||||
}
|
||||
|
||||
function handlePeriodChange(event: Event) {
|
||||
emit('update:period', readValue(event) as ReportPeriodOption)
|
||||
}
|
||||
|
||||
function handleCampusChange(event: Event) {
|
||||
emit('update:campus', readValue(event) as ReportCampus)
|
||||
}
|
||||
|
||||
function handleDepartmentChange(event: Event) {
|
||||
emit('update:department', readValue(event))
|
||||
}
|
||||
|
||||
function handleCategoryChange(event: Event) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<section class="filter-card">
|
||||
<div class="filter-bar">
|
||||
<label class="filter-field">
|
||||
<span>时间</span>
|
||||
<select :value="period" aria-label="选择报表时间" @change="handlePeriodChange">
|
||||
<option v-for="option in periodOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>院区</span>
|
||||
<select :value="campus" aria-label="选择报表院区" @change="handleCampusChange">
|
||||
<option v-for="option in campusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>科室</span>
|
||||
<select :value="department" aria-label="选择报表科室" @change="handleDepartmentChange">
|
||||
<option v-for="option in departmentOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>文档类别</span>
|
||||
<select :value="category" aria-label="选择文档类别" @change="handleCategoryChange">
|
||||
<option v-for="option in categoryOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</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>
|
||||
<input
|
||||
type="date"
|
||||
:value="customFrom"
|
||||
aria-label="选择报表起始日期"
|
||||
@change="emit('update:customFrom', readValue($event))"
|
||||
/>
|
||||
</label>
|
||||
<label class="filter-field date-field">
|
||||
<span>结束</span>
|
||||
<input
|
||||
type="date"
|
||||
:value="customTo"
|
||||
aria-label="选择报表结束日期"
|
||||
@change="emit('update:customTo', readValue($event))"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<span class="filter-spacer" />
|
||||
<span v-if="exportStatus" class="export-status" role="status">{{ exportStatus }}</span>
|
||||
<button
|
||||
v-if="canExport"
|
||||
type="button"
|
||||
class="export-button"
|
||||
:disabled="exportLoading"
|
||||
@click="emit('export')"
|
||||
>
|
||||
{{ exportLoading ? '导出中…' : '导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.filter-field select {
|
||||
width: auto;
|
||||
min-width: 98px;
|
||||
padding: 7px 10px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.filter-field input {
|
||||
width: 132px;
|
||||
padding: 7px 8px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.keyword-field input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.filter-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filter-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
padding: 8px 16px;
|
||||
color: var(--brand);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReportMetricCard as ReportMetricCardData } from '../types'
|
||||
|
||||
defineProps<{
|
||||
metric: ReportMetricCardData
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card">
|
||||
<div class="metric-icon" :class="'tone-' + metric.tone" aria-hidden="true">
|
||||
<svg v-if="metric.key === 'total'" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m-6 9 2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="metric.key === 'signed'"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M20 6 9 17l-5-5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else-if="metric.key === 'rate'" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M12 7v5l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M12 8v5M12 16h.01"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="metric-value">
|
||||
{{ metric.value
|
||||
}}<span v-if="metric.unit && metric.value !== '—'" class="metric-unit">{{
|
||||
metric.unit
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="metric-label">{{ metric.label }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.metric-icon.tone-brand {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.metric-icon.tone-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.metric-icon.tone-info {
|
||||
color: var(--info);
|
||||
background: var(--info-l);
|
||||
}
|
||||
|
||||
.metric-icon.tone-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-l);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--ink);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
margin-left: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
margin-top: 3px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,114 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ReportTrendPoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: ReportTrendPoint[]
|
||||
}>()
|
||||
|
||||
const chartBars = computed(() => {
|
||||
const width = 640
|
||||
const height = 200
|
||||
const paddingX = 30
|
||||
const paddingY = 30
|
||||
const slotWidth = (width - paddingX * 2) / Math.max(props.points.length, 1)
|
||||
const barWidth = slotWidth * 0.55
|
||||
const maxValue = Math.max(...props.points.map((point) => point.value), 3) * 1.2
|
||||
|
||||
return props.points.map((point, index) => {
|
||||
const barHeight = (point.value / maxValue) * (height - paddingY * 2)
|
||||
const x = paddingX + slotWidth * index + (slotWidth - barWidth) / 2
|
||||
const y = height - paddingY - barHeight
|
||||
|
||||
return {
|
||||
...point,
|
||||
x,
|
||||
y,
|
||||
width: barWidth,
|
||||
height: Math.max(barHeight, 2),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const gridLines = [0, 1, 2, 3].map((index) => 30 + ((200 - 60) / 3) * index)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="points.length" class="chart-wrap">
|
||||
<svg viewBox="0 0 640 200" role="img" aria-label="每日签署量趋势柱状图">
|
||||
<defs>
|
||||
<linearGradient id="report-bar-gradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#18a0c0" />
|
||||
<stop offset="1" stop-color="#0e6e8c" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<line
|
||||
v-for="line in gridLines"
|
||||
:key="line"
|
||||
x1="30"
|
||||
:y1="line"
|
||||
x2="610"
|
||||
:y2="line"
|
||||
stroke="#e6eef2"
|
||||
stroke-dasharray="3 4"
|
||||
/>
|
||||
|
||||
<g v-for="bar in chartBars" :key="bar.date">
|
||||
<rect
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.width"
|
||||
:height="bar.height"
|
||||
rx="4"
|
||||
:fill="bar.value ? 'url(#report-bar-gradient)' : '#e2ebef'"
|
||||
>
|
||||
<title>{{ bar.label }}:{{ bar.value }} 单</title>
|
||||
</rect>
|
||||
<text
|
||||
v-if="bar.value"
|
||||
:x="bar.x + bar.width / 2"
|
||||
:y="bar.y - 6"
|
||||
text-anchor="middle"
|
||||
font-size="10.5"
|
||||
fill="#0a4f66"
|
||||
font-weight="bold"
|
||||
>
|
||||
{{ bar.value }}
|
||||
</text>
|
||||
<text
|
||||
:x="bar.x + bar.width / 2"
|
||||
y="190"
|
||||
text-anchor="middle"
|
||||
font-size="9.5"
|
||||
fill="#687f8b"
|
||||
>
|
||||
{{ bar.label }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="empty-chart">暂无趋势数据</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrap {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-wrap svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.empty-chart {
|
||||
display: grid;
|
||||
min-height: 200px;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,235 +0,0 @@
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDepartmentPoint,
|
||||
ReportDocumentCategory,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
ReportTask,
|
||||
} from './types'
|
||||
|
||||
function daysAgo(days: number, hour: number, minute: number) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - days)
|
||||
date.setHours(hour, minute, 0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
export const reportCampusOptions: ReportSelectOption<ReportCampus>[] = [
|
||||
{ value: 'all', label: '全部院区' },
|
||||
{ value: '本部院区', label: '本部院区' },
|
||||
{ value: '东院区', label: '东院区' },
|
||||
{ value: '西院区', label: '西院区' },
|
||||
]
|
||||
|
||||
export const reportDepartmentOptions: ReportSelectOption[] = [
|
||||
{ value: 'all', label: '全部科室' },
|
||||
{ value: '放射科', label: '放射科' },
|
||||
{ value: '心内科', label: '心内科' },
|
||||
{ value: '骨科', label: '骨科' },
|
||||
{ value: '健康管理中心', label: '健康管理中心' },
|
||||
{ value: '全院通用', label: '全院通用' },
|
||||
]
|
||||
|
||||
export const reportCategoryOptions: ReportSelectOption<ReportDocumentCategory>[] = [
|
||||
{ value: 'all', label: '全部类别' },
|
||||
{ value: 'consent', label: '知情同意书' },
|
||||
{ value: 'risk', label: '风险告知书' },
|
||||
{ value: 'admission', label: '入院告知' },
|
||||
]
|
||||
|
||||
export const reportPeriodOptions: ReportSelectOption<ReportPeriodOption>[] = [
|
||||
{ value: '7d', label: '近7天' },
|
||||
{ value: '14d', label: '近14天' },
|
||||
{ value: 'month', label: '本月' },
|
||||
{ value: 'custom', label: '自定义…' },
|
||||
]
|
||||
|
||||
export const reportTasks: ReportTask[] = [
|
||||
{
|
||||
id: 'T0818001',
|
||||
patientName: '赵美英',
|
||||
patientId: 'P202610004',
|
||||
documentName: '体检报告发放及知情同意书',
|
||||
department: '健康管理中心',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(0, 9, 12),
|
||||
signedAt: daysAgo(0, 9, 19),
|
||||
expiredAt: daysAgo(-1, 9, 12),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0818009',
|
||||
patientName: '王秀兰',
|
||||
patientId: 'P202610007',
|
||||
documentName: '内镜检查知情同意书',
|
||||
department: '消化内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
// 「待科室确认」样本:患者已签完,等科室复核签章 ——
|
||||
// 没有这条样本,报表里的这一栏永远是 0,看不出它到底有没有接上
|
||||
status: 'confirming',
|
||||
createdAt: daysAgo(0, 10, 40),
|
||||
signedAt: daysAgo(0, 10, 45),
|
||||
expiredAt: daysAgo(-1, 10, 40),
|
||||
durationMinutes: 5,
|
||||
},
|
||||
{
|
||||
id: 'T0818002',
|
||||
patientName: '李淑芬',
|
||||
patientId: 'P202610002',
|
||||
documentName: 'MRI磁共振检查知情同意书',
|
||||
department: '放射科',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'pending',
|
||||
createdAt: daysAgo(0, 10, 5),
|
||||
},
|
||||
{
|
||||
id: 'T0818003',
|
||||
patientName: '周雅琴',
|
||||
patientId: 'P202610006',
|
||||
documentName: '住院须知及告知书',
|
||||
department: '全院通用',
|
||||
campus: '本部院区',
|
||||
category: 'admission',
|
||||
method: 'pad',
|
||||
status: 'pending',
|
||||
createdAt: daysAgo(0, 11, 20),
|
||||
},
|
||||
{
|
||||
id: 'T0818004',
|
||||
patientName: '孙浩然',
|
||||
patientId: 'P202610005',
|
||||
documentName: '心脏介入手术知情同意书',
|
||||
department: '心内科',
|
||||
campus: '西院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(1, 15, 40),
|
||||
signedAt: daysAgo(1, 15, 47),
|
||||
expiredAt: daysAgo(0, 15, 40),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0817001',
|
||||
patientName: '王建国',
|
||||
patientId: 'P202610001',
|
||||
documentName: '冠状动脉造影知情同意书',
|
||||
department: '心内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(1, 9, 30),
|
||||
signedAt: daysAgo(1, 9, 37),
|
||||
expiredAt: daysAgo(0, 9, 30),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0817002',
|
||||
patientName: '陈志强',
|
||||
patientId: 'P202610003',
|
||||
documentName: '骨科手术知情同意书',
|
||||
department: '骨科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(2, 10, 15),
|
||||
signedAt: daysAgo(2, 10, 22),
|
||||
expiredAt: daysAgo(1, 10, 15),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0816001',
|
||||
patientName: '陈志强',
|
||||
patientId: 'P202610003',
|
||||
documentName: '麻醉知情同意书',
|
||||
department: '骨科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(3, 14, 25),
|
||||
signedAt: daysAgo(3, 14, 32),
|
||||
expiredAt: daysAgo(2, 14, 25),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0815001',
|
||||
patientName: '王建国',
|
||||
patientId: 'P202610001',
|
||||
documentName: '心包穿刺知情同意书',
|
||||
department: '心内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'expired',
|
||||
createdAt: daysAgo(4, 16, 0),
|
||||
expiredAt: daysAgo(4, 16, 0),
|
||||
},
|
||||
{
|
||||
id: 'T0814001',
|
||||
patientName: '周雅琴',
|
||||
patientId: 'P202610006',
|
||||
documentName: '患者授权委托书',
|
||||
department: '全院通用',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'void',
|
||||
createdAt: daysAgo(5, 11, 45),
|
||||
},
|
||||
// Pad 与公众号两条:四种方式都要有样本,否则「方式」列与筛选选到 Pad / 公众号时
|
||||
// 永远只能得到空结果,看着像筛选坏了(其实是演示数据没覆盖)。
|
||||
{
|
||||
id: 'T0813002',
|
||||
patientName: '罗文静',
|
||||
patientId: 'P202610007',
|
||||
documentName: '床旁超声检查知情同意书',
|
||||
department: '放射科',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'ipad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(2, 8, 50),
|
||||
signedAt: daysAgo(2, 8, 58),
|
||||
expiredAt: daysAgo(1, 8, 50),
|
||||
durationMinutes: 8,
|
||||
},
|
||||
{
|
||||
id: 'T0813001',
|
||||
patientName: '梁俊豪',
|
||||
patientId: 'P202610008',
|
||||
documentName: '体检报告发放及知情同意书',
|
||||
department: '健康管理中心',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'wx',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(3, 16, 10),
|
||||
signedAt: daysAgo(3, 16, 26),
|
||||
expiredAt: daysAgo(2, 16, 10),
|
||||
durationMinutes: 16,
|
||||
},
|
||||
]
|
||||
|
||||
export const reportDepartmentColors: Record<string, string> = {
|
||||
放射科: '#0e6e8c',
|
||||
心内科: '#18a0c0',
|
||||
骨科: '#0f9d6c',
|
||||
健康管理中心: '#d9821f',
|
||||
全院通用: '#7a4dbb',
|
||||
}
|
||||
|
||||
export const fallbackDepartmentPoint: ReportDepartmentPoint = {
|
||||
department: '暂无数据',
|
||||
value: 0,
|
||||
color: '#cbd8de',
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
|
||||
|
||||
/** 院区由后端字典提供,保留 all 作为全院筛选值。 */
|
||||
export type ReportCampus = 'all' | (string & {})
|
||||
|
||||
export type ReportDocumentCategory = 'all' | (string & {})
|
||||
|
||||
export type ReportMethodFilter = 'all' | SigningMethod
|
||||
|
||||
export interface ReportFilterForm {
|
||||
period: ReportPeriodOption
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
method: ReportMethodFilter
|
||||
keyword: string
|
||||
customFrom: string
|
||||
customTo: string
|
||||
}
|
||||
|
||||
export type ReportMetricTone = 'brand' | 'ok' | 'info' | 'warn'
|
||||
|
||||
export interface ReportMetricCard {
|
||||
key: string
|
||||
label: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
tone: ReportMetricTone
|
||||
}
|
||||
|
||||
export type ReportTaskStatus =
|
||||
| 'signed'
|
||||
| 'confirming'
|
||||
| 'pending'
|
||||
| 'signing'
|
||||
| 'expired'
|
||||
| 'void'
|
||||
| 'failed'
|
||||
|
||||
export type ReportSigningMethod = SigningMethod
|
||||
|
||||
export interface ReportTask {
|
||||
id: string
|
||||
patientName: string
|
||||
patientId: string
|
||||
documentName: string
|
||||
department: string
|
||||
campus: Exclude<ReportCampus, 'all'>
|
||||
campusId?: string
|
||||
category: Exclude<ReportDocumentCategory, 'all'>
|
||||
method: ReportSigningMethod
|
||||
status: ReportTaskStatus
|
||||
createdAt: Date
|
||||
deadlineAt?: Date
|
||||
signStartedAt?: Date
|
||||
signedAt?: Date
|
||||
expiredAt?: Date
|
||||
voidedAt?: Date
|
||||
onTime?: boolean
|
||||
durationSeconds?: number
|
||||
durationMinutes?: number
|
||||
}
|
||||
|
||||
export interface ReportTrendPoint {
|
||||
date: string
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ReportDepartmentPoint {
|
||||
department: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface ReportSelectOption<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
@@ -10,13 +10,20 @@ function resolveBuildId() {
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
// 以 vite.config 所在目录为基准读取 .env,避免「在仓库根目录跑 dev:clinical」
|
||||
// 时 cwd 不是 clinical-web 而读不到 .env.local。
|
||||
const configDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
const env = loadEnv(mode, configDir, '')
|
||||
const buildId = resolveBuildId()
|
||||
const apiProxyTarget =
|
||||
process.env.VITE_API_PROXY_TARGET?.trim() ||
|
||||
env.VITE_API_PROXY_TARGET?.trim() ||
|
||||
'http://127.0.0.1:18080'
|
||||
|
||||
// 启动日志:直接告诉你 /api 到底被代理到了哪里,排查「没走真实接口」时用。
|
||||
// 显示 18080 说明 .env.local 没被读到;显示 ipad.shenynet.com 才是真实接口生效。
|
||||
console.log(`[vite] API 代理目标: ${apiProxyTarget}`)
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user