895 lines
24 KiB
Vue
895 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRoute } from 'vue-router'
|
||
|
||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||
import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts'
|
||
import { createPadSigningSession } from '@/api/workbench/deliveries'
|
||
import {
|
||
completeSigningTask,
|
||
getSigningTaskDetail,
|
||
getSigningTaskEvents,
|
||
getSigningTasks,
|
||
getSigningTemplates,
|
||
isSigningMockEnabled,
|
||
reopenSigningTask,
|
||
resendSigningSms,
|
||
updateSigningTaskMethod,
|
||
voidSigningTask,
|
||
} from '@/api/workbench/signing'
|
||
import type {
|
||
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 } from '@/utils/signing-document'
|
||
import { downloadSigningPdf } from '@/utils/signing-pdf'
|
||
|
||
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 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)
|
||
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)
|
||
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 })),
|
||
]
|
||
|
||
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
|
||
{ label: '全部方式', value: 'all' },
|
||
{ label: '手写板', value: 'pad' },
|
||
{ label: '短信线上', value: 'sms' },
|
||
]
|
||
|
||
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
|
||
{ label: '全部类型', value: 'all' },
|
||
{ label: '门诊', value: '门诊' },
|
||
{ label: '住院', value: '住院' },
|
||
{ label: '体检', value: '体检' },
|
||
]
|
||
|
||
const departmentOptions = computed<SigningFilterOption[]>(() => {
|
||
const departments = new Set([
|
||
...templates.value.map((template) => template.department),
|
||
...tasks.value.map((task) => task.department),
|
||
])
|
||
|
||
return [
|
||
{ label: '全部科室', value: '' },
|
||
...[...departments].sort().map((department) => ({ label: department, value: department })),
|
||
]
|
||
})
|
||
|
||
const documentOptions = computed<SigningFilterOption[]>(() => [
|
||
{ label: '全部文档', value: '' },
|
||
...templates.value.map((template) => ({ label: template.name, value: template.id })),
|
||
])
|
||
|
||
const signingApiOptions = computed(() => ({
|
||
campus: appStore.selectedCampus,
|
||
campusId: campusIds.value[appStore.selectedCampus],
|
||
campusIds: campusIds.value,
|
||
campusNames: campusNames.value,
|
||
departmentIds: departmentIds.value,
|
||
templates: templates.value,
|
||
}))
|
||
|
||
function getQueryString(value: unknown) {
|
||
return typeof value === 'string' ? value : ''
|
||
}
|
||
|
||
function isSigningStatus(value: string): value is SigningTaskStatus {
|
||
return STATUS_VALUES.includes(value as SigningTaskStatus)
|
||
}
|
||
|
||
function isSigningDateRange(value: string): value is SigningDateRange {
|
||
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
|
||
}
|
||
|
||
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||
return 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
|
||
|
||
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
|
||
}
|
||
}
|
||
} 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
|
||
}
|
||
|
||
/**
|
||
* 采集签名前的填写项校验。
|
||
* 必填项没填完就采集签名,等于签了一份内容不完整的文书,因此在入口处挡住。
|
||
*/
|
||
function assertFieldsReady() {
|
||
const missing = signingProgress.value?.missingRequired ?? []
|
||
|
||
if (missing.length) {
|
||
ElMessage.warning(`请先完成必填项:${missing.join('、')}`)
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
function handlePageChange(nextPage: number) {
|
||
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
|
||
|
||
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
|
||
return
|
||
}
|
||
|
||
page.value = nextPage
|
||
selectedTaskId.value = null
|
||
void loadTasks()
|
||
}
|
||
|
||
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
|
||
if (!updatedTask) {
|
||
ElMessage.error('签署任务不存在或已被删除')
|
||
return
|
||
}
|
||
|
||
await loadTasks(updatedTask.id, true)
|
||
ElMessage.success(successMessage)
|
||
}
|
||
|
||
async function handleTaskAction(action: SigningTaskAction) {
|
||
const task = selectedTask.value
|
||
|
||
if (!task) {
|
||
return
|
||
}
|
||
|
||
const allowed =
|
||
isSigningMockEnabled ||
|
||
(action === 'create-pad-session' && canDeliveryWrite.value) ||
|
||
(action === 'resend-sms' && canDeliveryWrite.value) ||
|
||
(action === 'reopen' && canReopenTask.value) ||
|
||
(action === 'void' && canVoidTask.value) ||
|
||
((action === 'download-pdf' || action === 'download-signature' || action === 'print') &&
|
||
canDownloadArtifacts.value)
|
||
|
||
if (!allowed) {
|
||
ElMessage.warning('当前账号没有执行此操作的权限')
|
||
return
|
||
}
|
||
|
||
if (action === 'pad-sign') {
|
||
if (!isSigningMockEnabled) {
|
||
ElMessage.info('手写板设备签署接口待接入')
|
||
return
|
||
}
|
||
|
||
if (!assertFieldsReady()) {
|
||
return
|
||
}
|
||
|
||
signatureDialogVisible.value = true
|
||
return
|
||
}
|
||
|
||
if (action === 'create-pad-session') {
|
||
padSessionLoading.value = true
|
||
|
||
try {
|
||
const session: SignDeliveryResponseDto = await createPadSigningSession(task.id)
|
||
padSessionStatus.value = session.status
|
||
ElMessage.success('手写板签署会话已创建,请由设备适配器继续采集签名')
|
||
} catch {
|
||
ElMessage.error('手写板签署会话创建失败,请稍后重试')
|
||
} finally {
|
||
padSessionLoading.value = false
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
if (action === 'online-sign') {
|
||
if (!isSigningMockEnabled) {
|
||
ElMessage.info('线上签署页面接口待接入')
|
||
return
|
||
}
|
||
|
||
if (!assertFieldsReady()) {
|
||
return
|
||
}
|
||
|
||
onlineDialogVisible.value = true
|
||
return
|
||
}
|
||
|
||
if (action === 'void') {
|
||
voidDialogVisible.value = true
|
||
return
|
||
}
|
||
|
||
if (action === 'reopen' && task.status !== 'expired') {
|
||
ElMessage.info('只有已超时任务可以重新发起')
|
||
return
|
||
}
|
||
|
||
if (action === 'download-pdf') {
|
||
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
|
||
|
||
if (artifact) {
|
||
await downloadArtifact(artifact)
|
||
} else 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 === 'print') {
|
||
ElMessage.info('原型演示:打印服务待接入')
|
||
return
|
||
}
|
||
|
||
try {
|
||
if (action === 'switch-method') {
|
||
if (!isSigningMockEnabled) {
|
||
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
|
||
return
|
||
}
|
||
|
||
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
|
||
await refreshTask(
|
||
await updateSigningTaskMethod(task.id, method),
|
||
method === 'sms' ? '已切换为短信线上签署' : '已切换为手写板签署',
|
||
)
|
||
return
|
||
}
|
||
|
||
if (action === 'resend-sms') {
|
||
await refreshTask(
|
||
await resendSigningSms(task.id, {
|
||
...signingApiOptions.value,
|
||
expectedRowVersion: task.rowVersion,
|
||
}),
|
||
'短信已重新发送',
|
||
)
|
||
return
|
||
}
|
||
|
||
if (action === 'reopen') {
|
||
await refreshTask(
|
||
await reopenSigningTask(task.id, {
|
||
...signingApiOptions.value,
|
||
expectedRowVersion: task.rowVersion,
|
||
}),
|
||
'签署任务已重新发起',
|
||
)
|
||
}
|
||
} catch {
|
||
ElMessage.error('任务操作失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 原型阶段:后端没有签署后 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 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"
|
||
@action="handleTaskAction"
|
||
@download-artifact="downloadArtifact"
|
||
@selection-change="handleSelectionChange"
|
||
@field-change="handleFieldChange"
|
||
/>
|
||
</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>
|