Compare commits
19 Commits
b533ab2a58
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc86ee9149 | ||
|
|
b4184260da | ||
|
|
45971b62d9 | ||
|
|
2963c6ea56 | ||
|
|
5b2cc33038 | ||
|
|
8b4b9d031d | ||
|
|
40cd313786 | ||
|
|
e467f03f23 | ||
|
|
beeb98d18f | ||
|
|
2e2d2250dd | ||
|
|
c34ef6f79a | ||
|
|
f4f40f0596 | ||
|
|
9c1b2f53a6 | ||
|
|
40b0a5d040 | ||
|
|
1bc075d5d6 | ||
|
|
a261a978f2 | ||
|
|
a5e0a80191 | ||
|
|
1e2ff79b0c | ||
|
|
2de75ed993 |
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 医签通项目开发规范
|
||||||
|
|
||||||
|
本文件适用于 `medical-sign` 仓库及其子目录。子目录中的 `AGENTS.md` 可以补充更具体的应用规范。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
- `clinical-web`:医护端、电子病历嵌入端和管理端,电脑端优先。
|
||||||
|
- `patient-h5`:患者或家属使用的手机签署 H5,不是移动版电子病历。
|
||||||
|
- `packages`:两个应用稳定共用的接口客户端、类型和基础逻辑;不单独部署。
|
||||||
|
- 两个应用分别维护自己的 `package.json`、`node_modules` 和构建产物。
|
||||||
|
|
||||||
|
## 通用原则
|
||||||
|
|
||||||
|
- 以“患者 + 就诊 + 医嘱/项目 + 签署任务 + 文书版本 + 签署证据”为核心业务关系。
|
||||||
|
- 解释医生、实际签署人和协助操作人员必须分别建模和记录。
|
||||||
|
- 已签署的正式文书不可直接修改;追加内容应创建新的签署任务。
|
||||||
|
- 前端不替代后端完成权限、身份和签署有效性校验。
|
||||||
|
- 不在代码、日志或提交中写入 Token、密码、身份证号、完整病历等敏感信息。
|
||||||
|
- 不把 `dist`、`node_modules`、本地 `.env` 和临时文件提交到仓库。
|
||||||
|
- 未经明确要求,不执行 `git commit`、`git push` 或覆盖他人已有修改。
|
||||||
|
|
||||||
|
## 共享代码
|
||||||
|
|
||||||
|
- 只有在两个应用确实复用且接口已经稳定时,才将代码放入 `packages`。
|
||||||
|
- 应用内只通过明确的公共导出使用共享包,不直接依赖共享包的内部文件。
|
||||||
|
- 与单个应用强相关的页面、组件、Store 和接口,保留在对应应用中。
|
||||||
|
|
||||||
|
## 开发与验证
|
||||||
|
|
||||||
|
在对应应用目录执行其自己的脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
修改接口、状态或跨页面组件时,应同时更新相应的 TypeScript 类型和 README/开发说明。
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面;
|
- `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面;
|
||||||
- `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面;
|
- `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面;
|
||||||
- 当前页面使用演示数据,尚未接入真实后端接口;
|
- 默认仍使用演示数据,但医护端已按 MEDISIGN 接口文档接入登录、患者/就诊、模板、签署任务、文件产物、投递、用户组织和模板权限等真实接口;患者 H5 已接入一次性 Token 消费和签名上传请求;
|
||||||
- `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。
|
- `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。
|
||||||
|
|
||||||
## 项目定位
|
## 项目定位
|
||||||
|
|||||||
117
clinical-web/AGENTS.md
Normal file
117
clinical-web/AGENTS.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# clinical-web 开发规范
|
||||||
|
|
||||||
|
## 技术栈与边界
|
||||||
|
|
||||||
|
- 使用 Vue 3、TypeScript、Vite、Vue Router、Pinia、Element Plus 和 Axios。
|
||||||
|
- 所有接口请求统一经过 `src/utils/request.ts` 的单例实例。
|
||||||
|
- 开发环境默认使用 `/api`,由 `vite.config.ts` 代理到后端;不要在页面中直接拼接后端域名。
|
||||||
|
- 当前除登录外的业务接口可以使用 Mock,但 Mock 数据必须遵守真实接口类型。
|
||||||
|
|
||||||
|
## 目录约定
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├─ api/
|
||||||
|
│ ├─ auth.ts
|
||||||
|
│ ├─ workbench/
|
||||||
|
│ │ ├─ home.ts
|
||||||
|
│ │ ├─ signing.ts
|
||||||
|
│ │ └─ types.ts
|
||||||
|
│ └─ management/
|
||||||
|
│ ├─ documents.ts
|
||||||
|
│ ├─ reports.ts
|
||||||
|
│ ├─ users.ts
|
||||||
|
│ ├─ permissions.ts
|
||||||
|
│ ├─ document-permissions.ts
|
||||||
|
│ ├─ settings.ts
|
||||||
|
│ └─ types.ts
|
||||||
|
├─ components/
|
||||||
|
│ ├─ layout/
|
||||||
|
│ └─ signing/
|
||||||
|
├─ composables/
|
||||||
|
├─ stores/
|
||||||
|
├─ types/
|
||||||
|
├─ utils/
|
||||||
|
└─ views/
|
||||||
|
├─ auth/
|
||||||
|
├─ workbench/
|
||||||
|
└─ management/
|
||||||
|
```
|
||||||
|
|
||||||
|
- API 文件按页面业务域对应;管理端直接使用 `documents.ts`、`reports.ts` 等文件,不再额外套同名目录。
|
||||||
|
- 每个具体 View 使用 `index.vue` 作为入口;当前页面专用组件放在同级 `components`。
|
||||||
|
- 跨页面的领域组件放在 `src/components`,布局组件统一放在 `src/components/layout`。
|
||||||
|
- API DTO 类型放在对应 API 业务域的 `types.ts`;页面展示和交互类型放在 View 的 `types.ts`;全局类型放在 `src/types/common.ts`。
|
||||||
|
|
||||||
|
## API 规范
|
||||||
|
|
||||||
|
- API 文件只负责请求、响应类型和必要的数据标准化。
|
||||||
|
- 不在 API 文件中创建新的 Axios 实例,不在 API 层操作组件、路由或 `ElMessage`。
|
||||||
|
- 所有请求通过 `request` 单例发送;接口函数必须提供明确的返回类型。
|
||||||
|
- 接口地址使用相对路径,不重复写基础地址。
|
||||||
|
- 业务接口的列表、详情、创建、更新和删除函数按资源命名,例如:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ReportOverviewResponse, ReportQuery } from './types'
|
||||||
|
|
||||||
|
export function getReportOverview(params: ReportQuery) {
|
||||||
|
return request.get<ReportOverviewResponse>('/management/reports/overview', { params })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 响应包装、错误码和后端 DTO 与页面展示模型不一致时,在 API 边界或 composable 中转换,组件不负责猜测接口结构。
|
||||||
|
- 登录 Token、用户信息和本地持久化只由 `stores/login.ts` 与 `utils/auth-storage.ts` 管理,页面不直接操作 `localStorage`。
|
||||||
|
|
||||||
|
## 页面与组件拆分
|
||||||
|
|
||||||
|
- `views/**/index.vue` 负责页面编排、接口调用、加载/错误状态、筛选条件和路由联动。
|
||||||
|
- 当前页面专用组件负责展示和交互,通过类型化 `props` 接收数据,通过 `emit` 通知父组件。
|
||||||
|
- 展示型组件不直接调用 API、不直接修改全局 Store;确有跨页面业务复用时,放入领域组件并明确其边界。
|
||||||
|
- 组件只承担一个清晰职责。不要为了减少文件长度机械拆分小片段,也不要让页面入口堆积复杂表单和弹窗逻辑。
|
||||||
|
- 复杂表单、弹窗流程、校验、提交和异步状态抽到 `composables/useXxx.ts`;弹窗组件负责表单渲染和事件呈现。
|
||||||
|
- 签署任务新增流程由 `components/signing/NewSigningTaskDialog.vue` 和 `composables/useSigningTaskForm.ts` 共同维护,供签署工作台和文档库复用。
|
||||||
|
|
||||||
|
## 状态管理
|
||||||
|
|
||||||
|
- 页面只使用一次的表格数据、筛选条件、弹窗显隐和 loading,优先使用页面内的 `ref`、`reactive` 和 composable。
|
||||||
|
- 只有跨页面共享、需要持久化或代表全局上下文的状态才进入 Pinia。
|
||||||
|
- Store 按业务域拆分,并统一使用 Composition API 写法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const useExampleStore = defineStore('example', () => {
|
||||||
|
const value = ref('')
|
||||||
|
|
||||||
|
const isReady = computed(() => Boolean(value.value))
|
||||||
|
|
||||||
|
function setValue(nextValue: string) {
|
||||||
|
value.value = nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value, isReady, setValue }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- 不使用 Pinia 的 `state`、`getters`、`actions` 配置式写法。
|
||||||
|
- `stores/app.ts` 只管理院区、环境等应用上下文。
|
||||||
|
- `stores/login.ts` 只管理登录态、用户信息、Token 和登录/退出流程。
|
||||||
|
- 不把所有业务数据集中到 `app` Store;签署任务列表等页面数据默认留在页面或 composable 中。
|
||||||
|
- 避免 Store 之间互相依赖,尤其不要让 `request.ts` 静态依赖业务 Store,防止循环依赖。
|
||||||
|
|
||||||
|
## 类型与命名
|
||||||
|
|
||||||
|
- 不使用无理由的 `any`;后端字段不确定时先定义 `unknown`,在边界处完成校验或标准化。
|
||||||
|
- Vue 组件使用 PascalCase;API、composable、Store 和工具文件使用 kebab-case 或项目已有命名方式。
|
||||||
|
- `useXxx` 表示 composable,`useXxxStore` 表示 Pinia Store。
|
||||||
|
- 类型名描述业务含义,避免使用 `Data`、`Info` 等无语义名称。
|
||||||
|
|
||||||
|
## 提交前检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npx vue-tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
如果构建被本机 `node_modules` 或已有 `dist` 的权限问题阻断,应保留错误信息并使用临时输出目录验证,不要删除或覆盖已有构建产物。
|
||||||
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页和医护端整体布局。当前所有业务 View 暂时保留文字占位,登录使用演示认证逻辑,尚未接入真实后端。
|
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。登录、首页统计、签署工作台的患者/就诊/模板/任务/审计、签署文件下载、短信重发、模板创建/版本工作流、用户组织查询与用户创建编辑,以及按模板维护文档权限已经接入 MEDISIGN 后端;系统设置仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
|
||||||
|
|
||||||
## 当前路由结构
|
## 当前路由结构
|
||||||
|
|
||||||
@@ -108,18 +108,67 @@ ClinicalLayout
|
|||||||
|
|
||||||
页面目录按业务域组织:`auth` 保留登录页;工作台页面位于 `views/workbench`;管理页面位于 `views/management`。每个具体页面目录都有 `index.vue` 入口和 `components` 目录,用于继续拆分当前页面组件。
|
页面目录按业务域组织:`auth` 保留登录页;工作台页面位于 `views/workbench`;管理页面位于 `views/management`。每个具体页面目录都有 `index.vue` 入口和 `components` 目录,用于继续拆分当前页面组件。
|
||||||
|
|
||||||
|
跨页面复用的业务组件和 composable 不放在具体 View 目录中:
|
||||||
|
|
||||||
|
- `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用;
|
||||||
|
- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态。
|
||||||
|
|
||||||
## API 与类型约定
|
## API 与类型约定
|
||||||
|
|
||||||
API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 下的页面文件,管理页面使用 `api/management` 下的直接文件:
|
API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 下的页面文件,管理页面使用 `api/management` 下的直接文件:
|
||||||
|
|
||||||
- `api/workbench/home.ts`
|
- `api/workbench/home.ts`
|
||||||
- `api/workbench/signing.ts`
|
- `api/workbench/signing.ts`
|
||||||
|
- `api/workbench/artifacts.ts`
|
||||||
|
- `api/workbench/deliveries.ts`
|
||||||
- `api/management/documents.ts`
|
- `api/management/documents.ts`
|
||||||
- `api/management/reports.ts`
|
- `api/management/reports.ts`
|
||||||
- `api/management/users.ts`
|
- `api/management/users.ts`
|
||||||
|
- `api/management/roles.ts`
|
||||||
|
- `api/management/permissions.ts`
|
||||||
|
- `api/management/organization.ts`
|
||||||
- `api/management/document-permissions.ts`
|
- `api/management/document-permissions.ts`
|
||||||
|
- `api/management/audit.ts`
|
||||||
- `api/management/settings.ts`
|
- `api/management/settings.ts`
|
||||||
|
|
||||||
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。
|
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
|
||||||
|
|
||||||
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。当前 API 模块默认返回 mock 数据,设置 `VITE_USE_MOCK=false` 后切换为真实接口。
|
- `POST /api/v1/auth/login`:账号密码登录;
|
||||||
|
- `GET /api/v1/auth/captcha`:获取按需启用的图形验证码;
|
||||||
|
- `GET /api/v1/auth/me`:查询当前用户;
|
||||||
|
- `POST /api/v1/auth/logout`:注销当前会话;
|
||||||
|
- 后续请求自动携带 `X-Token` 请求头。
|
||||||
|
|
||||||
|
签署工作台已接入的真实接口包括:
|
||||||
|
|
||||||
|
- `GET /api/v1/patients`、`GET /api/v1/patients/{id}`:患者定位;
|
||||||
|
- `GET /api/v1/patients/{id}/visits`、`GET /api/v1/visits`:就诊关联;
|
||||||
|
- `GET /api/v1/templates/available-versions`:可发起模板版本;
|
||||||
|
- `GET /api/v1/sign-tasks`、`GET /api/v1/sign-tasks/{id}`:任务列表和详情;
|
||||||
|
- `POST /api/v1/sign-tasks`、`POST /api/v1/sign-tasks/{id}/prepare-send`:创建任务和准备投递;
|
||||||
|
- `POST /api/v1/sign-tasks/{id}/void`、`/resend`、`/reopen`:作废、短信重发和重新开启;
|
||||||
|
- `GET /api/v1/sign-tasks/{id}/events`:操作审计事件。
|
||||||
|
|
||||||
|
已接入或封装的扩展接口包括:
|
||||||
|
|
||||||
|
- `GET /api/v1/sign-artifacts/task/{taskId}`、`GET /api/v1/sign-artifacts/task/{taskId}/{artifactId}`:签署文件索引;
|
||||||
|
- `GET /api/v1/sign-artifacts/{artifactId}/download`:原始 PDF、签署后 PDF、签名原图下载;
|
||||||
|
- `POST /api/v1/sign-deliveries/{taskId}/sms/resend`:短信重发,自动携带幂等键;
|
||||||
|
- `GET/POST /api/v1/templates`、`GET /api/v1/templates/{id}`:模板列表、详情;
|
||||||
|
- `GET/POST /api/v1/templates/{id}/versions`、版本工作流接口:版本查询、创建、送审、驳回、通过、发布、停用、归档;
|
||||||
|
- `GET /api/v1/users`、`GET /api/v1/roles`、`GET /api/v1/campuses`、`GET /api/v1/departments`:用户页真实查询及组织字典;
|
||||||
|
- `GET/POST/PUT/DELETE /api/v1/permissions`:API 权限分页查询、详情、新增、修改和停用;
|
||||||
|
- `GET/POST/DELETE /api/v1/templates/{id}/permissions`:按模板查询和维护文档权限;
|
||||||
|
- `GET /api/v1/audit-logs`:全局审计日志查询 API。
|
||||||
|
|
||||||
|
报表页在真实模式下使用 `GET /api/v1/sign-tasks` 聚合当前任务数据,并支持跳转任务和导出当前筛选结果;MEDISIGN 文档中暂无独立的报表统计接口。首页同样从模板、院区和签署任务接口聚合工作台概览。
|
||||||
|
|
||||||
|
首页前端修复记录及后端聚合接口需求见 [`docs/workbench-home-api.md`](./docs/workbench-home-api.md)。
|
||||||
|
|
||||||
|
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
|
||||||
|
|
||||||
|
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
|
||||||
|
|
||||||
|
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。除登录外,当前业务 API 模块默认返回 Mock 数据,设置 `VITE_USE_MOCK=false` 后切换已实现的真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
|
||||||
|
|
||||||
|
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。默认 Mock 模式仍可用于演示完整交互,联调时设置 `VITE_USE_MOCK=false`。
|
||||||
|
|||||||
141
clinical-web/docs/workbench-home-api.md
Normal file
141
clinical-web/docs/workbench-home-api.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 工作台首页后端接口需求
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
工作台首页是只读的概览页,当前前端可以通过模板、院区和签署任务接口临时聚合数据,但任务量较大时会产生多次分页请求,而且现有任务列表主要按 `createdAt` 查询,无法准确支持“按签署时间统计”和“按超时时间统计”。
|
||||||
|
|
||||||
|
建议后端提供一个聚合接口,由后端在权限范围内一次性计算首页所需数据。
|
||||||
|
|
||||||
|
## 2. 推荐接口
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/workbench/overview
|
||||||
|
```
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Token: {登录令牌}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| -------------- | ------- | ---- | ---------------------------------------------- |
|
||||||
|
| `campusId` | UUID | 是 | 当前首页选择的院区 |
|
||||||
|
| `period` | integer | 是 | 签署趋势和文档排名周期,只允许 `7`、`14`、`30` |
|
||||||
|
| `todoLimit` | integer | 否 | 待办数量,默认 `8`,最大 `20` |
|
||||||
|
| `rankingLimit` | integer | 否 | 排名数量,默认 `10`,最大 `20` |
|
||||||
|
|
||||||
|
日期计算建议统一使用服务端的医院业务时区(上海为 `Asia/Shanghai`),响应中的时间字段使用 ISO-8601 格式。
|
||||||
|
|
||||||
|
## 3. 返回结构
|
||||||
|
|
||||||
|
接口继续使用当前统一响应包装:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "OK",
|
||||||
|
"data": {
|
||||||
|
"campusId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"generatedAt": "2026-09-01T08:00:00Z",
|
||||||
|
"summary": {
|
||||||
|
"todaySigned": 36,
|
||||||
|
"todaySignedChange": 2,
|
||||||
|
"pendingPatientSigning": 18,
|
||||||
|
"todayOverdue": 2,
|
||||||
|
"availableTemplates": 128,
|
||||||
|
"coveredDepartments": 9
|
||||||
|
},
|
||||||
|
"trend": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-26",
|
||||||
|
"label": "8/26",
|
||||||
|
"signedCount": 39
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"todos": [
|
||||||
|
{
|
||||||
|
"taskId": "任务 UUID",
|
||||||
|
"patientId": "脱敏患者标识",
|
||||||
|
"patientName": "患者姓名",
|
||||||
|
"documentName": "知情同意书",
|
||||||
|
"departmentName": "消化内科",
|
||||||
|
"status": "WAITING_SIGN",
|
||||||
|
"signMethod": "PAD",
|
||||||
|
"updatedAt": "2026-09-01T07:50:00Z",
|
||||||
|
"expiredAt": "2026-09-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"documentRanking": [
|
||||||
|
{
|
||||||
|
"templateId": "模板 UUID",
|
||||||
|
"documentName": "住院患者知情同意书",
|
||||||
|
"departmentName": "全院通用",
|
||||||
|
"signedCount": 58
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"traceId": "链路追踪 ID",
|
||||||
|
"timestamp": "2026-09-01T08:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 统计口径
|
||||||
|
|
||||||
|
### 4.1 指标卡
|
||||||
|
|
||||||
|
- `todaySigned`:`status=SIGNED` 且 `signedAt` 位于今天业务时区起止范围内的任务数。
|
||||||
|
- `todaySignedChange`:`todaySigned - yesterdaySigned`,正数表示上升,负数表示下降,`0` 表示持平。
|
||||||
|
- `pendingPatientSigning`:状态为 `CREATED`、`WAITING_SIGN` 或 `GENERATING` 的任务数;不按创建日期限制。
|
||||||
|
- `todayOverdue`:状态为 `EXPIRED` 且 `expiredAt` 位于今天业务时区起止范围内的任务数。
|
||||||
|
- `availableTemplates`:当前用户在指定院区有 `USE` 权限且已发布生效的模板数量,按 `templateId` 去重,不按版本行数重复计算。
|
||||||
|
- `coveredDepartments`:上述可用模板中非空 `departmentId` 的去重数量;全院通用模板不计入科室数。
|
||||||
|
|
||||||
|
### 4.2 签署趋势
|
||||||
|
|
||||||
|
返回最近 `period` 个自然日,包含今天,按日期升序排列。每天按 `signedAt` 统计 `SIGNED` 任务数,而不是按 `createdAt` 或 `updatedAt` 统计。
|
||||||
|
|
||||||
|
### 4.3 待办任务
|
||||||
|
|
||||||
|
待办包括 `CREATED`、`WAITING_SIGN`、`GENERATING` 和 `EXPIRED` 任务,按 `updatedAt` 倒序返回前 `todoLimit` 条。前端会将前三种状态展示为“等待患者签署”,将 `EXPIRED` 展示为“已超时”。
|
||||||
|
|
||||||
|
返回的患者标识必须遵守现有数据脱敏和权限规则,不返回明文证件号、明文手机号或其他不必要的敏感信息。
|
||||||
|
|
||||||
|
### 4.4 文档签署排名
|
||||||
|
|
||||||
|
仅统计 `signedAt` 位于最近 `period` 个自然日内的 `SIGNED` 任务,按 `templateId` 分组后倒序返回前 `rankingLimit` 条。分组键必须使用模板 ID,不能只使用文档名称,避免同名模板被错误合并。
|
||||||
|
|
||||||
|
## 5. 权限和错误响应
|
||||||
|
|
||||||
|
接口必须复用签署任务查询和模板 `USE` 权限的数据范围控制,只统计当前用户有权查看的院区、科室和任务。
|
||||||
|
|
||||||
|
建议至少返回以下状态:
|
||||||
|
|
||||||
|
| HTTP 状态 | 场景 |
|
||||||
|
| --------- | ------------------------------- |
|
||||||
|
| `200` | 查询成功 |
|
||||||
|
| `400` | `campusId` 或 `period` 参数错误 |
|
||||||
|
| `401` | 未登录或令牌失效 |
|
||||||
|
| `403` | 无权查看指定院区或工作台数据 |
|
||||||
|
| `500` | 服务端聚合失败 |
|
||||||
|
|
||||||
|
## 6. 暂时不增加聚合接口时的最小改造
|
||||||
|
|
||||||
|
如果暂时不能提供 `/workbench/overview`,现有接口至少需要补充或明确以下能力:
|
||||||
|
|
||||||
|
1. `GET /api/v1/templates/available-versions` 必须支持 `campusId`,并返回真实 `departmentId`;前端已可以使用该筛选参数。
|
||||||
|
2. `GET /api/v1/sign-tasks` 需要支持按 `signedAt` 和 `expiredAt` 查询,而不能只支持 `createdAt`。
|
||||||
|
3. 任务列表最好支持多状态查询,或者提供待签署任务汇总接口,否则待办数量需要分别请求多个状态。
|
||||||
|
4. 任务或模板响应需要提供稳定的 `templateId`、`templateName`、`departmentName`,避免历史模板不可用时只能显示模板版本号。
|
||||||
|
5. 如果继续使用分页聚合,接口需要保证 `total`、`page`、`size` 一致,并允许客户端按最大页大小安全拉取全部数据。
|
||||||
|
|
||||||
|
## 7. 前端对接验收标准
|
||||||
|
|
||||||
|
- 切换院区后,四个指标、趋势、待办和排名都只属于当前院区。
|
||||||
|
- 切换 `7/14/30` 天后,趋势和排名同时改变统计周期。
|
||||||
|
- 跨自然日、任务延迟签署和任务延迟超时场景下,指标仍按 `signedAt`、`expiredAt` 正确统计。
|
||||||
|
- 首页显示的昨日变化不再使用固定文案。
|
||||||
|
- 待办点击后可以根据 `taskId` 直接打开详情,即使任务不在签署工作台当前第一页。
|
||||||
101
clinical-web/src/api/auth.ts
Normal file
101
clinical-web/src/api/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { ApiResponseError, unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CaptchaResponse,
|
||||||
|
CurrentUserResponse,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
} from '@/types/auth'
|
||||||
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
|
export { ApiResponseError } from '@/utils/api-response'
|
||||||
|
|
||||||
|
export async function getCaptcha(): Promise<CaptchaResponse> {
|
||||||
|
const response = await request.get<ApiResponse<CaptchaResponse>>('/v1/auth/captcha')
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissions(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is string => typeof item === 'string')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthenticatedUser(user: AuthenticatedUser): AuthenticatedUser {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
permissions: normalizePermissions(user.permissions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(payload: LoginRequest): Promise<LoginResponse> {
|
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login', payload)
|
||||||
|
const session = unwrapApiResponse(response)
|
||||||
|
|
||||||
|
if (!session.token || !session.user) {
|
||||||
|
throw new Error('登录接口响应缺少 token 或用户信息')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSession: LoginResponse = {
|
||||||
|
...session,
|
||||||
|
expiresAt: session.expiresAt || '',
|
||||||
|
user: normalizeAuthenticatedUser(session.user),
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedSession
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(): Promise<CurrentUserResponse> {
|
||||||
|
const response = await request.get<ApiResponse<CurrentUserResponse>>('/v1/auth/me')
|
||||||
|
const user = unwrapApiResponse(response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
permissions: normalizePermissions(user.permissions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
const response = await request.post<ApiResponse<Record<string, never>>>('/v1/auth/logout')
|
||||||
|
unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthErrorMessage(error: unknown) {
|
||||||
|
if (error instanceof ApiResponseError) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
return '无法连接登录服务,请检查网络或接口地址'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
return '账号或密码错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `登录失败(HTTP ${error.response.status})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return '登录失败,请稍后重试'
|
||||||
|
}
|
||||||
82
clinical-web/src/api/management/audit.ts
Normal file
82
clinical-web/src/api/management/audit.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse, PageResult } from '@/types/common'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AuditLogListResponse,
|
||||||
|
AuditLogQuery,
|
||||||
|
AuditLogRecord,
|
||||||
|
AuditLogResponseDto,
|
||||||
|
BackendCollection,
|
||||||
|
BackendPage,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAuditLog(dto: AuditLogResponseDto): AuditLogRecord {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
action: dto.action,
|
||||||
|
resourceType: dto.resourceType,
|
||||||
|
resourceId: dto.resourceId,
|
||||||
|
operatorId: dto.operatorId,
|
||||||
|
clientIp: dto.clientIp ?? '—',
|
||||||
|
userAgent: dto.userAgent ?? '—',
|
||||||
|
detailsJson: dto.detailsJson ?? '',
|
||||||
|
createdAt: formatDateTime(dto.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePage<T>(
|
||||||
|
data: BackendPage<T> | T[],
|
||||||
|
fallbackPage: number,
|
||||||
|
fallbackPageSize: number,
|
||||||
|
): PageResult<T> {
|
||||||
|
const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
total: Array.isArray(data) ? records.length : (data.total ?? records.length),
|
||||||
|
page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage),
|
||||||
|
pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLogs(query: AuditLogQuery): Promise<AuditLogListResponse> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.resolve({ records: [], total: 0, page: query.page, pageSize: query.pageSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: Math.max(query.page, 1),
|
||||||
|
size: Math.min(Math.max(query.pageSize, 1), 200),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.keyword?.trim()) {
|
||||||
|
params.keyword = query.keyword.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.get<ApiResponse<BackendCollection<AuditLogResponseDto>>>('/v1/audit-logs', { params })
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then((data) => {
|
||||||
|
const page = normalizePage(data, query.page, query.pageSize)
|
||||||
|
return { ...page, records: page.records.map(mapAuditLog) }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
import type { PermissionListResponse, PermissionQuery, PermissionRecord } from './types'
|
import type {
|
||||||
|
BackendCollection,
|
||||||
|
CreateTemplatePermissionRequest,
|
||||||
|
PermissionListResponse,
|
||||||
|
PermissionQuery,
|
||||||
|
PermissionRecord,
|
||||||
|
TemplatePermissionRecord,
|
||||||
|
TemplatePermissionResponseDto,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
export const isPermissionMockEnabled = useMockData
|
||||||
|
|
||||||
const mockRecords: PermissionRecord[] = [
|
const mockRecords: PermissionRecord[] = [
|
||||||
{
|
{
|
||||||
id: 'permission-001',
|
id: 'permission-001',
|
||||||
@@ -25,9 +37,9 @@ const mockRecords: PermissionRecord[] = [
|
|||||||
|
|
||||||
export function getDocumentPermissions(query: PermissionQuery): Promise<PermissionListResponse> {
|
export function getDocumentPermissions(query: PermissionQuery): Promise<PermissionListResponse> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<PermissionListResponse>('/management/document-permissions', {
|
return Promise.reject(
|
||||||
params: query,
|
new Error('MEDISIGN 未提供跨模板权限汇总接口,请使用 getTemplatePermissions'),
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = query.keyword?.trim().toLowerCase()
|
const keyword = query.keyword?.trim().toLowerCase()
|
||||||
@@ -51,3 +63,80 @@ export function getDocumentPermissions(query: PermissionQuery): Promise<Permissi
|
|||||||
pageSize,
|
pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTemplatePermission(dto: TemplatePermissionResponseDto): TemplatePermissionRecord {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
templateId: dto.templateId,
|
||||||
|
subjectType: dto.subjectType,
|
||||||
|
subjectId: dto.subjectId,
|
||||||
|
subjectName: dto.subjectId,
|
||||||
|
permissionLevel: dto.permissionLevel,
|
||||||
|
effect: dto.effect,
|
||||||
|
inherited: dto.inherited,
|
||||||
|
createdAt: formatDateTime(dto.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplatePermissions(
|
||||||
|
templateId: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<TemplatePermissionRecord[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.get<ApiResponse<BackendCollection<TemplatePermissionResponseDto>>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/permissions`,
|
||||||
|
{ params: userId ? { userId } : undefined },
|
||||||
|
)
|
||||||
|
const data = unwrapApiResponse(response)
|
||||||
|
const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
return records.map(mapTemplatePermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplatePermission(
|
||||||
|
templateId: string,
|
||||||
|
payload: CreateTemplatePermissionRequest,
|
||||||
|
): Promise<TemplatePermissionRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行权限写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<TemplatePermissionResponseDto>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/permissions`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return mapTemplatePermission(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplatePermission(templateId: string, permissionId: string) {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行权限写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.delete<ApiResponse<null>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/permissions/${encodeURIComponent(permissionId)}`,
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { request } from '@/utils/request'
|
import axios from 'axios'
|
||||||
|
|
||||||
import type { DocumentListResponse, DocumentQuery, DocumentRecord } from './types'
|
import { unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse, PageResult } from '@/types/common'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BackendCollection,
|
||||||
|
BackendPage,
|
||||||
|
BackendTemplateStatus,
|
||||||
|
CreateTemplateRequest,
|
||||||
|
CreateTemplateVersionRequest,
|
||||||
|
DocumentListResponse,
|
||||||
|
DocumentQuery,
|
||||||
|
DocumentRecord,
|
||||||
|
TemplateResponseDto,
|
||||||
|
TemplateVersionResponseDto,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
export const isDocumentMockEnabled = useMockData
|
||||||
|
|
||||||
const mockRecords: DocumentRecord[] = [
|
const mockRecords: DocumentRecord[] = [
|
||||||
{
|
{
|
||||||
id: 'doc-001',
|
id: 'doc-001',
|
||||||
@@ -25,11 +42,119 @@ const mockRecords: DocumentRecord[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentStatus(status: BackendTemplateStatus | null | undefined) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PUBLISHED':
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'published' as const
|
||||||
|
case 'ARCHIVED':
|
||||||
|
case 'DISABLED':
|
||||||
|
return 'archived' as const
|
||||||
|
case 'PENDING_REVIEW':
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'review' as const
|
||||||
|
case 'DRAFT':
|
||||||
|
default:
|
||||||
|
return 'draft' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDocument(dto: TemplateResponseDto): DocumentRecord {
|
||||||
|
const versionId = dto.currentVersionId ?? undefined
|
||||||
|
const version = dto.versionSequence ? `v${dto.versionSequence}` : '—'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.templateCode,
|
||||||
|
category: dto.category ?? '未分类',
|
||||||
|
version: String(version),
|
||||||
|
status: normalizeDocumentStatus(dto.status),
|
||||||
|
backendStatus: dto.status ?? undefined,
|
||||||
|
updatedBy: dto.updatedBy ?? dto.createdBy ?? '—',
|
||||||
|
updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt),
|
||||||
|
versionId,
|
||||||
|
departmentId: dto.departmentId,
|
||||||
|
departmentName: dto.departmentId ? undefined : '全院通用',
|
||||||
|
campusId: dto.campusId,
|
||||||
|
description: dto.description ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePage<T>(
|
||||||
|
data: BackendPage<T> | T[],
|
||||||
|
fallbackPage: number,
|
||||||
|
fallbackPageSize: number,
|
||||||
|
): PageResult<T> {
|
||||||
|
const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
const page = Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage)
|
||||||
|
const pageSize = Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
total: Array.isArray(data) ? records.length : (data.total ?? records.length),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendQuery(query: DocumentQuery) {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: Math.max(query.page, 1),
|
||||||
|
size: Math.min(Math.max(query.pageSize, 1), 200),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.keyword?.trim()) {
|
||||||
|
params.keyword = query.keyword.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status && query.status !== 'all') {
|
||||||
|
const statusMap: Record<Exclude<DocumentQuery['status'], undefined | 'all'>, string> = {
|
||||||
|
draft: 'DRAFT',
|
||||||
|
published: 'PUBLISHED',
|
||||||
|
archived: 'ARCHIVED',
|
||||||
|
review: 'PENDING_REVIEW',
|
||||||
|
}
|
||||||
|
params.status = statusMap[query.status]
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
export function getDocuments(query: DocumentQuery): Promise<DocumentListResponse> {
|
export function getDocuments(query: DocumentQuery): Promise<DocumentListResponse> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<DocumentListResponse>('/management/documents', {
|
return request
|
||||||
params: query,
|
.get<ApiResponse<BackendCollection<TemplateResponseDto>>>('/v1/templates', {
|
||||||
})
|
params: toBackendQuery(query),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
records: page.records.map(mapDocument),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = query.keyword?.trim().toLowerCase()
|
const keyword = query.keyword?.trim().toLowerCase()
|
||||||
@@ -44,17 +169,111 @@ export function getDocuments(query: DocumentQuery): Promise<DocumentListResponse
|
|||||||
const start = (page - 1) * pageSize
|
const start = (page - 1) * pageSize
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
records: filteredRecords.slice(start, start + pageSize),
|
records: filteredRecords.slice(start, start + pageSize).map((record) => ({ ...record })),
|
||||||
total: filteredRecords.length,
|
total: filteredRecords.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDocumentDetail(id: string): Promise<DocumentRecord | null> {
|
export async function getDocumentDetail(id: string): Promise<DocumentRecord | null> {
|
||||||
if (!useMockData) {
|
if (useMockData) {
|
||||||
return request.get<DocumentRecord>('/management/documents/' + id)
|
const record = mockRecords.find((item) => item.id === id)
|
||||||
|
return record ? { ...record } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null)
|
try {
|
||||||
|
const response = await request.get<ApiResponse<TemplateResponseDto>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
return mapDocument(unwrapApiResponse(response))
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplate(payload: CreateTemplateRequest): Promise<DocumentRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行模板写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<TemplateResponseDto>>('/v1/templates', payload)
|
||||||
|
return mapDocument(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplateVersion(
|
||||||
|
templateId: string,
|
||||||
|
payload: CreateTemplateVersionRequest,
|
||||||
|
): Promise<TemplateVersionResponseDto> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行模板写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<TemplateVersionResponseDto>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/versions`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateVersions(
|
||||||
|
templateId: string,
|
||||||
|
): Promise<TemplateVersionResponseDto[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.get<ApiResponse<BackendCollection<TemplateVersionResponseDto>>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/versions`,
|
||||||
|
)
|
||||||
|
return normalizePage(unwrapApiResponse(response), 1, 200).records
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateVersionDetail(
|
||||||
|
templateId: string,
|
||||||
|
versionId: string,
|
||||||
|
): Promise<TemplateVersionResponseDto | null> {
|
||||||
|
if (useMockData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get<ApiResponse<TemplateVersionResponseDto>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}`,
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateVersionAction =
|
||||||
|
'submit-review' | 'reject' | 'approve' | 'publish' | 'disable' | 'archive'
|
||||||
|
|
||||||
|
export function updateTemplateVersionStatus(
|
||||||
|
templateId: string,
|
||||||
|
versionId: string,
|
||||||
|
action: TemplateVersionAction,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<TemplateVersionResponseDto> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行模板版本工作流'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponse<TemplateVersionResponseDto>>(
|
||||||
|
`/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}/${action}`,
|
||||||
|
requestData,
|
||||||
|
)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
}
|
}
|
||||||
|
|||||||
159
clinical-web/src/api/management/organization.ts
Normal file
159
clinical-web/src/api/management/organization.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BackendCollection,
|
||||||
|
CampusRecord,
|
||||||
|
CreateCampusRequest,
|
||||||
|
CreateDepartmentRequest,
|
||||||
|
DepartmentRecord,
|
||||||
|
OrganizationResponseDto,
|
||||||
|
UpdateCampusRequest,
|
||||||
|
UpdateDepartmentRequest,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
function getRecords(data: BackendCollection<OrganizationResponseDto>) {
|
||||||
|
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampuses(): Promise<CampusRecord[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses')
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then((data) =>
|
||||||
|
getRecords(data).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
code: item.code ?? item.id,
|
||||||
|
name: item.name ?? item.campusName ?? item.id,
|
||||||
|
address: item.address ?? undefined,
|
||||||
|
status: item.status ?? 'ENABLED',
|
||||||
|
sortNo: item.sortNo ?? 0,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDepartments(campusId?: string): Promise<DepartmentRecord[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/departments', {
|
||||||
|
params: campusId ? { campusId } : undefined,
|
||||||
|
})
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then((data) =>
|
||||||
|
getRecords(data).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
campusId: item.campusId ?? '',
|
||||||
|
parentId: item.parentId,
|
||||||
|
code: item.code ?? item.id,
|
||||||
|
name: item.name ?? item.departmentName ?? item.id,
|
||||||
|
status: item.status ?? 'ENABLED',
|
||||||
|
sortNo: item.sortNo ?? 0,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCampus(data: OrganizationResponseDto): CampusRecord {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
code: data.code ?? data.id,
|
||||||
|
name: data.name ?? data.campusName ?? data.id,
|
||||||
|
address: data.address ?? undefined,
|
||||||
|
status: data.status ?? 'ENABLED',
|
||||||
|
sortNo: data.sortNo ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDepartment(data: OrganizationResponseDto): DepartmentRecord {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
campusId: data.campusId ?? '',
|
||||||
|
parentId: data.parentId,
|
||||||
|
code: data.code ?? data.id,
|
||||||
|
name: data.name ?? data.departmentName ?? data.id,
|
||||||
|
status: data.status ?? 'ENABLED',
|
||||||
|
sortNo: data.sortNo ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCampus(payload: CreateCampusRequest): Promise<CampusRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行院区写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<OrganizationResponseDto>>('/v1/campuses', payload)
|
||||||
|
return mapCampus(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCampus(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateCampusRequest,
|
||||||
|
): Promise<CampusRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行院区写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.put<ApiResponse<OrganizationResponseDto>>(
|
||||||
|
`/v1/campuses/${encodeURIComponent(id)}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return mapCampus(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCampus(id: string): Promise<void> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行院区写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.delete<ApiResponse<null>>(`/v1/campuses/${encodeURIComponent(id)}`)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDepartment(
|
||||||
|
payload: CreateDepartmentRequest,
|
||||||
|
): Promise<DepartmentRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行科室写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<OrganizationResponseDto>>(
|
||||||
|
'/v1/departments',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return mapDepartment(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDepartment(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateDepartmentRequest,
|
||||||
|
): Promise<DepartmentRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行科室写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.put<ApiResponse<OrganizationResponseDto>>(
|
||||||
|
`/v1/departments/${encodeURIComponent(id)}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return mapDepartment(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDepartment(id: string): Promise<void> {
|
||||||
|
if (useMockData) {
|
||||||
|
throw new Error('Mock 模式不执行科室写操作')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.delete<ApiResponse<null>>(
|
||||||
|
`/v1/departments/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
}
|
||||||
209
clinical-web/src/api/management/permissions.ts
Normal file
209
clinical-web/src/api/management/permissions.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse, PageResult } from '@/types/common'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiPermissionListResponse,
|
||||||
|
ApiPermissionQuery,
|
||||||
|
ApiPermissionRecord,
|
||||||
|
ApiPermissionResponseDto,
|
||||||
|
BackendPage,
|
||||||
|
CreateApiPermissionRequest,
|
||||||
|
UpdateApiPermissionRequest,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
export const isApiPermissionMockEnabled = useMockData
|
||||||
|
|
||||||
|
const mockRecords: ApiPermissionRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'api-permission-001',
|
||||||
|
parentId: null,
|
||||||
|
code: 'system:user:query',
|
||||||
|
name: '查询用户',
|
||||||
|
resourceType: 'USER',
|
||||||
|
resourcePath: '/api/v1/users',
|
||||||
|
action: 'QUERY',
|
||||||
|
status: 'ENABLED',
|
||||||
|
createdAt: '2026-08-27 09:00',
|
||||||
|
updatedAt: '2026-08-27 09:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'api-permission-002',
|
||||||
|
parentId: null,
|
||||||
|
code: 'system:sign-task:create',
|
||||||
|
name: '创建签署任务',
|
||||||
|
resourceType: 'SIGN_TASK',
|
||||||
|
resourcePath: '/api/v1/sign-tasks',
|
||||||
|
action: 'CREATE',
|
||||||
|
status: 'ENABLED',
|
||||||
|
createdAt: '2026-08-27 09:02',
|
||||||
|
updatedAt: '2026-08-27 09:02',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPermission(dto: ApiPermissionResponseDto): ApiPermissionRecord {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
parentId: dto.parentId ?? null,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
resourceType: dto.resourceType,
|
||||||
|
resourcePath: dto.resourcePath ?? '',
|
||||||
|
action: dto.action ?? 'QUERY',
|
||||||
|
status: dto.status ?? 'ENABLED',
|
||||||
|
createdAt: formatDateTime(dto.createdAt),
|
||||||
|
updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePage<T>(
|
||||||
|
data: BackendPage<T> | T[],
|
||||||
|
fallbackPage: number,
|
||||||
|
fallbackPageSize: number,
|
||||||
|
): PageResult<T> {
|
||||||
|
const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
total: Array.isArray(data) ? records.length : (data.total ?? records.length),
|
||||||
|
page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage),
|
||||||
|
pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendQuery(query: ApiPermissionQuery) {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: Math.max(query.page, 1),
|
||||||
|
size: Math.min(Math.max(query.pageSize, 1), 200),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.keyword?.trim()) {
|
||||||
|
params.keyword = query.keyword.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPermissions(query: ApiPermissionQuery): Promise<ApiPermissionListResponse> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request
|
||||||
|
.get<ApiResponse<BackendPage<ApiPermissionResponseDto>>>('/v1/permissions', {
|
||||||
|
params: toBackendQuery(query),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
records: page.records.map(mapPermission),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = query.keyword?.trim().toLowerCase()
|
||||||
|
const filteredRecords = mockRecords.filter((record) => {
|
||||||
|
return (
|
||||||
|
!keyword ||
|
||||||
|
[record.code, record.name, record.resourceType, record.resourcePath]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const page = Math.max(query.page, 1)
|
||||||
|
const pageSize = Math.max(query.pageSize, 1)
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
records: filteredRecords.slice(start, start + pageSize).map((record) => ({ ...record })),
|
||||||
|
total: filteredRecords.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermission(id: string): Promise<ApiPermissionRecord | null> {
|
||||||
|
if (useMockData) {
|
||||||
|
const record = mockRecords.find((item) => item.id === id)
|
||||||
|
return record ? { ...record } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get<ApiResponse<ApiPermissionResponseDto>>(
|
||||||
|
`/v1/permissions/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
return mapPermission(unwrapApiResponse(response))
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPermission(
|
||||||
|
payload: CreateApiPermissionRequest,
|
||||||
|
): Promise<ApiPermissionRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponse<ApiPermissionResponseDto>>('/v1/permissions', payload)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then(mapPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePermission(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateApiPermissionRequest,
|
||||||
|
): Promise<ApiPermissionRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.put<ApiResponse<ApiPermissionResponseDto>>(
|
||||||
|
`/v1/permissions/${encodeURIComponent(id)}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then(mapPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePermission(id: string): Promise<void> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.delete<ApiResponse<null>>(`/v1/permissions/${encodeURIComponent(id)}`)
|
||||||
|
.then((response) => {
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
})
|
||||||
|
}
|
||||||
79
clinical-web/src/api/management/roles.ts
Normal file
79
clinical-web/src/api/management/roles.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BackendCollection,
|
||||||
|
CreateRoleRequest,
|
||||||
|
RoleRecord,
|
||||||
|
RoleResponseDto,
|
||||||
|
UpdateRoleRequest,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d']
|
||||||
|
|
||||||
|
function normalizeRole(dto: RoleResponseDto, index: number): RoleRecord {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? '—',
|
||||||
|
color: fallbackColors[index % fallbackColors.length],
|
||||||
|
userCount: dto.userCount ?? 0,
|
||||||
|
status: dto.status ?? 'ENABLED',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoles(): Promise<RoleRecord[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.get<ApiResponse<BackendCollection<RoleResponseDto>>>('/v1/roles')
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then((data) => {
|
||||||
|
const records = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
return records.map(normalizeRole)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRole(payload: CreateRoleRequest): Promise<RoleRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponse<RoleResponseDto>>('/v1/roles', payload)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then((role) => normalizeRole(role, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRole(id: string, payload: UpdateRoleRequest): Promise<RoleRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.put<ApiResponse<RoleResponseDto>>(`/v1/roles/${encodeURIComponent(id)}`, payload)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then((role) => normalizeRole(role, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRole(id: string): Promise<void> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.delete<ApiResponse<null>>(`/v1/roles/${encodeURIComponent(id)}`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.code !== 0 && response.code !== '0') {
|
||||||
|
throw new Error(response.message || '角色删除失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PageQuery, PageResult } from '@/types/common'
|
import type { PageQuery, PageResult } from '@/types/common'
|
||||||
|
|
||||||
export type DocumentStatus = 'draft' | 'published' | 'archived'
|
export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review'
|
||||||
|
|
||||||
export interface DocumentQuery extends PageQuery {
|
export interface DocumentQuery extends PageQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
@@ -10,15 +10,252 @@ export interface DocumentQuery extends PageQuery {
|
|||||||
export interface DocumentRecord {
|
export interface DocumentRecord {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
code?: string
|
||||||
category: string
|
category: string
|
||||||
version: string
|
version: string
|
||||||
status: DocumentStatus
|
status: DocumentStatus
|
||||||
|
backendStatus?: BackendTemplateStatus
|
||||||
updatedBy: string
|
updatedBy: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
versionId?: string
|
||||||
|
departmentId?: string | null
|
||||||
|
departmentName?: string
|
||||||
|
campusId?: string | null
|
||||||
|
campusName?: string
|
||||||
|
description?: string
|
||||||
|
contentHtml?: string
|
||||||
|
contentSha256?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentListResponse = PageResult<DocumentRecord>
|
export type DocumentListResponse = PageResult<DocumentRecord>
|
||||||
|
|
||||||
|
export type BackendTemplateStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'PENDING_REVIEW'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'APPROVED'
|
||||||
|
| 'PUBLISHED'
|
||||||
|
| 'DISABLED'
|
||||||
|
| 'ARCHIVED'
|
||||||
|
| string
|
||||||
|
|
||||||
|
export interface TemplateResponseDto {
|
||||||
|
id: string
|
||||||
|
templateCode: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
campusId: string
|
||||||
|
departmentId?: string | null
|
||||||
|
category?: string | null
|
||||||
|
status?: BackendTemplateStatus | null
|
||||||
|
currentVersionId?: string | null
|
||||||
|
versionSequence?: number | null
|
||||||
|
createdAt?: string | null
|
||||||
|
createdBy?: string | null
|
||||||
|
updatedAt?: string | null
|
||||||
|
updatedBy?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateVersionResponseDto {
|
||||||
|
id: string
|
||||||
|
templateId: string
|
||||||
|
versionNo?: string | null
|
||||||
|
versionNumber?: number | null
|
||||||
|
contentHtml?: string | null
|
||||||
|
content?: string | null
|
||||||
|
signatureFields?: unknown[] | null
|
||||||
|
contentSha256?: string | null
|
||||||
|
requestFingerprint?: string | null
|
||||||
|
fileSizeBytes?: number | null
|
||||||
|
fileMimeType?: string | null
|
||||||
|
fileSha256?: string | null
|
||||||
|
fileMetadata?: Record<string, unknown> | null
|
||||||
|
status?: BackendTemplateStatus | null
|
||||||
|
effectiveAt?: string | null
|
||||||
|
disabledAt?: string | null
|
||||||
|
reviewSubmittedAt?: string | null
|
||||||
|
reviewedAt?: string | null
|
||||||
|
reviewedBy?: string | null
|
||||||
|
reviewComment?: string | null
|
||||||
|
publishedAt?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
createdBy?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateRequest {
|
||||||
|
templateCode: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
campusId: string
|
||||||
|
departmentId?: string | null
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateVersionRequest {
|
||||||
|
versionNo?: string
|
||||||
|
contentHtml: string
|
||||||
|
signatureFields?: unknown[]
|
||||||
|
fileStorageKey?: string
|
||||||
|
fileRelativePath?: string
|
||||||
|
fileSizeBytes?: number
|
||||||
|
fileMimeType?: string
|
||||||
|
fileSha256?: string
|
||||||
|
fileMetadata?: Record<string, unknown>
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendPage<T> {
|
||||||
|
records?: T[]
|
||||||
|
items?: T[]
|
||||||
|
content?: T[]
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
total?: number
|
||||||
|
pages?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackendCollection<T> = T[] | BackendPage<T>
|
||||||
|
|
||||||
|
export interface RoleRecord {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
color?: string
|
||||||
|
userCount?: number
|
||||||
|
status: RoleStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleStatus = 'ENABLED' | 'DISABLED' | string
|
||||||
|
|
||||||
|
export interface RoleResponseDto {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
status?: RoleStatus | null
|
||||||
|
userCount?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleRequest {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
status?: RoleStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateRoleRequest = Partial<CreateRoleRequest>
|
||||||
|
|
||||||
|
export type ApiPermissionStatus = 'ENABLED' | 'DISABLED'
|
||||||
|
export type ApiPermissionAction = 'QUERY' | 'CREATE' | 'UPDATE' | 'DISABLE'
|
||||||
|
|
||||||
|
export interface ApiPermissionQuery extends PageQuery {
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPermissionRecord {
|
||||||
|
id: string
|
||||||
|
parentId: string | null
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
resourceType: string
|
||||||
|
resourcePath: string
|
||||||
|
action: ApiPermissionAction | string
|
||||||
|
status: ApiPermissionStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPermissionResponseDto {
|
||||||
|
id: string
|
||||||
|
parentId?: string | null
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
resourceType: string
|
||||||
|
resourcePath?: string | null
|
||||||
|
action?: ApiPermissionAction | string | null
|
||||||
|
status?: ApiPermissionStatus | null
|
||||||
|
createdAt?: string | null
|
||||||
|
updatedAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiPermissionRequest {
|
||||||
|
parentId?: string | null
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
resourceType: string
|
||||||
|
resourcePath?: string
|
||||||
|
action?: ApiPermissionAction
|
||||||
|
status?: ApiPermissionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApiPermissionRequest {
|
||||||
|
parentId?: string | null
|
||||||
|
name: string
|
||||||
|
resourceType: string
|
||||||
|
resourcePath?: string
|
||||||
|
action?: ApiPermissionAction
|
||||||
|
status: ApiPermissionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiPermissionListResponse = PageResult<ApiPermissionRecord>
|
||||||
|
|
||||||
|
export interface CampusRecord {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
address?: string
|
||||||
|
status: OrganizationStatus
|
||||||
|
sortNo: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepartmentRecord {
|
||||||
|
id: string
|
||||||
|
campusId: string
|
||||||
|
parentId?: string | null
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
status: OrganizationStatus
|
||||||
|
sortNo: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganizationStatus = 'ENABLED' | 'DISABLED' | string
|
||||||
|
|
||||||
|
export interface CreateCampusRequest {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
address?: string
|
||||||
|
status?: OrganizationStatus
|
||||||
|
sortNo?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCampusRequest = Partial<CreateCampusRequest>
|
||||||
|
|
||||||
|
export interface CreateDepartmentRequest {
|
||||||
|
campusId: string
|
||||||
|
parentId?: string | null
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
status?: OrganizationStatus
|
||||||
|
sortNo?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateDepartmentRequest = Partial<CreateDepartmentRequest>
|
||||||
|
|
||||||
|
export interface OrganizationResponseDto {
|
||||||
|
id: string
|
||||||
|
code?: string | null
|
||||||
|
name?: string | null
|
||||||
|
address?: string | null
|
||||||
|
status?: OrganizationStatus | null
|
||||||
|
sortNo?: number | null
|
||||||
|
campusId?: string | null
|
||||||
|
parentId?: string | null
|
||||||
|
campusName?: string | null
|
||||||
|
departmentId?: string | null
|
||||||
|
departmentName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type ReportPeriod = 'today' | 'week' | 'month' | 'year'
|
export type ReportPeriod = 'today' | 'week' | 'month' | 'year'
|
||||||
|
|
||||||
export interface ReportQuery {
|
export interface ReportQuery {
|
||||||
@@ -70,10 +307,62 @@ export interface UserRecord {
|
|||||||
role: string
|
role: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
lastLoginAt: string
|
lastLoginAt: string
|
||||||
|
employeeNo?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
email?: string | null
|
||||||
|
campusId?: string | null
|
||||||
|
campusName?: string
|
||||||
|
departmentId?: string | null
|
||||||
|
departmentName?: string
|
||||||
|
roleNames?: string[]
|
||||||
|
dataScope?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserListResponse = PageResult<UserRecord>
|
export type UserListResponse = PageResult<UserRecord>
|
||||||
|
|
||||||
|
export interface UserResponseDto {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
employeeNo?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
email?: string | null
|
||||||
|
campusId?: string | null
|
||||||
|
departmentId?: string | null
|
||||||
|
dataScope?: string | null
|
||||||
|
status?: string | null
|
||||||
|
lastLoginAt?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
updatedAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
employeeNo?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
campusId: string
|
||||||
|
departmentId?: string | null
|
||||||
|
status?: BackendUserStatus
|
||||||
|
dataScope?: BackendDataScope
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackendUserStatus = 'ENABLED' | 'DISABLED' | string
|
||||||
|
export type BackendDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL' | string
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
displayName: string
|
||||||
|
employeeNo?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
campusId: string
|
||||||
|
departmentId?: string | null
|
||||||
|
status: BackendUserStatus
|
||||||
|
dataScope: BackendDataScope
|
||||||
|
}
|
||||||
|
|
||||||
export type PermissionSubjectType = 'user' | 'role'
|
export type PermissionSubjectType = 'user' | 'role'
|
||||||
|
|
||||||
export interface PermissionQuery extends PageQuery {
|
export interface PermissionQuery extends PageQuery {
|
||||||
@@ -115,3 +404,67 @@ export interface SystemSettingsResponse {
|
|||||||
export interface UpdateSystemSettingsRequest {
|
export interface UpdateSystemSettingsRequest {
|
||||||
values: Record<string, SettingValue>
|
values: Record<string, SettingValue>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiPermissionSubjectType = 'ROLE' | 'DEPARTMENT' | 'USER'
|
||||||
|
export type ApiPermissionLevel = 'VIEW' | 'USE' | 'MAINTAIN'
|
||||||
|
export type ApiPermissionEffect = 'ALLOW' | 'DENY'
|
||||||
|
|
||||||
|
export interface TemplatePermissionResponseDto {
|
||||||
|
id: string
|
||||||
|
templateId: string
|
||||||
|
subjectType: ApiPermissionSubjectType
|
||||||
|
subjectId: string
|
||||||
|
permissionLevel: ApiPermissionLevel
|
||||||
|
effect: ApiPermissionEffect
|
||||||
|
inherited: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatePermissionRecord {
|
||||||
|
id: string
|
||||||
|
templateId: string
|
||||||
|
subjectType: ApiPermissionSubjectType
|
||||||
|
subjectId: string
|
||||||
|
subjectName: string
|
||||||
|
permissionLevel: ApiPermissionLevel
|
||||||
|
effect: ApiPermissionEffect
|
||||||
|
inherited: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplatePermissionRequest {
|
||||||
|
subjectType: ApiPermissionSubjectType
|
||||||
|
subjectId: string
|
||||||
|
permissionLevel: ApiPermissionLevel
|
||||||
|
effect: ApiPermissionEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogQuery extends PageQuery {
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogResponseDto {
|
||||||
|
id: string
|
||||||
|
action: string
|
||||||
|
resourceType: string
|
||||||
|
resourceId: string
|
||||||
|
operatorId: string
|
||||||
|
clientIp?: string | null
|
||||||
|
userAgent?: string | null
|
||||||
|
detailsJson?: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogRecord {
|
||||||
|
id: string
|
||||||
|
action: string
|
||||||
|
resourceType: string
|
||||||
|
resourceId: string
|
||||||
|
operatorId: string
|
||||||
|
clientIp: string
|
||||||
|
userAgent: string
|
||||||
|
detailsJson: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuditLogListResponse = PageResult<AuditLogRecord>
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { request } from '@/utils/request'
|
import axios from 'axios'
|
||||||
|
|
||||||
import type { UserListResponse, UserQuery, UserRecord } from './types'
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { ApiResponse, PageResult } from '@/types/common'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BackendCollection,
|
||||||
|
BackendPage,
|
||||||
|
CreateUserRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
|
UserListResponse,
|
||||||
|
UserQuery,
|
||||||
|
UserRecord,
|
||||||
|
UserResponseDto,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
export const isUserMockEnabled = useMockData
|
||||||
|
|
||||||
const mockRecords: UserRecord[] = [
|
const mockRecords: UserRecord[] = [
|
||||||
{
|
{
|
||||||
id: 'user-001',
|
id: 'user-001',
|
||||||
@@ -34,11 +49,90 @@ const mockRecords: UserRecord[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: string | null | undefined): UserRecord['status'] {
|
||||||
|
return value === 'DISABLED' || value === 'INACTIVE' ? 'disabled' : 'enabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUser(dto: UserResponseDto): UserRecord {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.displayName,
|
||||||
|
account: dto.username,
|
||||||
|
department: dto.departmentId ?? '未指定科室',
|
||||||
|
role: '未分配角色',
|
||||||
|
status: normalizeStatus(dto.status),
|
||||||
|
lastLoginAt: formatDateTime(dto.lastLoginAt),
|
||||||
|
employeeNo: dto.employeeNo,
|
||||||
|
phone: dto.phone,
|
||||||
|
email: dto.email,
|
||||||
|
campusId: dto.campusId,
|
||||||
|
departmentId: dto.departmentId,
|
||||||
|
dataScope: dto.dataScope ?? '—',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePage<T>(
|
||||||
|
data: BackendPage<T> | T[],
|
||||||
|
fallbackPage: number,
|
||||||
|
fallbackPageSize: number,
|
||||||
|
): PageResult<T> {
|
||||||
|
const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
total: Array.isArray(data) ? records.length : (data.total ?? records.length),
|
||||||
|
page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage),
|
||||||
|
pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendQuery(query: UserQuery) {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: Math.max(query.page, 1),
|
||||||
|
size: Math.min(Math.max(query.pageSize, 1), 200),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.keyword?.trim()) {
|
||||||
|
params.keyword = query.keyword.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
export function getUsers(query: UserQuery): Promise<UserListResponse> {
|
export function getUsers(query: UserQuery): Promise<UserListResponse> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<UserListResponse>('/management/users', {
|
return request
|
||||||
params: query,
|
.get<ApiResponse<BackendCollection<UserResponseDto>>>('/v1/users', {
|
||||||
})
|
params: toBackendQuery(query),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
records: page.records.map(mapUser),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = query.keyword?.trim().toLowerCase()
|
const keyword = query.keyword?.trim().toLowerCase()
|
||||||
@@ -61,3 +155,57 @@ export function getUsers(query: UserQuery): Promise<UserListResponse> {
|
|||||||
pageSize,
|
pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserDetail(id: string): Promise<UserRecord | null> {
|
||||||
|
if (useMockData) {
|
||||||
|
const record = mockRecords.find((item) => item.id === id)
|
||||||
|
return record ? { ...record } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get<ApiResponse<UserResponseDto>>(
|
||||||
|
`/v1/users/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
return mapUser(unwrapApiResponse(response))
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(payload: CreateUserRequest): Promise<UserRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行用户写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponse<UserResponseDto>>('/v1/users', payload)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then(mapUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: string, payload: UpdateUserRequest): Promise<UserRecord> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行用户写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.put<ApiResponse<UserResponseDto>>(`/v1/users/${encodeURIComponent(id)}`, payload)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
.then(mapUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: string): Promise<void> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.reject(new Error('Mock 模式不执行用户写操作'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
.delete<ApiResponse<null>>(`/v1/users/${encodeURIComponent(id)}`)
|
||||||
|
.then((response) => {
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
123
clinical-web/src/api/workbench/artifacts.ts
Normal file
123
clinical-web/src/api/workbench/artifacts.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiPageResponse,
|
||||||
|
ApiResponseOf,
|
||||||
|
SignArtifactResponseDto,
|
||||||
|
SignArtifactType,
|
||||||
|
SigningArtifact,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
type ArtifactCollection = SignArtifactResponseDto[] | ApiPageResponse<SignArtifactResponseDto>
|
||||||
|
|
||||||
|
const artifactLabels: Record<string, string> = {
|
||||||
|
ORIGINAL_PDF: 'PDF 原件',
|
||||||
|
SIGNATURE_IMAGE: '签名原图',
|
||||||
|
SIGNED_PDF: '签署后 PDF',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollectionRecords(collection: ArtifactCollection) {
|
||||||
|
return Array.isArray(collection) ? collection : collection.records
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact {
|
||||||
|
const artifactType: SignArtifactType = dto.artifactType
|
||||||
|
const label = artifactLabels[artifactType] ?? '签署文件'
|
||||||
|
const mimeType = dto.mimeType || 'application/octet-stream'
|
||||||
|
const extension =
|
||||||
|
mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
taskId: dto.taskId,
|
||||||
|
pipelineId: dto.pipelineId,
|
||||||
|
artifactType,
|
||||||
|
label,
|
||||||
|
fileName: `${label}.${extension}`,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: dto.sizeBytes,
|
||||||
|
sha256: dto.sha256,
|
||||||
|
metadata: dto.metadata ?? {},
|
||||||
|
createdAt: formatDateTime(dto.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isArtifactMockEnabled = useMockData
|
||||||
|
|
||||||
|
export async function getSigningArtifacts(taskId: string): Promise<SigningArtifact[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get<ApiResponseOf<ArtifactCollection>>(
|
||||||
|
`/v1/sign-artifacts/task/${encodeURIComponent(taskId)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return getCollectionRecords(unwrapApiResponse(response)).map(normalizeArtifact)
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSigningArtifact(
|
||||||
|
taskId: string,
|
||||||
|
artifactId: string,
|
||||||
|
): Promise<SigningArtifact | null> {
|
||||||
|
if (useMockData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get<ApiResponseOf<SignArtifactResponseDto>>(
|
||||||
|
`/v1/sign-artifacts/task/${encodeURIComponent(taskId)}/${encodeURIComponent(artifactId)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalizeArtifact(unwrapApiResponse(response))
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadSigningArtifact(artifactId: string): Promise<Blob> {
|
||||||
|
if (useMockData) {
|
||||||
|
return Promise.resolve(new Blob(['Mock 签署文件,仅用于界面演示。'], { type: 'text/plain' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.get<Blob>(`/v1/sign-artifacts/${encodeURIComponent(artifactId)}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
}
|
||||||
154
clinical-web/src/api/workbench/deliveries.ts
Normal file
154
clinical-web/src/api/workbench/deliveries.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiResponseOf,
|
||||||
|
PadBindingRequest,
|
||||||
|
PadSessionUploadInput,
|
||||||
|
SendSmsDeliveryRequest,
|
||||||
|
SignDeliveryResponseDto,
|
||||||
|
SignatureUploadResponse,
|
||||||
|
SigningTokenUploadInput,
|
||||||
|
TokenConsumeRequest,
|
||||||
|
TokenConsumeResponse,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
function createIdempotencyKey() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `clinical-web-${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `clinical-web-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function idempotencyHeaders(value?: string) {
|
||||||
|
return { 'Idempotency-Key': value || createIdempotencyKey() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendSigningSms(
|
||||||
|
taskId: string,
|
||||||
|
payload: SendSmsDeliveryRequest,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): Promise<SignDeliveryResponseDto | null> {
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<SignDeliveryResponseDto | null>>(
|
||||||
|
`/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/send`,
|
||||||
|
payload,
|
||||||
|
{ headers: idempotencyHeaders(idempotencyKey) },
|
||||||
|
)
|
||||||
|
.then(unwrapNullableApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resendSigningSms(
|
||||||
|
taskId: string,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): Promise<SignDeliveryResponseDto | null> {
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<SignDeliveryResponseDto | null>>(
|
||||||
|
`/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/resend`,
|
||||||
|
undefined,
|
||||||
|
{ headers: idempotencyHeaders(idempotencyKey) },
|
||||||
|
)
|
||||||
|
.then(unwrapNullableApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPadSigningSession(
|
||||||
|
taskId: string,
|
||||||
|
payload: PadBindingRequest = {},
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): Promise<SignDeliveryResponseDto> {
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<SignDeliveryResponseDto>>(
|
||||||
|
`/v1/sign-deliveries/${encodeURIComponent(taskId)}/pad/sessions`,
|
||||||
|
payload,
|
||||||
|
{ headers: idempotencyHeaders(idempotencyKey) },
|
||||||
|
)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeSigningToken(
|
||||||
|
payload: TokenConsumeRequest,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): Promise<TokenConsumeResponse> {
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
||||||
|
headers: idempotencyHeaders(idempotencyKey),
|
||||||
|
})
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSignatureFormData(file: File | Blob, metadata: string) {
|
||||||
|
const formData = new FormData()
|
||||||
|
const fileName =
|
||||||
|
typeof File !== 'undefined' && file instanceof File && file.name ? file.name : 'signature.png'
|
||||||
|
formData.append('file', file, fileName)
|
||||||
|
formData.append('metadata', metadata)
|
||||||
|
return formData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadSigningSignature(
|
||||||
|
payload: SigningTokenUploadInput,
|
||||||
|
): Promise<SignatureUploadResponse> {
|
||||||
|
const formData = createSignatureFormData(payload.file, payload.metadata)
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<SignatureUploadResponse>>(
|
||||||
|
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
deliveryId: payload.deliveryId,
|
||||||
|
uploadToken: payload.uploadToken,
|
||||||
|
},
|
||||||
|
headers: idempotencyHeaders(payload.idempotencyKey),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD 专用兼容路径。服务端要求同时提交 PAD 元数据和真实 PNG,旧的 JSON-only 提交会返回 409。
|
||||||
|
* 具体的 challenge、文件摘要和 storageKey 由设备适配层生成,页面不应伪造这些值。
|
||||||
|
*/
|
||||||
|
export function uploadPadSigningSignature(
|
||||||
|
payload: PadSessionUploadInput,
|
||||||
|
): Promise<SignatureUploadResponse> {
|
||||||
|
if (!payload.pad) {
|
||||||
|
return Promise.reject(new Error('PAD 上传缺少会话元数据'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = createSignatureFormData(payload.file, payload.metadata)
|
||||||
|
formData.append('challenge', payload.pad.challenge)
|
||||||
|
formData.append('contentType', payload.pad.contentType)
|
||||||
|
formData.append('sizeBytes', String(payload.pad.sizeBytes ?? payload.file.size))
|
||||||
|
formData.append('sha256', payload.pad.sha256)
|
||||||
|
formData.append('storageKey', payload.pad.storageKey)
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<SignatureUploadResponse>>(
|
||||||
|
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/pad/sessions/${encodeURIComponent(payload.deliveryId)}/upload`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
params: { uploadToken: payload.uploadToken },
|
||||||
|
headers: idempotencyHeaders(payload.idempotencyKey),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadSigningSignatureByProof(
|
||||||
|
payload: SigningTokenUploadInput,
|
||||||
|
): Promise<SignatureUploadResponse> {
|
||||||
|
const formData = createSignatureFormData(payload.file, payload.metadata)
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponseOf<SignatureUploadResponse>>('/v1/sign-deliveries/token/signature', formData, {
|
||||||
|
params: {
|
||||||
|
taskId: payload.taskId,
|
||||||
|
deliveryId: payload.deliveryId,
|
||||||
|
uploadToken: payload.uploadToken,
|
||||||
|
},
|
||||||
|
headers: idempotencyHeaders(payload.idempotencyKey),
|
||||||
|
})
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { request } from '@/utils/request'
|
import { getCampuses } from '@/api/management/organization'
|
||||||
|
|
||||||
|
import { getSigningTasks, getSigningTemplates } from './signing'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
HomeRankingPeriod,
|
HomeRankingPeriod,
|
||||||
@@ -6,6 +8,10 @@ import type {
|
|||||||
WorkbenchDocumentRanking,
|
WorkbenchDocumentRanking,
|
||||||
WorkbenchOverviewQuery,
|
WorkbenchOverviewQuery,
|
||||||
WorkbenchOverviewResponse,
|
WorkbenchOverviewResponse,
|
||||||
|
SigningApiOptions,
|
||||||
|
SigningTemplate,
|
||||||
|
SigningTaskRecord,
|
||||||
|
SigningTaskQuery,
|
||||||
WorkbenchTodoTask,
|
WorkbenchTodoTask,
|
||||||
WorkbenchTrendPoint,
|
WorkbenchTrendPoint,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -23,6 +29,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
|||||||
本部院区: {
|
本部院区: {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned: 36,
|
todaySigned: 36,
|
||||||
|
todaySignedChange: 2,
|
||||||
pendingPatientSigning: 18,
|
pendingPatientSigning: 18,
|
||||||
todayOverdue: 2,
|
todayOverdue: 2,
|
||||||
availableTemplates: 128,
|
availableTemplates: 128,
|
||||||
@@ -90,6 +97,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
|||||||
东院区: {
|
东院区: {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned: 22,
|
todaySigned: 22,
|
||||||
|
todaySignedChange: 1,
|
||||||
pendingPatientSigning: 11,
|
pendingPatientSigning: 11,
|
||||||
todayOverdue: 1,
|
todayOverdue: 1,
|
||||||
availableTemplates: 76,
|
availableTemplates: 76,
|
||||||
@@ -157,6 +165,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
|||||||
西院区: {
|
西院区: {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned: 17,
|
todaySigned: 17,
|
||||||
|
todaySignedChange: 2,
|
||||||
pendingPatientSigning: 9,
|
pendingPatientSigning: 9,
|
||||||
todayOverdue: 2,
|
todayOverdue: 2,
|
||||||
availableTemplates: 54,
|
availableTemplates: 54,
|
||||||
@@ -243,6 +252,179 @@ function createRanking(
|
|||||||
.sort((left, right) => right.signedCount - left.signedCount)
|
.sort((left, right) => right.signedCount - left.signedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countAvailableTemplates(templates: SigningTemplate[]) {
|
||||||
|
return new Set(templates.map((template) => template.id)).size
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCoveredDepartments(templates: SigningTemplate[]) {
|
||||||
|
return new Set(
|
||||||
|
templates
|
||||||
|
.map((template) => {
|
||||||
|
if (template.departmentId) {
|
||||||
|
return `id:${template.departmentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.department && template.department !== '全院通用'
|
||||||
|
? `name:${template.department}`
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter((department): department is string => Boolean(department)),
|
||||||
|
).size
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllSigningTasks(
|
||||||
|
query: SigningTaskQuery,
|
||||||
|
options: SigningApiOptions,
|
||||||
|
): Promise<SigningTaskRecord[]> {
|
||||||
|
const pageSize = 200
|
||||||
|
const firstPage = await getSigningTasks({ ...query, page: 1, pageSize }, options)
|
||||||
|
const totalPages = Math.ceil(firstPage.total / Math.max(firstPage.pageSize, 1))
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
return firstPage.records
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingPages = await Promise.all(
|
||||||
|
Array.from({ length: totalPages - 1 }, (_, index) =>
|
||||||
|
getSigningTasks({ ...query, page: index + 2, pageSize }, options),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||||
|
return value === '本部院区' || value === '东院区' || value === '西院区'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(left: Date | null, right: Date) {
|
||||||
|
return Boolean(
|
||||||
|
left &&
|
||||||
|
left.getFullYear() === right.getFullYear() &&
|
||||||
|
left.getMonth() === right.getMonth() &&
|
||||||
|
left.getDate() === right.getDate(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSignedDate(task: SigningTaskRecord) {
|
||||||
|
return parseDate(task.signedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpiredDate(task: SigningTaskRecord) {
|
||||||
|
return parseDate(task.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdatedDate(task: SigningTaskRecord) {
|
||||||
|
return parseDate(task.updatedAt) ?? parseDate(task.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinPeriod(value: Date | null, today: Date, period: HomeRankingPeriod) {
|
||||||
|
if (!value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(today)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
start.setDate(start.getDate() - period + 1)
|
||||||
|
|
||||||
|
const end = new Date(today)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
|
return value >= start && value <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingTask(task: SigningTaskRecord) {
|
||||||
|
return task.status === 'pending' || task.status === 'signing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLiveOverview(
|
||||||
|
tasks: SigningTaskRecord[],
|
||||||
|
templateCount: number,
|
||||||
|
coveredDepartments: number,
|
||||||
|
rankingPeriod: HomeRankingPeriod,
|
||||||
|
): WorkbenchOverviewResponse {
|
||||||
|
const today = new Date()
|
||||||
|
const signedTasks = tasks.filter((task) => task.status === 'signed')
|
||||||
|
const todaySigned = signedTasks.filter((task) => isSameDay(getSignedDate(task), today)).length
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const yesterdaySigned = signedTasks.filter((task) =>
|
||||||
|
isSameDay(getSignedDate(task), yesterday),
|
||||||
|
).length
|
||||||
|
const pendingTasks = tasks.filter(isPendingTask)
|
||||||
|
const todayOverdue = tasks.filter(
|
||||||
|
(task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today),
|
||||||
|
).length
|
||||||
|
|
||||||
|
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
|
||||||
|
const date = new Date(today)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
date.setDate(today.getDate() - rankingPeriod + index + 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
||||||
|
value: signedTasks.filter((task) => isSameDay(getSignedDate(task), date)).length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const todos = tasks
|
||||||
|
.filter((task) => isPendingTask(task) || task.status === 'expired')
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftDate = getUpdatedDate(left)?.getTime() ?? 0
|
||||||
|
const rightDate = getUpdatedDate(right)?.getTime() ?? 0
|
||||||
|
return rightDate - leftDate
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
patientId: task.patientId,
|
||||||
|
patientName: task.patientName,
|
||||||
|
documentName: task.documentName,
|
||||||
|
department: task.department,
|
||||||
|
status: task.status === 'expired' ? ('overdue' as const) : ('pending' as const),
|
||||||
|
method: task.method,
|
||||||
|
updatedAt: task.updatedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
|
||||||
|
signedTasks
|
||||||
|
.filter((task) => isWithinPeriod(getSignedDate(task), today, rankingPeriod))
|
||||||
|
.forEach((task) => {
|
||||||
|
const current = rankingMap.get(task.documentId)
|
||||||
|
rankingMap.set(task.documentId, {
|
||||||
|
id: current?.id ?? task.documentId,
|
||||||
|
documentName: task.documentName,
|
||||||
|
department: task.department,
|
||||||
|
signedCount: (current?.signedCount ?? 0) + 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
todaySigned,
|
||||||
|
todaySignedChange: todaySigned - yesterdaySigned,
|
||||||
|
pendingPatientSigning: pendingTasks.length,
|
||||||
|
todayOverdue,
|
||||||
|
availableTemplates: templateCount,
|
||||||
|
coveredDepartments,
|
||||||
|
},
|
||||||
|
trend,
|
||||||
|
todos,
|
||||||
|
documentRanking: [...rankingMap.values()]
|
||||||
|
.sort((left, right) => right.signedCount - left.signedCount)
|
||||||
|
.slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkbenchOverview(
|
export function getWorkbenchOverview(
|
||||||
query: WorkbenchOverviewQuery,
|
query: WorkbenchOverviewQuery,
|
||||||
): Promise<WorkbenchOverviewResponse> {
|
): Promise<WorkbenchOverviewResponse> {
|
||||||
@@ -257,7 +439,35 @@ export function getWorkbenchOverview(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return request.get<WorkbenchOverviewResponse>('/workbench/overview', {
|
return getCampuses().then(async (campuses) => {
|
||||||
params: query,
|
const campus = campuses.find((item) => item.name === query.campus)
|
||||||
|
|
||||||
|
if (!campus) {
|
||||||
|
throw new Error(`未找到院区:${query.campus}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const campusIds = Object.fromEntries(campuses.map((item) => [item.name, item.id]))
|
||||||
|
const campusNames = Object.fromEntries(
|
||||||
|
campuses.filter((item) => isWorkbenchCampus(item.name)).map((item) => [item.id, item.name]),
|
||||||
|
) as Record<string, WorkbenchCampus>
|
||||||
|
const templates = await getSigningTemplates({ campusId: campus.id })
|
||||||
|
const tasks = await getAllSigningTasks(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
status: 'all',
|
||||||
|
dateRange: 'all',
|
||||||
|
campus: query.campus,
|
||||||
|
method: 'all',
|
||||||
|
},
|
||||||
|
{ templates, campusIds, campusNames },
|
||||||
|
)
|
||||||
|
|
||||||
|
return createLiveOverview(
|
||||||
|
tasks,
|
||||||
|
countAvailableTemplates(templates),
|
||||||
|
countCoveredDepartments(templates),
|
||||||
|
query.rankingPeriod,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import type { PageQuery, PageResult } from '@/types/common'
|
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
|
||||||
|
|
||||||
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
|
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
|
||||||
|
|
||||||
@@ -9,10 +9,53 @@ export interface WorkbenchOverviewQuery {
|
|||||||
rankingPeriod: HomeRankingPeriod
|
rankingPeriod: HomeRankingPeriod
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired'
|
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
|
||||||
|
|
||||||
|
export type PatientSex = '男' | '女' | '未知'
|
||||||
|
|
||||||
|
export type VisitType = '门诊' | '住院' | '体检' | '其他'
|
||||||
|
|
||||||
|
export interface PatientVisit {
|
||||||
|
id: string
|
||||||
|
type: VisitType
|
||||||
|
visitNo: string
|
||||||
|
department: string
|
||||||
|
departmentId?: string | null
|
||||||
|
doctorName?: string
|
||||||
|
visitDate: string
|
||||||
|
visitedAt?: string
|
||||||
|
campusId?: string
|
||||||
|
sourceSystem?: string
|
||||||
|
isCurrent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sex: PatientSex
|
||||||
|
age: number
|
||||||
|
idCard: string
|
||||||
|
phone: string
|
||||||
|
visits: PatientVisit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningTemplate {
|
||||||
|
id: string
|
||||||
|
versionId: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
version: string
|
||||||
|
department: string
|
||||||
|
departmentId?: string | null
|
||||||
|
campusId?: string
|
||||||
|
category: string
|
||||||
|
description: string
|
||||||
|
supportedMethods: SigningMethod[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkbenchSummary {
|
export interface WorkbenchSummary {
|
||||||
todaySigned: number
|
todaySigned: number
|
||||||
|
todaySignedChange: number
|
||||||
pendingPatientSigning: number
|
pendingPatientSigning: number
|
||||||
todayOverdue: number
|
todayOverdue: number
|
||||||
availableTemplates: number
|
availableTemplates: number
|
||||||
@@ -66,12 +109,371 @@ export interface WorkbenchOverviewResponse {
|
|||||||
export interface SigningTaskQuery extends PageQuery {
|
export interface SigningTaskQuery extends PageQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
status?: SigningTaskStatus | 'all'
|
status?: SigningTaskStatus | 'all'
|
||||||
|
dateRange?: SigningDateRange
|
||||||
|
campus?: WorkbenchCampus | 'all'
|
||||||
|
method?: SigningMethod | 'all'
|
||||||
|
department?: string
|
||||||
|
documentId?: string
|
||||||
|
visitType?: VisitType | 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
||||||
|
|
||||||
export interface SigningTaskRecord extends WorkbenchTask {
|
export interface SigningTaskRecord extends WorkbenchTask {
|
||||||
|
templateVersionId: string
|
||||||
|
campus: WorkbenchCampus
|
||||||
|
campusId?: string
|
||||||
|
patientId: string
|
||||||
|
sex: PatientSex
|
||||||
|
age: number
|
||||||
|
documentId: string
|
||||||
|
departmentId?: string | null
|
||||||
signerName: string
|
signerName: string
|
||||||
|
method: SigningMethod
|
||||||
source: string
|
source: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
createdAt?: string
|
||||||
|
visitType: VisitType
|
||||||
|
visitDate: string
|
||||||
|
rowVersion?: number
|
||||||
|
backendStatus?: BackendSigningTaskStatus
|
||||||
|
signatureDataUrl?: string
|
||||||
|
signedAt?: string
|
||||||
|
voidReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||||
|
|
||||||
|
/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */
|
||||||
|
export interface CreateSigningTaskInput {
|
||||||
|
campus: WorkbenchCampus
|
||||||
|
patientId: string
|
||||||
|
visitId: string
|
||||||
|
documentId: string
|
||||||
|
documentName: string
|
||||||
|
templateVersionId: string
|
||||||
|
method: SigningMethod
|
||||||
|
visitType: VisitType
|
||||||
|
visitNo: string
|
||||||
|
department: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
|
||||||
|
export interface CreateSigningTaskRequest {
|
||||||
|
patientId: string
|
||||||
|
visitId: string
|
||||||
|
templateVersionId: string
|
||||||
|
signMethod: BackendSigningMethod
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningApiOptions {
|
||||||
|
templates?: SigningTemplate[]
|
||||||
|
campus?: WorkbenchCampus
|
||||||
|
campusId?: string
|
||||||
|
campusIds?: Record<string, string>
|
||||||
|
campusNames?: Record<string, WorkbenchCampus>
|
||||||
|
departmentIds?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningTaskActionOptions extends SigningApiOptions {
|
||||||
|
expectedRowVersion?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackendSigningTaskStatus =
|
||||||
|
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
|
||||||
|
|
||||||
|
export type BackendSigningMethod = 'PAD' | 'SMS'
|
||||||
|
|
||||||
|
export type BackendVisitType = 'OUTPATIENT' | 'INPATIENT' | 'CHECKUP' | string
|
||||||
|
|
||||||
|
export interface ApiPageResponse<T> {
|
||||||
|
records: T[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientResponseDto {
|
||||||
|
id: string
|
||||||
|
externalPatientId: string
|
||||||
|
name: string
|
||||||
|
sex: string
|
||||||
|
birthDate: string | null
|
||||||
|
age: number
|
||||||
|
idCardMasked: string | null
|
||||||
|
phoneMasked: string | null
|
||||||
|
sourceSystem: string
|
||||||
|
campusId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitResponseDto {
|
||||||
|
id: string
|
||||||
|
patientId: string
|
||||||
|
visitNo: string
|
||||||
|
visitType: BackendVisitType
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string
|
||||||
|
doctorName: string | null
|
||||||
|
visitedAt: string
|
||||||
|
campusId: string
|
||||||
|
sourceSystem: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableTemplateVersionResponseDto {
|
||||||
|
templateId: string
|
||||||
|
templateVersionId: string
|
||||||
|
templateCode: string
|
||||||
|
templateName: string
|
||||||
|
category: string
|
||||||
|
campusId: string
|
||||||
|
departmentId: string | null
|
||||||
|
versionNo: string
|
||||||
|
contentSha256: string
|
||||||
|
effectiveAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskPatientSnapshotDto {
|
||||||
|
patientId: string
|
||||||
|
name: string
|
||||||
|
sex: string
|
||||||
|
age: number
|
||||||
|
birthDate: string | null
|
||||||
|
idCardMasked: string | null
|
||||||
|
phoneMasked: string | null
|
||||||
|
sourceSystem: string
|
||||||
|
capturedAt: string
|
||||||
|
schemaVersion: string
|
||||||
|
contentHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskVisitSnapshotDto {
|
||||||
|
visitNo: string
|
||||||
|
visitType: BackendVisitType
|
||||||
|
departmentName: string
|
||||||
|
doctorName: string | null
|
||||||
|
visitedAt: string
|
||||||
|
sourceSystem: string
|
||||||
|
capturedAt: string
|
||||||
|
schemaVersion: string
|
||||||
|
contentHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskResponseDto {
|
||||||
|
id: string
|
||||||
|
templateVersionId: string
|
||||||
|
templateVersionNumber: number
|
||||||
|
signMethod: BackendSigningMethod
|
||||||
|
status: BackendSigningTaskStatus
|
||||||
|
patientSnapshot: SignTaskPatientSnapshotDto
|
||||||
|
visitSnapshot: SignTaskVisitSnapshotDto
|
||||||
|
campusId: string
|
||||||
|
departmentId: string | null
|
||||||
|
patientSnapshotSchemaVersion: string
|
||||||
|
patientSnapshotCapturedAt: string
|
||||||
|
patientSnapshotHash: string
|
||||||
|
visitSnapshotSchemaVersion: string
|
||||||
|
visitSnapshotCapturedAt: string
|
||||||
|
visitSnapshotHash: string
|
||||||
|
templateContentSha256: string
|
||||||
|
templateFileSha256: string
|
||||||
|
smsMobileMasked: string | null
|
||||||
|
signedAt: string | null
|
||||||
|
expiredAt: string | null
|
||||||
|
voidedAt: string | null
|
||||||
|
voidReason: string | null
|
||||||
|
createdAt: string
|
||||||
|
createdBy: string
|
||||||
|
updatedAt: string
|
||||||
|
updatedBy: string
|
||||||
|
rowVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskActionRequest {
|
||||||
|
reason?: string
|
||||||
|
expectedRowVersion?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskSendPreparationResponseDto {
|
||||||
|
taskId: string
|
||||||
|
status: BackendSigningTaskStatus
|
||||||
|
signMethod: BackendSigningMethod
|
||||||
|
intentCreated: boolean
|
||||||
|
preparedAt: string
|
||||||
|
rowVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendSmsDeliveryRequest {
|
||||||
|
destination: string
|
||||||
|
templateCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignDeliveryChannel = 'PAD' | 'SMS'
|
||||||
|
|
||||||
|
export type SignDeliveryStatus =
|
||||||
|
| 'PENDING'
|
||||||
|
| 'IN_FLIGHT'
|
||||||
|
| 'SUCCEEDED'
|
||||||
|
| 'RETRYABLE'
|
||||||
|
| 'FAILED'
|
||||||
|
| 'DEAD'
|
||||||
|
| 'CANCELLED'
|
||||||
|
| 'UNKNOWN'
|
||||||
|
| 'REVOKED'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| string
|
||||||
|
|
||||||
|
export interface SignDeliveryResponseDto {
|
||||||
|
deliveryId: string
|
||||||
|
taskId: string
|
||||||
|
channel: SignDeliveryChannel
|
||||||
|
status: SignDeliveryStatus
|
||||||
|
attemptId?: string | null
|
||||||
|
attemptNo?: number | null
|
||||||
|
tokenVersion?: number | null
|
||||||
|
destinationMasked?: string | null
|
||||||
|
token?: string | null
|
||||||
|
tokenExpiresAt?: string | null
|
||||||
|
errorCode?: string | null
|
||||||
|
errorMessage?: string | null
|
||||||
|
traceId?: string | null
|
||||||
|
timestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PadBindingRequest {
|
||||||
|
deviceId?: string
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PadUploadRequest {
|
||||||
|
challenge: string
|
||||||
|
contentType: 'image/png'
|
||||||
|
sizeBytes?: number
|
||||||
|
sha256: string
|
||||||
|
storageKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenConsumeRequest {
|
||||||
|
token: string
|
||||||
|
channel?: SignDeliveryChannel
|
||||||
|
taskId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenConsumeResponse {
|
||||||
|
deliveryId: string
|
||||||
|
taskId: string
|
||||||
|
channel: SignDeliveryChannel
|
||||||
|
uploadToken: string
|
||||||
|
traceId?: string | null
|
||||||
|
timestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignatureUploadStatus = BackendSigningTaskStatus
|
||||||
|
|
||||||
|
export interface SignatureUploadResponse {
|
||||||
|
taskId: string
|
||||||
|
pipelineId: string
|
||||||
|
status: SignatureUploadStatus
|
||||||
|
originalPdfArtifactId: string
|
||||||
|
signedPdfArtifactId: string
|
||||||
|
signatureImageArtifactId: string
|
||||||
|
originalPdfSha256: string
|
||||||
|
signedPdfSha256: string
|
||||||
|
signatureImageSha256: string
|
||||||
|
completedAt: string | null
|
||||||
|
traceId?: string | null
|
||||||
|
timestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureUploadInput {
|
||||||
|
deliveryId: string
|
||||||
|
uploadToken: string
|
||||||
|
file: File | Blob
|
||||||
|
metadata: string
|
||||||
|
pad?: PadUploadRequest
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningTokenUploadInput extends Omit<SignatureUploadInput, 'pad'> {
|
||||||
|
taskId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PadSessionUploadInput extends SignatureUploadInput {
|
||||||
|
taskId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureDeliveryResult {
|
||||||
|
taskId: string
|
||||||
|
deliveryId: string
|
||||||
|
channel: SignDeliveryChannel
|
||||||
|
uploadToken: string
|
||||||
|
upload: SignatureUploadResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskEventResponseDto {
|
||||||
|
id: string
|
||||||
|
taskId: string
|
||||||
|
eventType: string
|
||||||
|
fromStatus: string | null
|
||||||
|
toStatus: string | null
|
||||||
|
actorType: string | null
|
||||||
|
actorSubject: string | null
|
||||||
|
operatorId: string | null
|
||||||
|
eventTime: string
|
||||||
|
clientIp: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
traceId: string | null
|
||||||
|
requestId: string | null
|
||||||
|
channel: string | null
|
||||||
|
attemptNo: number | null
|
||||||
|
reason: string | null
|
||||||
|
fromRowVersion: number | null
|
||||||
|
toRowVersion: number | null
|
||||||
|
detail: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignArtifactType = 'ORIGINAL_PDF' | 'SIGNATURE_IMAGE' | 'SIGNED_PDF' | string
|
||||||
|
|
||||||
|
export interface SignArtifactResponseDto {
|
||||||
|
id: string
|
||||||
|
taskId: string
|
||||||
|
pipelineId: string
|
||||||
|
artifactType: SignArtifactType
|
||||||
|
sizeBytes: number
|
||||||
|
mimeType: string
|
||||||
|
sha256: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningArtifact {
|
||||||
|
id: string
|
||||||
|
taskId: string
|
||||||
|
pipelineId: string
|
||||||
|
artifactType: SignArtifactType
|
||||||
|
label: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
sizeBytes: number
|
||||||
|
sha256: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningTaskEvent {
|
||||||
|
id: string
|
||||||
|
time: string
|
||||||
|
text: string
|
||||||
|
eventType: string
|
||||||
|
actorSubject?: string
|
||||||
|
reason?: string
|
||||||
|
channel?: string
|
||||||
|
attemptNo?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponseOf<T> = ApiResponse<T>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -13,7 +13,7 @@ interface MenuGroup {
|
|||||||
items: MenuItem[]
|
items: MenuItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const loginStore = useLoginStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const menuGroups: MenuGroup[] = [
|
const menuGroups: MenuGroup[] = [
|
||||||
@@ -36,9 +36,14 @@ const menuGroups: MenuGroup[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
appStore.logout()
|
try {
|
||||||
void router.push('/login')
|
await loginStore.logout()
|
||||||
|
} catch {
|
||||||
|
// 即使服务暂时不可用,也要清理本地会话。
|
||||||
|
} finally {
|
||||||
|
await router.push('/login')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -85,10 +90,10 @@ function handleLogout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="menu-profile">
|
<div class="menu-profile">
|
||||||
<div class="menu-profile-avatar">{{ appStore.userName.slice(0, 1) }}</div>
|
<div class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</div>
|
||||||
<div class="menu-profile-copy">
|
<div class="menu-profile-copy">
|
||||||
<strong>{{ appStore.userName }}</strong>
|
<strong>{{ loginStore.userName }}</strong>
|
||||||
<span>{{ appStore.department }}</span>
|
<span>{{ loginStore.department }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="menu-logout" type="button" @click="handleLogout">退出</button>
|
<button class="menu-logout" type="button" @click="handleLogout">退出</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
965
clinical-web/src/components/signing/NewSigningTaskDialog.vue
Normal file
965
clinical-web/src/components/signing/NewSigningTaskDialog.vue
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { isSigningMockEnabled } from '@/api/workbench/signing'
|
||||||
|
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||||||
|
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
initialTemplate?: SigningTemplate | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
created: [task: SigningTaskRecord]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const patientInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const initialTemplate = computed(() => props.initialTemplate ?? null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
documentSearch,
|
||||||
|
errors,
|
||||||
|
filteredTemplateGroups,
|
||||||
|
loadingTemplates,
|
||||||
|
loadTemplates,
|
||||||
|
locatePatient,
|
||||||
|
locating,
|
||||||
|
maskPhone,
|
||||||
|
method,
|
||||||
|
patient,
|
||||||
|
patientId,
|
||||||
|
resetForNextTask,
|
||||||
|
resetForm,
|
||||||
|
selectMethod,
|
||||||
|
selectTemplate,
|
||||||
|
selectVisit,
|
||||||
|
selectedTemplate,
|
||||||
|
selectedTemplateId,
|
||||||
|
selectedVisit,
|
||||||
|
selectedVisitId,
|
||||||
|
smsDestination,
|
||||||
|
submit: submitForm,
|
||||||
|
submitting,
|
||||||
|
visits,
|
||||||
|
} = useSigningTaskForm({
|
||||||
|
campus: computed(() => appStore.selectedCampus),
|
||||||
|
initialTemplate,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submitTask(keepOpen: boolean) {
|
||||||
|
try {
|
||||||
|
const task = await submitForm()
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('created', task)
|
||||||
|
const smsMessage =
|
||||||
|
task.method === 'sms' ? (isSigningMockEnabled ? ',已生成短信投递意图' : ',短信已发送') : ''
|
||||||
|
ElMessage.success(`已创建 1 个签署任务${smsMessage}`)
|
||||||
|
|
||||||
|
if (keepOpen) {
|
||||||
|
resetForNextTask()
|
||||||
|
await nextTick()
|
||||||
|
patientInput.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('签署任务创建失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
await loadTemplates()
|
||||||
|
await nextTick()
|
||||||
|
patientInput.value?.focus()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card new-task-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="new-task-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="new-task-title">新增签署任务</strong>
|
||||||
|
<span>单页完成,无需分步</span>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dialog-body">
|
||||||
|
<section class="form-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<span class="section-index">A</span>
|
||||||
|
<strong>患者定位</strong>
|
||||||
|
<span>输入患者 ID / 门诊号 / 住院号,回车定位</span>
|
||||||
|
</header>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="patient-search">
|
||||||
|
<input
|
||||||
|
ref="patientInput"
|
||||||
|
v-model="patientId"
|
||||||
|
placeholder="如:P202610001"
|
||||||
|
aria-label="患者 ID"
|
||||||
|
@input="errors.patient = ''"
|
||||||
|
@keydown.enter.prevent="locatePatient"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button"
|
||||||
|
:disabled="locating"
|
||||||
|
@click="locatePatient"
|
||||||
|
>
|
||||||
|
{{ locating ? '定位中…' : '定位患者' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="patient" class="patient-card">
|
||||||
|
<span class="patient-avatar" aria-hidden="true">人</span>
|
||||||
|
<strong>{{ patient.name }}</strong>
|
||||||
|
<span>{{ patient.sex }} · {{ patient.age }}岁</span>
|
||||||
|
<span class="patient-field"><small>身份证号</small>{{ patient.idCard }}</span>
|
||||||
|
<span class="patient-field">
|
||||||
|
<small>手机号</small>
|
||||||
|
{{ maskPhone(patient.phone) }}
|
||||||
|
</span>
|
||||||
|
<span class="patient-field"><small>患者 ID</small>{{ patient.id }}</span>
|
||||||
|
<span v-if="selectedVisit?.isCurrent" class="in-hospital">在院</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors.patient" class="field-error">{{ errors.patient }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<span class="section-index">B</span>
|
||||||
|
<strong>关联就诊</strong>
|
||||||
|
<span>默认最近一次,在院优先</span>
|
||||||
|
</header>
|
||||||
|
<div class="section-body">
|
||||||
|
<div v-if="visits.length" class="visit-list">
|
||||||
|
<button
|
||||||
|
v-for="visit in visits"
|
||||||
|
:key="visit.id"
|
||||||
|
type="button"
|
||||||
|
class="visit-option"
|
||||||
|
:class="{ 'visit-option--active': visit.id === selectedVisitId }"
|
||||||
|
@click="selectVisit(visit)"
|
||||||
|
>
|
||||||
|
<span class="radio-mark" />
|
||||||
|
<strong>{{ visit.type }} · {{ visit.department }}</strong>
|
||||||
|
<span class="visit-number">{{ visit.visitNo }}</span>
|
||||||
|
<span class="visit-date">{{ visit.visitDate }}</span>
|
||||||
|
<span v-if="visit.isCurrent" class="visit-current">● 在院</span>
|
||||||
|
<span v-else-if="visit === visits[0]" class="visit-current visit-current--recent"
|
||||||
|
>最近一次</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="section-empty">请先定位患者…</p>
|
||||||
|
<p v-if="errors.visit" class="field-error">{{ errors.visit }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="document-method-grid">
|
||||||
|
<section class="form-section document-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<span class="section-index">C</span>
|
||||||
|
<strong>选择文档</strong>
|
||||||
|
<span>单选一份</span>
|
||||||
|
<input
|
||||||
|
v-model="documentSearch"
|
||||||
|
placeholder="⌕ 搜索文档名称…"
|
||||||
|
aria-label="搜索文档"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<div class="section-body">
|
||||||
|
<div v-if="selectedTemplate" class="selected-template">
|
||||||
|
<span>已选:</span>
|
||||||
|
<strong>{{ selectedTemplate.name }}</strong>
|
||||||
|
<button type="button" aria-label="移除已选文档" @click="selectedTemplateId = ''">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingTemplates" class="tree-loading">正在加载文档模板…</div>
|
||||||
|
<div v-else-if="filteredTemplateGroups.length" class="template-tree">
|
||||||
|
<div
|
||||||
|
v-for="department in filteredTemplateGroups"
|
||||||
|
:key="department.name"
|
||||||
|
class="tree-department"
|
||||||
|
>
|
||||||
|
<div class="tree-label">
|
||||||
|
<span>▾</span><strong>{{ department.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="category in department.categories"
|
||||||
|
:key="category.name"
|
||||||
|
class="tree-category"
|
||||||
|
>
|
||||||
|
<div class="tree-label tree-label--category">
|
||||||
|
<span>▾</span>{{ category.name
|
||||||
|
}}<small>{{ category.templates.length }}</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="template in category.templates"
|
||||||
|
:key="template.id"
|
||||||
|
type="button"
|
||||||
|
class="document-option"
|
||||||
|
:class="{ 'document-option--active': selectedTemplateId === template.id }"
|
||||||
|
@click="selectTemplate(template)"
|
||||||
|
>
|
||||||
|
<span class="radio-mark radio-mark--document">●</span>
|
||||||
|
<span>{{ template.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="tree-empty">没有匹配的文档模板</div>
|
||||||
|
<p v-if="errors.document" class="field-error">{{ errors.document }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section method-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<span class="section-index">D</span>
|
||||||
|
<strong>签署方式</strong>
|
||||||
|
<span>二选一</span>
|
||||||
|
</header>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="method-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="method-option"
|
||||||
|
:class="{ 'method-option--active': method === 'pad' }"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
selectedTemplate && !selectedTemplate.supportedMethods.includes('pad'),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="selectMethod('pad')"
|
||||||
|
>
|
||||||
|
<span class="method-icon">✍</span>
|
||||||
|
<span
|
||||||
|
><strong>手写板签署(现场)</strong
|
||||||
|
><small>护士站手写板签署,即签即得</small></span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="method-option"
|
||||||
|
:class="{ 'method-option--active': method === 'sms' }"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
selectedTemplate && !selectedTemplate.supportedMethods.includes('sms'),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="selectMethod('sms')"
|
||||||
|
>
|
||||||
|
<span class="method-icon">📱</span>
|
||||||
|
<span
|
||||||
|
><strong>线上签署(短信链接)</strong
|
||||||
|
><small>短信发送链接,48 小时内有效</small></span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="method === 'sms'" class="sms-confirm">
|
||||||
|
<div class="sms-row">
|
||||||
|
<span class="sms-icon">📱</span>
|
||||||
|
<strong>短信将发送至</strong>
|
||||||
|
<span>{{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="isSigningMockEnabled">
|
||||||
|
<p>使用患者权威记录中的手机号,具体投递结果以服务端返回为准。</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="sms-destination-field">
|
||||||
|
<span>真实投递手机号</span>
|
||||||
|
<input
|
||||||
|
v-model="smsDestination"
|
||||||
|
inputmode="tel"
|
||||||
|
autocomplete="tel"
|
||||||
|
placeholder="请输入完整手机号或 E.164 地址"
|
||||||
|
@input="errors.sms = ''"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p>
|
||||||
|
患者接口仅返回脱敏手机号,真实联调时由当前操作人员确认投递地址;服务端不会回显完整号码。
|
||||||
|
</p>
|
||||||
|
<p v-if="errors.sms" class="field-error">{{ errors.sms }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span>患者手写签名图片将合成至文档签名域,PDF 原件与签名原图双留存</span>
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitTask(true)"
|
||||||
|
>
|
||||||
|
提交并继续新增
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitTask(false)"
|
||||||
|
>
|
||||||
|
{{ submitting ? '提交中…' : '提交任务' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 680px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-task-dialog {
|
||||||
|
display: flex;
|
||||||
|
width: 1200px;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header > span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #f5f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header > span:not(.section-index) {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-index {
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10.5px;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-search input,
|
||||||
|
.sms-row input {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-search input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-search input:focus,
|
||||||
|
.sms-row input:focus,
|
||||||
|
.section-header input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover:not(:disabled) {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover:not(:disabled) {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 7px 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-avatar {
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card > strong {
|
||||||
|
color: var(--brand-d);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card > span:not(.patient-field):not(.patient-avatar):not(.in-hospital) {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-field {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-field small {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.in-hospital {
|
||||||
|
padding: 1px 7px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10.5px;
|
||||||
|
background: var(--err);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-option {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-option:hover,
|
||||||
|
.visit-option--active {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-option--active {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-mark {
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid #b8cbd4;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-option--active .radio-mark {
|
||||||
|
background: radial-gradient(circle, var(--brand) 42%, transparent 46%);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-option strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-number,
|
||||||
|
.visit-date {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-current {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-current--recent {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-empty,
|
||||||
|
.tree-empty,
|
||||||
|
.tree-loading {
|
||||||
|
padding: 4px 0;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-method-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-section,
|
||||||
|
.method-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-section .section-header input {
|
||||||
|
width: 240px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-section .section-header > span:not(.section-index) {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-template {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--brand-d);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-template strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-template button {
|
||||||
|
padding: 0 3px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-tree {
|
||||||
|
height: 252px;
|
||||||
|
padding: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-label > span {
|
||||||
|
width: 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-category {
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-label--category {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-label small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-option {
|
||||||
|
display: flex;
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 6px;
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-option:hover,
|
||||||
|
.document-option--active {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-mark--document {
|
||||||
|
display: grid;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1.5px solid #b8cbd4;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-option--active .radio-mark--document {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option:hover:not(:disabled),
|
||||||
|
.method-option--active {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option--active {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-icon {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option strong,
|
||||||
|
.method-option small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option strong {
|
||||||
|
margin-bottom: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-confirm {
|
||||||
|
padding: 9px 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border: 1px dashed var(--brand);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-row strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--brand-d);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-confirm p {
|
||||||
|
padding-left: 28px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-destination-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 28px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-destination-field span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--brand-d);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-destination-field input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 9px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-destination-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-confirm .field-error {
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.document-method-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.patient-card {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header > span:not(.section-index) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-section .section-header input {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer > span:first-child {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-destination-field {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-destination-field input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
316
clinical-web/src/composables/useSigningTaskForm.ts
Normal file
316
clinical-web/src/composables/useSigningTaskForm.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { sendSigningSms } from '@/api/workbench/deliveries'
|
||||||
|
import {
|
||||||
|
createSigningTask,
|
||||||
|
getPatientProfile,
|
||||||
|
getSigningTemplates,
|
||||||
|
isSigningMockEnabled,
|
||||||
|
} from '@/api/workbench/signing'
|
||||||
|
import type {
|
||||||
|
PatientProfile,
|
||||||
|
PatientVisit,
|
||||||
|
SigningMethod,
|
||||||
|
SigningTaskRecord,
|
||||||
|
SigningTemplate,
|
||||||
|
WorkbenchCampus,
|
||||||
|
} from '@/api/workbench/types'
|
||||||
|
|
||||||
|
export interface UseSigningTaskFormOptions {
|
||||||
|
campus: MaybeRefOrGetter<WorkbenchCampus>
|
||||||
|
initialTemplate?: MaybeRefOrGetter<SigningTemplate | null | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateCategoryGroup {
|
||||||
|
name: string
|
||||||
|
templates: SigningTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateDepartmentGroup {
|
||||||
|
name: string
|
||||||
|
categories: TemplateCategoryGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||||
|
const patientId = ref('')
|
||||||
|
const patient = ref<PatientProfile | null>(null)
|
||||||
|
const visits = ref<PatientVisit[]>([])
|
||||||
|
const templates = ref<SigningTemplate[]>([])
|
||||||
|
const documentSearch = ref('')
|
||||||
|
const selectedTemplateId = ref('')
|
||||||
|
const selectedVisitId = ref('')
|
||||||
|
const method = ref<SigningMethod>('pad')
|
||||||
|
const smsDestination = ref('')
|
||||||
|
const loadingTemplates = ref(false)
|
||||||
|
const locating = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const errors = reactive({
|
||||||
|
patient: '',
|
||||||
|
visit: '',
|
||||||
|
document: '',
|
||||||
|
sms: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedVisit = computed(
|
||||||
|
() => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedTemplate = computed(
|
||||||
|
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredTemplateGroups = computed<TemplateDepartmentGroup[]>(() => {
|
||||||
|
const keyword = documentSearch.value.trim().toLowerCase()
|
||||||
|
const grouped = new Map<string, Map<string, SigningTemplate[]>>()
|
||||||
|
|
||||||
|
templates.value.forEach((template) => {
|
||||||
|
const matches =
|
||||||
|
!keyword ||
|
||||||
|
[template.name, template.code, template.department, template.category]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword)
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = grouped.get(template.department) ?? new Map<string, SigningTemplate[]>()
|
||||||
|
const categoryTemplates = categories.get(template.category) ?? []
|
||||||
|
categoryTemplates.push(template)
|
||||||
|
categories.set(template.category, categoryTemplates)
|
||||||
|
grouped.set(template.department, categories)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...grouped.entries()].map(([department, categories]) => ({
|
||||||
|
name: department,
|
||||||
|
categories: [...categories.entries()].map(([category, categoryTemplates]) => ({
|
||||||
|
name: category,
|
||||||
|
templates: categoryTemplates,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function getInitialTemplate() {
|
||||||
|
return options.initialTemplate ? (toValue(options.initialTemplate) ?? null) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function includeInitialTemplate() {
|
||||||
|
const initialTemplate = getInitialTemplate()
|
||||||
|
|
||||||
|
if (
|
||||||
|
initialTemplate &&
|
||||||
|
!templates.value.some((template) => template.id === initialTemplate.id)
|
||||||
|
) {
|
||||||
|
templates.value = [initialTemplate, ...templates.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
errors.patient = ''
|
||||||
|
errors.visit = ''
|
||||||
|
errors.document = ''
|
||||||
|
errors.sms = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
const initialTemplate = getInitialTemplate()
|
||||||
|
|
||||||
|
patientId.value = ''
|
||||||
|
patient.value = null
|
||||||
|
visits.value = []
|
||||||
|
documentSearch.value = ''
|
||||||
|
selectedTemplateId.value = initialTemplate?.id ?? ''
|
||||||
|
selectedVisitId.value = ''
|
||||||
|
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
||||||
|
smsDestination.value = ''
|
||||||
|
clearErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForNextTask() {
|
||||||
|
const initialTemplate = getInitialTemplate()
|
||||||
|
|
||||||
|
documentSearch.value = ''
|
||||||
|
selectedTemplateId.value = initialTemplate?.id ?? ''
|
||||||
|
method.value = initialTemplate?.supportedMethods[0] ?? method.value
|
||||||
|
smsDestination.value = ''
|
||||||
|
errors.document = ''
|
||||||
|
errors.sms = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
if (!templates.value.length) {
|
||||||
|
loadingTemplates.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
templates.value = await getSigningTemplates()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('文档模板加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loadingTemplates.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
includeInitialTemplate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function locatePatient() {
|
||||||
|
const keyword = patientId.value.trim()
|
||||||
|
errors.patient = ''
|
||||||
|
errors.visit = ''
|
||||||
|
patient.value = null
|
||||||
|
visits.value = []
|
||||||
|
selectedVisitId.value = ''
|
||||||
|
selectedTemplateId.value = getInitialTemplate()?.id ?? ''
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
errors.patient = '请输入患者 ID、门诊号或住院号'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getPatientProfile(keyword)
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
errors.patient = '未找到该患者,请核对输入内容'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patient.value = profile
|
||||||
|
visits.value = profile.visits
|
||||||
|
selectedVisitId.value =
|
||||||
|
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
|
||||||
|
} catch {
|
||||||
|
errors.patient = '患者信息查询失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
locating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVisit(visit: PatientVisit) {
|
||||||
|
selectedVisitId.value = visit.id
|
||||||
|
errors.visit = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTemplate(template: SigningTemplate) {
|
||||||
|
selectedTemplateId.value = template.id
|
||||||
|
errors.document = ''
|
||||||
|
|
||||||
|
if (!template.supportedMethods.includes(method.value)) {
|
||||||
|
method.value = template.supportedMethods[0] ?? 'pad'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMethod(nextMethod: SigningMethod) {
|
||||||
|
if (selectedTemplate.value && !selectedTemplate.value.supportedMethods.includes(nextMethod)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
method.value = nextMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(value: string) {
|
||||||
|
if (value.length < 7) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
clearErrors()
|
||||||
|
let valid = true
|
||||||
|
|
||||||
|
if (!patient.value) {
|
||||||
|
errors.patient = '请先定位患者'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedVisit.value) {
|
||||||
|
errors.visit = '请选择关联就诊'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedTemplate.value) {
|
||||||
|
errors.document = '请至少选择一份文档'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSigningMockEnabled && method.value === 'sms') {
|
||||||
|
const destination = smsDestination.value.trim().replace(/\s+/g, '')
|
||||||
|
|
||||||
|
if (!/^\+?[1-9]\d{6,14}$/.test(destination)) {
|
||||||
|
errors.sms = '请输入完整的手机号或 E.164 地址,才能发送短信'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(): Promise<SigningTaskRecord | null> {
|
||||||
|
if (!validate() || !patient.value || !selectedVisit.value || !selectedTemplate.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await createSigningTask({
|
||||||
|
campus: toValue(options.campus),
|
||||||
|
patientId: patient.value.id,
|
||||||
|
visitId: selectedVisit.value.id,
|
||||||
|
documentId: selectedTemplate.value.id,
|
||||||
|
documentName: selectedTemplate.value.name,
|
||||||
|
templateVersionId: selectedTemplate.value.versionId,
|
||||||
|
method: method.value,
|
||||||
|
visitType: selectedVisit.value.type,
|
||||||
|
visitNo: selectedVisit.value.visitNo,
|
||||||
|
department: selectedVisit.value.department,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isSigningMockEnabled && method.value === 'sms') {
|
||||||
|
await sendSigningSms(task.id, {
|
||||||
|
destination: smsDestination.value.trim().replace(/\s+/g, ''),
|
||||||
|
templateCode: 'SIGN_LINK',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return task
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentSearch,
|
||||||
|
errors,
|
||||||
|
filteredTemplateGroups,
|
||||||
|
loadingTemplates,
|
||||||
|
loadTemplates,
|
||||||
|
locatePatient,
|
||||||
|
locating,
|
||||||
|
maskPhone,
|
||||||
|
method,
|
||||||
|
patient,
|
||||||
|
patientId,
|
||||||
|
resetForNextTask,
|
||||||
|
resetForm,
|
||||||
|
selectMethod,
|
||||||
|
selectTemplate,
|
||||||
|
selectVisit,
|
||||||
|
selectedTemplate,
|
||||||
|
selectedTemplateId,
|
||||||
|
selectedVisit,
|
||||||
|
selectedVisitId,
|
||||||
|
smsDestination,
|
||||||
|
submit,
|
||||||
|
submitting,
|
||||||
|
templates,
|
||||||
|
visits,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
import { useAppStore } from '@/stores/app'
|
|
||||||
import ClinicalLayout from '@/layouts/ClinicalLayout.vue'
|
import ClinicalLayout from '@/layouts/ClinicalLayout.vue'
|
||||||
import LoginView from '@/views/auth/LoginView.vue'
|
import LoginView from '@/views/auth/LoginView.vue'
|
||||||
import DocumentPermissionsView from '@/views/management/document-permissions/index.vue'
|
import DocumentPermissionsView from '@/views/management/document-permissions/index.vue'
|
||||||
@@ -10,6 +9,7 @@ import SettingsView from '@/views/management/settings/index.vue'
|
|||||||
import UserPermissionsView from '@/views/management/users/index.vue'
|
import UserPermissionsView from '@/views/management/users/index.vue'
|
||||||
import ConsentTasksView from '@/views/workbench/signing/index.vue'
|
import ConsentTasksView from '@/views/workbench/signing/index.vue'
|
||||||
import DashboardView from '@/views/workbench/home/index.vue'
|
import DashboardView from '@/views/workbench/home/index.vue'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -93,13 +93,13 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const appStore = useAppStore()
|
const loginStore = useLoginStore()
|
||||||
|
|
||||||
if (to.name === 'login' && appStore.isLoggedIn) {
|
if (to.name === 'login' && loginStore.isLoggedIn) {
|
||||||
return { name: 'workbench-home' }
|
return { name: 'workbench-home' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !appStore.isLoggedIn) {
|
if (to.meta.requiresAuth && !loginStore.isLoggedIn) {
|
||||||
return {
|
return {
|
||||||
name: 'login',
|
name: 'login',
|
||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', {
|
export const useAppStore = defineStore('app', () => {
|
||||||
state: () => ({
|
const environment = ref('接口联调环境')
|
||||||
isLoggedIn: false,
|
const campuses = ref<WorkbenchCampus[]>(['本部院区', '东院区', '西院区'])
|
||||||
userName: '张文静',
|
const selectedCampus = ref<WorkbenchCampus>('本部院区')
|
||||||
department: '医务管理 · 主任医师',
|
|
||||||
environment: '原型演示环境',
|
return {
|
||||||
campuses: ['本部院区', '东院区', '西院区'] as WorkbenchCampus[],
|
environment,
|
||||||
selectedCampus: '本部院区' as WorkbenchCampus,
|
campuses,
|
||||||
}),
|
selectedCampus,
|
||||||
actions: {
|
}
|
||||||
login(userName: string) {
|
|
||||||
this.userName = userName || '张文静'
|
|
||||||
this.isLoggedIn = true
|
|
||||||
},
|
|
||||||
logout() {
|
|
||||||
this.isLoggedIn = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
87
clinical-web/src/stores/login.ts
Normal file
87
clinical-web/src/stores/login.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import { getCurrentUser, login as loginRequest, logout as logoutRequest } from '@/api/auth'
|
||||||
|
import type { AuthenticatedUser, LoginRequest, LoginResponse } from '@/types/auth'
|
||||||
|
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
|
||||||
|
|
||||||
|
const dataScopeLabels: Record<AuthenticatedUser['dataScope'], string> = {
|
||||||
|
ALL: '全院',
|
||||||
|
CAMPUS: '院区',
|
||||||
|
DEPARTMENT: '科室',
|
||||||
|
READ_ONLY_ALL: '全院只读',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserContext(user: AuthenticatedUser | null) {
|
||||||
|
return user ? `${dataScopeLabels[user.dataScope]}数据范围` : '医签通用户'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLoginStore = defineStore('login', () => {
|
||||||
|
const storedSession = readStoredAuthSession()
|
||||||
|
const token = ref(storedSession?.token ?? '')
|
||||||
|
const expiresAt = ref(storedSession?.expiresAt ?? '')
|
||||||
|
const user = ref<AuthenticatedUser | null>(storedSession?.user ?? null)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => Boolean(token.value))
|
||||||
|
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
|
||||||
|
const department = computed(() => getUserContext(user.value))
|
||||||
|
|
||||||
|
function setSession(session: LoginResponse) {
|
||||||
|
saveAuthSession(session)
|
||||||
|
token.value = session.token
|
||||||
|
expiresAt.value = session.expiresAt
|
||||||
|
user.value = session.user
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentUser(nextUser: AuthenticatedUser) {
|
||||||
|
user.value = nextUser
|
||||||
|
|
||||||
|
if (token.value) {
|
||||||
|
saveAuthSession({
|
||||||
|
token: token.value,
|
||||||
|
expiresAt: expiresAt.value,
|
||||||
|
user: nextUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
clearAuthStorage()
|
||||||
|
token.value = ''
|
||||||
|
expiresAt.value = ''
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(payload: LoginRequest) {
|
||||||
|
const session = await loginRequest(payload)
|
||||||
|
setSession(session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
setCurrentUser(currentUser)
|
||||||
|
return currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await logoutRequest()
|
||||||
|
} finally {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
expiresAt,
|
||||||
|
user,
|
||||||
|
isLoggedIn,
|
||||||
|
userName,
|
||||||
|
department,
|
||||||
|
login,
|
||||||
|
refreshCurrentUser,
|
||||||
|
logout,
|
||||||
|
clearSession,
|
||||||
|
}
|
||||||
|
})
|
||||||
42
clinical-web/src/types/auth.ts
Normal file
42
clinical-web/src/types/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type AuthDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL'
|
||||||
|
|
||||||
|
export type AuthUserStatus = 'ENABLED' | 'DISABLED'
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
captchaId?: string
|
||||||
|
captchaCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaResponse {
|
||||||
|
captchaId: string
|
||||||
|
imageBase64: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
campusId: string
|
||||||
|
departmentId: string | null
|
||||||
|
dataScope: AuthDataScope
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
expiresAt: string
|
||||||
|
user: AuthenticatedUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUserResponse extends AuthenticatedUser {
|
||||||
|
employeeNo: string | null
|
||||||
|
phone: string | null
|
||||||
|
email: string | null
|
||||||
|
status: AuthUserStatus
|
||||||
|
lastLoginAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -13,9 +13,11 @@ export interface PageResult<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
code: number
|
code: number | string
|
||||||
message: string
|
message: string
|
||||||
data: T
|
data: T | null
|
||||||
|
traceId?: string
|
||||||
|
timestamp?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectOption<T extends string = string> {
|
export interface SelectOption<T extends string = string> {
|
||||||
|
|||||||
37
clinical-web/src/utils/api-response.ts
Normal file
37
clinical-web/src/utils/api-response.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
|
export class ApiResponseError extends Error {
|
||||||
|
readonly code: number | string
|
||||||
|
readonly traceId?: string
|
||||||
|
|
||||||
|
constructor(response: ApiResponse<unknown>) {
|
||||||
|
super(response.message || '接口请求失败')
|
||||||
|
this.name = 'ApiResponseError'
|
||||||
|
this.code = response.code
|
||||||
|
this.traceId = response.traceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuccessCode(code: number | string) {
|
||||||
|
return code === 0 || code === '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapApiResponse<T>(response: ApiResponse<T>): T {
|
||||||
|
if (!isSuccessCode(response.code)) {
|
||||||
|
throw new ApiResponseError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data === null) {
|
||||||
|
throw new Error(response.message || '接口未返回业务数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapNullableApiResponse<T>(response: ApiResponse<T | null>): T | null {
|
||||||
|
if (!isSuccessCode(response.code)) {
|
||||||
|
throw new ApiResponseError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
59
clinical-web/src/utils/auth-storage.ts
Normal file
59
clinical-web/src/utils/auth-storage.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { AuthenticatedUser, LoginResponse } from '@/types/auth'
|
||||||
|
|
||||||
|
export const AUTH_TOKEN_KEY = 'token'
|
||||||
|
export const AUTH_USER_KEY = 'userInfo'
|
||||||
|
export const AUTH_EXPIRES_AT_KEY = 'tokenExpiresAt'
|
||||||
|
|
||||||
|
export interface StoredAuthSession {
|
||||||
|
token: string
|
||||||
|
user: AuthenticatedUser | null
|
||||||
|
expiresAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredUser(): AuthenticatedUser | null {
|
||||||
|
const rawUser = localStorage.getItem(AUTH_USER_KEY)
|
||||||
|
|
||||||
|
if (!rawUser) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawUser) as AuthenticatedUser
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(AUTH_USER_KEY)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredAuthSession(): StoredAuthSession | null {
|
||||||
|
const token = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = localStorage.getItem(AUTH_EXPIRES_AT_KEY)
|
||||||
|
|
||||||
|
if (expiresAt && Date.parse(expiresAt) <= Date.now()) {
|
||||||
|
clearAuthStorage()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user: readStoredUser(),
|
||||||
|
expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuthSession(session: LoginResponse) {
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, session.token)
|
||||||
|
localStorage.setItem(AUTH_EXPIRES_AT_KEY, session.expiresAt)
|
||||||
|
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(session.user))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthStorage() {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(AUTH_EXPIRES_AT_KEY)
|
||||||
|
localStorage.removeItem(AUTH_USER_KEY)
|
||||||
|
}
|
||||||
@@ -5,23 +5,38 @@ import axios, {
|
|||||||
type InternalAxiosRequestConfig,
|
type InternalAxiosRequestConfig,
|
||||||
} from 'axios'
|
} from 'axios'
|
||||||
|
|
||||||
|
import { clearAuthStorage, AUTH_TOKEN_KEY } from '@/utils/auth-storage'
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT = 15_000
|
const DEFAULT_TIMEOUT = 15_000
|
||||||
|
const DEFAULT_API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
if (typeof window === 'undefined' || window.location.pathname === '/login') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||||
|
window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`)
|
||||||
|
}
|
||||||
|
|
||||||
class Request {
|
class Request {
|
||||||
private readonly instance: AxiosInstance
|
private readonly instance: AxiosInstance
|
||||||
|
|
||||||
constructor(baseURL = import.meta.env.VITE_API_BASE_URL || '/api', timeout = DEFAULT_TIMEOUT) {
|
constructor(
|
||||||
|
baseURL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
|
||||||
|
timeout = DEFAULT_TIMEOUT,
|
||||||
|
) {
|
||||||
this.instance = axios.create({
|
this.instance = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
timeout,
|
timeout,
|
||||||
withCredentials: true,
|
withCredentials: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.set('Authorization', 'Bearer ' + token)
|
config.headers.set('X-Token', token)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
@@ -29,7 +44,14 @@ class Request {
|
|||||||
|
|
||||||
this.instance.interceptors.response.use(
|
this.instance.interceptors.response.use(
|
||||||
(response: AxiosResponse) => response,
|
(response: AxiosResponse) => response,
|
||||||
(error: unknown) => Promise.reject(error),
|
(error: unknown) => {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
clearAuthStorage()
|
||||||
|
redirectToLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,90 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useAppStore } from '@/stores/app'
|
import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
|
||||||
|
import type { CaptchaResponse, LoginRequest } from '@/types/auth'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const loginStore = useLoginStore()
|
||||||
|
|
||||||
const username = ref('zhangwj')
|
const username = ref('')
|
||||||
const password = ref('******')
|
const password = ref('')
|
||||||
const captcha = ref('8362')
|
const captcha = ref<CaptchaResponse | null>(null)
|
||||||
|
const captchaCode = ref('')
|
||||||
|
const captchaLoading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const captchaCode = '8362'
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
async function loadCaptcha() {
|
||||||
|
captchaLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
captcha.value = await getCaptcha()
|
||||||
|
captchaCode.value = ''
|
||||||
|
} catch {
|
||||||
|
// 验证码由服务端按需启用;接口不可用时保持账号密码登录兼容。
|
||||||
|
captcha.value = null
|
||||||
|
captchaCode.value = ''
|
||||||
|
} finally {
|
||||||
|
captchaLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getRedirectPath() {
|
function getRedirectPath() {
|
||||||
const redirect = route.query.redirect
|
const redirect = route.query.redirect
|
||||||
return typeof redirect === 'string' && redirect.startsWith('/') ? redirect : '/workbench/home'
|
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
|
||||||
|
? redirect
|
||||||
|
: '/workbench/home'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!username.value.trim() || !password.value.trim() || !captcha.value.trim()) {
|
const normalizedUsername = username.value.trim()
|
||||||
errorMessage.value = '请输入完整的账号、密码和验证码'
|
|
||||||
|
if (!normalizedUsername || !password.value) {
|
||||||
|
errorMessage.value = '请输入账号和密码'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (captcha.value.trim() !== captchaCode) {
|
if (captcha.value && !captchaCode.value.trim()) {
|
||||||
errorMessage.value = '验证码错误,请重新输入'
|
errorMessage.value = '请输入图形验证码'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
appStore.login(username.value.trim())
|
isSubmitting.value = true
|
||||||
void router.replace(getRedirectPath())
|
|
||||||
|
try {
|
||||||
|
const payload: LoginRequest = {
|
||||||
|
username: normalizedUsername,
|
||||||
|
password: password.value,
|
||||||
|
...(captcha.value
|
||||||
|
? {
|
||||||
|
captchaId: captcha.value.captchaId,
|
||||||
|
captchaCode: captchaCode.value.trim(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginStore.login(payload)
|
||||||
|
|
||||||
|
await router.replace(getRedirectPath())
|
||||||
|
} catch (error: unknown) {
|
||||||
|
errorMessage.value = getAuthErrorMessage(error)
|
||||||
|
|
||||||
|
if (captcha.value) {
|
||||||
|
void loadCaptcha()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadCaptcha()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -87,25 +138,36 @@ function handleLogin() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="login-field">
|
<div v-if="captcha" class="login-field login-captcha">
|
||||||
<label for="login-captcha">验证码</label>
|
<label for="login-captcha">验证码</label>
|
||||||
<div class="login-captcha-row">
|
<div class="captcha-row">
|
||||||
<input
|
<input
|
||||||
id="login-captcha"
|
id="login-captcha"
|
||||||
v-model="captcha"
|
v-model="captchaCode"
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
placeholder="验证码"
|
inputmode="text"
|
||||||
|
maxlength="8"
|
||||||
|
placeholder="请输入验证码"
|
||||||
/>
|
/>
|
||||||
<span class="login-captcha-image" aria-label="验证码">{{ captchaCode }}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="captcha-image-button"
|
||||||
|
:disabled="captchaLoading"
|
||||||
|
aria-label="刷新验证码"
|
||||||
|
@click="loadCaptcha"
|
||||||
|
>
|
||||||
|
<img :src="captcha.imageBase64" alt="图形验证码,点击刷新" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<button class="login-submit" type="submit">登 录</button>
|
<button class="login-submit" type="submit" :disabled="isSubmitting">
|
||||||
<p class="login-tip">原型演示:任意账号密码均可登录</p>
|
{{ isSubmitting ? '登录中…' : '登 录' }}
|
||||||
|
</button>
|
||||||
|
<p class="login-tip">请使用医院分配的账号登录</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p>
|
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p>
|
||||||
@@ -194,27 +256,43 @@ function handleLogin() {
|
|||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-captcha-row {
|
.captcha-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-captcha-row input {
|
.captcha-row input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-captcha-image {
|
.captcha-image-button {
|
||||||
|
display: grid;
|
||||||
|
width: 112px;
|
||||||
|
height: 34px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8px 14px;
|
padding: 0;
|
||||||
color: #5c7d8a;
|
overflow: hidden;
|
||||||
font-size: 13px;
|
background: #f4f7f9;
|
||||||
font-style: italic;
|
border: 1px solid var(--line);
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 6px;
|
|
||||||
user-select: none;
|
|
||||||
background: linear-gradient(120deg, #dfeef3, #c8dfe8);
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image-button:hover:not(:disabled) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image-button img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
@@ -238,6 +316,11 @@ function handleLogin() {
|
|||||||
background: var(--brand-d);
|
background: var(--brand-d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-submit:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.login-tip {
|
.login-tip {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
|
||||||
|
|
||||||
|
import type { PermissionSubjectOption, TemplatePermissionForm } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
templates: DocumentRecord[]
|
||||||
|
selectedTemplateId: string
|
||||||
|
permissions: TemplatePermissionRecord[]
|
||||||
|
subjects: PermissionSubjectOption[]
|
||||||
|
loading: boolean
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedTemplateId': [value: string]
|
||||||
|
add: [form: TemplatePermissionForm]
|
||||||
|
delete: [permissionId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive<TemplatePermissionForm>({
|
||||||
|
subjectType: 'ROLE',
|
||||||
|
subjectId: '',
|
||||||
|
permissionLevel: 'VIEW',
|
||||||
|
effect: 'ALLOW',
|
||||||
|
})
|
||||||
|
|
||||||
|
const subjectTypeLabels: Record<PermissionSubjectOption['type'], string> = {
|
||||||
|
ROLE: '角色',
|
||||||
|
DEPARTMENT: '科室',
|
||||||
|
USER: '用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionLabels: Record<TemplatePermissionForm['permissionLevel'], string> = {
|
||||||
|
VIEW: '可见(查看)',
|
||||||
|
USE: '可用(发起签署)',
|
||||||
|
MAINTAIN: '可维护(编辑/停用)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectLabels: Record<TemplatePermissionForm['effect'], string> = {
|
||||||
|
ALLOW: '允许',
|
||||||
|
DENY: '拒绝',
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableSubjects = computed(() =>
|
||||||
|
props.subjects.filter((subject) => subject.type === form.subjectType),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedTemplate = computed(() =>
|
||||||
|
props.templates.find((template) => template.id === props.selectedTemplateId),
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleTemplateChange(event: Event) {
|
||||||
|
emit('update:selectedTemplateId', (event.target as HTMLSelectElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubjectTypeChange() {
|
||||||
|
form.subjectId = availableSubjects.value[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!form.subjectId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('add', { ...form })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubject(subjectType: PermissionSubjectOption['type'], subjectId: string) {
|
||||||
|
const subject = props.subjects.find((item) => item.type === subjectType && item.id === subjectId)
|
||||||
|
return subject?.label ?? subjectId
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.subjects,
|
||||||
|
() => {
|
||||||
|
if (!availableSubjects.value.some((subject) => subject.id === form.subjectId)) {
|
||||||
|
form.subjectId = availableSubjects.value[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="permission-panel">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<label class="template-select">
|
||||||
|
<span>选择模板</span>
|
||||||
|
<select :value="selectedTemplateId" @change="handleTemplateChange">
|
||||||
|
<option value="" disabled>请选择模板</option>
|
||||||
|
<option v-for="template in templates" :key="template.id" :value="template.id">
|
||||||
|
{{ template.name }} · {{ template.version }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span v-if="selectedTemplate" class="template-meta">
|
||||||
|
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedTemplateId" class="empty-state">暂无模板,请先选择一个模板。</div>
|
||||||
|
<div v-else-if="loading" class="loading-state">正在加载模板权限…</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="permission-list">
|
||||||
|
<div class="list-heading">
|
||||||
|
<span>已配置权限</span>
|
||||||
|
<span>{{ permissions.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="permissions.length" class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>授权对象</th>
|
||||||
|
<th>权限</th>
|
||||||
|
<th>效果</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="permission in permissions" :key="permission.id">
|
||||||
|
<td>
|
||||||
|
<span class="subject-type">{{ subjectTypeLabels[permission.subjectType] }}</span>
|
||||||
|
{{ formatSubject(permission.subjectType, permission.subjectId) }}
|
||||||
|
</td>
|
||||||
|
<td>{{ permissionLabels[permission.permissionLevel] }}</td>
|
||||||
|
<td>{{ effectLabels[permission.effect] }}</td>
|
||||||
|
<td>{{ permission.inherited ? '继承' : '直接' }}</td>
|
||||||
|
<td>{{ permission.createdAt }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-button"
|
||||||
|
:disabled="permission.inherited || saving"
|
||||||
|
:title="permission.inherited ? '继承权限不可直接删除' : '删除权限'"
|
||||||
|
@click="emit('delete', permission.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-state compact">当前模板暂无直接权限配置。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="add-form" @submit.prevent="submit">
|
||||||
|
<div class="list-heading">
|
||||||
|
<span>新增权限</span>
|
||||||
|
<span>服务端会再次校验授权范围</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>对象类型</span>
|
||||||
|
<select v-model="form.subjectType" @change="handleSubjectTypeChange">
|
||||||
|
<option v-for="(label, value) in subjectTypeLabels" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>授权对象</span>
|
||||||
|
<select v-model="form.subjectId" :disabled="!availableSubjects.length">
|
||||||
|
<option value="" disabled>请选择对象</option>
|
||||||
|
<option v-for="subject in availableSubjects" :key="subject.id" :value="subject.id">
|
||||||
|
{{ subject.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>权限级别</span>
|
||||||
|
<select v-model="form.permissionLevel">
|
||||||
|
<option v-for="(label, value) in permissionLabels" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>授权效果</span>
|
||||||
|
<select v-model="form.effect">
|
||||||
|
<option v-for="(label, value) in effectLabels" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="add-button" :disabled="saving || !form.subjectId">
|
||||||
|
{{ saving ? '保存中…' : '添加权限' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.permission-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select,
|
||||||
|
.form-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select {
|
||||||
|
min-width: min(420px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:disabled {
|
||||||
|
background: #f5f8f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-meta {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-heading span:last-child {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 760px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #f5f9fa;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom: 1px solid #eef4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-type {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-right: 5px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 10.5px;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 11.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgb(194 73 73 / 32%);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:disabled {
|
||||||
|
color: #aebbc0;
|
||||||
|
border-color: #dce5e8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
padding: 7px 13px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--brand);
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.loading-state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.compact {
|
||||||
|
padding: 18px 12px;
|
||||||
|
margin: 0;
|
||||||
|
background: #f8fbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,24 +2,118 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { getDepartments } from '@/api/management/organization'
|
||||||
|
import { getDocuments } from '@/api/management/documents'
|
||||||
|
import {
|
||||||
|
createTemplatePermission,
|
||||||
|
deleteTemplatePermission,
|
||||||
|
getTemplatePermissions,
|
||||||
|
isPermissionMockEnabled,
|
||||||
|
} from '@/api/management/document-permissions'
|
||||||
|
import { getRoles } from '@/api/management/roles'
|
||||||
|
import { getUsers } from '@/api/management/users'
|
||||||
|
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
|
||||||
|
|
||||||
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
|
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
|
||||||
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
|
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
|
||||||
|
import TemplatePermissionPanel from './components/TemplatePermissionPanel.vue'
|
||||||
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
|
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
|
||||||
import type { DepartmentPermissionRow, PermissionKey, RolePermissionRow } from './types'
|
import type {
|
||||||
|
DepartmentPermissionRow,
|
||||||
|
PermissionKey,
|
||||||
|
PermissionSubjectOption,
|
||||||
|
RolePermissionRow,
|
||||||
|
TemplatePermissionForm,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const roles = ref<RolePermissionRow[]>([])
|
const roles = ref<RolePermissionRow[]>([])
|
||||||
const departments = ref<DepartmentPermissionRow[]>([])
|
const departments = ref<DepartmentPermissionRow[]>([])
|
||||||
|
const templates = ref<DocumentRecord[]>([])
|
||||||
|
const selectedTemplateId = ref('')
|
||||||
|
const templatePermissions = ref<TemplatePermissionRecord[]>([])
|
||||||
|
const permissionSubjects = ref<PermissionSubjectOption[]>([])
|
||||||
|
const permissionLoading = ref(false)
|
||||||
|
const permissionSaving = ref(false)
|
||||||
|
|
||||||
async function loadPermissions() {
|
async function loadPermissions() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await Promise.resolve()
|
|
||||||
roles.value = rolePermissionRows.map((role) => ({
|
try {
|
||||||
...role,
|
if (isPermissionMockEnabled) {
|
||||||
permissions: { ...role.permissions },
|
roles.value = rolePermissionRows.map((role) => ({
|
||||||
}))
|
...role,
|
||||||
departments.value = departmentPermissionRows.map((row) => ({ ...row }))
|
permissions: { ...role.permissions },
|
||||||
loading.value = false
|
}))
|
||||||
|
departments.value = departmentPermissionRows.map((row) => ({ ...row }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templatesResult, rolesResult, departmentsResult, usersResult] = await Promise.allSettled(
|
||||||
|
[
|
||||||
|
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
|
||||||
|
getRoles(),
|
||||||
|
getDepartments(),
|
||||||
|
getUsers({ page: 1, pageSize: 200, status: 'all' }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (templatesResult.status === 'rejected') {
|
||||||
|
throw templatesResult.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.value = templatesResult.value.records
|
||||||
|
selectedTemplateId.value = templates.value[0]?.id ?? ''
|
||||||
|
permissionSubjects.value = [
|
||||||
|
...(rolesResult.status === 'fulfilled'
|
||||||
|
? rolesResult.value.map((role) => ({
|
||||||
|
type: 'ROLE' as const,
|
||||||
|
id: role.id,
|
||||||
|
label: role.name,
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
...(departmentsResult.status === 'fulfilled'
|
||||||
|
? departmentsResult.value.map((department) => ({
|
||||||
|
type: 'DEPARTMENT' as const,
|
||||||
|
id: department.id,
|
||||||
|
label: department.name,
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
...(usersResult.status === 'fulfilled'
|
||||||
|
? usersResult.value.records.map((user) => ({
|
||||||
|
type: 'USER' as const,
|
||||||
|
id: user.id,
|
||||||
|
label: `${user.name}${user.employeeNo ? `(${user.employeeNo})` : ''}`,
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
await loadTemplatePermissions()
|
||||||
|
} catch {
|
||||||
|
templates.value = []
|
||||||
|
permissionSubjects.value = []
|
||||||
|
templatePermissions.value = []
|
||||||
|
ElMessage.error('权限配置加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplatePermissions() {
|
||||||
|
if (!selectedTemplateId.value || isPermissionMockEnabled) {
|
||||||
|
templatePermissions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
templatePermissions.value = await getTemplatePermissions(selectedTemplateId.value)
|
||||||
|
} catch {
|
||||||
|
templatePermissions.value = []
|
||||||
|
ElMessage.error('模板权限加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
permissionLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRolePermission(roleId: string, permission: PermissionKey) {
|
function toggleRolePermission(roleId: string, permission: PermissionKey) {
|
||||||
@@ -37,6 +131,47 @@ function adjustDepartmentPermission(row: DepartmentPermissionRow) {
|
|||||||
ElMessage.info(`原型演示:调整${row.department}文档权限`)
|
ElMessage.info(`原型演示:调整${row.department}文档权限`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addTemplatePermission(form: TemplatePermissionForm) {
|
||||||
|
if (!selectedTemplateId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createTemplatePermission(selectedTemplateId.value, form)
|
||||||
|
await loadTemplatePermissions()
|
||||||
|
ElMessage.success('模板权限已添加')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('模板权限添加失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
permissionSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTemplatePermissionRecord(permissionId: string) {
|
||||||
|
if (!selectedTemplateId.value || !window.confirm('确定删除这条模板权限吗?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTemplatePermission(selectedTemplateId.value, permissionId)
|
||||||
|
await loadTemplatePermissions()
|
||||||
|
ElMessage.success('模板权限已删除')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('模板权限删除失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
permissionSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTemplate(templateId: string) {
|
||||||
|
selectedTemplateId.value = templateId
|
||||||
|
void loadTemplatePermissions()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadPermissions)
|
onMounted(loadPermissions)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,7 +196,7 @@ onMounted(loadPermissions)
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else-if="isPermissionMockEnabled">
|
||||||
<section class="page-card">
|
<section class="page-card">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
<h2>按角色授权</h2>
|
<h2>按角色授权</h2>
|
||||||
@@ -81,6 +216,24 @@ onMounted(loadPermissions)
|
|||||||
<DepartmentPermissionTable :rows="departments" @adjust="adjustDepartmentPermission" />
|
<DepartmentPermissionTable :rows="departments" @adjust="adjustDepartmentPermission" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<section v-else class="page-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h2>模板权限</h2>
|
||||||
|
<span>按模板配置角色、科室和用户的可见/可用/可维护权限</span>
|
||||||
|
</div>
|
||||||
|
<TemplatePermissionPanel
|
||||||
|
:templates="templates"
|
||||||
|
:selected-template-id="selectedTemplateId"
|
||||||
|
:permissions="templatePermissions"
|
||||||
|
:subjects="permissionSubjects"
|
||||||
|
:loading="permissionLoading"
|
||||||
|
:saving="permissionSaving"
|
||||||
|
@update:selected-template-id="selectTemplate"
|
||||||
|
@add="addTemplatePermission"
|
||||||
|
@delete="deleteTemplatePermissionRecord"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,16 @@ export interface DepartmentPermissionRow {
|
|||||||
commonDocuments: DepartmentDocumentAccess
|
commonDocuments: DepartmentDocumentAccess
|
||||||
otherDocuments: DepartmentDocumentAccess
|
otherDocuments: DepartmentDocumentAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PermissionSubjectOption {
|
||||||
|
id: string
|
||||||
|
type: 'ROLE' | 'DEPARTMENT' | 'USER'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatePermissionForm {
|
||||||
|
subjectType: PermissionSubjectOption['type']
|
||||||
|
subjectId: string
|
||||||
|
permissionLevel: 'VIEW' | 'USE' | 'MAINTAIN'
|
||||||
|
effect: 'ALLOW' | 'DENY'
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { DocumentTemplate } from '../types'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
template: DocumentTemplate | null
|
template: DocumentTemplate | null
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -49,7 +51,18 @@ const statusLabel = computed(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<article class="preview-paper">
|
<div v-if="loading" class="preview-state">正在加载模板内容…</div>
|
||||||
|
<div v-else-if="error" class="preview-state preview-state--error">
|
||||||
|
模板内容加载失败,请关闭后重试。
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-else-if="template.contentHtml"
|
||||||
|
class="preview-frame"
|
||||||
|
:srcdoc="template.contentHtml"
|
||||||
|
sandbox=""
|
||||||
|
title="文档模板内容预览"
|
||||||
|
/>
|
||||||
|
<article v-else class="preview-paper">
|
||||||
<h3>{{ template.name }}</h3>
|
<h3>{{ template.name }}</h3>
|
||||||
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
|
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -84,9 +97,21 @@ const statusLabel = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="dialog-footer">
|
<footer class="dialog-footer">
|
||||||
<p>占位符由系统在签署时自动填充,当前仅用于原型演示。</p>
|
<p>
|
||||||
|
{{
|
||||||
|
template.contentHtml
|
||||||
|
? '模板内容来自服务端,预览区域已限制脚本执行。'
|
||||||
|
: '占位符由系统在签署时自动填充,当前仅用于原型演示。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
|
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
|
||||||
<button type="button" class="footer-button primary" @click="emit('use', template)">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="footer-button primary"
|
||||||
|
:disabled="template.status !== 'enabled'"
|
||||||
|
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
|
||||||
|
@click="emit('use', template)"
|
||||||
|
>
|
||||||
用此模板发起签署
|
用此模板发起签署
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -194,6 +219,26 @@ const statusLabel = computed(() => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-state {
|
||||||
|
display: grid;
|
||||||
|
min-height: 420px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 13px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state--error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 560px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eef4f6;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-paper {
|
.preview-paper {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
padding: 26px 34px;
|
padding: 26px 34px;
|
||||||
@@ -299,6 +344,14 @@ const statusLabel = computed(() => {
|
|||||||
filter: brightness(0.96);
|
filter: brightness(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-button:disabled {
|
||||||
|
color: #aebbc0;
|
||||||
|
background: #f1f5f6;
|
||||||
|
border-color: #dce5e8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.preview-paper {
|
.preview-paper {
|
||||||
padding: 22px 18px;
|
padding: 22px 18px;
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
|||||||
<div class="template-actions">
|
<div class="template-actions">
|
||||||
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
|
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
|
||||||
<button type="button" class="card-button" @click="emit('edit', template)">编辑</button>
|
<button type="button" class="card-button" @click="emit('edit', template)">编辑</button>
|
||||||
<button type="button" class="card-button primary" @click="emit('initiate', template)">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card-button primary"
|
||||||
|
:disabled="template.status !== 'enabled'"
|
||||||
|
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
|
||||||
|
@click="emit('initiate', template)"
|
||||||
|
>
|
||||||
发起
|
发起
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +213,17 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
|||||||
background: var(--brand-d);
|
background: var(--brand-d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-button:disabled {
|
||||||
|
color: #aebbc0;
|
||||||
|
background: #f1f5f6;
|
||||||
|
border-color: #dce5e8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button.primary:disabled:hover {
|
||||||
|
background: #f1f5f6;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.template-footer {
|
.template-footer {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||||
|
import type { TemplateVersionAction } from '@/api/management/documents'
|
||||||
|
|
||||||
|
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
template: DocumentTemplate | null
|
||||||
|
campuses: CampusRecord[]
|
||||||
|
departments: DepartmentRecord[]
|
||||||
|
saving: boolean
|
||||||
|
workflowSaving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [form: TemplateEditorForm]
|
||||||
|
workflow: [action: TemplateVersionAction, comment?: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive<TemplateEditorForm>(createEmptyForm())
|
||||||
|
const error = reactive({ message: '' })
|
||||||
|
const rejectComment = reactive({ value: '' })
|
||||||
|
|
||||||
|
const isNew = computed(() => !form.id)
|
||||||
|
const availableDepartments = computed(() =>
|
||||||
|
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
||||||
|
)
|
||||||
|
|
||||||
|
const workflowActions = computed(() => {
|
||||||
|
if (!props.template || !form.id) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: Array<{ action: TemplateVersionAction; label: string; tone: string }> = []
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.template.backendStatus === 'DRAFT' ||
|
||||||
|
props.template.backendStatus === 'REJECTED' ||
|
||||||
|
(!props.template.backendStatus && props.template.status === 'draft')
|
||||||
|
) {
|
||||||
|
actions.push({ action: 'submit-review', label: '提交审核', tone: 'primary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.template.backendStatus === 'PENDING_REVIEW' ||
|
||||||
|
(!props.template.backendStatus && props.template.status === 'review')
|
||||||
|
) {
|
||||||
|
actions.push({ action: 'approve', label: '审核通过', tone: 'primary' })
|
||||||
|
actions.push({ action: 'reject', label: '驳回', tone: 'danger' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.template.backendStatus === 'APPROVED') {
|
||||||
|
actions.push({ action: 'publish', label: '发布版本', tone: 'primary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.template.backendStatus === 'PUBLISHED' ||
|
||||||
|
(!props.template.backendStatus && props.template.status === 'enabled')
|
||||||
|
) {
|
||||||
|
actions.push({ action: 'disable', label: '停用版本', tone: 'secondary' })
|
||||||
|
actions.push({ action: 'archive', label: '归档版本', tone: 'secondary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
})
|
||||||
|
|
||||||
|
function createEmptyForm(): TemplateEditorForm {
|
||||||
|
return {
|
||||||
|
templateCode: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
campusId: props.campuses[0]?.id ?? '',
|
||||||
|
departmentId: '',
|
||||||
|
category: '知情同意书',
|
||||||
|
versionNo: '',
|
||||||
|
contentHtml: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (!props.template) {
|
||||||
|
Object.assign(form, createEmptyForm())
|
||||||
|
} else {
|
||||||
|
Object.assign(form, {
|
||||||
|
id: props.template.id,
|
||||||
|
templateCode: props.template.code,
|
||||||
|
name: props.template.name,
|
||||||
|
description: props.template.description,
|
||||||
|
campusId: props.template.campusId ?? props.campuses[0]?.id ?? '',
|
||||||
|
departmentId: props.template.departmentId ?? '',
|
||||||
|
category: props.template.category,
|
||||||
|
versionNo: '',
|
||||||
|
contentHtml: props.template.contentHtml ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
error.message = ''
|
||||||
|
rejectComment.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCampusChange() {
|
||||||
|
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
|
||||||
|
form.departmentId = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
error.message = ''
|
||||||
|
|
||||||
|
if (!form.templateCode.trim()) {
|
||||||
|
error.message = '请输入模板编号'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
error.message = '请输入模板名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.campusId) {
|
||||||
|
error.message = '请选择院区'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.contentHtml.trim()) {
|
||||||
|
error.message = '请输入模板 HTML 内容'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
...form,
|
||||||
|
templateCode: form.templateCode.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
category: form.category.trim() || '知情同意书',
|
||||||
|
versionNo: form.versionNo.trim(),
|
||||||
|
contentHtml: form.contentHtml.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWorkflow(action: TemplateVersionAction) {
|
||||||
|
if (action === 'reject' && !rejectComment.value.trim()) {
|
||||||
|
error.message = '请输入驳回原因'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
error.message = ''
|
||||||
|
emit('workflow', action, rejectComment.value.trim() || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.template,
|
||||||
|
() => {
|
||||||
|
if (props.visible) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section class="editor-dialog" role="dialog" aria-modal="true" aria-labelledby="editor-title">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<strong id="editor-title">{{ isNew ? '新增文档模板' : '编辑模板版本' }}</strong>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
isNew
|
||||||
|
? '创建模板并同时保存首个版本。'
|
||||||
|
: '编辑操作会创建新的文书版本,已发布版本不会被直接覆盖。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="dialog-body" @submit.prevent="submit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>模板编号 <em>*</em></span>
|
||||||
|
<input
|
||||||
|
v-model="form.templateCode"
|
||||||
|
:disabled="!isNew"
|
||||||
|
placeholder="如:TPL-RAD-MRI-001"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>模板名称 <em>*</em></span>
|
||||||
|
<input v-model="form.name" :disabled="!isNew" placeholder="请输入模板名称" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>院区 <em>*</em></span>
|
||||||
|
<select v-model="form.campusId" :disabled="!isNew" @change="handleCampusChange">
|
||||||
|
<option value="" disabled>请选择院区</option>
|
||||||
|
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
|
||||||
|
{{ campus.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>科室</span>
|
||||||
|
<select v-model="form.departmentId" :disabled="!isNew">
|
||||||
|
<option value="">全院通用</option>
|
||||||
|
<option
|
||||||
|
v-for="department in availableDepartments"
|
||||||
|
:key="department.id"
|
||||||
|
:value="department.id"
|
||||||
|
>
|
||||||
|
{{ department.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>文档类别</span>
|
||||||
|
<input v-model="form.category" :disabled="!isNew" placeholder="如:知情同意书" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>版本号</span>
|
||||||
|
<input v-model="form.versionNo" placeholder="如:V1.0,不填由服务端生成" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="full-field">
|
||||||
|
<span>模板说明</span>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
:disabled="!isNew"
|
||||||
|
rows="2"
|
||||||
|
placeholder="请输入模板说明"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="full-field">
|
||||||
|
<span>模板 HTML <em>*</em></span>
|
||||||
|
<textarea
|
||||||
|
v-model="form.contentHtml"
|
||||||
|
rows="11"
|
||||||
|
placeholder="请输入服务端可渲染的 HTML 内容,不要包含脚本。"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||||
|
<p class="form-tip">
|
||||||
|
版本工作流由服务端校验权限和状态;此处不会将本地签名或患者数据写入模板内容。
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<div v-if="workflowActions.length" class="workflow-actions">
|
||||||
|
<span>当前版本:{{ template?.version }}</span>
|
||||||
|
<textarea
|
||||||
|
v-if="workflowActions.some((item) => item.action === 'reject')"
|
||||||
|
v-model="rejectComment.value"
|
||||||
|
rows="1"
|
||||||
|
placeholder="驳回原因(驳回时必填)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-for="item in workflowActions"
|
||||||
|
:key="item.action"
|
||||||
|
type="button"
|
||||||
|
class="footer-button"
|
||||||
|
:class="item.tone"
|
||||||
|
:disabled="saving || workflowSaving"
|
||||||
|
@click="runWorkflow(item.action)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="footer-button secondary" @click="emit('close')">取消</button>
|
||||||
|
<button type="button" class="footer-button primary" :disabled="saving" @click="submit">
|
||||||
|
{{ saving ? '保存中…' : isNew ? '创建模板' : '创建新版本' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 760px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
margin: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label,
|
||||||
|
.full-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-field {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span em {
|
||||||
|
color: var(--err);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px rgb(14 110 140 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
color: var(--mut);
|
||||||
|
background: #f5f8f9;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-actions textarea {
|
||||||
|
width: 170px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-button {
|
||||||
|
padding: 7px 13px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-button.primary {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-button.secondary {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-button.danger {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
border-color: rgb(194 73 73 / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-button:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,22 +2,72 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
|
import {
|
||||||
|
createTemplate,
|
||||||
|
createTemplateVersion,
|
||||||
|
getDocumentDetail,
|
||||||
|
getDocuments,
|
||||||
|
getTemplateVersionDetail,
|
||||||
|
isDocumentMockEnabled,
|
||||||
|
updateTemplateVersionStatus,
|
||||||
|
} from '@/api/management/documents'
|
||||||
|
import type { TemplateVersionAction } from '@/api/management/documents'
|
||||||
|
import type { CampusRecord, DepartmentRecord, DocumentRecord } from '@/api/management/types'
|
||||||
|
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||||
|
import type { SigningTemplate } from '@/api/workbench/types'
|
||||||
|
|
||||||
import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
|
import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
|
||||||
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
|
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
|
||||||
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
|
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
|
||||||
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
|
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
|
||||||
|
import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
|
||||||
import { documentLibrary, documentStatusOptions } from './mock'
|
import { documentLibrary, documentStatusOptions } from './mock'
|
||||||
import type {
|
import type {
|
||||||
DepartmentTab,
|
DepartmentTab,
|
||||||
|
DocumentStatus,
|
||||||
DocumentDepartment,
|
DocumentDepartment,
|
||||||
DocumentFilterForm,
|
DocumentFilterForm,
|
||||||
DocumentTemplate,
|
DocumentTemplate,
|
||||||
StatusTab,
|
StatusTab,
|
||||||
|
TemplateEditorForm,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const library = ref<DocumentDepartment[]>([])
|
const library = ref<DocumentDepartment[]>([])
|
||||||
const selectedTemplate = ref<DocumentTemplate | null>(null)
|
const selectedTemplate = ref<DocumentTemplate | null>(null)
|
||||||
|
const signingTemplate = ref<SigningTemplate | null>(null)
|
||||||
|
const editorTemplate = ref<DocumentTemplate | null>(null)
|
||||||
|
const campuses = ref<CampusRecord[]>([])
|
||||||
|
const departments = ref<DepartmentRecord[]>([])
|
||||||
|
const signingDialogVisible = ref(false)
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editorSaving = ref(false)
|
||||||
|
const workflowSaving = ref(false)
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const previewError = ref(false)
|
||||||
|
let previewRequestId = 0
|
||||||
|
|
||||||
|
const mockCampuses: CampusRecord[] = [
|
||||||
|
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||||
|
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||||||
|
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockDepartments: DepartmentRecord[] = [
|
||||||
|
'放射科',
|
||||||
|
'心内科',
|
||||||
|
'骨科',
|
||||||
|
'健康管理中心',
|
||||||
|
'全院通用',
|
||||||
|
].map((name, index) => ({
|
||||||
|
id: `mock-template-department-${index + 1}`,
|
||||||
|
campusId: index === 3 ? 'mock-east' : 'mock-main',
|
||||||
|
code: `MOCK-TPL-${index + 1}`,
|
||||||
|
name,
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
const filters = reactive<DocumentFilterForm>({
|
const filters = reactive<DocumentFilterForm>({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -71,33 +121,377 @@ const filteredTemplates = computed(() => {
|
|||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await Promise.resolve()
|
try {
|
||||||
library.value = documentLibrary
|
if (isDocumentMockEnabled) {
|
||||||
loading.value = false
|
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
|
||||||
|
departments.value = mockDepartments.map((department) => ({ ...department }))
|
||||||
|
library.value = documentLibrary.map((department) => ({
|
||||||
|
...department,
|
||||||
|
categories: department.categories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
documents: category.documents.map((document) => ({ ...document })),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [documentsResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||||
|
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
|
||||||
|
getCampuses(),
|
||||||
|
getDepartments(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (documentsResult.status === 'rejected') {
|
||||||
|
throw documentsResult.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||||||
|
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||||||
|
library.value = buildDocumentLibrary(
|
||||||
|
documentsResult.value.records,
|
||||||
|
new Map(departments.value.map((department) => [department.id, department.name])),
|
||||||
|
new Map(campuses.value.map((campus) => [campus.id, campus.name])),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
library.value = []
|
||||||
|
ElMessage.error('文档模板加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePreview(template: DocumentTemplate) {
|
function mapDocumentStatus(status: DocumentRecord['status']): DocumentStatus {
|
||||||
|
if (status === 'published') {
|
||||||
|
return 'enabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'archived') {
|
||||||
|
return 'archived'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'review') {
|
||||||
|
return 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'draft'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentLibrary(
|
||||||
|
records: DocumentRecord[],
|
||||||
|
departmentNames = new Map<string, string>(),
|
||||||
|
campusNames = new Map<string, string>(),
|
||||||
|
): DocumentDepartment[] {
|
||||||
|
const departments = new Map<string, DocumentDepartment>()
|
||||||
|
|
||||||
|
records.forEach((record) => {
|
||||||
|
const departmentName =
|
||||||
|
record.departmentName || departmentNames.get(record.departmentId ?? '') || '全院通用'
|
||||||
|
const departmentKey = record.departmentId || departmentName
|
||||||
|
const campusName = record.campusName || campusNames.get(record.campusId ?? '')
|
||||||
|
const department = departments.get(departmentKey) ?? {
|
||||||
|
key: departmentKey,
|
||||||
|
name: departmentName,
|
||||||
|
campus:
|
||||||
|
campusName === '本部院区' || campusName === '东院区' || campusName === '西院区'
|
||||||
|
? campusName
|
||||||
|
: 'all',
|
||||||
|
categories: [],
|
||||||
|
}
|
||||||
|
const category =
|
||||||
|
department.categories.find((item) => item.name === record.category) ??
|
||||||
|
(() => {
|
||||||
|
const nextCategory = { name: record.category, documents: [] }
|
||||||
|
department.categories.push(nextCategory)
|
||||||
|
return nextCategory
|
||||||
|
})()
|
||||||
|
|
||||||
|
category.documents.push({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
code: record.code ?? record.id,
|
||||||
|
version: record.version,
|
||||||
|
status: mapDocumentStatus(record.status),
|
||||||
|
backendStatus: record.backendStatus,
|
||||||
|
description: record.description ?? '',
|
||||||
|
versionId: record.versionId,
|
||||||
|
departmentId: record.departmentId,
|
||||||
|
campusId: record.campusId,
|
||||||
|
contentHtml: record.contentHtml,
|
||||||
|
contentSha256: record.contentSha256,
|
||||||
|
})
|
||||||
|
departments.set(departmentKey, department)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...departments.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview(template: DocumentTemplate) {
|
||||||
selectedTemplate.value = template
|
selectedTemplate.value = template
|
||||||
|
previewError.value = false
|
||||||
|
|
||||||
|
if (isDocumentMockEnabled) {
|
||||||
|
previewLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++previewRequestId
|
||||||
|
previewLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await getDocumentDetail(template.id)
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
throw new Error('模板不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentHtml = detail.contentHtml
|
||||||
|
const versionId = detail.versionId ?? template.versionId
|
||||||
|
|
||||||
|
if (!contentHtml && versionId) {
|
||||||
|
const version = await getTemplateVersionDetail(template.id, versionId)
|
||||||
|
contentHtml = version?.contentHtml ?? version?.content ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestId !== previewRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTemplate.value = {
|
||||||
|
...template,
|
||||||
|
code: detail.code ?? template.code,
|
||||||
|
version: detail.version || template.version,
|
||||||
|
versionId,
|
||||||
|
description: detail.description ?? template.description,
|
||||||
|
contentHtml,
|
||||||
|
contentSha256: detail.contentSha256,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId === previewRequestId) {
|
||||||
|
previewError.value = true
|
||||||
|
ElMessage.error('模板内容加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === previewRequestId) {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePreview() {
|
function closePreview() {
|
||||||
|
previewRequestId += 1
|
||||||
selectedTemplate.value = null
|
selectedTemplate.value = null
|
||||||
|
previewLoading.value = false
|
||||||
|
previewError.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAddMessage() {
|
function showAddMessage() {
|
||||||
ElMessage.info('原型演示:新增文档模板(类 Word 在线编辑器)')
|
editorTemplate.value = null
|
||||||
|
editorVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function showExportMessage() {
|
function showExportMessage() {
|
||||||
ElMessage.info('原型演示:导出模板包(ZIP)')
|
ElMessage.info('原型演示:导出模板包(ZIP)')
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEditMessage(template: DocumentTemplate) {
|
async function showEditMessage(template: DocumentTemplate) {
|
||||||
ElMessage.info('原型演示:编辑模板《' + template.name + '》')
|
editorTemplate.value = template
|
||||||
|
editorVisible.value = true
|
||||||
|
|
||||||
|
if (isDocumentMockEnabled || template.contentHtml) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await getDocumentDetail(template.id)
|
||||||
|
const versionId = detail?.versionId ?? template.versionId
|
||||||
|
let contentHtml = detail?.contentHtml
|
||||||
|
|
||||||
|
if (!contentHtml && versionId) {
|
||||||
|
const version = await getTemplateVersionDetail(template.id, versionId)
|
||||||
|
contentHtml = version?.contentHtml ?? version?.content ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorVisible.value && editorTemplate.value?.id === template.id) {
|
||||||
|
editorTemplate.value = {
|
||||||
|
...template,
|
||||||
|
versionId,
|
||||||
|
description: detail?.description ?? template.description,
|
||||||
|
contentHtml,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.warning('模板内容加载失败,可先关闭后重试')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showInitiateMessage(template: DocumentTemplate) {
|
function closeEditor() {
|
||||||
ElMessage.info('已选择《' + template.name + '》,签署工作台暂未实现')
|
editorVisible.value = false
|
||||||
|
editorTemplate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMockTemplate(form: TemplateEditorForm) {
|
||||||
|
const department = departments.value.find((item) => item.id === form.departmentId)
|
||||||
|
const departmentName = department?.name ?? '全院通用'
|
||||||
|
const departmentKey = department?.id ?? 'mock-general'
|
||||||
|
let departmentGroup = library.value.find((item) => item.key === departmentKey)
|
||||||
|
|
||||||
|
if (!departmentGroup) {
|
||||||
|
departmentGroup = {
|
||||||
|
key: departmentKey,
|
||||||
|
name: departmentName,
|
||||||
|
campus: 'all',
|
||||||
|
categories: [],
|
||||||
|
}
|
||||||
|
library.value.push(departmentGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let category = departmentGroup.categories.find((item) => item.name === form.category)
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
category = { name: form.category, documents: [] }
|
||||||
|
departmentGroup.categories.push(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now()
|
||||||
|
category.documents.push({
|
||||||
|
id: `mock-template-${timestamp}`,
|
||||||
|
name: form.name,
|
||||||
|
code: form.templateCode,
|
||||||
|
version: form.versionNo || 'v1',
|
||||||
|
status: 'draft',
|
||||||
|
description: form.description,
|
||||||
|
versionId: `mock-version-${timestamp}`,
|
||||||
|
departmentId: form.departmentId || null,
|
||||||
|
campusId: form.campusId,
|
||||||
|
contentHtml: form.contentHtml,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate(form: TemplateEditorForm) {
|
||||||
|
editorSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isDocumentMockEnabled) {
|
||||||
|
if (form.id) {
|
||||||
|
library.value.forEach((department) =>
|
||||||
|
department.categories.forEach((category) =>
|
||||||
|
category.documents.forEach((document) => {
|
||||||
|
if (document.id === form.id) {
|
||||||
|
document.version = form.versionNo || document.version
|
||||||
|
document.contentHtml = form.contentHtml
|
||||||
|
document.description = form.description
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
addMockTemplate(form)
|
||||||
|
}
|
||||||
|
} else if (form.id) {
|
||||||
|
await createTemplateVersion(form.id, {
|
||||||
|
versionNo: form.versionNo || undefined,
|
||||||
|
contentHtml: form.contentHtml,
|
||||||
|
})
|
||||||
|
await loadLibrary()
|
||||||
|
} else {
|
||||||
|
const template = await createTemplate({
|
||||||
|
templateCode: form.templateCode,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || undefined,
|
||||||
|
campusId: form.campusId,
|
||||||
|
departmentId: form.departmentId || null,
|
||||||
|
category: form.category || undefined,
|
||||||
|
})
|
||||||
|
await createTemplateVersion(template.id, {
|
||||||
|
versionNo: form.versionNo || undefined,
|
||||||
|
contentHtml: form.contentHtml,
|
||||||
|
})
|
||||||
|
await loadLibrary()
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditor()
|
||||||
|
ElMessage.success(form.id ? '模板新版本已保存' : '模板已创建')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(form.id ? '模板版本保存失败,请稍后重试' : '模板创建失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
editorSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMockTemplateStatus(templateId: string, action: TemplateVersionAction) {
|
||||||
|
const nextStatus =
|
||||||
|
action === 'submit-review' || action === 'reject'
|
||||||
|
? 'review'
|
||||||
|
: action === 'disable' || action === 'archive'
|
||||||
|
? 'archived'
|
||||||
|
: 'enabled'
|
||||||
|
|
||||||
|
library.value.forEach((department) =>
|
||||||
|
department.categories.forEach((category) =>
|
||||||
|
category.documents.forEach((document) => {
|
||||||
|
if (document.id === templateId) {
|
||||||
|
document.status = nextStatus
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWorkflow(action: TemplateVersionAction, comment?: string) {
|
||||||
|
const template = editorTemplate.value
|
||||||
|
const versionId = template?.versionId
|
||||||
|
|
||||||
|
if (!template || (!versionId && !isDocumentMockEnabled)) {
|
||||||
|
ElMessage.error('当前模板没有可操作的版本')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isDocumentMockEnabled) {
|
||||||
|
updateMockTemplateStatus(template.id, action)
|
||||||
|
} else if (versionId) {
|
||||||
|
await updateTemplateVersionStatus(template.id, versionId, action, comment)
|
||||||
|
await loadLibrary()
|
||||||
|
} else {
|
||||||
|
throw new Error('当前模板没有可操作的版本')
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditor()
|
||||||
|
ElMessage.success('模板版本状态已更新')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('模板版本操作失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
workflowSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||||
|
return {
|
||||||
|
id: template.id,
|
||||||
|
versionId: template.versionId ?? template.id,
|
||||||
|
name: template.name,
|
||||||
|
code: template.code,
|
||||||
|
version: template.version,
|
||||||
|
department: template.department,
|
||||||
|
departmentId: template.departmentId,
|
||||||
|
campusId: template.campusId ?? undefined,
|
||||||
|
category: template.category,
|
||||||
|
description: template.description,
|
||||||
|
supportedMethods: ['pad', 'sms'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSigningDialog(template: DocumentTemplate) {
|
||||||
|
closePreview()
|
||||||
|
signingTemplate.value = toSigningTemplate(template)
|
||||||
|
signingDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSigningDialog() {
|
||||||
|
signingDialogVisible.value = false
|
||||||
|
signingTemplate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
@@ -106,8 +500,7 @@ function clearFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useTemplate(template: DocumentTemplate) {
|
function useTemplate(template: DocumentTemplate) {
|
||||||
closePreview()
|
openSigningDialog(template)
|
||||||
ElMessage.info('原型演示:从模板《' + template.name + '》发起签署')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadLibrary)
|
onMounted(loadLibrary)
|
||||||
@@ -146,7 +539,7 @@ onMounted(loadLibrary)
|
|||||||
:template="template"
|
:template="template"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
@edit="showEditMessage"
|
@edit="showEditMessage"
|
||||||
@initiate="showInitiateMessage"
|
@initiate="openSigningDialog"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,9 +559,27 @@ onMounted(loadLibrary)
|
|||||||
<DocumentPreviewDialog
|
<DocumentPreviewDialog
|
||||||
:visible="Boolean(selectedTemplate)"
|
:visible="Boolean(selectedTemplate)"
|
||||||
:template="selectedTemplate"
|
:template="selectedTemplate"
|
||||||
|
:loading="previewLoading"
|
||||||
|
:error="previewError"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
@use="useTemplate"
|
@use="useTemplate"
|
||||||
/>
|
/>
|
||||||
|
<NewSigningTaskDialog
|
||||||
|
:visible="signingDialogVisible"
|
||||||
|
:initial-template="signingTemplate"
|
||||||
|
@close="closeSigningDialog"
|
||||||
|
/>
|
||||||
|
<TemplateEditorDialog
|
||||||
|
:visible="editorVisible"
|
||||||
|
:template="editorTemplate"
|
||||||
|
:campuses="campuses"
|
||||||
|
:departments="departments"
|
||||||
|
:saving="editorSaving"
|
||||||
|
:workflow-saving="workflowSaving"
|
||||||
|
@close="closeEditor"
|
||||||
|
@save="saveTemplate"
|
||||||
|
@workflow="handleWorkflow"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export interface LibraryDocument {
|
|||||||
code: string
|
code: string
|
||||||
version: string
|
version: string
|
||||||
status: DocumentStatus
|
status: DocumentStatus
|
||||||
|
backendStatus?: string
|
||||||
description: string
|
description: string
|
||||||
|
versionId?: string
|
||||||
|
departmentId?: string | null
|
||||||
|
campusId?: string | null
|
||||||
|
contentHtml?: string
|
||||||
|
contentSha256?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentCategory {
|
export interface DocumentCategory {
|
||||||
@@ -31,6 +37,18 @@ export interface DocumentTemplate extends LibraryDocument {
|
|||||||
category: string
|
category: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemplateEditorForm {
|
||||||
|
id?: string
|
||||||
|
templateCode: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
campusId: string
|
||||||
|
departmentId: string
|
||||||
|
category: string
|
||||||
|
versionNo: string
|
||||||
|
contentHtml: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentFilterForm {
|
export interface DocumentFilterForm {
|
||||||
keyword: string
|
keyword: string
|
||||||
department: string | null
|
department: string | null
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
|
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing'
|
||||||
|
import type { SigningTaskRecord, SigningTemplate, WorkbenchCampus } from '@/api/workbench/types'
|
||||||
|
|
||||||
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
||||||
import ReportDetailTable from './components/ReportDetailTable.vue'
|
import ReportDetailTable from './components/ReportDetailTable.vue'
|
||||||
@@ -22,12 +27,16 @@ import type {
|
|||||||
ReportFilterForm,
|
ReportFilterForm,
|
||||||
ReportMetricCard as ReportMetricCardData,
|
ReportMetricCard as ReportMetricCardData,
|
||||||
ReportPeriodOption,
|
ReportPeriodOption,
|
||||||
|
ReportSelectOption,
|
||||||
ReportTask,
|
ReportTask,
|
||||||
|
ReportTaskStatus,
|
||||||
ReportTrendPoint,
|
ReportTrendPoint,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const tasks = ref<ReportTask[]>([])
|
const tasks = ref<ReportTask[]>([])
|
||||||
|
const departmentOptions = ref<ReportSelectOption[]>(reportDepartmentOptions)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const filters = reactive<ReportFilterForm>({
|
const filters = reactive<ReportFilterForm>({
|
||||||
period: '14d',
|
period: '14d',
|
||||||
@@ -145,21 +154,159 @@ function updateCategory(value: ReportDocumentCategory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleExport() {
|
function handleExport() {
|
||||||
ElMessage.info('原型演示:导出 Excel')
|
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
|
||||||
|
const header = ['任务号', '文档', '科室', '院区', '方式', '状态', '发起时间']
|
||||||
|
const rows = filteredTasks.value.map((task) => [
|
||||||
|
task.id,
|
||||||
|
task.documentName,
|
||||||
|
task.department,
|
||||||
|
task.campus,
|
||||||
|
task.method === 'pad' ? '手写板' : '短信',
|
||||||
|
getReportStatusLabel(task.status),
|
||||||
|
formatExportDate(task.createdAt),
|
||||||
|
])
|
||||||
|
const csv = `\uFEFF${[header, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n')}`
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `医签通签署报表-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
link.click()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
ElMessage.success('报表已导出')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleView(row: ReportTask) {
|
function handleView(row: ReportTask) {
|
||||||
ElMessage.info(`原型演示:查看任务${row.id},签署工作台暂未实现`)
|
void router.push({ name: 'workbench-signing', query: { taskId: row.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReportStatusLabel(status: ReportTaskStatus) {
|
||||||
|
const labels: Record<ReportTaskStatus, string> = {
|
||||||
|
signed: '已签署',
|
||||||
|
pending: '待签署',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportDate(value: Date) {
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(
|
||||||
|
value.getHours(),
|
||||||
|
)}:${pad(value.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCategory(
|
||||||
|
category: string,
|
||||||
|
documentName: string,
|
||||||
|
): Exclude<ReportDocumentCategory, 'all'> {
|
||||||
|
const value = `${category} ${documentName}`
|
||||||
|
|
||||||
|
if (value.includes('入院') || value.includes('住院')) {
|
||||||
|
return 'admission'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('风险') || value.includes('告知')) {
|
||||||
|
return 'risk'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'consent'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): ReportTask {
|
||||||
|
const template = templates.find(
|
||||||
|
(item) => item.versionId === task.templateVersionId || item.id === task.documentId,
|
||||||
|
)
|
||||||
|
const createdAt = new Date((task.createdAt ?? task.updatedAt).replace(' ', 'T'))
|
||||||
|
const normalizedCreatedAt = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt
|
||||||
|
const status: ReportTask['status'] =
|
||||||
|
task.status === 'signed'
|
||||||
|
? 'signed'
|
||||||
|
: task.status === 'expired'
|
||||||
|
? 'expired'
|
||||||
|
: task.status === 'void' || task.status === 'failed'
|
||||||
|
? 'void'
|
||||||
|
: 'pending'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
patientName: task.patientName,
|
||||||
|
patientId: task.patientId,
|
||||||
|
documentName: task.documentName,
|
||||||
|
department: task.department,
|
||||||
|
campus: task.campus as Exclude<ReportCampus, 'all'>,
|
||||||
|
category: mapCategory(template?.category ?? '', task.documentName),
|
||||||
|
method: task.method,
|
||||||
|
status,
|
||||||
|
createdAt: normalizedCreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||||
|
return value === '本部院区' || value === '东院区' || value === '西院区'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadReports() {
|
async function loadReports() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await Promise.resolve()
|
|
||||||
tasks.value = reportTasks.map((task) => ({
|
try {
|
||||||
...task,
|
if (isSigningMockEnabled) {
|
||||||
createdAt: new Date(task.createdAt),
|
tasks.value = reportTasks.map((task) => ({
|
||||||
}))
|
...task,
|
||||||
loading.value = false
|
createdAt: new Date(task.createdAt),
|
||||||
|
}))
|
||||||
|
departmentOptions.value = reportDepartmentOptions
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||||
|
getSigningTemplates(),
|
||||||
|
getCampuses(),
|
||||||
|
getDepartments(),
|
||||||
|
])
|
||||||
|
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : []
|
||||||
|
const campusNames: Record<string, WorkbenchCampus> =
|
||||||
|
campusesResult.status === 'fulfilled'
|
||||||
|
? (Object.fromEntries(
|
||||||
|
campusesResult.value
|
||||||
|
.filter((campus) => isWorkbenchCampus(campus.name))
|
||||||
|
.map((campus) => [campus.id, campus.name]),
|
||||||
|
) as Record<string, WorkbenchCampus>)
|
||||||
|
: {}
|
||||||
|
const taskResponse = await getSigningTasks(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
status: 'all',
|
||||||
|
dateRange: 'all',
|
||||||
|
campus: 'all',
|
||||||
|
method: 'all',
|
||||||
|
},
|
||||||
|
{ templates, campusNames },
|
||||||
|
)
|
||||||
|
tasks.value = taskResponse.records.map((task) => mapReportTask(task, templates))
|
||||||
|
|
||||||
|
const departmentNames = new Set<string>(
|
||||||
|
departmentsResult.status === 'fulfilled'
|
||||||
|
? departmentsResult.value.map((department) => department.name)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
tasks.value.forEach((task) => departmentNames.add(task.department))
|
||||||
|
departmentOptions.value = [
|
||||||
|
{ value: 'all', label: '全部科室' },
|
||||||
|
...[...departmentNames]
|
||||||
|
.filter((name) => name && name !== '未指定科室')
|
||||||
|
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||||
|
.map((name) => ({ value: name, label: name })),
|
||||||
|
]
|
||||||
|
} catch {
|
||||||
|
tasks.value = []
|
||||||
|
ElMessage.error('报表数据加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadReports)
|
onMounted(loadReports)
|
||||||
@@ -174,7 +321,7 @@ onMounted(loadReports)
|
|||||||
:category="filters.category"
|
:category="filters.category"
|
||||||
:period-options="reportPeriodOptions"
|
:period-options="reportPeriodOptions"
|
||||||
:campus-options="reportCampusOptions"
|
:campus-options="reportCampusOptions"
|
||||||
:department-options="reportDepartmentOptions"
|
:department-options="departmentOptions"
|
||||||
:category-options="reportCategoryOptions"
|
:category-options="reportCategoryOptions"
|
||||||
@update:period="updatePeriod"
|
@update:period="updatePeriod"
|
||||||
@update:campus="updateCampus"
|
@update:campus="updateCampus"
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||||
|
|
||||||
|
import type { UserEditorForm } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
initial: UserEditorForm | null
|
||||||
|
campuses: CampusRecord[]
|
||||||
|
departments: DepartmentRecord[]
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [form: UserEditorForm]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive<UserEditorForm>(createEmptyForm())
|
||||||
|
const error = reactive({ message: '' })
|
||||||
|
|
||||||
|
const isEditing = computed(() => Boolean(form.id))
|
||||||
|
const availableDepartments = computed(() =>
|
||||||
|
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
||||||
|
)
|
||||||
|
|
||||||
|
function createEmptyForm(): UserEditorForm {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
displayName: '',
|
||||||
|
employeeNo: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
campusId: props.campuses[0]?.id ?? '',
|
||||||
|
departmentId: '',
|
||||||
|
status: 'enabled',
|
||||||
|
dataScope: 'DEPARTMENT',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(form, props.initial ? { ...props.initial } : createEmptyForm())
|
||||||
|
error.message = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCampusChange() {
|
||||||
|
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
|
||||||
|
form.departmentId = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
error.message = ''
|
||||||
|
|
||||||
|
if (!form.displayName.trim()) {
|
||||||
|
error.message = '请输入姓名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing.value && !form.username.trim()) {
|
||||||
|
error.message = '请输入登录账号'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing.value && !form.password) {
|
||||||
|
error.message = '请输入初始密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.campusId) {
|
||||||
|
error.message = '请选择院区'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
...form,
|
||||||
|
username: form.username.trim(),
|
||||||
|
displayName: form.displayName.trim(),
|
||||||
|
employeeNo: form.employeeNo.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initial,
|
||||||
|
() => {
|
||||||
|
if (props.visible) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="user-editor-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
|
||||||
|
<p>{{ isEditing ? '更新用户基础资料与数据范围' : '创建可登录医签通的医护用户' }}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="dialog-body" @submit.prevent="submit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label v-if="!isEditing">
|
||||||
|
<span>登录账号 <em>*</em></span>
|
||||||
|
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
|
||||||
|
</label>
|
||||||
|
<label v-else>
|
||||||
|
<span>登录账号</span>
|
||||||
|
<input :value="form.username" disabled />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>姓名 <em>*</em></span>
|
||||||
|
<input v-model="form.displayName" autocomplete="name" placeholder="请输入姓名" />
|
||||||
|
</label>
|
||||||
|
<label v-if="!isEditing">
|
||||||
|
<span>初始密码 <em>*</em></span>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="请输入初始密码"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>工号</span>
|
||||||
|
<input v-model="form.employeeNo" placeholder="请输入工号" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>手机号</span>
|
||||||
|
<input
|
||||||
|
v-model="form.phone"
|
||||||
|
inputmode="tel"
|
||||||
|
autocomplete="tel"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>邮箱</span>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>院区 <em>*</em></span>
|
||||||
|
<select v-model="form.campusId" @change="handleCampusChange">
|
||||||
|
<option value="" disabled>请选择院区</option>
|
||||||
|
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
|
||||||
|
{{ campus.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>科室</span>
|
||||||
|
<select v-model="form.departmentId">
|
||||||
|
<option value="">未指定科室</option>
|
||||||
|
<option
|
||||||
|
v-for="department in availableDepartments"
|
||||||
|
:key="department.id"
|
||||||
|
:value="department.id"
|
||||||
|
>
|
||||||
|
{{ department.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>状态</span>
|
||||||
|
<select v-model="form.status">
|
||||||
|
<option value="enabled">启用</option>
|
||||||
|
<option value="disabled">停用</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>数据范围</span>
|
||||||
|
<select v-model="form.dataScope">
|
||||||
|
<option value="ALL">全院</option>
|
||||||
|
<option value="CAMPUS">院区</option>
|
||||||
|
<option value="DEPARTMENT">本科室</option>
|
||||||
|
<option value="READ_ONLY_ALL">全院只读</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||||
|
<p class="form-tip">当前接口未提供用户角色绑定字段,角色分配暂保留在角色管理流程。</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="button button--ghost" @click="emit('close')">取消</button>
|
||||||
|
<button type="button" class="button" :disabled="saving" @click="submit">
|
||||||
|
{{ saving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 680px;
|
||||||
|
max-width: 96vw;
|
||||||
|
margin: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header,
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label > span {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid em {
|
||||||
|
color: var(--err);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input,
|
||||||
|
.form-grid select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input:focus,
|
||||||
|
.form-grid select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input:disabled {
|
||||||
|
color: var(--mut);
|
||||||
|
background: #f5f8f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
padding: 9px 10px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #f7fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,21 +2,64 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
|
import { getRoles } from '@/api/management/roles'
|
||||||
|
import { getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
|
||||||
|
import { createUser, updateUser } from '@/api/management/users'
|
||||||
|
import type {
|
||||||
|
CampusRecord,
|
||||||
|
DepartmentRecord,
|
||||||
|
RoleRecord,
|
||||||
|
UserRecord as ApiUserRecord,
|
||||||
|
} from '@/api/management/types'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
||||||
import UserTable from './components/UserTable.vue'
|
import UserTable from './components/UserTable.vue'
|
||||||
|
import UserEditorDialog from './components/UserEditorDialog.vue'
|
||||||
import { roleColors, roleSummaries, users } from './mock'
|
import { roleColors, roleSummaries, users } from './mock'
|
||||||
import type { RoleSummary, UserTableRow } from './types'
|
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const roleRows = ref<RoleSummary[]>([])
|
const roleRows = ref<RoleSummary[]>([])
|
||||||
const userRows = ref<UserTableRow[]>([])
|
const userRows = ref<UserTableRow[]>([])
|
||||||
|
const roleColorMap = ref<Record<string, string>>(roleColors)
|
||||||
|
const campuses = ref<CampusRecord[]>([])
|
||||||
|
const departments = ref<DepartmentRecord[]>([])
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editorInitial = ref<UserEditorForm | null>(null)
|
||||||
|
const editorSaving = ref(false)
|
||||||
|
|
||||||
|
const mockCampuses: CampusRecord[] = [
|
||||||
|
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||||
|
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||||||
|
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockDepartments: DepartmentRecord[] = [
|
||||||
|
'医务处',
|
||||||
|
'放射科',
|
||||||
|
'心内科',
|
||||||
|
'骨科',
|
||||||
|
'健康管理中心',
|
||||||
|
'信息科',
|
||||||
|
'纪检监察室',
|
||||||
|
].map((name, index) => ({
|
||||||
|
id: `mock-department-${index + 1}`,
|
||||||
|
campusId: index === 3 || index === 4 ? 'mock-east' : 'mock-main',
|
||||||
|
code: `MOCK-${index + 1}`,
|
||||||
|
name,
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
const visibleUsers = computed(() => {
|
const visibleUsers = computed(() => {
|
||||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||||
|
|
||||||
return userRows.value.filter((user) => {
|
return userRows.value.filter((user) => {
|
||||||
const matchesCampus = user.campus === '本部院区'
|
const matchesCampus = user.campus === appStore.selectedCampus
|
||||||
const matchesKeyword =
|
const matchesKeyword =
|
||||||
!normalizedKeyword ||
|
!normalizedKeyword ||
|
||||||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
|
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
|
||||||
@@ -27,26 +70,233 @@ const visibleUsers = computed(() => {
|
|||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await Promise.resolve()
|
try {
|
||||||
roleRows.value = roleSummaries.map((role) => ({ ...role }))
|
if (isUserMockEnabled) {
|
||||||
userRows.value = users.map((user) => ({ ...user }))
|
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
|
||||||
loading.value = false
|
departments.value = mockDepartments.map((department) => ({ ...department }))
|
||||||
|
roleRows.value = roleSummaries.map((role) => ({ ...role }))
|
||||||
|
userRows.value = users.map((user) => ({ ...user }))
|
||||||
|
roleColorMap.value = roleColors
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [usersResult, rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||||
|
getUsers({ page: 1, pageSize: 200, status: 'all' }),
|
||||||
|
getRoles(),
|
||||||
|
getCampuses(),
|
||||||
|
getDepartments(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (usersResult.status === 'rejected') {
|
||||||
|
throw usersResult.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
const campusNames = new Map(
|
||||||
|
campusesResult.status === 'fulfilled'
|
||||||
|
? campusesResult.value.map((campus) => [campus.id, campus.name])
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
const departmentNames = new Map(
|
||||||
|
departmentsResult.status === 'fulfilled'
|
||||||
|
? departmentsResult.value.map((department) => [department.id, department.name])
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||||||
|
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||||||
|
const apiUsers = usersResult.value.records
|
||||||
|
|
||||||
|
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
|
||||||
|
|
||||||
|
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
|
||||||
|
roleRows.value = roles.map((role) => mapRoleSummary(role, apiUsers))
|
||||||
|
roleColorMap.value = Object.fromEntries(
|
||||||
|
roles.map((role) => [role.name, role.color ?? '#8a9aa3']),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
roleRows.value = []
|
||||||
|
userRows.value = []
|
||||||
|
ElMessage.error('用户与角色加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserRow(
|
||||||
|
user: ApiUserRecord,
|
||||||
|
campusNames: Map<string, string>,
|
||||||
|
departmentNames: Map<string, string>,
|
||||||
|
): UserTableRow {
|
||||||
|
const dataScopeLabels: Record<string, string> = {
|
||||||
|
ALL: '全院',
|
||||||
|
CAMPUS: '院区',
|
||||||
|
DEPARTMENT: '本科室',
|
||||||
|
READ_ONLY_ALL: '全院只读',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
account: user.account ?? '',
|
||||||
|
employeeNo: user.employeeNo ?? '—',
|
||||||
|
phone: user.phone ?? '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
campus: campusNames.get(user.campusId ?? '') ?? '未指定院区',
|
||||||
|
campusId: user.campusId,
|
||||||
|
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
||||||
|
departmentId: user.departmentId,
|
||||||
|
role: user.role,
|
||||||
|
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
|
||||||
|
status: user.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRoleSummary(role: RoleRecord, users: ApiUserRecord[]): RoleSummary {
|
||||||
|
const userCount =
|
||||||
|
role.userCount ??
|
||||||
|
users.filter((user) => user.roleNames?.some((roleName) => roleName === role.name)).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
color: role.color ?? '#8a9aa3',
|
||||||
|
description: role.description,
|
||||||
|
userCount,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAddUserMessage() {
|
function showAddUserMessage() {
|
||||||
ElMessage.info('原型演示:新增用户')
|
editorInitial.value = null
|
||||||
|
editorVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function showImportMessage() {
|
function showImportMessage() {
|
||||||
ElMessage.info('原型演示:批量导入')
|
ElMessage.info('原型演示:批量导入')
|
||||||
}
|
}
|
||||||
|
|
||||||
function editUser(user: UserTableRow) {
|
async function editUser(row: UserTableRow) {
|
||||||
ElMessage.info(`原型演示:编辑用户《${user.name}》`)
|
try {
|
||||||
|
const user = isUserMockEnabled
|
||||||
|
? userRows.value.find((item) => item.id === row.id)
|
||||||
|
: await getUserDetail(row.id)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
ElMessage.error('用户不存在或已被删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editorInitial.value = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.account ?? '',
|
||||||
|
password: '',
|
||||||
|
displayName: user.name,
|
||||||
|
employeeNo: user.employeeNo ?? '',
|
||||||
|
phone: user.phone ?? '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
campusId:
|
||||||
|
user.campusId ?? campuses.value.find((campus) => campus.name === row.campus)?.id ?? '',
|
||||||
|
departmentId:
|
||||||
|
user.departmentId ??
|
||||||
|
departments.value.find((department) => department.name === row.department)?.id ??
|
||||||
|
'',
|
||||||
|
status: user.status,
|
||||||
|
dataScope: toBackendDataScope(user.dataScope),
|
||||||
|
}
|
||||||
|
editorVisible.value = true
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('用户详情加载失败,请稍后重试')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPassword(user: UserTableRow) {
|
function resetPassword(user: UserTableRow) {
|
||||||
ElMessage.success(`已重置${user.name}的密码`)
|
ElMessage.info(
|
||||||
|
isUserMockEnabled
|
||||||
|
? `原型演示:重置${user.name}的密码`
|
||||||
|
: '当前接口未提供重置密码能力,请由认证系统或管理员流程处理',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendDataScope(value: string | undefined) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
全院: 'ALL',
|
||||||
|
院区: 'CAMPUS',
|
||||||
|
本科室: 'DEPARTMENT',
|
||||||
|
全院只读: 'READ_ONLY_ALL',
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[value ?? ''] ?? value ?? 'DEPARTMENT'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCampusName(id: string) {
|
||||||
|
return campuses.value.find((campus) => campus.id === id)?.name ?? '未指定院区'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDepartmentName(id: string) {
|
||||||
|
return departments.value.find((department) => department.id === id)?.name ?? '未指定科室'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser(form: UserEditorForm) {
|
||||||
|
editorSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isUserMockEnabled) {
|
||||||
|
const row: UserTableRow = {
|
||||||
|
id: form.id ?? `mock-user-${Date.now()}`,
|
||||||
|
name: form.displayName,
|
||||||
|
account: form.username,
|
||||||
|
employeeNo: form.employeeNo || '—',
|
||||||
|
phone: form.phone,
|
||||||
|
email: form.email,
|
||||||
|
campus: getCampusName(form.campusId),
|
||||||
|
campusId: form.campusId,
|
||||||
|
department: getDepartmentName(form.departmentId),
|
||||||
|
departmentId: form.departmentId || null,
|
||||||
|
role: form.id
|
||||||
|
? (userRows.value.find((item) => item.id === form.id)?.role ?? '未分配角色')
|
||||||
|
: '未分配角色',
|
||||||
|
dataScope: form.dataScope,
|
||||||
|
status: form.status,
|
||||||
|
}
|
||||||
|
const index = userRows.value.findIndex((item) => item.id === row.id)
|
||||||
|
if (index === -1) {
|
||||||
|
userRows.value.unshift(row)
|
||||||
|
} else {
|
||||||
|
userRows.value[index] = row
|
||||||
|
}
|
||||||
|
} else if (form.id) {
|
||||||
|
await updateUser(form.id, {
|
||||||
|
displayName: form.displayName,
|
||||||
|
employeeNo: form.employeeNo || undefined,
|
||||||
|
phone: form.phone || undefined,
|
||||||
|
email: form.email || undefined,
|
||||||
|
campusId: form.campusId,
|
||||||
|
departmentId: form.departmentId || null,
|
||||||
|
status: form.status.toUpperCase(),
|
||||||
|
dataScope: form.dataScope,
|
||||||
|
})
|
||||||
|
await loadUsers()
|
||||||
|
} else {
|
||||||
|
await createUser({
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
displayName: form.displayName,
|
||||||
|
employeeNo: form.employeeNo || undefined,
|
||||||
|
phone: form.phone || undefined,
|
||||||
|
email: form.email || undefined,
|
||||||
|
campusId: form.campusId,
|
||||||
|
departmentId: form.departmentId || null,
|
||||||
|
status: form.status.toUpperCase(),
|
||||||
|
dataScope: form.dataScope,
|
||||||
|
})
|
||||||
|
await loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
editorVisible.value = false
|
||||||
|
ElMessage.success(form.id ? '用户已更新' : '用户已创建')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(form.id ? '用户更新失败,请稍后重试' : '用户创建失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
editorSaving.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
@@ -77,12 +327,22 @@ onMounted(loadUsers)
|
|||||||
</div>
|
</div>
|
||||||
<UserTable
|
<UserTable
|
||||||
:rows="visibleUsers"
|
:rows="visibleUsers"
|
||||||
:role-colors="roleColors"
|
:role-colors="roleColorMap"
|
||||||
@edit="editUser"
|
@edit="editUser"
|
||||||
@reset-password="resetPassword"
|
@reset-password="resetPassword"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<UserEditorDialog
|
||||||
|
:visible="editorVisible"
|
||||||
|
:initial="editorInitial"
|
||||||
|
:campuses="campuses"
|
||||||
|
:departments="departments"
|
||||||
|
:saving="editorSaving"
|
||||||
|
@close="editorVisible = false"
|
||||||
|
@save="saveUser"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,29 @@ export interface RoleSummary {
|
|||||||
export interface UserTableRow {
|
export interface UserTableRow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
account?: string
|
||||||
employeeNo: string
|
employeeNo: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
campus: string
|
campus: string
|
||||||
|
campusId?: string | null
|
||||||
department: string
|
department: string
|
||||||
|
departmentId?: string | null
|
||||||
role: string
|
role: string
|
||||||
dataScope: string
|
dataScope: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserEditorForm {
|
||||||
|
id?: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
employeeNo: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
campusId: string
|
||||||
|
departmentId: string
|
||||||
|
status: UserStatus
|
||||||
|
dataScope: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,11 +27,21 @@ const loading = ref(true)
|
|||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
const overview = ref<WorkbenchOverviewResponse | null>(null)
|
const overview = ref<WorkbenchOverviewResponse | null>(null)
|
||||||
const rankingPeriod = ref<HomeRankingPeriod>(14)
|
const rankingPeriod = ref<HomeRankingPeriod>(14)
|
||||||
|
let overviewRequestId = 0
|
||||||
|
|
||||||
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
||||||
|
|
||||||
const metrics = computed<HomeMetricCardData[]>(() => {
|
const metrics = computed<HomeMetricCardData[]>(() => {
|
||||||
const summary = overview.value?.summary
|
const summary = overview.value?.summary
|
||||||
|
const signedChange = summary?.todaySignedChange ?? 0
|
||||||
|
const signedChangeTone: HomeMetricCardData['detailTone'] =
|
||||||
|
signedChange > 0 ? 'up' : signedChange < 0 ? 'down' : 'muted'
|
||||||
|
const signedChangeDetail =
|
||||||
|
signedChange > 0
|
||||||
|
? `▲ 较昨日 +${signedChange} · 点击查看`
|
||||||
|
: signedChange < 0
|
||||||
|
? `▼ 较昨日 ${signedChange} · 点击查看`
|
||||||
|
: '较昨日持平 · 点击查看'
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -39,8 +49,8 @@ const metrics = computed<HomeMetricCardData[]>(() => {
|
|||||||
label: '今日已签署(单)',
|
label: '今日已签署(单)',
|
||||||
value: summary?.todaySigned ?? 0,
|
value: summary?.todaySigned ?? 0,
|
||||||
tone: 'success',
|
tone: 'success',
|
||||||
detail: '▲ 较昨日 +2 · 点击查看',
|
detail: signedChangeDetail,
|
||||||
detailTone: 'up',
|
detailTone: signedChangeTone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pending',
|
key: 'pending',
|
||||||
@@ -74,26 +84,42 @@ const todoItems = computed<HomeTodoItem[]>(() => overview.value?.todos ?? [])
|
|||||||
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
|
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
|
const requestId = ++overviewRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
overview.value = await getWorkbenchOverview({
|
const nextOverview = await getWorkbenchOverview({
|
||||||
campus: selectedCampus.value,
|
campus: selectedCampus.value,
|
||||||
rankingPeriod: rankingPeriod.value,
|
rankingPeriod: rankingPeriod.value,
|
||||||
})
|
})
|
||||||
|
if (requestId !== overviewRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.value = nextOverview
|
||||||
} catch {
|
} catch {
|
||||||
|
if (requestId !== overviewRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.value = null
|
||||||
loadFailed.value = true
|
loadFailed.value = true
|
||||||
ElMessage.error('首页数据加载失败,请稍后重试')
|
ElMessage.error('首页数据加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (requestId === overviewRequestId) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToSigning(query: Record<string, string> = {}) {
|
function goToSigning(query: Record<string, string> = {}) {
|
||||||
void router.push({
|
void router.push({
|
||||||
name: 'workbench-signing',
|
name: 'workbench-signing',
|
||||||
query,
|
query: {
|
||||||
|
campus: selectedCampus.value,
|
||||||
|
...query,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SignatureCanvas from './SignatureCanvas.vue'
|
||||||
|
|
||||||
|
type OnlineStage = 'message' | 'signing'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
resend: []
|
||||||
|
complete: [signatureDataUrl: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const stage = ref<OnlineStage>('message')
|
||||||
|
const signatureDataUrl = ref<string | null>(null)
|
||||||
|
const signatureCanvas = ref<{ clear: () => void } | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
stage.value = 'message'
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
stage.value = 'signing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete() {
|
||||||
|
if (signatureDataUrl.value) {
|
||||||
|
emit('complete', signatureDataUrl.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSignature() {
|
||||||
|
signatureCanvas.value?.clear()
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card online-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="online-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="online-title">线上签署 · 患者手机模拟</strong>
|
||||||
|
<span>{{ task.patientName }} · {{ task.documentName }}</span>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="phone-frame">
|
||||||
|
<div class="phone-screen">
|
||||||
|
<div class="phone-status"><span>9:41</span><span>5G ▮▮▮</span></div>
|
||||||
|
<div class="phone-header">{{ stage === 'message' ? '短信' : '患者签署' }}</div>
|
||||||
|
<div class="phone-body">
|
||||||
|
<template v-if="stage === 'message'">
|
||||||
|
<div class="message-bubble">
|
||||||
|
<strong>【xx医院】</strong>
|
||||||
|
<p>
|
||||||
|
尊敬的{{ task.patientName }},您有一份《{{
|
||||||
|
task.documentName
|
||||||
|
}}》待签署,请点击下方链接完成签署,链接 48 小时内有效。
|
||||||
|
</p>
|
||||||
|
<span class="sign-link">https://s.hosp.cn/s/{{ task.id }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="mobile-document">
|
||||||
|
<strong>{{ task.documentName }}</strong>
|
||||||
|
<span>{{ task.patientName }} · {{ task.patientId }}</span>
|
||||||
|
<p>
|
||||||
|
本人已充分理解本检查/治疗的医疗风险、替代方案及可能的不良后果,自愿接受并签署确认。签名图片与文档原件一并存档。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-signature">
|
||||||
|
<span>请手写签名:</span>
|
||||||
|
<SignatureCanvas
|
||||||
|
ref="signatureCanvas"
|
||||||
|
:height="140"
|
||||||
|
placeholder="请在此签名"
|
||||||
|
@update:data-url="signatureDataUrl = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="stage === 'signing'" class="phone-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="phone-button phone-button--light"
|
||||||
|
@click="clearSignature"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button type="button" class="phone-button" @click="complete">确认签署</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="phone-footer phone-footer--hint">↑ 患者收到短信</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span>模拟患者收到短信 → 打开链接 → 查看文档 → 手写签署的全过程</span>
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="emit('resend')">
|
||||||
|
重发短信
|
||||||
|
</button>
|
||||||
|
<button v-if="stage === 'message'" type="button" class="action-button" @click="openLink">
|
||||||
|
模拟打开链接
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 680px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-dialog {
|
||||||
|
width: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 15px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-frame {
|
||||||
|
width: 330px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--side);
|
||||||
|
border-radius: 34px;
|
||||||
|
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen {
|
||||||
|
display: flex;
|
||||||
|
height: 540px;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f4f7f9;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble,
|
||||||
|
.mobile-document,
|
||||||
|
.mobile-signature {
|
||||||
|
padding: 11px 13px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble p {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-link {
|
||||||
|
display: block;
|
||||||
|
color: var(--brand);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document strong,
|
||||||
|
.mobile-document span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document strong {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document span {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-signature {
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-signature > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-signature :deep(.signature-canvas) {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-footer--hint {
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-button--light {
|
||||||
|
color: var(--mut);
|
||||||
|
background: #eef2f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
height?: number
|
||||||
|
placeholder?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
height: 300,
|
||||||
|
placeholder: '请 在 此 签 名',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:dataUrl': [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const hasInk = ref(false)
|
||||||
|
|
||||||
|
let context: CanvasRenderingContext2D | null = null
|
||||||
|
let pixelRatio = 1
|
||||||
|
let drawing = false
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function setupCanvas() {
|
||||||
|
const element = canvas.value
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
pixelRatio = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
element.width = Math.max(Math.round(rect.width * pixelRatio), 1)
|
||||||
|
element.height = Math.max(Math.round(props.height * pixelRatio), 1)
|
||||||
|
|
||||||
|
context = element.getContext('2d')
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.scale(pixelRatio, pixelRatio)
|
||||||
|
context.lineWidth = 4.2
|
||||||
|
context.lineCap = 'round'
|
||||||
|
context.lineJoin = 'round'
|
||||||
|
context.strokeStyle = '#123a5a'
|
||||||
|
hasInk.value = false
|
||||||
|
emit('update:dataUrl', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPoint(event: PointerEvent) {
|
||||||
|
const element = canvas.value
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrawing(event: PointerEvent) {
|
||||||
|
if (!context || !canvas.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = getPoint(event)
|
||||||
|
drawing = true
|
||||||
|
hasInk.value = true
|
||||||
|
context.beginPath()
|
||||||
|
context.moveTo(point.x, point.y)
|
||||||
|
canvas.value.setPointerCapture(event.pointerId)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(event: PointerEvent) {
|
||||||
|
if (!drawing || !context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = getPoint(event)
|
||||||
|
context.lineTo(point.x, point.y)
|
||||||
|
context.stroke()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrawing() {
|
||||||
|
if (!drawing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drawing = false
|
||||||
|
emit('update:dataUrl', getDataUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
const element = canvas.value
|
||||||
|
|
||||||
|
if (!element || !context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, element.width / pixelRatio, element.height / pixelRatio)
|
||||||
|
hasInk.value = false
|
||||||
|
emit('update:dataUrl', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataUrl() {
|
||||||
|
return canvas.value?.toDataURL('image/png') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
clear,
|
||||||
|
getDataUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
setupCanvas()
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && canvas.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!hasInk.value) {
|
||||||
|
setupCanvas()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resizeObserver.observe(canvas.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.height,
|
||||||
|
() => {
|
||||||
|
void nextTick(setupCanvas)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="signature-canvas" :style="{ height: `${height}px` }">
|
||||||
|
<canvas
|
||||||
|
ref="canvas"
|
||||||
|
@pointercancel="stopDrawing"
|
||||||
|
@pointerdown="startDrawing"
|
||||||
|
@pointerleave="stopDrawing"
|
||||||
|
@pointermove="draw"
|
||||||
|
@pointerup="stopDrawing"
|
||||||
|
/>
|
||||||
|
<span v-if="!hasInk" class="signature-placeholder">{{ placeholder }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signature-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-canvas canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
color: #c9d8de;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SignatureCanvas from './SignatureCanvas.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [signatureDataUrl: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const signatureDataUrl = ref<string | null>(null)
|
||||||
|
const signatureCanvas = ref<{ clear: () => void } | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
await nextTick()
|
||||||
|
signatureCanvas.value?.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearSignature() {
|
||||||
|
signatureCanvas.value?.clear()
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSignature() {
|
||||||
|
if (!signatureDataUrl.value) {
|
||||||
|
ElMessage.warning('请先完成患者签名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('confirm', signatureDataUrl.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card signature-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="pad-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="pad-title">手写板签署</strong>
|
||||||
|
<span>{{ task.patientName }} · {{ task.documentName }}</span>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<p class="dialog-hint">
|
||||||
|
请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):
|
||||||
|
</p>
|
||||||
|
<SignatureCanvas
|
||||||
|
ref="signatureCanvas"
|
||||||
|
:height="300"
|
||||||
|
@update:data-url="signatureDataUrl = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span>签名将实时合成到文档签名域</span>
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
|
||||||
|
清空重写
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-button" @click="confirmSignature">确认签署</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 680px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-dialog {
|
||||||
|
width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 12px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningArtifact } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
artifacts: SigningArtifact[]
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
download: [artifact: SigningArtifact]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (!size) {
|
||||||
|
return '大小未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="artifact-card" aria-labelledby="artifact-title">
|
||||||
|
<div class="artifact-heading">
|
||||||
|
<div>
|
||||||
|
<h3 id="artifact-title">签署文件</h3>
|
||||||
|
<p>原始文档、签名原图和签署后文档均由服务端留存。</p>
|
||||||
|
</div>
|
||||||
|
<span class="artifact-count">{{ artifacts.length }} 个文件</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="artifact-loading">正在加载文件索引…</div>
|
||||||
|
<div v-else-if="error" class="artifact-empty">文件索引加载失败,请稍后重试。</div>
|
||||||
|
<div v-else-if="!artifacts.length" class="artifact-empty">当前任务暂无可下载的签署文件。</div>
|
||||||
|
<ul v-else class="artifact-list">
|
||||||
|
<li v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
|
||||||
|
<div class="artifact-icon" aria-hidden="true">
|
||||||
|
{{ artifact.mimeType === 'application/pdf' ? 'PDF' : '图' }}
|
||||||
|
</div>
|
||||||
|
<div class="artifact-info">
|
||||||
|
<strong>{{ artifact.label }}</strong>
|
||||||
|
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }}</span>
|
||||||
|
<small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="artifact-download" @click="emit('download', artifact)">
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.artifact-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-top: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-heading p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-loading,
|
||||||
|
.artifact-empty {
|
||||||
|
padding: 14px 0 2px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border: 1px solid #edf3f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 7px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-info {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-info strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-info span,
|
||||||
|
.artifact-info small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-info small {
|
||||||
|
color: #9aabb3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-download {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-download:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
task: SigningTaskRecord
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="paper-preview">
|
||||||
|
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
|
||||||
|
<h2>{{ task.documentName }}</h2>
|
||||||
|
<p class="paper-number">编号:{{ task.id }} · 就诊号:{{ task.visitNo }}</p>
|
||||||
|
|
||||||
|
<p class="paper-meta">
|
||||||
|
患者:<strong>{{ task.patientName }}</strong
|
||||||
|
>({{ task.sex }},{{ task.age }}岁) · 患者 ID:{{ task.patientId }}
|
||||||
|
</p>
|
||||||
|
<p class="paper-meta">
|
||||||
|
就诊信息:{{ task.visitType }} · {{ task.department }} · {{ task.visitDate }}
|
||||||
|
<span v-if="task.visitType === '住院'" class="current-badge">在院</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
医务人员已就本项检查/治疗的适应证、禁忌证、医疗风险、替代方案及可能出现的不良后果向本人(或授权委托人)充分说明,本人已完全理解上述内容。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
本人自愿接受上述检查/治疗,并同意在相关医疗文书中签署确认。所签署文件将作为医疗过程留痕依据,签署过程采用电子方式完成,签名图片与文档原件一并存档。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="notice-block">
|
||||||
|
<strong>签署须知:</strong>
|
||||||
|
请仔细阅读全部内容后签名;签名图片将合成至本文档签名域;文档与签名原图双留存,可随时查验;如对内容有疑问,请先向医务人员咨询。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-grid">
|
||||||
|
<div class="signature-field">
|
||||||
|
<span>患者 / 授权委托人签名:</span>
|
||||||
|
<div class="signature-box">
|
||||||
|
<img v-if="task.signatureDataUrl" :src="task.signatureDataUrl" alt="患者签名" />
|
||||||
|
<span v-else>{{ task.status === 'void' ? '—' : '未签署' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-field">
|
||||||
|
<span>日期:</span>
|
||||||
|
<div class="signature-box signature-box--date">
|
||||||
|
<span>{{ task.signedAt ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-field">
|
||||||
|
<span>经治医师:</span>
|
||||||
|
<div class="signature-box signature-box--doctor">
|
||||||
|
<span>刘明辉</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.paper-preview {
|
||||||
|
position: relative;
|
||||||
|
min-height: 560px;
|
||||||
|
padding: 34px 44px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.9;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-preview h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-number {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-preview p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-preview .paper-meta {
|
||||||
|
color: var(--mut);
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-meta strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--err-l);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-block {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fafcfd;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-block strong {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-field > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box {
|
||||||
|
display: flex;
|
||||||
|
min-width: 200px;
|
||||||
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box img {
|
||||||
|
max-width: 190px;
|
||||||
|
max-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box--date {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box--doctor {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 34px;
|
||||||
|
right: 44px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: rgb(208 83 79 / 70%);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
border: 2.5px solid rgb(208 83 79 / 55%);
|
||||||
|
border-radius: 8px;
|
||||||
|
transform: rotate(-14deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.paper-preview {
|
||||||
|
padding: 26px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-stamp {
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { WorkbenchCampus, VisitType } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import type { SigningFilterForm, SigningFilterOption } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
filter: SigningFilterForm
|
||||||
|
campuses: SigningFilterOption<WorkbenchCampus | 'all'>[]
|
||||||
|
statuses: SigningFilterOption<SigningFilterForm['status']>[]
|
||||||
|
methods: SigningFilterOption<SigningFilterForm['method']>[]
|
||||||
|
departments: SigningFilterOption[]
|
||||||
|
documents: SigningFilterOption[]
|
||||||
|
visitTypes: SigningFilterOption<VisitType | 'all'>[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [filter: SigningFilterForm]
|
||||||
|
add: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const draft = reactive<SigningFilterForm>({ ...props.filter })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.filter,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(draft, value)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit('search', { ...draft })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="filter-card" @submit.prevent="submit">
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>日期</span>
|
||||||
|
<select v-model="draft.dateRange" aria-label="筛选日期">
|
||||||
|
<option value="today">今日</option>
|
||||||
|
<option value="yesterday">昨日</option>
|
||||||
|
<option value="3d">近3天</option>
|
||||||
|
<option value="7d">近7天</option>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>状态</span>
|
||||||
|
<select v-model="draft.status" aria-label="筛选状态">
|
||||||
|
<option v-for="option in statuses" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>院区</span>
|
||||||
|
<select v-model="draft.campus" aria-label="筛选院区">
|
||||||
|
<option v-for="option in campuses" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>方式</span>
|
||||||
|
<select v-model="draft.method" aria-label="筛选签署方式">
|
||||||
|
<option v-for="option in methods" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>科室</span>
|
||||||
|
<select v-model="draft.department" aria-label="筛选科室">
|
||||||
|
<option v-for="option in departments" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>就诊类型</span>
|
||||||
|
<select v-model="draft.visitType" aria-label="筛选就诊类型">
|
||||||
|
<option v-for="option in visitTypes" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field filter-field--document">
|
||||||
|
<span>文档</span>
|
||||||
|
<select v-model="draft.documentId" aria-label="筛选文档">
|
||||||
|
<option v-for="option in documents" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field filter-field--keyword">
|
||||||
|
<span>关键词</span>
|
||||||
|
<input v-model="draft.keyword" placeholder="患者姓名 / ID / 文档名" aria-label="关键词" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="filter-spacer" />
|
||||||
|
<button type="submit" class="action-button action-button--ghost">查询</button>
|
||||||
|
<button type="button" class="action-button" @click="emit('add')">+ 新增签署任务</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 104px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select,
|
||||||
|
.filter-field input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 82px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select:focus,
|
||||||
|
.filter-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field--document select {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field--keyword input {
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||||||
|
import SigningArtifactList from './SigningArtifactList.vue'
|
||||||
|
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
||||||
|
import type { SigningTaskAction } from '../types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
loading: boolean
|
||||||
|
auditEvents: SigningTaskEvent[]
|
||||||
|
auditLoading: boolean
|
||||||
|
auditError: boolean
|
||||||
|
allowLocalSigning: boolean
|
||||||
|
artifacts: SigningArtifact[]
|
||||||
|
artifactsLoading: boolean
|
||||||
|
artifactsError: boolean
|
||||||
|
padSessionStatus: string | null
|
||||||
|
padSessionLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
action: [action: SigningTaskAction]
|
||||||
|
downloadArtifact: [artifact: SigningArtifact]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="detail-panel">
|
||||||
|
<div v-if="loading" class="detail-loading" aria-label="正在加载任务详情">
|
||||||
|
<span class="detail-loading__actions" />
|
||||||
|
<span class="detail-loading__paper" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="task">
|
||||||
|
<div class="task-actions">
|
||||||
|
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
|
||||||
|
|
||||||
|
<template v-if="task.status === 'pending' && task.method === 'pad'">
|
||||||
|
<template v-if="allowLocalSigning">
|
||||||
|
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
|
||||||
|
✍ 手写板签署
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'switch-method')"
|
||||||
|
>
|
||||||
|
转为短信发送
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button"
|
||||||
|
:disabled="padSessionLoading"
|
||||||
|
@click="emit('action', 'create-pad-session')"
|
||||||
|
>
|
||||||
|
{{ padSessionLoading ? '创建会话中…' : '创建手写板会话' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="padSessionStatus" class="action-hint">
|
||||||
|
会话状态:{{ padSessionStatus }};等待设备适配器读取 challenge
|
||||||
|
</span>
|
||||||
|
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
|
||||||
|
<button
|
||||||
|
v-if="allowLocalSigning"
|
||||||
|
type="button"
|
||||||
|
class="action-button"
|
||||||
|
@click="emit('action', 'online-sign')"
|
||||||
|
>
|
||||||
|
📱 查看线上签署
|
||||||
|
</button>
|
||||||
|
<span v-else class="action-hint">线上签署页面接口待接入</span>
|
||||||
|
<button
|
||||||
|
v-if="allowLocalSigning"
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'switch-method')"
|
||||||
|
>
|
||||||
|
转手写板签署
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'resend-sms')"
|
||||||
|
>
|
||||||
|
重发短信
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="task.status === 'expired'">
|
||||||
|
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
||||||
|
↻ 重新发起
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="task.status === 'signed'">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'download-pdf')"
|
||||||
|
>
|
||||||
|
下载签署后 PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'download-signature')"
|
||||||
|
>
|
||||||
|
签名原图
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'print')"
|
||||||
|
>
|
||||||
|
打印
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="
|
||||||
|
task.status === 'pending' ||
|
||||||
|
task.status === 'expired' ||
|
||||||
|
task.status === 'signing' ||
|
||||||
|
task.status === 'failed'
|
||||||
|
"
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--danger"
|
||||||
|
@click="emit('action', 'void')"
|
||||||
|
>
|
||||||
|
作废
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningDocumentPreview :task="task" />
|
||||||
|
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
|
||||||
|
<SigningArtifactList
|
||||||
|
:artifacts="artifacts"
|
||||||
|
:loading="artifactsLoading"
|
||||||
|
:error="artifactsError"
|
||||||
|
@download="emit('downloadArtifact', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="detail-empty">
|
||||||
|
<span aria-hidden="true">📋</span>
|
||||||
|
<p>请从左侧列表选择一个签署任务</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-action-meta {
|
||||||
|
align-self: center;
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hint {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f7fafb;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger {
|
||||||
|
color: var(--err);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger:hover {
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 560px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty span {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 34px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading {
|
||||||
|
display: flex;
|
||||||
|
min-height: 680px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading span {
|
||||||
|
display: block;
|
||||||
|
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
animation: skeleton-shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading__actions {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading__paper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.task-action-meta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tasks: SigningTaskRecord[]
|
||||||
|
selectedId: string | null
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: string]
|
||||||
|
pageChange: [page: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize), 1))
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskRecord['status'], string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
failed: '处理失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(task: SigningTaskRecord) {
|
||||||
|
if (task.status === 'pending') {
|
||||||
|
return task.method === 'pad' ? 'task-status--pad' : 'task-status--sms'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `task-status--${task.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethodLabel(task: SigningTaskRecord) {
|
||||||
|
return task.method === 'pad' ? '✍ 手写板' : '📱 短信'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||||
|
return type === '住院'
|
||||||
|
? 'visit-tag--hospital'
|
||||||
|
: type === '体检'
|
||||||
|
? 'visit-tag--check'
|
||||||
|
: 'visit-tag--outpatient'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="task-list-panel">
|
||||||
|
<div class="task-list-heading">
|
||||||
|
<strong>签署任务列表</strong>
|
||||||
|
<span>共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="task-items task-items--loading" aria-label="正在加载任务">
|
||||||
|
<div v-for="index in 4" :key="index" class="task-skeleton">
|
||||||
|
<span class="task-skeleton__title" />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span class="task-skeleton__short" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tasks.length" class="task-items">
|
||||||
|
<button
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
type="button"
|
||||||
|
class="task-item"
|
||||||
|
:class="{ 'task-item--active': selectedId === task.id }"
|
||||||
|
@click="emit('select', task.id)"
|
||||||
|
>
|
||||||
|
<span class="task-row">
|
||||||
|
<strong>{{ task.patientName }}</strong>
|
||||||
|
<span class="task-basic">{{ task.sex }} · {{ task.age }}岁</span>
|
||||||
|
<span class="task-spacer" />
|
||||||
|
<span class="task-status" :class="getStatusClass(task)">
|
||||||
|
{{
|
||||||
|
task.status === 'pending'
|
||||||
|
? task.method === 'pad'
|
||||||
|
? '待签署·手写板'
|
||||||
|
: '待签署·短信'
|
||||||
|
: statusLabels[task.status]
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="task-row task-row--document">📄 {{ task.documentName }}</span>
|
||||||
|
<span class="task-row task-row--meta">
|
||||||
|
<span>{{ task.patientId }}</span>
|
||||||
|
<span>{{ task.updatedAt }}</span>
|
||||||
|
<span>{{ getMethodLabel(task) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="task-row task-row--visit">
|
||||||
|
<span class="visit-tag" :class="getVisitClass(task.visitType)">{{ task.visitType }}</span>
|
||||||
|
<span>{{ task.department }}</span>
|
||||||
|
<span>({{ task.visitNo }})</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="task-empty">
|
||||||
|
<span aria-hidden="true">🔍</span>
|
||||||
|
<p>没有符合条件的任务</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="total > pageSize" class="task-pagination">
|
||||||
|
<span>第 {{ page }} / {{ pageCount }} 页</span>
|
||||||
|
<span class="task-pagination__total">共 {{ total }} 条</span>
|
||||||
|
<button type="button" :disabled="page <= 1 || loading" @click="emit('pageChange', page - 1)">
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="page >= pageCount || loading"
|
||||||
|
@click="emit('pageChange', page + 1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-list-panel {
|
||||||
|
width: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-heading strong {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-heading span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items {
|
||||||
|
max-height: calc(100vh - 270px);
|
||||||
|
padding-right: 3px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item--active {
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 1px var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-basic,
|
||||||
|
.task-row--meta,
|
||||||
|
.task-row--visit {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--pad {
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--sms {
|
||||||
|
color: #7a4dbb;
|
||||||
|
background: #f3ecfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--signing {
|
||||||
|
color: var(--info);
|
||||||
|
background: var(--info-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--signed {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--expired {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--failed {
|
||||||
|
color: #a15c19;
|
||||||
|
background: #fff3df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--void {
|
||||||
|
color: #8a9aa3;
|
||||||
|
background: #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--document {
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--visit {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag--outpatient {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag--hospital {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag--check {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-empty span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 34px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pagination {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pagination__total {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pagination button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 11.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pagination button:hover:not(:disabled) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pagination button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items--loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton {
|
||||||
|
display: flex;
|
||||||
|
min-height: 144px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton span {
|
||||||
|
display: block;
|
||||||
|
width: 88%;
|
||||||
|
height: 11px;
|
||||||
|
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: skeleton-shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton__title {
|
||||||
|
width: 65% !important;
|
||||||
|
height: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton__short {
|
||||||
|
width: 48% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.task-list-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items {
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningTaskEvent } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
events: SigningTaskEvent[]
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="audit-card">
|
||||||
|
<strong>操作留痕</strong>
|
||||||
|
|
||||||
|
<div v-if="loading" class="audit-state">正在加载操作留痕…</div>
|
||||||
|
<div v-else-if="error" class="audit-state audit-state--error">
|
||||||
|
操作留痕加载失败,请稍后刷新重试
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!events.length" class="audit-state">暂无操作留痕</div>
|
||||||
|
|
||||||
|
<ol v-else class="audit-list">
|
||||||
|
<li v-for="event in events" :key="event.id">
|
||||||
|
<time>{{ event.time }}</time>
|
||||||
|
<span class="audit-event-content">
|
||||||
|
<span>{{ event.text }}</span>
|
||||||
|
<small v-if="event.attemptNo || event.reason">
|
||||||
|
<template v-if="event.attemptNo">第 {{ event.attemptNo }} 次</template>
|
||||||
|
<template v-if="event.attemptNo && event.reason"> · </template>
|
||||||
|
<template v-if="event.reason">{{ event.reason }}</template>
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audit-card {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-card > strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-state {
|
||||||
|
padding: 8px 0 1px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-state--error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-list time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--mut);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-event-content {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-event-content small {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [reason: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const reason = ref('患者信息录入有误')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
reason.value = '患者信息录入有误'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card void-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="void-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="void-title">作废确认</strong>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="void-summary">
|
||||||
|
<span class="void-icon" aria-hidden="true">⚠</span>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
确定作废任务 <strong>{{ task.id }}</strong
|
||||||
|
>({{ task.documentName }})吗?
|
||||||
|
</p>
|
||||||
|
<span>作废后不可恢复,将记录操作人、时间与原因,全程留痕。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="reason-field">
|
||||||
|
<span>作废原因(必填)</span>
|
||||||
|
<select v-model="reason" aria-label="作废原因">
|
||||||
|
<option>患者信息录入有误</option>
|
||||||
|
<option>文档选择错误</option>
|
||||||
|
<option>患者拒绝签署</option>
|
||||||
|
<option>重复发起</option>
|
||||||
|
<option>其他</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--danger"
|
||||||
|
@click="emit('confirm', reason)"
|
||||||
|
>
|
||||||
|
确认作废
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 440px;
|
||||||
|
max-width: 96vw;
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-summary p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-summary > div > span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-field span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger {
|
||||||
|
color: var(--err);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger:hover {
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,752 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
|
import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts'
|
||||||
|
import { createPadSigningSession } from '@/api/workbench/deliveries'
|
||||||
|
import {
|
||||||
|
completeSigningTask,
|
||||||
|
getSigningTaskDetail,
|
||||||
|
getSigningTaskEvents,
|
||||||
|
getSigningTasks,
|
||||||
|
getSigningTemplates,
|
||||||
|
isSigningMockEnabled,
|
||||||
|
reopenSigningTask,
|
||||||
|
resendSigningSms,
|
||||||
|
updateSigningTaskMethod,
|
||||||
|
voidSigningTask,
|
||||||
|
} from '@/api/workbench/signing'
|
||||||
|
import type {
|
||||||
|
SigningDateRange,
|
||||||
|
SigningMethod,
|
||||||
|
SigningArtifact,
|
||||||
|
SignDeliveryResponseDto,
|
||||||
|
SigningTaskEvent,
|
||||||
|
SigningTaskRecord,
|
||||||
|
SigningTaskStatus,
|
||||||
|
SigningTemplate,
|
||||||
|
VisitType,
|
||||||
|
WorkbenchCampus,
|
||||||
|
} from '@/api/workbench/types'
|
||||||
|
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
||||||
|
import SignaturePadDialog from './components/SignaturePadDialog.vue'
|
||||||
|
import SigningFilterBar from './components/SigningFilterBar.vue'
|
||||||
|
import SigningTaskDetail from './components/SigningTaskDetail.vue'
|
||||||
|
import SigningTaskList from './components/SigningTaskList.vue'
|
||||||
|
import VoidTaskDialog from './components/VoidTaskDialog.vue'
|
||||||
|
import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from './types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const STATUS_VALUES: SigningTaskStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'signing',
|
||||||
|
'signed',
|
||||||
|
'expired',
|
||||||
|
'void',
|
||||||
|
'failed',
|
||||||
|
]
|
||||||
|
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
||||||
|
const CAMPUS_VALUES: WorkbenchCampus[] = ['本部院区', '东院区', '西院区']
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
failed: '处理失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = reactive<SigningFilterForm>({
|
||||||
|
keyword: '',
|
||||||
|
status: 'all',
|
||||||
|
dateRange: 'all',
|
||||||
|
campus: 'all',
|
||||||
|
method: 'all',
|
||||||
|
department: '',
|
||||||
|
documentId: '',
|
||||||
|
visitType: 'all',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks = ref<SigningTaskRecord[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const selectedTaskId = ref<string | null>(null)
|
||||||
|
const selectedTask = ref<SigningTaskRecord | null>(null)
|
||||||
|
const taskEvents = ref<SigningTaskEvent[]>([])
|
||||||
|
const artifacts = ref<SigningArtifact[]>([])
|
||||||
|
const templates = ref<SigningTemplate[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const eventsLoading = ref(false)
|
||||||
|
const eventsError = ref(false)
|
||||||
|
const artifactsLoading = ref(false)
|
||||||
|
const artifactsError = ref(false)
|
||||||
|
const padSessionStatus = ref<string | null>(null)
|
||||||
|
const padSessionLoading = ref(false)
|
||||||
|
const campusIds = ref<Record<string, string>>({})
|
||||||
|
const campusNames = ref<Record<string, WorkbenchCampus>>({})
|
||||||
|
const departmentIds = ref<Record<string, string>>({})
|
||||||
|
const newDialogVisible = ref(false)
|
||||||
|
const signatureDialogVisible = ref(false)
|
||||||
|
const onlineDialogVisible = ref(false)
|
||||||
|
const voidDialogVisible = ref(false)
|
||||||
|
|
||||||
|
let listRequestId = 0
|
||||||
|
let detailRequestId = 0
|
||||||
|
|
||||||
|
const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [
|
||||||
|
{ label: '全部院区', value: 'all' },
|
||||||
|
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
|
||||||
|
{ label: '全部状态', value: 'all' },
|
||||||
|
...STATUS_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
|
||||||
|
{ label: '全部方式', value: 'all' },
|
||||||
|
{ label: '手写板', value: 'pad' },
|
||||||
|
{ label: '短信线上', value: 'sms' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
|
||||||
|
{ label: '全部类型', value: 'all' },
|
||||||
|
{ label: '门诊', value: '门诊' },
|
||||||
|
{ label: '住院', value: '住院' },
|
||||||
|
{ label: '体检', value: '体检' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const departmentOptions = computed<SigningFilterOption[]>(() => {
|
||||||
|
const departments = new Set([
|
||||||
|
...templates.value.map((template) => template.department),
|
||||||
|
...tasks.value.map((task) => task.department),
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '全部科室', value: '' },
|
||||||
|
...[...departments].sort().map((department) => ({ label: department, value: department })),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const documentOptions = computed<SigningFilterOption[]>(() => [
|
||||||
|
{ label: '全部文档', value: '' },
|
||||||
|
...templates.value.map((template) => ({ label: template.name, value: template.id })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const signingApiOptions = computed(() => ({
|
||||||
|
campus: appStore.selectedCampus,
|
||||||
|
campusId: campusIds.value[appStore.selectedCampus],
|
||||||
|
campusIds: campusIds.value,
|
||||||
|
campusNames: campusNames.value,
|
||||||
|
departmentIds: departmentIds.value,
|
||||||
|
templates: templates.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function getQueryString(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSigningStatus(value: string): value is SigningTaskStatus {
|
||||||
|
return STATUS_VALUES.includes(value as SigningTaskStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSigningDateRange(value: string): value is SigningDateRange {
|
||||||
|
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||||
|
return CAMPUS_VALUES.includes(value as WorkbenchCampus)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFilterFromRoute() {
|
||||||
|
const status = getQueryString(route.query.status)
|
||||||
|
const range = getQueryString(route.query.range)
|
||||||
|
const campus = getQueryString(route.query.campus)
|
||||||
|
|
||||||
|
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
|
||||||
|
filter.dateRange = isSigningDateRange(range) ? range : 'all'
|
||||||
|
filter.campus = isWorkbenchCampus(campus) ? campus : 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
templates.value = await getSigningTemplates()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('文档模板加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrganizationOptions() {
|
||||||
|
if (isSigningMockEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [campusesResult, departmentsResult] = await Promise.allSettled([
|
||||||
|
getCampuses(),
|
||||||
|
getDepartments(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (campusesResult.status === 'fulfilled') {
|
||||||
|
campusIds.value = Object.fromEntries(
|
||||||
|
campusesResult.value.map((campus) => [campus.name, campus.id]),
|
||||||
|
)
|
||||||
|
campusNames.value = Object.fromEntries(
|
||||||
|
campusesResult.value
|
||||||
|
.filter(
|
||||||
|
(campus): campus is typeof campus & { name: WorkbenchCampus } =>
|
||||||
|
campus.name === '本部院区' || campus.name === '东院区' || campus.name === '西院区',
|
||||||
|
)
|
||||||
|
.map((campus) => [campus.id, campus.name]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (departmentsResult.status === 'fulfilled') {
|
||||||
|
departmentIds.value = Object.fromEntries(
|
||||||
|
departmentsResult.value.map((department) => [department.name, department.id]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskDetail(id: string | null) {
|
||||||
|
const requestId = ++detailRequestId
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
selectedTask.value = null
|
||||||
|
taskEvents.value = []
|
||||||
|
artifacts.value = []
|
||||||
|
detailLoading.value = false
|
||||||
|
eventsLoading.value = false
|
||||||
|
eventsError.value = false
|
||||||
|
artifactsLoading.value = false
|
||||||
|
artifactsError.value = false
|
||||||
|
padSessionStatus.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = true
|
||||||
|
eventsLoading.value = true
|
||||||
|
eventsError.value = false
|
||||||
|
artifactsLoading.value = true
|
||||||
|
artifactsError.value = false
|
||||||
|
taskEvents.value = []
|
||||||
|
artifacts.value = []
|
||||||
|
padSessionStatus.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
|
||||||
|
getSigningTaskDetail(id, signingApiOptions.value),
|
||||||
|
getSigningTaskEvents(id),
|
||||||
|
getSigningArtifacts(id),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (requestId === detailRequestId) {
|
||||||
|
if (detailResult.status === 'rejected') {
|
||||||
|
throw detailResult.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTask.value = detailResult.value
|
||||||
|
|
||||||
|
if (eventsResult.status === 'fulfilled') {
|
||||||
|
taskEvents.value = eventsResult.value
|
||||||
|
} else {
|
||||||
|
eventsError.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifactsResult.status === 'fulfilled') {
|
||||||
|
artifacts.value = artifactsResult.value
|
||||||
|
} else {
|
||||||
|
artifactsError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId === detailRequestId) {
|
||||||
|
selectedTask.value = null
|
||||||
|
taskEvents.value = []
|
||||||
|
ElMessage.error('签署任务详情加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === detailRequestId) {
|
||||||
|
detailLoading.value = false
|
||||||
|
eventsLoading.value = false
|
||||||
|
artifactsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks(preferredTaskId?: string, resetPage = false) {
|
||||||
|
if (resetPage) {
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++listRequestId
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getSigningTasks(
|
||||||
|
{
|
||||||
|
...filter,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
},
|
||||||
|
signingApiOptions.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (requestId !== listRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.value = response.records
|
||||||
|
total.value = response.total
|
||||||
|
page.value = response.page
|
||||||
|
pageSize.value = response.pageSize
|
||||||
|
|
||||||
|
const taskIds = new Set(response.records.map((task) => task.id))
|
||||||
|
const routeTaskId = getQueryString(route.query.taskId)
|
||||||
|
const matchedTaskId = [preferredTaskId, selectedTaskId.value, routeTaskId].find(
|
||||||
|
(id) => id && taskIds.has(id),
|
||||||
|
)
|
||||||
|
const nextTaskId = matchedTaskId ?? (routeTaskId || response.records[0]?.id || null)
|
||||||
|
|
||||||
|
selectedTaskId.value = nextTaskId
|
||||||
|
await loadTaskDetail(nextTaskId)
|
||||||
|
} catch {
|
||||||
|
if (requestId === listRequestId) {
|
||||||
|
tasks.value = []
|
||||||
|
total.value = 0
|
||||||
|
selectedTaskId.value = null
|
||||||
|
selectedTask.value = null
|
||||||
|
ElMessage.error('签署任务加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === listRequestId) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(nextFilter: SigningFilterForm) {
|
||||||
|
Object.assign(filter, nextFilter)
|
||||||
|
selectedTaskId.value = null
|
||||||
|
void loadTasks(undefined, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(id: string) {
|
||||||
|
selectedTaskId.value = id
|
||||||
|
void loadTaskDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(nextPage: number) {
|
||||||
|
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
|
||||||
|
|
||||||
|
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page.value = nextPage
|
||||||
|
selectedTaskId.value = null
|
||||||
|
void loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
|
||||||
|
if (!updatedTask) {
|
||||||
|
ElMessage.error('签署任务不存在或已被删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTasks(updatedTask.id, true)
|
||||||
|
ElMessage.success(successMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskAction(action: SigningTaskAction) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'pad-sign') {
|
||||||
|
if (!isSigningMockEnabled) {
|
||||||
|
ElMessage.info('手写板设备签署接口待接入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureDialogVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'create-pad-session') {
|
||||||
|
padSessionLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session: SignDeliveryResponseDto = await createPadSigningSession(task.id)
|
||||||
|
padSessionStatus.value = session.status
|
||||||
|
ElMessage.success('手写板签署会话已创建,请由设备适配器继续采集签名')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('手写板签署会话创建失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
padSessionLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'online-sign') {
|
||||||
|
if (!isSigningMockEnabled) {
|
||||||
|
ElMessage.info('线上签署页面接口待接入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onlineDialogVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'void') {
|
||||||
|
voidDialogVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'reopen' && task.status !== 'expired') {
|
||||||
|
ElMessage.info('只有已超时任务可以重新发起')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'download-pdf') {
|
||||||
|
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
|
||||||
|
|
||||||
|
if (artifact) {
|
||||||
|
await downloadArtifact(artifact)
|
||||||
|
} else {
|
||||||
|
ElMessage.info('当前任务暂无签署后 PDF')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'download-signature') {
|
||||||
|
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNATURE_IMAGE')
|
||||||
|
|
||||||
|
if (artifact) {
|
||||||
|
await downloadArtifact(artifact)
|
||||||
|
} else {
|
||||||
|
ElMessage.info('当前任务暂无签名原图')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'print') {
|
||||||
|
ElMessage.info('原型演示:打印服务待接入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'switch-method') {
|
||||||
|
if (!isSigningMockEnabled) {
|
||||||
|
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
|
||||||
|
await refreshTask(
|
||||||
|
await updateSigningTaskMethod(task.id, method),
|
||||||
|
method === 'sms' ? '已切换为短信线上签署' : '已切换为手写板签署',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'resend-sms') {
|
||||||
|
await refreshTask(
|
||||||
|
await resendSigningSms(task.id, {
|
||||||
|
...signingApiOptions.value,
|
||||||
|
expectedRowVersion: task.rowVersion,
|
||||||
|
}),
|
||||||
|
'短信已重新发送',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'reopen') {
|
||||||
|
await refreshTask(
|
||||||
|
await reopenSigningTask(task.id, {
|
||||||
|
...signingApiOptions.value,
|
||||||
|
expectedRowVersion: task.rowVersion,
|
||||||
|
}),
|
||||||
|
'签署任务已重新发起',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('任务操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadArtifact(artifact: SigningArtifact) {
|
||||||
|
try {
|
||||||
|
const blob = await downloadSigningArtifact(artifact.id)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = artifact.fileName
|
||||||
|
link.click()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
ElMessage.success(`${artifact.label}下载已开始`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(`${artifact.label}下载失败,请稍后重试`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPadSignature(signatureDataUrl: string) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshTask(await completeSigningTask(task.id, signatureDataUrl), '手写签署已完成')
|
||||||
|
signatureDialogVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('手写签署失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeOnlineSignature(signatureDataUrl: string) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshTask(await completeSigningTask(task.id, signatureDataUrl), '线上签署已完成')
|
||||||
|
onlineDialogVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('线上签署失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmVoidTask(reason: string) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshTask(
|
||||||
|
await voidSigningTask(task.id, reason, {
|
||||||
|
...signingApiOptions.value,
|
||||||
|
expectedRowVersion: task.rowVersion,
|
||||||
|
}),
|
||||||
|
'签署任务已作废',
|
||||||
|
)
|
||||||
|
voidDialogVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('任务作废失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOnlineResend() {
|
||||||
|
await handleTaskAction('resend-sms')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializePage() {
|
||||||
|
await loadOrganizationOptions()
|
||||||
|
await loadTemplates()
|
||||||
|
await loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskCreated(task: SigningTaskRecord) {
|
||||||
|
await loadTasks(task.id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.status, route.query.range, route.query.campus, route.query.taskId],
|
||||||
|
() => {
|
||||||
|
syncFilterFromRoute()
|
||||||
|
page.value = 1
|
||||||
|
selectedTaskId.value = null
|
||||||
|
void loadTasks()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => appStore.selectedCampus,
|
||||||
|
(campus) => {
|
||||||
|
if (filter.campus !== 'all') {
|
||||||
|
filter.campus = campus
|
||||||
|
}
|
||||||
|
page.value = 1
|
||||||
|
void loadTasks()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncFilterFromRoute()
|
||||||
|
void initializePage()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagePlaceholder
|
<section class="signing-page">
|
||||||
group="工作台"
|
<div class="page-intro">
|
||||||
title="签署工作台"
|
<div>
|
||||||
icon="✍"
|
<h1>签署任务</h1>
|
||||||
description="签署工作台页面占位,待根据 Web 端需求实现。"
|
<p>集中处理患者知情同意文书的发起、签署与全过程留痕。</p>
|
||||||
/>
|
</div>
|
||||||
|
<div class="page-intro__meta">
|
||||||
|
<span>当前院区:{{ appStore.selectedCampus }}</span>
|
||||||
|
<span>共 {{ total }} 条任务</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningFilterBar
|
||||||
|
:filter="filter"
|
||||||
|
:campuses="campusOptions"
|
||||||
|
:statuses="statusOptions"
|
||||||
|
:methods="methodOptions"
|
||||||
|
:departments="departmentOptions"
|
||||||
|
:documents="documentOptions"
|
||||||
|
:visit-types="visitTypeOptions"
|
||||||
|
@add="newDialogVisible = true"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="task-workspace">
|
||||||
|
<SigningTaskList
|
||||||
|
:tasks="tasks"
|
||||||
|
:selected-id="selectedTaskId"
|
||||||
|
:total="total"
|
||||||
|
:page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:loading="loading"
|
||||||
|
@select="selectTask"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
<SigningTaskDetail
|
||||||
|
:task="selectedTask"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:audit-events="taskEvents"
|
||||||
|
:audit-loading="eventsLoading"
|
||||||
|
:audit-error="eventsError"
|
||||||
|
:allow-local-signing="isSigningMockEnabled"
|
||||||
|
:artifacts="artifacts"
|
||||||
|
:artifacts-loading="artifactsLoading"
|
||||||
|
:artifacts-error="artifactsError"
|
||||||
|
:pad-session-status="padSessionStatus"
|
||||||
|
:pad-session-loading="padSessionLoading"
|
||||||
|
@action="handleTaskAction"
|
||||||
|
@download-artifact="downloadArtifact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewSigningTaskDialog
|
||||||
|
:visible="newDialogVisible"
|
||||||
|
@close="newDialogVisible = false"
|
||||||
|
@created="handleTaskCreated"
|
||||||
|
/>
|
||||||
|
<SignaturePadDialog
|
||||||
|
:visible="signatureDialogVisible"
|
||||||
|
:task="selectedTask"
|
||||||
|
@close="signatureDialogVisible = false"
|
||||||
|
@confirm="confirmPadSignature"
|
||||||
|
/>
|
||||||
|
<OnlineSigningDialog
|
||||||
|
:visible="onlineDialogVisible"
|
||||||
|
:task="selectedTask"
|
||||||
|
@close="onlineDialogVisible = false"
|
||||||
|
@complete="completeOnlineSignature"
|
||||||
|
@resend="handleOnlineResend"
|
||||||
|
/>
|
||||||
|
<VoidTaskDialog
|
||||||
|
:visible="voidDialogVisible"
|
||||||
|
:task="selectedTask"
|
||||||
|
@close="voidDialogVisible = false"
|
||||||
|
@confirm="confirmVoidTask"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signing-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta span {
|
||||||
|
padding: 5px 9px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.task-workspace {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.page-intro {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,54 @@
|
|||||||
export type SigningFilterStatus = 'all' | 'pending' | 'signing' | 'signed' | 'rejected' | 'expired'
|
import type {
|
||||||
|
PatientProfile,
|
||||||
|
PatientVisit,
|
||||||
|
SignDeliveryStatus,
|
||||||
|
SigningDateRange,
|
||||||
|
SigningMethod,
|
||||||
|
SigningTaskRecord,
|
||||||
|
SigningTaskStatus,
|
||||||
|
SigningTemplate,
|
||||||
|
VisitType,
|
||||||
|
WorkbenchCampus,
|
||||||
|
} from '@/api/workbench/types'
|
||||||
|
|
||||||
|
export type SigningFilterStatus = 'all' | SigningTaskStatus
|
||||||
|
|
||||||
export interface SigningFilterForm {
|
export interface SigningFilterForm {
|
||||||
keyword: string
|
keyword: string
|
||||||
status: SigningFilterStatus
|
status: SigningFilterStatus
|
||||||
dateRange: [string, string] | null
|
dateRange: SigningDateRange
|
||||||
|
campus: WorkbenchCampus | 'all'
|
||||||
|
method: SigningMethod | 'all'
|
||||||
|
department: string
|
||||||
|
documentId: string
|
||||||
|
visitType: VisitType | 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SigningTableRow {
|
export type SigningTableRow = SigningTaskRecord
|
||||||
id: string
|
|
||||||
patientName: string
|
export interface SigningFilterOption<T extends string = string> {
|
||||||
visitNo: string
|
label: string
|
||||||
documentName: string
|
value: T
|
||||||
department: string
|
|
||||||
signerName: string
|
|
||||||
status: SigningFilterStatus
|
|
||||||
updatedAt: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NewSigningTaskState {
|
||||||
|
patient: PatientProfile | null
|
||||||
|
selectedVisit: PatientVisit | null
|
||||||
|
selectedTemplate: SigningTemplate | null
|
||||||
|
method: SigningMethod
|
||||||
|
phone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SigningTaskAction =
|
||||||
|
| 'pad-sign'
|
||||||
|
| 'create-pad-session'
|
||||||
|
| 'online-sign'
|
||||||
|
| 'resend-sms'
|
||||||
|
| 'switch-method'
|
||||||
|
| 'reopen'
|
||||||
|
| 'void'
|
||||||
|
| 'download-pdf'
|
||||||
|
| 'download-signature'
|
||||||
|
| 'print'
|
||||||
|
|
||||||
|
export type SigningPadSessionStatus = SignDeliveryStatus | null
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { defineConfig } from 'vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://ipad.shenynet.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
VITE_APP_NAME=medical-sign-patient
|
VITE_APP_NAME=medical-sign-patient
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
|
VITE_USE_MOCK=true
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ Vant、Zod、PDF 预览、第三方签名组件和自动化测试暂不接入;
|
|||||||
|
|
||||||
患者端应采用移动优先设计,按钮、文字和签名区域需要适合老年患者使用。
|
患者端应采用移动优先设计,按钮、文字和签名区域需要适合老年患者使用。
|
||||||
|
|
||||||
|
## API 接入
|
||||||
|
|
||||||
|
开发环境默认使用 `/api`,由 `vite.config.ts` 代理到 `https://ipad.shenynet.com`;生产环境需要由 Nginx 或医院网关配置同路径反向代理。
|
||||||
|
|
||||||
|
已接入的真实接口:
|
||||||
|
|
||||||
|
- `POST /api/v1/sign-deliveries/token/consume`:消费短信/二维码一次性签署 Token;
|
||||||
|
- `POST /api/v1/sign-deliveries/{taskId}/signature`:以 `multipart/form-data` 上传真实 PNG 签名和签署元数据。
|
||||||
|
|
||||||
|
真实流程会在进入页面时消费 Token,并在提交时携带 `deliveryId`、`uploadToken` 和幂等键。Token 消费接口目前只返回投递凭证,不返回正式文书内容,因此真实模式会停在安全提示页,不会让患者在未阅读正式文书时提交签名。待后端提供患者端文书读取接口后,再接入 PDF/文书展示和正式提交闭环。
|
||||||
|
|
||||||
|
默认 `VITE_USE_MOCK=true` 时可以无 Token 演示完整页面交互;联调真实 Token 流程时设置 `VITE_USE_MOCK=false`,或直接使用带 `token`/`signToken`/`t` 查询参数的签署链接。
|
||||||
|
|
||||||
## 与医护端的关系
|
## 与医护端的关系
|
||||||
|
|
||||||
医护端创建签署任务,patient-h5 完成签署,后端统一保存结果:
|
医护端创建签署任务,patient-h5 完成签署,后端统一保存结果:
|
||||||
@@ -67,4 +80,4 @@ patient-h5 不直接访问医院完整电子病历。
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
已完成 Vite 基础初始化、路由、签署流程状态管理和第一版移动端页面骨架。当前流程使用演示数据,尚未接入真实 Token 校验、文书接口和签署提交接口。
|
已完成 Vite 基础初始化、路由、签署流程状态管理和第一版移动端页面骨架。Mock 模式可以演示完整交互;真实模式已经接入一次性 Token 消费和 PNG 签名上传请求,并增加路由保护、重复提交保护和服务端结果展示。正式文书公开读取接口尚未提供,因此真实模式暂不会进入签署步骤。
|
||||||
|
|||||||
@@ -1,6 +1,56 @@
|
|||||||
import axios from 'axios'
|
import { unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
import type {
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
ApiResponse,
|
||||||
timeout: 10_000,
|
SignatureUploadInput,
|
||||||
})
|
SignatureUploadResponse,
|
||||||
|
TokenConsumeRequest,
|
||||||
|
TokenConsumeResponse,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
|
||||||
|
return request
|
||||||
|
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
||||||
|
headers: {
|
||||||
|
'Idempotency-Key': createIdempotencyKey(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdempotencyKey(value?: string) {
|
||||||
|
if (value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `patient-h5-${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadSigningSignature(
|
||||||
|
payload: SignatureUploadInput,
|
||||||
|
): Promise<SignatureUploadResponse> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', payload.file, 'signature.png')
|
||||||
|
formData.append('metadata', payload.metadata)
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post<ApiResponse<SignatureUploadResponse>>(
|
||||||
|
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
deliveryId: payload.deliveryId,
|
||||||
|
uploadToken: payload.uploadToken,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Idempotency-Key': createIdempotencyKey(payload.idempotencyKey),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|||||||
51
patient-h5/src/api/types.ts
Normal file
51
patient-h5/src/api/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number | string
|
||||||
|
message: string
|
||||||
|
data: T | null
|
||||||
|
traceId?: string
|
||||||
|
timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignDeliveryChannel = 'PAD' | 'SMS'
|
||||||
|
|
||||||
|
export interface TokenConsumeRequest {
|
||||||
|
token: string
|
||||||
|
channel?: SignDeliveryChannel
|
||||||
|
taskId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenConsumeResponse {
|
||||||
|
deliveryId: string
|
||||||
|
taskId: string
|
||||||
|
channel: SignDeliveryChannel
|
||||||
|
uploadToken: string
|
||||||
|
traceId?: string | null
|
||||||
|
timestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignatureUploadStatus =
|
||||||
|
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string
|
||||||
|
|
||||||
|
export interface SignatureUploadResponse {
|
||||||
|
taskId: string
|
||||||
|
pipelineId: string
|
||||||
|
status: SignatureUploadStatus
|
||||||
|
originalPdfArtifactId: string
|
||||||
|
signedPdfArtifactId: string
|
||||||
|
signatureImageArtifactId: string
|
||||||
|
originalPdfSha256: string
|
||||||
|
signedPdfSha256: string
|
||||||
|
signatureImageSha256: string
|
||||||
|
completedAt: string | null
|
||||||
|
traceId?: string | null
|
||||||
|
timestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureUploadInput {
|
||||||
|
taskId: string
|
||||||
|
deliveryId: string
|
||||||
|
uploadToken: string
|
||||||
|
file: Blob
|
||||||
|
metadata: string
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
@@ -1,21 +1,135 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
import { consumeSigningToken, uploadSigningSignature } from '@/api'
|
||||||
|
import type { SignatureUploadResponse } from '@/api/types'
|
||||||
import type { SignFlowState } from '@/types/signing'
|
import type { SignFlowState } from '@/types/signing'
|
||||||
|
|
||||||
const initialState: SignFlowState = {
|
const initialState: SignFlowState = {
|
||||||
|
mode: 'mock',
|
||||||
|
status: 'idle',
|
||||||
patientName: '张*',
|
patientName: '张*',
|
||||||
visitNo: 'MZ20260827018',
|
visitNo: 'MZ20260827018',
|
||||||
documentTitle: '儿科急诊特殊检查知情同意书',
|
documentTitle: '儿科急诊特殊检查知情同意书',
|
||||||
taskNo: 'CT-20260827-001',
|
taskNo: 'CT-20260827-001',
|
||||||
|
taskId: '',
|
||||||
|
deliveryId: '',
|
||||||
|
uploadToken: '',
|
||||||
|
channel: '',
|
||||||
consentAccepted: false,
|
consentAccepted: false,
|
||||||
signerName: '',
|
signerName: '',
|
||||||
relation: '',
|
relation: '',
|
||||||
signatureCaptured: false,
|
signatureCaptured: false,
|
||||||
submittedAt: '',
|
submittedAt: '',
|
||||||
|
backendStatus: '',
|
||||||
|
pipelineId: '',
|
||||||
|
uploadResult: null,
|
||||||
|
errorMessage: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const signingState = reactive<SignFlowState>({ ...initialState })
|
export const signingState = reactive<SignFlowState>({ ...initialState })
|
||||||
|
|
||||||
export function resetSignFlow() {
|
export function resetSignFlow() {
|
||||||
Object.assign(signingState, initialState)
|
Object.assign(signingState, {
|
||||||
|
...initialState,
|
||||||
|
uploadResult: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '签署链接验证失败,请联系现场医务人员。'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeSignFlow(token?: string) {
|
||||||
|
resetSignFlow()
|
||||||
|
|
||||||
|
const candidate = token?.trim()
|
||||||
|
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
|
||||||
|
|
||||||
|
signingState.mode = shouldUseRealFlow ? 'real' : 'mock'
|
||||||
|
|
||||||
|
if (!shouldUseRealFlow) {
|
||||||
|
signingState.status = 'ready'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
signingState.status = 'blocked'
|
||||||
|
signingState.errorMessage = '请使用短信或二维码中的有效签署链接进入。'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
signingState.status = 'loading'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await consumeSigningToken({ token: candidate, channel: 'SMS' })
|
||||||
|
|
||||||
|
signingState.taskId = session.taskId
|
||||||
|
signingState.taskNo = session.taskId
|
||||||
|
signingState.deliveryId = session.deliveryId
|
||||||
|
signingState.uploadToken = session.uploadToken
|
||||||
|
signingState.channel = session.channel
|
||||||
|
signingState.documentTitle = '待签署知情同意文书'
|
||||||
|
signingState.patientName = '患者信息待确认'
|
||||||
|
signingState.visitNo = ''
|
||||||
|
signingState.status = 'ready'
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
signingState.status = 'error'
|
||||||
|
signingState.errorMessage = getErrorMessage(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitRealSignature(input: {
|
||||||
|
file: Blob
|
||||||
|
signerName: string
|
||||||
|
relation: NonNullable<SignFlowState['relation']>
|
||||||
|
consentAccepted: boolean
|
||||||
|
}): Promise<SignatureUploadResponse> {
|
||||||
|
if (signingState.mode !== 'real') {
|
||||||
|
throw new Error('当前是演示签署流程')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!signingState.taskId ||
|
||||||
|
!signingState.deliveryId ||
|
||||||
|
!signingState.uploadToken ||
|
||||||
|
!input.consentAccepted
|
||||||
|
) {
|
||||||
|
throw new Error('签署凭证或知情确认状态无效,请重新打开签署链接。')
|
||||||
|
}
|
||||||
|
|
||||||
|
signingState.status = 'submitting'
|
||||||
|
signingState.errorMessage = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadSigningSignature({
|
||||||
|
taskId: signingState.taskId,
|
||||||
|
deliveryId: signingState.deliveryId,
|
||||||
|
uploadToken: signingState.uploadToken,
|
||||||
|
file: input.file,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
signerName: input.signerName,
|
||||||
|
relation: input.relation,
|
||||||
|
consentAccepted: input.consentAccepted,
|
||||||
|
signedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
signingState.signerName = input.signerName
|
||||||
|
signingState.relation = input.relation
|
||||||
|
signingState.consentAccepted = input.consentAccepted
|
||||||
|
signingState.signatureCaptured = true
|
||||||
|
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||||
|
signingState.backendStatus = result.status
|
||||||
|
signingState.pipelineId = result.pipelineId
|
||||||
|
signingState.uploadResult = result
|
||||||
|
signingState.uploadToken = ''
|
||||||
|
signingState.status = 'submitted'
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
signingState.status = 'ready'
|
||||||
|
signingState.errorMessage = getErrorMessage(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import { signingState } from '@/composables/useSignFlow'
|
||||||
import ConsentView from '@/views/consent/ConsentView.vue'
|
import ConsentView from '@/views/consent/ConsentView.vue'
|
||||||
import EntryView from '@/views/entry/EntryView.vue'
|
import EntryView from '@/views/entry/EntryView.vue'
|
||||||
import ResultView from '@/views/result/ResultView.vue'
|
import ResultView from '@/views/result/ResultView.vue'
|
||||||
@@ -17,4 +18,24 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.path === '/entry') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signingState.status === 'idle') {
|
||||||
|
return '/entry'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path === '/signer' && !signingState.consentAccepted) {
|
||||||
|
return '/consent'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path === '/result' && !signingState.signatureCaptured) {
|
||||||
|
return '/entry'
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
|
import type { SignDeliveryChannel, SignatureUploadResponse } from '@/api/types'
|
||||||
|
|
||||||
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
||||||
|
|
||||||
|
export type SignFlowMode = 'mock' | 'real'
|
||||||
|
|
||||||
|
export type SignFlowStatus =
|
||||||
|
'idle' | 'loading' | 'ready' | 'blocked' | 'submitting' | 'submitted' | 'error'
|
||||||
|
|
||||||
export interface SignFlowState {
|
export interface SignFlowState {
|
||||||
|
mode: SignFlowMode
|
||||||
|
status: SignFlowStatus
|
||||||
patientName: string
|
patientName: string
|
||||||
visitNo: string
|
visitNo: string
|
||||||
documentTitle: string
|
documentTitle: string
|
||||||
taskNo: string
|
taskNo: string
|
||||||
|
taskId: string
|
||||||
|
deliveryId: string
|
||||||
|
uploadToken: string
|
||||||
|
channel: SignDeliveryChannel | ''
|
||||||
consentAccepted: boolean
|
consentAccepted: boolean
|
||||||
signerName: string
|
signerName: string
|
||||||
relation: SignerRelation | ''
|
relation: SignerRelation | ''
|
||||||
signatureCaptured: boolean
|
signatureCaptured: boolean
|
||||||
submittedAt: string
|
submittedAt: string
|
||||||
|
backendStatus: string
|
||||||
|
pipelineId: string
|
||||||
|
uploadResult: SignatureUploadResponse | null
|
||||||
|
errorMessage: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const relationOptions: Array<{ value: SignerRelation; label: string }> = [
|
export const relationOptions: Array<{ value: SignerRelation; label: string }> = [
|
||||||
|
|||||||
29
patient-h5/src/utils/api-response.ts
Normal file
29
patient-h5/src/utils/api-response.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ApiResponse } from '@/api/types'
|
||||||
|
|
||||||
|
export class ApiResponseError extends Error {
|
||||||
|
readonly code: number | string
|
||||||
|
readonly traceId?: string
|
||||||
|
|
||||||
|
constructor(response: ApiResponse<unknown>) {
|
||||||
|
super(response.message || '签署服务请求失败')
|
||||||
|
this.name = 'ApiResponseError'
|
||||||
|
this.code = response.code
|
||||||
|
this.traceId = response.traceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuccessCode(code: number | string) {
|
||||||
|
return code === 0 || code === '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapApiResponse<T>(response: ApiResponse<T>): T {
|
||||||
|
if (!isSuccessCode(response.code)) {
|
||||||
|
throw new ApiResponseError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data === null) {
|
||||||
|
throw new Error(response.message || '签署服务未返回数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
35
patient-h5/src/utils/request.ts
Normal file
35
patient-h5/src/utils/request.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE_URL = '/api'
|
||||||
|
const DEFAULT_TIMEOUT = 15_000
|
||||||
|
|
||||||
|
class Request {
|
||||||
|
private readonly instance: AxiosInstance
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
baseURL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
|
||||||
|
timeout = DEFAULT_TIMEOUT,
|
||||||
|
) {
|
||||||
|
this.instance = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout,
|
||||||
|
withCredentials: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
|
||||||
|
return this.instance.request<T, AxiosResponse<T>>(config).then((response) => response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = unknown>(url: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||||
|
return this.request<T>({ ...config, url, method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T = unknown>(url: string, data?: unknown, config: AxiosRequestConfig = {}): Promise<T> {
|
||||||
|
return this.request<T>({ ...config, url, data, method: 'POST' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const request = new Request()
|
||||||
|
|
||||||
|
export default request
|
||||||
@@ -1,14 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
import { initializeSignFlow, signingState } from '@/composables/useSignFlow'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const isRealFlow = computed(() => signingState.mode === 'real')
|
||||||
|
const canStartSigning = computed(() => signingState.status === 'ready')
|
||||||
|
|
||||||
|
function getQueryValue(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
const token =
|
||||||
|
getQueryValue(route.query.token) ||
|
||||||
|
getQueryValue(route.query.signToken) ||
|
||||||
|
getQueryValue(route.query.t)
|
||||||
|
|
||||||
|
await initializeSignFlow(token)
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function startSigning() {
|
function startSigning() {
|
||||||
resetSignFlow()
|
if (!canStartSigning.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void router.push('/consent')
|
void router.push('/consent')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void initialize()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,36 +52,170 @@ function startSigning() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="mobile-hero">
|
<div v-if="loading" class="mobile-state-card">
|
||||||
<p class="eyebrow">待签署文书</p>
|
<p class="eyebrow">安全验证</p>
|
||||||
<h1>{{ signingState.documentTitle }}</h1>
|
<h1>正在验证签署链接</h1>
|
||||||
<p>请确认以下任务信息,并在阅读文书后完成签署。</p>
|
<p>请稍候,系统正在确认本次签署凭证。</p>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section class="mobile-card patient-card">
|
<template v-else-if="signingState.status === 'error' || signingState.status === 'blocked'">
|
||||||
<div class="card-label">患者信息</div>
|
<section class="mobile-state-card mobile-state-card--error">
|
||||||
<div class="patient-row">
|
<p class="eyebrow">链接不可用</p>
|
||||||
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
<h1>无法继续签署</h1>
|
||||||
<div>
|
<p>{{ signingState.errorMessage }}</p>
|
||||||
<strong>{{ signingState.patientName }}</strong>
|
<p class="secure-note">请不要重复尝试或转发链接,如有疑问请联系现场医务人员。</p>
|
||||||
<span>{{ signingState.visitNo }}</span>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="mobile-hero">
|
||||||
|
<p class="eyebrow">待签署文书</p>
|
||||||
|
<h1>{{ signingState.documentTitle }}</h1>
|
||||||
|
<p v-if="isRealFlow">
|
||||||
|
签署链接已验证。请先核对现场展示的正式文书内容,再继续确认签署人信息。
|
||||||
|
</p>
|
||||||
|
<p v-else>请确认以下任务信息,并在阅读文书后完成签署。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="isRealFlow" class="mobile-card real-session-card">
|
||||||
|
<div class="card-label">签署凭证</div>
|
||||||
|
<div class="real-session-row">
|
||||||
|
<span class="real-session-icon">✓</span>
|
||||||
|
<div>
|
||||||
|
<strong>一次性签署链接已验证</strong>
|
||||||
|
<span>任务编号:{{ signingState.taskNo }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mobile-card notice-card">
|
<section v-else class="mobile-card patient-card">
|
||||||
<div class="notice-icon">i</div>
|
<div class="card-label">患者信息</div>
|
||||||
<div>
|
<div class="patient-row">
|
||||||
<strong>请使用实际签署人的信息</strong>
|
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
||||||
<p>如果由家属或监护人代签,请在下一步选择与患者的关系。</p>
|
<div>
|
||||||
</div>
|
<strong>{{ signingState.patientName }}</strong>
|
||||||
</section>
|
<span>{{ signingState.visitNo }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button class="mobile-primary-button" type="button" @click="startSigning">
|
<section class="mobile-card notice-card">
|
||||||
开始阅读并签署
|
<div class="notice-icon">i</div>
|
||||||
</button>
|
<div>
|
||||||
|
<strong>请使用实际签署人的信息</strong>
|
||||||
|
<p>如果由家属或监护人代签,请在下一步选择与患者的关系。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
<section v-if="isRealFlow" class="mobile-card integration-note">
|
||||||
|
<strong>正式文书内容暂未由公开接口返回</strong>
|
||||||
|
<p>
|
||||||
|
当前后端公开 Token
|
||||||
|
接口只返回一次性上传凭证,没有提供患者端读取任务文书的接口。为避免在未阅读正式内容的情况下签署,本版本不会提交真实签名。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mobile-primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isRealFlow"
|
||||||
|
@click="startSigning"
|
||||||
|
>
|
||||||
|
{{ isRealFlow ? '等待文书接口接入' : '开始阅读并签署' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-state-card,
|
||||||
|
.integration-note,
|
||||||
|
.real-session-card {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e0eceb;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgb(255 255 255 / 86%);
|
||||||
|
box-shadow: 0 12px 30px rgb(33 79 92 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-state-card {
|
||||||
|
margin-top: 20vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-state-card h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #173b56;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-state-card p:not(.eyebrow) {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #788f9d;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-state-card--error {
|
||||||
|
border-color: #f0d8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-session-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-session-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: #1f776f;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #dff3ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-session-row strong,
|
||||||
|
.real-session-row span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-session-row strong {
|
||||||
|
color: #31576c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-session-row div span {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #8aa0aa;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-note {
|
||||||
|
margin-top: 14px;
|
||||||
|
background: #fffaf2;
|
||||||
|
border-color: #f0dfc1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-note strong {
|
||||||
|
color: #876126;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-note p {
|
||||||
|
margin: 7px 0 0;
|
||||||
|
color: #927b58;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-primary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const resultTitle = computed(() =>
|
||||||
|
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||||
|
? '签名已提交,文书生成中'
|
||||||
|
: '知情同意书已提交',
|
||||||
|
)
|
||||||
|
|
||||||
|
const resultCopy = computed(() =>
|
||||||
|
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||||
|
? '签名图片已安全提交,系统正在生成签署后文书。最终状态以医签通服务端为准。'
|
||||||
|
: '本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。',
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (signingState.mode !== 'real') {
|
||||||
|
return '演示完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
SIGNED: '已签署',
|
||||||
|
GENERATING: '生成中',
|
||||||
|
WAITING_SIGN: '待签署',
|
||||||
|
FAILED: '处理失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels[signingState.backendStatus] ?? (signingState.backendStatus || '已提交')
|
||||||
|
})
|
||||||
|
|
||||||
function returnToEntry() {
|
function returnToEntry() {
|
||||||
resetSignFlow()
|
resetSignFlow()
|
||||||
void router.replace('/entry')
|
void router.replace('/entry')
|
||||||
@@ -15,8 +43,8 @@ function returnToEntry() {
|
|||||||
<main class="mobile-shell result-shell">
|
<main class="mobile-shell result-shell">
|
||||||
<div class="result-icon">✓</div>
|
<div class="result-icon">✓</div>
|
||||||
<p class="eyebrow">签署完成</p>
|
<p class="eyebrow">签署完成</p>
|
||||||
<h1>知情同意书已提交</h1>
|
<h1>{{ resultTitle }}</h1>
|
||||||
<p class="result-copy">本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。</p>
|
<p class="result-copy">{{ resultCopy }}</p>
|
||||||
|
|
||||||
<section class="mobile-card result-card">
|
<section class="mobile-card result-card">
|
||||||
<div>
|
<div>
|
||||||
@@ -31,6 +59,14 @@ function returnToEntry() {
|
|||||||
<span>提交时间</span>
|
<span>提交时间</span>
|
||||||
<strong>{{ signingState.submittedAt }}</strong>
|
<strong>{{ signingState.submittedAt }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="signingState.mode === 'real'">
|
||||||
|
<span>服务端状态</span>
|
||||||
|
<strong>{{ statusLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="signingState.mode === 'real'">
|
||||||
|
<span>任务编号</span>
|
||||||
|
<strong>{{ signingState.taskNo }}</strong>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="secure-note">请不要重复提交。如需修改,请联系现场医务人员。</p>
|
<p class="secure-note">请不要重复提交。如需修改,请联系现场医务人员。</p>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import SignatureCanvas from '@/components/SignatureCanvas.vue'
|
import SignatureCanvas from '@/components/SignatureCanvas.vue'
|
||||||
import { signingState } from '@/composables/useSignFlow'
|
import { signingState, submitRealSignature } from '@/composables/useSignFlow'
|
||||||
import { relationOptions, type SignerRelation } from '@/types/signing'
|
import { relationOptions, type SignerRelation } from '@/types/signing'
|
||||||
|
|
||||||
interface SignatureCanvasExpose {
|
interface SignatureCanvasExpose {
|
||||||
@@ -15,9 +15,20 @@ const router = useRouter()
|
|||||||
const signerName = ref('')
|
const signerName = ref('')
|
||||||
const relation = ref<SignerRelation | ''>('')
|
const relation = ref<SignerRelation | ''>('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const signatureDataUrl = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
const signatureCanvas = ref<SignatureCanvasExpose | null>(null)
|
const signatureCanvas = ref<SignatureCanvasExpose | null>(null)
|
||||||
|
|
||||||
function submitSigning() {
|
function dataUrlToBlob(dataUrl: string) {
|
||||||
|
const [header, encoded] = dataUrl.split(',')
|
||||||
|
const mimeType = header?.match(/data:(.*?);base64/)?.[1] ?? 'image/png'
|
||||||
|
const binary = atob(encoded ?? '')
|
||||||
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: mimeType })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSigning() {
|
||||||
const trimmedName = signerName.value.trim()
|
const trimmedName = signerName.value.trim()
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
errorMessage.value = '请输入实际签署人姓名。'
|
errorMessage.value = '请输入实际签署人姓名。'
|
||||||
@@ -32,8 +43,39 @@ function submitSigning() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedRelation = relation.value
|
||||||
|
|
||||||
|
if (!selectedRelation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signingState.mode === 'real') {
|
||||||
|
if (!signatureDataUrl.value) {
|
||||||
|
errorMessage.value = '未读取到签名图片,请重新签名。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitRealSignature({
|
||||||
|
file: dataUrlToBlob(signatureDataUrl.value),
|
||||||
|
signerName: trimmedName,
|
||||||
|
relation: selectedRelation,
|
||||||
|
consentAccepted: signingState.consentAccepted,
|
||||||
|
})
|
||||||
|
await router.push('/result')
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '签署提交失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
signingState.signerName = trimmedName
|
signingState.signerName = trimmedName
|
||||||
signingState.relation = relation.value
|
signingState.relation = selectedRelation
|
||||||
signingState.signatureCaptured = true
|
signingState.signatureCaptured = true
|
||||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||||
void router.push('/result')
|
void router.push('/result')
|
||||||
@@ -69,10 +111,17 @@ function submitSigning() {
|
|||||||
</select>
|
</select>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SignatureCanvas ref="signatureCanvas" />
|
<SignatureCanvas ref="signatureCanvas" @update:data-url="signatureDataUrl = $event" />
|
||||||
|
|
||||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||||
<button class="mobile-primary-button" type="button" @click="submitSigning">确认提交签署</button>
|
<button
|
||||||
|
class="mobile-primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitSigning"
|
||||||
|
>
|
||||||
|
{{ submitting ? '提交中…' : '确认提交签署' }}
|
||||||
|
</button>
|
||||||
<button class="mobile-text-button" type="button" @click="router.back()">返回修改</button>
|
<button class="mobile-text-button" type="button" @click="router.back()">返回修改</button>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { defineConfig } from 'vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://ipad.shenynet.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user