Files
X-Financial/web/src/views/TravelRequestDetailView.vue
caoxiaozhu e701fa01da feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
2026-05-21 16:09:47 +08:00

723 lines
33 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>用于说明本次出差或办事目的例如去哪里拜访谁处理什么事项</p>
</div>
</div>
<div v-if="canEditDetailNote" class="detail-note-editor">
<textarea
v-model="detailNoteEditor"
maxlength="500"
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
aria-label="附加说明"
></textarea>
<div class="detail-note-editor-meta">
<span>仅草稿待提交状态可编辑提交后将作为明确说明展示</span>
<div class="detail-note-actions">
<button
v-if="detailNoteDirty"
class="inline-action"
type="button"
:disabled="savingDetailNote"
@click="resetDetailNote"
>
恢复
</button>
<button
class="inline-action primary"
type="button"
:disabled="!detailNoteDirty || savingDetailNote"
@click="saveDetailNote"
>
{{ savingDetailNote ? '保存中' : '保存说明' }}
</button>
</div>
</div>
</div>
<div v-else class="detail-note readonly">{{ detailNote }}</div>
</article>
<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 :class="{ 'system-generated-row': item.isSystemGenerated }">
<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="resolveExpenseReasonPlaceholder(expenseEditor.itemType)"
/>
<span>{{ resolveExpenseReasonHelper(expenseEditor.itemType) }}</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 && !item.isSystemGenerated"
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 && !item.isSystemGenerated"
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 v-if="item.isSystemGenerated" class="system-attachment-note">
<i class="mdi mdi-calculator-variant-outline"></i>
<span>无需附件</span>
</div>
<div v-else class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
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 && !item.isSystemGenerated"
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="item.isSystemGenerated" class="system-row-lock">
<i class="mdi mdi-lock-outline"></i>
<span>系统计算</span>
</div>
<div v-else-if="editingExpenseId === item.id" class="row-action-group">
<button
class="inline-action primary"
type="button"
:disabled="actionBusy"
@click="saveExpenseEdit(item)"
>
{{ savingExpenseId === item.id ? '保存中' : '保存' }}
</button>
<button
class="inline-action danger"
type="button"
:disabled="actionBusy"
@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>
<div v-if="expenseItems.length" class="expense-total-under-table">
<span>金额合计</span>
<strong>{{ expenseTotal }}</strong>
</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.sections.length" class="validation-sections">
<section
v-for="section in aiAdvice.sections"
:key="section.kind"
:class="['validation-section', `validation-section--${section.kind}`]"
>
<h4 class="validation-section-title">{{ section.title }}</h4>
<ul v-if="section.kind === 'completion'" class="validation-list">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
<div v-else class="risk-advice-list">
<article
v-for="card in section.items"
: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>
</section>
</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>