diff --git a/clinical-web/.env.example b/clinical-web/.env.example index a5e3f84..5a3df2f 100644 --- a/clinical-web/.env.example +++ b/clinical-web/.env.example @@ -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 diff --git a/clinical-web/README.md b/clinical-web/README.md index 0b4eb35..efc8b05 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -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,不向后端发送患者快照、文档名称或明文手机号等页面字段。 diff --git a/clinical-web/docs/reports-api.md b/clinical-web/docs/reports-api.md deleted file mode 100644 index 9d42ad6..0000000 --- a/clinical-web/docs/reports-api.md +++ /dev/null @@ -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,可在筛选栏增加格式选择而无需新增接口。 diff --git a/clinical-web/docs/reports-backend-requirements.md b/clinical-web/docs/reports-backend-requirements.md deleted file mode 100644 index efac924..0000000 --- a/clinical-web/docs/reports-backend-requirements.md +++ /dev/null @@ -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,前端不显示越权数据。 diff --git a/clinical-web/src/api/management/reports.ts b/clinical-web/src/api/management/reports.ts deleted file mode 100644 index 013e010..0000000 --- a/clinical-web/src/api/management/reports.ts +++ /dev/null @@ -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 { - const response = await request.get>( - '/v1/reports/signing/options', - { params: compactQuery(filter) }, - ) - - return normalizeReportOptions(unwrapApiResponse(response)) -} - -export async function getReportOverview( - filter: ReportFilterDto, -): Promise { - const response = await request.get>( - '/v1/reports/signing/overview', - { params: compactQuery(filter) }, - ) - - return normalizeReportOverview(unwrapApiResponse(response)) -} - -export async function getReportTasks( - query: ReportTasksQueryDto, -): Promise> { - const response = await request.get>>( - '/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 { - const response = await request.post>( - '/v1/reports/signing/export-jobs', - payload, - ) - - return unwrapApiResponse(response) -} - -export async function getReportExportJob(jobId: string): Promise { - const response = await request.get>( - `/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`, - ) - - return unwrapApiResponse(response) -} - -export async function downloadReportExportFile(jobId: string): Promise { - return request.get( - `/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}/download`, - { responseType: 'blob' }, - ) -} diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index efc1cb3..f2e9a2b 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -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 { - 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 { diff --git a/clinical-web/src/components/layout/Menu.vue b/clinical-web/src/components/layout/Menu.vue index 6ac7a67..bf165e6 100644 --- a/clinical-web/src/components/layout/Menu.vue +++ b/clinical-web/src/components/layout/Menu.vue @@ -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 ?? []) diff --git a/clinical-web/src/env.d.ts b/clinical-web/src/env.d.ts index ecce41b..c42c436 100644 --- a/clinical-web/src/env.d.ts +++ b/clinical-web/src/env.d.ts @@ -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 diff --git a/clinical-web/src/router/index.ts b/clinical-web/src/router/index.ts index 3c19eec..1d054da 100644 --- a/clinical-web/src/router/index.ts +++ b/clinical-web/src/router/index.ts @@ -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' }, ], }) diff --git a/clinical-web/src/stores/login.ts b/clinical-web/src/stores/login.ts index b48b7d1..8c59f5e 100644 --- a/clinical-web/src/stores/login.ts +++ b/clinical-web/src/stores/login.ts @@ -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, diff --git a/clinical-web/src/utils/mock-flags.ts b/clinical-web/src/utils/mock-flags.ts index 03c17b8..0dd0ebb 100644 --- a/clinical-web/src/utils/mock-flags.ts +++ b/clinical-web/src/utils/mock-flags.ts @@ -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/* 接口)已整体下线, + * 开关与页面一并移除;这里是「报表分析」目录下的四个独立菜单,是另一套菜单、 + * 另一套后端面,不受影响。 * * 缺省开着:后端目前没有任何分析聚合接口,真实模式只会得到空态。 */ diff --git a/clinical-web/src/views/management/MenuResourceView.vue b/clinical-web/src/views/management/MenuResourceView.vue index adb71fe..758da37 100644 --- a/clinical-web/src/views/management/MenuResourceView.vue +++ b/clinical-web/src/views/management/MenuResourceView.vue @@ -16,7 +16,6 @@ const componentRegistry: Record = { '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'), diff --git a/clinical-web/src/views/management/reports/components/DepartmentDistributionChart.vue b/clinical-web/src/views/management/reports/components/DepartmentDistributionChart.vue deleted file mode 100644 index c013ba7..0000000 --- a/clinical-web/src/views/management/reports/components/DepartmentDistributionChart.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - - - diff --git a/clinical-web/src/views/management/reports/components/ReportDetailTable.vue b/clinical-web/src/views/management/reports/components/ReportDetailTable.vue deleted file mode 100644 index 4dd7875..0000000 --- a/clinical-web/src/views/management/reports/components/ReportDetailTable.vue +++ /dev/null @@ -1,353 +0,0 @@ - - - - - diff --git a/clinical-web/src/views/management/reports/components/ReportFilterBar.vue b/clinical-web/src/views/management/reports/components/ReportFilterBar.vue deleted file mode 100644 index 9633c3b..0000000 --- a/clinical-web/src/views/management/reports/components/ReportFilterBar.vue +++ /dev/null @@ -1,263 +0,0 @@ - - - - - diff --git a/clinical-web/src/views/management/reports/components/ReportMetricCard.vue b/clinical-web/src/views/management/reports/components/ReportMetricCard.vue deleted file mode 100644 index 91bb6d4..0000000 --- a/clinical-web/src/views/management/reports/components/ReportMetricCard.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/clinical-web/src/views/management/reports/components/SignatureTrendChart.vue b/clinical-web/src/views/management/reports/components/SignatureTrendChart.vue deleted file mode 100644 index 2050e33..0000000 --- a/clinical-web/src/views/management/reports/components/SignatureTrendChart.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - - - diff --git a/clinical-web/src/views/management/reports/index.vue b/clinical-web/src/views/management/reports/index.vue deleted file mode 100644 index a45e169..0000000 --- a/clinical-web/src/views/management/reports/index.vue +++ /dev/null @@ -1,1305 +0,0 @@ - - - - - diff --git a/clinical-web/src/views/management/reports/mock.ts b/clinical-web/src/views/management/reports/mock.ts deleted file mode 100644 index 90c3cd3..0000000 --- a/clinical-web/src/views/management/reports/mock.ts +++ /dev/null @@ -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[] = [ - { 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[] = [ - { value: 'all', label: '全部类别' }, - { value: 'consent', label: '知情同意书' }, - { value: 'risk', label: '风险告知书' }, - { value: 'admission', label: '入院告知' }, -] - -export const reportPeriodOptions: ReportSelectOption[] = [ - { 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 = { - 放射科: '#0e6e8c', - 心内科: '#18a0c0', - 骨科: '#0f9d6c', - 健康管理中心: '#d9821f', - 全院通用: '#7a4dbb', -} - -export const fallbackDepartmentPoint: ReportDepartmentPoint = { - department: '暂无数据', - value: 0, - color: '#cbd8de', -} diff --git a/clinical-web/src/views/management/reports/types.ts b/clinical-web/src/views/management/reports/types.ts deleted file mode 100644 index b8e7260..0000000 --- a/clinical-web/src/views/management/reports/types.ts +++ /dev/null @@ -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 - campusId?: string - category: Exclude - 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 { - value: T - label: string -} diff --git a/clinical-web/vite.config.ts b/clinical-web/vite.config.ts index c7237be..71b56f0 100644 --- a/clinical-web/vite.config.ts +++ b/clinical-web/vite.config.ts @@ -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: {