feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

View File

@@ -0,0 +1,409 @@
<template>
<ConfirmDialog
:open="open"
:badge="modeMeta.badge"
:title="modeMeta.title"
:description="modeMeta.description"
cancel-text="取消"
:confirm-text="modeMeta.confirmText"
busy-text="提交中..."
:confirm-disabled="confirmDisabled"
:busy="busy"
@close="closeDialog"
@confirm="submitAction"
>
<div class="assignment-form">
<p v-if="permissionReason" class="assignment-error" role="alert">
{{ permissionReason }}
</p>
<template v-if="requiresCandidate">
<label class="candidate-search">
<span>候选审批人</span>
<div>
<input
v-model="candidateKeyword"
type="search"
placeholder="输入姓名、工号或邮箱"
:disabled="busy"
@keydown.enter.prevent="loadCandidates"
/>
<button type="button" :disabled="candidateLoading || busy" @click="loadCandidates">
<i :class="candidateLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-magnify'"></i>
查询
</button>
</div>
</label>
<div class="candidate-list" role="radiogroup" aria-label="审批候选人">
<p v-if="candidateLoading">正在加载候选人...</p>
<p v-else-if="candidateError" class="assignment-error">{{ candidateError }}</p>
<p v-else-if="!candidates.length">暂无符合条件的候选人</p>
<label
v-for="candidate in candidates"
v-else
:key="candidate.employeeId"
:class="{ disabled: !candidate.qualified }"
>
<input
v-model="targetEmployeeId"
type="radio"
:value="candidate.employeeId"
:disabled="!candidate.qualified || busy"
/>
<span>
<strong>{{ candidate.name || candidate.employeeNo }}</strong>
<small>{{ candidate.employeeNo }} · {{ candidate.email }}</small>
<em v-if="!candidate.qualified">{{ candidate.reason || '不符合当前动作资格' }}</em>
</span>
</label>
</div>
</template>
<label v-if="mode === 'delegate'" class="assignment-field">
<span>委托失效时间可选</span>
<input v-model="expiresAt" type="datetime-local" :disabled="busy" />
<small>到期后的恢复行为由服务端审批任务策略决定</small>
</label>
<label class="assignment-field">
<span>{{ modeMeta.reasonLabel }} <em>必填</em></span>
<textarea
v-model="reason"
maxlength="500"
:disabled="busy"
:placeholder="modeMeta.reasonPlaceholder"
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<p v-if="submitError" class="assignment-error" role="alert">{{ submitError }}</p>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
delegateApprovalTask,
fetchApprovalTaskCandidates,
revokeApprovalTaskDelegation,
transferApprovalTask
} from '../../services/approvalTasks.js'
import { resolveApprovalTaskRetryKey } from '../../views/scripts/approvalTaskRetry.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
const MODE_METADATA = {
delegate: {
badge: '临时委托',
title: '委托当前审批任务',
description: '委托只改变当前任务的实际处理人,原任务责任人与到期策略以服务端记录为准。',
confirmText: '确认委托',
reasonLabel: '委托原因',
reasonPlaceholder: '说明委托原因和需要候选人关注的事项。'
},
transfer: {
badge: '审批转交',
title: '转交当前审批任务',
description: '转交会永久改变当前任务处理人,请确认候选人具备服务端返回的处理资格。',
confirmText: '确认转交',
reasonLabel: '转交原因',
reasonPlaceholder: '说明转交原因和后续责任边界。'
},
delegation_revoke: {
badge: '撤销委托',
title: '撤销当前任务委托',
description: '服务端会按最新任务版本复核是否仍可撤销,并记录完整审计事件。',
confirmText: '确认撤销',
reasonLabel: '撤销原因',
reasonPlaceholder: '说明撤销委托的原因。'
}
}
const props = defineProps({
open: { type: Boolean, default: false },
task: { type: Object, default: () => ({}) },
mode: {
type: String,
default: 'delegate',
validator: (value) => ['delegate', 'transfer', 'delegation_revoke'].includes(value)
},
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'completed'])
const candidateKeyword = ref('')
const candidates = ref([])
const candidateLoading = ref(false)
const candidateError = ref('')
const targetEmployeeId = ref('')
const expiresAt = ref('')
const reason = ref('')
const busy = ref(false)
const submitError = ref('')
const requestId = ref('')
const requestFingerprint = ref('')
let candidateRequestSequence = 0
const normalizedTask = computed(() => props.task?.task || props.task || {})
const taskId = computed(() => String(normalizedTask.value.id || '').trim())
const availableActions = computed(() => new Set(
Array.isArray(normalizedTask.value.availableActions)
? normalizedTask.value.availableActions
: Array.isArray(normalizedTask.value.available_actions)
? normalizedTask.value.available_actions
: []
))
const modeMeta = computed(() => MODE_METADATA[props.mode] || MODE_METADATA.delegate)
const requiresCandidate = computed(() => ['delegate', 'transfer'].includes(props.mode))
const canAct = computed(() =>
Boolean(taskId.value)
&& Number(normalizedTask.value.version) >= 1
&& availableActions.value.has(props.mode)
)
const permissionReason = computed(() => {
if (canAct.value) return ''
return String(
normalizedTask.value.readOnlyReason
?? normalizedTask.value.read_only_reason
?? '服务端未授权当前审批动作。'
).trim()
})
const selectedCandidate = computed(() =>
candidates.value.find((candidate) => candidate.employeeId === targetEmployeeId.value) || null
)
const confirmDisabled = computed(() =>
!canAct.value
|| reason.value.trim().length < 2
|| (requiresCandidate.value && !selectedCandidate.value?.qualified)
)
watch(
[() => props.open, taskId, () => props.mode],
([open]) => {
if (!open) return
resetForm()
if (requiresCandidate.value && canAct.value) {
void loadCandidates()
}
}
)
function resetForm() {
candidateRequestSequence += 1
candidateKeyword.value = ''
candidates.value = []
candidateError.value = ''
targetEmployeeId.value = ''
expiresAt.value = ''
reason.value = ''
submitError.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
async function loadCandidates() {
if (!taskId.value || !requiresCandidate.value || !canAct.value) return
const sequence = ++candidateRequestSequence
candidateLoading.value = true
candidateError.value = ''
try {
const service = props.serviceOverrides.fetchApprovalTaskCandidates
|| fetchApprovalTaskCandidates
const result = await service(taskId.value, {
action: props.mode,
keyword: candidateKeyword.value,
limit: 50
})
if (sequence !== candidateRequestSequence) return
candidates.value = Array.isArray(result?.items) ? result.items : []
if (!candidates.value.some((candidate) => candidate.employeeId === targetEmployeeId.value)) {
targetEmployeeId.value = ''
}
} catch (error) {
if (sequence === candidateRequestSequence) {
candidates.value = []
candidateError.value = error?.message || '审批候选人加载失败。'
}
} finally {
if (sequence === candidateRequestSequence) {
candidateLoading.value = false
}
}
}
function resolveExpiresAt() {
if (!expiresAt.value) return ''
const date = new Date(expiresAt.value)
if (Number.isNaN(date.getTime())) {
throw new Error('委托失效时间格式无效。')
}
return date.toISOString()
}
async function submitAction() {
if (confirmDisabled.value || busy.value) return
const serviceMap = {
delegate: props.serviceOverrides.delegateApprovalTask || delegateApprovalTask,
transfer: props.serviceOverrides.transferApprovalTask || transferApprovalTask,
delegation_revoke: props.serviceOverrides.revokeApprovalTaskDelegation
|| revokeApprovalTaskDelegation
}
busy.value = true
submitError.value = ''
try {
const payload = {
expectedTaskVersion: Number(normalizedTask.value.version),
reason: reason.value.trim()
}
if (requiresCandidate.value) {
payload.targetEmployeeId = targetEmployeeId.value
}
if (props.mode === 'delegate' && expiresAt.value) {
payload.expiresAt = resolveExpiresAt()
}
const retryKey = resolveApprovalTaskRetryKey({
action: props.mode,
scopeId: taskId.value || 'task',
payload,
previousFingerprint: requestFingerprint.value,
previousRequestId: requestId.value
})
requestId.value = retryKey.requestId
requestFingerprint.value = retryKey.fingerprint
payload.requestId = retryKey.requestId
const mutation = await serviceMap[props.mode](taskId.value, payload)
emit('completed', { mode: props.mode, mutation })
emit('close')
} catch (error) {
submitError.value = error?.message || '审批任务分配操作失败。'
} finally {
busy.value = false
}
}
function closeDialog() {
if (!busy.value) emit('close')
}
</script>
<style scoped>
.assignment-form,
.assignment-field,
.candidate-search,
.candidate-list {
display: grid;
gap: 8px;
}
.candidate-search > span,
.assignment-field > span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.candidate-search > div {
display: flex;
gap: 8px;
}
.candidate-search input,
.assignment-field input,
.assignment-field textarea {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
outline: none;
}
.candidate-search button {
flex: 0 0 auto;
padding: 0 13px;
border: 1px solid var(--theme-primary);
border-radius: 6px;
background: #fff;
color: var(--theme-primary-active);
font-weight: 800;
}
.candidate-list {
max-height: 210px;
overflow-y: auto;
}
.candidate-list > p {
margin: 0;
padding: 12px;
border-radius: 6px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.candidate-list label {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 9px;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.candidate-list label.disabled {
background: #f8fafc;
opacity: 0.7;
}
.candidate-list label span,
.candidate-list label strong,
.candidate-list label small,
.candidate-list label em {
display: block;
}
.candidate-list label strong {
color: #0f172a;
font-size: 13px;
}
.candidate-list label small {
margin-top: 2px;
color: #64748b;
}
.candidate-list label em {
margin-top: 3px;
color: #b45309;
font-size: 11px;
font-style: normal;
}
.assignment-field textarea {
min-height: 82px;
resize: vertical;
}
.assignment-field small {
color: #64748b;
font-size: 11px;
}
.assignment-field em {
color: #b91c1c;
font-style: normal;
}
.assignment-error {
margin: 0;
padding: 9px 10px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<aside v-if="result" class="approval-batch-result" aria-live="polite">
<header>
<div>
<span>批量审批结果</span>
<strong>{{ statusLabel }}</strong>
</div>
<button type="button" aria-label="关闭批量审批结果" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="approval-batch-summary">
<span class="success">成功 {{ successCount }}</span>
<span v-if="result.blockedCount" class="warning">风险阻断 {{ result.blockedCount }}</span>
<span v-if="result.conflictCount" class="warning">状态冲突 {{ result.conflictCount }}</span>
<span v-if="result.forbiddenCount" class="danger">无权限 {{ result.forbiddenCount }}</span>
<span v-if="result.failedCount" class="danger">失败 {{ result.failedCount }}</span>
</div>
<ul v-if="result.items?.length">
<li v-for="item in result.items" :key="`${item.taskId}:${item.status}`">
<em :class="item.status">{{ itemStatusLabel(item.status) }}</em>
<div>
<strong>{{ item.claimId || item.taskId }}</strong>
<span>{{ item.message || item.code || '服务器未返回详细说明。' }}</span>
</div>
</li>
</ul>
<footer v-if="retryableCount">
<button type="button" @click="emit('retry-failed')">
重新处理保留的 {{ retryableCount }}
</button>
</footer>
</aside>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
result: { type: Object, default: null },
retryableCount: { type: Number, default: 0 }
})
const emit = defineEmits(['close', 'retry-failed'])
const successCount = computed(() =>
Number(props.result?.succeededCount || 0) + Number(props.result?.replayedCount || 0)
)
const statusLabel = computed(() => {
if (props.result?.status === 'succeeded') return '全部处理成功'
if (props.result?.status === 'partial') return '部分任务未完成'
return '批量处理失败'
})
function itemStatusLabel(status) {
return {
succeeded: '成功',
replayed: '已处理',
conflict: '冲突',
blocked: '阻断',
forbidden: '无权限',
failed: '失败'
}[String(status || '')] || '未知'
}
</script>
<style scoped>
.approval-batch-result {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid #dbe5ef;
border-radius: 8px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
}
.approval-batch-result header,
.approval-batch-result header > div,
.approval-batch-result li,
.approval-batch-summary {
display: flex;
align-items: center;
}
.approval-batch-result header {
justify-content: space-between;
gap: 12px;
}
.approval-batch-result header > div {
gap: 10px;
}
.approval-batch-result header span,
.approval-batch-result li span {
color: #64748b;
font-size: 12px;
}
.approval-batch-result header strong,
.approval-batch-result li strong {
color: #0f172a;
font-size: 13px;
}
.approval-batch-result header button {
width: 32px;
height: 32px;
border: 0;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
}
.approval-batch-summary {
flex-wrap: wrap;
gap: 8px;
}
.approval-batch-summary span,
.approval-batch-result li em {
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.success,
.approval-batch-result li em.succeeded,
.approval-batch-result li em.replayed {
background: #ecfdf5;
color: #047857;
}
.warning,
.approval-batch-result li em.blocked,
.approval-batch-result li em.conflict {
background: #fffbeb;
color: #b45309;
}
.danger,
.approval-batch-result li em.forbidden,
.approval-batch-result li em.failed {
background: #fef2f2;
color: #b91c1c;
}
.approval-batch-result ul {
display: grid;
gap: 7px;
max-height: 260px;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.approval-batch-result li {
align-items: flex-start;
gap: 9px;
padding: 9px;
border: 1px solid #edf2f7;
border-radius: 6px;
background: #f8fafc;
}
.approval-batch-result li div {
display: grid;
gap: 3px;
}
.approval-batch-result footer {
display: flex;
justify-content: flex-end;
}
.approval-batch-result footer button {
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--theme-primary);
border-radius: 6px;
background: #fff;
color: var(--theme-primary-active);
font-weight: 800;
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<ConfirmDialog
:open="open"
:badge="modeMeta.badge"
:title="modeMeta.title"
:description="modeMeta.description"
cancel-text="取消"
:confirm-text="modeMeta.confirmText"
busy-text="提交中..."
:confirm-disabled="confirmDisabled"
:busy="busy"
@close="closeDialog"
@confirm="submitAction"
>
<div class="participants-form">
<p v-if="permissionReason" class="participants-error" role="alert">
{{ permissionReason }}
</p>
<label class="participants-search">
<span>参与人</span>
<div>
<input
v-model="candidateKeyword"
type="search"
placeholder="输入姓名、工号或邮箱"
:disabled="busy"
@keydown.enter.prevent="loadCandidates"
/>
<button type="button" :disabled="candidateLoading || busy" @click="loadCandidates">
<i :class="candidateLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-magnify'"></i>
查询
</button>
</div>
</label>
<div class="participants-candidates" aria-label="可选审批参与人">
<p v-if="candidateLoading">正在加载候选人...</p>
<p v-else-if="candidateError" class="participants-error">{{ candidateError }}</p>
<p v-else-if="!candidates.length">暂无符合条件的候选人</p>
<label
v-for="candidate in candidates"
v-else
:key="candidate.employeeId"
:class="{ disabled: !candidate.qualified }"
>
<input
type="checkbox"
:checked="selectedParticipantIds.includes(candidate.employeeId)"
:disabled="!candidate.qualified || busy"
@change="toggleParticipant(candidate, $event.target.checked)"
/>
<span>
<strong>{{ candidate.name || candidate.employeeNo }}</strong>
<small>{{ candidate.employeeNo }} · {{ candidate.email }}</small>
<em v-if="!candidate.qualified">{{ candidate.reason || '不符合当前动作资格' }}</em>
</span>
</label>
</div>
<section v-if="selectedParticipants.length" class="selected-participants">
<header>
<strong>{{ mode === 'add_sign' ? '顺序加签队列' : '并行会签成员' }}</strong>
<span>{{ selectedParticipants.length }}/10</span>
</header>
<ol>
<li v-for="(candidate, index) in selectedParticipants" :key="candidate.employeeId">
<span>{{ mode === 'add_sign' ? index + 1 : '•' }}</span>
<strong>{{ candidate.name || candidate.employeeNo }}</strong>
<div>
<button
v-if="mode === 'add_sign'"
type="button"
aria-label="上移参与人"
:disabled="index === 0 || busy"
@click="moveParticipant(index, -1)"
></button>
<button
v-if="mode === 'add_sign'"
type="button"
aria-label="下移参与人"
:disabled="index === selectedParticipants.length - 1 || busy"
@click="moveParticipant(index, 1)"
></button>
<button type="button" :disabled="busy" @click="removeParticipant(candidate.employeeId)">
移除
</button>
</div>
</li>
</ol>
</section>
<label class="participants-reason">
<span>{{ modeMeta.reasonLabel }} <em>必填</em></span>
<textarea
v-model="reason"
maxlength="500"
:disabled="busy"
:placeholder="modeMeta.reasonPlaceholder"
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<p v-if="submitError" class="participants-error" role="alert">{{ submitError }}</p>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
addSignApprovalTask,
countersignApprovalTask,
fetchApprovalTaskCandidates
} from '../../services/approvalTasks.js'
import { resolveApprovalTaskRetryKey } from '../../views/scripts/approvalTaskRetry.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
const MODE_METADATA = {
add_sign: {
badge: '顺序加签',
title: '添加顺序审批参与人',
description: '参与人将按所列顺序处理,全部完成后再回到原审批任务;最终规则以服务端任务编排为准。',
confirmText: '确认加签',
reasonLabel: '加签原因',
reasonPlaceholder: '说明为什么需要这些参与人依次核对。'
},
countersign: {
badge: '并行会签',
title: '添加并行会签成员',
description: '成员将并行收到审批任务,通过和退回规则由服务端会签策略统一执行。',
confirmText: '确认会签',
reasonLabel: '会签原因',
reasonPlaceholder: '说明为什么需要多位参与人共同核对。'
}
}
const props = defineProps({
open: { type: Boolean, default: false },
task: { type: Object, default: () => ({}) },
mode: {
type: String,
default: 'add_sign',
validator: (value) => ['add_sign', 'countersign'].includes(value)
},
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'completed'])
const candidateKeyword = ref('')
const candidates = ref([])
const candidateLoading = ref(false)
const candidateError = ref('')
const selectedParticipantIds = ref([])
const reason = ref('')
const busy = ref(false)
const submitError = ref('')
const requestId = ref('')
const requestFingerprint = ref('')
let candidateRequestSequence = 0
const normalizedTask = computed(() => props.task?.task || props.task || {})
const taskId = computed(() => String(normalizedTask.value.id || '').trim())
const availableActions = computed(() => new Set(
Array.isArray(normalizedTask.value.availableActions)
? normalizedTask.value.availableActions
: Array.isArray(normalizedTask.value.available_actions)
? normalizedTask.value.available_actions
: []
))
const modeMeta = computed(() => MODE_METADATA[props.mode] || MODE_METADATA.add_sign)
const canAct = computed(() =>
Boolean(taskId.value)
&& Number(normalizedTask.value.version) >= 1
&& availableActions.value.has(props.mode)
)
const permissionReason = computed(() => {
if (canAct.value) return ''
return String(
normalizedTask.value.readOnlyReason
?? normalizedTask.value.read_only_reason
?? '服务端未授权当前审批动作。'
).trim()
})
const candidateIndex = computed(() => new Map(
candidates.value.map((candidate) => [candidate.employeeId, candidate])
))
const selectedParticipants = computed(() =>
selectedParticipantIds.value
.map((employeeId) => candidateIndex.value.get(employeeId))
.filter(Boolean)
)
const confirmDisabled = computed(() =>
!canAct.value
|| !selectedParticipantIds.value.length
|| selectedParticipantIds.value.length > 10
|| reason.value.trim().length < 2
)
watch(
[() => props.open, taskId, () => props.mode],
([open]) => {
if (!open) return
resetForm()
if (canAct.value) void loadCandidates()
}
)
function resetForm() {
candidateRequestSequence += 1
candidateKeyword.value = ''
candidates.value = []
candidateError.value = ''
selectedParticipantIds.value = []
reason.value = ''
submitError.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
async function loadCandidates() {
if (!taskId.value || !canAct.value) return
const sequence = ++candidateRequestSequence
candidateLoading.value = true
candidateError.value = ''
try {
const service = props.serviceOverrides.fetchApprovalTaskCandidates
|| fetchApprovalTaskCandidates
const result = await service(taskId.value, {
action: props.mode,
keyword: candidateKeyword.value,
limit: 50
})
if (sequence !== candidateRequestSequence) return
const nextCandidates = Array.isArray(result?.items) ? result.items : []
const selectedCandidateMap = new Map(
selectedParticipants.value.map((candidate) => [candidate.employeeId, candidate])
)
nextCandidates.forEach((candidate) => selectedCandidateMap.set(candidate.employeeId, candidate))
candidates.value = [...selectedCandidateMap.values()]
} catch (error) {
if (sequence === candidateRequestSequence) {
candidateError.value = error?.message || '审批候选人加载失败。'
}
} finally {
if (sequence === candidateRequestSequence) {
candidateLoading.value = false
}
}
}
function toggleParticipant(candidate, selected) {
if (!candidate?.qualified) return
const next = [...selectedParticipantIds.value]
const index = next.indexOf(candidate.employeeId)
if (selected && index < 0) {
if (next.length >= 10) {
submitError.value = '加签或会签参与人最多 10 名。'
return
}
next.push(candidate.employeeId)
} else if (!selected && index >= 0) {
next.splice(index, 1)
}
selectedParticipantIds.value = next
submitError.value = ''
}
function removeParticipant(employeeId) {
selectedParticipantIds.value = selectedParticipantIds.value.filter((id) => id !== employeeId)
}
function moveParticipant(index, offset) {
const targetIndex = index + offset
if (targetIndex < 0 || targetIndex >= selectedParticipantIds.value.length) return
const next = [...selectedParticipantIds.value]
;[next[index], next[targetIndex]] = [next[targetIndex], next[index]]
selectedParticipantIds.value = next
}
async function submitAction() {
if (confirmDisabled.value || busy.value) return
const service = props.mode === 'add_sign'
? props.serviceOverrides.addSignApprovalTask || addSignApprovalTask
: props.serviceOverrides.countersignApprovalTask || countersignApprovalTask
busy.value = true
submitError.value = ''
try {
const payload = {
expectedTaskVersion: Number(normalizedTask.value.version),
reason: reason.value.trim(),
participantEmployeeIds: [...selectedParticipantIds.value]
}
const retryKey = resolveApprovalTaskRetryKey({
action: props.mode,
scopeId: taskId.value || 'task',
payload,
previousFingerprint: requestFingerprint.value,
previousRequestId: requestId.value
})
requestId.value = retryKey.requestId
requestFingerprint.value = retryKey.fingerprint
payload.requestId = retryKey.requestId
const mutation = await service(taskId.value, payload)
emit('completed', { mode: props.mode, mutation })
emit('close')
} catch (error) {
submitError.value = error?.message || '审批参与人操作失败。'
} finally {
busy.value = false
}
}
function closeDialog() {
if (!busy.value) emit('close')
}
</script>
<style scoped>
.participants-form,
.participants-search,
.participants-candidates,
.selected-participants,
.participants-reason {
display: grid;
gap: 8px;
}
.participants-search > span,
.participants-reason > span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.participants-search > div {
display: flex;
gap: 8px;
}
.participants-search input,
.participants-reason textarea {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
outline: none;
}
.participants-search button,
.selected-participants button {
padding: 0 10px;
border: 1px solid #dbe5ef;
border-radius: 5px;
background: #fff;
color: #475569;
font-weight: 800;
}
.participants-candidates {
max-height: 190px;
overflow-y: auto;
}
.participants-candidates > p {
margin: 0;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.participants-candidates label {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.participants-candidates label.disabled {
background: #f8fafc;
opacity: 0.7;
}
.participants-candidates label strong,
.participants-candidates label small,
.participants-candidates label em {
display: block;
}
.participants-candidates label strong {
color: #0f172a;
font-size: 13px;
}
.participants-candidates label small {
margin-top: 2px;
color: #64748b;
}
.participants-candidates label em {
margin-top: 3px;
color: #b45309;
font-size: 11px;
font-style: normal;
}
.selected-participants {
padding: 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #f8fafc;
}
.selected-participants header,
.selected-participants li,
.selected-participants li div {
display: flex;
align-items: center;
}
.selected-participants header {
justify-content: space-between;
}
.selected-participants header strong,
.selected-participants header span {
color: #334155;
font-size: 12px;
}
.selected-participants ol {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.selected-participants li {
gap: 8px;
padding: 6px 8px;
border-radius: 5px;
background: #fff;
}
.selected-participants li > span {
min-width: 20px;
color: var(--theme-primary-active);
font-weight: 850;
}
.selected-participants li > strong {
flex: 1;
color: #0f172a;
font-size: 12px;
}
.selected-participants li div {
gap: 4px;
}
.selected-participants button {
min-height: 26px;
font-size: 11px;
}
.participants-reason textarea {
min-height: 80px;
resize: vertical;
}
.participants-reason small {
color: #64748b;
font-size: 11px;
}
.participants-reason em {
color: #b91c1c;
font-style: normal;
}
.participants-error {
margin: 0;
padding: 9px 10px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,696 @@
<template>
<section class="approval-task-queue" aria-label="审批任务工作台">
<header class="approval-task-toolbar">
<div class="approval-task-filters">
<label class="approval-task-search">
<i class="mdi mdi-magnify"></i>
<input
v-model="draftFilters.keyword"
type="search"
placeholder="搜索单号、申请人或部门"
@keydown.enter.prevent="applyFilters"
/>
</label>
<label>
<span>风险</span>
<select v-model="draftFilters.riskLevel" @change="applyFilters">
<option value="">全部风险</option>
<option value="critical">重大风险</option>
<option value="high">高风险</option>
<option value="medium">中风险</option>
<option value="low">低风险</option>
</select>
</label>
<label>
<span>SLA</span>
<select v-model="draftFilters.slaState" @change="applyFilters">
<option value="">全部 SLA</option>
<option value="due_soon">即将超时</option>
<option value="overdue">已超时</option>
<option value="escalated">已升级</option>
</select>
</label>
<button type="button" class="filter-apply" @click="applyFilters">查询</button>
</div>
<div class="approval-task-batch-actions">
<span v-if="queue.selectedCount.value">已选择 {{ queue.selectedCount.value }} </span>
<button
type="button"
:disabled="!queue.selectedCount.value || queue.batchBusy.value"
@click="batchConfirmOpen = true"
>
<i class="mdi mdi-check-all"></i>
批量通过
</button>
<button type="button" :disabled="queue.loading.value" @click="reload">
<i :class="queue.loading.value ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
刷新
</button>
</div>
</header>
<p v-if="queue.errorMessage.value" class="approval-task-error" role="alert">
{{ queue.errorMessage.value }}
</p>
<ApprovalBatchResultPanel
:result="queue.lastBatchResult.value"
:retryable-count="queue.selectedCount.value"
@close="queue.clearBatchResult"
@retry-failed="batchConfirmOpen = true"
/>
<div class="approval-task-table-wrap" :aria-busy="queue.loading.value">
<div v-if="queue.loading.value && !rows.length" class="approval-task-state">
<i class="mdi mdi-loading mdi-spin"></i>
正在加载审批任务
</div>
<div v-else-if="!rows.length" class="approval-task-state">
<i class="mdi mdi-clipboard-check-outline"></i>
当前筛选条件下没有审批任务
</div>
<table v-else>
<thead>
<tr>
<th class="selection-column">
<input
type="checkbox"
aria-label="选择当前页可批量审批的任务"
:checked="queue.pageSelection.value.allSelected"
:indeterminate="queue.pageSelection.value.indeterminate"
:disabled="!queue.pageSelection.value.eligibleCount"
@change="queue.toggleCurrentPage($event.target.checked)"
/>
</th>
<th>单号 / 申请人</th>
<th>金额</th>
<th>当前节点</th>
<th>优先级</th>
<th>风险 / 证据</th>
<th>SLA / 升级</th>
<th>当前处理人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="row.id"
:ref="(element) => setRowRef(row.id, element)"
:class="{
focused: focusedTaskId === row.id,
readonly: !row.actions.length
}"
:tabindex="focusedTaskId === row.id || (!focusedTaskId && index === 0) ? 0 : -1"
@click="openDetail(row.source)"
@focus="focusedTaskId = row.id"
@keydown="handleRowKeydown($event, index, row)"
>
<td class="selection-column" @click.stop>
<input
type="checkbox"
:aria-label="`选择审批任务 ${row.claimNo}`"
:checked="selectedSet.has(row.id)"
:disabled="!selectionMeta(row.source).selectable"
:title="selectionMeta(row.source).reason"
@change="queue.toggleTask(row.source, $event.target.checked)"
/>
</td>
<td>
<strong>{{ row.claimNo }}</strong>
<span>{{ row.applicant }} · {{ row.department }}</span>
</td>
<td><strong>{{ row.amountLabel }}</strong></td>
<td>
<strong>{{ row.nodeLabel }}</strong>
<span>{{ row.taskKind === 'root' ? '主任务' : row.taskKind }}</span>
</td>
<td>
<em class="priority" :class="row.priorityTier">
{{ row.priorityScore }} · {{ row.priorityTier }}
</em>
</td>
<td>
<em class="risk" :class="row.riskLevel">{{ row.riskLabel }}</em>
<span>{{ row.openRiskCount }} 项开放风险 · 证据 {{ row.evidenceLabel }}</span>
</td>
<td>
<strong class="sla" :class="row.sla.tone" :title="row.sla.title">
{{ row.sla.label }}
</strong>
<span v-if="row.sla.escalationLabel">
已升级 {{ row.sla.escalationLabel }}
</span>
<span v-else>尚未升级</span>
</td>
<td>
<strong>{{ row.assigneeName }}</strong>
<span v-if="row.readOnlyReason" :title="row.readOnlyReason">
{{ row.readOnlyReason }}
</span>
</td>
<td @click.stop>
<div v-if="row.actions.length" class="row-actions">
<button
v-for="action in row.actions"
:key="action.action"
type="button"
:class="action.tone"
@click="emitAction(action.action, row.source)"
>
<i :class="action.icon"></i>
{{ action.label }}
</button>
</div>
<span v-else class="readonly-label" :title="row.readOnlyReason">
只读
</span>
</td>
</tr>
</tbody>
</table>
</div>
<EnterprisePagination
v-if="rows.length || queue.total.value"
:current-page="queue.page.value"
:page-size="queue.pageSize.value"
:page-size-options="pageSizeOptions"
:total="queue.total.value"
:total-pages="Math.max(1, queue.totalPages.value)"
:summary="pageSummary"
@update:current-page="changePage"
@page-size-change="changePageSize"
/>
<ConfirmDialog
:open="batchConfirmOpen"
badge="批量审批"
:title="`确认通过所选 ${queue.selectedCount.value} 项审批任务吗?`"
description="服务器会逐项复核权限、任务版本、单据状态和风险前置条件;部分失败不会被显示为全部成功。"
cancel-text="返回核对"
confirm-text="确认批量通过"
busy-text="逐项审批中..."
confirm-icon="mdi mdi-check-all"
:busy="queue.batchBusy.value"
:confirm-disabled="!queue.selectedCount.value"
@close="closeBatchConfirm"
@confirm="confirmBatchApprove"
>
<label class="batch-opinion">
<span>统一审批意见可选</span>
<textarea
v-model="batchOpinion"
maxlength="500"
:disabled="queue.batchBusy.value"
placeholder="不填写则由服务端使用审批任务默认意见"
></textarea>
</label>
</ConfirmDialog>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useApprovalTaskQueue } from '../../composables/useApprovalTaskQueue.js'
import {
resolveApprovalTaskKeyboardCommand,
resolveApprovalTaskRow
} from '../../views/scripts/approvalTaskQueueViewModel.js'
import { resolveApprovalTaskSelectionMeta } from '../../views/scripts/approvalTaskQueueState.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import EnterprisePagination from '../shared/EnterprisePagination.vue'
import ApprovalBatchResultPanel from './ApprovalBatchResultPanel.vue'
const props = defineProps({
initialFilters: { type: Object, default: () => ({}) },
initialPage: { type: Number, default: 1 },
initialPageSize: { type: Number, default: 20 },
refreshToken: { type: [String, Number], default: 0 },
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits([
'open-detail',
'action',
'batch-result',
'selection-change',
'loaded',
'state-change',
'error'
])
const queue = useApprovalTaskQueue({
initialFilters: props.initialFilters,
initialPage: props.initialPage,
initialPageSize: props.initialPageSize,
services: props.serviceOverrides
})
const draftFilters = reactive({
keyword: String(props.initialFilters.keyword || ''),
riskLevel: String(props.initialFilters.riskLevel || props.initialFilters.risk_level || ''),
slaState: String(props.initialFilters.slaState || props.initialFilters.sla_state || '')
})
const focusedTaskId = ref('')
const batchConfirmOpen = ref(false)
const batchOpinion = ref('')
const clock = ref(Date.now())
const rowRefs = new Map()
const pageSizeOptions = [10, 20].map((value) => ({ label: `${value} 条/页`, value }))
const rows = computed(() =>
queue.items.value.map((item) => resolveApprovalTaskRow(item, clock.value))
)
const selectedSet = computed(() => new Set(queue.selectedTaskIds.value))
const pageSummary = computed(() =>
`${queue.total.value} 条,当前第 ${queue.page.value} / ${Math.max(1, queue.totalPages.value)}`
)
watch(
() => queue.selectedTaskIds.value,
(selectedTaskIds) => emit('selection-change', [...selectedTaskIds]),
{ deep: true }
)
watch(
() => props.refreshToken,
() => void reload()
)
watch(
() => rows.value.map((row) => row.id).join('|'),
() => {
if (!rows.value.some((row) => row.id === focusedTaskId.value)) {
focusedTaskId.value = rows.value[0]?.id || ''
}
}
)
let clockTimer = 0
onMounted(() => {
clockTimer = window.setInterval(() => {
clock.value = Date.now()
}, 60000)
void reload()
})
onBeforeUnmount(() => {
if (clockTimer) window.clearInterval(clockTimer)
})
async function reload() {
try {
const response = await queue.loadQueue()
publishQueueResponse(response)
} catch (error) {
emit('error', error)
}
}
async function applyFilters() {
try {
const response = await queue.setFilters({ ...draftFilters })
publishQueueResponse(response)
} catch (error) {
emit('error', error)
}
}
async function changePage(page) {
try {
publishQueueResponse(await queue.setPage(page))
focusFirstRow()
} catch (error) {
emit('error', error)
}
}
async function changePageSize(pageSize) {
try {
publishQueueResponse(await queue.setPageSize(pageSize))
focusFirstRow()
} catch (error) {
emit('error', error)
}
}
function publishQueueResponse(response) {
if (!response || response.ignored) return
emit('loaded', response.result)
emit('state-change', {
filters: { ...queue.filters.value },
page: queue.page.value,
pageSize: queue.pageSize.value
})
}
function selectionMeta(item) {
return resolveApprovalTaskSelectionMeta(item)
}
function setRowRef(taskId, element) {
if (element) {
rowRefs.set(taskId, element)
} else {
rowRefs.delete(taskId)
}
}
function focusRow(index) {
if (!rows.value.length) return
const normalizedIndex = Math.min(Math.max(index, 0), rows.value.length - 1)
const row = rows.value[normalizedIndex]
focusedTaskId.value = row.id
nextTick(() => rowRefs.get(row.id)?.focus())
}
function focusFirstRow() {
nextTick(() => focusRow(0))
}
function handleRowKeydown(event, index, row) {
const command = resolveApprovalTaskKeyboardCommand(event)
if (!command) return
event.preventDefault()
if (command === 'open') {
openDetail(row.source)
} else if (command === 'toggle') {
queue.toggleTask(row.source)
} else if (command === 'next') {
focusRow(index + 1)
} else if (command === 'previous') {
focusRow(index - 1)
}
}
function openDetail(item) {
emit('open-detail', item)
}
function emitAction(action, item) {
emit('action', { action, item })
}
function closeBatchConfirm() {
if (!queue.batchBusy.value) {
batchConfirmOpen.value = false
}
}
async function confirmBatchApprove() {
try {
const result = await queue.batchApprove({ opinion: batchOpinion.value })
if (result) {
batchConfirmOpen.value = false
batchOpinion.value = ''
emit('batch-result', result)
}
} catch (error) {
emit('error', error)
}
}
defineExpose({
clearSelection: queue.clearSelection,
queue,
refreshTask: queue.refreshTask,
reload
})
</script>
<style scoped>
.approval-task-queue {
display: grid;
gap: 12px;
min-width: 0;
}
.approval-task-toolbar,
.approval-task-filters,
.approval-task-batch-actions,
.approval-task-filters label,
.row-actions {
display: flex;
align-items: center;
}
.approval-task-toolbar {
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.approval-task-filters,
.approval-task-batch-actions {
gap: 8px;
flex-wrap: wrap;
}
.approval-task-filters label {
min-height: 38px;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.approval-task-search {
width: min(320px, 70vw);
}
.approval-task-filters input,
.approval-task-filters select {
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #0f172a;
}
.approval-task-search input {
flex: 1;
}
.approval-task-toolbar button,
.row-actions button {
min-height: 36px;
padding: 0 12px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #fff;
color: #334155;
font-weight: 800;
}
.approval-task-batch-actions button:first-of-type,
.filter-apply,
.row-actions button.primary {
border-color: var(--theme-primary);
background: var(--theme-primary);
color: #fff;
}
.approval-task-toolbar button:disabled,
.row-actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.approval-task-batch-actions span {
color: #475569;
font-size: 12px;
font-weight: 800;
}
.approval-task-error {
margin: 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.approval-task-table-wrap {
min-width: 0;
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.approval-task-state {
min-height: 220px;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: #64748b;
}
.approval-task-state i {
font-size: 28px;
}
table {
width: 100%;
min-width: 1280px;
border-collapse: collapse;
}
th,
td {
padding: 11px 10px;
border-bottom: 1px solid #edf2f7;
text-align: left;
vertical-align: middle;
}
th {
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
tbody tr {
cursor: pointer;
outline: none;
}
tbody tr:hover,
tbody tr.focused,
tbody tr:focus-visible {
background: #f5faff;
box-shadow: inset 3px 0 var(--theme-primary);
}
tbody tr.readonly {
background: #fbfcfe;
}
td strong,
td span {
display: block;
}
td strong {
color: #0f172a;
font-size: 13px;
}
td span {
margin-top: 3px;
color: #64748b;
font-size: 11px;
}
.selection-column {
width: 44px;
text-align: center;
}
.selection-column input {
width: 16px;
height: 16px;
accent-color: var(--theme-primary);
}
em.priority,
em.risk,
.sla,
.readonly-label {
display: inline-flex;
width: fit-content;
padding: 3px 7px;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 850;
}
.priority.urgent,
.risk.critical,
.risk.high,
.sla.danger {
background: #fef2f2;
color: #b91c1c;
}
.priority.high,
.risk.medium,
.sla.warning {
background: #fffbeb;
color: #b45309;
}
.priority.normal,
.risk.low,
.sla.safe {
background: #ecfdf5;
color: #047857;
}
.sla.neutral,
.readonly-label {
background: #f1f5f9;
color: #64748b;
}
.row-actions {
gap: 5px;
flex-wrap: wrap;
}
.row-actions button {
min-height: 30px;
padding: 0 8px;
font-size: 11px;
}
.row-actions button.danger {
border-color: #fecaca;
color: #b91c1c;
}
.row-actions button.warning {
border-color: #fde68a;
color: #b45309;
}
.batch-opinion {
display: grid;
gap: 7px;
}
.batch-opinion span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.batch-opinion textarea {
min-height: 80px;
padding: 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
resize: vertical;
}
@media (max-width: 900px) {
.approval-task-toolbar {
align-items: stretch;
}
.approval-task-filters,
.approval-task-batch-actions {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,390 @@
<template>
<section class="approval-task-workspace">
<p v-if="statusMessage" class="workspace-message success" aria-live="polite">
<i class="mdi mdi-check-circle-outline"></i>
{{ statusMessage }}
</p>
<p v-if="workspaceError" class="workspace-message error" role="alert">
<i class="mdi mdi-alert-circle-outline"></i>
{{ workspaceError }}
</p>
<ApprovalTaskQueue
ref="queueRef"
:initial-filters="initialFilters"
:initial-page="initialPage"
:initial-page-size="initialPageSize"
:refresh-token="refreshToken"
:service-overrides="serviceOverrides"
@open-detail="openDetail"
@action="openAction"
@batch-result="handleBatchResult"
@loaded="handleQueueLoaded"
@state-change="$emit('state-change', $event)"
@error="handleQueueError"
/>
<ApprovalAssignmentDialog
:open="assignmentOpen"
:task="activeItem"
:mode="actionMode"
:service-overrides="serviceOverrides"
@close="closeAction"
@completed="handleMutationCompleted"
/>
<ApprovalParticipantsDialog
:open="participantsOpen"
:task="activeItem"
:mode="actionMode"
:service-overrides="serviceOverrides"
@close="closeAction"
@completed="handleMutationCompleted"
/>
<ConfirmDialog
:open="decisionOpen"
:badge="decisionMeta.badge"
:badge-tone="decisionMeta.badgeTone"
:title="decisionMeta.title"
:description="decisionMeta.description"
cancel-text="返回核对"
:confirm-text="decisionMeta.confirmText"
busy-text="提交中..."
:confirm-tone="decisionMeta.confirmTone"
:confirm-icon="decisionMeta.icon"
:busy="actionBusy"
:confirm-disabled="decisionReason.trim().length < 2"
:close-on-mask="false"
@close="closeAction"
@confirm="submitDecision"
>
<p v-if="actionError" class="workspace-message error" role="alert">
{{ actionError }}
</p>
<label class="workspace-reason">
<span>{{ decisionMeta.reasonLabel }} <em>必填</em></span>
<textarea
v-model="decisionReason"
maxlength="500"
:disabled="actionBusy"
:placeholder="decisionMeta.placeholder"
></textarea>
<small>{{ decisionReason.trim().length }}/500</small>
</label>
</ConfirmDialog>
<ReturnReasonDialog
:open="returnOpen"
:busy="actionBusy"
:claim-no="activeClaimNo"
:application="activeIsApplication"
:error="actionError"
@close="closeAction"
@confirm="submitReturn"
/>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import {
approveApprovalTask,
escalateApprovalTask,
fetchApprovalTasks,
returnApprovalTask
} from '../../services/approvalTasks.js'
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import { resolveApprovalTaskRetryKey } from '../../views/scripts/approvalTaskRetry.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import ReturnReasonDialog from '../shared/ReturnReasonDialog.vue'
import ApprovalAssignmentDialog from './ApprovalAssignmentDialog.vue'
import ApprovalParticipantsDialog from './ApprovalParticipantsDialog.vue'
import ApprovalTaskQueue from './ApprovalTaskQueue.vue'
const DECISION_META = {
approve: {
badge: '审批通过',
badgeTone: 'info',
title: '确认通过当前审批任务吗?',
description: '提交时会重新校验任务版本、审批权限、开放风险和单据状态。',
confirmText: '确认通过',
confirmTone: 'primary',
icon: 'mdi mdi-check-circle-outline',
reasonLabel: '审批意见',
placeholder: '写明核对结论,至少 2 个字。'
},
sla_escalate: {
badge: '任务升级',
badgeTone: 'warning',
title: '确认立即升级该审批任务吗?',
description: '升级只改变任务优先级和 SLA 审计信息,不会替代审批人的最终决定。',
confirmText: '确认升级',
confirmTone: 'warning',
icon: 'mdi mdi-arrow-up-bold-circle-outline',
reasonLabel: '升级原因',
placeholder: '说明需要立即升级的业务原因。'
}
}
const ASSIGNMENT_ACTIONS = new Set(['delegate', 'transfer', 'delegation_revoke'])
const PARTICIPANT_ACTIONS = new Set(['add_sign', 'countersign'])
const props = defineProps({
initialFilters: { type: Object, default: () => ({}) },
initialPage: { type: Number, default: 1 },
initialPageSize: { type: Number, default: 20 },
refreshToken: { type: [String, Number], default: 0 },
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-document', 'request-updated', 'loaded', 'batch-result', 'state-change'])
const queueRef = ref(null)
const activeItem = ref(null)
const actionMode = ref('')
const decisionReason = ref('')
const actionBusy = ref(false)
const actionError = ref('')
const queueError = ref('')
const summaryError = ref('')
const statusMessage = ref('')
const requestId = ref('')
const requestFingerprint = ref('')
const activeTask = computed(() => activeItem.value?.task || activeItem.value || {})
const activeClaim = computed(() => activeItem.value?.claim || {})
const activeTaskId = computed(() => String(activeTask.value.id || '').trim())
const activeClaimNo = computed(() => String(
activeClaim.value.claim_no || activeClaim.value.claimNo || activeTask.value.claimId || ''
).trim())
const activeIsApplication = computed(() => (
isApplicationDocumentNo(activeClaimNo.value)
|| ['application', 'expense_application'].includes(String(
activeClaim.value.expense_type || activeClaim.value.expenseType || ''
).trim())
))
const assignmentOpen = computed(() => ASSIGNMENT_ACTIONS.has(actionMode.value))
const participantsOpen = computed(() => PARTICIPANT_ACTIONS.has(actionMode.value))
const decisionOpen = computed(() => ['approve', 'sla_escalate'].includes(actionMode.value))
const returnOpen = computed(() => actionMode.value === 'return')
const decisionMeta = computed(() => DECISION_META[actionMode.value] || DECISION_META.approve)
const workspaceError = computed(() => queueError.value || summaryError.value)
let summaryRequestSequence = 0
onMounted(() => void loadTaskSummary())
watch(() => props.refreshToken, () => void loadTaskSummary())
function openDetail(item) {
const task = item?.task || item || {}
const claim = item?.claim || {}
const claimId = String(task.claimId || task.claim_id || item?.claimId || '').trim()
emit('open-document', {
claimId,
id: claimId,
claimNo: String(claim.claim_no || claim.claimNo || '').trim(),
detailLookupOnly: true
})
}
function openAction(payload = {}) {
const action = String(payload.action || '').trim()
if (![...ASSIGNMENT_ACTIONS, ...PARTICIPANT_ACTIONS, 'approve', 'return', 'sla_escalate'].includes(action)) {
return
}
activeItem.value = payload.item || null
actionMode.value = action
decisionReason.value = ''
actionError.value = ''
statusMessage.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
function closeAction() {
if (actionBusy.value) return
activeItem.value = null
actionMode.value = ''
decisionReason.value = ''
actionError.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
async function submitDecision() {
if (actionBusy.value || decisionReason.value.trim().length < 2) return
const service = actionMode.value === 'approve'
? props.serviceOverrides.approveApprovalTask || approveApprovalTask
: props.serviceOverrides.escalateApprovalTask || escalateApprovalTask
const payload = {
expectedTaskVersion: Number(activeTask.value.version),
reason: decisionReason.value.trim()
}
if (actionMode.value === 'approve') payload.opinion = decisionReason.value.trim()
attachRetryRequestId(payload)
await executeAction(() => service(activeTaskId.value, payload))
}
async function submitReturn(payload = {}) {
if (actionBusy.value) return
const service = props.serviceOverrides.returnApprovalTask || returnApprovalTask
const actionPayload = {
expectedTaskVersion: Number(activeTask.value.version),
reason: String(payload.reason || '').trim(),
reasonCodes: Array.isArray(payload.reason_codes) ? payload.reason_codes : []
}
attachRetryRequestId(actionPayload)
await executeAction(() => service(activeTaskId.value, actionPayload))
}
function attachRetryRequestId(payload) {
const retryKey = resolveApprovalTaskRetryKey({
action: actionMode.value,
scopeId: activeTaskId.value || 'task',
payload,
previousFingerprint: requestFingerprint.value,
previousRequestId: requestId.value
})
requestId.value = retryKey.requestId
requestFingerprint.value = retryKey.fingerprint
payload.requestId = retryKey.requestId
}
async function executeAction(executor) {
actionBusy.value = true
actionError.value = ''
try {
const mutation = await executor()
await finishMutation(actionMode.value, mutation)
} catch (error) {
actionError.value = error?.message || '审批任务操作失败,请按当前 request_id 安全重试。'
} finally {
actionBusy.value = false
}
}
async function handleMutationCompleted(payload = {}) {
await finishMutation(payload.mode || actionMode.value, payload.mutation)
}
async function finishMutation(mode, mutation) {
const labels = {
approve: '审批通过',
return: '退回',
delegate: '委托',
delegation_revoke: '撤销委托',
transfer: '转交',
add_sign: '加签',
countersign: '会签',
sla_escalate: '任务升级'
}
statusMessage.value = mutation?.replayed
? `${labels[mode] || '任务操作'}结果已安全重放。`
: `${labels[mode] || '任务操作'}已完成。`
if (mutation?.claim) emit('request-updated', { claim: mutation.claim })
activeItem.value = null
actionMode.value = ''
decisionReason.value = ''
requestId.value = ''
requestFingerprint.value = ''
try {
await queueRef.value?.reload?.()
} catch {
queueError.value = '操作已成功,但任务列表刷新失败,请稍后手动刷新。'
}
await loadTaskSummary()
}
function handleBatchResult(result) {
statusMessage.value = result?.status === 'succeeded'
? `批量审批完成:成功 ${Number(result.succeededCount || 0) + Number(result.replayedCount || 0)} 项。`
: '批量审批已完成,部分任务需要单独处理。'
const completedItems = (Array.isArray(result?.items) ? result.items : [])
.filter((item) => ['succeeded', 'replayed'].includes(String(item?.status || '').toLowerCase()))
const claims = completedItems.map((item) => item?.claim).filter(Boolean)
const claimIds = completedItems.map((item) => item?.claimId).filter(Boolean)
if (completedItems.length) {
emit('request-updated', { claim: claims[0], claimId: claimIds[0], claimIds })
}
void loadTaskSummary()
emit('batch-result', result)
}
function handleQueueError(error) {
queueError.value = error?.message || '审批任务队列加载失败。'
}
function handleQueueLoaded() {
queueError.value = ''
}
async function loadTaskSummary() {
const sequence = ++summaryRequestSequence
try {
const service = props.serviceOverrides.fetchApprovalTasks || fetchApprovalTasks
const result = await service({ status: 'pending', page: 1, pageSize: 1 })
if (sequence !== summaryRequestSequence) return
summaryError.value = ''
emit('loaded', result)
} catch (error) {
if (sequence === summaryRequestSequence) {
summaryError.value = error?.message || '审批任务总数同步失败。'
}
}
}
</script>
<style scoped>
.approval-task-workspace {
display: grid;
gap: 10px;
min-width: 0;
}
.workspace-message {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
padding: 9px 11px;
border: 1px solid;
border-radius: 7px;
font-size: 12px;
font-weight: 750;
}
.workspace-message.success {
border-color: #a7f3d0;
background: #ecfdf5;
color: #047857;
}
.workspace-message.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.workspace-reason {
display: grid;
gap: 7px;
}
.workspace-reason span,
.workspace-reason small {
color: #475569;
font-size: 12px;
}
.workspace-reason em {
color: #b91c1c;
font-style: normal;
}
.workspace-reason textarea {
min-height: 90px;
padding: 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
resize: vertical;
}
</style>

View File

@@ -328,21 +328,6 @@
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div class="topbar-spacer"></div>
<button class="create-top-btn" type="button">
<i class="mdi mdi-check-circle"></i>
<span>批量通过</span>
</button>
</template>
<template v-else-if="isPolicies">
<div class="kpi-chips">
<div v-for="kpi in knowledgeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
@@ -377,7 +362,6 @@ import { createCurrentYearDateRange } from '../../utils/dateRangeDefaults.js'
import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import {
APPROVAL_KPIS,
CHAT_KPIS,
buildDigitalEmployeeWorkRecordKpis,
buildDocumentKpis,
@@ -452,7 +436,6 @@ const emit = defineEmits([
'update:activeRange',
'update:customRange',
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication',
'openDocument',
@@ -466,7 +449,6 @@ const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digit
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => (
@@ -756,20 +738,13 @@ function openNotification(item) {
const requestKpis = computed(() => buildRequestKpis(props.requestSummary ?? {}))
const documentKpis = computed(() => buildDocumentKpis(props.documentSummary ?? {}))
const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords'
})
const digitalEmployeeWorkRecordKpis = computed(() => buildDigitalEmployeeWorkRecordKpis(props.digitalEmployeeSummary ?? {}))
const chatKpis = CHAT_KPIS
const approvalKpis = APPROVAL_KPIS
const knowledgeKpis = computed(() => buildKnowledgeKpis(props.knowledgeSummary ?? {}))
const employeeKpis = computed(() => buildEmployeeKpis(props.employeeSummary ?? {}))
const {
calendarOpen,

View File

@@ -5,13 +5,6 @@ export const CHAT_KPIS = [
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
]
export const APPROVAL_KPIS = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
]
export function buildRequestKpis(summary = {}) {
const total = Number(summary.total ?? 0)
const draft = Number(summary.draft ?? 0)

View File

@@ -16,6 +16,7 @@
@confirm="handleConfirm"
>
<div class="return-reason-dialog">
<small v-if="error" class="error" role="alert">{{ error }}</small>
<div class="return-reason-section">
<span>{{ optionsTitle }}</span>
<div class="return-reason-options" role="group" :aria-label="optionsAriaLabel">
@@ -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;

View File

@@ -77,12 +77,14 @@
<p v-if="currentDisposition.resolution" class="risk-disposition-resolution">
{{ currentDisposition.resolution }}
</p>
<RiskWaiverRecord :disposition="currentDisposition" :expired="waiverExpired" />
<template v-if="dispositionActions.length">
<textarea
v-if="nonWaiverActions.length"
v-model="dispositionNote"
:disabled="dispositionBusy"
maxlength="1000"
placeholder="填写复核依据、补充要求或解决说明(误报、补材料、豁免和解决时必填)"
placeholder="填写复核依据、补充要求或解决说明(误报、补材料和解决时必填)"
></textarea>
<div class="risk-disposition-actions">
<button
@@ -91,7 +93,7 @@
type="button"
:class="action.tone"
:disabled="dispositionBusy"
@click="submitDispositionAction(action.action)"
@click="handleDispositionAction(action.action)"
>
<i :class="dispositionBusyAction === action.action ? 'mdi mdi-loading mdi-spin' : action.icon"></i>
{{ action.label }}
@@ -99,7 +101,22 @@
</div>
</template>
<p v-else class="risk-disposition-complete">{{ dispositionCompleteMessage }}</p>
<p v-if="dispositionError" class="risk-disposition-error">{{ dispositionError }}</p>
<p
v-if="currentDisposition.readOnlyReason"
class="risk-disposition-read-only"
>
<i class="mdi mdi-lock-outline"></i>
{{ currentDisposition.readOnlyReason }}
</p>
<div
v-if="dispositionError"
class="risk-disposition-error"
role="alert"
:data-error-code="dispositionError.code"
>
<strong>{{ dispositionError.title }}</strong>
<span>{{ dispositionError.message }}</span>
</div>
</section>
<div class="risk-evidence-grid">
@@ -179,17 +196,28 @@
</button>
</div>
</template>
<RiskWaiverActionDialog
:open="Boolean(waiverDialogAction)"
:action="waiverDialogAction"
:busy="dispositionBusy"
:disposition="currentDisposition"
@cancel="closeWaiverDialog"
@confirm="confirmWaiverAction"
/>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import {
createRiskDispositionRequestId,
executeRiskDispositionAction,
fetchClaimRiskObservations
fetchClaimRiskObservations,
normalizeRiskDispositionError
} from '../../services/riskObservations.js'
import RiskWaiverActionDialog from './RiskWaiverActionDialog.vue'
import RiskWaiverRecord from './RiskWaiverRecord.vue'
const props = defineProps({
claimId: { type: String, default: '' }
@@ -201,10 +229,47 @@ const errorMessage = ref('')
const activeObservationKey = ref('')
const dispositionNote = ref('')
const dispositionBusyAction = ref('')
const dispositionError = ref('')
const dispositionError = ref(null)
const dispositionRequest = ref({ key: '', requestId: '' })
const waiverDialogAction = ref('')
const waiverClock = ref(Date.now())
const detailRegionId = 'risk-observation-active-detail'
let loadSequence = 0
let waiverExpiryTimer = 0
const waiverActions = new Set(['request_waiver', 'approve_waiver', 'reject_waiver'])
const actionCatalog = Object.freeze({
confirm: {
action: 'confirm', label: '确认风险', icon: 'mdi mdi-shield-check', tone: 'danger'
},
false_positive: {
action: 'false_positive', label: '标记误报', icon: 'mdi mdi-shield-off-outline', tone: 'safe'
},
request_supplement: {
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
},
start_remediation: {
action: 'start_remediation', label: '启动整改', icon: 'mdi mdi-tools', tone: 'warning'
},
request_waiver: {
action: 'request_waiver', label: '申请豁免', icon: 'mdi mdi-file-sign', tone: 'neutral'
},
approve_waiver: {
action: 'approve_waiver',
label: '批准豁免',
icon: 'mdi mdi-check-decagram-outline',
tone: 'safe'
},
reject_waiver: {
action: 'reject_waiver', label: '拒绝豁免', icon: 'mdi mdi-close-octagon-outline', tone: 'danger'
},
resolve: {
action: 'resolve', label: '确认已解决', icon: 'mdi mdi-check-decagram-outline', tone: 'safe'
}
})
const visible = computed(() =>
loading.value || Boolean(errorMessage.value) || observations.value.length > 0
@@ -239,10 +304,20 @@ const currentDisposition = computed(() => {
: 'unreviewed',
lifecycleStatus: status === 'resolved' ? 'resolved' : 'open',
version: 0,
resolution: ''
resolution: '',
availableActions: mainObservation.value?.availableActions || [],
readOnlyReason: mainObservation.value?.availableActions?.length
? ''
: mainObservation.value?.readOnlyReason
|| '服务端未返回可执行动作,当前风险仅供查看。',
waiverConditions: []
}
})
const dispositionBusy = computed(() => Boolean(dispositionBusyAction.value))
const waiverExpired = computed(() => {
const expiry = new Date(currentDisposition.value.waiverExpiresAt)
return !Number.isNaN(expiry.getTime()) && expiry.getTime() <= waiverClock.value
})
const dispositionStateLabel = computed(() => {
if (currentDisposition.value.adjudication === 'false_positive') {
return '该观察已判定为误报,不再阻断审批'
@@ -250,69 +325,47 @@ const dispositionStateLabel = computed(() => {
if (currentDisposition.value.lifecycleStatus === 'resolved') {
return '风险已完成处置,不再阻断审批'
}
if (currentDisposition.value.lifecycleStatus === 'waived') {
return waiverExpired.value
? '风险豁免已经到期,将继续阻断审批'
: `风险已限时豁免,有效至 ${formatDateTime(currentDisposition.value.waiverExpiresAt)}`
}
if (currentDisposition.value.lifecycleStatus === 'waiver_rejected') {
return '风险豁免已拒绝,仍会阻断审批'
}
if (currentDisposition.value.lifecycleStatus === 'waiver_requested') {
return '豁免申请等待决定,当前仍会阻断审批'
}
if (['high', 'critical'].includes(String(mainObservation.value?.riskLevel || ''))) {
return '高风险未关闭,将阻断审批通过'
}
return '请结合证据完成人工裁决'
})
const dispositionActions = computed(() => {
const adjudication = currentDisposition.value.adjudication
const lifecycle = currentDisposition.value.lifecycleStatus
if (adjudication === 'false_positive' || lifecycle === 'resolved') {
return []
}
if (adjudication !== 'confirmed') {
const actions = [
{ action: 'confirm', label: '确认风险', icon: 'mdi mdi-shield-check', tone: 'danger' },
{ action: 'false_positive', label: '标记误报', icon: 'mdi mdi-shield-off-outline', tone: 'safe' }
]
if (lifecycle !== 'supplement_requested') {
actions.push({
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
})
}
return actions
}
return [
{
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
},
{
action: 'start_remediation',
label: '启动整改',
icon: 'mdi mdi-tools',
tone: 'warning'
},
{
action: 'request_waiver',
label: '申请豁免',
icon: 'mdi mdi-file-sign',
tone: 'neutral'
},
{
action: 'resolve',
label: '确认已解决',
icon: 'mdi mdi-check-decagram-outline',
tone: 'safe'
}
].filter((item) => (
item.action === 'resolve'
|| (item.action === 'request_supplement' && lifecycle !== 'supplement_requested')
|| (item.action === 'start_remediation' && lifecycle !== 'remediation_in_progress')
|| (item.action === 'request_waiver' && lifecycle !== 'waiver_requested')
))
})
const dispositionCompleteMessage = computed(() => (
currentDisposition.value.adjudication === 'false_positive'
? '该风险已标记为误报;如需更正,请由管理员通过审计流程处理。'
: '该风险已解决,处置事件会保留在追加式审计链中。'
const dispositionActions = computed(() => (
(currentDisposition.value.availableActions || [])
.map((action) => actionCatalog[action])
.filter(Boolean)
))
const nonWaiverActions = computed(() => (
dispositionActions.value.filter((item) => !waiverActions.has(item.action))
))
const dispositionCompleteMessage = computed(() => {
if (currentDisposition.value.lifecycleStatus === 'waiver_rejected') {
return '豁免已拒绝;风险维持阻断,需完成整改或其他服务端允许的处置。'
}
if (currentDisposition.value.lifecycleStatus === 'waived') {
return waiverExpired.value
? '豁免已到期;风险控制已经恢复。'
: `豁免仅在 ${formatDateTime(currentDisposition.value.waiverExpiresAt)} 前有效。`
}
if (currentDisposition.value.adjudication === 'false_positive') {
return '该风险已标记为误报;如需更正,请通过审计流程处理。'
}
if (currentDisposition.value.lifecycleStatus === 'resolved') {
return '该风险已解决,处置事件会保留在追加式审计链中。'
}
return '当前没有服务端授权的可执行动作。'
})
const scoreItems = computed(() => {
const scores = mainObservation.value?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => {
@@ -367,11 +420,22 @@ const feedbackRows = computed(() =>
watch(
() => props.claimId,
() => {
waiverDialogAction.value = ''
dispositionError.value = null
dispositionRequest.value = { key: '', requestId: '' }
void loadObservations()
},
{ immediate: true }
)
watch(
() => currentDisposition.value.waiverExpiresAt,
(expiresAt) => scheduleWaiverExpiryRefresh(expiresAt),
{ immediate: true }
)
onBeforeUnmount(clearWaiverExpiryTimer)
async function loadObservations() {
const claimId = String(props.claimId || '').trim()
const sequence = ++loadSequence
@@ -404,6 +468,31 @@ async function loadObservations() {
}
}
function clearWaiverExpiryTimer() {
if (waiverExpiryTimer) {
globalThis.clearTimeout(waiverExpiryTimer)
waiverExpiryTimer = 0
}
}
function scheduleWaiverExpiryRefresh(value) {
clearWaiverExpiryTimer()
waiverClock.value = Date.now()
const expiresAt = new Date(value).getTime()
if (!Number.isFinite(expiresAt) || expiresAt <= waiverClock.value) return
const maximumDelay = 2_147_000_000
const delay = Math.min(expiresAt - waiverClock.value + 50, maximumDelay)
waiverExpiryTimer = globalThis.setTimeout(async () => {
waiverExpiryTimer = 0
waiverClock.value = Date.now()
if (waiverClock.value >= expiresAt) {
await loadObservations()
return
}
scheduleWaiverExpiryRefresh(value)
}, delay)
}
function observationIdentity(item, index = -1) {
const explicitKey = String(item?.observationKey || item?.id || '').trim()
if (explicitKey) {
@@ -426,22 +515,69 @@ function selectObservation(item, index = -1) {
if (key) {
activeObservationKey.value = key
dispositionNote.value = ''
dispositionError.value = ''
dispositionError.value = null
dispositionRequest.value = { key: '', requestId: '' }
waiverDialogAction.value = ''
}
}
async function submitDispositionAction(action) {
function handleDispositionAction(action) {
if (!isActionAvailable(action) || dispositionBusy.value) return
dispositionError.value = null
if (waiverActions.has(action)) {
waiverDialogAction.value = action
return
}
void submitDispositionAction(action)
}
function closeWaiverDialog() {
if (!dispositionBusy.value) waiverDialogAction.value = ''
}
async function confirmWaiverAction(payload) {
const succeeded = await submitDispositionAction(payload?.action, payload)
if (succeeded) waiverDialogAction.value = ''
}
function isActionAvailable(action) {
return (currentDisposition.value.availableActions || []).includes(action)
}
async function submitDispositionAction(action, actionPayload = {}) {
const observationId = String(mainObservation.value?.id || '').trim()
if (!observationId || dispositionBusy.value) {
return
return false
}
if (!isActionAvailable(action)) {
dispositionError.value = {
code: 'RISK_DISPOSITION_ACTION_UNAVAILABLE',
title: '操作权限已经变化',
message: currentDisposition.value.readOnlyReason || '服务端未授权当前风险处置动作。'
}
waiverDialogAction.value = ''
return false
}
const note = dispositionNote.value.trim()
if (['false_positive', 'request_supplement', 'request_waiver', 'resolve'].includes(action) && !note) {
dispositionError.value = '该处置动作需要填写复核依据或处理说明。'
return
if (['false_positive', 'request_supplement', 'resolve'].includes(action) && !note) {
dispositionError.value = {
code: 'RISK_DISPOSITION_NOTE_REQUIRED',
title: '需要填写处理说明',
message: '该处置动作需要填写复核依据或处理说明。'
}
return false
}
const requestKey = [observationId, action, currentDisposition.value.version, note].join(':')
const mutationPayload = {
...actionPayload,
comment: String(actionPayload.comment || note).trim(),
resolution: action === 'resolve' ? note : ''
}
const requestKey = [
observationId,
action,
currentDisposition.value.version,
JSON.stringify(mutationPayload)
].join(':')
if (dispositionRequest.value.key !== requestKey) {
dispositionRequest.value = {
key: requestKey,
@@ -449,24 +585,29 @@ async function submitDispositionAction(action) {
}
}
dispositionBusyAction.value = action
dispositionError.value = ''
dispositionError.value = null
try {
await executeRiskDispositionAction(observationId, {
...mutationPayload,
action,
expectedVersion: currentDisposition.value.version,
requestId: dispositionRequest.value.requestId,
comment: note,
resolution: action === 'resolve' ? note : ''
requestId: dispositionRequest.value.requestId
})
dispositionNote.value = ''
dispositionRequest.value = { key: '', requestId: '' }
await loadObservations()
return true
} catch (error) {
dispositionError.value = error?.message || '风险处置失败,请刷新状态后重试。'
if (error?.code === 'RISK_DISPOSITION_VERSION_CONFLICT') {
dispositionRequest.value = { key: '', requestId: '' }
const normalizedError = normalizeRiskDispositionError(error)
dispositionError.value = normalizedError
if (normalizedError.shouldRefresh) {
if (normalizedError.code !== 'REQUEST_TIMEOUT') {
dispositionRequest.value = { key: '', requestId: '' }
}
await loadObservations()
if (!isActionAvailable(action)) waiverDialogAction.value = ''
}
return false
} finally {
dispositionBusyAction.value = ''
}
@@ -559,6 +700,19 @@ function formatFeedbackStatus(value) {
return labels[String(value || '').trim()] || '未复核'
}
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '未记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
}
function formatAdjudication(value) {
const labels = {
unreviewed: '待裁决',
@@ -574,6 +728,8 @@ function formatLifecycleStatus(value) {
supplement_requested: '待补材料',
remediation_in_progress: '整改中',
waiver_requested: '豁免申请中',
waived: '限时豁免',
waiver_rejected: '豁免已拒绝',
resolved: '已解决'
}
return labels[String(value || '').trim()] || '待处置'

View File

@@ -0,0 +1,264 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="risk-waiver-dialog-backdrop"
@click.self="cancel"
@keydown.esc.prevent="cancel"
>
<section
class="risk-waiver-dialog"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
>
<header>
<div>
<span>风险豁免</span>
<h3 :id="titleId">{{ dialogTitle }}</h3>
</div>
<button type="button" :disabled="busy" aria-label="关闭" @click="cancel">
<i class="mdi mdi-close"></i>
</button>
</header>
<p class="risk-waiver-dialog-notice" :class="dialogTone">
{{ dialogNotice }}
</p>
<dl v-if="!isRequest" class="risk-waiver-dialog-summary">
<div>
<dt>申请人</dt>
<dd>{{ disposition.waiverRequesterName || disposition.waiverRequesterId || '未记录' }}</dd>
</div>
<div>
<dt>申请理由</dt>
<dd>{{ disposition.waiverReason || '未记录' }}</dd>
</div>
<div>
<dt>豁免范围</dt>
<dd>{{ disposition.waiverScope || '未记录' }}</dd>
</div>
<div>
<dt>有效期至</dt>
<dd>{{ formatDateTime(disposition.waiverExpiresAt) }}</dd>
</div>
<div v-if="disposition.waiverConditions?.length">
<dt>补偿条件</dt>
<dd>{{ disposition.waiverConditions.join('') }}</dd>
</div>
</dl>
<form @submit.prevent="confirm">
<template v-if="isRequest">
<label>
<span>申请理由 <em>必填</em></span>
<textarea
v-model="form.reason"
:disabled="busy"
maxlength="2000"
placeholder="说明为什么需要例外处理,以及无法按常规流程解决的原因"
></textarea>
</label>
<label>
<span>豁免范围 <em>必填</em></span>
<textarea
v-model="form.scope"
:disabled="busy"
maxlength="1000"
placeholder="限定到本次单据、具体材料或风险项,避免扩大豁免范围"
></textarea>
</label>
<label>
<span>有效期 <em>必填</em></span>
<input
v-model="form.expiresAt"
type="datetime-local"
:min="minimumExpiry"
:disabled="busy"
/>
</label>
<label>
<span>补偿条件 <small>选填每行一项</small></span>
<textarea
v-model="form.conditions"
:disabled="busy"
maxlength="4000"
placeholder="例如:三日内补交原件&#10;到期前由财务复核"
></textarea>
</label>
</template>
<label v-else>
<span>{{ decisionReasonLabel }} <em>必填</em></span>
<textarea
v-model="form.decisionReason"
:disabled="busy"
maxlength="1000"
:placeholder="decisionReasonPlaceholder"
></textarea>
</label>
<p v-if="validationMessage" class="risk-waiver-dialog-validation" role="alert">
{{ validationMessage }}
</p>
<footer>
<button type="button" :disabled="busy" @click="cancel">取消</button>
<button
type="submit"
class="primary"
:class="dialogTone"
:disabled="busy"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
{{ busy ? '正在提交' : confirmLabel }}
</button>
</footer>
</form>
</section>
</div>
</Teleport>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
action: { type: String, default: '' },
busy: { type: Boolean, default: false },
disposition: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['cancel', 'confirm'])
const titleId = 'risk-waiver-action-dialog-title'
const validationMessage = ref('')
const form = reactive({
reason: '',
scope: '',
expiresAt: '',
conditions: '',
decisionReason: ''
})
const isRequest = computed(() => props.action === 'request_waiver')
const isRejection = computed(() => props.action === 'reject_waiver')
const dialogTitle = computed(() => (
isRequest.value ? '申请风险豁免' : isRejection.value ? '拒绝风险豁免' : '批准风险豁免'
))
const dialogTone = computed(() => (isRejection.value ? 'danger' : 'warning'))
const dialogNotice = computed(() => {
if (isRequest.value) {
return '提交后仍会保持风险阻断,必须由另一位有权决定人批准后才会在有效期内解除。'
}
if (isRejection.value) {
return '拒绝后该风险仍会阻断审批;拒绝理由将写入审计记录且不可省略。'
}
return '批准后仅在申请有效期内解除风险阻断,到期后系统会自动恢复风险控制。'
})
const confirmLabel = computed(() => (
isRequest.value
? '确认提交豁免申请'
: isRejection.value
? '确认拒绝豁免'
: '确认批准豁免'
))
const decisionReasonLabel = computed(() => (isRejection.value ? '拒绝理由' : '批准理由'))
const decisionReasonPlaceholder = computed(() => (
isRejection.value
? '说明拒绝原因和仍需补充的控制措施'
: '说明批准依据和已确认的补偿控制'
))
const minimumExpiry = computed(() => toLocalDateTime(new Date(Date.now() + 60 * 1000)))
watch(
() => [props.open, props.action],
([open]) => {
if (!open) return
validationMessage.value = ''
form.reason = ''
form.scope = ''
form.conditions = ''
form.decisionReason = ''
form.expiresAt = isRequest.value
? toLocalDateTime(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000))
: ''
}
)
function cancel() {
if (!props.busy) emit('cancel')
}
function confirm() {
validationMessage.value = ''
if (props.busy) return
if (isRequest.value) {
const reason = form.reason.trim()
const scope = form.scope.trim()
const expiry = new Date(form.expiresAt)
if (!reason || !scope || !form.expiresAt) {
validationMessage.value = '请完整填写申请理由、豁免范围和有效期。'
return
}
if (Number.isNaN(expiry.getTime()) || expiry.getTime() <= Date.now()) {
validationMessage.value = '有效期必须晚于当前时间。'
return
}
const conditions = parseConditions(form.conditions)
if (conditions.length > 20) {
validationMessage.value = '补偿条件最多填写 20 项。'
return
}
if (conditions.some((item) => item.length > 500)) {
validationMessage.value = '单项补偿条件不能超过 500 个字符。'
return
}
emit('confirm', {
action: props.action,
waiverReason: reason,
waiverScope: scope,
waiverExpiresAt: expiry.toISOString(),
waiverConditions: conditions
})
return
}
const comment = form.decisionReason.trim()
if (!comment) {
validationMessage.value = isRejection.value ? '请填写拒绝理由。' : '请填写批准理由。'
return
}
emit('confirm', { action: props.action, comment })
}
function parseConditions(value) {
return [...new Set(
String(value || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
)]
}
function toLocalDateTime(value) {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const offset = date.getTimezoneOffset() * 60 * 1000
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
}
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '未记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
}
</script>
<style scoped src="../../assets/styles/components/risk-waiver-action-dialog.css"></style>

View File

@@ -0,0 +1,161 @@
<template>
<div v-if="visible" class="risk-waiver-record">
<div class="risk-waiver-record-head">
<strong>豁免申请与决定</strong>
<em :class="disposition.waiverDecision || 'pending'">{{ decisionLabel }}</em>
</div>
<dl>
<div>
<dt>申请人</dt>
<dd>
{{ disposition.waiverRequesterName || disposition.waiverRequesterId || '未记录' }}
<small>{{ formatDateTime(disposition.waiverRequestedAt) }}</small>
</dd>
</div>
<div>
<dt>申请理由</dt>
<dd>{{ disposition.waiverReason || '未记录' }}</dd>
</div>
<div>
<dt>豁免范围</dt>
<dd>{{ disposition.waiverScope || '未记录' }}</dd>
</div>
<div>
<dt>有效期至</dt>
<dd :class="{ expired }">{{ formatDateTime(disposition.waiverExpiresAt) }}</dd>
</div>
<div class="wide">
<dt>补偿条件</dt>
<dd>
<span v-if="!disposition.waiverConditions?.length">无附加条件</span>
<ul v-else>
<li v-for="condition in disposition.waiverConditions" :key="condition">
{{ condition }}
</li>
</ul>
</dd>
</div>
<div v-if="disposition.waiverDecision" class="wide">
<dt>决定记录</dt>
<dd>
{{ disposition.waiverDeciderName || disposition.waiverDeciderId || '未记录决定人' }}
<small>{{ formatDateTime(disposition.waiverDecidedAt) }}</small>
<span>{{ disposition.waiverDecisionReason || '未记录决定理由' }}</span>
</dd>
</div>
</dl>
<section v-if="waiverEvents.length" class="risk-waiver-history">
<h4>豁免审计轨迹</h4>
<ol>
<li v-for="event in waiverEvents" :key="event.key">
<div>
<strong>{{ event.label }}</strong>
<em>v{{ event.version }}</em>
</div>
<p>{{ event.actor }} · {{ formatDateTime(event.createdAt) }}</p>
<p v-if="event.comment">{{ event.comment }}</p>
<span v-for="detail in event.details" :key="detail">{{ detail }}</span>
<code v-if="event.requestId">请求 ID{{ event.requestId }}</code>
</li>
</ol>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
disposition: { type: Object, default: () => ({}) },
expired: { type: Boolean, default: false }
})
const visible = computed(() => Boolean(
props.disposition.waiverRequesterId
|| props.disposition.waiverRequesterName
|| props.disposition.waiverRequestedAt
))
const decisionLabel = computed(() => {
if (props.disposition.waiverDecision === 'approved') {
return props.expired ? '已批准 · 已到期' : '已批准'
}
if (props.disposition.waiverDecision === 'rejected') return '已拒绝'
return '等待决定'
})
const waiverEvents = computed(() => (
(props.disposition.events || [])
.filter((event) => ['request_waiver', 'approve_waiver', 'reject_waiver'].includes(event.action))
.map((event, index) => {
const state = event.afterState || {}
const details = event.action === 'request_waiver'
? [
formatAuditDetail('原因', stateValue(state, 'waiver_reason', 'waiverReason')),
formatAuditDetail('范围', stateValue(state, 'waiver_scope', 'waiverScope')),
formatAuditDetail(
'有效期至',
formatDateTime(stateValue(state, 'waiver_expires_at', 'waiverExpiresAt'))
),
formatAuditConditions(
stateValue(state, 'waiver_conditions_json', 'waiverConditions')
)
].filter(Boolean)
: []
return {
key: event.id || `${event.action}:${event.version}:${index}`,
label: formatWaiverAction(event.action),
version: event.version,
actor: formatActor(event),
createdAt: event.createdAt,
comment: event.comment,
requestId: event.requestId,
details
}
})
))
function stateValue(state, snakeKey, camelKey) {
return state?.[snakeKey] ?? state?.[camelKey]
}
function formatAuditDetail(label, value) {
const text = String(value || '').trim()
return text && text !== '未记录' ? `${label}${text}` : ''
}
function formatAuditConditions(value) {
const conditions = Array.isArray(value)
? value.map((item) => String(item || '').trim()).filter(Boolean)
: []
return conditions.length ? `条件:${conditions.join('')}` : ''
}
function formatActor(event) {
const name = String(event.actorName || '').trim()
const id = String(event.actorId || '').trim()
if (name && id && name !== id) return `${name}${id}`
return name || id || '未记录处理人'
}
function formatWaiverAction(action) {
return {
request_waiver: '提交豁免申请',
approve_waiver: '批准豁免',
reject_waiver: '拒绝豁免'
}[action] || action
}
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '未记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
}
</script>
<style scoped src="../../assets/styles/components/risk-waiver-record.css"></style>