fix(signing): align workbench with MEDISIGN contract

This commit is contained in:
yelan
2026-09-01 14:50:07 +08:00
parent 8b4b9d031d
commit 5b2cc33038
10 changed files with 155 additions and 51 deletions

View File

@@ -46,21 +46,23 @@ function getCollectionRecords(collection: ArtifactCollection) {
}
function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact {
const artifactType: SignArtifactType = dto.artifactType ?? dto.type ?? 'UNKNOWN'
const artifactType: SignArtifactType = dto.artifactType
const label = artifactLabels[artifactType] ?? '签署文件'
const mimeType = dto.mimeType ?? dto.contentType ?? 'application/octet-stream'
const mimeType = dto.mimeType || 'application/octet-stream'
const extension =
mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin'
return {
id: dto.id,
taskId: dto.taskId,
pipelineId: dto.pipelineId,
artifactType,
label,
fileName: dto.fileName || `${label}.${extension}`,
fileName: `${label}.${extension}`,
mimeType,
size: dto.size ?? dto.byteSize ?? dto.fileSize ?? 0,
sha256: dto.sha256 ?? dto.contentSha256 ?? '',
sizeBytes: dto.sizeBytes,
sha256: dto.sha256,
metadata: dto.metadata ?? {},
createdAt: formatDateTime(dto.createdAt),
}
}

View File

@@ -41,15 +41,12 @@ export function sendSigningSms(
export function resendSigningSms(
taskId: string,
expectedRowVersion?: number,
idempotencyKey?: string,
): Promise<SignDeliveryResponseDto | null> {
const data = expectedRowVersion === undefined ? undefined : { expectedRowVersion }
return request
.post<ApiResponseOf<SignDeliveryResponseDto | null>>(
`/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/resend`,
data,
undefined,
{ headers: idempotencyHeaders(idempotencyKey) },
)
.then(unwrapNullableApiResponse)
@@ -69,9 +66,14 @@ export function createPadSigningSession(
.then(unwrapApiResponse)
}
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
export function consumeSigningToken(
payload: TokenConsumeRequest,
idempotencyKey?: string,
): Promise<TokenConsumeResponse> {
return request
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload)
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
headers: idempotencyHeaders(idempotencyKey),
})
.then(unwrapApiResponse)
}

View File

@@ -10,6 +10,7 @@ import type {
AvailableTemplateVersionResponseDto,
BackendSigningMethod,
BackendSigningTaskStatus,
BackendVisitType,
CreateSigningTaskInput,
CreateSigningTaskRequest,
PatientProfile,
@@ -563,7 +564,6 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
pending: 'WAITING_SIGN',
signing: 'GENERATING',
signed: 'SIGNED',
rejected: 'FAILED',
expired: 'EXPIRED',
void: 'VOIDED',
failed: 'FAILED',
@@ -579,6 +579,19 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD'
}
if (query.visitType && query.visitType !== 'all') {
const visitTypeMap: Partial<Record<VisitType, BackendVisitType>> = {
: 'OUTPATIENT',
: 'INPATIENT',
: 'CHECKUP',
}
const visitType = visitTypeMap[query.visitType]
if (visitType) {
params.visitType = visitType
}
}
if (query.documentId) {
params.templateVersionId =
findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId
@@ -604,8 +617,12 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
params.createdTo = end.toISOString()
}
if (query.campus && query.campus !== 'all' && options.campusId) {
params.campusId = options.campusId
if (query.campus && query.campus !== 'all') {
const campusId = options.campusIds?.[query.campus] ?? options.campusId
if (campusId) {
params.campusId = campusId
}
}
if (query.department && options.departmentIds?.[query.department]) {
@@ -1031,7 +1048,7 @@ export async function resendSigningSms(
options: SigningTaskActionOptions = {},
): Promise<SigningTaskRecord | null> {
if (!useMockData) {
await resendSigningSmsDelivery(id, options.expectedRowVersion)
await resendSigningSmsDelivery(id)
return getSigningTaskDetail(id, options)
}
@@ -1107,7 +1124,6 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
failed: '处理失败',

View File

@@ -9,8 +9,7 @@ export interface WorkbenchOverviewQuery {
rankingPeriod: HomeRankingPeriod
}
export type SigningTaskStatus =
'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' | 'failed'
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
export type PatientSex = '男' | '女' | '未知'
@@ -171,6 +170,7 @@ export interface SigningApiOptions {
templates?: SigningTemplate[]
campus?: WorkbenchCampus
campusId?: string
campusIds?: Record<string, string>
campusNames?: Record<string, WorkbenchCampus>
departmentIds?: Record<string, string>
}
@@ -438,32 +438,29 @@ export interface SignTaskEventResponseDto {
export type SignArtifactType = 'ORIGINAL_PDF' | 'SIGNATURE_IMAGE' | 'SIGNED_PDF' | string
/** MEDISIGN 签署产物 DTO。部分文件元数据由不同版本的服务端返回字段保持可选并在 API 边界归一化。 */
export interface SignArtifactResponseDto {
id: string
taskId: string
artifactType?: SignArtifactType
type?: SignArtifactType
fileName?: string | null
mimeType?: string | null
contentType?: string | null
size?: number | null
byteSize?: number | null
fileSize?: number | null
sha256?: string | null
contentSha256?: string | null
createdAt?: string | null
pipelineId: string
artifactType: SignArtifactType
sizeBytes: number
mimeType: string
sha256: string
metadata: Record<string, unknown>
createdAt: string
}
export interface SigningArtifact {
id: string
taskId: string
pipelineId: string
artifactType: SignArtifactType
label: string
fileName: string
mimeType: string
size: number
sizeBytes: number
sha256: string
metadata: Record<string, unknown>
createdAt: string
}

View File

@@ -226,7 +226,7 @@ function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): R
? 'signed'
: task.status === 'expired'
? 'expired'
: task.status === 'void' || task.status === 'failed' || task.status === 'rejected'
: task.status === 'void' || task.status === 'failed'
? 'void'
: 'pending'

View File

@@ -48,7 +48,7 @@ function formatFileSize(size: number) {
</div>
<div class="artifact-info">
<strong>{{ artifact.label }}</strong>
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.size) }}</span>
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }}</span>
<small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small>
</div>
<button type="button" class="artifact-download" @click="emit('download', artifact)">

View File

@@ -93,7 +93,7 @@ const emit = defineEmits<{
</button>
</template>
<template v-else-if="task.status === 'expired' || task.status === 'failed'">
<template v-else-if="task.status === 'expired'">
<button type="button" class="action-button" @click="emit('action', 'reopen')">
重新发起
</button>
@@ -105,7 +105,7 @@ const emit = defineEmits<{
class="action-button action-button--ghost"
@click="emit('action', 'download-pdf')"
>
下载 PDF 原件
下载签署后 PDF
</button>
<button
type="button"

View File

@@ -1,22 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
defineProps<{
const props = defineProps<{
tasks: SigningTaskRecord[]
selectedId: string | null
total: number
page: number
pageSize: number
loading: boolean
}>()
const emit = defineEmits<{
select: [id: string]
pageChange: [page: number]
}>()
const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize), 1))
const statusLabels: Record<SigningTaskRecord['status'], string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
failed: '处理失败',
@@ -100,6 +106,21 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
<span aria-hidden="true">🔍</span>
<p>没有符合条件的任务</p>
</div>
<footer v-if="total > pageSize" class="task-pagination">
<span> {{ page }} / {{ pageCount }} </span>
<span class="task-pagination__total"> {{ total }} </span>
<button type="button" :disabled="page <= 1 || loading" @click="emit('pageChange', page - 1)">
上一页
</button>
<button
type="button"
:disabled="page >= pageCount || loading"
@click="emit('pageChange', page + 1)"
>
下一页
</button>
</footer>
</section>
</template>
@@ -212,7 +233,6 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
background: var(--ok-l);
}
.task-status--rejected,
.task-status--expired {
color: var(--err);
background: var(--err-l);
@@ -292,6 +312,42 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
font-size: 13px;
}
.task-pagination {
display: flex;
gap: 8px;
align-items: center;
padding: 9px 10px;
margin-top: 10px;
color: var(--mut);
font-size: 11.5px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.task-pagination__total {
margin-right: auto;
}
.task-pagination button {
padding: 4px 8px;
color: var(--brand);
font-size: 11.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 5px;
}
.task-pagination button:hover:not(:disabled) {
border-color: var(--brand);
background: var(--brand-l);
}
.task-pagination button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.task-items--loading {
display: flex;
flex-direction: column;

View File

@@ -48,7 +48,6 @@ const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
'signing',
'signed',
'rejected',
'expired',
'void',
'failed',
@@ -59,7 +58,6 @@ const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
rejected: '已拒签',
expired: '已超时',
void: '已作废',
failed: '处理失败',
@@ -78,6 +76,8 @@ const filter = reactive<SigningFilterForm>({
const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([])
@@ -145,6 +145,7 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus],
campusIds: campusIds.value,
campusNames: campusNames.value,
departmentIds: departmentIds.value,
templates: templates.value,
@@ -275,7 +276,11 @@ async function loadTaskDetail(id: string | null) {
}
}
async function loadTasks(preferredTaskId?: string) {
async function loadTasks(preferredTaskId?: string, resetPage = false) {
if (resetPage) {
page.value = 1
}
const requestId = ++listRequestId
loading.value = true
@@ -283,8 +288,8 @@ async function loadTasks(preferredTaskId?: string) {
const response = await getSigningTasks(
{
...filter,
page: 1,
pageSize: 50,
page: page.value,
pageSize: pageSize.value,
},
signingApiOptions.value,
)
@@ -295,6 +300,8 @@ async function loadTasks(preferredTaskId?: string) {
tasks.value = response.records
total.value = response.total
page.value = response.page
pageSize.value = response.pageSize
const taskIds = new Set(response.records.map((task) => task.id))
const routeTaskId = getQueryString(route.query.taskId)
@@ -323,7 +330,7 @@ async function loadTasks(preferredTaskId?: string) {
function handleSearch(nextFilter: SigningFilterForm) {
Object.assign(filter, nextFilter)
selectedTaskId.value = null
void loadTasks()
void loadTasks(undefined, true)
}
function selectTask(id: string) {
@@ -331,13 +338,25 @@ function selectTask(id: string) {
void loadTaskDetail(id)
}
function handlePageChange(nextPage: number) {
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
return
}
page.value = nextPage
selectedTaskId.value = null
void loadTasks()
}
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
if (!updatedTask) {
ElMessage.error('签署任务不存在或已被删除')
return
}
await loadTasks(updatedTask.id)
await loadTasks(updatedTask.id, true)
ElMessage.success(successMessage)
}
@@ -389,15 +408,18 @@ async function handleTaskAction(action: SigningTaskAction) {
return
}
if (action === 'reopen' && task.status !== 'expired') {
ElMessage.info('只有已超时任务可以重新发起')
return
}
if (action === 'download-pdf') {
const artifact =
artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF') ??
artifacts.value.find((item) => item.artifactType === 'ORIGINAL_PDF')
const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
if (artifact) {
await downloadArtifact(artifact)
} else {
ElMessage.info('当前任务暂无 PDF 文件')
ElMessage.info('当前任务暂无签署后 PDF')
}
return
}
@@ -535,13 +557,14 @@ async function initializePage() {
}
async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id)
await loadTasks(task.id, true)
}
watch(
() => [route.query.status, route.query.range, route.query.taskId],
() => {
syncFilterFromRoute()
page.value = 1
selectedTaskId.value = null
void loadTasks()
},
@@ -553,6 +576,7 @@ watch(
if (filter.campus !== 'all') {
filter.campus = campus
}
page.value = 1
void loadTasks()
},
)
@@ -593,8 +617,11 @@ onMounted(() => {
:tasks="tasks"
:selected-id="selectedTaskId"
:total="total"
:page="page"
:page-size="pageSize"
:loading="loading"
@select="selectTask"
@page-change="handlePageChange"
/>
<SigningTaskDetail
:task="selectedTask"

View File

@@ -11,7 +11,11 @@ import type {
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
return request
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload)
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
headers: {
'Idempotency-Key': createIdempotencyKey(),
},
})
.then(unwrapApiResponse)
}