feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -14,10 +14,27 @@
<h2>{{ profile.name }}</h2>
<span class="identity-badge">{{ profile.identity }}</span>
</div>
<div class="applicant-meta-line">
<span><em>部门</em><strong>{{ profile.department }}</strong></span>
<span><em>职级</em><strong>{{ profile.grade }}</strong></span>
<span><em>直属上司</em><strong>{{ profile.manager }}</strong></span>
<div class="applicant-profile-meta">
<div class="applicant-profile-meta__org">
<span class="applicant-meta-item">
<em>部门</em>
<strong>{{ profile.department }}</strong>
</span>
<span class="applicant-meta-item applicant-meta-item--sub">
<em>直属上司</em>
<strong>{{ profile.manager }}</strong>
</span>
</div>
<div class="applicant-profile-meta__role">
<span class="applicant-meta-item">
<em>职级</em>
<strong>{{ profile.grade }}</strong>
</span>
<span class="applicant-meta-item">
<em>岗位</em>
<strong>{{ profile.position }}</strong>
</span>
</div>
</div>
</div>
</div>
@@ -59,8 +76,11 @@
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
<div class="progress-step-copy" :title="step.title || step.detail || step.time">
<strong>{{ step.label }}</strong>
<small class="progress-step-status">{{ step.time }}</small>
<em v-if="step.detail" class="progress-step-meta">{{ step.detail }}</em>
</div>
</div>
</div>
</div>
@@ -73,16 +93,16 @@
<div>
<h3>费用明细</h3>
<p>
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与系统校验。' }}
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
</p>
</div>
<div class="detail-card-actions">
<button class="smart-entry-btn" type="button" @click="openAiEntry">
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
</button>
<button
v-if="isEditableRequest"
v-if="isEditableRequest"
class="smart-entry-btn secondary"
type="button"
:disabled="actionBusy"
@@ -99,11 +119,11 @@
<thead>
<tr>
<th class="col-time">时间</th>
<th class="col-filled-at">填写时间</th>
<th class="col-type">费用项目</th>
<th class="col-desc">说明</th>
<th class="col-amount">金额</th>
<th class="col-attachment">附件材料</th>
<th v-if="hasExpenseRiskColumn" class="col-risk">系统校验</th>
<th v-if="isEditableRequest" class="col-action">操作</th>
</tr>
</thead>
@@ -122,6 +142,10 @@
<span>{{ item.dayLabel }}</span>
</template>
</td>
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td class="expense-type col-type">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
@@ -140,9 +164,9 @@
</td>
<td class="expense-desc col-desc">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor editor-stack">
<div class="cell-editor">
<input v-model="expenseEditor.itemReason" class="editor-input" type="text" placeholder="输入费用说明" />
<input v-model="expenseEditor.itemLocation" class="editor-input" type="text" :placeholder="locationInputPlaceholder" />
<span>业务报销说明</span>
</div>
</template>
<template v-else>
@@ -177,9 +201,11 @@
<div class="cell-editor editor-stack">
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId"
class="icon-action upload"
type="button"
title="上传附件"
title="上传单据"
aria-label="上传单据"
:disabled="actionBusy"
@click="triggerExpenseUpload(item)"
>
@@ -189,51 +215,34 @@
v-if="canPreviewAttachment(item)"
class="icon-action preview"
type="button"
title="查看附件"
:title="resolveAttachmentPreviewTitle(item)"
:aria-label="resolveAttachmentPreviewTitle(item)"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
v-if="item.invoiceId"
v-if="isEditableRequest && item.invoiceId"
class="icon-action danger"
type="button"
title="删除附件"
aria-label="删除附件"
:disabled="deletingAttachmentId === item.id"
@click="removeExpenseAttachment(item)"
>
<i :class="deletingAttachmentId === item.id ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-close-thick'"></i>
</button>
</div>
<span class="attachment-hint compact">
{{ resolveAttachmentDisplayName(item) || '支持上传 JPG、PNG、PDF未上传也可先保存草稿。' }}
</span>
<div v-if="resolveAttachmentRecognition(item)" class="attachment-recognition">
<div class="attachment-recognition-pills">
<span class="attachment-recognition-pill type">
{{ resolveAttachmentRecognition(item).documentTypeLabel }}
</span>
<span
:class="['attachment-recognition-pill', resolveAttachmentRecognition(item).requirementTone]"
>
{{ resolveAttachmentRecognition(item).requirementLabel }}
</span>
</div>
<p v-if="resolveAttachmentRecognition(item).message" class="attachment-recognition-message">
{{ resolveAttachmentRecognition(item).message }}
</p>
<ul v-if="resolveAttachmentRecognition(item).fields.length" class="attachment-recognition-fields">
<li v-for="field in resolveAttachmentRecognition(item).fields" :key="field">{{ field }}</li>
</ul>
</div>
</div>
</template>
<template v-else>
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId"
class="icon-action upload"
type="button"
title="上传附件"
title="上传单据"
aria-label="上传单据"
:disabled="actionBusy"
@click="triggerExpenseUpload(item)"
>
@@ -243,58 +252,24 @@
v-if="canPreviewAttachment(item)"
class="icon-action preview"
type="button"
title="查看附件"
:title="resolveAttachmentPreviewTitle(item)"
:aria-label="resolveAttachmentPreviewTitle(item)"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
v-if="item.invoiceId"
v-if="isEditableRequest && item.invoiceId"
class="icon-action danger"
type="button"
title="删除附件"
aria-label="删除附件"
:disabled="deletingAttachmentId === item.id"
@click="removeExpenseAttachment(item)"
>
<i :class="deletingAttachmentId === item.id ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-close-thick'"></i>
</button>
</div>
<span class="attachment-hint compact">
{{ resolveAttachmentDisplayName(item) || '未上传附件' }}
</span>
<div v-if="resolveAttachmentRecognition(item)" class="attachment-recognition">
<div class="attachment-recognition-pills">
<span class="attachment-recognition-pill type">
{{ resolveAttachmentRecognition(item).documentTypeLabel }}
</span>
<span
:class="['attachment-recognition-pill', resolveAttachmentRecognition(item).requirementTone]"
>
{{ resolveAttachmentRecognition(item).requirementLabel }}
</span>
</div>
<p v-if="resolveAttachmentRecognition(item).message" class="attachment-recognition-message">
{{ resolveAttachmentRecognition(item).message }}
</p>
<ul v-if="resolveAttachmentRecognition(item).fields.length" class="attachment-recognition-fields">
<li v-for="field in resolveAttachmentRecognition(item).fields" :key="field">{{ field }}</li>
</ul>
</div>
</template>
</td>
<td v-if="hasExpenseRiskColumn" class="expense-risk col-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', resolveExpenseRiskState(item).tone]">
{{ resolveExpenseRiskState(item).label }}
</span>
<strong class="risk-headline">{{ resolveExpenseRiskState(item).headline }}</strong>
<p>{{ resolveExpenseRiskState(item).summary }}</p>
<ul v-if="resolveExpenseRiskState(item).points.length" class="risk-point-list">
<li v-for="point in resolveExpenseRiskState(item).points" :key="point">{{ point }}</li>
</ul>
<p v-if="resolveExpenseRiskState(item).suggestion" class="risk-suggestion">
{{ resolveExpenseRiskState(item).suggestion }}
</p>
</template>
</td>
<td v-if="isEditableRequest" class="expense-action-cell col-action">
@@ -350,17 +325,6 @@
当前还没有费用明细点击右上角增加明细继续补充
</td>
</tr>
<tr class="total-row">
<td :colspan="expenseTableColumnCount">
<div class="expense-total-bar">
<strong>合计 {{ expenseTotal }}</strong>
<div class="expense-total-meta">
<span>{{ uploadedExpenseCount }} 项已关联票据</span>
<span>{{ expenseSummaryText }}</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -375,6 +339,31 @@
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div>
<p class="validation-summary">{{ aiAdvice.summary }}</p>
<div v-if="aiAdvice.riskCards.length" class="risk-advice-list">
<article
v-for="card in aiAdvice.riskCards"
:key="card.id"
:class="['risk-advice-card', card.tone]"
>
<div class="risk-advice-card-head">
<span>{{ card.label }}</span>
<strong>{{ card.title }}</strong>
</div>
<p class="risk-advice-point">{{ card.risk }}</p>
<div class="risk-advice-meta">
<div>
<span>规则依据</span>
<ul>
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
</ul>
</div>
<div>
<span>修改建议</span>
<p>{{ card.suggestion }}</p>
</div>
</div>
</article>
</div>
<ul v-if="aiAdvice.items.length" class="validation-list">
<li v-for="item in aiAdvice.items" :key="item">{{ item }}</li>
</ul>
@@ -384,6 +373,20 @@
<h3>附加说明</h3>
<div class="detail-note">{{ detailNote }}</div>
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>领导意见</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
placeholder="请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。"
aria-label="领导意见"
></textarea>
<div class="leader-opinion-meta">
<span>审批通过后将流转至财务审批</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
</section>
</div>
</div>
@@ -391,7 +394,7 @@
<footer class="detail-actions">
<button class="back-action" type="button" @click="emit('backToRequests')">
<i class="mdi mdi-arrow-left"></i>
<span>返回报销列表</span>
<span>{{ backLabel }}</span>
</button>
<div v-if="isEditableRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
@@ -403,7 +406,7 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<div v-else-if="canReturnRequest || canApproveRequest || canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
@@ -414,7 +417,23 @@
<i class="mdi mdi-undo"></i>
{{ returnBusy ? '退回中' : '退回单据' }}
</button>
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<button
v-if="canApproveRequest"
class="approve-action"
type="button"
:disabled="actionBusy"
@click="handleApproveRequest"
>
<i class="mdi mdi-check-circle-outline"></i>
{{ approveBusy ? '通过中' : '审批通过' }}
</button>
<button
v-if="canManageCurrentClaim"
class="reject-action"
type="button"
:disabled="actionBusy"
@click="handleDeleteRequest"
>
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除单据' }}
</button>
@@ -444,41 +463,145 @@
<span class="attachment-preview-badge">附件预览</span>
<h4>{{ attachmentPreviewName || '当前附件' }}</h4>
</div>
<div class="attachment-preview-toolbar">
<button
v-if="canNavigateAttachmentPreview"
class="attachment-preview-nav"
type="button"
title="上一份附件"
:disabled="attachmentPreviewLoading"
@click="goToPreviousAttachmentPreview"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<span v-if="attachmentPreviewIndexLabel" class="attachment-preview-count">{{ attachmentPreviewIndexLabel }}</span>
<button
v-if="canNavigateAttachmentPreview"
class="attachment-preview-nav"
type="button"
title="下一份附件"
:disabled="attachmentPreviewLoading"
@click="goToNextAttachmentPreview"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<button class="attachment-preview-close" type="button" @click="closeAttachmentPreview">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="attachment-preview-body">
<div v-if="attachmentPreviewLoading" class="attachment-preview-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载附件预览</span>
</div>
<div v-else-if="attachmentPreviewError" class="attachment-preview-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ attachmentPreviewError }}</span>
</div>
<img
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType.startsWith('image/')"
:src="attachmentPreviewUrl"
:alt="attachmentPreviewName || '附件图片'"
class="attachment-preview-image"
/>
<iframe
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType === 'application/pdf'"
:src="attachmentPreviewUrl"
class="attachment-preview-frame"
title="附件预览"
></iframe>
<div v-else class="attachment-preview-state">
<i class="mdi mdi-file-outline"></i>
<span>当前附件暂不支持直接预览</span>
<div class="attachment-source-pane">
<div v-if="attachmentPreviewLoading" class="attachment-preview-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载附件预览</span>
</div>
<div v-else-if="attachmentPreviewError" class="attachment-preview-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ attachmentPreviewError }}</span>
</div>
<img
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType.startsWith('image/')"
:src="attachmentPreviewUrl"
:alt="attachmentPreviewName || '附件图片'"
class="attachment-preview-image"
/>
<iframe
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType === 'application/pdf'"
:src="attachmentPreviewUrl"
class="attachment-preview-frame"
title="附件预览"
></iframe>
<div v-else class="attachment-preview-state">
<i class="mdi mdi-file-outline"></i>
<span>当前附件暂不支持直接预览</span>
</div>
</div>
<aside class="attachment-insight-pane">
<div class="attachment-insight-head">
<span>识别信息</span>
<strong>{{ currentAttachmentPreviewInsight?.documentTypeLabel || '待识别' }}</strong>
</div>
<div v-if="currentAttachmentPreviewInsight" class="attachment-insight-content">
<div class="attachment-insight-pills">
<span :class="['attachment-recognition-pill', currentAttachmentPreviewInsight.requirementTone]">
{{ currentAttachmentPreviewInsight.requirementLabel }}
</span>
</div>
<p v-if="currentAttachmentPreviewInsight.message" class="attachment-recognition-message">
{{ currentAttachmentPreviewInsight.message }}
</p>
<div v-if="currentAttachmentPreviewInsight.fields.length" class="attachment-insight-section">
<span>字段结果</span>
<ul>
<li v-for="field in currentAttachmentPreviewInsight.fields" :key="field">{{ field }}</li>
</ul>
</div>
<div v-if="currentAttachmentPreviewInsight.ruleBasis.length" class="attachment-insight-section">
<span>规则依据</span>
<ul>
<li v-for="basis in currentAttachmentPreviewInsight.ruleBasis" :key="basis">{{ basis }}</li>
</ul>
</div>
<div v-if="currentAttachmentPreviewRiskCards.length" class="attachment-insight-section risk">
<span>风险点</span>
<article
v-for="card in currentAttachmentPreviewRiskCards"
:key="card.id"
:class="['attachment-risk-card', card.tone]"
>
<strong>{{ card.risk }}</strong>
<p>{{ card.suggestion }}</p>
</article>
</div>
</div>
<div v-else class="attachment-preview-state compact">
<i class="mdi mdi-file-search-outline"></i>
<span>预览打开后会在这里展示票据字段规则依据和风险提示</span>
</div>
</aside>
</div>
</section>
</div>
</Transition>
<ConfirmDialog
:open="submitConfirmDialogOpen"
badge="提交确认"
badge-tone="warning"
:title="`确认提交 ${request.id} 吗?`"
description="请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。"
cancel-text="返回核对"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-circle-outline"
:busy="submitBusy"
@close="closeSubmitConfirmDialog"
@confirm="confirmSubmitRequest"
>
<div class="submit-confirm-summary" aria-label="提交前核对摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ request.documentNo || request.id }}</strong>
</div>
<div class="submit-confirm-row">
<span>报销类型</span>
<strong>{{ request.typeLabel }}</strong>
</div>
<div class="submit-confirm-row">
<span>报销金额</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
</div>
<div class="submit-confirm-row">
<span>费用明细</span>
<strong>{{ expenseItems.length }} / {{ uploadedExpenseCount }} 张单据</strong>
</div>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="deleteDialogOpen"
:badge="deleteActionLabel"
@@ -496,16 +619,44 @@
/>
<ConfirmDialog
:open="approveConfirmDialogOpen"
badge="领导审批"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
description="确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
confirm-tone="primary"
confirm-icon="mdi mdi-check-circle-outline"
:busy="approveBusy"
@close="closeApproveConfirmDialog"
@confirm="confirmApproveRequest"
>
<div class="submit-confirm-summary" aria-label="领导审批通过摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ request.documentNo || request.id }}</strong>
</div>
<div class="submit-confirm-row">
<span>当前节点</span>
<strong>{{ request.node }}</strong>
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<strong>财务审批</strong>
</div>
<div class="submit-confirm-row">
<span>领导意见</span>
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
</div>
</div>
</ConfirmDialog>
<ReturnReasonDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"