Compare commits
5 Commits
8b4b9d031d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc86ee9149 | ||
|
|
b4184260da | ||
|
|
45971b62d9 | ||
|
|
2963c6ea56 | ||
|
|
5b2cc33038 |
@@ -21,6 +21,7 @@ src/
|
||||
│ ├─ documents.ts
|
||||
│ ├─ reports.ts
|
||||
│ ├─ users.ts
|
||||
│ ├─ permissions.ts
|
||||
│ ├─ document-permissions.ts
|
||||
│ ├─ settings.ts
|
||||
│ └─ types.ts
|
||||
|
||||
@@ -125,6 +125,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
- `api/management/reports.ts`
|
||||
- `api/management/users.ts`
|
||||
- `api/management/roles.ts`
|
||||
- `api/management/permissions.ts`
|
||||
- `api/management/organization.ts`
|
||||
- `api/management/document-permissions.ts`
|
||||
- `api/management/audit.ts`
|
||||
@@ -133,6 +134,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
|
||||
|
||||
- `POST /api/v1/auth/login`:账号密码登录;
|
||||
- `GET /api/v1/auth/captcha`:获取按需启用的图形验证码;
|
||||
- `GET /api/v1/auth/me`:查询当前用户;
|
||||
- `POST /api/v1/auth/logout`:注销当前会话;
|
||||
- 后续请求自动携带 `X-Token` 请求头。
|
||||
@@ -155,11 +157,14 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
- `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 开发服务。
|
||||
|
||||
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` 直接打开详情,即使任务不在签署工作台当前第一页。
|
||||
@@ -4,6 +4,7 @@ import { ApiResponseError, unwrapApiResponse } from '@/utils/api-response'
|
||||
import { request } from '@/utils/request'
|
||||
import type {
|
||||
AuthenticatedUser,
|
||||
CaptchaResponse,
|
||||
CurrentUserResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -12,6 +13,11 @@ 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')
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -146,6 +146,60 @@ export interface CreateRoleRequest {
|
||||
|
||||
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
|
||||
|
||||
@@ -46,21 +46,23 @@ function getCollectionRecords(collection: ArtifactCollection) {
|
||||
}
|
||||
|
||||
function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact {
|
||||
const artifactType: SignArtifactType = dto.artifactType ?? dto.type ?? 'UNKNOWN'
|
||||
const artifactType: SignArtifactType = dto.artifactType
|
||||
const label = artifactLabels[artifactType] ?? '签署文件'
|
||||
const mimeType = dto.mimeType ?? dto.contentType ?? 'application/octet-stream'
|
||||
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: dto.fileName || `${label}.${extension}`,
|
||||
fileName: `${label}.${extension}`,
|
||||
mimeType,
|
||||
size: dto.size ?? dto.byteSize ?? dto.fileSize ?? 0,
|
||||
sha256: dto.sha256 ?? dto.contentSha256 ?? '',
|
||||
sizeBytes: dto.sizeBytes,
|
||||
sha256: dto.sha256,
|
||||
metadata: dto.metadata ?? {},
|
||||
createdAt: formatDateTime(dto.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +41,12 @@ export function sendSigningSms(
|
||||
|
||||
export function resendSigningSms(
|
||||
taskId: string,
|
||||
expectedRowVersion?: number,
|
||||
idempotencyKey?: string,
|
||||
): Promise<SignDeliveryResponseDto | null> {
|
||||
const data = expectedRowVersion === undefined ? undefined : { expectedRowVersion }
|
||||
|
||||
return request
|
||||
.post<ApiResponseOf<SignDeliveryResponseDto | null>>(
|
||||
`/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/resend`,
|
||||
data,
|
||||
undefined,
|
||||
{ headers: idempotencyHeaders(idempotencyKey) },
|
||||
)
|
||||
.then(unwrapNullableApiResponse)
|
||||
@@ -69,9 +66,14 @@ export function createPadSigningSession(
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
|
||||
export function consumeSigningToken(
|
||||
payload: TokenConsumeRequest,
|
||||
idempotencyKey?: string,
|
||||
): Promise<TokenConsumeResponse> {
|
||||
return request
|
||||
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload)
|
||||
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
||||
headers: idempotencyHeaders(idempotencyKey),
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import type {
|
||||
WorkbenchDocumentRanking,
|
||||
WorkbenchOverviewQuery,
|
||||
WorkbenchOverviewResponse,
|
||||
SigningApiOptions,
|
||||
SigningTemplate,
|
||||
SigningTaskRecord,
|
||||
SigningTaskQuery,
|
||||
WorkbenchTodoTask,
|
||||
WorkbenchTrendPoint,
|
||||
} from './types'
|
||||
@@ -26,6 +29,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
||||
本部院区: {
|
||||
summary: {
|
||||
todaySigned: 36,
|
||||
todaySignedChange: 2,
|
||||
pendingPatientSigning: 18,
|
||||
todayOverdue: 2,
|
||||
availableTemplates: 128,
|
||||
@@ -93,6 +97,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
||||
东院区: {
|
||||
summary: {
|
||||
todaySigned: 22,
|
||||
todaySignedChange: 1,
|
||||
pendingPatientSigning: 11,
|
||||
todayOverdue: 1,
|
||||
availableTemplates: 76,
|
||||
@@ -160,6 +165,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
||||
西院区: {
|
||||
summary: {
|
||||
todaySigned: 17,
|
||||
todaySignedChange: 2,
|
||||
pendingPatientSigning: 9,
|
||||
todayOverdue: 2,
|
||||
availableTemplates: 54,
|
||||
@@ -246,6 +252,47 @@ function createRanking(
|
||||
.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 === '西院区'
|
||||
}
|
||||
@@ -268,8 +315,31 @@ function isSameDay(left: Date | null, right: Date) {
|
||||
)
|
||||
}
|
||||
|
||||
function getTaskDate(task: SigningTaskRecord) {
|
||||
return parseDate(task.createdAt) ?? parseDate(task.updatedAt)
|
||||
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) {
|
||||
@@ -284,10 +354,15 @@ function createLiveOverview(
|
||||
): WorkbenchOverviewResponse {
|
||||
const today = new Date()
|
||||
const signedTasks = tasks.filter((task) => task.status === 'signed')
|
||||
const todaySigned = signedTasks.filter((task) => isSameDay(getTaskDate(task), today)).length
|
||||
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(getTaskDate(task), today),
|
||||
(task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today),
|
||||
).length
|
||||
|
||||
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
|
||||
@@ -297,15 +372,15 @@ function createLiveOverview(
|
||||
|
||||
return {
|
||||
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
||||
value: signedTasks.filter((task) => isSameDay(getTaskDate(task), date)).length,
|
||||
value: signedTasks.filter((task) => isSameDay(getSignedDate(task), date)).length,
|
||||
}
|
||||
})
|
||||
|
||||
const todos = tasks
|
||||
.filter((task) => isPendingTask(task) || task.status === 'expired')
|
||||
.sort((left, right) => {
|
||||
const leftDate = getTaskDate(left)?.getTime() ?? 0
|
||||
const rightDate = getTaskDate(right)?.getTime() ?? 0
|
||||
const leftDate = getUpdatedDate(left)?.getTime() ?? 0
|
||||
const rightDate = getUpdatedDate(right)?.getTime() ?? 0
|
||||
return rightDate - leftDate
|
||||
})
|
||||
.slice(0, 8)
|
||||
@@ -321,19 +396,22 @@ function createLiveOverview(
|
||||
}))
|
||||
|
||||
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
|
||||
signedTasks.forEach((task) => {
|
||||
const current = rankingMap.get(task.documentName)
|
||||
rankingMap.set(task.documentName, {
|
||||
id: current?.id ?? task.documentId,
|
||||
documentName: task.documentName,
|
||||
department: task.department,
|
||||
signedCount: (current?.signedCount ?? 0) + 1,
|
||||
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,
|
||||
@@ -361,38 +439,35 @@ export function getWorkbenchOverview(
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.allSettled([getSigningTemplates(), getCampuses()]).then(
|
||||
async ([templatesResult, campusesResult]) => {
|
||||
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 tasksResponse = await getSigningTasks(
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
status: 'all',
|
||||
dateRange: 'all',
|
||||
campus: 'all',
|
||||
method: 'all',
|
||||
},
|
||||
{ templates, campusNames },
|
||||
)
|
||||
const scopedTasks = Object.keys(campusNames).length
|
||||
? tasksResponse.records.filter((task) => task.campus === query.campus)
|
||||
: tasksResponse.records
|
||||
return getCampuses().then(async (campuses) => {
|
||||
const campus = campuses.find((item) => item.name === query.campus)
|
||||
|
||||
return createLiveOverview(
|
||||
scopedTasks,
|
||||
templates.length,
|
||||
new Set(templates.map((template) => template.department)).size,
|
||||
query.rankingPeriod,
|
||||
)
|
||||
},
|
||||
)
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AvailableTemplateVersionResponseDto,
|
||||
BackendSigningMethod,
|
||||
BackendSigningTaskStatus,
|
||||
BackendVisitType,
|
||||
CreateSigningTaskInput,
|
||||
CreateSigningTaskRequest,
|
||||
PatientProfile,
|
||||
@@ -563,7 +564,6 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
|
||||
pending: 'WAITING_SIGN',
|
||||
signing: 'GENERATING',
|
||||
signed: 'SIGNED',
|
||||
rejected: 'FAILED',
|
||||
expired: 'EXPIRED',
|
||||
void: 'VOIDED',
|
||||
failed: 'FAILED',
|
||||
@@ -579,6 +579,19 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
|
||||
params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD'
|
||||
}
|
||||
|
||||
if (query.visitType && query.visitType !== 'all') {
|
||||
const visitTypeMap: Partial<Record<VisitType, BackendVisitType>> = {
|
||||
门诊: 'OUTPATIENT',
|
||||
住院: 'INPATIENT',
|
||||
体检: 'CHECKUP',
|
||||
}
|
||||
const visitType = visitTypeMap[query.visitType]
|
||||
|
||||
if (visitType) {
|
||||
params.visitType = visitType
|
||||
}
|
||||
}
|
||||
|
||||
if (query.documentId) {
|
||||
params.templateVersionId =
|
||||
findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId
|
||||
@@ -604,8 +617,12 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
|
||||
params.createdTo = end.toISOString()
|
||||
}
|
||||
|
||||
if (query.campus && query.campus !== 'all' && options.campusId) {
|
||||
params.campusId = options.campusId
|
||||
if (query.campus && query.campus !== 'all') {
|
||||
const campusId = options.campusIds?.[query.campus] ?? options.campusId
|
||||
|
||||
if (campusId) {
|
||||
params.campusId = campusId
|
||||
}
|
||||
}
|
||||
|
||||
if (query.department && options.departmentIds?.[query.department]) {
|
||||
@@ -1031,7 +1048,7 @@ export async function resendSigningSms(
|
||||
options: SigningTaskActionOptions = {},
|
||||
): Promise<SigningTaskRecord | null> {
|
||||
if (!useMockData) {
|
||||
await resendSigningSmsDelivery(id, options.expectedRowVersion)
|
||||
await resendSigningSmsDelivery(id)
|
||||
return getSigningTaskDetail(id, options)
|
||||
}
|
||||
|
||||
@@ -1107,7 +1124,6 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
|
||||
pending: '待签署',
|
||||
signing: '签署中',
|
||||
signed: '已签署',
|
||||
rejected: '已拒签',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
|
||||
@@ -9,8 +9,7 @@ export interface WorkbenchOverviewQuery {
|
||||
rankingPeriod: HomeRankingPeriod
|
||||
}
|
||||
|
||||
export type SigningTaskStatus =
|
||||
'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' | 'failed'
|
||||
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
|
||||
|
||||
export type PatientSex = '男' | '女' | '未知'
|
||||
|
||||
@@ -56,6 +55,7 @@ export interface SigningTemplate {
|
||||
|
||||
export interface WorkbenchSummary {
|
||||
todaySigned: number
|
||||
todaySignedChange: number
|
||||
pendingPatientSigning: number
|
||||
todayOverdue: number
|
||||
availableTemplates: number
|
||||
@@ -171,6 +171,7 @@ export interface SigningApiOptions {
|
||||
templates?: SigningTemplate[]
|
||||
campus?: WorkbenchCampus
|
||||
campusId?: string
|
||||
campusIds?: Record<string, string>
|
||||
campusNames?: Record<string, WorkbenchCampus>
|
||||
departmentIds?: Record<string, string>
|
||||
}
|
||||
@@ -438,32 +439,29 @@ export interface SignTaskEventResponseDto {
|
||||
|
||||
export type SignArtifactType = 'ORIGINAL_PDF' | 'SIGNATURE_IMAGE' | 'SIGNED_PDF' | string
|
||||
|
||||
/** MEDISIGN 签署产物 DTO。部分文件元数据由不同版本的服务端返回,字段保持可选并在 API 边界归一化。 */
|
||||
export interface SignArtifactResponseDto {
|
||||
id: string
|
||||
taskId: string
|
||||
artifactType?: SignArtifactType
|
||||
type?: SignArtifactType
|
||||
fileName?: string | null
|
||||
mimeType?: string | null
|
||||
contentType?: string | null
|
||||
size?: number | null
|
||||
byteSize?: number | null
|
||||
fileSize?: number | null
|
||||
sha256?: string | null
|
||||
contentSha256?: string | null
|
||||
createdAt?: string | null
|
||||
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
|
||||
size: number
|
||||
sizeBytes: number
|
||||
sha256: string
|
||||
metadata: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ 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 {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getAuthErrorMessage } from '@/api/auth'
|
||||
import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
|
||||
import type { CaptchaResponse, LoginRequest } from '@/types/auth'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -11,9 +12,27 @@ const loginStore = useLoginStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const captcha = ref<CaptchaResponse | null>(null)
|
||||
const captchaCode = ref('')
|
||||
const captchaLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
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() {
|
||||
const redirect = route.query.redirect
|
||||
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
|
||||
@@ -29,22 +48,43 @@ async function handleLogin() {
|
||||
return
|
||||
}
|
||||
|
||||
if (captcha.value && !captchaCode.value.trim()) {
|
||||
errorMessage.value = '请输入图形验证码'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
await loginStore.login({
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -98,6 +138,30 @@ async function handleLogin() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="captcha" class="login-field login-captcha">
|
||||
<label for="login-captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<input
|
||||
id="login-captcha"
|
||||
v-model="captchaCode"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="text"
|
||||
maxlength="8"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="captcha-image-button"
|
||||
:disabled="captchaLoading"
|
||||
aria-label="刷新验证码"
|
||||
@click="loadCaptcha"
|
||||
>
|
||||
<img :src="captcha.imageBase64" alt="图形验证码,点击刷新" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<button class="login-submit" type="submit" :disabled="isSubmitting">
|
||||
@@ -192,6 +256,45 @@ async function handleLogin() {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-image-button {
|
||||
display: grid;
|
||||
width: 112px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #f4f7f9;
|
||||
border: 1px solid var(--line);
|
||||
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 {
|
||||
margin: -4px 0 10px;
|
||||
color: var(--err);
|
||||
|
||||
@@ -226,7 +226,7 @@ function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): R
|
||||
? 'signed'
|
||||
: task.status === 'expired'
|
||||
? 'expired'
|
||||
: task.status === 'void' || task.status === 'failed' || task.status === 'rejected'
|
||||
: task.status === 'void' || task.status === 'failed'
|
||||
? 'void'
|
||||
: 'pending'
|
||||
|
||||
|
||||
@@ -27,11 +27,21 @@ const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const overview = ref<WorkbenchOverviewResponse | null>(null)
|
||||
const rankingPeriod = ref<HomeRankingPeriod>(14)
|
||||
let overviewRequestId = 0
|
||||
|
||||
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
||||
|
||||
const metrics = computed<HomeMetricCardData[]>(() => {
|
||||
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 [
|
||||
{
|
||||
@@ -39,8 +49,8 @@ const metrics = computed<HomeMetricCardData[]>(() => {
|
||||
label: '今日已签署(单)',
|
||||
value: summary?.todaySigned ?? 0,
|
||||
tone: 'success',
|
||||
detail: '▲ 较昨日 +2 · 点击查看',
|
||||
detailTone: 'up',
|
||||
detail: signedChangeDetail,
|
||||
detailTone: signedChangeTone,
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
@@ -74,26 +84,42 @@ const todoItems = computed<HomeTodoItem[]>(() => overview.value?.todos ?? [])
|
||||
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
|
||||
|
||||
async function loadOverview() {
|
||||
const requestId = ++overviewRequestId
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
|
||||
try {
|
||||
overview.value = await getWorkbenchOverview({
|
||||
const nextOverview = await getWorkbenchOverview({
|
||||
campus: selectedCampus.value,
|
||||
rankingPeriod: rankingPeriod.value,
|
||||
})
|
||||
if (requestId !== overviewRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
overview.value = nextOverview
|
||||
} catch {
|
||||
if (requestId !== overviewRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
overview.value = null
|
||||
loadFailed.value = true
|
||||
ElMessage.error('首页数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === overviewRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToSigning(query: Record<string, string> = {}) {
|
||||
void router.push({
|
||||
name: 'workbench-signing',
|
||||
query,
|
||||
query: {
|
||||
campus: selectedCampus.value,
|
||||
...query,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function formatFileSize(size: number) {
|
||||
</div>
|
||||
<div class="artifact-info">
|
||||
<strong>{{ artifact.label }}</strong>
|
||||
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.size) }}</span>
|
||||
<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)">
|
||||
|
||||
@@ -93,7 +93,7 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="task.status === 'expired' || task.status === 'failed'">
|
||||
<template v-else-if="task.status === 'expired'">
|
||||
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
||||
↻ 重新发起
|
||||
</button>
|
||||
@@ -105,7 +105,7 @@ const emit = defineEmits<{
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('action', 'download-pdf')"
|
||||
>
|
||||
下载 PDF 原件
|
||||
下载签署后 PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
|
||||
defineProps<{
|
||||
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: '已签署',
|
||||
rejected: '已拒签',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
@@ -100,6 +106,21 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
<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>
|
||||
|
||||
@@ -212,7 +233,6 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.task-status--rejected,
|
||||
.task-status--expired {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
@@ -292,6 +312,42 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
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;
|
||||
|
||||
@@ -48,18 +48,17 @@ const STATUS_VALUES: SigningTaskStatus[] = [
|
||||
'pending',
|
||||
'signing',
|
||||
'signed',
|
||||
'rejected',
|
||||
'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: '已签署',
|
||||
rejected: '已拒签',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
@@ -78,6 +77,8 @@ const filter = reactive<SigningFilterForm>({
|
||||
|
||||
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[]>([])
|
||||
@@ -145,6 +146,7 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
|
||||
const signingApiOptions = computed(() => ({
|
||||
campus: appStore.selectedCampus,
|
||||
campusId: campusIds.value[appStore.selectedCampus],
|
||||
campusIds: campusIds.value,
|
||||
campusNames: campusNames.value,
|
||||
departmentIds: departmentIds.value,
|
||||
templates: templates.value,
|
||||
@@ -162,12 +164,18 @@ 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() {
|
||||
@@ -275,7 +283,11 @@ async function loadTaskDetail(id: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks(preferredTaskId?: string) {
|
||||
async function loadTasks(preferredTaskId?: string, resetPage = false) {
|
||||
if (resetPage) {
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
const requestId = ++listRequestId
|
||||
loading.value = true
|
||||
|
||||
@@ -283,8 +295,8 @@ async function loadTasks(preferredTaskId?: string) {
|
||||
const response = await getSigningTasks(
|
||||
{
|
||||
...filter,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
},
|
||||
signingApiOptions.value,
|
||||
)
|
||||
@@ -295,13 +307,15 @@ async function loadTasks(preferredTaskId?: string) {
|
||||
|
||||
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 nextTaskId =
|
||||
[preferredTaskId, selectedTaskId.value, routeTaskId].find((id) => id && taskIds.has(id)) ??
|
||||
response.records[0]?.id ??
|
||||
null
|
||||
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)
|
||||
@@ -323,7 +337,7 @@ async function loadTasks(preferredTaskId?: string) {
|
||||
function handleSearch(nextFilter: SigningFilterForm) {
|
||||
Object.assign(filter, nextFilter)
|
||||
selectedTaskId.value = null
|
||||
void loadTasks()
|
||||
void loadTasks(undefined, true)
|
||||
}
|
||||
|
||||
function selectTask(id: string) {
|
||||
@@ -331,13 +345,25 @@ function selectTask(id: string) {
|
||||
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)
|
||||
await loadTasks(updatedTask.id, true)
|
||||
ElMessage.success(successMessage)
|
||||
}
|
||||
|
||||
@@ -389,15 +415,18 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'reopen' && task.status !== 'expired') {
|
||||
ElMessage.info('只有已超时任务可以重新发起')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'download-pdf') {
|
||||
const artifact =
|
||||
artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF') ??
|
||||
artifacts.value.find((item) => item.artifactType === 'ORIGINAL_PDF')
|
||||
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
|
||||
|
||||
if (artifact) {
|
||||
await downloadArtifact(artifact)
|
||||
} else {
|
||||
ElMessage.info('当前任务暂无 PDF 文件')
|
||||
ElMessage.info('当前任务暂无签署后 PDF')
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -535,13 +564,14 @@ async function initializePage() {
|
||||
}
|
||||
|
||||
async function handleTaskCreated(task: SigningTaskRecord) {
|
||||
await loadTasks(task.id)
|
||||
await loadTasks(task.id, true)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.status, route.query.range, route.query.taskId],
|
||||
() => [route.query.status, route.query.range, route.query.campus, route.query.taskId],
|
||||
() => {
|
||||
syncFilterFromRoute()
|
||||
page.value = 1
|
||||
selectedTaskId.value = null
|
||||
void loadTasks()
|
||||
},
|
||||
@@ -553,6 +583,7 @@ watch(
|
||||
if (filter.campus !== 'all') {
|
||||
filter.campus = campus
|
||||
}
|
||||
page.value = 1
|
||||
void loadTasks()
|
||||
},
|
||||
)
|
||||
@@ -593,8 +624,11 @@ onMounted(() => {
|
||||
:tasks="tasks"
|
||||
:selected-id="selectedTaskId"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:loading="loading"
|
||||
@select="selectTask"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
<SigningTaskDetail
|
||||
:task="selectedTask"
|
||||
|
||||
@@ -11,7 +11,11 @@ import type {
|
||||
|
||||
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
|
||||
return request
|
||||
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload)
|
||||
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
||||
headers: {
|
||||
'Idempotency-Key': createIdempotencyKey(),
|
||||
},
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user