feat(approval): add task workflow and waiver decisions
This commit is contained in:
409
web/src/components/approval/ApprovalAssignmentDialog.vue
Normal file
409
web/src/components/approval/ApprovalAssignmentDialog.vue
Normal 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>
|
||||
193
web/src/components/approval/ApprovalBatchResultPanel.vue
Normal file
193
web/src/components/approval/ApprovalBatchResultPanel.vue
Normal 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>
|
||||
497
web/src/components/approval/ApprovalParticipantsDialog.vue
Normal file
497
web/src/components/approval/ApprovalParticipantsDialog.vue
Normal 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>
|
||||
696
web/src/components/approval/ApprovalTaskQueue.vue
Normal file
696
web/src/components/approval/ApprovalTaskQueue.vue
Normal 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>
|
||||
390
web/src/components/approval/ApprovalTaskWorkspace.vue
Normal file
390
web/src/components/approval/ApprovalTaskWorkspace.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()] || '待处置'
|
||||
|
||||
264
web/src/components/travel/RiskWaiverActionDialog.vue
Normal file
264
web/src/components/travel/RiskWaiverActionDialog.vue
Normal 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="例如:三日内补交原件 到期前由财务复核"
|
||||
></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>
|
||||
161
web/src/components/travel/RiskWaiverRecord.vue
Normal file
161
web/src/components/travel/RiskWaiverRecord.vue
Normal 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>
|
||||
Reference in New Issue
Block a user