feat(workbench): 扩展签署任务管理功能

This commit is contained in:
yelan
2026-08-28 10:02:51 +08:00
parent b533ab2a58
commit 2de75ed993
14 changed files with 4287 additions and 37 deletions

View File

@@ -1,48 +1,334 @@
import { request } from '@/utils/request'
import type { SigningTaskListResponse, SigningTaskQuery, SigningTaskRecord } from './types'
import type {
CreateSigningTaskRequest,
PatientProfile,
SigningMethod,
SigningTaskListResponse,
SigningTaskQuery,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockPatients: PatientProfile[] = [
{
id: 'P202610001',
name: '李某某',
sex: '男',
age: 46,
idCard: '310***********1234',
phone: '13800005678',
visits: [
{
id: 'visit-001',
type: '住院',
visitNo: 'ZY20260827001',
department: '消化内科',
visitDate: '2026-08-27',
isCurrent: true,
},
{
id: 'visit-002',
type: '门诊',
visitNo: 'MZ20260812018',
department: '消化内科',
visitDate: '2026-08-12',
},
],
},
{
id: 'P202610002',
name: '王某某',
sex: '女',
age: 37,
idCard: '310***********6721',
phone: '13900006789',
visits: [
{
id: 'visit-003',
type: '门诊',
visitNo: 'JZ20260827018',
department: '儿科急诊',
visitDate: '2026-08-27',
isCurrent: true,
},
],
},
{
id: 'P202610003',
name: '赵某某',
sex: '男',
age: 52,
idCard: '310***********2846',
phone: '13700007890',
visits: [
{
id: 'visit-004',
type: '住院',
visitNo: 'ZY20260826027',
department: '消化内科',
visitDate: '2026-08-26',
isCurrent: true,
},
],
},
{
id: 'P202610004',
name: '周雅琴',
sex: '女',
age: 29,
idCard: '310***********9152',
phone: '13600008901',
visits: [
{
id: 'visit-005',
type: '体检',
visitNo: 'TJ20260825006',
department: '健康管理中心',
visitDate: '2026-08-25',
isCurrent: true,
},
],
},
{
id: 'P202610005',
name: '陈志强',
sex: '男',
age: 61,
idCard: '310***********3488',
phone: '13500009012',
visits: [
{
id: 'visit-006',
type: '住院',
visitNo: 'ZY20260826011',
department: '普外科',
visitDate: '2026-08-26',
isCurrent: true,
},
],
},
]
const mockTemplates: SigningTemplate[] = [
{
id: 'tpl-001',
name: '住院患者知情同意书',
code: 'DOC-HOS-001',
department: '全院通用',
category: '住院告知',
description: '住院期间诊疗事项、风险与患者权利告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-002',
name: '住院须知及告知书',
code: 'DOC-HOS-002',
department: '全院通用',
category: '住院告知',
description: '住院流程、陪护要求和患者须知确认。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-003',
name: '内镜检查知情同意书',
code: 'DOC-DIG-001',
department: '消化内科',
category: '检查告知',
description: '内镜检查适应证、风险及替代方案告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-004',
name: 'MRI 磁共振检查知情同意书',
code: 'DOC-RAD-001',
department: '放射科',
category: '检查告知',
description: '磁共振检查注意事项、禁忌证及风险告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-005',
name: '心脏介入手术知情同意书',
code: 'DOC-CARD-001',
department: '心内科',
category: '手术告知',
description: '介入治疗方案、围术期风险及替代方案告知。',
supportedMethods: ['pad'],
},
{
id: 'tpl-006',
name: '体检报告发放及知情同意书',
code: 'DOC-PE-001',
department: '健康管理中心',
category: '体检告知',
description: '体检报告领取方式、隐私保护和异常结果随访告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-007',
name: '患者授权委托书',
code: 'DOC-GEN-003',
department: '医务处',
category: '授权文书',
description: '患者授权家属或代理人办理相关医疗事项。',
supportedMethods: ['pad', 'sms'],
},
]
const mockRecords: SigningTaskRecord[] = [
{
id: 'task-001',
campus: '本部院区',
patientId: 'P202610001',
patientName: '李某某',
sex: '男',
age: 46,
visitNo: 'ZY20260827001',
visitType: '住院',
visitDate: '2026-08-27',
documentId: 'tpl-001',
documentName: '住院患者知情同意书',
department: '消化内科',
status: 'pending',
signerName: '李某某',
method: 'pad',
source: '电子病历',
expiresAt: '2026-08-28 09:30',
expiresAt: '2026-08-30 09:30',
updatedAt: '2026-08-27 09:30',
},
{
id: 'task-002',
campus: '东院区',
patientId: 'P202610002',
patientName: '王某某',
sex: '女',
age: 37,
visitNo: 'JZ20260827018',
documentName: '急诊留观知情同意书',
department: '儿科急诊',
visitType: '门诊',
visitDate: '2026-08-27',
documentId: 'tpl-004',
documentName: 'MRI 磁共振检查知情同意书',
department: '放射科',
status: 'signed',
signerName: '王某某家属',
method: 'sms',
source: '工作台发起',
expiresAt: '2026-08-27 18:00',
expiresAt: '2026-08-29 18:00',
updatedAt: '2026-08-27 09:12',
signedAt: '2026-08-27 09:12',
},
{
id: 'task-003',
campus: '本部院区',
patientId: 'P202610003',
patientName: '赵某某',
sex: '男',
age: 52,
visitNo: 'ZY20260826027',
visitType: '住院',
visitDate: '2026-08-26',
documentId: 'tpl-003',
documentName: '内镜检查知情同意书',
department: '消化内科',
status: 'rejected',
status: 'expired',
signerName: '赵某某家属',
method: 'sms',
source: '电子病历',
expiresAt: '2026-08-27 16:00',
updatedAt: '2026-08-27 08:45',
},
{
id: 'task-004',
campus: '本部院区',
patientId: 'P202610005',
patientName: '陈志强',
sex: '男',
age: 61,
visitNo: 'ZY20260826011',
visitType: '住院',
visitDate: '2026-08-26',
documentId: 'tpl-002',
documentName: '住院须知及告知书',
department: '普外科',
status: 'void',
signerName: '陈志强',
method: 'pad',
source: '电子病历',
expiresAt: '2026-08-29 11:00',
updatedAt: '2026-08-26 11:20',
voidReason: '文档选择错误',
},
{
id: 'task-005',
campus: '东院区',
patientId: 'P202610004',
patientName: '周雅琴',
sex: '女',
age: 29,
visitNo: 'TJ20260825006',
visitType: '体检',
visitDate: '2026-08-25',
documentId: 'tpl-006',
documentName: '体检报告发放及知情同意书',
department: '健康管理中心',
status: 'signing',
signerName: '周雅琴',
method: 'pad',
source: '工作台发起',
expiresAt: '2026-08-30 14:20',
updatedAt: '2026-08-27 08:20',
},
]
function clonePatient(patient: PatientProfile): PatientProfile {
return {
...patient,
visits: patient.visits.map((visit) => ({ ...visit })),
}
}
function cloneTask(task: SigningTaskRecord): SigningTaskRecord {
return { ...task }
}
function getRangeStart(range: SigningTaskQuery['dateRange']) {
if (!range || range === 'all') {
return null
}
const date = new Date('2026-08-27T00:00:00')
if (range === 'yesterday') {
date.setDate(date.getDate() - 1)
} else if (range === '3d') {
date.setDate(date.getDate() - 2)
} else if (range === '7d') {
date.setDate(date.getDate() - 6)
}
return date
}
function parseMockDate(value: string) {
return new Date(value.replace(' ', 'T'))
}
function formatMockDate(date = new Date('2026-08-27T12:00:00')) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function addHours(value: string, hours: number) {
const date = parseMockDate(value)
date.setHours(date.getHours() + hours)
return formatMockDate(date)
}
export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskListResponse> {
if (!useMockData) {
return request.get<SigningTaskListResponse>('/workbench/signing/tasks', {
@@ -51,23 +337,49 @@ export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskLis
}
const keyword = query.keyword?.trim().toLowerCase()
const filteredRecords = mockRecords.filter((record) => {
const matchesKeyword =
!keyword ||
[record.patientName, record.visitNo, record.documentName]
.join(' ')
.toLowerCase()
.includes(keyword)
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
const rangeStart = getRangeStart(query.dateRange)
const filteredRecords = mockRecords
.filter((record) => {
const matchesKeyword =
!keyword ||
[record.patientName, record.patientId, record.visitNo, record.documentName, record.id]
.join(' ')
.toLowerCase()
.includes(keyword)
const matchesStatus =
!query.status || query.status === 'all' || record.status === query.status
const matchesCampus =
!query.campus || query.campus === 'all' || record.campus === query.campus
const matchesMethod =
!query.method || query.method === 'all' || record.method === query.method
const matchesDepartment = !query.department || record.department === query.department
const matchesDocument = !query.documentId || record.documentId === query.documentId
const matchesVisitType =
!query.visitType || query.visitType === 'all' || record.visitType === query.visitType
const matchesDate = !rangeStart || parseMockDate(record.updatedAt) >= rangeStart
return (
matchesKeyword &&
matchesStatus &&
matchesCampus &&
matchesMethod &&
matchesDepartment &&
matchesDocument &&
matchesVisitType &&
matchesDate
)
})
.sort(
(left, right) =>
parseMockDate(right.updatedAt).getTime() - parseMockDate(left.updatedAt).getTime(),
)
return matchesKeyword && matchesStatus
})
const page = Math.max(query.page, 1)
const pageSize = Math.max(query.pageSize, 1)
const start = (page - 1) * pageSize
return Promise.resolve({
records: filteredRecords.slice(start, start + pageSize),
records: filteredRecords.slice(start, start + pageSize).map(cloneTask),
total: filteredRecords.length,
page,
pageSize,
@@ -79,5 +391,175 @@ export function getSigningTaskDetail(id: string): Promise<SigningTaskRecord | nu
return request.get<SigningTaskRecord>('/workbench/signing/tasks/' + id)
}
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null)
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null).then((task) =>
task ? cloneTask(task) : null,
)
}
export function getPatientProfile(keyword: string): Promise<PatientProfile | null> {
if (!useMockData) {
return request.get<PatientProfile>('/patients/' + encodeURIComponent(keyword))
}
const normalizedKeyword = keyword.trim()
const patient = mockPatients.find(
(item) =>
item.id === normalizedKeyword ||
item.visits.some((visit) => visit.visitNo === normalizedKeyword),
)
return Promise.resolve(patient ? clonePatient(patient) : null)
}
export function getSigningTemplates(): Promise<SigningTemplate[]> {
if (!useMockData) {
return request.get<SigningTemplate[]>('/workbench/signing/templates')
}
return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
}
export function createSigningTask(payload: CreateSigningTaskRequest): Promise<SigningTaskRecord> {
if (!useMockData) {
return request.post<SigningTaskRecord>('/workbench/signing/tasks', payload)
}
const patient = mockPatients.find((item) => item.id === payload.patientId)
const task: SigningTaskRecord = {
id: `task-${Date.now()}`,
campus: payload.campus,
patientId: payload.patientId,
patientName: patient?.name ?? '演示患者',
sex: patient?.sex ?? '男',
age: patient?.age ?? 40,
visitNo: payload.visitNo,
visitType: payload.visitType,
visitDate:
patient?.visits.find((visit) => visit.id === payload.visitId)?.visitDate ?? '2026-08-27',
documentId: payload.documentId,
documentName: payload.documentName,
department: payload.department,
signerName: patient?.name ?? '演示患者',
method: payload.method,
status: 'pending',
source: '工作台发起',
expiresAt: addHours('2026-08-27 12:00', 48),
updatedAt: formatMockDate(),
}
mockRecords.unshift(task)
return Promise.resolve(cloneTask(task))
}
export function updateSigningTaskMethod(
id: string,
method: SigningMethod,
): Promise<SigningTaskRecord | null> {
if (!useMockData) {
return request.put<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/method', { method })
}
const task = mockRecords.find((record) => record.id === id)
if (!task) {
return Promise.resolve(null)
}
task.method = method
task.updatedAt = formatMockDate()
return Promise.resolve(cloneTask(task))
}
export function completeSigningTask(
id: string,
signatureDataUrl: string,
): Promise<SigningTaskRecord | null> {
if (!useMockData) {
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/complete', {
signatureDataUrl,
})
}
const task = mockRecords.find((record) => record.id === id)
if (!task) {
return Promise.resolve(null)
}
task.status = 'signed'
task.signatureDataUrl = signatureDataUrl
task.signedAt = formatMockDate()
task.updatedAt = task.signedAt
return Promise.resolve(cloneTask(task))
}
export function reopenSigningTask(id: string): Promise<SigningTaskRecord | null> {
if (!useMockData) {
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/reopen')
}
const task = mockRecords.find((record) => record.id === id)
if (!task) {
return Promise.resolve(null)
}
task.status = 'pending'
task.signatureDataUrl = undefined
task.signedAt = undefined
task.updatedAt = formatMockDate()
return Promise.resolve(cloneTask(task))
}
export function voidSigningTask(id: string, reason: string): Promise<SigningTaskRecord | null> {
if (!useMockData) {
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/void', { reason })
}
const task = mockRecords.find((record) => record.id === id)
if (!task) {
return Promise.resolve(null)
}
task.status = 'void'
task.voidReason = reason
task.signatureDataUrl = undefined
task.updatedAt = formatMockDate()
return Promise.resolve(cloneTask(task))
}
export function resendSigningSms(id: string): Promise<SigningTaskRecord | null> {
if (!useMockData) {
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/resend-sms')
}
const task = mockRecords.find((record) => record.id === id)
if (!task) {
return Promise.resolve(null)
}
task.method = 'sms'
task.status = 'pending'
task.expiresAt = addHours('2026-08-27 12:00', 48)
task.updatedAt = formatMockDate()
return Promise.resolve(cloneTask(task))
}
export function getSigningStatusLabel(status: SigningTaskStatus) {
const labels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
}
return labels[status]
}
export function getVisitTypeLabel(type: VisitType) {
return type
}

View File

@@ -9,7 +9,38 @@ export interface WorkbenchOverviewQuery {
rankingPeriod: HomeRankingPeriod
}
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired'
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void'
export type VisitType = '门诊' | '住院' | '体检'
export interface PatientVisit {
id: string
type: VisitType
visitNo: string
department: string
visitDate: string
isCurrent?: boolean
}
export interface PatientProfile {
id: string
name: string
sex: '男' | '女'
age: number
idCard: string
phone: string
visits: PatientVisit[]
}
export interface SigningTemplate {
id: string
name: string
code: string
department: string
category: string
description: string
supportedMethods: SigningMethod[]
}
export interface WorkbenchSummary {
todaySigned: number
@@ -66,12 +97,44 @@ export interface WorkbenchOverviewResponse {
export interface SigningTaskQuery extends PageQuery {
keyword?: string
status?: SigningTaskStatus | 'all'
dateRange?: SigningDateRange
campus?: WorkbenchCampus | 'all'
method?: SigningMethod | 'all'
department?: string
documentId?: string
visitType?: VisitType | 'all'
}
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
export interface SigningTaskRecord extends WorkbenchTask {
campus: WorkbenchCampus
patientId: string
sex: '男' | '女'
age: number
documentId: string
signerName: string
method: SigningMethod
source: string
expiresAt: string
visitType: VisitType
visitDate: string
signatureDataUrl?: string
signedAt?: string
voidReason?: string
}
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
export interface CreateSigningTaskRequest {
campus: WorkbenchCampus
patientId: string
visitId: string
documentId: string
documentName: string
method: SigningMethod
visitType: VisitType
visitNo: string
department: string
phone?: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
import SignatureCanvas from './SignatureCanvas.vue'
type OnlineStage = 'message' | 'signing'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
}>()
const emit = defineEmits<{
close: []
resend: []
complete: [signatureDataUrl: string]
}>()
const stage = ref<OnlineStage>('message')
const signatureDataUrl = ref<string | null>(null)
const signatureCanvas = ref<{ clear: () => void } | null>(null)
watch(
() => props.visible,
(visible) => {
if (visible) {
stage.value = 'message'
signatureDataUrl.value = null
}
},
)
function openLink() {
stage.value = 'signing'
}
function complete() {
if (signatureDataUrl.value) {
emit('complete', signatureDataUrl.value)
}
}
function clearSignature() {
signatureCanvas.value?.clear()
signatureDataUrl.value = null
}
</script>
<template>
<Teleport to="body">
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card online-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="online-title"
>
<header class="dialog-header">
<strong id="online-title">线上签署 · 患者手机模拟</strong>
<span>{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<div class="phone-frame">
<div class="phone-screen">
<div class="phone-status"><span>9:41</span><span>5G </span></div>
<div class="phone-header">{{ stage === 'message' ? '短信' : '患者签署' }}</div>
<div class="phone-body">
<template v-if="stage === 'message'">
<div class="message-bubble">
<strong>xx医院</strong>
<p>
尊敬的{{ task.patientName }}您有一份{{
task.documentName
}}待签署请点击下方链接完成签署链接 48 小时内有效
</p>
<span class="sign-link">https://s.hosp.cn/s/{{ task.id }}</span>
</div>
</template>
<template v-else>
<div class="mobile-document">
<strong>{{ task.documentName }}</strong>
<span>{{ task.patientName }} · {{ task.patientId }}</span>
<p>
本人已充分理解本检查/治疗的医疗风险替代方案及可能的不良后果自愿接受并签署确认签名图片与文档原件一并存档
</p>
</div>
<div class="mobile-signature">
<span>请手写签名</span>
<SignatureCanvas
ref="signatureCanvas"
:height="140"
placeholder="请在此签名"
@update:data-url="signatureDataUrl = $event"
/>
</div>
</template>
</div>
<div v-if="stage === 'signing'" class="phone-footer">
<button
type="button"
class="phone-button phone-button--light"
@click="clearSignature"
>
清空
</button>
<button type="button" class="phone-button" @click="complete">确认签署</button>
</div>
<div v-else class="phone-footer phone-footer--hint"> 患者收到短信</div>
</div>
</div>
</div>
<footer class="dialog-footer">
<span>模拟患者收到短信 打开链接 查看文档 手写签署的全过程</span>
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="emit('resend')">
重发短信
</button>
<button v-if="stage === 'message'" type="button" class="action-button" @click="openLink">
模拟打开链接
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 680px;
max-width: 96vw;
max-height: calc(100vh - 32px);
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.online-dialog {
width: 390px;
}
.dialog-header {
display: flex;
gap: 8px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-header span {
display: none;
}
.dialog-close {
flex-shrink: 0;
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
padding: 12px;
}
.phone-frame {
width: 330px;
padding: 12px 10px;
margin: 0 auto;
background: var(--side);
border-radius: 34px;
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
}
.phone-screen {
display: flex;
height: 540px;
flex-direction: column;
overflow: hidden;
background: #f4f7f9;
border-radius: 24px;
}
.phone-status {
display: flex;
justify-content: space-between;
padding: 6px 14px;
color: #fff;
font-size: 11px;
background: var(--brand);
}
.phone-header {
padding: 12px 14px;
color: #fff;
font-size: 14px;
font-weight: 700;
text-align: center;
background: var(--brand);
}
.phone-body {
flex: 1;
padding: 12px;
overflow-y: auto;
}
.message-bubble,
.mobile-document,
.mobile-signature {
padding: 11px 13px;
background: #fff;
border-radius: 10px;
box-shadow: var(--sh);
}
.message-bubble {
margin-top: 14px;
color: var(--ink);
font-size: 12.5px;
line-height: 1.7;
}
.message-bubble p {
margin: 3px 0;
}
.sign-link {
display: block;
color: var(--brand);
word-break: break-all;
}
.mobile-document {
color: var(--ink);
font-size: 12px;
line-height: 1.75;
}
.mobile-document strong,
.mobile-document span {
display: block;
}
.mobile-document strong {
font-size: 13.5px;
}
.mobile-document span {
margin: 6px 0;
color: var(--mut);
font-size: 11.5px;
}
.mobile-document p {
margin: 0;
}
.mobile-signature {
padding: 10px;
margin-top: 10px;
}
.mobile-signature > span {
display: block;
margin-bottom: 6px;
color: var(--mut);
font-size: 12px;
}
.mobile-signature :deep(.signature-canvas) {
border-style: dashed;
}
.phone-footer {
display: flex;
gap: 10px;
padding: 10px 12px;
background: #fff;
border-top: 1px solid var(--line);
}
.phone-footer--hint {
justify-content: center;
color: var(--mut);
font-size: 12px;
}
.phone-button {
flex: 1;
padding: 9px 0;
color: #fff;
font-size: 13.5px;
font-weight: 700;
background: var(--brand);
border-radius: 22px;
}
.phone-button--light {
color: var(--mut);
background: #eef2f4;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 18px;
color: var(--mut);
font-size: 12px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.footer-spacer {
flex: 1;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
height?: number
placeholder?: string
}>(),
{
height: 300,
placeholder: '请 在 此 签 名',
},
)
const emit = defineEmits<{
'update:dataUrl': [value: string | null]
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const hasInk = ref(false)
let context: CanvasRenderingContext2D | null = null
let pixelRatio = 1
let drawing = false
let resizeObserver: ResizeObserver | null = null
function setupCanvas() {
const element = canvas.value
if (!element) {
return
}
const rect = element.getBoundingClientRect()
pixelRatio = Math.min(window.devicePixelRatio || 1, 2)
element.width = Math.max(Math.round(rect.width * pixelRatio), 1)
element.height = Math.max(Math.round(props.height * pixelRatio), 1)
context = element.getContext('2d')
if (!context) {
return
}
context.scale(pixelRatio, pixelRatio)
context.lineWidth = 4.2
context.lineCap = 'round'
context.lineJoin = 'round'
context.strokeStyle = '#123a5a'
hasInk.value = false
emit('update:dataUrl', null)
}
function getPoint(event: PointerEvent) {
const element = canvas.value
if (!element) {
return { x: 0, y: 0 }
}
const rect = element.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
function startDrawing(event: PointerEvent) {
if (!context || !canvas.value) {
return
}
const point = getPoint(event)
drawing = true
hasInk.value = true
context.beginPath()
context.moveTo(point.x, point.y)
canvas.value.setPointerCapture(event.pointerId)
event.preventDefault()
}
function draw(event: PointerEvent) {
if (!drawing || !context) {
return
}
const point = getPoint(event)
context.lineTo(point.x, point.y)
context.stroke()
event.preventDefault()
}
function stopDrawing() {
if (!drawing) {
return
}
drawing = false
emit('update:dataUrl', getDataUrl())
}
function clear() {
const element = canvas.value
if (!element || !context) {
return
}
context.clearRect(0, 0, element.width / pixelRatio, element.height / pixelRatio)
hasInk.value = false
emit('update:dataUrl', null)
}
function getDataUrl() {
return canvas.value?.toDataURL('image/png') ?? null
}
defineExpose({
clear,
getDataUrl,
})
onMounted(async () => {
await nextTick()
setupCanvas()
if (typeof ResizeObserver !== 'undefined' && canvas.value) {
resizeObserver = new ResizeObserver(() => {
if (!hasInk.value) {
setupCanvas()
}
})
resizeObserver.observe(canvas.value)
}
})
watch(
() => props.height,
() => {
void nextTick(setupCanvas)
},
)
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<div class="signature-canvas" :style="{ height: `${height}px` }">
<canvas
ref="canvas"
@pointercancel="stopDrawing"
@pointerdown="startDrawing"
@pointerleave="stopDrawing"
@pointermove="draw"
@pointerup="stopDrawing"
/>
<span v-if="!hasInk" class="signature-placeholder">{{ placeholder }}</span>
</div>
</template>
<style scoped>
.signature-canvas {
position: relative;
width: 100%;
overflow: hidden;
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
}
.signature-canvas canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
}
.signature-placeholder {
position: absolute;
inset: 0;
display: grid;
color: #c9d8de;
font-size: 15px;
letter-spacing: 2px;
pointer-events: none;
place-items: center;
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { SigningTaskRecord } from '@/api/workbench/types'
import SignatureCanvas from './SignatureCanvas.vue'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
}>()
const emit = defineEmits<{
close: []
confirm: [signatureDataUrl: string]
}>()
const signatureDataUrl = ref<string | null>(null)
const signatureCanvas = ref<{ clear: () => void } | null>(null)
watch(
() => props.visible,
async (visible) => {
if (visible) {
signatureDataUrl.value = null
await nextTick()
signatureCanvas.value?.clear()
}
},
)
function clearSignature() {
signatureCanvas.value?.clear()
signatureDataUrl.value = null
}
function confirmSignature() {
if (!signatureDataUrl.value) {
ElMessage.warning('请先完成患者签名')
return
}
emit('confirm', signatureDataUrl.value)
}
</script>
<template>
<Teleport to="body">
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card signature-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="pad-title"
>
<header class="dialog-header">
<strong id="pad-title">手写板签署</strong>
<span>{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<p class="dialog-hint">
请患者在下框内签名当前为 Canvas 演示真实手写板接入走设备适配器
</p>
<SignatureCanvas
ref="signatureCanvas"
:height="300"
@update:data-url="signatureDataUrl = $event"
/>
</div>
<footer class="dialog-footer">
<span>签名将实时合成到文档签名域</span>
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
清空重写
</button>
<button type="button" class="action-button" @click="confirmSignature">确认签署</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 680px;
max-width: 96vw;
max-height: calc(100vh - 32px);
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.signature-dialog {
width: 760px;
}
.dialog-header {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
font-size: 15px;
}
.dialog-header span {
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-close {
flex-shrink: 0;
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
padding: 12px 18px;
}
.dialog-hint {
margin: 0 0 8px;
color: var(--mut);
font-size: 12px;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 18px;
color: var(--mut);
font-size: 12px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.footer-spacer {
flex: 1;
}
.action-button {
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { SigningTaskRecord } from '@/api/workbench/types'
defineProps<{
task: SigningTaskRecord
}>()
</script>
<template>
<article class="paper-preview">
<div v-if="task.status === 'void'" class="void-stamp"> </div>
<h2>{{ task.documentName }}</h2>
<p class="paper-number">编号{{ task.id }} · 就诊号{{ task.visitNo }}</p>
<p class="paper-meta">
患者<strong>{{ task.patientName }}</strong
>{{ task.sex }}{{ task.age }} · 患者 ID{{ task.patientId }}
</p>
<p class="paper-meta">
就诊信息{{ task.visitType }} · {{ task.department }} · {{ task.visitDate }}
<span v-if="task.visitType === '住院'" class="current-badge">在院</span>
</p>
<p>
医务人员已就本项检查/治疗的适应证禁忌证医疗风险替代方案及可能出现的不良后果向本人或授权委托人充分说明本人已完全理解上述内容
</p>
<p>
本人自愿接受上述检查/治疗并同意在相关医疗文书中签署确认所签署文件将作为医疗过程留痕依据签署过程采用电子方式完成签名图片与文档原件一并存档
</p>
<div class="notice-block">
<strong>签署须知</strong>
请仔细阅读全部内容后签名签名图片将合成至本文档签名域文档与签名原图双留存可随时查验如对内容有疑问请先向医务人员咨询
</div>
<div class="signature-grid">
<div class="signature-field">
<span>患者 / 授权委托人签名</span>
<div class="signature-box">
<img v-if="task.signatureDataUrl" :src="task.signatureDataUrl" alt="患者签名" />
<span v-else>{{ task.status === 'void' ? '' : '未签署' }}</span>
</div>
</div>
<div class="signature-field">
<span>日期</span>
<div class="signature-box signature-box--date">
<span>{{ task.signedAt ?? '—' }}</span>
</div>
</div>
<div class="signature-field">
<span>经治医师</span>
<div class="signature-box signature-box--doctor">
<span>刘明辉</span>
</div>
</div>
</div>
</article>
</template>
<style scoped>
.paper-preview {
position: relative;
min-height: 560px;
padding: 34px 44px;
color: var(--ink);
font-size: 13.5px;
line-height: 1.9;
background: #fff;
border-radius: var(--r);
box-shadow: var(--sh);
}
.paper-preview h2 {
margin: 0 0 6px;
font-size: 19px;
letter-spacing: 2px;
text-align: center;
}
.paper-number {
margin: 0 0 18px;
color: var(--mut);
font-size: 12px;
text-align: center;
}
.paper-preview p {
margin: 0 0 6px;
text-align: justify;
text-indent: 2em;
}
.paper-preview .paper-meta {
color: var(--mut);
text-indent: 0;
}
.paper-meta strong {
color: var(--ink);
}
.current-badge {
display: inline-block;
padding: 1px 7px;
margin-left: 4px;
color: var(--err);
font-size: 10px;
line-height: 1.5;
background: var(--err-l);
border-radius: 4px;
}
.notice-block {
padding: 10px 14px;
margin: 10px 0;
font-size: 12.5px;
background: #fafcfd;
border: 1px dashed var(--line);
border-radius: 8px;
}
.notice-block strong {
font-size: 12.5px;
}
.signature-grid {
display: flex;
gap: 40px;
align-items: flex-end;
flex-wrap: wrap;
margin-top: 22px;
}
.signature-field > span {
display: block;
margin-bottom: 6px;
color: var(--mut);
font-size: 12.5px;
}
.signature-box {
display: flex;
min-width: 200px;
min-height: 64px;
align-items: center;
justify-content: center;
border-bottom: 2px solid var(--ink);
}
.signature-box img {
max-width: 190px;
max-height: 60px;
}
.signature-box span {
color: var(--mut);
font-size: 12px;
}
.signature-box--date {
min-width: 150px;
}
.signature-box--doctor {
min-width: 120px;
}
.void-stamp {
position: absolute;
top: 34px;
right: 44px;
padding: 4px 12px;
color: rgb(208 83 79 / 70%);
font-size: 20px;
font-weight: 700;
letter-spacing: 4px;
border: 2.5px solid rgb(208 83 79 / 55%);
border-radius: 8px;
transform: rotate(-14deg);
}
@media (max-width: 700px) {
.paper-preview {
padding: 26px 24px;
}
.void-stamp {
top: 24px;
right: 24px;
}
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { WorkbenchCampus, VisitType } from '@/api/workbench/types'
import type { SigningFilterForm, SigningFilterOption } from '../types'
const props = defineProps<{
filter: SigningFilterForm
campuses: SigningFilterOption<WorkbenchCampus | 'all'>[]
statuses: SigningFilterOption<SigningFilterForm['status']>[]
methods: SigningFilterOption<SigningFilterForm['method']>[]
departments: SigningFilterOption[]
documents: SigningFilterOption[]
visitTypes: SigningFilterOption<VisitType | 'all'>[]
}>()
const emit = defineEmits<{
search: [filter: SigningFilterForm]
add: []
}>()
const draft = reactive<SigningFilterForm>({ ...props.filter })
watch(
() => props.filter,
(value) => {
Object.assign(draft, value)
},
{ deep: true },
)
function submit() {
emit('search', { ...draft })
}
</script>
<template>
<form class="filter-card" @submit.prevent="submit">
<label class="filter-field">
<span>日期</span>
<select v-model="draft.dateRange" aria-label="筛选日期">
<option value="today">今日</option>
<option value="yesterday">昨日</option>
<option value="3d">近3天</option>
<option value="7d">近7天</option>
<option value="all">全部</option>
</select>
</label>
<label class="filter-field">
<span>状态</span>
<select v-model="draft.status" aria-label="筛选状态">
<option v-for="option in statuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>院区</span>
<select v-model="draft.campus" aria-label="筛选院区">
<option v-for="option in campuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>方式</span>
<select v-model="draft.method" aria-label="筛选签署方式">
<option v-for="option in methods" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>科室</span>
<select v-model="draft.department" aria-label="筛选科室">
<option v-for="option in departments" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>就诊类型</span>
<select v-model="draft.visitType" aria-label="筛选就诊类型">
<option v-for="option in visitTypes" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field filter-field--document">
<span>文档</span>
<select v-model="draft.documentId" aria-label="筛选文档">
<option v-for="option in documents" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<input v-model="draft.keyword" placeholder="患者姓名 / ID / 文档名" aria-label="关键词" />
</label>
<span class="filter-spacer" />
<button type="submit" class="action-button action-button--ghost">查询</button>
<button type="button" class="action-button" @click="emit('add')"> 新增签署任务</button>
</form>
</template>
<style scoped>
.filter-card {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.filter-field {
display: flex;
min-width: 104px;
gap: 6px;
align-items: center;
color: var(--mut);
font-size: 12.5px;
}
.filter-field select,
.filter-field input {
width: auto;
min-width: 82px;
padding: 7px 9px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.filter-field select:focus,
.filter-field input:focus {
outline: none;
border-color: var(--brand);
}
.filter-field--document select {
max-width: 150px;
}
.filter-field--keyword input {
width: 190px;
}
.filter-spacer {
flex: 1;
min-width: 12px;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
transition: background 0.15s ease;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
@media (max-width: 1200px) {
.filter-spacer {
display: none;
}
}
</style>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import type { SigningTaskRecord } from '@/api/workbench/types'
import SigningDocumentPreview from './SigningDocumentPreview.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue'
import type { SigningTaskAction } from '../types'
defineProps<{
task: SigningTaskRecord | null
loading: boolean
}>()
const emit = defineEmits<{
action: [action: SigningTaskAction]
}>()
</script>
<template>
<section class="detail-panel">
<div v-if="loading" class="detail-loading" aria-label="正在加载任务详情">
<span class="detail-loading__actions" />
<span class="detail-loading__paper" />
</div>
<template v-else-if="task">
<div class="task-actions">
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
<template v-if="task.status === 'pending' && task.method === 'pad'">
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
手写板签署
</button>
<button
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'switch-method')"
>
转为短信发送
</button>
</template>
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
<button type="button" class="action-button" @click="emit('action', 'online-sign')">
📱 查看线上签署
</button>
<button
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'switch-method')"
>
转手写板签署
</button>
<button
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'resend-sms')"
>
重发短信
</button>
</template>
<template v-else-if="task.status === 'expired'">
<button type="button" class="action-button" @click="emit('action', 'reopen')">
重新发起
</button>
</template>
<template v-else-if="task.status === 'signed'">
<button
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'download-pdf')"
>
下载 PDF 原件
</button>
<button
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'download-signature')"
>
签名原图
</button>
<button
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'print')"
>
打印
</button>
</template>
<button
v-if="task.status === 'pending' || task.status === 'expired' || task.status === 'signing'"
type="button"
class="action-button action-button--danger"
@click="emit('action', 'void')"
>
作废
</button>
</div>
<SigningDocumentPreview :task="task" />
<TaskAuditTimeline :task="task" />
</template>
<div v-else class="detail-empty">
<span aria-hidden="true">📋</span>
<p>请从左侧列表选择一个签署任务</p>
</div>
</section>
</template>
<style scoped>
.detail-panel {
flex: 1;
min-width: 0;
}
.task-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 14px;
}
.task-action-meta {
align-self: center;
margin-right: auto;
color: var(--mut);
font-size: 12px;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
transition: background 0.15s ease;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
.action-button--danger {
color: var(--err);
background: #fff;
border: 1px solid var(--err);
}
.action-button--danger:hover {
background: var(--err-l);
}
.detail-empty {
display: grid;
min-height: 560px;
padding: 40px 24px;
color: var(--mut);
text-align: center;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
place-items: center;
align-content: center;
}
.detail-empty span {
margin-bottom: 8px;
font-size: 34px;
opacity: 0.5;
}
.detail-empty p {
margin: 0;
font-size: 13px;
}
.detail-loading {
display: flex;
min-height: 680px;
flex-direction: column;
gap: 14px;
}
.detail-loading span {
display: block;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
border-radius: var(--r);
box-shadow: var(--sh);
animation: skeleton-shimmer 1.4s ease infinite;
}
.detail-loading__actions {
height: 38px;
}
.detail-loading__paper {
flex: 1;
min-height: 560px;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 700px) {
.task-action-meta {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import type { SigningTaskRecord } from '@/api/workbench/types'
defineProps<{
tasks: SigningTaskRecord[]
selectedId: string | null
total: number
loading: boolean
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const statusLabels: Record<SigningTaskRecord['status'], string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
}
function getStatusClass(task: SigningTaskRecord) {
if (task.status === 'pending') {
return task.method === 'pad' ? 'task-status--pad' : 'task-status--sms'
}
return `task-status--${task.status}`
}
function getMethodLabel(task: SigningTaskRecord) {
return task.method === 'pad' ? '✍ 手写板' : '📱 短信'
}
function getVisitClass(type: SigningTaskRecord['visitType']) {
return type === '住院'
? 'visit-tag--hospital'
: type === '体检'
? 'visit-tag--check'
: 'visit-tag--outpatient'
}
</script>
<template>
<section class="task-list-panel">
<div class="task-list-heading">
<strong>签署任务列表</strong>
<span> {{ total }} </span>
</div>
<div v-if="loading" class="task-items task-items--loading" aria-label="正在加载任务">
<div v-for="index in 4" :key="index" class="task-skeleton">
<span class="task-skeleton__title" />
<span />
<span />
<span class="task-skeleton__short" />
</div>
</div>
<div v-else-if="tasks.length" class="task-items">
<button
v-for="task in tasks"
:key="task.id"
type="button"
class="task-item"
:class="{ 'task-item--active': selectedId === task.id }"
@click="emit('select', task.id)"
>
<span class="task-row">
<strong>{{ task.patientName }}</strong>
<span class="task-basic">{{ task.sex }} · {{ task.age }}</span>
<span class="task-spacer" />
<span class="task-status" :class="getStatusClass(task)">
{{
task.status === 'pending'
? task.method === 'pad'
? '待签署·手写板'
: '待签署·短信'
: statusLabels[task.status]
}}
</span>
</span>
<span class="task-row task-row--document">📄 {{ task.documentName }}</span>
<span class="task-row task-row--meta">
<span>{{ task.patientId }}</span>
<span>{{ task.updatedAt }}</span>
<span>{{ getMethodLabel(task) }}</span>
</span>
<span class="task-row task-row--visit">
<span class="visit-tag" :class="getVisitClass(task.visitType)">{{ task.visitType }}</span>
<span>{{ task.department }}</span>
<span>{{ task.visitNo }}</span>
</span>
</button>
</div>
<div v-else class="task-empty">
<span aria-hidden="true">🔍</span>
<p>没有符合条件的任务</p>
</div>
</section>
</template>
<style scoped>
.task-list-panel {
width: 400px;
flex-shrink: 0;
}
.task-list-heading {
display: flex;
align-items: center;
padding: 10px 14px;
margin-bottom: 12px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.task-list-heading strong {
font-size: 13.5px;
}
.task-list-heading span {
margin-left: auto;
color: var(--mut);
font-size: 12px;
}
.task-items {
max-height: calc(100vh - 270px);
padding-right: 3px;
overflow-y: auto;
}
.task-item {
display: block;
width: 100%;
padding: 11px 13px;
margin-bottom: 10px;
color: var(--ink);
text-align: left;
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.task-item:hover {
border-color: var(--brand);
}
.task-item--active {
background: var(--brand-l);
border-color: var(--brand);
box-shadow: 0 0 0 1px var(--brand);
}
.task-row {
display: flex;
min-width: 0;
gap: 8px;
align-items: center;
}
.task-row strong {
flex-shrink: 0;
font-size: 14px;
}
.task-basic,
.task-row--meta,
.task-row--visit {
color: var(--mut);
font-size: 12px;
}
.task-spacer {
flex: 1;
}
.task-status {
flex-shrink: 0;
padding: 2px 9px;
font-size: 11px;
font-weight: 700;
border-radius: 20px;
}
.task-status--pad {
color: var(--brand);
background: var(--brand-l);
}
.task-status--sms {
color: #7a4dbb;
background: #f3ecfb;
}
.task-status--signing {
color: var(--info);
background: var(--info-l);
}
.task-status--signed {
color: var(--ok);
background: var(--ok-l);
}
.task-status--rejected,
.task-status--expired {
color: var(--err);
background: var(--err-l);
}
.task-status--void {
color: #8a9aa3;
background: #eceef0;
}
.task-row--document {
margin-top: 5px;
overflow: hidden;
color: var(--ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-row--meta {
flex-wrap: wrap;
margin-top: 5px;
}
.task-row--visit {
flex-wrap: wrap;
margin-top: 5px;
}
.visit-tag {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
line-height: 1.5;
border-radius: 4px;
}
.visit-tag--outpatient {
color: var(--ok);
background: var(--ok-l);
}
.visit-tag--hospital {
color: var(--err);
background: var(--err-l);
}
.visit-tag--check {
color: var(--warn);
background: var(--warn-l);
}
.task-empty {
padding: 40px 0;
color: var(--mut);
text-align: center;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.task-empty span {
display: block;
margin-bottom: 8px;
font-size: 34px;
opacity: 0.5;
}
.task-empty p {
margin: 0;
font-size: 13px;
}
.task-items--loading {
display: flex;
flex-direction: column;
gap: 10px;
}
.task-skeleton {
display: flex;
min-height: 144px;
flex-direction: column;
gap: 10px;
padding: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.task-skeleton span {
display: block;
width: 88%;
height: 11px;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
border-radius: 4px;
animation: skeleton-shimmer 1.4s ease infinite;
}
.task-skeleton__title {
width: 65% !important;
height: 16px !important;
}
.task-skeleton__short {
width: 48% !important;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 1200px) {
.task-list-panel {
width: 100%;
}
.task-items {
max-height: 420px;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
const props = defineProps<{
task: SigningTaskRecord
}>()
const events = computed(() => {
const result = [
{
time: props.task.updatedAt,
text: `张文静发起签署任务(${props.task.method === 'pad' ? '手写板' : '线上短信'}`,
},
]
if (props.task.status === 'signing') {
result.push({ time: props.task.updatedAt, text: '已打开签名采集,等待患者完成签名' })
}
if (props.task.signedAt) {
result.push({
time: props.task.signedAt,
text: `患者 ${props.task.patientName} 完成签名,系统自动合成并双留存`,
})
}
if (props.task.status === 'void') {
result.push({
time: props.task.updatedAt,
text: `任务已作废${props.task.voidReason ? `,原因:${props.task.voidReason}` : ''},全程可审计`,
})
}
return result
})
</script>
<template>
<section class="audit-card">
<strong>操作留痕</strong>
<ol class="audit-list">
<li v-for="event in events" :key="`${event.time}-${event.text}`">
<time>{{ event.time }}</time>
<span>{{ event.text }}</span>
</li>
</ol>
</section>
</template>
<style scoped>
.audit-card {
padding: 12px 16px;
margin-top: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.audit-card > strong {
font-size: 12px;
}
.audit-list {
display: flex;
flex-direction: column;
gap: 5px;
padding: 0;
margin: 6px 0 0;
color: var(--mut);
font-size: 12px;
line-height: 1.65;
list-style: none;
}
.audit-list li {
display: flex;
gap: 10px;
}
.audit-list time {
flex-shrink: 0;
color: var(--mut);
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
}>()
const emit = defineEmits<{
close: []
confirm: [reason: string]
}>()
const reason = ref('患者信息录入有误')
watch(
() => props.visible,
(visible) => {
if (visible) {
reason.value = '患者信息录入有误'
}
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card void-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="void-title"
>
<header class="dialog-header">
<strong id="void-title">作废确认</strong>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<div class="void-summary">
<span class="void-icon" aria-hidden="true"></span>
<div>
<p>
确定作废任务 <strong>{{ task.id }}</strong
>{{ task.documentName }}
</p>
<span>作废后不可恢复将记录操作人时间与原因全程留痕</span>
</div>
</div>
<label class="reason-field">
<span>作废原因必填</span>
<select v-model="reason" aria-label="作废原因">
<option>患者信息录入有误</option>
<option>文档选择错误</option>
<option>患者拒绝签署</option>
<option>重复发起</option>
<option>其他</option>
</select>
</label>
</div>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
取消
</button>
<button
type="button"
class="action-button action-button--danger"
@click="emit('confirm', reason)"
>
确认作废
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 440px;
max-width: 96vw;
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.dialog-header {
display: flex;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
font-size: 15px;
}
.dialog-close {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
padding: 16px 18px 12px;
}
.void-summary {
display: flex;
gap: 12px;
align-items: flex-start;
}
.void-icon {
font-size: 30px;
line-height: 1;
}
.void-summary p {
margin: 0;
color: var(--ink);
line-height: 1.6;
}
.void-summary > div > span {
display: block;
margin-top: 6px;
color: var(--mut);
font-size: 12px;
line-height: 1.6;
}
.reason-field {
display: flex;
gap: 8px;
flex-direction: column;
margin-top: 14px;
}
.reason-field span {
color: var(--mut);
font-size: 12px;
}
.reason-field select {
width: 100%;
padding: 7px 10px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 6px;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.footer-spacer {
flex: 1;
}
.action-button {
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
.action-button--danger {
color: var(--err);
background: #fff;
border: 1px solid var(--err);
}
.action-button--danger:hover {
background: var(--err-l);
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style>

View File

@@ -1,12 +1,520 @@
<script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import {
completeSigningTask,
getSigningTaskDetail,
getSigningTasks,
getSigningTemplates,
reopenSigningTask,
resendSigningSms,
updateSigningTaskMethod,
voidSigningTask,
} from '@/api/workbench/signing'
import type {
SigningDateRange,
SigningMethod,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
import { useAppStore } from '@/stores/app'
import NewSigningTaskDialog from './components/NewSigningTaskDialog.vue'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
import SigningFilterBar from './components/SigningFilterBar.vue'
import SigningTaskDetail from './components/SigningTaskDetail.vue'
import SigningTaskList from './components/SigningTaskList.vue'
import VoidTaskDialog from './components/VoidTaskDialog.vue'
import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from './types'
const route = useRoute()
const appStore = useAppStore()
const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
'signing',
'signed',
'rejected',
'expired',
'void',
]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
}
const filter = reactive<SigningFilterForm>({
keyword: '',
status: 'all',
dateRange: 'all',
campus: 'all',
method: 'all',
department: '',
documentId: '',
visitType: 'all',
})
const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0)
const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null)
const templates = ref<SigningTemplate[]>([])
const loading = ref(true)
const detailLoading = ref(false)
const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false)
const voidDialogVisible = ref(false)
let listRequestId = 0
let detailRequestId = 0
const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [
{ label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
])
const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
{ label: '全部状态', value: 'all' },
...STATUS_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
]
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
{ label: '全部方式', value: 'all' },
{ label: '手写板', value: 'pad' },
{ label: '短信线上', value: 'sms' },
]
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
{ label: '全部类型', value: 'all' },
{ label: '门诊', value: '门诊' },
{ label: '住院', value: '住院' },
{ label: '体检', value: '体检' },
]
const departmentOptions = computed<SigningFilterOption[]>(() => {
const departments = new Set([
...templates.value.map((template) => template.department),
...tasks.value.map((task) => task.department),
])
return [
{ label: '全部科室', value: '' },
...[...departments].sort().map((department) => ({ label: department, value: department })),
]
})
const documentOptions = computed<SigningFilterOption[]>(() => [
{ label: '全部文档', value: '' },
...templates.value.map((template) => ({ label: template.name, value: template.id })),
])
function getQueryString(value: unknown) {
return typeof value === 'string' ? value : ''
}
function isSigningStatus(value: string): value is SigningTaskStatus {
return STATUS_VALUES.includes(value as SigningTaskStatus)
}
function isSigningDateRange(value: string): value is SigningDateRange {
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
}
function syncFilterFromRoute() {
const status = getQueryString(route.query.status)
const range = getQueryString(route.query.range)
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
filter.dateRange = isSigningDateRange(range) ? range : 'all'
}
async function loadTemplates() {
try {
templates.value = await getSigningTemplates()
} catch {
ElMessage.error('文档模板加载失败,请稍后重试')
}
}
async function loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId
if (!id) {
selectedTask.value = null
detailLoading.value = false
return
}
detailLoading.value = true
try {
const detail = await getSigningTaskDetail(id)
if (requestId === detailRequestId) {
selectedTask.value = detail
}
} catch {
if (requestId === detailRequestId) {
selectedTask.value = null
ElMessage.error('签署任务详情加载失败,请稍后重试')
}
} finally {
if (requestId === detailRequestId) {
detailLoading.value = false
}
}
}
async function loadTasks(preferredTaskId?: string) {
const requestId = ++listRequestId
loading.value = true
try {
const response = await getSigningTasks({
...filter,
page: 1,
pageSize: 50,
})
if (requestId !== listRequestId) {
return
}
tasks.value = response.records
total.value = response.total
const taskIds = new Set(response.records.map((task) => task.id))
const routeTaskId = getQueryString(route.query.taskId)
const nextTaskId =
[preferredTaskId, selectedTaskId.value, routeTaskId].find((id) => id && taskIds.has(id)) ??
response.records[0]?.id ??
null
selectedTaskId.value = nextTaskId
await loadTaskDetail(nextTaskId)
} catch {
if (requestId === listRequestId) {
tasks.value = []
total.value = 0
selectedTaskId.value = null
selectedTask.value = null
ElMessage.error('签署任务加载失败,请稍后重试')
}
} finally {
if (requestId === listRequestId) {
loading.value = false
}
}
}
function handleSearch(nextFilter: SigningFilterForm) {
Object.assign(filter, nextFilter)
selectedTaskId.value = null
void loadTasks()
}
function selectTask(id: string) {
selectedTaskId.value = id
void loadTaskDetail(id)
}
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
if (!updatedTask) {
ElMessage.error('签署任务不存在或已被删除')
return
}
await loadTasks(updatedTask.id)
ElMessage.success(successMessage)
}
async function handleTaskAction(action: SigningTaskAction) {
const task = selectedTask.value
if (!task) {
return
}
if (action === 'pad-sign') {
signatureDialogVisible.value = true
return
}
if (action === 'online-sign') {
onlineDialogVisible.value = true
return
}
if (action === 'void') {
voidDialogVisible.value = true
return
}
if (action === 'download-pdf') {
ElMessage.info('原型演示PDF 原件下载接口待接入')
return
}
if (action === 'download-signature') {
ElMessage.info('原型演示:签名原图下载接口待接入')
return
}
if (action === 'print') {
ElMessage.info('原型演示:打印服务待接入')
return
}
try {
if (action === 'switch-method') {
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
await refreshTask(
await updateSigningTaskMethod(task.id, method),
method === 'sms' ? '已切换为短信线上签署' : '已切换为手写板签署',
)
return
}
if (action === 'resend-sms') {
await refreshTask(await resendSigningSms(task.id), '短信已重新发送')
return
}
if (action === 'reopen') {
await refreshTask(await reopenSigningTask(task.id), '签署任务已重新发起')
}
} catch {
ElMessage.error('任务操作失败,请稍后重试')
}
}
async function confirmPadSignature(signatureDataUrl: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(await completeSigningTask(task.id, signatureDataUrl), '手写签署已完成')
signatureDialogVisible.value = false
} catch {
ElMessage.error('手写签署失败,请稍后重试')
}
}
async function completeOnlineSignature(signatureDataUrl: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(await completeSigningTask(task.id, signatureDataUrl), '线上签署已完成')
onlineDialogVisible.value = false
} catch {
ElMessage.error('线上签署失败,请稍后重试')
}
}
async function confirmVoidTask(reason: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(await voidSigningTask(task.id, reason), '签署任务已作废')
voidDialogVisible.value = false
} catch {
ElMessage.error('任务作废失败,请稍后重试')
}
}
async function handleOnlineResend() {
await handleTaskAction('resend-sms')
}
async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id)
}
watch(
() => [route.query.status, route.query.range, route.query.taskId],
() => {
syncFilterFromRoute()
selectedTaskId.value = null
void loadTasks()
},
)
watch(
() => appStore.selectedCampus,
(campus) => {
if (filter.campus !== 'all') {
filter.campus = campus
}
void loadTasks()
},
)
onMounted(() => {
syncFilterFromRoute()
void Promise.all([loadTemplates(), loadTasks()])
})
</script>
<template>
<PagePlaceholder
group="工作台"
title="签署工作台"
icon="✍"
description="签署工作台页面占位,待根据 Web 端需求实现。"
/>
<section class="signing-page">
<div class="page-intro">
<div>
<h1>签署任务</h1>
<p>集中处理患者知情同意文书的发起签署与全过程留痕</p>
</div>
<div class="page-intro__meta">
<span>当前院区{{ appStore.selectedCampus }}</span>
<span> {{ total }} 条任务</span>
</div>
</div>
<SigningFilterBar
:filter="filter"
:campuses="campusOptions"
:statuses="statusOptions"
:methods="methodOptions"
:departments="departmentOptions"
:documents="documentOptions"
:visit-types="visitTypeOptions"
@add="newDialogVisible = true"
@search="handleSearch"
/>
<div class="task-workspace">
<SigningTaskList
:tasks="tasks"
:selected-id="selectedTaskId"
:total="total"
:loading="loading"
@select="selectTask"
/>
<SigningTaskDetail :task="selectedTask" :loading="detailLoading" @action="handleTaskAction" />
</div>
<NewSigningTaskDialog
:visible="newDialogVisible"
@close="newDialogVisible = false"
@created="handleTaskCreated"
/>
<SignaturePadDialog
:visible="signatureDialogVisible"
:task="selectedTask"
@close="signatureDialogVisible = false"
@confirm="confirmPadSignature"
/>
<OnlineSigningDialog
:visible="onlineDialogVisible"
:task="selectedTask"
@close="onlineDialogVisible = false"
@complete="completeOnlineSignature"
@resend="handleOnlineResend"
/>
<VoidTaskDialog
:visible="voidDialogVisible"
:task="selectedTask"
@close="voidDialogVisible = false"
@confirm="confirmVoidTask"
/>
</section>
</template>
<style scoped>
.signing-page {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.page-intro {
display: flex;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14px;
}
.page-intro h1 {
margin: 0;
color: var(--ink);
font-size: 19px;
letter-spacing: 0.5px;
}
.page-intro p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12.5px;
}
.page-intro__meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--mut);
font-size: 12px;
white-space: nowrap;
}
.page-intro__meta span {
padding: 5px 9px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 6px;
}
.task-workspace {
display: flex;
gap: 16px;
align-items: flex-start;
}
.task-workspace > * {
min-width: 0;
}
@media (max-width: 1200px) {
.task-workspace {
flex-direction: column;
}
.task-workspace > * {
width: 100%;
}
}
@media (max-width: 680px) {
.page-intro {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.page-intro__meta {
white-space: normal;
}
}
</style>

View File

@@ -1,18 +1,50 @@
export type SigningFilterStatus = 'all' | 'pending' | 'signing' | 'signed' | 'rejected' | 'expired'
import type {
PatientProfile,
PatientVisit,
SigningDateRange,
SigningMethod,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
export type SigningFilterStatus = 'all' | SigningTaskStatus
export interface SigningFilterForm {
keyword: string
status: SigningFilterStatus
dateRange: [string, string] | null
dateRange: SigningDateRange
campus: WorkbenchCampus | 'all'
method: SigningMethod | 'all'
department: string
documentId: string
visitType: VisitType | 'all'
}
export interface SigningTableRow {
id: string
patientName: string
visitNo: string
documentName: string
department: string
signerName: string
status: SigningFilterStatus
updatedAt: string
export type SigningTableRow = SigningTaskRecord
export interface SigningFilterOption<T extends string = string> {
label: string
value: T
}
export interface NewSigningTaskState {
patient: PatientProfile | null
selectedVisit: PatientVisit | null
selectedTemplate: SigningTemplate | null
method: SigningMethod
phone: string
}
export type SigningTaskAction =
| 'pad-sign'
| 'online-sign'
| 'resend-sms'
| 'switch-method'
| 'reopen'
| 'void'
| 'download-pdf'
| 'download-signature'
| 'print'