@@ -114,6 +115,7 @@ const props = defineProps({
busy: { type: Boolean, default: false },
claimNo: { type: String, default: '' },
application: { type: Boolean, default: false },
+ error: { type: String, default: '' },
title: { type: String, default: '确认退回该单据吗?' },
description: {
type: String,
@@ -238,6 +240,16 @@ function handleConfirm() {
gap: 14px;
}
+.return-reason-dialog > small.error {
+ padding: 9px 11px;
+ border: 1px solid #fecaca;
+ border-radius: 7px;
+ background: #fef2f2;
+ color: #b91c1c;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
.return-reason-section {
display: grid;
gap: 8px;
diff --git a/web/src/components/travel/RiskObservationEvidenceCard.vue b/web/src/components/travel/RiskObservationEvidenceCard.vue
index 66e6cff..64ace5e 100644
--- a/web/src/components/travel/RiskObservationEvidenceCard.vue
+++ b/web/src/components/travel/RiskObservationEvidenceCard.vue
@@ -77,12 +77,14 @@
{{ currentDisposition.resolution }}
+
{{ dispositionCompleteMessage }}
-
{{ dispositionError }}
+
+
+ {{ currentDisposition.readOnlyReason }}
+
+
+ {{ dispositionError.title }}
+ {{ dispositionError.message }}
+
@@ -179,17 +196,28 @@
+
+
+
diff --git a/web/src/components/travel/RiskWaiverRecord.vue b/web/src/components/travel/RiskWaiverRecord.vue
new file mode 100644
index 0000000..085da78
--- /dev/null
+++ b/web/src/components/travel/RiskWaiverRecord.vue
@@ -0,0 +1,161 @@
+
+
+
+ 豁免申请与决定
+ {{ decisionLabel }}
+
+
+
+
- 申请人
+ -
+ {{ disposition.waiverRequesterName || disposition.waiverRequesterId || '未记录' }}
+ {{ formatDateTime(disposition.waiverRequestedAt) }}
+
+
+
+
- 申请理由
+ - {{ disposition.waiverReason || '未记录' }}
+
+
+
- 豁免范围
+ - {{ disposition.waiverScope || '未记录' }}
+
+
+
- 有效期至
+ - {{ formatDateTime(disposition.waiverExpiresAt) }}
+
+
+
+
- 决定记录
+ -
+ {{ disposition.waiverDeciderName || disposition.waiverDeciderId || '未记录决定人' }}
+ {{ formatDateTime(disposition.waiverDecidedAt) }}
+ {{ disposition.waiverDecisionReason || '未记录决定理由' }}
+
+
+
+
+ 豁免审计轨迹
+
+ -
+
+ {{ event.label }}
+ v{{ event.version }}
+
+ {{ event.actor }} · {{ formatDateTime(event.createdAt) }}
+ {{ event.comment }}
+ {{ detail }}
+ 请求 ID:{{ event.requestId }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/composables/useApprovalTaskQueue.js b/web/src/composables/useApprovalTaskQueue.js
new file mode 100644
index 0000000..8ab27dd
--- /dev/null
+++ b/web/src/composables/useApprovalTaskQueue.js
@@ -0,0 +1,339 @@
+import { computed, ref } from 'vue'
+
+import {
+ batchApproveApprovalTasks,
+ fetchApprovalTaskDetail,
+ fetchApprovalTasks
+} from '../services/approvalTasks.js'
+import { resolveApprovalTaskRetryKey } from '../views/scripts/approvalTaskRetry.js'
+import {
+ createApprovalTaskSelectionState,
+ reconcileApprovalTaskSelectionAfterBatch,
+ reconcileApprovalTaskSelectionWithPage,
+ resolveCurrentPageApprovalSelection,
+ syncApprovalTaskSelectionFilters,
+ toggleApprovalTaskSelection,
+ toggleCurrentPageApprovalSelection
+} from '../views/scripts/approvalTaskQueueState.js'
+
+const DEFAULT_FILTERS = Object.freeze({
+ status: 'pending',
+ nodeKey: '',
+ taskKind: '',
+ riskLevel: '',
+ slaState: '',
+ keyword: '',
+ sort: 'priority_desc',
+ batchEligible: null
+})
+const MAX_BATCH_SELECTION = 20
+
+function toText(value) {
+ return String(value ?? '').trim()
+}
+
+function toPositiveInteger(value, fallback = 1) {
+ const number = Math.trunc(Number(value))
+ return Number.isFinite(number) && number > 0 ? number : fallback
+}
+
+function taskIdOf(item = {}) {
+ return toText(item?.task?.id ?? item?.id ?? item?.task_id ?? item?.taskId)
+}
+
+export function normalizeApprovalTaskQueueFilters(filters = {}) {
+ return {
+ ...DEFAULT_FILTERS,
+ ...filters,
+ status: filters.status ?? DEFAULT_FILTERS.status,
+ nodeKey: filters.nodeKey ?? filters.node_key ?? DEFAULT_FILTERS.nodeKey,
+ taskKind: filters.taskKind ?? filters.task_kind ?? DEFAULT_FILTERS.taskKind,
+ riskLevel: filters.riskLevel ?? filters.risk_level ?? DEFAULT_FILTERS.riskLevel,
+ slaState: filters.slaState ?? filters.sla_state ?? DEFAULT_FILTERS.slaState,
+ keyword: toText(filters.keyword),
+ sort: filters.sort ?? DEFAULT_FILTERS.sort,
+ batchEligible: typeof (filters.batchEligible ?? filters.batch_eligible) === 'boolean'
+ ? Boolean(filters.batchEligible ?? filters.batch_eligible)
+ : null
+ }
+}
+
+export function useApprovalTaskQueue(options = {}) {
+ const services = {
+ fetchApprovalTasks,
+ fetchApprovalTaskDetail,
+ batchApproveApprovalTasks,
+ ...(options.services || {})
+ }
+ const page = ref(toPositiveInteger(options.initialPage, 1))
+ const pageSize = ref(toPositiveInteger(options.initialPageSize, 20))
+ const filters = ref(normalizeApprovalTaskQueueFilters(options.initialFilters))
+ const items = ref([])
+ const total = ref(0)
+ const totalPages = ref(0)
+ const generatedAt = ref('')
+ const loading = ref(false)
+ const batchBusy = ref(false)
+ const errorMessage = ref('')
+ const lastBatchResult = ref(null)
+ const pendingBatchRequestId = ref('')
+ const pendingBatchFingerprint = ref('')
+ const selectionState = ref(createApprovalTaskSelectionState({ filters: filters.value }))
+ const selectedSnapshots = new Map()
+ const taskRefreshSequences = new Map()
+ let listRequestSequence = 0
+
+ const selectedTaskIds = computed(() => selectionState.value.selectedTaskIds)
+ const pageSelection = computed(() =>
+ resolveCurrentPageApprovalSelection(items.value, selectedTaskIds.value)
+ )
+ const selectedCount = computed(() => selectedTaskIds.value.length)
+
+ function updateSelection(nextTaskIds) {
+ const selectedSet = new Set(nextTaskIds)
+ selectionState.value = {
+ ...selectionState.value,
+ selectedTaskIds: [...selectedSet]
+ }
+ for (const taskId of [...selectedSnapshots.keys()]) {
+ if (!selectedSet.has(taskId)) {
+ selectedSnapshots.delete(taskId)
+ }
+ }
+ for (const item of items.value) {
+ const taskId = taskIdOf(item)
+ if (taskId && selectedSet.has(taskId)) {
+ selectedSnapshots.set(taskId, item)
+ }
+ }
+ }
+
+ function applyListResult(result) {
+ items.value = Array.isArray(result?.items) ? result.items : []
+ total.value = Math.max(0, Number(result?.total) || 0)
+ totalPages.value = Math.max(0, Number(result?.totalPages) || 0)
+ generatedAt.value = toText(result?.generatedAt)
+ page.value = toPositiveInteger(result?.page, page.value)
+ pageSize.value = toPositiveInteger(result?.pageSize, pageSize.value)
+ updateSelection(reconcileApprovalTaskSelectionWithPage(
+ selectedTaskIds.value,
+ items.value
+ ))
+ }
+
+ async function loadQueue(loadOptions = {}) {
+ const requestSequence = ++listRequestSequence
+ if (!loadOptions.silent) {
+ loading.value = true
+ }
+ errorMessage.value = ''
+ try {
+ const result = await services.fetchApprovalTasks({
+ ...filters.value,
+ page: page.value,
+ pageSize: pageSize.value
+ }, loadOptions.requestOptions)
+ if (requestSequence !== listRequestSequence) {
+ return { ignored: true, result }
+ }
+ applyListResult(result)
+ return { ignored: false, result }
+ } catch (error) {
+ if (requestSequence !== listRequestSequence) {
+ return { ignored: true, error }
+ }
+ errorMessage.value = error?.message || '审批任务列表加载失败。'
+ throw error
+ } finally {
+ if (requestSequence === listRequestSequence) {
+ loading.value = false
+ }
+ }
+ }
+
+ function setFilters(nextFilters = {}, setOptions = {}) {
+ const normalized = normalizeApprovalTaskQueueFilters({
+ ...filters.value,
+ ...nextFilters
+ })
+ selectionState.value = syncApprovalTaskSelectionFilters(
+ selectionState.value,
+ normalized
+ )
+ if (!selectionState.value.selectedTaskIds.length) {
+ selectedSnapshots.clear()
+ }
+ filters.value = normalized
+ page.value = 1
+ return setOptions.reload === false ? Promise.resolve(null) : loadQueue()
+ }
+
+ function setPage(nextPage, setOptions = {}) {
+ page.value = toPositiveInteger(nextPage, 1)
+ return setOptions.reload === false ? Promise.resolve(null) : loadQueue()
+ }
+
+ function setPageSize(nextPageSize, setOptions = {}) {
+ pageSize.value = toPositiveInteger(nextPageSize, pageSize.value)
+ page.value = 1
+ return setOptions.reload === false ? Promise.resolve(null) : loadQueue()
+ }
+
+ function toggleTask(item, selected) {
+ const nextSelection = toggleApprovalTaskSelection(selectedTaskIds.value, item, selected)
+ if (nextSelection.length > MAX_BATCH_SELECTION) {
+ errorMessage.value = `批量审批每次最多选择 ${MAX_BATCH_SELECTION} 项任务。`
+ return
+ }
+ updateSelection(nextSelection)
+ }
+
+ function toggleCurrentPage(selected) {
+ const nextSelection = toggleCurrentPageApprovalSelection(
+ selectedTaskIds.value,
+ items.value,
+ selected
+ )
+ if (nextSelection.length > MAX_BATCH_SELECTION) {
+ errorMessage.value = `批量审批每次最多选择 ${MAX_BATCH_SELECTION} 项,已保留当前结果中的前 ${MAX_BATCH_SELECTION} 项。`
+ updateSelection(nextSelection.slice(0, MAX_BATCH_SELECTION))
+ return
+ }
+ updateSelection(nextSelection)
+ }
+
+ function clearSelection() {
+ updateSelection([])
+ }
+
+ function replaceTaskItem(taskId, nextItem) {
+ const index = items.value.findIndex((item) => taskIdOf(item) === taskId)
+ if (index < 0) {
+ return
+ }
+ if (!nextItem) {
+ items.value = items.value.filter((_, itemIndex) => itemIndex !== index)
+ total.value = Math.max(0, total.value - 1)
+ } else {
+ items.value = items.value.map((item, itemIndex) => itemIndex === index ? nextItem : item)
+ }
+ updateSelection(reconcileApprovalTaskSelectionWithPage(
+ selectedTaskIds.value,
+ items.value
+ ))
+ }
+
+ async function refreshTask(taskId, refreshOptions = {}) {
+ const normalizedTaskId = toText(taskId)
+ if (!normalizedTaskId) {
+ return null
+ }
+ const sequence = (taskRefreshSequences.get(normalizedTaskId) || 0) + 1
+ taskRefreshSequences.set(normalizedTaskId, sequence)
+ try {
+ const nextItem = await services.fetchApprovalTaskDetail(
+ normalizedTaskId,
+ refreshOptions.requestOptions
+ )
+ if (taskRefreshSequences.get(normalizedTaskId) !== sequence) {
+ return null
+ }
+ replaceTaskItem(normalizedTaskId, nextItem)
+ return nextItem
+ } catch (error) {
+ if (taskRefreshSequences.get(normalizedTaskId) !== sequence) {
+ return null
+ }
+ if (Number(error?.status) === 404) {
+ replaceTaskItem(normalizedTaskId, null)
+ return null
+ }
+ errorMessage.value = error?.message || '审批任务刷新失败。'
+ throw error
+ }
+ }
+
+ async function batchApprove(batchOptions = {}) {
+ if (batchBusy.value) {
+ return null
+ }
+ const selectedItems = selectedTaskIds.value.map((taskId) => selectedSnapshots.get(taskId))
+ if (!selectedItems.length || selectedItems.some((item) => !item)) {
+ throw new Error('所选审批任务快照已失效,请刷新列表后重新选择。')
+ }
+ const opinion = toText(batchOptions.opinion)
+ const payloadItems = selectedItems.map((item) => opinion ? { ...item, opinion } : item)
+ const retryKey = resolveApprovalTaskRetryKey({
+ action: 'batch-approve',
+ scopeId: 'queue',
+ payload: { items: payloadItems },
+ previousFingerprint: pendingBatchFingerprint.value,
+ previousRequestId: pendingBatchRequestId.value
+ })
+ pendingBatchRequestId.value = retryKey.requestId.slice(0, 80)
+ pendingBatchFingerprint.value = retryKey.fingerprint
+ batchBusy.value = true
+ errorMessage.value = ''
+ try {
+ const result = await services.batchApproveApprovalTasks({
+ batchRequestId: pendingBatchRequestId.value,
+ items: payloadItems
+ }, batchOptions.requestOptions)
+ lastBatchResult.value = result
+ pendingBatchRequestId.value = ''
+ pendingBatchFingerprint.value = ''
+ updateSelection(reconcileApprovalTaskSelectionAfterBatch(
+ selectedTaskIds.value,
+ result
+ ))
+ try {
+ await loadQueue({ silent: true })
+ } catch {
+ // 批量结果已经落定,列表刷新失败由 errorMessage 明确展示,不覆盖真实结果。
+ }
+ return result
+ } catch (error) {
+ errorMessage.value = error?.message || '批量审批失败,请刷新任务状态后重试。'
+ throw error
+ } finally {
+ batchBusy.value = false
+ }
+ }
+
+ function clearBatchResult() {
+ lastBatchResult.value = null
+ }
+
+ function clearError() {
+ errorMessage.value = ''
+ }
+
+ return {
+ batchApprove,
+ batchBusy,
+ clearBatchResult,
+ clearError,
+ clearSelection,
+ errorMessage,
+ filters,
+ generatedAt,
+ items,
+ lastBatchResult,
+ loadQueue,
+ loading,
+ page,
+ pageSelection,
+ pageSize,
+ pendingBatchRequestId,
+ refreshTask,
+ selectedCount,
+ selectedTaskIds,
+ setFilters,
+ setPage,
+ setPageSize,
+ toggleCurrentPage,
+ toggleTask,
+ total,
+ totalPages
+ }
+}
diff --git a/web/src/composables/useDocumentCenterArchiveRows.js b/web/src/composables/useDocumentCenterArchiveRows.js
new file mode 100644
index 0000000..790aca7
--- /dev/null
+++ b/web/src/composables/useDocumentCenterArchiveRows.js
@@ -0,0 +1,43 @@
+import { ref } from 'vue'
+
+import {
+ REIMBURSEMENT_LIST_PREVIEW_PARAMS,
+ extractExpenseClaimItems,
+ fetchArchivedExpenseClaims
+} from '../services/reimbursements.js'
+import { buildDocumentRow } from '../utils/documentCenterViewModel.js'
+import { mapExpenseClaimToRequest } from './useRequests.js'
+
+export function useDocumentCenterArchiveRows(options = {}) {
+ const archiveRows = ref([])
+ const supportingLoading = ref(false)
+ const supportingError = ref('')
+ let requestSequence = 0
+
+ async function loadSupportingRows() {
+ const sequence = ++requestSequence
+ supportingLoading.value = true
+ supportingError.value = ''
+ try {
+ const payload = await fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
+ if (sequence !== requestSequence) return
+ archiveRows.value = extractExpenseClaimItems(payload)
+ .map(mapExpenseClaimToRequest)
+ .map((item) => buildDocumentRow(item, {
+ source: 'archive',
+ archived: true,
+ currentUser: options.currentUser?.value,
+ viewedDocumentKeys: options.viewedDocumentKeys?.value
+ }))
+ .filter(Boolean)
+ } catch (error) {
+ if (sequence !== requestSequence) return
+ archiveRows.value = []
+ supportingError.value = error?.message || '归档数据加载失败。'
+ } finally {
+ if (sequence === requestSequence) supportingLoading.value = false
+ }
+ }
+
+ return { archiveRows, supportingError, supportingLoading, loadSupportingRows }
+}
diff --git a/web/src/services/approvalTasks.js b/web/src/services/approvalTasks.js
new file mode 100644
index 0000000..c2bee5b
--- /dev/null
+++ b/web/src/services/approvalTasks.js
@@ -0,0 +1,535 @@
+import { apiRequest } from './api.js'
+
+const APPROVAL_TASKS_PATH = '/approval-tasks'
+const DEFAULT_PAGE_SIZE = 20
+const MAX_PAGE_SIZE = 200
+const DEFAULT_TIMEOUT_MS = 5000
+
+function toObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
+}
+
+function toArray(value) {
+ return Array.isArray(value) ? value : []
+}
+
+function toText(value) {
+ return String(value ?? '').trim()
+}
+
+function toNumber(value, fallback = 0) {
+ const number = Number(value)
+ return Number.isFinite(number) ? number : fallback
+}
+
+function toInteger(value, fallback = 0) {
+ return Math.trunc(toNumber(value, fallback))
+}
+
+function toPositiveInteger(value, fallback = 1, maximum = Number.MAX_SAFE_INTEGER) {
+ return Math.min(maximum, Math.max(1, toInteger(value, fallback)))
+}
+
+function toNullableObject(value) {
+ const object = toObject(value)
+ return Object.keys(object).length ? object : null
+}
+
+function uniqueTexts(value) {
+ return [...new Set(toArray(value).map(toText).filter(Boolean))]
+}
+
+function normalizePriorityReason(item = {}) {
+ return {
+ code: toText(item.code),
+ label: toText(item.label),
+ weight: toNumber(item.weight),
+ tone: toText(item.tone || 'normal') || 'normal'
+ }
+}
+
+export function createApprovalTaskRequestId(action = 'action', taskId = 'task') {
+ const normalizedAction = toText(action).replace(/[^a-z0-9_-]+/gi, '-') || 'action'
+ const normalizedTaskId = toText(taskId).replace(/[^a-z0-9_-]+/gi, '-') || 'task'
+ const suffix = typeof globalThis.crypto?.randomUUID === 'function'
+ ? globalThis.crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`
+ return `approval:${normalizedAction}:${normalizedTaskId}:${suffix}`.slice(0, 120)
+}
+
+export function normalizeApprovalTask(item = {}) {
+ const task = toObject(item?.task || item)
+ if (!toText(task.id)) {
+ return null
+ }
+
+ return {
+ id: toText(task.id),
+ tenantId: toText(task.tenant_id ?? task.tenantId),
+ claimId: toText(task.claim_id ?? task.claimId),
+ expenseCaseId: toText(task.expense_case_id ?? task.expenseCaseId),
+ nodeInstanceId: toText(task.node_instance_id ?? task.nodeInstanceId),
+ nodeEntryKey: toText(task.node_entry_key ?? task.nodeEntryKey),
+ parentTaskId: toText(task.parent_task_id ?? task.parentTaskId),
+ taskKind: toText(task.task_kind ?? task.taskKind ?? 'root') || 'root',
+ nodeKey: toText(task.node_key ?? task.nodeKey),
+ nodeLabel: toText(task.node_label ?? task.nodeLabel),
+ nodeSequence: toInteger(task.node_sequence ?? task.nodeSequence),
+ sequenceOrder: toInteger(task.sequence_order ?? task.sequenceOrder),
+ coordinationMode: toText(task.coordination_mode ?? task.coordinationMode ?? 'single') || 'single',
+ ownerKind: toText(task.owner_kind ?? task.ownerKind),
+ ownerKey: toText(task.owner_key ?? task.ownerKey),
+ ownerEmployeeId: toText(task.owner_employee_id ?? task.ownerEmployeeId),
+ ownerName: toText(task.owner_name ?? task.ownerName),
+ assigneeKind: toText(task.assignee_kind ?? task.assigneeKind),
+ assigneeKey: toText(task.assignee_key ?? task.assigneeKey),
+ assigneeEmployeeId: toText(task.assignee_employee_id ?? task.assigneeEmployeeId),
+ assigneeName: toText(task.assignee_name ?? task.assigneeName),
+ delegatedBy: toText(task.delegated_by ?? task.delegatedBy),
+ delegationExpiresAt: toText(task.delegation_expires_at ?? task.delegationExpiresAt),
+ status: toText(task.status),
+ decision: toText(task.decision),
+ opinion: toText(task.opinion),
+ version: Math.max(0, toInteger(task.version)),
+ claimStatusSnapshot: toText(task.claim_status_snapshot ?? task.claimStatusSnapshot),
+ claimStageSnapshot: toText(task.claim_stage_snapshot ?? task.claimStageSnapshot),
+ enteredAt: toText(task.entered_at ?? task.enteredAt),
+ enteredAtSource: toText(task.entered_at_source ?? task.enteredAtSource),
+ activatedAt: toText(task.activated_at ?? task.activatedAt),
+ slaHoursSnapshot: Math.max(0, toInteger(task.sla_hours_snapshot ?? task.slaHoursSnapshot)),
+ dueAt: toText(task.due_at ?? task.dueAt),
+ completedAt: toText(task.completed_at ?? task.completedAt),
+ cancelledAt: toText(task.cancelled_at ?? task.cancelledAt),
+ escalationLevel: Math.max(0, toInteger(task.escalation_level ?? task.escalationLevel)),
+ escalatedAt: toText(task.escalated_at ?? task.escalatedAt),
+ nextEscalationAt: toText(task.next_escalation_at ?? task.nextEscalationAt),
+ priorityScore: Math.max(0, Math.min(100, toNumber(task.priority_score ?? task.priorityScore))),
+ priorityTier: toText(task.priority_tier ?? task.priorityTier ?? 'normal') || 'normal',
+ priorityReasons: toArray(task.priority_reasons_json ?? task.priorityReasons)
+ .map(normalizePriorityReason)
+ .filter((reason) => reason.code || reason.label),
+ riskLevel: toText(task.risk_level ?? task.riskLevel ?? 'low') || 'low',
+ openRiskCount: Math.max(0, toInteger(task.open_risk_count ?? task.openRiskCount)),
+ evidenceCompleteness: Math.max(
+ 0,
+ Math.min(1, toNumber(task.evidence_completeness ?? task.evidenceCompleteness))
+ ),
+ batchEligible: Boolean(task.batch_eligible ?? task.batchEligible),
+ batchBlockReasons: uniqueTexts(task.batch_block_reasons_json ?? task.batchBlockReasons),
+ projectionUpdatedAt: toText(task.projection_updated_at ?? task.projectionUpdatedAt),
+ createdAt: toText(task.created_at ?? task.createdAt),
+ updatedAt: toText(task.updated_at ?? task.updatedAt),
+ canAct: Boolean(task.can_act ?? task.canAct),
+ availableActions: uniqueTexts(task.available_actions ?? task.availableActions),
+ readOnlyReason: toText(task.read_only_reason ?? task.readOnlyReason)
+ }
+}
+
+export function normalizeApprovalTaskQueueItem(item = {}) {
+ const source = toObject(item)
+ const task = normalizeApprovalTask(source.task || source)
+ if (!task) {
+ return null
+ }
+ return {
+ task,
+ claim: toNullableObject(source.claim),
+ id: task.id,
+ claimId: task.claimId
+ }
+}
+
+export function normalizeApprovalTaskEvent(item = {}) {
+ const event = toObject(item)
+ if (!toText(event.id)) {
+ return null
+ }
+ return {
+ id: toText(event.id),
+ tenantId: toText(event.tenant_id ?? event.tenantId),
+ taskId: toText(event.task_id ?? event.taskId),
+ nodeInstanceId: toText(event.node_instance_id ?? event.nodeInstanceId),
+ eventType: toText(event.event_type ?? event.eventType),
+ actorId: toText(event.actor_id ?? event.actorId),
+ actorName: toText(event.actor_name ?? event.actorName),
+ actorType: toText(event.actor_type ?? event.actorType),
+ requestId: toText(event.request_id ?? event.requestId),
+ expectedTaskVersion: Math.max(
+ 0,
+ toInteger(event.expected_task_version ?? event.expectedTaskVersion)
+ ),
+ resultTaskVersion: Math.max(
+ 0,
+ toInteger(event.result_task_version ?? event.resultTaskVersion)
+ ),
+ payload: toObject(event.payload_json ?? event.payload),
+ before: toObject(event.before_json ?? event.before),
+ after: toObject(event.after_json ?? event.after),
+ approvalActionLedgerId: toText(
+ event.approval_action_ledger_id ?? event.approvalActionLedgerId
+ ),
+ businessEventId: toText(event.business_event_id ?? event.businessEventId),
+ correlationId: toText(event.correlation_id ?? event.correlationId),
+ causationId: toText(event.causation_id ?? event.causationId),
+ occurredAt: toText(event.occurred_at ?? event.occurredAt)
+ }
+}
+
+export function normalizeApprovalTaskMutation(payload = {}) {
+ const envelope = toObject(payload)
+ const mutation = toObject(envelope.mutation || envelope)
+ return {
+ task: normalizeApprovalTask(mutation.task),
+ event: normalizeApprovalTaskEvent(mutation.event),
+ relatedTasks: toArray(mutation.related_tasks ?? mutation.relatedTasks)
+ .map(normalizeApprovalTask)
+ .filter(Boolean),
+ replayed: Boolean(mutation.replayed),
+ claim: toNullableObject(envelope.claim ?? mutation.claim)
+ }
+}
+
+export function normalizeApprovalTaskList(payload = {}) {
+ const source = toObject(payload)
+ const items = toArray(source.items).map(normalizeApprovalTaskQueueItem).filter(Boolean)
+ const total = Math.max(items.length, toInteger(source.total, items.length))
+ const pageSize = toPositiveInteger(
+ source.page_size ?? source.pageSize,
+ DEFAULT_PAGE_SIZE,
+ MAX_PAGE_SIZE
+ )
+ const page = toPositiveInteger(source.page, 1)
+ const totalPages = Math.max(
+ total ? 1 : 0,
+ toInteger(source.total_pages ?? source.totalPages, Math.ceil(total / pageSize))
+ )
+ return {
+ items,
+ total,
+ page,
+ pageSize,
+ totalPages,
+ generatedAt: toText(source.generated_at ?? source.generatedAt)
+ }
+}
+
+export function normalizeApprovalTaskCandidate(item = {}) {
+ const source = toObject(item)
+ const employeeId = toText(source.employee_id ?? source.employeeId)
+ if (!employeeId) {
+ return null
+ }
+ return {
+ employeeId,
+ employeeNo: toText(source.employee_no ?? source.employeeNo),
+ name: toText(source.name),
+ email: toText(source.email),
+ qualified: Boolean(source.qualified),
+ reason: toText(source.reason)
+ }
+}
+
+export function normalizeApprovalTaskCandidateList(payload = {}) {
+ const source = Array.isArray(payload) ? { items: payload } : toObject(payload)
+ const items = toArray(source.items).map(normalizeApprovalTaskCandidate).filter(Boolean)
+ return {
+ items,
+ total: Math.max(items.length, toInteger(source.total, items.length))
+ }
+}
+
+export function normalizeApprovalTaskBatchResult(payload = {}) {
+ const source = toObject(payload)
+ const items = toArray(source.items).map((item) => ({
+ taskId: toText(item?.task_id ?? item?.taskId),
+ claimId: toText(item?.claim_id ?? item?.claimId),
+ status: toText(item?.status || 'failed') || 'failed',
+ code: toText(item?.code),
+ message: toText(item?.message),
+ claim: toNullableObject(item?.claim)
+ })).filter((item) => item.taskId)
+ const countStatus = (status) => items.filter((item) => item.status === status).length
+ return {
+ batchRequestId: toText(source.batch_request_id ?? source.batchRequestId),
+ status: toText(source.status || 'failed') || 'failed',
+ succeededCount: Math.max(0, toInteger(source.succeeded_count ?? source.succeededCount, countStatus('succeeded'))),
+ replayedCount: Math.max(0, toInteger(source.replayed_count ?? source.replayedCount, countStatus('replayed'))),
+ conflictCount: Math.max(0, toInteger(source.conflict_count ?? source.conflictCount, countStatus('conflict'))),
+ blockedCount: Math.max(0, toInteger(source.blocked_count ?? source.blockedCount, countStatus('blocked'))),
+ forbiddenCount: Math.max(0, toInteger(source.forbidden_count ?? source.forbiddenCount, countStatus('forbidden'))),
+ failedCount: Math.max(0, toInteger(source.failed_count ?? source.failedCount, countStatus('failed'))),
+ items
+ }
+}
+
+function appendQueryValue(search, key, value) {
+ if (Array.isArray(value)) {
+ value.map(toText).filter(Boolean).forEach((item) => search.append(key, item))
+ return
+ }
+ const normalized = toText(value)
+ if (normalized) {
+ search.set(key, normalized)
+ }
+}
+
+function buildApprovalTaskListQuery(params = {}) {
+ const search = new URLSearchParams()
+ search.set('page', String(toPositiveInteger(params.page, 1)))
+ search.set(
+ 'page_size',
+ String(toPositiveInteger(params.pageSize ?? params.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE))
+ )
+ appendQueryValue(search, 'status', params.status)
+ appendQueryValue(search, 'node_key', params.nodeKey ?? params.node_key)
+ appendQueryValue(search, 'task_kind', params.taskKind ?? params.task_kind)
+ appendQueryValue(search, 'risk_level', params.riskLevel ?? params.risk_level)
+ appendQueryValue(search, 'sla_state', params.slaState ?? params.sla_state)
+ appendQueryValue(search, 'keyword', params.keyword)
+ appendQueryValue(search, 'sort', params.sort)
+ appendQueryValue(search, 'assignee', params.assignee)
+ if (typeof (params.batchEligible ?? params.batch_eligible) === 'boolean') {
+ search.set(
+ 'batch_eligible',
+ String(Boolean(params.batchEligible ?? params.batch_eligible))
+ )
+ }
+ return search.toString()
+}
+
+function requestOptions(options, timeoutMessage) {
+ return {
+ ...options,
+ timeoutMs: toNumber(options?.timeoutMs, DEFAULT_TIMEOUT_MS),
+ timeoutMessage
+ }
+}
+
+function requireTaskId(taskId) {
+ const normalized = toText(taskId)
+ if (!normalized) {
+ throw new Error('审批任务缺少任务标识。')
+ }
+ return normalized
+}
+
+function buildBaseActionPayload(action, taskId, payload = {}) {
+ const expectedTaskVersion = toInteger(
+ payload.expectedTaskVersion ?? payload.expected_task_version
+ )
+ if (expectedTaskVersion < 1) {
+ throw new Error('审批动作缺少有效的任务版本。')
+ }
+ const reason = toText(payload.reason)
+ if (reason.length < 2) {
+ throw new Error('审批动作需要填写至少 2 个字的原因。')
+ }
+ return {
+ request_id: toText(payload.requestId ?? payload.request_id)
+ || createApprovalTaskRequestId(action, taskId),
+ expected_task_version: expectedTaskVersion,
+ reason
+ }
+}
+
+function buildAssignmentActionPayload(action, taskId, payload) {
+ const targetEmployeeId = toText(payload.targetEmployeeId ?? payload.target_employee_id)
+ if (!targetEmployeeId) {
+ throw new Error('审批任务需要选择目标员工。')
+ }
+ const body = {
+ ...buildBaseActionPayload(action, taskId, payload),
+ target_employee_id: targetEmployeeId
+ }
+ const expiresAt = toText(payload.expiresAt ?? payload.expires_at)
+ if (expiresAt) {
+ body.expires_at = expiresAt
+ }
+ return body
+}
+
+function buildParticipantsActionPayload(action, taskId, payload) {
+ const participantEmployeeIds = uniqueTexts(
+ payload.participantEmployeeIds ?? payload.participant_employee_ids
+ )
+ if (!participantEmployeeIds.length) {
+ throw new Error('审批任务需要至少选择一名参与人。')
+ }
+ return {
+ ...buildBaseActionPayload(action, taskId, payload),
+ participant_employee_ids: participantEmployeeIds
+ }
+}
+
+async function mutateApprovalTask(taskId, path, payload, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ const response = await apiRequest(
+ `${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/${path}`,
+ {
+ ...requestOptions(options, '审批任务操作超时,请刷新任务状态后重试。'),
+ method: 'POST',
+ body: JSON.stringify(payload)
+ }
+ )
+ return normalizeApprovalTaskMutation(response)
+}
+
+export async function fetchApprovalTasks(params = {}, options = {}) {
+ const payload = await apiRequest(
+ `${APPROVAL_TASKS_PATH}?${buildApprovalTaskListQuery(params)}`,
+ requestOptions(options, '审批任务列表加载超时,请稍后重试。')
+ )
+ return normalizeApprovalTaskList(payload)
+}
+
+export async function fetchApprovalTaskDetail(taskId, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ const payload = await apiRequest(
+ `${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}`,
+ requestOptions(options, '审批任务详情加载超时,请稍后重试。')
+ )
+ return normalizeApprovalTaskQueueItem(payload)
+}
+
+export async function fetchApprovalTaskCandidates(taskId, params = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ const search = new URLSearchParams()
+ appendQueryValue(search, 'action', params.action)
+ appendQueryValue(search, 'keyword', params.keyword)
+ search.set('limit', String(toPositiveInteger(params.limit, 20, 100)))
+ const payload = await apiRequest(
+ `${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/candidates?${search}`,
+ requestOptions(options, '审批候选人加载超时,请稍后重试。')
+ )
+ return normalizeApprovalTaskCandidateList(payload)
+}
+
+export function delegateApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ return mutateApprovalTask(
+ normalizedTaskId,
+ 'delegate',
+ buildAssignmentActionPayload('delegate', normalizedTaskId, payload),
+ options
+ )
+}
+
+export function revokeApprovalTaskDelegation(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ return mutateApprovalTask(
+ normalizedTaskId,
+ 'delegation/revoke',
+ buildBaseActionPayload('delegation-revoke', normalizedTaskId, payload),
+ options
+ )
+}
+
+export function transferApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ return mutateApprovalTask(
+ normalizedTaskId,
+ 'transfer',
+ buildAssignmentActionPayload('transfer', normalizedTaskId, payload),
+ options
+ )
+}
+
+export function addSignApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ return mutateApprovalTask(
+ normalizedTaskId,
+ 'add-sign',
+ buildParticipantsActionPayload('add-sign', normalizedTaskId, payload),
+ options
+ )
+}
+
+export function countersignApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ return mutateApprovalTask(
+ normalizedTaskId,
+ 'countersign',
+ buildParticipantsActionPayload('countersign', normalizedTaskId, payload),
+ options
+ )
+}
+
+export function escalateApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ return mutateApprovalTask(
+ normalizedTaskId,
+ 'escalate',
+ buildBaseActionPayload('sla-escalate', normalizedTaskId, payload),
+ options
+ )
+}
+
+export function approveApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ const body = buildBaseActionPayload('approve', normalizedTaskId, payload)
+ const opinion = toText(payload.opinion)
+ if (opinion) {
+ body.opinion = opinion
+ }
+ return mutateApprovalTask(normalizedTaskId, 'approve', body, options)
+}
+
+export function returnApprovalTask(taskId, payload = {}, options = {}) {
+ const normalizedTaskId = requireTaskId(taskId)
+ const body = {
+ ...buildBaseActionPayload('return', normalizedTaskId, payload),
+ reason_codes: uniqueTexts(payload.reasonCodes ?? payload.reason_codes)
+ }
+ return mutateApprovalTask(normalizedTaskId, 'return', body, options)
+}
+
+function normalizeBatchApproveItem(item = {}) {
+ const source = toObject(item?.task || item)
+ const taskId = toText(item.task_id ?? item.taskId ?? source.id)
+ const expectedTaskVersion = toInteger(
+ item.expected_task_version ?? item.expectedTaskVersion ?? source.version
+ )
+ const expectedStatus = toText(
+ item.expected_status ?? item.expectedStatus
+ ?? source.claim_status_snapshot ?? source.claimStatusSnapshot
+ )
+ const expectedApprovalStage = toText(
+ item.expected_approval_stage ?? item.expectedApprovalStage
+ ?? source.claim_stage_snapshot ?? source.claimStageSnapshot
+ )
+ if (!taskId || expectedTaskVersion < 1 || !expectedStatus || !expectedApprovalStage) {
+ throw new Error('批量审批项缺少任务、版本、单据状态或审批节点快照。')
+ }
+ const body = {
+ task_id: taskId,
+ expected_task_version: expectedTaskVersion,
+ expected_status: expectedStatus,
+ expected_approval_stage: expectedApprovalStage
+ }
+ const opinion = toText(item.opinion)
+ if (opinion) {
+ body.opinion = opinion
+ }
+ return body
+}
+
+export async function batchApproveApprovalTasks(payload = {}, options = {}) {
+ const items = toArray(payload.items).map(normalizeBatchApproveItem)
+ if (!items.length || items.length > 20) {
+ throw new Error('批量审批每次需要选择 1 至 20 项任务。')
+ }
+ if (new Set(items.map((item) => item.task_id)).size !== items.length) {
+ throw new Error('批量审批不能包含重复任务。')
+ }
+ const batchRequestId = toText(payload.batchRequestId ?? payload.batch_request_id)
+ || createApprovalTaskRequestId('batch-approve', 'batch').slice(0, 80)
+ const response = await apiRequest(`${APPROVAL_TASKS_PATH}/batch-approve`, {
+ ...requestOptions(options, '批量审批超时,请刷新任务状态后查看实际结果。'),
+ method: 'POST',
+ body: JSON.stringify({
+ batch_request_id: batchRequestId,
+ items
+ })
+ })
+ return normalizeApprovalTaskBatchResult(response)
+}
diff --git a/web/src/services/riskObservations.js b/web/src/services/riskObservations.js
index 062a5a7..38be72e 100644
--- a/web/src/services/riskObservations.js
+++ b/web/src/services/riskObservations.js
@@ -13,6 +13,14 @@ function toObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
+function uniqueTexts(value, limit = Number.POSITIVE_INFINITY) {
+ return [...new Set(
+ toArray(value)
+ .map((item) => String(item || '').trim())
+ .filter(Boolean)
+ )].slice(0, limit)
+}
+
export function createRiskDispositionRequestId(action, observationId) {
const normalizedAction = String(action || 'action').trim() || 'action'
const normalizedObservationId = String(observationId || 'risk').trim() || 'risk'
@@ -35,14 +43,48 @@ export function normalizeRiskDisposition(item = {}) {
assignee: String(item.assignee || '').trim(),
dueAt: String(item.due_at || item.dueAt || '').trim(),
resolution: String(item.resolution || '').trim(),
+ availableActions: uniqueTexts(item.available_actions ?? item.availableActions),
+ readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(),
+ waiverRequesterId: String(
+ item.waiver_requester_id || item.waiverRequesterId || ''
+ ).trim(),
+ waiverRequesterName: String(
+ item.waiver_requester_name || item.waiverRequesterName || ''
+ ).trim(),
+ waiverRequestedAt: String(
+ item.waiver_requested_at || item.waiverRequestedAt || ''
+ ).trim(),
+ waiverReason: String(item.waiver_reason || item.waiverReason || '').trim(),
+ waiverScope: String(item.waiver_scope || item.waiverScope || '').trim(),
+ waiverExpiresAt: String(
+ item.waiver_expires_at || item.waiverExpiresAt || ''
+ ).trim(),
+ waiverConditions: uniqueTexts(
+ item.waiver_conditions_json
+ ?? item.waiver_conditions
+ ?? item.waiverConditions
+ ),
+ waiverDecision: String(item.waiver_decision || item.waiverDecision || '').trim(),
+ waiverDeciderId: String(item.waiver_decider_id || item.waiverDeciderId || '').trim(),
+ waiverDeciderName: String(
+ item.waiver_decider_name || item.waiverDeciderName || ''
+ ).trim(),
+ waiverDecidedAt: String(item.waiver_decided_at || item.waiverDecidedAt || '').trim(),
+ waiverDecisionReason: String(
+ item.waiver_decision_reason || item.waiverDecisionReason || ''
+ ).trim(),
createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim(),
events: toArray(item.events).map((event) => ({
id: String(event?.id || '').trim(),
action: String(event?.action || '').trim(),
version: Math.max(0, toNumber(event?.version)),
+ actorId: String(event?.actor_id || event?.actorId || '').trim(),
actorName: String(event?.actor_name || event?.actorName || '').trim(),
+ requestId: String(event?.request_id || event?.requestId || '').trim(),
comment: String(event?.comment || '').trim(),
+ beforeState: toObject(event?.before_json || event?.beforeState),
+ afterState: toObject(event?.after_json || event?.afterState),
createdAt: String(event?.created_at || event?.createdAt || '').trim()
}))
}
@@ -91,6 +133,8 @@ export function normalizeRiskObservation(item = {}) {
),
feedbackItems: toArray(item.feedback_items || item.feedbackItems),
disposition: normalizeRiskDisposition(item.disposition),
+ availableActions: uniqueTexts(item.available_actions ?? item.availableActions),
+ readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(),
createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim()
}
@@ -211,10 +255,23 @@ export async function executeRiskDispositionAction(observationId, payload = {})
const resolution = String(payload.resolution || '').trim()
const assignee = String(payload.assignee || '').trim()
const dueAt = String(payload.dueAt || payload.due_at || '').trim()
+ const waiverReason = String(payload.waiverReason || payload.waiver_reason || '').trim()
+ const waiverScope = String(payload.waiverScope || payload.waiver_scope || '').trim()
+ const waiverExpiresAt = String(
+ payload.waiverExpiresAt || payload.waiver_expires_at || ''
+ ).trim()
+ const waiverConditions = uniqueTexts(
+ payload.waiverConditions ?? payload.waiver_conditions,
+ 20
+ )
if (comment) body.comment = comment
if (resolution) body.resolution = resolution
if (assignee) body.assignee = assignee
if (dueAt) body.due_at = dueAt
+ if (waiverReason) body.waiver_reason = waiverReason
+ if (waiverScope) body.waiver_scope = waiverScope
+ if (waiverExpiresAt) body.waiver_expires_at = waiverExpiresAt
+ if (waiverConditions.length) body.waiver_conditions = waiverConditions
const result = await apiRequest(
`/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`,
@@ -232,3 +289,55 @@ export async function executeRiskDispositionAction(observationId, payload = {})
requestId
}
}
+
+export function normalizeRiskDispositionError(error) {
+ const code = String(error?.code || '').trim()
+ const status = Number(error?.status || 0)
+ const serverMessage = String(error?.message || '').trim()
+ if (code === 'RISK_DISPOSITION_VERSION_CONFLICT') {
+ return {
+ code,
+ title: '风险状态已更新',
+ message: serverMessage || '其他处理人已更新该风险,请刷新后重新确认。',
+ shouldRefresh: true
+ }
+ }
+ if (code === 'RISK_WAIVER_DECISION_FORBIDDEN' || status === 403) {
+ return {
+ code: code || 'RISK_DISPOSITION_FORBIDDEN',
+ title: '当前账号不能执行此操作',
+ message: serverMessage || '服务端未授予当前风险处置动作。',
+ shouldRefresh: true
+ }
+ }
+ if (code === 'REQUEST_TIMEOUT') {
+ return {
+ code,
+ title: '提交结果暂未确认',
+ message: serverMessage || '请求已超时,请刷新状态后再决定是否重试。',
+ shouldRefresh: true
+ }
+ }
+ if (status === 409) {
+ return {
+ code: code || 'RISK_DISPOSITION_CONFLICT',
+ title: '当前状态不允许该操作',
+ message: serverMessage || '风险处置状态发生冲突,请刷新后重试。',
+ shouldRefresh: true
+ }
+ }
+ if (status === 422) {
+ return {
+ code: code || 'RISK_DISPOSITION_VALIDATION_FAILED',
+ title: '提交内容不完整',
+ message: serverMessage || '请检查必填项、有效期和输入长度。',
+ shouldRefresh: false
+ }
+ }
+ return {
+ code: code || 'RISK_DISPOSITION_REQUEST_FAILED',
+ title: '风险处置提交失败',
+ message: serverMessage || '请稍后重试;如持续失败,请联系系统管理员。',
+ shouldRefresh: false
+ }
+}
diff --git a/web/src/utils/documentCenterViewModel.js b/web/src/utils/documentCenterViewModel.js
index 7aa68c4..8a9ac6a 100644
--- a/web/src/utils/documentCenterViewModel.js
+++ b/web/src/utils/documentCenterViewModel.js
@@ -44,7 +44,12 @@ export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
'dc_scene',
'dc_q',
'dc_start',
- 'dc_end'
+ 'dc_end',
+ 'dc_review_page',
+ 'dc_review_page_size',
+ 'dc_review_q',
+ 'dc_review_risk',
+ 'dc_review_sla'
])
export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
export const RISK_TONE_META = {
diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue
index 65e3768..3a0de41 100644
--- a/web/src/views/AppShellRouteView.vue
+++ b/web/src/views/AppShellRouteView.vue
@@ -96,7 +96,6 @@
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@update:overview-dashboard="overviewDashboard = $event"
- @batch-approve="toast('已批量通过 23 条审批任务。')"
@new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument"
@navigate="handleNavigate"
@@ -183,6 +182,7 @@
@create-request="openTravelCreate"
@create-application="openExpenseApplicationCreate"
@reload="reloadRequests"
+ @request-updated="handleRequestUpdated"
@summary-change="documentSummary = $event"
/>
diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue
index 4b2a287..a7fe0b6 100644
--- a/web/src/views/DocumentsCenterView.vue
+++ b/web/src/views/DocumentsCenterView.vue
@@ -11,14 +11,27 @@
>
{{ tab.label }}
-
+
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
-