feat(approval): add task workflow and waiver decisions

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

View File

@@ -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 {

View 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;
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
@confirm="handleConfirm"
>
<div class="return-reason-dialog">
<small v-if="error" class="error" role="alert">{{ error }}</small>
<div class="return-reason-section">
<span>{{ optionsTitle }}</span>
<div class="return-reason-options" role="group" :aria-label="optionsAriaLabel">
@@ -114,6 +115,7 @@ const props = defineProps({
busy: { type: Boolean, default: false },
claimNo: { type: String, default: '' },
application: { type: Boolean, default: false },
error: { type: String, default: '' },
title: { type: String, default: '确认退回该单据吗?' },
description: {
type: String,
@@ -238,6 +240,16 @@ function handleConfirm() {
gap: 14px;
}
.return-reason-dialog > small.error {
padding: 9px 11px;
border: 1px solid #fecaca;
border-radius: 7px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
line-height: 1.5;
}
.return-reason-section {
display: grid;
gap: 8px;

View File

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

View File

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

View File

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

View 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
}
}

View 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 }
}

View 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)
}

View File

@@ -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
}
}

View File

@@ -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 = {

View File

@@ -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"
/>

View File

@@ -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

View 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))
}

View 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 ''
}

View 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)
}
}

View 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
}