feat(approval): add task workflow and waiver decisions
This commit is contained in:
@@ -252,13 +252,15 @@
|
||||
|
||||
.risk-disposition-badges em.confirmed,
|
||||
.risk-disposition-badges em.remediation_in_progress,
|
||||
.risk-disposition-badges em.waiver_requested {
|
||||
.risk-disposition-badges em.waiver_requested,
|
||||
.risk-disposition-badges em.waiver_rejected {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-active);
|
||||
}
|
||||
|
||||
.risk-disposition-badges em.false_positive,
|
||||
.risk-disposition-badges em.resolved {
|
||||
.risk-disposition-badges em.resolved,
|
||||
.risk-disposition-badges em.waived {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
@@ -318,8 +320,7 @@
|
||||
}
|
||||
|
||||
.risk-disposition-resolution,
|
||||
.risk-disposition-complete,
|
||||
.risk-disposition-error {
|
||||
.risk-disposition-complete {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
@@ -328,7 +329,34 @@
|
||||
}
|
||||
|
||||
.risk-disposition-error {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--danger-line);
|
||||
border-radius: 5px;
|
||||
background: rgba(239, 68, 68, .06);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.risk-disposition-error strong {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.risk-disposition-error span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-disposition-read-only {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-evidence-meta {
|
||||
|
||||
231
web/src/assets/styles/components/risk-waiver-action-dialog.css
Normal file
231
web/src/assets/styles/components/risk-waiver-action-dialog.css
Normal file
@@ -0,0 +1,231 @@
|
||||
.risk-waiver-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 1800;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, .52);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.risk-waiver-dialog {
|
||||
width: min(620px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, .24);
|
||||
}
|
||||
|
||||
.risk-waiver-dialog header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog header > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog header span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog header h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog header button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-notice {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--warning-line);
|
||||
border-radius: 6px;
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-active);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-notice.danger {
|
||||
border-color: var(--danger-line);
|
||||
background: rgba(239, 68, 68, .07);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-summary div {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-summary dt,
|
||||
.risk-waiver-dialog-summary dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-summary dt {
|
||||
color: #64748b;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-summary dd {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog form,
|
||||
.risk-waiver-dialog label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog form {
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog label > span {
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog label em,
|
||||
.risk-waiver-dialog label small {
|
||||
margin-left: 4px;
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog label small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog textarea,
|
||||
.risk-waiver-dialog input {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog textarea {
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog textarea:focus,
|
||||
.risk-waiver-dialog input:focus {
|
||||
border-color: var(--theme-primary);
|
||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-validation {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog footer button {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog footer button.primary {
|
||||
border-color: var(--warning-line);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-active);
|
||||
}
|
||||
|
||||
.risk-waiver-dialog footer button.primary.danger {
|
||||
border-color: var(--danger-line);
|
||||
background: rgba(239, 68, 68, .07);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-waiver-dialog button:disabled,
|
||||
.risk-waiver-dialog textarea:disabled,
|
||||
.risk-waiver-dialog input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .66;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.risk-waiver-dialog-backdrop {
|
||||
align-items: end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog {
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.risk-waiver-dialog-summary div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
171
web/src/assets/styles/components/risk-waiver-record.css
Normal file
171
web/src/assets/styles/components/risk-waiver-record.css
Normal file
@@ -0,0 +1,171 @@
|
||||
.risk-waiver-record {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 11px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, .82);
|
||||
}
|
||||
|
||||
.risk-waiver-record-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-waiver-record-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.risk-waiver-record-head em {
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-active);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-waiver-record-head em.approved {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.risk-waiver-record-head em.rejected {
|
||||
background: rgba(239, 68, 68, .08);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-waiver-record dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.risk-waiver-record dl > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.risk-waiver-record dl > div.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.risk-waiver-record dt,
|
||||
.risk-waiver-record dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-waiver-record dt {
|
||||
color: #64748b;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-waiver-record dd {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.risk-waiver-record dd.expired {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-waiver-record dd small,
|
||||
.risk-waiver-record dd span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.risk-waiver-record ul {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.risk-waiver-history {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.risk-waiver-history h4 {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.risk-waiver-history ol {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.risk-waiver-history li {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 9px 10px;
|
||||
border-left: 3px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-waiver-history li > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.risk-waiver-history strong,
|
||||
.risk-waiver-history em,
|
||||
.risk-waiver-history p,
|
||||
.risk-waiver-history span,
|
||||
.risk-waiver-history code {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.risk-waiver-history strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-waiver-history em,
|
||||
.risk-waiver-history code {
|
||||
color: #64748b;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.risk-waiver-history code {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.risk-waiver-record dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.risk-waiver-record dl > div.wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
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>
|
||||
339
web/src/composables/useApprovalTaskQueue.js
Normal file
339
web/src/composables/useApprovalTaskQueue.js
Normal file
@@ -0,0 +1,339 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
batchApproveApprovalTasks,
|
||||
fetchApprovalTaskDetail,
|
||||
fetchApprovalTasks
|
||||
} from '../services/approvalTasks.js'
|
||||
import { resolveApprovalTaskRetryKey } from '../views/scripts/approvalTaskRetry.js'
|
||||
import {
|
||||
createApprovalTaskSelectionState,
|
||||
reconcileApprovalTaskSelectionAfterBatch,
|
||||
reconcileApprovalTaskSelectionWithPage,
|
||||
resolveCurrentPageApprovalSelection,
|
||||
syncApprovalTaskSelectionFilters,
|
||||
toggleApprovalTaskSelection,
|
||||
toggleCurrentPageApprovalSelection
|
||||
} from '../views/scripts/approvalTaskQueueState.js'
|
||||
|
||||
const DEFAULT_FILTERS = Object.freeze({
|
||||
status: 'pending',
|
||||
nodeKey: '',
|
||||
taskKind: '',
|
||||
riskLevel: '',
|
||||
slaState: '',
|
||||
keyword: '',
|
||||
sort: 'priority_desc',
|
||||
batchEligible: null
|
||||
})
|
||||
const MAX_BATCH_SELECTION = 20
|
||||
|
||||
function toText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function toPositiveInteger(value, fallback = 1) {
|
||||
const number = Math.trunc(Number(value))
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback
|
||||
}
|
||||
|
||||
function taskIdOf(item = {}) {
|
||||
return toText(item?.task?.id ?? item?.id ?? item?.task_id ?? item?.taskId)
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskQueueFilters(filters = {}) {
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
...filters,
|
||||
status: filters.status ?? DEFAULT_FILTERS.status,
|
||||
nodeKey: filters.nodeKey ?? filters.node_key ?? DEFAULT_FILTERS.nodeKey,
|
||||
taskKind: filters.taskKind ?? filters.task_kind ?? DEFAULT_FILTERS.taskKind,
|
||||
riskLevel: filters.riskLevel ?? filters.risk_level ?? DEFAULT_FILTERS.riskLevel,
|
||||
slaState: filters.slaState ?? filters.sla_state ?? DEFAULT_FILTERS.slaState,
|
||||
keyword: toText(filters.keyword),
|
||||
sort: filters.sort ?? DEFAULT_FILTERS.sort,
|
||||
batchEligible: typeof (filters.batchEligible ?? filters.batch_eligible) === 'boolean'
|
||||
? Boolean(filters.batchEligible ?? filters.batch_eligible)
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export function useApprovalTaskQueue(options = {}) {
|
||||
const services = {
|
||||
fetchApprovalTasks,
|
||||
fetchApprovalTaskDetail,
|
||||
batchApproveApprovalTasks,
|
||||
...(options.services || {})
|
||||
}
|
||||
const page = ref(toPositiveInteger(options.initialPage, 1))
|
||||
const pageSize = ref(toPositiveInteger(options.initialPageSize, 20))
|
||||
const filters = ref(normalizeApprovalTaskQueueFilters(options.initialFilters))
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const generatedAt = ref('')
|
||||
const loading = ref(false)
|
||||
const batchBusy = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastBatchResult = ref(null)
|
||||
const pendingBatchRequestId = ref('')
|
||||
const pendingBatchFingerprint = ref('')
|
||||
const selectionState = ref(createApprovalTaskSelectionState({ filters: filters.value }))
|
||||
const selectedSnapshots = new Map()
|
||||
const taskRefreshSequences = new Map()
|
||||
let listRequestSequence = 0
|
||||
|
||||
const selectedTaskIds = computed(() => selectionState.value.selectedTaskIds)
|
||||
const pageSelection = computed(() =>
|
||||
resolveCurrentPageApprovalSelection(items.value, selectedTaskIds.value)
|
||||
)
|
||||
const selectedCount = computed(() => selectedTaskIds.value.length)
|
||||
|
||||
function updateSelection(nextTaskIds) {
|
||||
const selectedSet = new Set(nextTaskIds)
|
||||
selectionState.value = {
|
||||
...selectionState.value,
|
||||
selectedTaskIds: [...selectedSet]
|
||||
}
|
||||
for (const taskId of [...selectedSnapshots.keys()]) {
|
||||
if (!selectedSet.has(taskId)) {
|
||||
selectedSnapshots.delete(taskId)
|
||||
}
|
||||
}
|
||||
for (const item of items.value) {
|
||||
const taskId = taskIdOf(item)
|
||||
if (taskId && selectedSet.has(taskId)) {
|
||||
selectedSnapshots.set(taskId, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyListResult(result) {
|
||||
items.value = Array.isArray(result?.items) ? result.items : []
|
||||
total.value = Math.max(0, Number(result?.total) || 0)
|
||||
totalPages.value = Math.max(0, Number(result?.totalPages) || 0)
|
||||
generatedAt.value = toText(result?.generatedAt)
|
||||
page.value = toPositiveInteger(result?.page, page.value)
|
||||
pageSize.value = toPositiveInteger(result?.pageSize, pageSize.value)
|
||||
updateSelection(reconcileApprovalTaskSelectionWithPage(
|
||||
selectedTaskIds.value,
|
||||
items.value
|
||||
))
|
||||
}
|
||||
|
||||
async function loadQueue(loadOptions = {}) {
|
||||
const requestSequence = ++listRequestSequence
|
||||
if (!loadOptions.silent) {
|
||||
loading.value = true
|
||||
}
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await services.fetchApprovalTasks({
|
||||
...filters.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}, loadOptions.requestOptions)
|
||||
if (requestSequence !== listRequestSequence) {
|
||||
return { ignored: true, result }
|
||||
}
|
||||
applyListResult(result)
|
||||
return { ignored: false, result }
|
||||
} catch (error) {
|
||||
if (requestSequence !== listRequestSequence) {
|
||||
return { ignored: true, error }
|
||||
}
|
||||
errorMessage.value = error?.message || '审批任务列表加载失败。'
|
||||
throw error
|
||||
} finally {
|
||||
if (requestSequence === listRequestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setFilters(nextFilters = {}, setOptions = {}) {
|
||||
const normalized = normalizeApprovalTaskQueueFilters({
|
||||
...filters.value,
|
||||
...nextFilters
|
||||
})
|
||||
selectionState.value = syncApprovalTaskSelectionFilters(
|
||||
selectionState.value,
|
||||
normalized
|
||||
)
|
||||
if (!selectionState.value.selectedTaskIds.length) {
|
||||
selectedSnapshots.clear()
|
||||
}
|
||||
filters.value = normalized
|
||||
page.value = 1
|
||||
return setOptions.reload === false ? Promise.resolve(null) : loadQueue()
|
||||
}
|
||||
|
||||
function setPage(nextPage, setOptions = {}) {
|
||||
page.value = toPositiveInteger(nextPage, 1)
|
||||
return setOptions.reload === false ? Promise.resolve(null) : loadQueue()
|
||||
}
|
||||
|
||||
function setPageSize(nextPageSize, setOptions = {}) {
|
||||
pageSize.value = toPositiveInteger(nextPageSize, pageSize.value)
|
||||
page.value = 1
|
||||
return setOptions.reload === false ? Promise.resolve(null) : loadQueue()
|
||||
}
|
||||
|
||||
function toggleTask(item, selected) {
|
||||
const nextSelection = toggleApprovalTaskSelection(selectedTaskIds.value, item, selected)
|
||||
if (nextSelection.length > MAX_BATCH_SELECTION) {
|
||||
errorMessage.value = `批量审批每次最多选择 ${MAX_BATCH_SELECTION} 项任务。`
|
||||
return
|
||||
}
|
||||
updateSelection(nextSelection)
|
||||
}
|
||||
|
||||
function toggleCurrentPage(selected) {
|
||||
const nextSelection = toggleCurrentPageApprovalSelection(
|
||||
selectedTaskIds.value,
|
||||
items.value,
|
||||
selected
|
||||
)
|
||||
if (nextSelection.length > MAX_BATCH_SELECTION) {
|
||||
errorMessage.value = `批量审批每次最多选择 ${MAX_BATCH_SELECTION} 项,已保留当前结果中的前 ${MAX_BATCH_SELECTION} 项。`
|
||||
updateSelection(nextSelection.slice(0, MAX_BATCH_SELECTION))
|
||||
return
|
||||
}
|
||||
updateSelection(nextSelection)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
updateSelection([])
|
||||
}
|
||||
|
||||
function replaceTaskItem(taskId, nextItem) {
|
||||
const index = items.value.findIndex((item) => taskIdOf(item) === taskId)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
if (!nextItem) {
|
||||
items.value = items.value.filter((_, itemIndex) => itemIndex !== index)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
} else {
|
||||
items.value = items.value.map((item, itemIndex) => itemIndex === index ? nextItem : item)
|
||||
}
|
||||
updateSelection(reconcileApprovalTaskSelectionWithPage(
|
||||
selectedTaskIds.value,
|
||||
items.value
|
||||
))
|
||||
}
|
||||
|
||||
async function refreshTask(taskId, refreshOptions = {}) {
|
||||
const normalizedTaskId = toText(taskId)
|
||||
if (!normalizedTaskId) {
|
||||
return null
|
||||
}
|
||||
const sequence = (taskRefreshSequences.get(normalizedTaskId) || 0) + 1
|
||||
taskRefreshSequences.set(normalizedTaskId, sequence)
|
||||
try {
|
||||
const nextItem = await services.fetchApprovalTaskDetail(
|
||||
normalizedTaskId,
|
||||
refreshOptions.requestOptions
|
||||
)
|
||||
if (taskRefreshSequences.get(normalizedTaskId) !== sequence) {
|
||||
return null
|
||||
}
|
||||
replaceTaskItem(normalizedTaskId, nextItem)
|
||||
return nextItem
|
||||
} catch (error) {
|
||||
if (taskRefreshSequences.get(normalizedTaskId) !== sequence) {
|
||||
return null
|
||||
}
|
||||
if (Number(error?.status) === 404) {
|
||||
replaceTaskItem(normalizedTaskId, null)
|
||||
return null
|
||||
}
|
||||
errorMessage.value = error?.message || '审批任务刷新失败。'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function batchApprove(batchOptions = {}) {
|
||||
if (batchBusy.value) {
|
||||
return null
|
||||
}
|
||||
const selectedItems = selectedTaskIds.value.map((taskId) => selectedSnapshots.get(taskId))
|
||||
if (!selectedItems.length || selectedItems.some((item) => !item)) {
|
||||
throw new Error('所选审批任务快照已失效,请刷新列表后重新选择。')
|
||||
}
|
||||
const opinion = toText(batchOptions.opinion)
|
||||
const payloadItems = selectedItems.map((item) => opinion ? { ...item, opinion } : item)
|
||||
const retryKey = resolveApprovalTaskRetryKey({
|
||||
action: 'batch-approve',
|
||||
scopeId: 'queue',
|
||||
payload: { items: payloadItems },
|
||||
previousFingerprint: pendingBatchFingerprint.value,
|
||||
previousRequestId: pendingBatchRequestId.value
|
||||
})
|
||||
pendingBatchRequestId.value = retryKey.requestId.slice(0, 80)
|
||||
pendingBatchFingerprint.value = retryKey.fingerprint
|
||||
batchBusy.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await services.batchApproveApprovalTasks({
|
||||
batchRequestId: pendingBatchRequestId.value,
|
||||
items: payloadItems
|
||||
}, batchOptions.requestOptions)
|
||||
lastBatchResult.value = result
|
||||
pendingBatchRequestId.value = ''
|
||||
pendingBatchFingerprint.value = ''
|
||||
updateSelection(reconcileApprovalTaskSelectionAfterBatch(
|
||||
selectedTaskIds.value,
|
||||
result
|
||||
))
|
||||
try {
|
||||
await loadQueue({ silent: true })
|
||||
} catch {
|
||||
// 批量结果已经落定,列表刷新失败由 errorMessage 明确展示,不覆盖真实结果。
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '批量审批失败,请刷新任务状态后重试。'
|
||||
throw error
|
||||
} finally {
|
||||
batchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearBatchResult() {
|
||||
lastBatchResult.value = null
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
batchApprove,
|
||||
batchBusy,
|
||||
clearBatchResult,
|
||||
clearError,
|
||||
clearSelection,
|
||||
errorMessage,
|
||||
filters,
|
||||
generatedAt,
|
||||
items,
|
||||
lastBatchResult,
|
||||
loadQueue,
|
||||
loading,
|
||||
page,
|
||||
pageSelection,
|
||||
pageSize,
|
||||
pendingBatchRequestId,
|
||||
refreshTask,
|
||||
selectedCount,
|
||||
selectedTaskIds,
|
||||
setFilters,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toggleCurrentPage,
|
||||
toggleTask,
|
||||
total,
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
43
web/src/composables/useDocumentCenterArchiveRows.js
Normal file
43
web/src/composables/useDocumentCenterArchiveRows.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchArchivedExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { buildDocumentRow } from '../utils/documentCenterViewModel.js'
|
||||
import { mapExpenseClaimToRequest } from './useRequests.js'
|
||||
|
||||
export function useDocumentCenterArchiveRows(options = {}) {
|
||||
const archiveRows = ref([])
|
||||
const supportingLoading = ref(false)
|
||||
const supportingError = ref('')
|
||||
let requestSequence = 0
|
||||
|
||||
async function loadSupportingRows() {
|
||||
const sequence = ++requestSequence
|
||||
supportingLoading.value = true
|
||||
supportingError.value = ''
|
||||
try {
|
||||
const payload = await fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
if (sequence !== requestSequence) return
|
||||
archiveRows.value = extractExpenseClaimItems(payload)
|
||||
.map(mapExpenseClaimToRequest)
|
||||
.map((item) => buildDocumentRow(item, {
|
||||
source: 'archive',
|
||||
archived: true,
|
||||
currentUser: options.currentUser?.value,
|
||||
viewedDocumentKeys: options.viewedDocumentKeys?.value
|
||||
}))
|
||||
.filter(Boolean)
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence) return
|
||||
archiveRows.value = []
|
||||
supportingError.value = error?.message || '归档数据加载失败。'
|
||||
} finally {
|
||||
if (sequence === requestSequence) supportingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { archiveRows, supportingError, supportingLoading, loadSupportingRows }
|
||||
}
|
||||
535
web/src/services/approvalTasks.js
Normal file
535
web/src/services/approvalTasks.js
Normal file
@@ -0,0 +1,535 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
const APPROVAL_TASKS_PATH = '/approval-tasks'
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const MAX_PAGE_SIZE = 200
|
||||
const DEFAULT_TIMEOUT_MS = 5000
|
||||
|
||||
function toObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : fallback
|
||||
}
|
||||
|
||||
function toInteger(value, fallback = 0) {
|
||||
return Math.trunc(toNumber(value, fallback))
|
||||
}
|
||||
|
||||
function toPositiveInteger(value, fallback = 1, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
return Math.min(maximum, Math.max(1, toInteger(value, fallback)))
|
||||
}
|
||||
|
||||
function toNullableObject(value) {
|
||||
const object = toObject(value)
|
||||
return Object.keys(object).length ? object : null
|
||||
}
|
||||
|
||||
function uniqueTexts(value) {
|
||||
return [...new Set(toArray(value).map(toText).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizePriorityReason(item = {}) {
|
||||
return {
|
||||
code: toText(item.code),
|
||||
label: toText(item.label),
|
||||
weight: toNumber(item.weight),
|
||||
tone: toText(item.tone || 'normal') || 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export function createApprovalTaskRequestId(action = 'action', taskId = 'task') {
|
||||
const normalizedAction = toText(action).replace(/[^a-z0-9_-]+/gi, '-') || 'action'
|
||||
const normalizedTaskId = toText(taskId).replace(/[^a-z0-9_-]+/gi, '-') || 'task'
|
||||
const suffix = typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return `approval:${normalizedAction}:${normalizedTaskId}:${suffix}`.slice(0, 120)
|
||||
}
|
||||
|
||||
export function normalizeApprovalTask(item = {}) {
|
||||
const task = toObject(item?.task || item)
|
||||
if (!toText(task.id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(task.id),
|
||||
tenantId: toText(task.tenant_id ?? task.tenantId),
|
||||
claimId: toText(task.claim_id ?? task.claimId),
|
||||
expenseCaseId: toText(task.expense_case_id ?? task.expenseCaseId),
|
||||
nodeInstanceId: toText(task.node_instance_id ?? task.nodeInstanceId),
|
||||
nodeEntryKey: toText(task.node_entry_key ?? task.nodeEntryKey),
|
||||
parentTaskId: toText(task.parent_task_id ?? task.parentTaskId),
|
||||
taskKind: toText(task.task_kind ?? task.taskKind ?? 'root') || 'root',
|
||||
nodeKey: toText(task.node_key ?? task.nodeKey),
|
||||
nodeLabel: toText(task.node_label ?? task.nodeLabel),
|
||||
nodeSequence: toInteger(task.node_sequence ?? task.nodeSequence),
|
||||
sequenceOrder: toInteger(task.sequence_order ?? task.sequenceOrder),
|
||||
coordinationMode: toText(task.coordination_mode ?? task.coordinationMode ?? 'single') || 'single',
|
||||
ownerKind: toText(task.owner_kind ?? task.ownerKind),
|
||||
ownerKey: toText(task.owner_key ?? task.ownerKey),
|
||||
ownerEmployeeId: toText(task.owner_employee_id ?? task.ownerEmployeeId),
|
||||
ownerName: toText(task.owner_name ?? task.ownerName),
|
||||
assigneeKind: toText(task.assignee_kind ?? task.assigneeKind),
|
||||
assigneeKey: toText(task.assignee_key ?? task.assigneeKey),
|
||||
assigneeEmployeeId: toText(task.assignee_employee_id ?? task.assigneeEmployeeId),
|
||||
assigneeName: toText(task.assignee_name ?? task.assigneeName),
|
||||
delegatedBy: toText(task.delegated_by ?? task.delegatedBy),
|
||||
delegationExpiresAt: toText(task.delegation_expires_at ?? task.delegationExpiresAt),
|
||||
status: toText(task.status),
|
||||
decision: toText(task.decision),
|
||||
opinion: toText(task.opinion),
|
||||
version: Math.max(0, toInteger(task.version)),
|
||||
claimStatusSnapshot: toText(task.claim_status_snapshot ?? task.claimStatusSnapshot),
|
||||
claimStageSnapshot: toText(task.claim_stage_snapshot ?? task.claimStageSnapshot),
|
||||
enteredAt: toText(task.entered_at ?? task.enteredAt),
|
||||
enteredAtSource: toText(task.entered_at_source ?? task.enteredAtSource),
|
||||
activatedAt: toText(task.activated_at ?? task.activatedAt),
|
||||
slaHoursSnapshot: Math.max(0, toInteger(task.sla_hours_snapshot ?? task.slaHoursSnapshot)),
|
||||
dueAt: toText(task.due_at ?? task.dueAt),
|
||||
completedAt: toText(task.completed_at ?? task.completedAt),
|
||||
cancelledAt: toText(task.cancelled_at ?? task.cancelledAt),
|
||||
escalationLevel: Math.max(0, toInteger(task.escalation_level ?? task.escalationLevel)),
|
||||
escalatedAt: toText(task.escalated_at ?? task.escalatedAt),
|
||||
nextEscalationAt: toText(task.next_escalation_at ?? task.nextEscalationAt),
|
||||
priorityScore: Math.max(0, Math.min(100, toNumber(task.priority_score ?? task.priorityScore))),
|
||||
priorityTier: toText(task.priority_tier ?? task.priorityTier ?? 'normal') || 'normal',
|
||||
priorityReasons: toArray(task.priority_reasons_json ?? task.priorityReasons)
|
||||
.map(normalizePriorityReason)
|
||||
.filter((reason) => reason.code || reason.label),
|
||||
riskLevel: toText(task.risk_level ?? task.riskLevel ?? 'low') || 'low',
|
||||
openRiskCount: Math.max(0, toInteger(task.open_risk_count ?? task.openRiskCount)),
|
||||
evidenceCompleteness: Math.max(
|
||||
0,
|
||||
Math.min(1, toNumber(task.evidence_completeness ?? task.evidenceCompleteness))
|
||||
),
|
||||
batchEligible: Boolean(task.batch_eligible ?? task.batchEligible),
|
||||
batchBlockReasons: uniqueTexts(task.batch_block_reasons_json ?? task.batchBlockReasons),
|
||||
projectionUpdatedAt: toText(task.projection_updated_at ?? task.projectionUpdatedAt),
|
||||
createdAt: toText(task.created_at ?? task.createdAt),
|
||||
updatedAt: toText(task.updated_at ?? task.updatedAt),
|
||||
canAct: Boolean(task.can_act ?? task.canAct),
|
||||
availableActions: uniqueTexts(task.available_actions ?? task.availableActions),
|
||||
readOnlyReason: toText(task.read_only_reason ?? task.readOnlyReason)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskQueueItem(item = {}) {
|
||||
const source = toObject(item)
|
||||
const task = normalizeApprovalTask(source.task || source)
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
task,
|
||||
claim: toNullableObject(source.claim),
|
||||
id: task.id,
|
||||
claimId: task.claimId
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskEvent(item = {}) {
|
||||
const event = toObject(item)
|
||||
if (!toText(event.id)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: toText(event.id),
|
||||
tenantId: toText(event.tenant_id ?? event.tenantId),
|
||||
taskId: toText(event.task_id ?? event.taskId),
|
||||
nodeInstanceId: toText(event.node_instance_id ?? event.nodeInstanceId),
|
||||
eventType: toText(event.event_type ?? event.eventType),
|
||||
actorId: toText(event.actor_id ?? event.actorId),
|
||||
actorName: toText(event.actor_name ?? event.actorName),
|
||||
actorType: toText(event.actor_type ?? event.actorType),
|
||||
requestId: toText(event.request_id ?? event.requestId),
|
||||
expectedTaskVersion: Math.max(
|
||||
0,
|
||||
toInteger(event.expected_task_version ?? event.expectedTaskVersion)
|
||||
),
|
||||
resultTaskVersion: Math.max(
|
||||
0,
|
||||
toInteger(event.result_task_version ?? event.resultTaskVersion)
|
||||
),
|
||||
payload: toObject(event.payload_json ?? event.payload),
|
||||
before: toObject(event.before_json ?? event.before),
|
||||
after: toObject(event.after_json ?? event.after),
|
||||
approvalActionLedgerId: toText(
|
||||
event.approval_action_ledger_id ?? event.approvalActionLedgerId
|
||||
),
|
||||
businessEventId: toText(event.business_event_id ?? event.businessEventId),
|
||||
correlationId: toText(event.correlation_id ?? event.correlationId),
|
||||
causationId: toText(event.causation_id ?? event.causationId),
|
||||
occurredAt: toText(event.occurred_at ?? event.occurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskMutation(payload = {}) {
|
||||
const envelope = toObject(payload)
|
||||
const mutation = toObject(envelope.mutation || envelope)
|
||||
return {
|
||||
task: normalizeApprovalTask(mutation.task),
|
||||
event: normalizeApprovalTaskEvent(mutation.event),
|
||||
relatedTasks: toArray(mutation.related_tasks ?? mutation.relatedTasks)
|
||||
.map(normalizeApprovalTask)
|
||||
.filter(Boolean),
|
||||
replayed: Boolean(mutation.replayed),
|
||||
claim: toNullableObject(envelope.claim ?? mutation.claim)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskList(payload = {}) {
|
||||
const source = toObject(payload)
|
||||
const items = toArray(source.items).map(normalizeApprovalTaskQueueItem).filter(Boolean)
|
||||
const total = Math.max(items.length, toInteger(source.total, items.length))
|
||||
const pageSize = toPositiveInteger(
|
||||
source.page_size ?? source.pageSize,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
MAX_PAGE_SIZE
|
||||
)
|
||||
const page = toPositiveInteger(source.page, 1)
|
||||
const totalPages = Math.max(
|
||||
total ? 1 : 0,
|
||||
toInteger(source.total_pages ?? source.totalPages, Math.ceil(total / pageSize))
|
||||
)
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
generatedAt: toText(source.generated_at ?? source.generatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskCandidate(item = {}) {
|
||||
const source = toObject(item)
|
||||
const employeeId = toText(source.employee_id ?? source.employeeId)
|
||||
if (!employeeId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
employeeId,
|
||||
employeeNo: toText(source.employee_no ?? source.employeeNo),
|
||||
name: toText(source.name),
|
||||
email: toText(source.email),
|
||||
qualified: Boolean(source.qualified),
|
||||
reason: toText(source.reason)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskCandidateList(payload = {}) {
|
||||
const source = Array.isArray(payload) ? { items: payload } : toObject(payload)
|
||||
const items = toArray(source.items).map(normalizeApprovalTaskCandidate).filter(Boolean)
|
||||
return {
|
||||
items,
|
||||
total: Math.max(items.length, toInteger(source.total, items.length))
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskBatchResult(payload = {}) {
|
||||
const source = toObject(payload)
|
||||
const items = toArray(source.items).map((item) => ({
|
||||
taskId: toText(item?.task_id ?? item?.taskId),
|
||||
claimId: toText(item?.claim_id ?? item?.claimId),
|
||||
status: toText(item?.status || 'failed') || 'failed',
|
||||
code: toText(item?.code),
|
||||
message: toText(item?.message),
|
||||
claim: toNullableObject(item?.claim)
|
||||
})).filter((item) => item.taskId)
|
||||
const countStatus = (status) => items.filter((item) => item.status === status).length
|
||||
return {
|
||||
batchRequestId: toText(source.batch_request_id ?? source.batchRequestId),
|
||||
status: toText(source.status || 'failed') || 'failed',
|
||||
succeededCount: Math.max(0, toInteger(source.succeeded_count ?? source.succeededCount, countStatus('succeeded'))),
|
||||
replayedCount: Math.max(0, toInteger(source.replayed_count ?? source.replayedCount, countStatus('replayed'))),
|
||||
conflictCount: Math.max(0, toInteger(source.conflict_count ?? source.conflictCount, countStatus('conflict'))),
|
||||
blockedCount: Math.max(0, toInteger(source.blocked_count ?? source.blockedCount, countStatus('blocked'))),
|
||||
forbiddenCount: Math.max(0, toInteger(source.forbidden_count ?? source.forbiddenCount, countStatus('forbidden'))),
|
||||
failedCount: Math.max(0, toInteger(source.failed_count ?? source.failedCount, countStatus('failed'))),
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
function appendQueryValue(search, key, value) {
|
||||
if (Array.isArray(value)) {
|
||||
value.map(toText).filter(Boolean).forEach((item) => search.append(key, item))
|
||||
return
|
||||
}
|
||||
const normalized = toText(value)
|
||||
if (normalized) {
|
||||
search.set(key, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function buildApprovalTaskListQuery(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
search.set('page', String(toPositiveInteger(params.page, 1)))
|
||||
search.set(
|
||||
'page_size',
|
||||
String(toPositiveInteger(params.pageSize ?? params.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE))
|
||||
)
|
||||
appendQueryValue(search, 'status', params.status)
|
||||
appendQueryValue(search, 'node_key', params.nodeKey ?? params.node_key)
|
||||
appendQueryValue(search, 'task_kind', params.taskKind ?? params.task_kind)
|
||||
appendQueryValue(search, 'risk_level', params.riskLevel ?? params.risk_level)
|
||||
appendQueryValue(search, 'sla_state', params.slaState ?? params.sla_state)
|
||||
appendQueryValue(search, 'keyword', params.keyword)
|
||||
appendQueryValue(search, 'sort', params.sort)
|
||||
appendQueryValue(search, 'assignee', params.assignee)
|
||||
if (typeof (params.batchEligible ?? params.batch_eligible) === 'boolean') {
|
||||
search.set(
|
||||
'batch_eligible',
|
||||
String(Boolean(params.batchEligible ?? params.batch_eligible))
|
||||
)
|
||||
}
|
||||
return search.toString()
|
||||
}
|
||||
|
||||
function requestOptions(options, timeoutMessage) {
|
||||
return {
|
||||
...options,
|
||||
timeoutMs: toNumber(options?.timeoutMs, DEFAULT_TIMEOUT_MS),
|
||||
timeoutMessage
|
||||
}
|
||||
}
|
||||
|
||||
function requireTaskId(taskId) {
|
||||
const normalized = toText(taskId)
|
||||
if (!normalized) {
|
||||
throw new Error('审批任务缺少任务标识。')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildBaseActionPayload(action, taskId, payload = {}) {
|
||||
const expectedTaskVersion = toInteger(
|
||||
payload.expectedTaskVersion ?? payload.expected_task_version
|
||||
)
|
||||
if (expectedTaskVersion < 1) {
|
||||
throw new Error('审批动作缺少有效的任务版本。')
|
||||
}
|
||||
const reason = toText(payload.reason)
|
||||
if (reason.length < 2) {
|
||||
throw new Error('审批动作需要填写至少 2 个字的原因。')
|
||||
}
|
||||
return {
|
||||
request_id: toText(payload.requestId ?? payload.request_id)
|
||||
|| createApprovalTaskRequestId(action, taskId),
|
||||
expected_task_version: expectedTaskVersion,
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssignmentActionPayload(action, taskId, payload) {
|
||||
const targetEmployeeId = toText(payload.targetEmployeeId ?? payload.target_employee_id)
|
||||
if (!targetEmployeeId) {
|
||||
throw new Error('审批任务需要选择目标员工。')
|
||||
}
|
||||
const body = {
|
||||
...buildBaseActionPayload(action, taskId, payload),
|
||||
target_employee_id: targetEmployeeId
|
||||
}
|
||||
const expiresAt = toText(payload.expiresAt ?? payload.expires_at)
|
||||
if (expiresAt) {
|
||||
body.expires_at = expiresAt
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function buildParticipantsActionPayload(action, taskId, payload) {
|
||||
const participantEmployeeIds = uniqueTexts(
|
||||
payload.participantEmployeeIds ?? payload.participant_employee_ids
|
||||
)
|
||||
if (!participantEmployeeIds.length) {
|
||||
throw new Error('审批任务需要至少选择一名参与人。')
|
||||
}
|
||||
return {
|
||||
...buildBaseActionPayload(action, taskId, payload),
|
||||
participant_employee_ids: participantEmployeeIds
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateApprovalTask(taskId, path, payload, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const response = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/${path}`,
|
||||
{
|
||||
...requestOptions(options, '审批任务操作超时,请刷新任务状态后重试。'),
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
)
|
||||
return normalizeApprovalTaskMutation(response)
|
||||
}
|
||||
|
||||
export async function fetchApprovalTasks(params = {}, options = {}) {
|
||||
const payload = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}?${buildApprovalTaskListQuery(params)}`,
|
||||
requestOptions(options, '审批任务列表加载超时,请稍后重试。')
|
||||
)
|
||||
return normalizeApprovalTaskList(payload)
|
||||
}
|
||||
|
||||
export async function fetchApprovalTaskDetail(taskId, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const payload = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}`,
|
||||
requestOptions(options, '审批任务详情加载超时,请稍后重试。')
|
||||
)
|
||||
return normalizeApprovalTaskQueueItem(payload)
|
||||
}
|
||||
|
||||
export async function fetchApprovalTaskCandidates(taskId, params = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const search = new URLSearchParams()
|
||||
appendQueryValue(search, 'action', params.action)
|
||||
appendQueryValue(search, 'keyword', params.keyword)
|
||||
search.set('limit', String(toPositiveInteger(params.limit, 20, 100)))
|
||||
const payload = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/candidates?${search}`,
|
||||
requestOptions(options, '审批候选人加载超时,请稍后重试。')
|
||||
)
|
||||
return normalizeApprovalTaskCandidateList(payload)
|
||||
}
|
||||
|
||||
export function delegateApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'delegate',
|
||||
buildAssignmentActionPayload('delegate', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function revokeApprovalTaskDelegation(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'delegation/revoke',
|
||||
buildBaseActionPayload('delegation-revoke', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function transferApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'transfer',
|
||||
buildAssignmentActionPayload('transfer', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function addSignApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'add-sign',
|
||||
buildParticipantsActionPayload('add-sign', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function countersignApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'countersign',
|
||||
buildParticipantsActionPayload('countersign', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function escalateApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'escalate',
|
||||
buildBaseActionPayload('sla-escalate', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function approveApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const body = buildBaseActionPayload('approve', normalizedTaskId, payload)
|
||||
const opinion = toText(payload.opinion)
|
||||
if (opinion) {
|
||||
body.opinion = opinion
|
||||
}
|
||||
return mutateApprovalTask(normalizedTaskId, 'approve', body, options)
|
||||
}
|
||||
|
||||
export function returnApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const body = {
|
||||
...buildBaseActionPayload('return', normalizedTaskId, payload),
|
||||
reason_codes: uniqueTexts(payload.reasonCodes ?? payload.reason_codes)
|
||||
}
|
||||
return mutateApprovalTask(normalizedTaskId, 'return', body, options)
|
||||
}
|
||||
|
||||
function normalizeBatchApproveItem(item = {}) {
|
||||
const source = toObject(item?.task || item)
|
||||
const taskId = toText(item.task_id ?? item.taskId ?? source.id)
|
||||
const expectedTaskVersion = toInteger(
|
||||
item.expected_task_version ?? item.expectedTaskVersion ?? source.version
|
||||
)
|
||||
const expectedStatus = toText(
|
||||
item.expected_status ?? item.expectedStatus
|
||||
?? source.claim_status_snapshot ?? source.claimStatusSnapshot
|
||||
)
|
||||
const expectedApprovalStage = toText(
|
||||
item.expected_approval_stage ?? item.expectedApprovalStage
|
||||
?? source.claim_stage_snapshot ?? source.claimStageSnapshot
|
||||
)
|
||||
if (!taskId || expectedTaskVersion < 1 || !expectedStatus || !expectedApprovalStage) {
|
||||
throw new Error('批量审批项缺少任务、版本、单据状态或审批节点快照。')
|
||||
}
|
||||
const body = {
|
||||
task_id: taskId,
|
||||
expected_task_version: expectedTaskVersion,
|
||||
expected_status: expectedStatus,
|
||||
expected_approval_stage: expectedApprovalStage
|
||||
}
|
||||
const opinion = toText(item.opinion)
|
||||
if (opinion) {
|
||||
body.opinion = opinion
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export async function batchApproveApprovalTasks(payload = {}, options = {}) {
|
||||
const items = toArray(payload.items).map(normalizeBatchApproveItem)
|
||||
if (!items.length || items.length > 20) {
|
||||
throw new Error('批量审批每次需要选择 1 至 20 项任务。')
|
||||
}
|
||||
if (new Set(items.map((item) => item.task_id)).size !== items.length) {
|
||||
throw new Error('批量审批不能包含重复任务。')
|
||||
}
|
||||
const batchRequestId = toText(payload.batchRequestId ?? payload.batch_request_id)
|
||||
|| createApprovalTaskRequestId('batch-approve', 'batch').slice(0, 80)
|
||||
const response = await apiRequest(`${APPROVAL_TASKS_PATH}/batch-approve`, {
|
||||
...requestOptions(options, '批量审批超时,请刷新任务状态后查看实际结果。'),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
batch_request_id: batchRequestId,
|
||||
items
|
||||
})
|
||||
})
|
||||
return normalizeApprovalTaskBatchResult(response)
|
||||
}
|
||||
@@ -13,6 +13,14 @@ function toObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function uniqueTexts(value, limit = Number.POSITIVE_INFINITY) {
|
||||
return [...new Set(
|
||||
toArray(value)
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
)].slice(0, limit)
|
||||
}
|
||||
|
||||
export function createRiskDispositionRequestId(action, observationId) {
|
||||
const normalizedAction = String(action || 'action').trim() || 'action'
|
||||
const normalizedObservationId = String(observationId || 'risk').trim() || 'risk'
|
||||
@@ -35,14 +43,48 @@ export function normalizeRiskDisposition(item = {}) {
|
||||
assignee: String(item.assignee || '').trim(),
|
||||
dueAt: String(item.due_at || item.dueAt || '').trim(),
|
||||
resolution: String(item.resolution || '').trim(),
|
||||
availableActions: uniqueTexts(item.available_actions ?? item.availableActions),
|
||||
readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(),
|
||||
waiverRequesterId: String(
|
||||
item.waiver_requester_id || item.waiverRequesterId || ''
|
||||
).trim(),
|
||||
waiverRequesterName: String(
|
||||
item.waiver_requester_name || item.waiverRequesterName || ''
|
||||
).trim(),
|
||||
waiverRequestedAt: String(
|
||||
item.waiver_requested_at || item.waiverRequestedAt || ''
|
||||
).trim(),
|
||||
waiverReason: String(item.waiver_reason || item.waiverReason || '').trim(),
|
||||
waiverScope: String(item.waiver_scope || item.waiverScope || '').trim(),
|
||||
waiverExpiresAt: String(
|
||||
item.waiver_expires_at || item.waiverExpiresAt || ''
|
||||
).trim(),
|
||||
waiverConditions: uniqueTexts(
|
||||
item.waiver_conditions_json
|
||||
?? item.waiver_conditions
|
||||
?? item.waiverConditions
|
||||
),
|
||||
waiverDecision: String(item.waiver_decision || item.waiverDecision || '').trim(),
|
||||
waiverDeciderId: String(item.waiver_decider_id || item.waiverDeciderId || '').trim(),
|
||||
waiverDeciderName: String(
|
||||
item.waiver_decider_name || item.waiverDeciderName || ''
|
||||
).trim(),
|
||||
waiverDecidedAt: String(item.waiver_decided_at || item.waiverDecidedAt || '').trim(),
|
||||
waiverDecisionReason: String(
|
||||
item.waiver_decision_reason || item.waiverDecisionReason || ''
|
||||
).trim(),
|
||||
createdAt: String(item.created_at || item.createdAt || '').trim(),
|
||||
updatedAt: String(item.updated_at || item.updatedAt || '').trim(),
|
||||
events: toArray(item.events).map((event) => ({
|
||||
id: String(event?.id || '').trim(),
|
||||
action: String(event?.action || '').trim(),
|
||||
version: Math.max(0, toNumber(event?.version)),
|
||||
actorId: String(event?.actor_id || event?.actorId || '').trim(),
|
||||
actorName: String(event?.actor_name || event?.actorName || '').trim(),
|
||||
requestId: String(event?.request_id || event?.requestId || '').trim(),
|
||||
comment: String(event?.comment || '').trim(),
|
||||
beforeState: toObject(event?.before_json || event?.beforeState),
|
||||
afterState: toObject(event?.after_json || event?.afterState),
|
||||
createdAt: String(event?.created_at || event?.createdAt || '').trim()
|
||||
}))
|
||||
}
|
||||
@@ -91,6 +133,8 @@ export function normalizeRiskObservation(item = {}) {
|
||||
),
|
||||
feedbackItems: toArray(item.feedback_items || item.feedbackItems),
|
||||
disposition: normalizeRiskDisposition(item.disposition),
|
||||
availableActions: uniqueTexts(item.available_actions ?? item.availableActions),
|
||||
readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(),
|
||||
createdAt: String(item.created_at || item.createdAt || '').trim(),
|
||||
updatedAt: String(item.updated_at || item.updatedAt || '').trim()
|
||||
}
|
||||
@@ -211,10 +255,23 @@ export async function executeRiskDispositionAction(observationId, payload = {})
|
||||
const resolution = String(payload.resolution || '').trim()
|
||||
const assignee = String(payload.assignee || '').trim()
|
||||
const dueAt = String(payload.dueAt || payload.due_at || '').trim()
|
||||
const waiverReason = String(payload.waiverReason || payload.waiver_reason || '').trim()
|
||||
const waiverScope = String(payload.waiverScope || payload.waiver_scope || '').trim()
|
||||
const waiverExpiresAt = String(
|
||||
payload.waiverExpiresAt || payload.waiver_expires_at || ''
|
||||
).trim()
|
||||
const waiverConditions = uniqueTexts(
|
||||
payload.waiverConditions ?? payload.waiver_conditions,
|
||||
20
|
||||
)
|
||||
if (comment) body.comment = comment
|
||||
if (resolution) body.resolution = resolution
|
||||
if (assignee) body.assignee = assignee
|
||||
if (dueAt) body.due_at = dueAt
|
||||
if (waiverReason) body.waiver_reason = waiverReason
|
||||
if (waiverScope) body.waiver_scope = waiverScope
|
||||
if (waiverExpiresAt) body.waiver_expires_at = waiverExpiresAt
|
||||
if (waiverConditions.length) body.waiver_conditions = waiverConditions
|
||||
|
||||
const result = await apiRequest(
|
||||
`/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`,
|
||||
@@ -232,3 +289,55 @@ export async function executeRiskDispositionAction(observationId, payload = {})
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRiskDispositionError(error) {
|
||||
const code = String(error?.code || '').trim()
|
||||
const status = Number(error?.status || 0)
|
||||
const serverMessage = String(error?.message || '').trim()
|
||||
if (code === 'RISK_DISPOSITION_VERSION_CONFLICT') {
|
||||
return {
|
||||
code,
|
||||
title: '风险状态已更新',
|
||||
message: serverMessage || '其他处理人已更新该风险,请刷新后重新确认。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (code === 'RISK_WAIVER_DECISION_FORBIDDEN' || status === 403) {
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_FORBIDDEN',
|
||||
title: '当前账号不能执行此操作',
|
||||
message: serverMessage || '服务端未授予当前风险处置动作。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (code === 'REQUEST_TIMEOUT') {
|
||||
return {
|
||||
code,
|
||||
title: '提交结果暂未确认',
|
||||
message: serverMessage || '请求已超时,请刷新状态后再决定是否重试。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (status === 409) {
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_CONFLICT',
|
||||
title: '当前状态不允许该操作',
|
||||
message: serverMessage || '风险处置状态发生冲突,请刷新后重试。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (status === 422) {
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_VALIDATION_FAILED',
|
||||
title: '提交内容不完整',
|
||||
message: serverMessage || '请检查必填项、有效期和输入长度。',
|
||||
shouldRefresh: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_REQUEST_FAILED',
|
||||
title: '风险处置提交失败',
|
||||
message: serverMessage || '请稍后重试;如持续失败,请联系系统管理员。',
|
||||
shouldRefresh: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
|
||||
'dc_scene',
|
||||
'dc_q',
|
||||
'dc_start',
|
||||
'dc_end'
|
||||
'dc_end',
|
||||
'dc_review_page',
|
||||
'dc_review_page_size',
|
||||
'dc_review_q',
|
||||
'dc_review_risk',
|
||||
'dc_review_sla'
|
||||
])
|
||||
export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
|
||||
export const RISK_TONE_META = {
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
@update:active-range="activeRange = $event"
|
||||
@update:custom-range="customRange = $event"
|
||||
@update:overview-dashboard="overviewDashboard = $event"
|
||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||
@new-application="openExpenseApplicationCreate"
|
||||
@open-document="openWorkbenchDocument"
|
||||
@navigate="handleNavigate"
|
||||
@@ -183,6 +182,7 @@
|
||||
@create-request="openTravelCreate"
|
||||
@create-application="openExpenseApplicationCreate"
|
||||
@reload="reloadRequests"
|
||||
@request-updated="handleRequestUpdated"
|
||||
@summary-change="documentSummary = $event"
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,14 +11,27 @@
|
||||
>
|
||||
<span class="scope-tab-label">
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
|
||||
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" :aria-label="tab.badgeLabel">
|
||||
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="document-toolbar">
|
||||
<ApprovalTaskWorkspace
|
||||
v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"
|
||||
:initial-filters="approvalTaskRouteState.filters"
|
||||
:initial-page="approvalTaskRouteState.page"
|
||||
:initial-page-size="approvalTaskRouteState.pageSize"
|
||||
:refresh-token="refreshToken"
|
||||
@open-document="emit('open-document', $event)"
|
||||
@request-updated="emit('request-updated', $event)"
|
||||
@loaded="handleApprovalTasksLoaded"
|
||||
@state-change="handleApprovalTaskStateChange"
|
||||
/>
|
||||
|
||||
<template v-if="activeScopeTab !== DOCUMENT_SCOPE_REVIEW">
|
||||
<div class="document-toolbar">
|
||||
<div class="filter-set">
|
||||
<div class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
@@ -143,9 +156,9 @@
|
||||
<span>发起报销</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="showLoading" class="table-state">
|
||||
<TableLoadingState
|
||||
title="单据数据同步中"
|
||||
@@ -235,18 +248,19 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnterprisePagination
|
||||
v-if="showTable"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalPages"
|
||||
@page-size-change="changePageSize"
|
||||
@update:current-page="currentPage = $event"
|
||||
/>
|
||||
<EnterprisePagination
|
||||
v-if="showTable"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalPages"
|
||||
@page-size-change="changePageSize"
|
||||
@update:current-page="currentPage = $event"
|
||||
/>
|
||||
</template>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -254,19 +268,19 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ApprovalTaskWorkspace from '../components/approval/ApprovalTaskWorkspace.vue'
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { useDocumentCenterArchiveRows } from '../composables/useDocumentCenterArchiveRows.js'
|
||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchArchivedExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
|
||||
import {
|
||||
appendApprovalTaskRouteQuery,
|
||||
normalizeApprovalTaskRouteState,
|
||||
readApprovalTaskRouteState
|
||||
} from './scripts/approvalTaskRouteState.js'
|
||||
import {
|
||||
buildDocumentViewedStatePatch,
|
||||
buildDocumentsViewedStatePatches,
|
||||
@@ -303,6 +317,7 @@ const emit = defineEmits([
|
||||
'create-request',
|
||||
'create-application',
|
||||
'reload',
|
||||
'request-updated',
|
||||
'summary-change'
|
||||
])
|
||||
|
||||
@@ -353,9 +368,12 @@ function buildDocumentCenterRouteQuery() {
|
||||
}
|
||||
})
|
||||
|
||||
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
|
||||
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
|
||||
return appendApprovalTaskRouteQuery(nextQuery, approvalTaskRouteState.value)
|
||||
}
|
||||
if (currentPage.value > 1) nextQuery.dc_page = String(currentPage.value)
|
||||
if (pageSize.value !== 20) nextQuery.dc_page_size = String(pageSize.value)
|
||||
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
|
||||
if (activeStatusTab.value !== '全部') nextQuery.dc_status = activeStatusTab.value
|
||||
if (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL) {
|
||||
nextQuery.dc_doc_type = activeDocumentType.value
|
||||
@@ -369,6 +387,7 @@ function buildDocumentCenterRouteQuery() {
|
||||
}
|
||||
|
||||
const initialScopeTab = resolveInitialScopeTab()
|
||||
const approvalTaskRouteState = ref(readApprovalTaskRouteState(route.query))
|
||||
const initialAppliedStart = readDocumentCenterQueryText('dc_start')
|
||||
const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
|
||||
const activeScopeTab = ref(initialScopeTab)
|
||||
@@ -384,11 +403,14 @@ const appliedStart = ref(initialAppliedStart)
|
||||
const appliedEnd = ref(initialAppliedEnd)
|
||||
const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1))
|
||||
const pageSize = ref(resolveInitialPageSize())
|
||||
const archiveRows = ref([])
|
||||
const approvalRows = ref([])
|
||||
const supportingLoading = ref(false)
|
||||
const supportingError = ref('')
|
||||
const approvalTaskTotal = ref(0)
|
||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||
const {
|
||||
archiveRows,
|
||||
supportingLoading,
|
||||
supportingError,
|
||||
loadSupportingRows
|
||||
} = useDocumentCenterArchiveRows({ currentUser, viewedDocumentKeys })
|
||||
const activeFilterConfig = computed(() =>
|
||||
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
|
||||
)
|
||||
@@ -423,14 +445,14 @@ const ownedRows = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
|
||||
const nonArchivedRows = computed(() => mergeDocumentRows(ownedRows.value))
|
||||
const applicationScopeRows = computed(() => prepareApplicationScopeRows(ownedRows.value))
|
||||
|
||||
const scopeNewCountMap = computed(() => ({
|
||||
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(filterApplicationScopeNewRows(applicationScopeRows.value), viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_REVIEW]: approvalTaskTotal.value,
|
||||
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
|
||||
}))
|
||||
|
||||
@@ -438,14 +460,14 @@ const scopeTabItems = computed(() =>
|
||||
scopeTabs.map((tab) => ({
|
||||
value: tab,
|
||||
label: tab,
|
||||
badgeCount: scopeNewCountMap.value[tab] || 0
|
||||
badgeCount: scopeNewCountMap.value[tab] || 0,
|
||||
badgeLabel: tab === DOCUMENT_SCOPE_REVIEW ? '待处理审批任务数' : '新增单据数'
|
||||
}))
|
||||
)
|
||||
const allReadableDocumentRows = computed(() => [
|
||||
...nonArchivedRows.value,
|
||||
...filterApplicationScopeNewRows(applicationScopeRows.value),
|
||||
...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT),
|
||||
...approvalRows.value
|
||||
...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
|
||||
])
|
||||
const totalNewDocumentCount = computed(() => countNewDocuments(allReadableDocumentRows.value, viewedDocumentKeys.value))
|
||||
const showCreateDocumentActions = computed(() =>
|
||||
@@ -464,10 +486,6 @@ const activeScopeRows = computed(() => {
|
||||
return ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
|
||||
}
|
||||
|
||||
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
|
||||
return approvalRows.value
|
||||
}
|
||||
|
||||
if (activeScopeTab.value === DOCUMENT_SCOPE_ARCHIVE) {
|
||||
return archiveRows.value
|
||||
}
|
||||
@@ -529,16 +547,14 @@ const showError = computed(() => Boolean(props.error) && !visibleRows.value.leng
|
||||
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
|
||||
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)
|
||||
const showTable = computed(() => !showLoading.value && !showError.value && visibleRows.value.length > 0)
|
||||
const showStayTimeColumn = computed(() =>
|
||||
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REVIEW].includes(activeScopeTab.value)
|
||||
)
|
||||
const showStayTimeColumn = computed(() => activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION)
|
||||
|
||||
const documentSummary = computed(() => {
|
||||
const rows = nonArchivedRows.value
|
||||
return {
|
||||
total: rows.length,
|
||||
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
|
||||
toProcess: approvalRows.value.length,
|
||||
toProcess: approvalTaskTotal.value,
|
||||
archived: archiveRows.value.length
|
||||
}
|
||||
})
|
||||
@@ -680,48 +696,12 @@ function markAllDocumentsRead() {
|
||||
void syncDocumentViewedPatches(viewedPatches)
|
||||
}
|
||||
|
||||
async function loadSupportingRows() {
|
||||
supportingLoading.value = true
|
||||
supportingError.value = ''
|
||||
function handleApprovalTasksLoaded(result = {}) {
|
||||
approvalTaskTotal.value = Math.max(0, Number(result.total) || 0)
|
||||
}
|
||||
|
||||
const [approvalResult, archiveResult] = await Promise.allSettled([
|
||||
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
|
||||
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
])
|
||||
|
||||
if (approvalResult.status === 'fulfilled') {
|
||||
approvalRows.value = excludeArchivedDocumentRows(
|
||||
extractExpenseClaimItems(approvalResult.value)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, {
|
||||
source: 'approval',
|
||||
currentUser: currentUser.value,
|
||||
viewedDocumentKeys: viewedDocumentKeys.value
|
||||
}))
|
||||
.filter(Boolean)
|
||||
)
|
||||
} else {
|
||||
approvalRows.value = []
|
||||
}
|
||||
|
||||
if (archiveResult.status === 'fulfilled') {
|
||||
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, {
|
||||
source: 'archive',
|
||||
archived: true,
|
||||
currentUser: currentUser.value,
|
||||
viewedDocumentKeys: viewedDocumentKeys.value
|
||||
}))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
archiveRows.value = []
|
||||
supportingError.value = archiveResult.reason instanceof Error
|
||||
? archiveResult.reason.message
|
||||
: '归档数据加载失败。'
|
||||
}
|
||||
|
||||
supportingLoading.value = false
|
||||
function handleApprovalTaskStateChange(state = {}) {
|
||||
approvalTaskRouteState.value = normalizeApprovalTaskRouteState(state)
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
@@ -738,7 +718,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
|
||||
[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd, approvalTaskRouteState],
|
||||
() => {
|
||||
if (route.name !== 'app-documents') {
|
||||
return
|
||||
@@ -752,6 +732,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(activeFilterConfig, () => {
|
||||
writeDocumentScope(activeScopeTab.value, scopeTabs)
|
||||
openFilterKey.value = ''
|
||||
datePopover.value = false
|
||||
|
||||
|
||||
191
web/src/views/scripts/approvalTaskQueueState.js
Normal file
191
web/src/views/scripts/approvalTaskQueueState.js
Normal file
@@ -0,0 +1,191 @@
|
||||
function toArray(value) {
|
||||
if (value instanceof Set) {
|
||||
return [...value]
|
||||
}
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function uniqueTaskIds(value) {
|
||||
return [...new Set(toArray(value).map(toText).filter(Boolean))]
|
||||
}
|
||||
|
||||
function unwrapTask(item = {}) {
|
||||
return item?.task && typeof item.task === 'object' ? item.task : item
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskId(item = {}) {
|
||||
return toText(unwrapTask(item)?.id ?? item?.task_id ?? item?.taskId)
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskSelectionMeta(item = {}) {
|
||||
const task = unwrapTask(item) || {}
|
||||
const taskId = resolveApprovalTaskId(item)
|
||||
const readOnlyReason = toText(task.readOnlyReason ?? task.read_only_reason)
|
||||
const batchBlockReasons = toArray(
|
||||
task.batchBlockReasons ?? task.batch_block_reasons_json
|
||||
).map(toText).filter(Boolean)
|
||||
const availableActions = new Set(
|
||||
toArray(task.availableActions ?? task.available_actions).map(toText).filter(Boolean)
|
||||
)
|
||||
|
||||
if (!taskId) {
|
||||
return { taskId: '', selectable: false, reason: '审批任务缺少任务标识。' }
|
||||
}
|
||||
if ((task.canAct ?? task.can_act) !== true) {
|
||||
return {
|
||||
taskId,
|
||||
selectable: false,
|
||||
reason: readOnlyReason || '当前用户没有处理该审批任务的权限。'
|
||||
}
|
||||
}
|
||||
if (toText(task.status).toLowerCase() !== 'pending') {
|
||||
return { taskId, selectable: false, reason: '该审批任务已不在待处理状态。' }
|
||||
}
|
||||
if (!availableActions.has('approve')) {
|
||||
return {
|
||||
taskId,
|
||||
selectable: false,
|
||||
reason: readOnlyReason || '当前任务不允许执行审批通过。'
|
||||
}
|
||||
}
|
||||
if ((task.batchEligible ?? task.batch_eligible) !== true) {
|
||||
return {
|
||||
taskId,
|
||||
selectable: false,
|
||||
reason: batchBlockReasons.join(';') || '该任务需要单独核对,不能批量审批。'
|
||||
}
|
||||
}
|
||||
if (Number(task.version) < 1) {
|
||||
return { taskId, selectable: false, reason: '任务版本无效,请刷新后重试。' }
|
||||
}
|
||||
return { taskId, selectable: true, reason: '' }
|
||||
}
|
||||
|
||||
export function isApprovalTaskSelectable(item = {}) {
|
||||
return resolveApprovalTaskSelectionMeta(item).selectable
|
||||
}
|
||||
|
||||
export function resolveCurrentPageApprovalSelection(items = [], selectedTaskIds = []) {
|
||||
const eligibleIds = toArray(items)
|
||||
.map(resolveApprovalTaskSelectionMeta)
|
||||
.filter((meta) => meta.selectable)
|
||||
.map((meta) => meta.taskId)
|
||||
const selectedSet = new Set(uniqueTaskIds(selectedTaskIds))
|
||||
const selectedEligibleIds = eligibleIds.filter((taskId) => selectedSet.has(taskId))
|
||||
const allSelected = eligibleIds.length > 0 && selectedEligibleIds.length === eligibleIds.length
|
||||
const someSelected = selectedEligibleIds.length > 0
|
||||
return {
|
||||
eligibleIds,
|
||||
selectedEligibleIds,
|
||||
eligibleCount: eligibleIds.length,
|
||||
selectedCount: selectedEligibleIds.length,
|
||||
allSelected,
|
||||
someSelected,
|
||||
indeterminate: someSelected && !allSelected
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleApprovalTaskSelection(selectedTaskIds, item, selected) {
|
||||
const current = uniqueTaskIds(selectedTaskIds)
|
||||
const currentSet = new Set(current)
|
||||
const meta = resolveApprovalTaskSelectionMeta(item)
|
||||
if (!meta.taskId) {
|
||||
return current
|
||||
}
|
||||
|
||||
const shouldSelect = typeof selected === 'boolean' ? selected : !currentSet.has(meta.taskId)
|
||||
if (!shouldSelect) {
|
||||
currentSet.delete(meta.taskId)
|
||||
return [...currentSet]
|
||||
}
|
||||
if (!meta.selectable) {
|
||||
return current
|
||||
}
|
||||
currentSet.add(meta.taskId)
|
||||
return [...currentSet]
|
||||
}
|
||||
|
||||
export function toggleCurrentPageApprovalSelection(selectedTaskIds, items = [], selected) {
|
||||
const current = uniqueTaskIds(selectedTaskIds)
|
||||
const currentSet = new Set(current)
|
||||
const pageState = resolveCurrentPageApprovalSelection(items, current)
|
||||
const pageTaskIds = new Set(toArray(items).map(resolveApprovalTaskId).filter(Boolean))
|
||||
const shouldSelect = typeof selected === 'boolean' ? selected : !pageState.allSelected
|
||||
|
||||
if (shouldSelect) {
|
||||
pageState.eligibleIds.forEach((taskId) => currentSet.add(taskId))
|
||||
} else {
|
||||
pageTaskIds.forEach((taskId) => currentSet.delete(taskId))
|
||||
}
|
||||
return [...currentSet]
|
||||
}
|
||||
|
||||
export function reconcileApprovalTaskSelectionWithPage(selectedTaskIds, items = []) {
|
||||
const current = uniqueTaskIds(selectedTaskIds)
|
||||
const eligibleIds = new Set(
|
||||
toArray(items)
|
||||
.map(resolveApprovalTaskSelectionMeta)
|
||||
.filter((meta) => meta.selectable)
|
||||
.map((meta) => meta.taskId)
|
||||
)
|
||||
const pageIds = new Set(toArray(items).map(resolveApprovalTaskId).filter(Boolean))
|
||||
return current.filter((taskId) => !pageIds.has(taskId) || eligibleIds.has(taskId))
|
||||
}
|
||||
|
||||
function stableFilterValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableFilterValue).sort((left, right) => String(left).localeCompare(String(right)))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, stableFilterValue(value[key])])
|
||||
)
|
||||
}
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
export function buildApprovalTaskFilterSignature(filters = {}) {
|
||||
const ignoredKeys = new Set(['page', 'pageSize', 'page_size'])
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(filters || {})
|
||||
.filter(([key]) => !ignoredKeys.has(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, stableFilterValue(value)])
|
||||
)
|
||||
return JSON.stringify(normalized)
|
||||
}
|
||||
|
||||
export function createApprovalTaskSelectionState(options = {}) {
|
||||
return {
|
||||
selectedTaskIds: uniqueTaskIds(options.selectedTaskIds),
|
||||
filterSignature: buildApprovalTaskFilterSignature(options.filters)
|
||||
}
|
||||
}
|
||||
|
||||
export function syncApprovalTaskSelectionFilters(state = {}, filters = {}) {
|
||||
const nextSignature = buildApprovalTaskFilterSignature(filters)
|
||||
const currentSignature = toText(state.filterSignature)
|
||||
return {
|
||||
...state,
|
||||
selectedTaskIds: currentSignature === nextSignature
|
||||
? uniqueTaskIds(state.selectedTaskIds)
|
||||
: [],
|
||||
filterSignature: nextSignature
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileApprovalTaskSelectionAfterBatch(selectedTaskIds, batchResult = {}) {
|
||||
const completedTaskIds = new Set(
|
||||
toArray(batchResult.items)
|
||||
.filter((item) => ['succeeded', 'replayed'].includes(toText(item?.status).toLowerCase()))
|
||||
.map((item) => toText(item?.taskId ?? item?.task_id))
|
||||
.filter(Boolean)
|
||||
)
|
||||
return uniqueTaskIds(selectedTaskIds).filter((taskId) => !completedTaskIds.has(taskId))
|
||||
}
|
||||
181
web/src/views/scripts/approvalTaskQueueViewModel.js
Normal file
181
web/src/views/scripts/approvalTaskQueueViewModel.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const RISK_LABELS = {
|
||||
critical: '重大风险',
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险'
|
||||
}
|
||||
|
||||
const ACTION_METADATA = {
|
||||
approve: { label: '审批通过', tone: 'primary', icon: 'mdi mdi-check-circle-outline' },
|
||||
return: { label: '退回', tone: 'danger', icon: 'mdi mdi-undo' },
|
||||
delegate: { label: '委托', tone: 'neutral', icon: 'mdi mdi-account-arrow-right-outline' },
|
||||
delegation_revoke: { label: '撤销委托', tone: 'neutral', icon: 'mdi mdi-account-cancel-outline' },
|
||||
transfer: { label: '转交', tone: 'warning', icon: 'mdi mdi-swap-horizontal' },
|
||||
add_sign: { label: '加签', tone: 'neutral', icon: 'mdi mdi-account-plus-outline' },
|
||||
countersign: { label: '会签', tone: 'neutral', icon: 'mdi mdi-account-group-outline' },
|
||||
sla_escalate: { label: '立即升级', tone: 'warning', icon: 'mdi mdi-arrow-up-bold-circle-outline' }
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : fallback
|
||||
}
|
||||
|
||||
export function unwrapApprovalTaskQueueItem(item = {}) {
|
||||
return item?.task && typeof item.task === 'object' ? item.task : item
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskClaim(item = {}) {
|
||||
return item?.claim && typeof item.claim === 'object' ? item.claim : {}
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskReadOnlyReason(item = {}) {
|
||||
const task = unwrapApprovalTaskQueueItem(item)
|
||||
const explicit = toText(task.readOnlyReason ?? task.read_only_reason)
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
const blockers = toArray(task.batchBlockReasons ?? task.batch_block_reasons_json)
|
||||
.map(toText)
|
||||
.filter(Boolean)
|
||||
return blockers.join(';')
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskActionItems(item = {}) {
|
||||
const task = unwrapApprovalTaskQueueItem(item)
|
||||
const canDecide = (task.canAct ?? task.can_act) === true
|
||||
return toArray(task.availableActions ?? task.available_actions)
|
||||
.map(toText)
|
||||
.filter((action, index, values) => action && values.indexOf(action) === index)
|
||||
.filter((action) => canDecide || !['approve', 'return'].includes(action))
|
||||
.map((action) => ACTION_METADATA[action] ? { action, ...ACTION_METADATA[action] } : null)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskSlaMeta(item = {}, now = Date.now()) {
|
||||
const task = unwrapApprovalTaskQueueItem(item)
|
||||
const dueAtText = toText(task.dueAt ?? task.due_at)
|
||||
const dueAt = dueAtText ? new Date(dueAtText) : null
|
||||
const dueTime = dueAt && !Number.isNaN(dueAt.getTime()) ? dueAt.getTime() : null
|
||||
const escalationLevel = Math.max(0, Math.trunc(toNumber(
|
||||
task.escalationLevel ?? task.escalation_level
|
||||
)))
|
||||
const nextEscalationAt = toText(task.nextEscalationAt ?? task.next_escalation_at)
|
||||
|
||||
if (dueTime === null) {
|
||||
return {
|
||||
label: '未配置',
|
||||
tone: 'neutral',
|
||||
overdue: false,
|
||||
nearDue: false,
|
||||
escalationLevel,
|
||||
escalationLabel: escalationLevel ? `L${escalationLevel}` : '',
|
||||
title: '当前节点未返回 SLA 截止时间。',
|
||||
dueAt: '',
|
||||
nextEscalationAt
|
||||
}
|
||||
}
|
||||
|
||||
const diffMs = dueTime - Number(now)
|
||||
const overdue = diffMs <= 0
|
||||
const absoluteMinutes = Math.max(1, Math.ceil(Math.abs(diffMs) / 60000))
|
||||
const hours = absoluteMinutes / 60
|
||||
const durationLabel = hours >= 1
|
||||
? `${hours >= 10 ? Math.round(hours) : hours.toFixed(1)}h`
|
||||
: `${absoluteMinutes}m`
|
||||
const nearDue = !overdue && diffMs <= 2 * 60 * 60 * 1000
|
||||
const label = overdue ? `超时 ${durationLabel}` : `剩余 ${durationLabel}`
|
||||
const tone = overdue ? 'danger' : nearDue ? 'warning' : 'safe'
|
||||
const escalationLabel = escalationLevel ? `L${escalationLevel}` : ''
|
||||
const titleParts = [
|
||||
`节点截止时间:${dueAtText}`,
|
||||
escalationLabel ? `当前升级等级:${escalationLabel}` : '',
|
||||
nextEscalationAt ? `下次升级:${nextEscalationAt}` : ''
|
||||
].filter(Boolean)
|
||||
return {
|
||||
label,
|
||||
tone,
|
||||
overdue,
|
||||
nearDue,
|
||||
escalationLevel,
|
||||
escalationLabel,
|
||||
title: titleParts.join(';'),
|
||||
dueAt: dueAtText,
|
||||
nextEscalationAt
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskRow(item = {}, now = Date.now()) {
|
||||
const task = unwrapApprovalTaskQueueItem(item)
|
||||
const claim = resolveApprovalTaskClaim(item)
|
||||
const amount = toNumber(claim.amount)
|
||||
const riskLevel = toText(task.riskLevel ?? task.risk_level ?? 'low') || 'low'
|
||||
const evidenceCompleteness = Math.max(0, Math.min(
|
||||
1,
|
||||
toNumber(task.evidenceCompleteness ?? task.evidence_completeness)
|
||||
))
|
||||
return {
|
||||
id: toText(task.id),
|
||||
claimId: toText(task.claimId ?? task.claim_id),
|
||||
claimNo: toText(claim.claim_no ?? claim.claimNo ?? task.claimId ?? task.claim_id),
|
||||
applicant: toText(claim.employee_name ?? claim.employeeName ?? claim.person ?? '未记录'),
|
||||
department: toText(claim.department_name ?? claim.departmentName ?? claim.dept ?? '未记录'),
|
||||
amountLabel: new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(amount),
|
||||
nodeLabel: toText(task.nodeLabel ?? task.node_label ?? claim.approval_stage ?? claim.approvalStage),
|
||||
priorityScore: Math.max(0, Math.min(100, toNumber(task.priorityScore ?? task.priority_score))),
|
||||
priorityTier: toText(task.priorityTier ?? task.priority_tier ?? 'normal') || 'normal',
|
||||
riskLevel,
|
||||
riskLabel: RISK_LABELS[riskLevel] || RISK_LABELS.low,
|
||||
openRiskCount: Math.max(0, Math.trunc(toNumber(task.openRiskCount ?? task.open_risk_count))),
|
||||
evidenceLabel: `${Math.round(evidenceCompleteness * 100)}%`,
|
||||
assigneeName: toText(task.assigneeName ?? task.assignee_name ?? '未分配'),
|
||||
taskKind: toText(task.taskKind ?? task.task_kind ?? 'root') || 'root',
|
||||
coordinationMode: toText(task.coordinationMode ?? task.coordination_mode ?? 'single') || 'single',
|
||||
canAct: (task.canAct ?? task.can_act) === true,
|
||||
readOnlyReason: resolveApprovalTaskReadOnlyReason(item),
|
||||
actions: resolveApprovalTaskActionItems(item),
|
||||
sla: resolveApprovalTaskSlaMeta(item, now),
|
||||
source: item
|
||||
}
|
||||
}
|
||||
|
||||
export function isApprovalTaskKeyboardInputTarget(target) {
|
||||
if (!target || typeof target !== 'object') {
|
||||
return false
|
||||
}
|
||||
const tagName = toText(target.tagName).toLowerCase()
|
||||
if (['input', 'textarea', 'select', 'button', 'a'].includes(tagName)) {
|
||||
return true
|
||||
}
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
return typeof target.closest === 'function'
|
||||
&& Boolean(target.closest('input, textarea, select, button, a, [contenteditable="true"]'))
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskKeyboardCommand(event = {}) {
|
||||
if (isApprovalTaskKeyboardInputTarget(event.target)) {
|
||||
return ''
|
||||
}
|
||||
const rawKey = String(event.key ?? '')
|
||||
if (rawKey === ' ') return 'toggle'
|
||||
const key = rawKey.trim().toLowerCase()
|
||||
if (key === 'enter') return 'open'
|
||||
if (key === 'spacebar') return 'toggle'
|
||||
if (key === 'j' || key === 'arrowdown') return 'next'
|
||||
if (key === 'k' || key === 'arrowup') return 'previous'
|
||||
return ''
|
||||
}
|
||||
27
web/src/views/scripts/approvalTaskRetry.js
Normal file
27
web/src/views/scripts/approvalTaskRetry.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createApprovalTaskRequestId } from '../../services/approvalTasks.js'
|
||||
|
||||
function normalizeFingerprintValue(value) {
|
||||
if (Array.isArray(value)) return value.map(normalizeFingerprintValue)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value).sort().map((key) => [key, normalizeFingerprintValue(value[key])])
|
||||
)
|
||||
}
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
export function buildApprovalTaskRetryFingerprint(payload = {}) {
|
||||
return JSON.stringify(normalizeFingerprintValue(payload))
|
||||
}
|
||||
|
||||
export function resolveApprovalTaskRetryKey(options = {}) {
|
||||
const fingerprint = buildApprovalTaskRetryFingerprint(options.payload)
|
||||
const previousFingerprint = String(options.previousFingerprint || '')
|
||||
const previousRequestId = String(options.previousRequestId || '')
|
||||
return {
|
||||
fingerprint,
|
||||
requestId: previousRequestId && previousFingerprint === fingerprint
|
||||
? previousRequestId
|
||||
: createApprovalTaskRequestId(options.action, options.scopeId)
|
||||
}
|
||||
}
|
||||
59
web/src/views/scripts/approvalTaskRouteState.js
Normal file
59
web/src/views/scripts/approvalTaskRouteState.js
Normal file
@@ -0,0 +1,59 @@
|
||||
export const APPROVAL_TASK_ROUTE_QUERY_KEYS = Object.freeze([
|
||||
'dc_review_page',
|
||||
'dc_review_page_size',
|
||||
'dc_review_q',
|
||||
'dc_review_risk',
|
||||
'dc_review_sla'
|
||||
])
|
||||
|
||||
const ALLOWED_PAGE_SIZES = new Set([10, 20])
|
||||
const ALLOWED_RISK_LEVELS = new Set(['critical', 'high', 'medium', 'low'])
|
||||
const ALLOWED_SLA_STATES = new Set(['due_soon', 'overdue', 'escalated'])
|
||||
|
||||
function readText(source, key) {
|
||||
const value = source?.[key]
|
||||
return String(Array.isArray(value) ? value[0] || '' : value || '').trim()
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const number = Math.trunc(Number(value))
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskRouteState(state = {}) {
|
||||
const filters = state.filters || {}
|
||||
const riskLevel = String(filters.riskLevel || filters.risk_level || '').trim()
|
||||
const slaState = String(filters.slaState || filters.sla_state || '').trim()
|
||||
const pageSize = positiveInteger(state.pageSize || state.page_size, 20)
|
||||
return {
|
||||
page: positiveInteger(state.page, 1),
|
||||
pageSize: ALLOWED_PAGE_SIZES.has(pageSize) ? pageSize : 20,
|
||||
filters: {
|
||||
keyword: String(filters.keyword || '').trim(),
|
||||
riskLevel: ALLOWED_RISK_LEVELS.has(riskLevel) ? riskLevel : '',
|
||||
slaState: ALLOWED_SLA_STATES.has(slaState) ? slaState : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readApprovalTaskRouteState(query = {}) {
|
||||
return normalizeApprovalTaskRouteState({
|
||||
page: readText(query, 'dc_review_page'),
|
||||
pageSize: readText(query, 'dc_review_page_size'),
|
||||
filters: {
|
||||
keyword: readText(query, 'dc_review_q'),
|
||||
riskLevel: readText(query, 'dc_review_risk'),
|
||||
slaState: readText(query, 'dc_review_sla')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function appendApprovalTaskRouteQuery(query = {}, state = {}) {
|
||||
const normalized = normalizeApprovalTaskRouteState(state)
|
||||
if (normalized.page > 1) query.dc_review_page = String(normalized.page)
|
||||
if (normalized.pageSize !== 20) query.dc_review_page_size = String(normalized.pageSize)
|
||||
if (normalized.filters.keyword) query.dc_review_q = normalized.filters.keyword
|
||||
if (normalized.filters.riskLevel) query.dc_review_risk = normalized.filters.riskLevel
|
||||
if (normalized.filters.slaState) query.dc_review_sla = normalized.filters.slaState
|
||||
return query
|
||||
}
|
||||
48
web/tests/approval-task-components-compile.test.mjs
Normal file
48
web/tests/approval-task-components-compile.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
compileScript,
|
||||
compileStyle,
|
||||
compileTemplate,
|
||||
parse
|
||||
} from '@vue/compiler-sfc'
|
||||
|
||||
const componentFiles = [
|
||||
'../src/components/approval/ApprovalTaskQueue.vue',
|
||||
'../src/components/approval/ApprovalTaskWorkspace.vue',
|
||||
'../src/components/approval/ApprovalAssignmentDialog.vue',
|
||||
'../src/components/approval/ApprovalParticipantsDialog.vue',
|
||||
'../src/components/approval/ApprovalBatchResultPanel.vue'
|
||||
]
|
||||
|
||||
for (const relativeFile of componentFiles) {
|
||||
test(`${relativeFile} 可以独立编译`, () => {
|
||||
const filename = fileURLToPath(new URL(relativeFile, import.meta.url))
|
||||
const source = readFileSync(filename, 'utf8')
|
||||
const parsed = parse(source, { filename })
|
||||
assert.deepEqual(parsed.errors, [])
|
||||
|
||||
const id = `approval-${relativeFile.replace(/\W+/g, '-')}`
|
||||
const script = compileScript(parsed.descriptor, { id })
|
||||
const template = compileTemplate({
|
||||
source: parsed.descriptor.template?.content || '',
|
||||
filename,
|
||||
id,
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(template.errors, [])
|
||||
|
||||
for (const style of parsed.descriptor.styles) {
|
||||
const compiledStyle = compileStyle({
|
||||
source: style.content,
|
||||
filename,
|
||||
id,
|
||||
scoped: style.scoped
|
||||
})
|
||||
assert.deepEqual(compiledStyle.errors, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
201
web/tests/approval-task-queue-composable.test.mjs
Normal file
201
web/tests/approval-task-queue-composable.test.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { useApprovalTaskQueue } from '../src/composables/useApprovalTaskQueue.js'
|
||||
|
||||
function queueItem(id, overrides = {}) {
|
||||
return {
|
||||
task: {
|
||||
id,
|
||||
claimId: `claim-${id}`,
|
||||
status: 'pending',
|
||||
version: 1,
|
||||
canAct: true,
|
||||
availableActions: ['approve'],
|
||||
batchEligible: true,
|
||||
claimStatusSnapshot: 'submitted',
|
||||
claimStageSnapshot: '直属领导审批',
|
||||
...overrides
|
||||
},
|
||||
claim: { id: `claim-${id}`, claim_no: `BX-${id}` }
|
||||
}
|
||||
}
|
||||
|
||||
function listResult(items, page = 1) {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize: 20,
|
||||
totalPages: items.length ? 1 : 0,
|
||||
generatedAt: '2026-07-16T10:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
test('列表请求代次阻止慢请求覆盖新页结果', async () => {
|
||||
const requests = []
|
||||
const queue = useApprovalTaskQueue({
|
||||
services: {
|
||||
fetchApprovalTasks(params) {
|
||||
const request = deferred()
|
||||
requests.push({ params, request })
|
||||
return request.promise
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const firstLoad = queue.loadQueue()
|
||||
await queue.setPage(2, { reload: false })
|
||||
const secondLoad = queue.loadQueue()
|
||||
requests[1].request.resolve(listResult([queueItem('task-new')], 2))
|
||||
await secondLoad
|
||||
requests[0].request.resolve(listResult([queueItem('task-stale')], 1))
|
||||
const staleResult = await firstLoad
|
||||
|
||||
assert.equal(requests[0].params.page, 1)
|
||||
assert.equal(requests[1].params.page, 2)
|
||||
assert.equal(staleResult.ignored, true)
|
||||
assert.equal(queue.items.value[0].task.id, 'task-new')
|
||||
assert.equal(queue.page.value, 2)
|
||||
})
|
||||
|
||||
test('筛选变化清空选择并回到第一页', async () => {
|
||||
const queue = useApprovalTaskQueue({
|
||||
services: {
|
||||
async fetchApprovalTasks() {
|
||||
return listResult([queueItem('task-001')])
|
||||
}
|
||||
}
|
||||
})
|
||||
await queue.loadQueue()
|
||||
queue.toggleTask(queue.items.value[0], true)
|
||||
await queue.setPage(3, { reload: false })
|
||||
assert.deepEqual(queue.selectedTaskIds.value, ['task-001'])
|
||||
|
||||
await queue.setFilters({ riskLevel: 'high' }, { reload: false })
|
||||
assert.equal(queue.page.value, 1)
|
||||
assert.equal(queue.filters.value.riskLevel, 'high')
|
||||
assert.deepEqual(queue.selectedTaskIds.value, [])
|
||||
})
|
||||
|
||||
test('批量部分成功保留失败选择并使用逐项任务快照', async () => {
|
||||
const batchCalls = []
|
||||
let listCallCount = 0
|
||||
const rows = [queueItem('task-001'), queueItem('task-002')]
|
||||
const queue = useApprovalTaskQueue({
|
||||
services: {
|
||||
async fetchApprovalTasks() {
|
||||
listCallCount += 1
|
||||
return listResult(rows)
|
||||
},
|
||||
async batchApproveApprovalTasks(payload) {
|
||||
batchCalls.push(payload)
|
||||
return {
|
||||
batchRequestId: payload.batchRequestId,
|
||||
status: 'partial',
|
||||
succeededCount: 1,
|
||||
replayedCount: 0,
|
||||
conflictCount: 0,
|
||||
blockedCount: 1,
|
||||
forbiddenCount: 0,
|
||||
failedCount: 0,
|
||||
items: [
|
||||
{ taskId: 'task-001', status: 'succeeded' },
|
||||
{ taskId: 'task-002', status: 'blocked', message: '存在开放高风险' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await queue.loadQueue()
|
||||
queue.toggleCurrentPage(true)
|
||||
const result = await queue.batchApprove({ opinion: '批量核对完成' })
|
||||
|
||||
assert.equal(listCallCount, 2)
|
||||
assert.equal(batchCalls.length, 1)
|
||||
assert.equal(batchCalls[0].items[0].task.id, 'task-001')
|
||||
assert.equal(batchCalls[0].items[0].opinion, '批量核对完成')
|
||||
assert.equal(result.status, 'partial')
|
||||
assert.deepEqual(queue.selectedTaskIds.value, ['task-002'])
|
||||
assert.equal(queue.lastBatchResult.value.items[1].status, 'blocked')
|
||||
assert.equal(queue.pendingBatchRequestId.value, '')
|
||||
})
|
||||
|
||||
test('单项刷新采用服务端最新权限并移除失效选择', async () => {
|
||||
const queue = useApprovalTaskQueue({
|
||||
services: {
|
||||
async fetchApprovalTasks() {
|
||||
return listResult([queueItem('task-001')])
|
||||
},
|
||||
async fetchApprovalTaskDetail() {
|
||||
return queueItem('task-001', {
|
||||
version: 2,
|
||||
canAct: false,
|
||||
availableActions: [],
|
||||
batchEligible: false,
|
||||
readOnlyReason: '任务已转交给其他审批人'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await queue.loadQueue()
|
||||
queue.toggleTask(queue.items.value[0], true)
|
||||
await queue.refreshTask('task-001')
|
||||
|
||||
assert.equal(queue.items.value[0].task.version, 2)
|
||||
assert.equal(queue.items.value[0].task.readOnlyReason, '任务已转交给其他审批人')
|
||||
assert.deepEqual(queue.selectedTaskIds.value, [])
|
||||
})
|
||||
|
||||
test('批量选择在前端基础层限制为服务端允许的 20 项', async () => {
|
||||
const rows = Array.from({ length: 21 }, (_, index) => queueItem(`task-${index + 1}`))
|
||||
const queue = useApprovalTaskQueue({
|
||||
services: {
|
||||
async fetchApprovalTasks() {
|
||||
return listResult(rows)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await queue.loadQueue()
|
||||
queue.toggleCurrentPage(true)
|
||||
|
||||
assert.equal(queue.selectedTaskIds.value.length, 20)
|
||||
assert.match(queue.errorMessage.value, /最多选择 20 项/)
|
||||
})
|
||||
|
||||
test('批量失败按相同负载复用幂等键,编辑意见后生成新键', async () => {
|
||||
const requestIds = []
|
||||
const queue = useApprovalTaskQueue({
|
||||
services: {
|
||||
async fetchApprovalTasks() {
|
||||
return listResult([queueItem('task-retry')])
|
||||
},
|
||||
async batchApproveApprovalTasks(payload) {
|
||||
requestIds.push(payload.batchRequestId)
|
||||
throw new Error('模拟网络中断')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await queue.loadQueue()
|
||||
queue.toggleCurrentPage(true)
|
||||
await assert.rejects(queue.batchApprove({ opinion: '核对通过' }), /模拟网络中断/)
|
||||
await assert.rejects(queue.batchApprove({ opinion: '核对通过' }), /模拟网络中断/)
|
||||
await assert.rejects(queue.batchApprove({ opinion: '补充核对后通过' }), /模拟网络中断/)
|
||||
|
||||
assert.equal(requestIds[1], requestIds[0])
|
||||
assert.notEqual(requestIds[2], requestIds[0])
|
||||
})
|
||||
127
web/tests/approval-task-queue-state.test.mjs
Normal file
127
web/tests/approval-task-queue-state.test.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
createApprovalTaskSelectionState,
|
||||
reconcileApprovalTaskSelectionAfterBatch,
|
||||
reconcileApprovalTaskSelectionWithPage,
|
||||
resolveApprovalTaskSelectionMeta,
|
||||
resolveCurrentPageApprovalSelection,
|
||||
syncApprovalTaskSelectionFilters,
|
||||
toggleApprovalTaskSelection,
|
||||
toggleCurrentPageApprovalSelection
|
||||
} from '../src/views/scripts/approvalTaskQueueState.js'
|
||||
|
||||
function task(id, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
status: 'pending',
|
||||
version: 1,
|
||||
canAct: true,
|
||||
availableActions: ['approve'],
|
||||
batchEligible: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('任务可选性优先使用服务端权限和批量阻断原因', () => {
|
||||
assert.deepEqual(resolveApprovalTaskSelectionMeta(task('task-001')), {
|
||||
taskId: 'task-001',
|
||||
selectable: true,
|
||||
reason: ''
|
||||
})
|
||||
assert.equal(
|
||||
resolveApprovalTaskSelectionMeta(task('task-002', {
|
||||
canAct: false,
|
||||
readOnlyReason: '该任务已委托给李审批'
|
||||
})).reason,
|
||||
'该任务已委托给李审批'
|
||||
)
|
||||
assert.equal(
|
||||
resolveApprovalTaskSelectionMeta(task('task-003', {
|
||||
batchEligible: false,
|
||||
batchBlockReasons: ['存在开放高风险', '审批意见必须单独填写']
|
||||
})).reason,
|
||||
'存在开放高风险;审批意见必须单独填写'
|
||||
)
|
||||
assert.equal(
|
||||
resolveApprovalTaskSelectionMeta(task('task-004', {
|
||||
availableActions: ['delegate']
|
||||
})).selectable,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('当前页全选和半选只处理服务端允许批量审批的任务', () => {
|
||||
const rows = [
|
||||
task('task-001'),
|
||||
task('task-002'),
|
||||
task('task-003', { batchEligible: false, batchBlockReasons: ['需单独核对'] })
|
||||
]
|
||||
let selected = toggleApprovalTaskSelection([], rows[0], true)
|
||||
let pageState = resolveCurrentPageApprovalSelection(rows, selected)
|
||||
assert.deepEqual(selected, ['task-001'])
|
||||
assert.equal(pageState.indeterminate, true)
|
||||
assert.equal(pageState.allSelected, false)
|
||||
|
||||
selected = toggleCurrentPageApprovalSelection(selected, rows, true)
|
||||
pageState = resolveCurrentPageApprovalSelection(rows, selected)
|
||||
assert.deepEqual(selected, ['task-001', 'task-002'])
|
||||
assert.equal(pageState.allSelected, true)
|
||||
assert.equal(pageState.eligibleCount, 2)
|
||||
|
||||
selected = toggleCurrentPageApprovalSelection(selected, rows, false)
|
||||
assert.deepEqual(selected, [])
|
||||
})
|
||||
|
||||
test('刷新后任务失去权限时从当前页选择中移除', () => {
|
||||
const selected = ['off-page-task', 'task-001', 'task-002']
|
||||
const rows = [
|
||||
task('task-001'),
|
||||
task('task-002', { canAct: false, readOnlyReason: '任务已转交' })
|
||||
]
|
||||
assert.deepEqual(
|
||||
reconcileApprovalTaskSelectionWithPage(selected, rows),
|
||||
['off-page-task', 'task-001']
|
||||
)
|
||||
})
|
||||
|
||||
test('筛选变化清空选择但翻页和每页条数变化不清空', () => {
|
||||
let state = createApprovalTaskSelectionState({
|
||||
selectedTaskIds: ['task-001', 'task-002'],
|
||||
filters: { status: 'pending', riskLevel: 'high', page: 1, pageSize: 20 }
|
||||
})
|
||||
state = syncApprovalTaskSelectionFilters(state, {
|
||||
status: 'pending',
|
||||
riskLevel: 'high',
|
||||
page: 2,
|
||||
pageSize: 50
|
||||
})
|
||||
assert.deepEqual(state.selectedTaskIds, ['task-001', 'task-002'])
|
||||
|
||||
state = syncApprovalTaskSelectionFilters(state, {
|
||||
status: 'pending',
|
||||
riskLevel: 'medium',
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
assert.deepEqual(state.selectedTaskIds, [])
|
||||
})
|
||||
|
||||
test('批量部分成功只移除成功和幂等重放项,失败项继续保留', () => {
|
||||
const selected = ['task-001', 'task-002', 'task-003', 'task-004', 'task-005', 'task-unreported']
|
||||
const result = {
|
||||
status: 'partial',
|
||||
items: [
|
||||
{ taskId: 'task-001', status: 'succeeded' },
|
||||
{ taskId: 'task-002', status: 'replayed' },
|
||||
{ taskId: 'task-003', status: 'blocked' },
|
||||
{ taskId: 'task-004', status: 'conflict' },
|
||||
{ taskId: 'task-005', status: 'forbidden' }
|
||||
]
|
||||
}
|
||||
assert.deepEqual(
|
||||
reconcileApprovalTaskSelectionAfterBatch(selected, result),
|
||||
['task-003', 'task-004', 'task-005', 'task-unreported']
|
||||
)
|
||||
})
|
||||
89
web/tests/approval-task-queue-view-model.test.mjs
Normal file
89
web/tests/approval-task-queue-view-model.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
resolveApprovalTaskActionItems,
|
||||
resolveApprovalTaskKeyboardCommand,
|
||||
resolveApprovalTaskRow,
|
||||
resolveApprovalTaskSlaMeta
|
||||
} from '../src/views/scripts/approvalTaskQueueViewModel.js'
|
||||
|
||||
function task(overrides = {}) {
|
||||
return {
|
||||
id: 'task-001',
|
||||
claimId: 'claim-001',
|
||||
nodeLabel: '直属领导审批',
|
||||
canAct: true,
|
||||
availableActions: ['approve', 'delegate', 'unknown_action'],
|
||||
riskLevel: 'high',
|
||||
openRiskCount: 2,
|
||||
evidenceCompleteness: 0.75,
|
||||
dueAt: '2026-07-16T12:00:00Z',
|
||||
escalationLevel: 1,
|
||||
nextEscalationAt: '2026-07-16T13:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('动作列表只展示服务端授权且前端认识的动作', () => {
|
||||
assert.deepEqual(
|
||||
resolveApprovalTaskActionItems(task()).map((item) => item.action),
|
||||
['approve', 'delegate']
|
||||
)
|
||||
assert.deepEqual(
|
||||
resolveApprovalTaskActionItems(task({
|
||||
canAct: false,
|
||||
availableActions: ['approve', 'return', 'transfer', 'sla_escalate'],
|
||||
readOnlyReason: '管理员只能管理任务'
|
||||
})).map((item) => item.action),
|
||||
['transfer', 'sla_escalate']
|
||||
)
|
||||
})
|
||||
|
||||
test('SLA 展示使用服务端截止时间和升级等级', () => {
|
||||
const nearDue = resolveApprovalTaskSlaMeta(task(), Date.parse('2026-07-16T11:00:00Z'))
|
||||
assert.equal(nearDue.label, '剩余 1.0h')
|
||||
assert.equal(nearDue.tone, 'warning')
|
||||
assert.equal(nearDue.escalationLabel, 'L1')
|
||||
assert.match(nearDue.title, /下次升级:2026-07-16T13:00:00Z/)
|
||||
|
||||
const overdue = resolveApprovalTaskSlaMeta(task(), Date.parse('2026-07-16T14:00:00Z'))
|
||||
assert.equal(overdue.label, '超时 2.0h')
|
||||
assert.equal(overdue.overdue, true)
|
||||
assert.equal(overdue.tone, 'danger')
|
||||
})
|
||||
|
||||
test('任务行组合风险、证据、单据和只读原因', () => {
|
||||
const row = resolveApprovalTaskRow({
|
||||
task: task({
|
||||
canAct: false,
|
||||
availableActions: [],
|
||||
readOnlyReason: '仅原审批人可操作'
|
||||
}),
|
||||
claim: {
|
||||
claim_no: 'BX-2026-001',
|
||||
employee_name: '张三',
|
||||
department_name: '销售部',
|
||||
amount: 1280.5
|
||||
}
|
||||
}, Date.parse('2026-07-16T11:00:00Z'))
|
||||
|
||||
assert.equal(row.claimNo, 'BX-2026-001')
|
||||
assert.equal(row.applicant, '张三')
|
||||
assert.equal(row.riskLabel, '高风险')
|
||||
assert.equal(row.evidenceLabel, '75%')
|
||||
assert.equal(row.readOnlyReason, '仅原审批人可操作')
|
||||
assert.deepEqual(row.actions, [])
|
||||
})
|
||||
|
||||
test('键盘模型支持 Enter、Space、J/K,并在输入控件聚焦时禁用', () => {
|
||||
const rowTarget = { tagName: 'TR', isContentEditable: false }
|
||||
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'Enter', target: rowTarget }), 'open')
|
||||
assert.equal(resolveApprovalTaskKeyboardCommand({ key: ' ', target: rowTarget }), 'toggle')
|
||||
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'j', target: rowTarget }), 'next')
|
||||
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'K', target: rowTarget }), 'previous')
|
||||
assert.equal(
|
||||
resolveApprovalTaskKeyboardCommand({ key: 'j', target: { tagName: 'INPUT' } }),
|
||||
''
|
||||
)
|
||||
})
|
||||
67
web/tests/approval-task-retry-and-route.test.mjs
Normal file
67
web/tests/approval-task-retry-and-route.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
resolveApprovalTaskRetryKey
|
||||
} from '../src/views/scripts/approvalTaskRetry.js'
|
||||
import {
|
||||
appendApprovalTaskRouteQuery,
|
||||
readApprovalTaskRouteState
|
||||
} from '../src/views/scripts/approvalTaskRouteState.js'
|
||||
|
||||
test('approval retry key is stable only while the idempotent payload stays identical', () => {
|
||||
const first = resolveApprovalTaskRetryKey({
|
||||
action: 'approve',
|
||||
scopeId: 'task-1',
|
||||
payload: { reason: '核对通过', expectedTaskVersion: 3 }
|
||||
})
|
||||
const samePayload = resolveApprovalTaskRetryKey({
|
||||
action: 'approve',
|
||||
scopeId: 'task-1',
|
||||
payload: { expectedTaskVersion: 3, reason: '核对通过' },
|
||||
previousFingerprint: first.fingerprint,
|
||||
previousRequestId: first.requestId
|
||||
})
|
||||
const editedPayload = resolveApprovalTaskRetryKey({
|
||||
action: 'approve',
|
||||
scopeId: 'task-1',
|
||||
payload: { expectedTaskVersion: 3, reason: '补充核对后通过' },
|
||||
previousFingerprint: first.fingerprint,
|
||||
previousRequestId: first.requestId
|
||||
})
|
||||
|
||||
assert.equal(samePayload.requestId, first.requestId)
|
||||
assert.notEqual(editedPayload.requestId, first.requestId)
|
||||
})
|
||||
|
||||
test('approval task route state restores only supported filters and compact defaults', () => {
|
||||
const state = readApprovalTaskRouteState({
|
||||
dc_review_page: '3',
|
||||
dc_review_page_size: '10',
|
||||
dc_review_q: ' REIM-100 ',
|
||||
dc_review_risk: 'high',
|
||||
dc_review_sla: 'overdue'
|
||||
})
|
||||
assert.deepEqual(state, {
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
filters: { keyword: 'REIM-100', riskLevel: 'high', slaState: 'overdue' }
|
||||
})
|
||||
assert.deepEqual(appendApprovalTaskRouteQuery({}, state), {
|
||||
dc_review_page: '3',
|
||||
dc_review_page_size: '10',
|
||||
dc_review_q: 'REIM-100',
|
||||
dc_review_risk: 'high',
|
||||
dc_review_sla: 'overdue'
|
||||
})
|
||||
assert.deepEqual(readApprovalTaskRouteState({
|
||||
dc_review_page: '-1',
|
||||
dc_review_page_size: '200',
|
||||
dc_review_risk: 'unknown',
|
||||
dc_review_sla: 'near_due'
|
||||
}), {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
filters: { keyword: '', riskLevel: '', slaState: '' }
|
||||
})
|
||||
})
|
||||
44
web/tests/approval-task-workspace.test.mjs
Normal file
44
web/tests/approval-task-workspace.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { readSourceFile } from './helpers/sourceSurface.mjs'
|
||||
|
||||
const workspace = readSourceFile('components/approval/ApprovalTaskWorkspace.vue')
|
||||
const documentsCenter = readSourceFile('views/DocumentsCenterView.vue')
|
||||
const appShell = readSourceFile('views/AppShellRouteView.vue')
|
||||
|
||||
test('approval task workspace wires every server-authorized action to a real mutation', () => {
|
||||
assert.match(workspace, /approveApprovalTask/)
|
||||
assert.match(workspace, /returnApprovalTask/)
|
||||
assert.match(workspace, /escalateApprovalTask/)
|
||||
assert.match(workspace, /<ApprovalAssignmentDialog/)
|
||||
assert.match(workspace, /<ApprovalParticipantsDialog/)
|
||||
assert.match(workspace, /<ReturnReasonDialog/)
|
||||
assert.doesNotMatch(workspace, /已批量通过 23 条|mock/i)
|
||||
})
|
||||
|
||||
test('approval task decisions keep one request id across retries and enforce task version', () => {
|
||||
assert.match(workspace, /resolveApprovalTaskRetryKey/)
|
||||
assert.match(workspace, /previousFingerprint: requestFingerprint\.value/)
|
||||
assert.match(workspace, /previousRequestId: requestId\.value/)
|
||||
assert.match(workspace, /payload\.requestId = retryKey\.requestId/)
|
||||
assert.match(workspace, /expectedTaskVersion: Number\(activeTask\.value\.version\)/)
|
||||
assert.match(workspace, /mutation\?\.replayed[\s\S]*结果已安全重放/)
|
||||
assert.match(workspace, /await queueRef\.value\?\.reload\?\.\(\)/)
|
||||
})
|
||||
|
||||
test('documents review tab owns the task workspace and propagates updates to the app shell', () => {
|
||||
assert.match(documentsCenter, /<ApprovalTaskWorkspace[\s\S]*v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"/)
|
||||
assert.match(documentsCenter, /@request-updated="emit\('request-updated', \$event\)"/)
|
||||
assert.match(documentsCenter, /@loaded="handleApprovalTasksLoaded"/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchApprovalExpenseClaims|approvalRows/)
|
||||
assert.match(appShell, /<DocumentsCenterView[\s\S]*@request-updated="handleRequestUpdated"/)
|
||||
})
|
||||
|
||||
test('workspace separates the unfiltered pending summary from queue filters and refreshes batches', () => {
|
||||
assert.match(workspace, /service\(\{ status: 'pending', page: 1, pageSize: 1 \}\)/)
|
||||
assert.match(workspace, /@loaded="handleQueueLoaded"/)
|
||||
assert.match(workspace, /void loadTaskSummary\(\)[\s\S]*emit\('batch-result', result\)/)
|
||||
assert.match(workspace, /emit\('request-updated', \{ claim: claims\[0\], claimId: claimIds\[0\], claimIds \}\)/)
|
||||
assert.match(workspace, /const workspaceError = computed\(\(\) => queueError\.value \|\| summaryError\.value\)/)
|
||||
})
|
||||
233
web/tests/approval-tasks-service.test.mjs
Normal file
233
web/tests/approval-tasks-service.test.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
addSignApprovalTask,
|
||||
approveApprovalTask,
|
||||
batchApproveApprovalTasks,
|
||||
countersignApprovalTask,
|
||||
delegateApprovalTask,
|
||||
escalateApprovalTask,
|
||||
fetchApprovalTaskCandidates,
|
||||
fetchApprovalTaskDetail,
|
||||
fetchApprovalTasks,
|
||||
returnApprovalTask,
|
||||
revokeApprovalTaskDelegation,
|
||||
transferApprovalTask
|
||||
} from '../src/services/approvalTasks.js'
|
||||
|
||||
function jsonResponse(payload) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pendingTask(overrides = {}) {
|
||||
return {
|
||||
id: 'task-001',
|
||||
claim_id: 'claim-001',
|
||||
status: 'pending',
|
||||
version: 3,
|
||||
can_act: true,
|
||||
available_actions: ['approve', 'delegate'],
|
||||
batch_eligible: true,
|
||||
claim_status_snapshot: 'submitted',
|
||||
claim_stage_snapshot: '直属领导审批',
|
||||
priority_reasons_json: [{ code: 'sla_near_due', label: '即将超时', weight: 16 }],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('审批任务列表发送服务端分页筛选参数并规范化任务', async () => {
|
||||
let capturedUrl = ''
|
||||
global.fetch = async (url) => {
|
||||
capturedUrl = String(url)
|
||||
return jsonResponse({
|
||||
items: [{ task: pendingTask(), claim: { id: 'claim-001', claim_no: 'BX-001' } }],
|
||||
total: 41,
|
||||
page: 2,
|
||||
page_size: 25,
|
||||
total_pages: 2,
|
||||
generated_at: '2026-07-16T10:00:00Z'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await fetchApprovalTasks({
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
status: 'pending',
|
||||
riskLevel: 'high',
|
||||
slaState: 'overdue',
|
||||
nodeKey: 'direct_manager',
|
||||
keyword: '张 三',
|
||||
batchEligible: true,
|
||||
sort: 'priority_desc'
|
||||
})
|
||||
|
||||
const url = new URL(capturedUrl, 'http://local.test')
|
||||
assert.equal(url.pathname, '/api/v1/approval-tasks')
|
||||
assert.equal(url.searchParams.get('page'), '2')
|
||||
assert.equal(url.searchParams.get('page_size'), '25')
|
||||
assert.equal(url.searchParams.get('status'), 'pending')
|
||||
assert.equal(url.searchParams.get('risk_level'), 'high')
|
||||
assert.equal(url.searchParams.get('sla_state'), 'overdue')
|
||||
assert.equal(url.searchParams.get('node_key'), 'direct_manager')
|
||||
assert.equal(url.searchParams.get('keyword'), '张 三')
|
||||
assert.equal(url.searchParams.get('batch_eligible'), 'true')
|
||||
assert.equal(url.searchParams.get('sort'), 'priority_desc')
|
||||
assert.equal(result.total, 41)
|
||||
assert.equal(result.pageSize, 25)
|
||||
assert.equal(result.items[0].task.claimId, 'claim-001')
|
||||
assert.equal(result.items[0].task.priorityReasons[0].code, 'sla_near_due')
|
||||
assert.equal(result.items[0].claim.claim_no, 'BX-001')
|
||||
})
|
||||
|
||||
test('审批任务详情和候选人使用任务作用域接口', async () => {
|
||||
const calls = []
|
||||
global.fetch = async (url) => {
|
||||
calls.push(String(url))
|
||||
if (String(url).includes('/candidates?')) {
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
employee_id: 'employee-002',
|
||||
employee_no: 'E002',
|
||||
name: '李审批',
|
||||
email: 'li@example.com',
|
||||
qualified: false,
|
||||
reason: '不属于当前部门'
|
||||
}],
|
||||
total: 1
|
||||
})
|
||||
}
|
||||
return jsonResponse({ task: pendingTask(), claim: { id: 'claim-001' } })
|
||||
}
|
||||
|
||||
const detail = await fetchApprovalTaskDetail('task/001')
|
||||
const candidates = await fetchApprovalTaskCandidates('task/001', {
|
||||
action: 'delegate',
|
||||
keyword: '李 审批',
|
||||
limit: 30
|
||||
})
|
||||
|
||||
assert.equal(calls[0], '/api/v1/approval-tasks/task%2F001')
|
||||
const candidateUrl = new URL(calls[1], 'http://local.test')
|
||||
assert.equal(candidateUrl.pathname, '/api/v1/approval-tasks/task%2F001/candidates')
|
||||
assert.equal(candidateUrl.searchParams.get('action'), 'delegate')
|
||||
assert.equal(candidateUrl.searchParams.get('keyword'), '李 审批')
|
||||
assert.equal(candidateUrl.searchParams.get('limit'), '30')
|
||||
assert.equal(detail.task.id, 'task-001')
|
||||
assert.equal(candidates.items[0].qualified, false)
|
||||
assert.equal(candidates.items[0].reason, '不属于当前部门')
|
||||
})
|
||||
|
||||
test('所有审批动作携带请求幂等键和任务版本', async () => {
|
||||
const calls = []
|
||||
global.fetch = async (url, options) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(options.body) })
|
||||
return jsonResponse({ task: pendingTask({ version: 4 }), related_tasks: [], replayed: false })
|
||||
}
|
||||
const base = {
|
||||
requestId: 'request-action-001',
|
||||
expectedTaskVersion: 3,
|
||||
reason: '业务审批处理'
|
||||
}
|
||||
|
||||
await delegateApprovalTask('task-001', {
|
||||
...base,
|
||||
targetEmployeeId: 'employee-002',
|
||||
expiresAt: '2026-07-18T10:00:00Z'
|
||||
})
|
||||
await revokeApprovalTaskDelegation('task-001', base)
|
||||
await transferApprovalTask('task-001', { ...base, targetEmployeeId: 'employee-003' })
|
||||
await addSignApprovalTask('task-001', {
|
||||
...base,
|
||||
participantEmployeeIds: ['employee-004', 'employee-005', 'employee-004']
|
||||
})
|
||||
await countersignApprovalTask('task-001', {
|
||||
...base,
|
||||
participantEmployeeIds: ['employee-006', 'employee-007']
|
||||
})
|
||||
await escalateApprovalTask('task-001', base)
|
||||
await approveApprovalTask('task-001', { ...base, opinion: '同意通过' })
|
||||
await returnApprovalTask('task-001', {
|
||||
...base,
|
||||
reasonCodes: ['missing_invoice', 'missing_invoice', 'amount_mismatch']
|
||||
})
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.url), [
|
||||
'/api/v1/approval-tasks/task-001/delegate',
|
||||
'/api/v1/approval-tasks/task-001/delegation/revoke',
|
||||
'/api/v1/approval-tasks/task-001/transfer',
|
||||
'/api/v1/approval-tasks/task-001/add-sign',
|
||||
'/api/v1/approval-tasks/task-001/countersign',
|
||||
'/api/v1/approval-tasks/task-001/escalate',
|
||||
'/api/v1/approval-tasks/task-001/approve',
|
||||
'/api/v1/approval-tasks/task-001/return'
|
||||
])
|
||||
calls.forEach((call) => {
|
||||
assert.equal(call.body.request_id, 'request-action-001')
|
||||
assert.equal(call.body.expected_task_version, 3)
|
||||
assert.equal(call.body.reason, '业务审批处理')
|
||||
})
|
||||
assert.equal(calls[0].body.target_employee_id, 'employee-002')
|
||||
assert.equal(calls[0].body.expires_at, '2026-07-18T10:00:00Z')
|
||||
assert.deepEqual(calls[3].body.participant_employee_ids, ['employee-004', 'employee-005'])
|
||||
assert.equal(calls[6].body.opinion, '同意通过')
|
||||
assert.deepEqual(calls[7].body.reason_codes, ['missing_invoice', 'amount_mismatch'])
|
||||
})
|
||||
|
||||
test('批量审批发送逐项快照并规范化部分成功结果', async () => {
|
||||
let capturedUrl = ''
|
||||
let capturedBody = null
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
capturedBody = JSON.parse(options.body)
|
||||
return jsonResponse({
|
||||
batch_request_id: 'batch-request-001',
|
||||
status: 'partial',
|
||||
succeeded_count: 1,
|
||||
blocked_count: 1,
|
||||
conflict_count: 1,
|
||||
items: [
|
||||
{ task_id: 'task-001', claim_id: 'claim-001', status: 'succeeded', code: 'OK', message: '已通过' },
|
||||
{ task_id: 'task-002', claim_id: 'claim-002', status: 'blocked', code: 'OPEN_RISK', message: '存在开放高风险' },
|
||||
{ task_id: 'task-003', claim_id: 'claim-003', status: 'conflict', code: 'STALE_TASK', message: '任务已更新' }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const result = await batchApproveApprovalTasks({
|
||||
batchRequestId: 'batch-request-001',
|
||||
items: [
|
||||
pendingTask({ id: 'task-001' }),
|
||||
pendingTask({ id: 'task-002', version: 5 }),
|
||||
{
|
||||
taskId: 'task-003',
|
||||
expectedTaskVersion: 2,
|
||||
expectedStatus: 'submitted',
|
||||
expectedApprovalStage: '财务审批',
|
||||
opinion: '票据已核对'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(capturedUrl, '/api/v1/approval-tasks/batch-approve')
|
||||
assert.equal(capturedBody.batch_request_id, 'batch-request-001')
|
||||
assert.deepEqual(capturedBody.items[0], {
|
||||
task_id: 'task-001',
|
||||
expected_task_version: 3,
|
||||
expected_status: 'submitted',
|
||||
expected_approval_stage: '直属领导审批'
|
||||
})
|
||||
assert.equal(capturedBody.items[1].expected_task_version, 5)
|
||||
assert.equal(capturedBody.items[2].opinion, '票据已核对')
|
||||
assert.equal(result.status, 'partial')
|
||||
assert.equal(result.succeededCount, 1)
|
||||
assert.equal(result.blockedCount, 1)
|
||||
assert.equal(result.conflictCount, 1)
|
||||
assert.equal(result.items[1].code, 'OPEN_RISK')
|
||||
})
|
||||
@@ -14,6 +14,9 @@ const documentListSharedStyles = readSourceFile('assets/styles/components/docume
|
||||
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
|
||||
const reimbursementService = readSourceFile('services/reimbursements.js')
|
||||
const requestsComposable = readSourceFile('composables/useRequests.js')
|
||||
const approvalTaskQueue = readSourceFile('components/approval/ApprovalTaskQueue.vue')
|
||||
const approvalTaskService = readSourceFile('services/approvalTasks.js')
|
||||
const archiveRowsComposable = readSourceFile('composables/useDocumentCenterArchiveRows.js')
|
||||
|
||||
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
|
||||
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
||||
@@ -69,14 +72,17 @@ test('documents center persists pagination and filters in route query for detail
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
|
||||
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd, approvalTaskRouteState\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
|
||||
)
|
||||
assert.match(documentsCenterView, /readApprovalTaskRouteState\(route\.query\)/)
|
||||
assert.match(documentsCenterView, /appendApprovalTaskRouteQuery\(nextQuery, approvalTaskRouteState\.value\)/)
|
||||
})
|
||||
|
||||
test('documents center category tabs map to the intended row sources', () => {
|
||||
test('documents center keeps owned rows separate and delegates review scope to approval tasks', () => {
|
||||
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
|
||||
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
|
||||
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
|
||||
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(ownedRows\.value\)\)/)
|
||||
assert.match(documentsCenterView, /<ApprovalTaskWorkspace[\s\S]*v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"/)
|
||||
assert.doesNotMatch(documentsCenterView, /fetchApprovalExpenseClaims|approvalRows/)
|
||||
assert.match(documentsCenterLogic, /import \{ sortDocumentRowsByLatestTime \} from '\.\/documentCenterSort\.js'/)
|
||||
assert.match(documentsCenterLogic, /activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
|
||||
assert.match(
|
||||
@@ -91,10 +97,6 @@ test('documents center category tabs map to the intended row sources', () => {
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*ownedRows\.value\.filter/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_REVIEW[\s\S]*return approvalRows\.value/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
|
||||
@@ -134,8 +136,9 @@ test('documents center preserves application document type from mapped requests'
|
||||
)
|
||||
})
|
||||
|
||||
test('documents center refresh token reloads supporting approval and archive rows', () => {
|
||||
test('documents center refresh token reloads the task workspace and archive rows', () => {
|
||||
assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/)
|
||||
assert.match(documentsCenterView, /<ApprovalTaskWorkspace[\s\S]*:refresh-token="refreshToken"/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/
|
||||
@@ -143,15 +146,16 @@ test('documents center refresh token reloads supporting approval and archive row
|
||||
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
|
||||
})
|
||||
|
||||
test('documents center fetches every paginated claim page for admin-scale lists', () => {
|
||||
test('documents center review scope uses server pagination while archive stays a bounded preview', () => {
|
||||
assert.match(reimbursementService, /export function fetchAllExpenseClaims/)
|
||||
assert.match(reimbursementService, /async function fetchAllExpenseClaimPages/)
|
||||
assert.match(reimbursementService, /payload\.has_next/)
|
||||
assert.match(requestsComposable, /import \{ fetchAllExpenseClaims \} from '\.\.\/services\/reimbursements\.js'/)
|
||||
assert.match(requestsComposable, /const payload = await fetchAllExpenseClaims\(\)/)
|
||||
assert.match(documentsCenterView, /fetchAllApprovalExpenseClaims/)
|
||||
assert.match(documentsCenterView, /fetchAllArchivedExpenseClaims/)
|
||||
assert.doesNotMatch(documentsCenterView, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||
assert.match(approvalTaskService, /search\.set\('page'/)
|
||||
assert.match(approvalTaskService, /search\.set\([\s\S]*'page_size'/)
|
||||
assert.match(approvalTaskQueue, /<EnterprisePagination/)
|
||||
assert.match(archiveRowsComposable, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(documentsCenterView, /fetchApprovalExpenseClaims/)
|
||||
assert.match(requestsComposable, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
})
|
||||
|
||||
test('documents center list shows created time and conditional stay time columns', () => {
|
||||
@@ -164,10 +168,7 @@ test('documents center list shows created time and conditional stay time columns
|
||||
assert.match(documentsCenterView, /<td data-label="创建时间">\{\{ row\.createdAtDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn" data-label="停留时间">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td data-label="发起人">\{\{ row\.initiatorName \}\}<\/td>/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
|
||||
)
|
||||
assert.match(documentsCenterView, /const showStayTimeColumn = computed\(\(\) => activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION\)/)
|
||||
assert.match(documentsCenterLogic, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
|
||||
assert.match(documentsCenterLogic, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
|
||||
assert.match(documentsCenterLogic, /initiatorName,/)
|
||||
@@ -212,7 +213,8 @@ test('documents center category tabs render bubble counts for new documents', ()
|
||||
documentsCenterView,
|
||||
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
|
||||
)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalTaskTotal\.value/)
|
||||
assert.match(documentsCenterView, /badgeLabel: tab === DOCUMENT_SCOPE_REVIEW \? '待处理审批任务数' : '新增单据数'/)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
@@ -233,8 +235,9 @@ test('documents center can mark all unread documents as read from toolbar', () =
|
||||
assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
|
||||
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*DOCUMENT_TYPE_REIMBURSEMENT/
|
||||
)
|
||||
assert.doesNotMatch(documentsCenterView, /approvalRows/)
|
||||
assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/)
|
||||
assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/)
|
||||
assert.match(
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const root = process.cwd()
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function readProjectFile(path) {
|
||||
return readFileSync(join(root, path), 'utf8')
|
||||
return readFileSync(fileURLToPath(new URL(`../${path.replace(/^web\//, '')}`, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
test('workbench and document list refreshes use preview pagination', () => {
|
||||
test('claim previews stay bounded while document review uses the approval task workspace', () => {
|
||||
const useRequests = readProjectFile('web/src/composables/useRequests.js')
|
||||
const useAppShell = readProjectFile('web/src/composables/useAppShell.js')
|
||||
const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue')
|
||||
const archiveRowsComposable = readProjectFile('web/src/composables/useDocumentCenterArchiveRows.js')
|
||||
const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js')
|
||||
const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js')
|
||||
|
||||
@@ -24,8 +23,9 @@ test('workbench and document list refreshes use preview pagination', () => {
|
||||
assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
|
||||
assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(archiveRowsComposable, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(documentsCenter, /<ApprovalTaskWorkspace/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchApprovalExpenseClaims/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ test('risk observation normalizes persisted json evidence and typed disposition'
|
||||
adjudication: 'confirmed',
|
||||
lifecycle_status: 'remediation_in_progress',
|
||||
version: 2,
|
||||
available_actions: ['request_waiver'],
|
||||
read_only_reason: null,
|
||||
events: [{ id: 'event-1', action: 'confirm', version: 1 }]
|
||||
}
|
||||
})
|
||||
@@ -37,6 +39,8 @@ test('risk observation normalizes persisted json evidence and typed disposition'
|
||||
assert.deepEqual(observation.graphNodeKeys, ['claim:1'])
|
||||
assert.equal(observation.decisionTrace.action, 'manual_review')
|
||||
assert.equal(observation.disposition.lifecycleStatus, 'remediation_in_progress')
|
||||
assert.deepEqual(observation.disposition.availableActions, ['request_waiver'])
|
||||
assert.equal(observation.disposition.readOnlyReason, '')
|
||||
assert.equal(observation.disposition.events[0].action, 'confirm')
|
||||
})
|
||||
|
||||
@@ -56,8 +60,9 @@ test('risk evidence card exposes typed actions and never offers direct approval'
|
||||
assert.match(evidenceCard, /确认已解决/)
|
||||
assert.match(evidenceCard, /expectedVersion: currentDisposition\.value\.version/)
|
||||
assert.doesNotMatch(evidenceCard, /自动审批|直接通过|batchApprove/)
|
||||
assert.match(evidenceCard, /RISK_DISPOSITION_VERSION_CONFLICT/)
|
||||
assert.match(evidenceCard, /lifecycle !== 'supplement_requested'/)
|
||||
assert.match(evidenceCard, /normalizeRiskDispositionError/)
|
||||
assert.match(evidenceCard, /currentDisposition\.value\.availableActions/)
|
||||
assert.doesNotMatch(evidenceCard, /roleCodes|role_codes|isAdmin|is_admin/)
|
||||
})
|
||||
|
||||
test('risk action request ids bind action and observation', () => {
|
||||
|
||||
215
web/tests/risk-waiver-flow.test.mjs
Normal file
215
web/tests/risk-waiver-flow.test.mjs
Normal file
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
executeRiskDispositionAction,
|
||||
normalizeRiskDisposition,
|
||||
normalizeRiskDispositionError,
|
||||
normalizeRiskObservation
|
||||
} from '../src/services/riskObservations.js'
|
||||
|
||||
const evidenceCard = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const waiverDialog = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/RiskWaiverActionDialog.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const waiverRecord = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/RiskWaiverRecord.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function jsonResponse(payload) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('risk disposition normalizes server-authorized actions and complete waiver audit fields', () => {
|
||||
const disposition = normalizeRiskDisposition({
|
||||
id: 'disposition-1',
|
||||
observation_id: 'risk-1',
|
||||
lifecycle_status: 'waived',
|
||||
version: 3,
|
||||
available_actions: ['approve_waiver', 'reject_waiver', 'approve_waiver'],
|
||||
read_only_reason: null,
|
||||
waiver_requester_id: 'employee-requester',
|
||||
waiver_requester_name: '申请人甲',
|
||||
waiver_requested_at: '2026-07-16T08:00:00Z',
|
||||
waiver_reason: '客户现场暂时无法取得原件',
|
||||
waiver_scope: '仅限本次单据的行程确认材料',
|
||||
waiver_expires_at: '2026-07-20T08:00:00Z',
|
||||
waiver_conditions_json: ['三日内补交原件', '到期前复核', '三日内补交原件'],
|
||||
waiver_decision: 'approved',
|
||||
waiver_decider_id: 'employee-decider',
|
||||
waiver_decider_name: '决定人乙',
|
||||
waiver_decided_at: '2026-07-16T09:00:00Z',
|
||||
waiver_decision_reason: '补偿控制充分,同意限时豁免',
|
||||
events: [{
|
||||
id: 'waiver-event-1',
|
||||
action: 'request_waiver',
|
||||
version: 2,
|
||||
actor_id: 'employee-requester',
|
||||
actor_name: '申请人甲',
|
||||
request_id: 'waiver-request-001',
|
||||
comment: '客户现场暂时无法取得原件',
|
||||
after_json: { waiver_scope: '仅限本次单据的行程确认材料' },
|
||||
created_at: '2026-07-16T08:00:00Z'
|
||||
}]
|
||||
})
|
||||
|
||||
assert.deepEqual(disposition.availableActions, ['approve_waiver', 'reject_waiver'])
|
||||
assert.equal(disposition.readOnlyReason, '')
|
||||
assert.equal(disposition.waiverRequesterName, '申请人甲')
|
||||
assert.equal(disposition.waiverReason, '客户现场暂时无法取得原件')
|
||||
assert.equal(disposition.waiverScope, '仅限本次单据的行程确认材料')
|
||||
assert.deepEqual(disposition.waiverConditions, ['三日内补交原件', '到期前复核'])
|
||||
assert.equal(disposition.waiverDeciderName, '决定人乙')
|
||||
assert.equal(disposition.waiverDecisionReason, '补偿控制充分,同意限时豁免')
|
||||
assert.equal(disposition.events[0].actorId, 'employee-requester')
|
||||
assert.equal(disposition.events[0].requestId, 'waiver-request-001')
|
||||
assert.equal(
|
||||
disposition.events[0].afterState.waiver_scope,
|
||||
'仅限本次单据的行程确认材料'
|
||||
)
|
||||
})
|
||||
|
||||
test('observation keeps server actions when the first disposition has not been created', () => {
|
||||
const observation = normalizeRiskDisposition(null)
|
||||
assert.equal(observation, null)
|
||||
|
||||
const normalized = normalizeRiskObservation({
|
||||
id: 'risk-first-action',
|
||||
disposition: null,
|
||||
available_actions: ['confirm', 'false_positive'],
|
||||
read_only_reason: ''
|
||||
})
|
||||
assert.equal(normalized.disposition, null)
|
||||
assert.deepEqual(normalized.availableActions, ['confirm', 'false_positive'])
|
||||
assert.equal(normalized.readOnlyReason, '')
|
||||
})
|
||||
|
||||
test('waiver request and decision payloads carry idempotency key and optimistic version', async (t) => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const calls = []
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(options.body) })
|
||||
return jsonResponse({
|
||||
disposition: {
|
||||
id: 'disposition-1',
|
||||
observation_id: 'risk/1',
|
||||
version: calls.length,
|
||||
available_actions: []
|
||||
},
|
||||
event: { id: `event-${calls.length}` },
|
||||
replayed: false
|
||||
})
|
||||
}
|
||||
|
||||
await executeRiskDispositionAction('risk/1', {
|
||||
action: 'request_waiver',
|
||||
expectedVersion: 2,
|
||||
requestId: 'risk-request-waiver-001',
|
||||
waiverReason: '材料暂缺',
|
||||
waiverScope: '仅限当前单据',
|
||||
waiverExpiresAt: '2026-07-20T08:00:00.000Z',
|
||||
waiverConditions: ['三日内补件', '三日内补件', '', '财务复核']
|
||||
})
|
||||
await executeRiskDispositionAction('risk/1', {
|
||||
action: 'approve_waiver',
|
||||
expectedVersion: 3,
|
||||
requestId: 'risk-approve-waiver-001',
|
||||
comment: '补偿控制充分,同意限时豁免'
|
||||
})
|
||||
await executeRiskDispositionAction('risk/1', {
|
||||
action: 'reject_waiver',
|
||||
expectedVersion: 3,
|
||||
requestId: 'risk-reject-waiver-001',
|
||||
comment: '补偿控制不足,拒绝豁免'
|
||||
})
|
||||
|
||||
assert.equal(calls[0].url, '/api/v1/risk-observations/risk%2F1/disposition/actions')
|
||||
assert.deepEqual(calls[0].body, {
|
||||
action: 'request_waiver',
|
||||
expected_version: 2,
|
||||
request_id: 'risk-request-waiver-001',
|
||||
waiver_reason: '材料暂缺',
|
||||
waiver_scope: '仅限当前单据',
|
||||
waiver_expires_at: '2026-07-20T08:00:00.000Z',
|
||||
waiver_conditions: ['三日内补件', '财务复核']
|
||||
})
|
||||
assert.deepEqual(calls[1].body, {
|
||||
action: 'approve_waiver',
|
||||
expected_version: 3,
|
||||
request_id: 'risk-approve-waiver-001',
|
||||
comment: '补偿控制充分,同意限时豁免'
|
||||
})
|
||||
assert.deepEqual(calls[2].body, {
|
||||
action: 'reject_waiver',
|
||||
expected_version: 3,
|
||||
request_id: 'risk-reject-waiver-001',
|
||||
comment: '补偿控制不足,拒绝豁免'
|
||||
})
|
||||
})
|
||||
|
||||
test('waiver errors are presented by conflict, permission, validation and timeout type', () => {
|
||||
assert.deepEqual(
|
||||
normalizeRiskDispositionError({
|
||||
code: 'RISK_DISPOSITION_VERSION_CONFLICT',
|
||||
message: '状态已变化'
|
||||
}),
|
||||
{
|
||||
code: 'RISK_DISPOSITION_VERSION_CONFLICT',
|
||||
title: '风险状态已更新',
|
||||
message: '状态已变化',
|
||||
shouldRefresh: true
|
||||
}
|
||||
)
|
||||
assert.equal(
|
||||
normalizeRiskDispositionError({ code: 'RISK_WAIVER_DECISION_FORBIDDEN' }).title,
|
||||
'当前账号不能执行此操作'
|
||||
)
|
||||
assert.equal(normalizeRiskDispositionError({ status: 422 }).code, 'RISK_DISPOSITION_VALIDATION_FAILED')
|
||||
assert.equal(normalizeRiskDispositionError({ code: 'REQUEST_TIMEOUT' }).shouldRefresh, true)
|
||||
})
|
||||
|
||||
test('waiver UI is server-action driven and makes all three confirmations explicit', () => {
|
||||
assert.match(evidenceCard, /currentDisposition\.value\.availableActions/)
|
||||
assert.match(evidenceCard, /currentDisposition\.readOnlyReason/)
|
||||
assert.match(evidenceCard, /批准豁免/)
|
||||
assert.match(evidenceCard, /拒绝豁免/)
|
||||
assert.match(evidenceCard, /风险豁免已拒绝,仍会阻断审批/)
|
||||
assert.match(evidenceCard, /waiverExpiresAt/)
|
||||
assert.match(evidenceCard, /scheduleWaiverExpiryRefresh/)
|
||||
assert.match(evidenceCard, /onBeforeUnmount\(clearWaiverExpiryTimer\)/)
|
||||
assert.doesNotMatch(evidenceCard, /roleCodes|role_codes|isAdmin|is_admin/)
|
||||
|
||||
assert.match(waiverDialog, /确认提交豁免申请/)
|
||||
assert.match(waiverDialog, /确认批准豁免/)
|
||||
assert.match(waiverDialog, /确认拒绝豁免/)
|
||||
assert.match(waiverDialog, /请填写拒绝理由。/)
|
||||
assert.match(waiverDialog, /补偿条件最多填写 20 项。/)
|
||||
assert.match(waiverDialog, /拒绝后该风险仍会阻断审批/)
|
||||
assert.match(waiverDialog, /:disabled="busy"/)
|
||||
|
||||
assert.match(waiverRecord, /申请人/)
|
||||
assert.match(waiverRecord, /申请理由/)
|
||||
assert.match(waiverRecord, /豁免范围/)
|
||||
assert.match(waiverRecord, /有效期至/)
|
||||
assert.match(waiverRecord, /补偿条件/)
|
||||
assert.match(waiverRecord, /决定记录/)
|
||||
assert.match(waiverRecord, /waiverDecisionReason/)
|
||||
assert.match(waiverRecord, /豁免审计轨迹/)
|
||||
assert.match(waiverRecord, /event\.requestId/)
|
||||
})
|
||||
Reference in New Issue
Block a user