Files
X-Financial/web/src/views/TravelRequestDetailView.vue
caoxiaozhu 8f65661809 feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
2026-05-21 09:28:33 +08:00

670 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="approval-page">
<div class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="hero-banner">
<div class="hero-banner-main">
<div class="applicant-card">
<div class="portrait">
<img src="/assets/person.png" alt="" />
</div>
<div class="applicant-copy">
<div class="applicant-name-row">
<h2>{{ profile.name }}</h2>
<span class="identity-badge">{{ profile.identity }}</span>
</div>
<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>
<div class="hero-fact-grid">
<div v-for="item in heroFactItems" :key="item.key" class="hero-fact">
<div class="hero-fact-label">
<i v-if="item.icon" :class="item.icon"></i>
<span>{{ item.label }}</span>
</div>
<strong :class="item.valueClass">{{ item.value }}</strong>
</div>
</div>
</div>
</div>
</article>
<article class="progress-card panel">
<div class="progress-block">
<div class="progress-head">
<h3>{{ isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
</div>
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
<div
v-for="step in progressSteps"
:key="step.label"
class="progress-step"
:class="{ active: step.active, current: step.current, done: step.done }"
>
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<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>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
</p>
</div>
<div class="detail-card-actions">
<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"
class="smart-entry-btn secondary"
type="button"
:disabled="actionBusy"
@click="handleAddExpenseItem"
>
<i class="mdi mdi-plus-circle-outline"></i>
<span>{{ creatingExpense ? '新增中' : '增加明细' }}</span>
</button>
</div>
</div>
<div class="detail-expense-table">
<table>
<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="isEditableRequest" class="col-action">操作</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time col-time">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
<span>{{ item.dayLabel }}</span>
</div>
</template>
<template v-else>
<strong>{{ item.time }}</strong>
<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">
<select v-model="expenseEditor.itemType" class="editor-select">
<option v-for="option in expenseTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<span>编辑费用项目</span>
</div>
</template>
<template v-else>
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</template>
</td>
<td class="expense-desc col-desc">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<input v-model="expenseEditor.itemReason" class="editor-input" type="text" placeholder="输入费用说明" />
<span>业务报销说明</span>
</div>
</template>
<template v-else>
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</template>
</td>
<td class="expense-amount col-amount">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<label class="currency-editor">
<span></span>
<input
v-model="expenseEditor.itemAmount"
class="editor-input"
type="number"
min="0"
step="0.01"
placeholder="输入金额"
/>
</label>
<span>保存后自动格式化为人民币</span>
</div>
</template>
<template v-else>
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</template>
</td>
<td class="expense-attachment col-attachment">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor editor-stack">
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId"
class="icon-action upload"
type="button"
title="上传单据"
aria-label="上传单据"
:disabled="actionBusy"
@click="triggerExpenseUpload(item)"
>
<i :class="uploadingExpenseId === item.id ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-file-upload-outline'"></i>
</button>
<button
v-if="canPreviewAttachment(item)"
class="icon-action preview"
type="button"
:title="resolveAttachmentPreviewTitle(item)"
:aria-label="resolveAttachmentPreviewTitle(item)"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
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>
</div>
</template>
<template v-else>
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId"
class="icon-action upload"
type="button"
title="上传单据"
aria-label="上传单据"
:disabled="actionBusy"
@click="triggerExpenseUpload(item)"
>
<i :class="uploadingExpenseId === item.id ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-file-upload-outline'"></i>
</button>
<button
v-if="canPreviewAttachment(item)"
class="icon-action preview"
type="button"
:title="resolveAttachmentPreviewTitle(item)"
:aria-label="resolveAttachmentPreviewTitle(item)"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
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>
</template>
</td>
<td v-if="isEditableRequest" class="expense-action-cell col-action">
<div v-if="editingExpenseId === item.id" class="row-action-group">
<button
class="inline-action primary"
type="button"
:disabled="savingExpenseId === item.id || submitBusy || deleteBusy || deletingExpenseId === item.id"
@click="saveExpenseEdit(item)"
>
{{ savingExpenseId === item.id ? '保存中' : '保存' }}
</button>
<button
class="inline-action"
type="button"
:disabled="savingExpenseId === item.id || submitBusy || deleteBusy || deletingExpenseId === item.id"
@click="cancelExpenseEdit"
>
取消
</button>
<button
class="inline-action danger"
type="button"
:disabled="savingExpenseId === item.id || submitBusy || deleteBusy || deletingExpenseId === item.id"
@click="removeExpenseItem(item)"
>
{{ deletingExpenseId === item.id ? '删除中' : '删除' }}
</button>
</div>
<div v-else class="row-action-group">
<button
class="inline-action"
type="button"
:disabled="actionBusy"
@click="startExpenseEdit(item)"
>
编辑
</button>
<button
class="inline-action danger"
type="button"
:disabled="actionBusy"
@click="removeExpenseItem(item)"
>
{{ deletingExpenseId === item.id ? '删除中' : '删除' }}
</button>
</div>
</td>
</tr>
</template>
<tr v-if="!expenseItems.length" class="empty-row">
<td :colspan="expenseTableColumnCount" class="empty-row-cell">
当前还没有费用明细点击右上角增加明细继续补充
</td>
</tr>
</tbody>
</table>
</div>
</article>
<article v-if="isEditableRequest" class="detail-card panel validation-card">
<div class="validation-head">
<div>
<h3>AI建议</h3>
<p>按建议顺序补齐信息或处理风险后再发起审批</p>
</div>
<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>
</article>
<article class="detail-card panel">
<h3>附加说明</h3>
<div class="detail-note">{{ detailNote }}</div>
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>{{ approvalOpinionTitle }}</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="emit('backToRequests')">
<i class="mdi mdi-arrow-left"></i>
<span>{{ backLabel }}</span>
</button>
<div v-if="isEditableRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : deleteActionLabel }}
</button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i class="mdi mdi-send-circle-outline"></i>
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canReturnRequest || canApproveRequest || canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
type="button"
:disabled="actionBusy"
@click="handleReturnRequest"
>
<i class="mdi mdi-undo"></i>
{{ returnBusy ? '退回中' : '退回单据' }}
</button>
<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>
</div>
<p v-else class="detail-action-hint">当前单据已进入流程详情页仅展示状态与费用明细</p>
</footer>
</div>
<input
ref="expenseUploadInput"
class="expense-upload-input"
type="file"
accept="image/*,.pdf"
@change="handleExpenseFileChange"
/>
<Transition name="shared-confirm">
<div
v-if="attachmentPreviewOpen"
class="attachment-preview-mask"
role="presentation"
@click.self="closeAttachmentPreview"
>
<section class="attachment-preview-card" role="dialog" aria-modal="true" @click.stop>
<div class="attachment-preview-head">
<div>
<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 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"
badge-tone="danger"
:title="deleteDialogTitle"
:description="deleteDialogDescription"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-trash-can-outline"
:busy="deleteBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteRequest"
/>
<ConfirmDialog
:open="approveConfirmDialogOpen"
:badge="approvalConfirmBadge"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
:description="approvalConfirmDescription"
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>{{ approvalNextStage }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ approvalOpinionTitle }}</span>
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
</div>
</div>
</ConfirmDialog>
<ReturnReasonDialog
:open="returnDialogOpen"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"
/>
</section>
</template>
<script src="./scripts/TravelRequestDetailView.js"></script>
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>