Files
xh-medical-sign-web/clinical-web/src/views/workbench/signing/index.vue
T
yelan 82acc03442 feat(签署工作台): 补代签标签、关联处方卡、线上等待态与只读副本
按原型 V10.2 详情页补齐四块:

- 任务列表「代签」标签(原型 .vtag.chk 橙底):SigningTaskRecord 增加
  signerType / signerRelation,仅签署人非「本人」时显示。
  有意偏离:关系为空时不输出空括号(原型会渲染成「家属代签()」)。
- 「关联处方」可折叠卡(原型 rxCard):新建 api/workbench/prescriptions.ts
  统一处方来源,把弹窗里那份搬过来消除重复,并新增详情用的 7 列明细。
  原型两处处方口径本就不同(弹窗按就诊号哈希、详情按任务号派生),分开维护不合并。
  真实模式返回空 → 整卡不渲染,不显示空表(会被读成「系统查过了,确实没处方」)。
  顺带按原型调了详情卡片顺序:文书 → 签署文件 → 关联处方 → 操作留痕。
- 短信 / 公众号待签署的等待态(原型 .tg-wait):号码取后端 smsMobileMasked,
  没有值时不拼号码,不拿患者档案里的号码冒充发送目标。
- 已签署的「在线预览(PDF)」与「只读副本」:
  预览弹窗复用 SigningDocumentInteractive(只读态),保证预览 / 工作台 / 导出 PDF
  三处版式一致;只读副本走 signing-pdf 新增的 watermark 选项,
  在 html2canvas 克隆体上平铺斜排水印,审计编号直接用任务号。
  水印行数按克隆体 scrollHeight 算,写死行数会让长文书下半部分没水印。
2026-09-26 19:45:40 +08:00

1088 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute } from 'vue-router'
import { getCampuses, getDepartments } from '@/api/management/organization'
import {
downloadSigningArtifact,
fetchSignatureDataUrl,
getSigningArtifacts,
} from '@/api/workbench/artifacts'
import { createPadSigningSession } from '@/api/workbench/deliveries'
import {
completeSigningTask,
getSigningTaskDetail,
getSigningTaskEvents,
getSigningTasks,
getSigningTemplates,
isSigningMockEnabled,
reopenSigningTask,
resendSigningSms,
updateSigningTaskMethod,
voidSigningTask,
} from '@/api/workbench/signing'
import type {
SigningAnswers,
SigningDateRange,
SigningMethod,
SigningArtifact,
SignDeliveryResponseDto,
SigningTaskEvent,
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import {
formatSigningMethodTitle,
readSigningMethod,
toggleSigningMethod,
WORKBENCH_METHOD_FILTERS,
} from '@/utils/signing-method'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
import SignedDocumentDialog from './components/SignedDocumentDialog.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 loginStore = useLoginStore()
const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
'signing',
'signed',
'expired',
'void',
'failed',
]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
const filter = reactive<SigningFilterForm>({
keyword: '',
status: 'all',
dateRange: 'all',
campus: 'all',
method: 'all',
department: '',
documentId: '',
visitType: 'all',
})
const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null)
/** 当前任务里患者已勾选的条目,签署完成时随签名一起固化到任务上 */
const signingSelection = ref<string[]>([])
/** 当前任务文书的填写项取值(含签名图),签署完成时随签名一起固化 */
const signingAnswers = ref<SigningAnswers>({})
/** 文书填写项完成情况:签署前用它做必填校验、提示异常项 */
const signingProgress = ref<SigningFieldProgress | null>(null)
/**
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
* 与 signingAnswers 分开:signingAnswers 每次输入都会变,把它当渲染输入会让
* 整篇文书重渲染、光标丢失;这份只在真的采到签名时变化。
*/
const fieldSignatureInjections = ref<SigningAnswers>({})
/** 当前正在采集的签名位,null 表示弹窗不是由「点击文中签名位」打开的 */
const pendingSignatureField = ref<SigningSignatureRequest | null>(null)
const fieldSignatureDialogVisible = ref(false)
const taskEvents = ref<SigningTaskEvent[]>([])
const artifacts = ref<SigningArtifact[]>([])
const templates = ref<SigningTemplate[]>([])
const loading = ref(true)
const detailLoading = ref(false)
const eventsLoading = ref(false)
const eventsError = ref(false)
const artifactsLoading = ref(false)
const artifactsError = ref(false)
const padSessionStatus = ref<string | null>(null)
const padSessionLoading = ref(false)
const campusIds = ref<Record<string, string>>({})
const campusNames = ref<Record<string, WorkbenchCampus>>({})
const departmentIds = ref<Record<string, string>>({})
const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false)
const voidDialogVisible = ref(false)
/** 已签署文书的在线预览弹窗(原型 openPdfView) */
const signedPreviewVisible = ref(false)
const taskDetailRef = ref<InstanceType<typeof SigningTaskDetail> | null>(null)
const pdfExporting = ref(false)
let listRequestId = 0
let detailRequestId = 0
function canWrite(permission: string) {
return (
!loginStore.isReadOnly && (isSigningMockEnabled || loginStore.hasWritePermission(permission))
)
}
function canRead(permission: string) {
return isSigningMockEnabled || loginStore.hasPermission(permission)
}
const canCreateTask = computed(() => canWrite('sign:task:create'))
const canDeliveryWrite = computed(() => canWrite('sign:delivery:write'))
const canReopenTask = computed(() => canWrite('sign:task:reopen'))
const canVoidTask = computed(() => canWrite('sign:task:void'))
const canViewEvents = computed(() => canRead('sign:task:event:query'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
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 })),
]
/** 原型 #qMethod 只有 3 项(手写板 / 短信线上 / Pad),没有公众号 */
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
{ label: '全部方式', value: 'all' },
...WORKBENCH_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
]
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
{ label: '全部类型', value: 'all' },
{ label: '门诊', value: '门诊' },
{ label: '住院', value: '住院' },
{ label: '体检', value: '体检' },
]
const departmentOptions = computed<SigningFilterOption[]>(() => {
const departments = new Set([
...templates.value.map((template) => template.department),
...tasks.value.map((task) => task.department),
])
return [
{ label: '全部科室', value: '' },
...[...departments].sort().map((department) => ({ label: department, value: department })),
]
})
const documentOptions = computed<SigningFilterOption[]>(() => [
{ label: '全部文档', value: '' },
...templates.value.map((template) => ({ label: template.name, value: template.id })),
])
const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus],
campusIds: campusIds.value,
campusNames: campusNames.value,
departmentIds: departmentIds.value,
templates: templates.value,
}))
function getQueryString(value: unknown) {
return typeof value === 'string' ? value : ''
}
function isSigningStatus(value: string): value is SigningTaskStatus {
return STATUS_VALUES.includes(value as SigningTaskStatus)
}
function isSigningDateRange(value: string): value is SigningDateRange {
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
}
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return appStore.campuses.includes(value)
}
function syncFilterFromRoute() {
const status = getQueryString(route.query.status)
const range = getQueryString(route.query.range)
const campus = getQueryString(route.query.campus)
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
filter.dateRange = isSigningDateRange(range) ? range : 'all'
filter.campus = isWorkbenchCampus(campus) ? campus : 'all'
}
async function loadTemplates() {
try {
templates.value = await getSigningTemplates()
} catch {
ElMessage.error('文档模板加载失败,请稍后重试')
}
}
async function loadOrganizationOptions() {
if (isSigningMockEnabled) {
return
}
const [campusesResult, departmentsResult] = await Promise.allSettled([
getCampuses(),
getDepartments(),
])
if (campusesResult.status === 'fulfilled') {
campusIds.value = Object.fromEntries(
campusesResult.value.map((campus) => [campus.name, campus.id]),
)
campusNames.value = Object.fromEntries(
campusesResult.value.map((campus) => [campus.id, campus.name]),
)
}
if (departmentsResult.status === 'fulfilled') {
departmentIds.value = Object.fromEntries(
departmentsResult.value.map((department) => [department.name, department.id]),
)
}
}
async function loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId
// 换任务时先清空勾选与填写项,避免把上一个任务的取值带到新任务上;
// 文书组件挂载后会用任务自身固化的取值重新上报
signingSelection.value = []
signingAnswers.value = {}
signingProgress.value = null
fieldSignatureInjections.value = {}
pendingSignatureField.value = null
fieldSignatureDialogVisible.value = false
// 预览弹窗跟着任务走:换任务时留在上一份文书的预览上会让人以为看的是当前任务
signedPreviewVisible.value = false
if (!id) {
selectedTask.value = null
taskEvents.value = []
artifacts.value = []
detailLoading.value = false
eventsLoading.value = false
eventsError.value = false
artifactsLoading.value = false
artifactsError.value = false
padSessionStatus.value = null
return
}
detailLoading.value = true
eventsLoading.value = canViewEvents.value
eventsError.value = false
artifactsLoading.value = canViewArtifacts.value
artifactsError.value = false
taskEvents.value = []
artifacts.value = []
padSessionStatus.value = null
try {
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value),
canViewEvents.value ? getSigningTaskEvents(id) : Promise.resolve([]),
canViewArtifacts.value ? getSigningArtifacts(id) : Promise.resolve([]),
])
if (requestId === detailRequestId) {
if (detailResult.status === 'rejected') {
throw detailResult.reason
}
const detail = detailResult.value
selectedTask.value = detail
signingAnswers.value = { ...(detail?.fieldAnswers ?? {}) }
if (eventsResult.status === 'fulfilled') {
taskEvents.value = eventsResult.value
} else {
eventsError.value = true
}
if (artifactsResult.status === 'fulfilled') {
artifacts.value = artifactsResult.value
} else {
artifactsError.value = true
}
// 任务接口不返回签名,签名图是独立的 SIGNATURE_IMAGE 产物;
// 这里把它取回来转成 dataURL 补到任务上,文书里的签名位才有内容。
// 取不到就保持原样(渲染成「待患者签名」),不伪造一个签名糊上去。
if (detail && !detail.signatureDataUrl) {
const signatureArtifact = artifacts.value.find(
(item) => item.artifactType === 'SIGNATURE_IMAGE',
)
if (signatureArtifact) {
const signatureDataUrl = await fetchSignatureDataUrl(signatureArtifact)
if (requestId === detailRequestId && signatureDataUrl) {
selectedTask.value = { ...detail, signatureDataUrl }
}
}
}
}
} catch {
if (requestId === detailRequestId) {
selectedTask.value = null
taskEvents.value = []
ElMessage.error('签署任务详情加载失败,请稍后重试')
}
} finally {
if (requestId === detailRequestId) {
detailLoading.value = false
eventsLoading.value = false
artifactsLoading.value = false
}
}
}
async function loadTasks(preferredTaskId?: string, resetPage = false) {
if (resetPage) {
page.value = 1
}
const requestId = ++listRequestId
loading.value = true
try {
const response = await getSigningTasks(
{
...filter,
page: page.value,
pageSize: pageSize.value,
},
signingApiOptions.value,
)
if (requestId !== listRequestId) {
return
}
tasks.value = response.records
total.value = response.total
page.value = response.page
pageSize.value = response.pageSize
const taskIds = new Set(response.records.map((task) => task.id))
const routeTaskId = getQueryString(route.query.taskId)
const matchedTaskId = [preferredTaskId, selectedTaskId.value, routeTaskId].find(
(id) => id && taskIds.has(id),
)
const nextTaskId = matchedTaskId ?? (routeTaskId || response.records[0]?.id || null)
selectedTaskId.value = nextTaskId
await loadTaskDetail(nextTaskId)
} catch {
if (requestId === listRequestId) {
tasks.value = []
total.value = 0
selectedTaskId.value = null
selectedTask.value = null
ElMessage.error('签署任务加载失败,请稍后重试')
}
} finally {
if (requestId === listRequestId) {
loading.value = false
}
}
}
function handleSearch(nextFilter: SigningFilterForm) {
Object.assign(filter, nextFilter)
selectedTaskId.value = null
void loadTasks(undefined, true)
}
function selectTask(id: string) {
selectedTaskId.value = id
void loadTaskDetail(id)
}
function handleSelectionChange(items: string[]) {
signingSelection.value = items
}
function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgress) {
signingAnswers.value = answers
signingProgress.value = progress
}
/**
* 采集主签名前的检查。
*
* ① 必填项没填完 → 直接挡住:签一份内容不完整的文书没有意义;
* ② 文书里还有没采集的签名位 → 让操作人员确认后再继续。
* 这一条不能硬挡:医师确实可能签在纸质件上,替业务做决定是越界;
* 但也不能不提醒,否则文书会带着空签名位被固化。
*/
async function assertSigningReady() {
const missing = signingProgress.value?.missingRequired ?? []
if (missing.length) {
ElMessage.warning(`请先完成必填项:${missing.join('、')}`)
return false
}
const pending = signingProgress.value?.pendingSignatures ?? []
if (!pending.length) {
return true
}
try {
await ElMessageBox.confirm(
`文书中还有签名位未采集:${pending.join('、')}。可直接点击文中签名位完成,也可以继续采集主签名。`,
'还有签名位待采集',
{ confirmButtonText: '继续签署', cancelButtonText: '返回采集', type: 'warning' },
)
return true
} catch {
return false
}
}
/** 点击文中签名位:先记下采的是哪一个,再由弹窗把签名图回灌到该位置 */
function handleSignatureRequest(request: SigningSignatureRequest) {
if (!selectedTask.value) {
return
}
pendingSignatureField.value = request
fieldSignatureDialogVisible.value = true
}
function closeFieldSignatureDialog() {
fieldSignatureDialogVisible.value = false
pendingSignatureField.value = null
}
/**
* 单个签名位采集完成。
*
* 这里不调 completeSigningTask:医师签名只是文书上的一个填写项,
* 任务级的「签署完成」仍要由主签名位(患者签名)触发,
* 否则点一下医师签名就把整个任务标记成已签署了。
*/
function confirmFieldSignature(signatureDataUrl: string) {
const request = pendingSignatureField.value
if (!request) {
return
}
fieldSignatureInjections.value = {
...fieldSignatureInjections.value,
[request.fieldId]: signatureDataUrl,
}
closeFieldSignatureDialog()
ElMessage.success(`「${request.label}」签名已采集,将随本次签署一并固化`)
}
function handlePageChange(nextPage: number) {
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
return
}
page.value = nextPage
selectedTaskId.value = null
void loadTasks()
}
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
if (!updatedTask) {
ElMessage.error('签署任务不存在或已被删除')
return
}
await loadTasks(updatedTask.id, true)
ElMessage.success(successMessage)
}
async function handleTaskAction(action: SigningTaskAction, targetMethod?: SigningMethod) {
const task = selectedTask.value
if (!task) {
return
}
const allowed =
isSigningMockEnabled ||
(action === 'create-pad-session' && canDeliveryWrite.value) ||
(action === 'resend-sms' && canDeliveryWrite.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
(action === 'preview-signed' && canViewArtifacts.value) ||
((action === 'download-pdf' ||
action === 'download-signature' ||
action === 'download-readonly-copy' ||
action === 'print') &&
canDownloadArtifacts.value)
if (!allowed) {
ElMessage.warning('当前账号没有执行此操作的权限')
return
}
if (action === 'pad-sign') {
if (!isSigningMockEnabled) {
ElMessage.info(`${readSigningMethod(task.method).short}设备签署接口待接入`)
return
}
if (!(await assertSigningReady())) {
return
}
signatureDialogVisible.value = true
return
}
if (action === 'create-pad-session') {
padSessionLoading.value = true
try {
const session: SignDeliveryResponseDto = await createPadSigningSession(task.id)
padSessionStatus.value = session.status
ElMessage.success(
`${readSigningMethod(task.method).short}签署会话已创建,请由设备适配器继续采集签名`,
)
} catch {
ElMessage.error(`${readSigningMethod(task.method).short}签署会话创建失败,请稍后重试`)
} finally {
padSessionLoading.value = false
}
return
}
if (action === 'online-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('线上签署页面接口待接入')
return
}
if (!(await assertSigningReady())) {
return
}
onlineDialogVisible.value = true
return
}
if (action === 'void') {
voidDialogVisible.value = true
return
}
if (action === 'reopen' && task.status !== 'expired' && task.status !== 'failed') {
ElMessage.info('只有已超时或处理失败的任务可以重新发起')
return
}
if (action === 'download-pdf') {
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
if (artifact) {
await downloadArtifact(artifact)
} else if (isSigningMockEnabled) {
await exportSignedPdf()
} else {
ElMessage.info('当前任务暂无签署后 PDF')
}
return
}
if (action === 'download-signature') {
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNATURE_IMAGE')
if (artifact) {
await downloadArtifact(artifact)
} else {
ElMessage.info('当前任务暂无签名原图')
}
return
}
if (action === 'preview-signed') {
signedPreviewVisible.value = true
return
}
if (action === 'download-readonly-copy') {
await exportReadonlyCopy()
return
}
if (action === 'print') {
ElMessage.info('原型演示:打印服务待接入')
return
}
try {
if (action === 'switch-method') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
// 详情页的次按钮会带上目标方式;没有目标时退回原型 switchMethod 的二选一
const method: SigningMethod = targetMethod ?? toggleSigningMethod(task.method)
await refreshTask(
await updateSigningTaskMethod(task.id, method),
`已切换为${formatSigningMethodTitle(method)}`,
)
return
}
if (action === 'resend-sms') {
await refreshTask(
await resendSigningSms(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'短信已重新发送',
)
return
}
if (action === 'reopen') {
await refreshTask(
await reopenSigningTask(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已重新发起',
)
}
} catch {
ElMessage.error('任务操作失败,请稍后重试')
}
}
/**
* 原型阶段:后端没有签署后 PDF 时,前端按当前渲染结果现场生成一份。
* 真实实现里 PDF 由服务端生成(正式文书以服务端产物为准),
* 这里只是让前端能先看到成品效果,因此仅在演示模式下兜底。
*/
async function exportSignedPdf() {
const task = selectedTask.value
const element = taskDetailRef.value?.getDocumentElement()
if (!task || !element) {
ElMessage.info('该任务没有固化的文书正文,无法导出 PDF')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
await downloadSigningPdf({ element, fileName: `${task.documentName}-${task.id}` })
ElMessage.success('签署后 PDF 已生成')
} catch {
ElMessage.error('PDF 生成失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
/**
* 只读副本:在按任务固化版式渲染的结果上加「水印 + 审计编号」再导出。
*
* 与原件的区别是用途:原件是对内留存的正式凭证,只读副本是对外提供的那一份,
* 因此必须能一眼看出不是原件。水印直接烙进截图,改不掉文本层。
* 审计编号直接用任务号 —— 它是全链路可追溯的真实标识,
* 另外编一个前端生成的编号只会让追溯断在这里。
*/
async function exportReadonlyCopy() {
const task = selectedTask.value
const element = taskDetailRef.value?.getDocumentElement()
if (!task || !element) {
ElMessage.info('该任务没有固化的文书正文,无法生成只读副本')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
await downloadSigningPdf({
element,
fileName: `${task.documentName}-${task.id}-只读副本`,
watermark: `只读副本 · 审计编号 ${task.id}`,
})
ElMessage.success('只读副本已生成(含水印与审计编号)')
} catch {
ElMessage.error('只读副本生成失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
async function downloadArtifact(artifact: SigningArtifact) {
if (!canDownloadArtifacts.value) {
ElMessage.warning('当前账号没有下载签署文件的权限')
return
}
try {
const blob = await downloadSigningArtifact(artifact.id)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = artifact.fileName
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
ElMessage.success(`${artifact.label}下载已开始`)
} catch {
ElMessage.error(`${artifact.label}下载失败,请稍后重试`)
}
}
async function confirmPadSignature(signatureDataUrl: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'手写签署已完成',
)
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,
signingSelection.value,
signingAnswers.value,
),
'线上签署已完成',
)
onlineDialogVisible.value = false
} catch {
ElMessage.error('线上签署失败,请稍后重试')
}
}
async function confirmVoidTask(reason: string) {
const task = selectedTask.value
if (!task) {
return
}
try {
await refreshTask(
await voidSigningTask(task.id, reason, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已作废',
)
voidDialogVisible.value = false
} catch {
ElMessage.error('任务作废失败,请稍后重试')
}
}
async function handleOnlineResend() {
await handleTaskAction('resend-sms')
}
async function initializePage() {
await appStore.loadCampuses()
syncFilterFromRoute()
await loadOrganizationOptions()
await loadTemplates()
await loadTasks()
}
async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id, true)
}
watch(
() => [route.query.status, route.query.range, route.query.campus, route.query.taskId],
() => {
syncFilterFromRoute()
page.value = 1
selectedTaskId.value = null
void loadTasks()
},
)
watch(
() => appStore.selectedCampus,
(campus) => {
if (filter.campus !== 'all') {
filter.campus = campus
}
page.value = 1
void loadTasks()
},
)
onMounted(() => {
void initializePage()
})
</script>
<template>
<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"
:can-create="canCreateTask"
@add="newDialogVisible = true"
@search="handleSearch"
/>
<div class="task-workspace">
<SigningTaskList
:tasks="tasks"
:selected-id="selectedTaskId"
:total="total"
:page="page"
:page-size="pageSize"
:loading="loading"
@select="selectTask"
@page-change="handlePageChange"
/>
<SigningTaskDetail
ref="taskDetailRef"
:task="selectedTask"
:loading="detailLoading"
:audit-events="taskEvents"
:audit-loading="eventsLoading"
:audit-error="eventsError"
:allow-local-signing="isSigningMockEnabled"
:artifacts="artifacts"
:artifacts-loading="artifactsLoading"
:artifacts-error="artifactsError"
:pad-session-status="padSessionStatus"
:pad-session-loading="padSessionLoading"
:can-delivery-write="canDeliveryWrite"
:can-reopen="canReopenTask"
:can-void="canVoidTask"
:can-view-audit="canViewEvents"
:can-view-artifacts="canViewArtifacts"
:can-download-artifact="canDownloadArtifacts"
:signature-injections="fieldSignatureInjections"
@action="handleTaskAction"
@download-artifact="downloadArtifact"
@selection-change="handleSelectionChange"
@field-change="handleFieldChange"
@signature-request="handleSignatureRequest"
/>
</div>
<NewSigningTaskDialog
:visible="newDialogVisible"
@close="newDialogVisible = false"
@created="handleTaskCreated"
/>
<SignaturePadDialog
:visible="signatureDialogVisible"
:task="selectedTask"
@close="signatureDialogVisible = false"
@confirm="confirmPadSignature"
/>
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->
<SignaturePadDialog
:visible="fieldSignatureDialogVisible"
:task="selectedTask"
:title="`采集「${pendingSignatureField?.label ?? '签名'}」`"
:hint="`请让签署人在下框内签名,签名图将回显到文书中的「${pendingSignatureField?.label ?? '签名'}」位置;本次采集不会结束签署任务:`"
confirm-label="确认采集"
@close="closeFieldSignatureDialog"
@confirm="confirmFieldSignature"
/>
<OnlineSigningDialog
:visible="onlineDialogVisible"
:task="selectedTask"
@close="onlineDialogVisible = false"
@complete="completeOnlineSignature"
@resend="handleOnlineResend"
/>
<SignedDocumentDialog
:visible="signedPreviewVisible"
:task="selectedTask"
:exporting="pdfExporting"
@close="signedPreviewVisible = false"
@download="handleTaskAction('download-pdf')"
@download-readonly-copy="exportReadonlyCopy"
/>
<VoidTaskDialog
:visible="voidDialogVisible"
:task="selectedTask"
@close="voidDialogVisible = false"
@confirm="confirmVoidTask"
/>
</section>
</template>
<style scoped>
/* 工作区吃满可视高度:文书在自己的滚动区里滚,页面本身不再滚。
外层 Container 仍是滚动容器,这里只是不去撑高它 */
.signing-page {
display: flex;
width: 100%;
max-width: 1600px;
height: 100%;
min-height: 0;
flex-direction: column;
margin: 0 auto;
}
.signing-page > * {
flex-shrink: 0;
}
.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;
flex: 1;
gap: 16px;
align-items: stretch;
/* 视口过矮时保底:宁可让外层滚动,也不把工作区压到看不见文书 */
min-height: 400px;
}
.task-workspace > * {
min-width: 0;
}
@media (max-width: 1200px) {
/* 窄屏两栏改上下堆叠,此时不再锁死高度,交回外层自然滚动 */
.signing-page {
height: auto;
}
.task-workspace {
flex-direction: column;
min-height: 0;
}
.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>