Files
X-Financial/web/src/views/TravelRequestDetailView.vue

452 lines
22 KiB
Vue
Raw Normal View History

<template>
<section class="approval-page">
<div class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="hero-topline">
<div class="applicant-card">
<div class="portrait">{{ profile.avatar }}</div>
<div class="applicant-copy">
<h2>{{ profile.name }} <span>{{ request.typeLabel }}</span></h2>
<p>{{ profile.position }} · {{ profile.identity }}</p>
<div class="applicant-meta">
<div v-for="item in profile.facts" :key="item.label" class="applicant-meta-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</div>
<div class="hero-stat-strip">
<div v-for="stat in heroStats" :key="stat.label" :class="['hero-stat', { emphasis: stat.emphasis }]">
<span>{{ stat.label }}</span>
<strong v-if="stat.kind === 'text'">{{ stat.value }}</strong>
<b v-else :class="[stat.className, stat.tone]">{{ stat.value }}</b>
</div>
</div>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<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>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</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 ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与系统校验。' }}
</p>
</div>
<div class="detail-card-actions">
<button class="smart-entry-btn" type="button" @click="openAiEntry">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
</button>
<button
v-if="isDraftRequest"
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-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="isDraftRequest" 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-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 editor-stack">
<input v-model="expenseEditor.itemReason" class="editor-input" type="text" placeholder="输入费用说明" />
<input v-model="expenseEditor.itemLocation" class="editor-input" type="text" :placeholder="locationInputPlaceholder" />
</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
class="icon-action upload"
type="button"
title="上传附件"
: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="查看附件"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
v-if="item.invoiceId"
class="icon-action danger"
type="button"
title="删除附件"
: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>
</template>
<template v-else>
<div class="attachment-action-group">
<button
class="icon-action upload"
type="button"
title="上传附件"
: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="查看附件"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
v-if="item.invoiceId"
class="icon-action danger"
type="button"
title="删除附件"
: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>
</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="isDraftRequest" 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>
<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>
</article>
<article v-if="isDraftRequest" 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>
<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>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="emit('backToRequests')">
<i class="mdi mdi-arrow-left"></i>
<span>返回报销列表</span>
</button>
<div v-if="isDraftRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteDraft">
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除草稿' }}
</button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i class="mdi mdi-send-circle-outline"></i>
{{ submitBusy ? '提交中' : '提交审批' }}
</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>
<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>
</div>
</section>
</div>
</Transition>
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除草稿"
badge-tone="danger"
:title="`确认删除草稿 ${request.id} 吗?`"
description="删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-trash-can-outline"
:busy="deleteBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteDraft"
/>
</section>
</template>
<script src="./scripts/TravelRequestDetailView.js"></script>
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>