Compare commits

...

6 Commits

Author SHA1 Message Date
yelan
8b4b9d031d docs: 更新接口接入与环境配置说明 2026-09-01 10:14:23 +08:00
yelan
40cd313786 feat(patient-h5): 接入签署令牌与签名上传 2026-09-01 10:13:45 +08:00
yelan
e467f03f23 feat(management): 接入文档用户权限与报表 2026-09-01 10:13:11 +08:00
yelan
beeb98d18f feat(workbench): 接入签署投递与产物管理 2026-09-01 10:12:22 +08:00
yelan
2e2d2250dd feat(api): 接入医签通业务接口与类型 2026-09-01 10:11:49 +08:00
yelan
c34ef6f79a fix(auth): 统一认证响应与异常处理 2026-09-01 10:11:03 +08:00
47 changed files with 5162 additions and 180 deletions

View File

@@ -8,7 +8,7 @@
- `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面; - `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面;
- `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面; - `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面;
- 当前页面使用演示数据,尚未接入真实后端接口 - 默认仍使用演示数据,但医护端已按 MEDISIGN 接口文档接入登录、患者/就诊、模板、签署任务、文件产物、投递、用户组织和模板权限等真实接口;患者 H5 已接入一次性 Token 消费和签名上传请求
- `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。 - `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。
## 项目定位 ## 项目定位

View File

@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态 ## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。报表、用户权限、文档权限和系统设置仍保留页面骨架。登录接口和签署工作台的患者就诊模板任务审计查询/部分任务操作已接入 MEDISIGN 后端;其余页面仍使用 Mock 或占位实现 已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。登录、首页统计、签署工作台的患者/就诊/模板/任务/审计、签署文件下载、短信重发、模板创建/版本工作流、用户组织查询与用户创建编辑,以及按模板维护文档权限已经接入 MEDISIGN 后端;系统设置仍保留 Mock真实签字板/线上签名回调和打印服务仍需设备或后端能力
## 当前路由结构 ## 当前路由结构
@@ -119,10 +119,15 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `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/organization.ts`
- `api/management/document-permissions.ts` - `api/management/document-permissions.ts`
- `api/management/audit.ts`
- `api/management/settings.ts` - `api/management/settings.ts`
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端: 所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
@@ -142,10 +147,23 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `POST /api/v1/sign-tasks/{id}/void``/resend``/reopen`:作废、短信重发和重新开启; - `POST /api/v1/sign-tasks/{id}/void``/resend``/reopen`:作废、短信重发和重新开启;
- `GET /api/v1/sign-tasks/{id}/events`:操作审计事件。 - `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/DELETE /api/v1/templates/{id}/permissions`:按模板查询和维护文档权限;
- `GET /api/v1/audit-logs`:全局审计日志查询 API。
报表页在真实模式下使用 `GET /api/v1/sign-tasks` 聚合当前任务数据并支持跳转任务和导出当前筛选结果MEDISIGN 文档中暂无独立的报表统计接口。首页同样从模板、院区和签署任务接口聚合工作台概览。
API 请求和响应类型按业务域放在 `api/workbench/types.ts``api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO不向后端发送患者快照、文档名称或明文手机号等页面字段。 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 开发服务。 开发环境默认使用 `/api` 作为同源接口前缀Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。除登录外,当前业务 API 模块默认返回 Mock 数据,设置 `VITE_USE_MOCK=false` 后切换签署工作台及其他已实现 API 的真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。除登录外,当前业务 API 模块默认返回 Mock 数据,设置 `VITE_USE_MOCK=false` 后切换已实现的真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调、PDF 原件下载、签名原图下载和打印服务。真实接口模式下这些演示按钮会隐藏或提示待接入Mock 模式仍可用于演示完整交互 当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。默认 Mock 模式仍可用于演示完整交互,联调时设置 `VITE_USE_MOCK=false`

View File

@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { ApiResponseError, unwrapApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { import type {
AuthenticatedUser, AuthenticatedUser,
@@ -9,33 +10,7 @@ import type {
} from '@/types/auth' } from '@/types/auth'
import type { ApiResponse } from '@/types/common' import type { ApiResponse } from '@/types/common'
export class ApiResponseError extends Error { export { ApiResponseError } from '@/utils/api-response'
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
}
}
function isSuccessCode(code: number | string) {
return code === 0 || code === '0'
}
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
}
function normalizePermissions(value: unknown): string[] { function normalizePermissions(value: unknown): string[] {
if (Array.isArray(value)) { if (Array.isArray(value)) {

View 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) }
})
}

View File

@@ -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)
}

View File

@@ -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)
} }

View 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)
}

View 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 || '角色删除失败')
}
})
}

View File

@@ -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,198 @@ 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 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 +253,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 +350,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>

View File

@@ -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)
})
}

View File

@@ -0,0 +1,121 @@
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 ?? dto.type ?? 'UNKNOWN'
const label = artifactLabels[artifactType] ?? '签署文件'
const mimeType = dto.mimeType ?? dto.contentType ?? 'application/octet-stream'
const extension =
mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin'
return {
id: dto.id,
taskId: dto.taskId,
artifactType,
label,
fileName: dto.fileName || `${label}.${extension}`,
mimeType,
size: dto.size ?? dto.byteSize ?? dto.fileSize ?? 0,
sha256: dto.sha256 ?? dto.contentSha256 ?? '',
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',
})
}

View File

@@ -0,0 +1,152 @@
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,
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,
{ 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): Promise<TokenConsumeResponse> {
return request
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload)
.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)
}

View File

@@ -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,7 @@ import type {
WorkbenchDocumentRanking, WorkbenchDocumentRanking,
WorkbenchOverviewQuery, WorkbenchOverviewQuery,
WorkbenchOverviewResponse, WorkbenchOverviewResponse,
SigningTaskRecord,
WorkbenchTodoTask, WorkbenchTodoTask,
WorkbenchTrendPoint, WorkbenchTrendPoint,
} from './types' } from './types'
@@ -243,6 +246,107 @@ function createRanking(
.sort((left, right) => right.signedCount - left.signedCount) .sort((left, right) => right.signedCount - left.signedCount)
} }
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 getTaskDate(task: SigningTaskRecord) {
return parseDate(task.createdAt) ?? parseDate(task.updatedAt)
}
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(getTaskDate(task), today)).length
const pendingTasks = tasks.filter(isPendingTask)
const todayOverdue = tasks.filter(
(task) => task.status === 'expired' && isSameDay(getTaskDate(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(getTaskDate(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
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.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,
})
})
return {
summary: {
todaySigned,
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 +361,38 @@ export function getWorkbenchOverview(
}) })
} }
return request.get<WorkbenchOverviewResponse>('/workbench/overview', { return Promise.allSettled([getSigningTemplates(), getCampuses()]).then(
params: query, 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 createLiveOverview(
scopedTasks,
templates.length,
new Set(templates.map((template) => template.department)).size,
query.rankingPeriod,
)
},
)
} }

View File

@@ -1,6 +1,8 @@
import axios from 'axios' import axios from 'axios'
import { unwrapApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import { resendSigningSms as resendSigningSmsDelivery } from './deliveries'
import type { import type {
ApiPageResponse, ApiPageResponse,
@@ -368,22 +370,6 @@ function addHours(value: string, hours: number) {
export const isSigningMockEnabled = useMockData export const isSigningMockEnabled = useMockData
function isSuccessCode(code: number | string) {
return code === 0 || code === '0'
}
function unwrapApiResponse<T>(response: ApiResponseOf<T>): T {
if (!isSuccessCode(response.code)) {
throw new Error(response.message || '接口请求失败')
}
if (response.data === null) {
throw new Error(response.message || '接口未返回业务数据')
}
return response.data
}
function isNotFoundError(error: unknown) { function isNotFoundError(error: unknown) {
return axios.isAxiosError(error) && error.response?.status === 404 return axios.isAxiosError(error) && error.response?.status === 404
} }
@@ -521,7 +507,8 @@ function mapSigningTask(
return { return {
id: dto.id, id: dto.id,
templateVersionId: dto.templateVersionId, templateVersionId: dto.templateVersionId,
campus: options.campus ?? '本部院区', campus: options.campusNames?.[dto.campusId] ?? options.campus ?? '本部院区',
campusId: dto.campusId,
patientId: dto.patientSnapshot.patientId, patientId: dto.patientSnapshot.patientId,
patientName: dto.patientSnapshot.name, patientName: dto.patientSnapshot.name,
sex: normalizePatientSex(dto.patientSnapshot.sex), sex: normalizePatientSex(dto.patientSnapshot.sex),
@@ -532,11 +519,13 @@ function mapSigningTask(
documentId: template?.id ?? dto.templateVersionId, documentId: template?.id ?? dto.templateVersionId,
documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`, documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`,
department: dto.visitSnapshot.departmentName || '未指定科室', department: dto.visitSnapshot.departmentName || '未指定科室',
departmentId: dto.departmentId,
signerName: dto.patientSnapshot.name, signerName: dto.patientSnapshot.name,
method: normalizeSigningMethod(dto.signMethod), method: normalizeSigningMethod(dto.signMethod),
status: normalizeTaskStatus(dto.status), status: normalizeTaskStatus(dto.status),
source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—', source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—',
expiresAt: formatApiDateTime(dto.expiredAt), expiresAt: formatApiDateTime(dto.expiredAt),
createdAt: formatApiDateTime(dto.createdAt),
updatedAt: formatApiDateTime(dto.updatedAt), updatedAt: formatApiDateTime(dto.updatedAt),
rowVersion: dto.rowVersion, rowVersion: dto.rowVersion,
backendStatus: dto.status, backendStatus: dto.status,
@@ -559,7 +548,7 @@ function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate
} }
} }
function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate[]) { function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions = {}) {
const params: Record<string, string | number> = { const params: Record<string, string | number> = {
page: Math.max(query.page, 1), page: Math.max(query.page, 1),
size: Math.min(Math.max(query.pageSize, 1), 200), size: Math.min(Math.max(query.pageSize, 1), 200),
@@ -592,7 +581,7 @@ function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate
if (query.documentId) { if (query.documentId) {
params.templateVersionId = params.templateVersionId =
findTemplate(templates, query.documentId)?.versionId ?? query.documentId findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId
} }
const now = new Date() const now = new Date()
@@ -615,8 +604,14 @@ function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate
params.createdTo = end.toISOString() params.createdTo = end.toISOString()
} }
// 院区、科室和就诊类型在页面中使用展示名称,不能直接当作 UUID 发送。 if (query.campus && query.campus !== 'all' && options.campusId) {
// 后端会按当前登录用户的数据范围默认过滤,待院区/科室字典接入后再补充 ID 映射。 params.campusId = options.campusId
}
if (query.department && options.departmentIds?.[query.department]) {
params.departmentId = options.departmentIds[query.department]
}
return params return params
} }
@@ -675,7 +670,7 @@ export async function getSigningTasks(
if (!useMockData) { if (!useMockData) {
const response = await request.get<ApiResponseOf<ApiPageResponse<SignTaskResponseDto>>>( const response = await request.get<ApiResponseOf<ApiPageResponse<SignTaskResponseDto>>>(
'/v1/sign-tasks', '/v1/sign-tasks',
{ params: toBackendTaskQuery(query, options.templates) }, { params: toBackendTaskQuery(query, options) },
) )
const page = unwrapApiResponse(response) const page = unwrapApiResponse(response)
@@ -853,12 +848,15 @@ export async function getSigningTemplates(
} = {}, } = {},
): Promise<SigningTemplate[]> { ): Promise<SigningTemplate[]> {
if (!useMockData) { if (!useMockData) {
const response = await request.get<ApiResponseOf<AvailableTemplateVersionResponseDto[]>>( const response = await request.get<
'/v1/templates/available-versions', ApiResponseOf<
{ params: query }, AvailableTemplateVersionResponseDto[] | ApiPageResponse<AvailableTemplateVersionResponseDto>
) >
>('/v1/templates/available-versions', { params: query })
return unwrapApiResponse(response).map(mapAvailableTemplate) const data = unwrapApiResponse(response)
const records = Array.isArray(data) ? data : data.records
return records.map(mapAvailableTemplate)
} }
return Promise.resolve(mockTemplates.map((template) => ({ ...template }))) return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
@@ -1033,11 +1031,7 @@ export async function resendSigningSms(
options: SigningTaskActionOptions = {}, options: SigningTaskActionOptions = {},
): Promise<SigningTaskRecord | null> { ): Promise<SigningTaskRecord | null> {
if (!useMockData) { if (!useMockData) {
const response = await request.post<ApiResponseOf<SignTaskSendPreparationResponseDto>>( await resendSigningSmsDelivery(id, options.expectedRowVersion)
`/v1/sign-tasks/${encodeURIComponent(id)}/resend`,
getTaskActionBody(options.expectedRowVersion),
)
unwrapApiResponse(response)
return getSigningTaskDetail(id, options) return getSigningTaskDetail(id, options)
} }

View File

@@ -122,14 +122,17 @@ export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
export interface SigningTaskRecord extends WorkbenchTask { export interface SigningTaskRecord extends WorkbenchTask {
templateVersionId: string templateVersionId: string
campus: WorkbenchCampus campus: WorkbenchCampus
campusId?: string
patientId: string patientId: string
sex: PatientSex sex: PatientSex
age: number age: number
documentId: string documentId: string
departmentId?: string | null
signerName: string signerName: string
method: SigningMethod method: SigningMethod
source: string source: string
expiresAt: string expiresAt: string
createdAt?: string
visitType: VisitType visitType: VisitType
visitDate: string visitDate: string
rowVersion?: number rowVersion?: number
@@ -167,6 +170,9 @@ export interface CreateSigningTaskRequest {
export interface SigningApiOptions { export interface SigningApiOptions {
templates?: SigningTemplate[] templates?: SigningTemplate[]
campus?: WorkbenchCampus campus?: WorkbenchCampus
campusId?: string
campusNames?: Record<string, WorkbenchCampus>
departmentIds?: Record<string, string>
} }
export interface SigningTaskActionOptions extends SigningApiOptions { export interface SigningTaskActionOptions extends SigningApiOptions {
@@ -301,6 +307,113 @@ export interface SignTaskSendPreparationResponseDto {
rowVersion: number 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 { export interface SignTaskEventResponseDto {
id: string id: string
taskId: string taskId: string
@@ -323,6 +436,37 @@ export interface SignTaskEventResponseDto {
detail: Record<string, unknown> | null detail: Record<string, unknown> | null
} }
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
}
export interface SigningArtifact {
id: string
taskId: string
artifactType: SignArtifactType
label: string
fileName: string
mimeType: string
size: number
sha256: string
createdAt: string
}
export interface SigningTaskEvent { export interface SigningTaskEvent {
id: string id: string
time: string time: string

View File

@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { isSigningMockEnabled } from '@/api/workbench/signing'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types' import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import { useSigningTaskForm } from '@/composables/useSigningTaskForm' import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
@@ -41,6 +42,7 @@ const {
selectedTemplateId, selectedTemplateId,
selectedVisit, selectedVisit,
selectedVisitId, selectedVisitId,
smsDestination,
submit: submitForm, submit: submitForm,
submitting, submitting,
visits, visits,
@@ -58,7 +60,9 @@ async function submitTask(keepOpen: boolean) {
} }
emit('created', task) emit('created', task)
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',已生成短信投递意图' : ''}`) const smsMessage =
task.method === 'sms' ? (isSigningMockEnabled ? ',已生成短信投递意图' : ',短信已发送') : ''
ElMessage.success(`已创建 1 个签署任务${smsMessage}`)
if (keepOpen) { if (keepOpen) {
resetForNextTask() resetForNextTask()
@@ -285,7 +289,25 @@ watch(
<strong>短信将发送至</strong> <strong>短信将发送至</strong>
<span>{{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}</span> <span>{{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}</span>
</div> </div>
<p>使用患者权威记录中的手机号具体投递结果以服务端返回为准</p> <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>
</div> </div>
</section> </section>
@@ -826,6 +848,42 @@ watch(
font-size: 11.5px; 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 { .dialog-footer {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -894,5 +952,14 @@ watch(
.dialog-footer > span:first-child { .dialog-footer > span:first-child {
width: 100%; width: 100%;
} }
.sms-destination-field {
align-items: flex-start;
flex-direction: column;
}
.sms-destination-field input {
width: 100%;
}
} }
</style> </style>

View File

@@ -1,7 +1,13 @@
import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue' import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { createSigningTask, getPatientProfile, getSigningTemplates } from '@/api/workbench/signing' import { sendSigningSms } from '@/api/workbench/deliveries'
import {
createSigningTask,
getPatientProfile,
getSigningTemplates,
isSigningMockEnabled,
} from '@/api/workbench/signing'
import type { import type {
PatientProfile, PatientProfile,
PatientVisit, PatientVisit,
@@ -35,6 +41,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const selectedTemplateId = ref('') const selectedTemplateId = ref('')
const selectedVisitId = ref('') const selectedVisitId = ref('')
const method = ref<SigningMethod>('pad') const method = ref<SigningMethod>('pad')
const smsDestination = ref('')
const loadingTemplates = ref(false) const loadingTemplates = ref(false)
const locating = ref(false) const locating = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -43,6 +50,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
patient: '', patient: '',
visit: '', visit: '',
document: '', document: '',
sms: '',
}) })
const selectedVisit = computed( const selectedVisit = computed(
@@ -104,6 +112,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
errors.patient = '' errors.patient = ''
errors.visit = '' errors.visit = ''
errors.document = '' errors.document = ''
errors.sms = ''
} }
function resetForm() { function resetForm() {
@@ -116,6 +125,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedTemplateId.value = initialTemplate?.id ?? '' selectedTemplateId.value = initialTemplate?.id ?? ''
selectedVisitId.value = '' selectedVisitId.value = ''
method.value = initialTemplate?.supportedMethods[0] ?? 'pad' method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
smsDestination.value = ''
clearErrors() clearErrors()
} }
@@ -125,7 +135,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
documentSearch.value = '' documentSearch.value = ''
selectedTemplateId.value = initialTemplate?.id ?? '' selectedTemplateId.value = initialTemplate?.id ?? ''
method.value = initialTemplate?.supportedMethods[0] ?? method.value method.value = initialTemplate?.supportedMethods[0] ?? method.value
smsDestination.value = ''
errors.document = '' errors.document = ''
errors.sms = ''
} }
async function loadTemplates() { async function loadTemplates() {
@@ -228,6 +240,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
valid = false 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 return valid
} }
@@ -239,7 +260,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
submitting.value = true submitting.value = true
try { try {
return await createSigningTask({ const task = await createSigningTask({
campus: toValue(options.campus), campus: toValue(options.campus),
patientId: patient.value.id, patientId: patient.value.id,
visitId: selectedVisit.value.id, visitId: selectedVisit.value.id,
@@ -251,6 +272,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
visitNo: selectedVisit.value.visitNo, visitNo: selectedVisit.value.visitNo,
department: selectedVisit.value.department, 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 { } finally {
submitting.value = false submitting.value = false
} }
@@ -277,6 +307,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedTemplateId, selectedTemplateId,
selectedVisit, selectedVisit,
selectedVisitId, selectedVisitId,
smsDestination,
submit, submit,
submitting, submitting,
templates, templates,

View 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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -2,6 +2,18 @@
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 NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import type { SigningTemplate } from '@/api/workbench/types' import type { SigningTemplate } from '@/api/workbench/types'
@@ -9,20 +21,53 @@ 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 signingTemplate = ref<SigningTemplate | null>(null)
const editorTemplate = ref<DocumentTemplate | null>(null)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const signingDialogVisible = ref(false) 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: '',
@@ -76,39 +121,362 @@ 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 closeEditor() {
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 { function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
return { return {
id: template.id, id: template.id,
versionId: template.id, versionId: template.versionId ?? template.id,
name: template.name, name: template.name,
code: template.code, code: template.code,
version: template.version, version: template.version,
department: template.department, department: template.department,
departmentId: template.departmentId,
campusId: template.campusId ?? undefined,
category: template.category, category: template.category,
description: template.description, description: template.description,
supportedMethods: ['pad', 'sms'], supportedMethods: ['pad', 'sms'],
@@ -191,6 +559,8 @@ 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"
/> />
@@ -199,6 +569,17 @@ onMounted(loadLibrary)
:initial-template="signingTemplate" :initial-template="signingTemplate"
@close="closeSigningDialog" @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>

View File

@@ -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

View File

@@ -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' || task.status === 'rejected'
? '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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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.size) }}</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>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types' import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
import SigningDocumentPreview from './SigningDocumentPreview.vue' import SigningDocumentPreview from './SigningDocumentPreview.vue'
import SigningArtifactList from './SigningArtifactList.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue' import TaskAuditTimeline from './TaskAuditTimeline.vue'
import type { SigningTaskAction } from '../types' import type { SigningTaskAction } from '../types'
@@ -12,10 +13,16 @@ defineProps<{
auditLoading: boolean auditLoading: boolean
auditError: boolean auditError: boolean
allowLocalSigning: boolean allowLocalSigning: boolean
artifacts: SigningArtifact[]
artifactsLoading: boolean
artifactsError: boolean
padSessionStatus: string | null
padSessionLoading: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
action: [action: SigningTaskAction] action: [action: SigningTaskAction]
downloadArtifact: [artifact: SigningArtifact]
}>() }>()
</script> </script>
@@ -43,7 +50,20 @@ const emit = defineEmits<{
转为短信发送 转为短信发送
</button> </button>
</template> </template>
<span v-else class="action-hint">手写板设备签署接口待接入</span> <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>
<template v-else-if="task.status === 'pending' && task.method === 'sms'"> <template v-else-if="task.status === 'pending' && task.method === 'sms'">
@@ -120,6 +140,12 @@ const emit = defineEmits<{
<SigningDocumentPreview :task="task" /> <SigningDocumentPreview :task="task" />
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" /> <TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
<SigningArtifactList
:artifacts="artifacts"
:loading="artifactsLoading"
:error="artifactsError"
@download="emit('downloadArtifact', $event)"
/>
</template> </template>
<div v-else class="detail-empty"> <div v-else class="detail-empty">

View File

@@ -3,6 +3,9 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router' 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 { import {
completeSigningTask, completeSigningTask,
getSigningTaskDetail, getSigningTaskDetail,
@@ -18,6 +21,8 @@ import {
import type { import type {
SigningDateRange, SigningDateRange,
SigningMethod, SigningMethod,
SigningArtifact,
SignDeliveryResponseDto,
SigningTaskEvent, SigningTaskEvent,
SigningTaskRecord, SigningTaskRecord,
SigningTaskStatus, SigningTaskStatus,
@@ -76,11 +81,19 @@ const total = ref(0)
const selectedTaskId = ref<string | null>(null) const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null) const selectedTask = ref<SigningTaskRecord | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([]) const taskEvents = ref<SigningTaskEvent[]>([])
const artifacts = ref<SigningArtifact[]>([])
const templates = ref<SigningTemplate[]>([]) const templates = ref<SigningTemplate[]>([])
const loading = ref(true) const loading = ref(true)
const detailLoading = ref(false) const detailLoading = ref(false)
const eventsLoading = ref(false) const eventsLoading = ref(false)
const eventsError = 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 newDialogVisible = ref(false)
const signatureDialogVisible = ref(false) const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false) const onlineDialogVisible = ref(false)
@@ -131,6 +144,9 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
const signingApiOptions = computed(() => ({ const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus, campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus],
campusNames: campusNames.value,
departmentIds: departmentIds.value,
templates: templates.value, templates: templates.value,
})) }))
@@ -162,27 +178,67 @@ async function loadTemplates() {
} }
} }
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) { async function loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId const requestId = ++detailRequestId
if (!id) { if (!id) {
selectedTask.value = null selectedTask.value = null
taskEvents.value = [] taskEvents.value = []
artifacts.value = []
detailLoading.value = false detailLoading.value = false
eventsLoading.value = false eventsLoading.value = false
eventsError.value = false eventsError.value = false
artifactsLoading.value = false
artifactsError.value = false
padSessionStatus.value = null
return return
} }
detailLoading.value = true detailLoading.value = true
eventsLoading.value = true eventsLoading.value = true
eventsError.value = false eventsError.value = false
artifactsLoading.value = true
artifactsError.value = false
taskEvents.value = [] taskEvents.value = []
artifacts.value = []
padSessionStatus.value = null
try { try {
const [detailResult, eventsResult] = await Promise.allSettled([ const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value), getSigningTaskDetail(id, signingApiOptions.value),
getSigningTaskEvents(id), getSigningTaskEvents(id),
getSigningArtifacts(id),
]) ])
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
@@ -197,6 +253,12 @@ async function loadTaskDetail(id: string | null) {
} else { } else {
eventsError.value = true eventsError.value = true
} }
if (artifactsResult.status === 'fulfilled') {
artifacts.value = artifactsResult.value
} else {
artifactsError.value = true
}
} }
} catch { } catch {
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
@@ -208,6 +270,7 @@ async function loadTaskDetail(id: string | null) {
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
detailLoading.value = false detailLoading.value = false
eventsLoading.value = false eventsLoading.value = false
artifactsLoading.value = false
} }
} }
} }
@@ -295,6 +358,22 @@ async function handleTaskAction(action: SigningTaskAction) {
return 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 (action === 'online-sign') {
if (!isSigningMockEnabled) { if (!isSigningMockEnabled) {
ElMessage.info('线上签署页面接口待接入') ElMessage.info('线上签署页面接口待接入')
@@ -311,12 +390,26 @@ async function handleTaskAction(action: SigningTaskAction) {
} }
if (action === 'download-pdf') { if (action === 'download-pdf') {
ElMessage.info('原型演示PDF 原件下载接口待接入') const artifact =
artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF') ??
artifacts.value.find((item) => item.artifactType === 'ORIGINAL_PDF')
if (artifact) {
await downloadArtifact(artifact)
} else {
ElMessage.info('当前任务暂无 PDF 文件')
}
return return
} }
if (action === 'download-signature') { if (action === 'download-signature') {
ElMessage.info('原型演示:签名原图下载接口待接入') const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNATURE_IMAGE')
if (artifact) {
await downloadArtifact(artifact)
} else {
ElMessage.info('当前任务暂无签名原图')
}
return return
} }
@@ -365,6 +458,21 @@ async function handleTaskAction(action: SigningTaskAction) {
} }
} }
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) { async function confirmPadSignature(signatureDataUrl: string) {
const task = selectedTask.value const task = selectedTask.value
@@ -421,6 +529,7 @@ async function handleOnlineResend() {
} }
async function initializePage() { async function initializePage() {
await loadOrganizationOptions()
await loadTemplates() await loadTemplates()
await loadTasks() await loadTasks()
} }
@@ -494,7 +603,13 @@ onMounted(() => {
:audit-loading="eventsLoading" :audit-loading="eventsLoading"
:audit-error="eventsError" :audit-error="eventsError"
:allow-local-signing="isSigningMockEnabled" :allow-local-signing="isSigningMockEnabled"
:artifacts="artifacts"
:artifacts-loading="artifactsLoading"
:artifacts-error="artifactsError"
:pad-session-status="padSessionStatus"
:pad-session-loading="padSessionLoading"
@action="handleTaskAction" @action="handleTaskAction"
@download-artifact="downloadArtifact"
/> />
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { import type {
PatientProfile, PatientProfile,
PatientVisit, PatientVisit,
SignDeliveryStatus,
SigningDateRange, SigningDateRange,
SigningMethod, SigningMethod,
SigningTaskRecord, SigningTaskRecord,
@@ -40,6 +41,7 @@ export interface NewSigningTaskState {
export type SigningTaskAction = export type SigningTaskAction =
| 'pad-sign' | 'pad-sign'
| 'create-pad-session'
| 'online-sign' | 'online-sign'
| 'resend-sms' | 'resend-sms'
| 'switch-method' | 'switch-method'
@@ -48,3 +50,5 @@ export type SigningTaskAction =
| 'download-pdf' | 'download-pdf'
| 'download-signature' | 'download-signature'
| 'print' | 'print'
export type SigningPadSessionStatus = SignDeliveryStatus | null

View File

@@ -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

View File

@@ -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 签名上传请求,并增加路由保护、重复提交保护和服务端结果展示。正式文书公开读取接口尚未提供,因此真实模式暂不会进入签署步骤

View File

@@ -1,6 +1,52 @@
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)
.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)
}

View 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
}

View File

@@ -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
}
} }

View File

@@ -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

View File

@@ -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 }> = [

View 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
}

View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)),