feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -1,200 +1,202 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<SidebarRail
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:company-name="companyProfile.name"
|
||||
:current-user="currentUser"
|
||||
@navigate="handleNavigate"
|
||||
@open-chat="openSmartEntry"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main"
|
||||
:class="{
|
||||
'overview-main': activeView === 'overview',
|
||||
'workbench-main': activeView === 'workbench',
|
||||
'requests-main': activeView === 'requests',
|
||||
'approval-main': activeView === 'approval',
|
||||
'policies-main': activeView === 'policies',
|
||||
'audit-main': activeView === 'audit',
|
||||
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
|
||||
'logs-main': activeView === 'logs',
|
||||
'employees-main': activeView === 'employees',
|
||||
'settings-main': activeView === 'settings'
|
||||
}"
|
||||
<template>
|
||||
<div class="app">
|
||||
<SidebarRail
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:company-name="companyProfile.name"
|
||||
:current-user="currentUser"
|
||||
@navigate="handleNavigate"
|
||||
@open-chat="openSmartEntry"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main"
|
||||
:class="{
|
||||
'overview-main': activeView === 'overview',
|
||||
'workbench-main': activeView === 'workbench',
|
||||
'requests-main': activeView === 'requests',
|
||||
'approval-main': activeView === 'approval',
|
||||
'policies-main': activeView === 'policies',
|
||||
'audit-main': activeView === 'audit',
|
||||
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
|
||||
'logs-main': activeView === 'logs',
|
||||
'employees-main': activeView === 'employees',
|
||||
'settings-main': activeView === 'settings'
|
||||
}"
|
||||
>
|
||||
<TopBar
|
||||
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen)"
|
||||
:current-view="topBarView"
|
||||
:search="search"
|
||||
:active-view="activeView"
|
||||
:ranges="ranges"
|
||||
:active-range="activeRange"
|
||||
:employee-summary="employeeSummary"
|
||||
:knowledge-summary="knowledgeSummary"
|
||||
:logs-summary="logsSummary"
|
||||
:request-summary="requestSummary"
|
||||
:detail-mode="detailMode"
|
||||
:log-detail-mode="logDetailMode"
|
||||
:detail-alerts="detailAlerts"
|
||||
:custom-range="customRange"
|
||||
@update:search="search = $event"
|
||||
@update:active-range="activeRange = $event"
|
||||
@update:custom-range="customRange = $event"
|
||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||
@new-application="openTravelCreate"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
:compact="activeView === 'overview'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
<TopBar
|
||||
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen)"
|
||||
:current-view="topBarView"
|
||||
:search="search"
|
||||
:active-view="activeView"
|
||||
:ranges="ranges"
|
||||
:active-range="activeRange"
|
||||
:employee-summary="employeeSummary"
|
||||
:knowledge-summary="knowledgeSummary"
|
||||
:logs-summary="logsSummary"
|
||||
:request-summary="requestSummary"
|
||||
:workbench-summary="workbenchSummary"
|
||||
:detail-mode="detailMode"
|
||||
:log-detail-mode="logDetailMode"
|
||||
:detail-alerts="detailAlerts"
|
||||
:custom-range="customRange"
|
||||
@update:search="search = $event"
|
||||
@update:active-range="activeRange = $event"
|
||||
@update:custom-range="customRange = $event"
|
||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||
@new-application="openTravelCreate"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
:compact="activeView === 'overview'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
:active-range="activeRange"
|
||||
@update:active-range="activeRange = $event"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="workarea"
|
||||
:class="{
|
||||
'requests-workarea': activeView === 'requests',
|
||||
'approval-workarea': activeView === 'approval',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'logs-workarea': activeView === 'logs',
|
||||
'employees-workarea': activeView === 'employees',
|
||||
'settings-workarea': activeView === 'settings'
|
||||
}"
|
||||
<section
|
||||
class="workarea"
|
||||
:class="{
|
||||
'requests-workarea': activeView === 'requests',
|
||||
'approval-workarea': activeView === 'approval',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'logs-workarea': activeView === 'logs',
|
||||
'employees-workarea': activeView === 'employees',
|
||||
'settings-workarea': activeView === 'settings'
|
||||
}"
|
||||
>
|
||||
<OverviewView
|
||||
v-if="activeView === 'overview'"
|
||||
:filtered-requests="filteredRequests"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
/>
|
||||
<OverviewView
|
||||
v-if="activeView === 'overview'"
|
||||
:filtered-requests="filteredRequests"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
/>
|
||||
|
||||
<PersonalWorkbenchView
|
||||
v-else-if="activeView === 'workbench'"
|
||||
:assistant-modal-open="smartEntryOpen"
|
||||
@open-assistant="openSmartEntry"
|
||||
/>
|
||||
|
||||
<TravelRequestDetailView
|
||||
v-else-if="activeView === 'requests' && detailMode && selectedRequest"
|
||||
:request="selectedRequest"
|
||||
@back-to-requests="closeRequestDetail"
|
||||
@open-assistant="openSmartEntry"
|
||||
@request-updated="handleRequestUpdated"
|
||||
@request-deleted="handleRequestDeleted"
|
||||
/>
|
||||
<PersonalWorkbenchView
|
||||
v-else-if="activeView === 'workbench'"
|
||||
:assistant-modal-open="smartEntryOpen"
|
||||
@open-assistant="openSmartEntry"
|
||||
/>
|
||||
|
||||
<RequestsView
|
||||
v-else-if="activeView === 'requests'"
|
||||
:filtered-requests="filteredRequests"
|
||||
:has-data="requests.length > 0"
|
||||
:loading="requestsLoading"
|
||||
:error="requestsError"
|
||||
@ask="openRequestDetail"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
@reload="reloadRequests"
|
||||
@create-request="openTravelCreate"
|
||||
/>
|
||||
<TravelRequestDetailView
|
||||
v-else-if="activeView === 'requests' && detailMode && selectedRequest"
|
||||
:request="selectedRequest"
|
||||
@back-to-requests="closeRequestDetail"
|
||||
@open-assistant="openSmartEntry"
|
||||
@request-updated="handleRequestUpdated"
|
||||
@request-deleted="handleRequestDeleted"
|
||||
/>
|
||||
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
|
||||
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
||||
<SettingsView v-else />
|
||||
<RequestsView
|
||||
v-else-if="activeView === 'requests'"
|
||||
:filtered-requests="filteredRequests"
|
||||
:has-data="requests.length > 0"
|
||||
:loading="requestsLoading"
|
||||
:error="requestsError"
|
||||
@ask="openRequestDetail"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
@reload="reloadRequests"
|
||||
@create-request="openTravelCreate"
|
||||
/>
|
||||
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
|
||||
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
||||
<SettingsView v-else />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<TravelReimbursementCreateView
|
||||
v-if="smartEntryOpen"
|
||||
:key="smartEntrySessionId"
|
||||
:initial-prompt="smartEntryContext.prompt"
|
||||
:initial-files="smartEntryContext.files"
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<TravelReimbursementCreateView
|
||||
v-if="smartEntryOpen"
|
||||
:key="smartEntrySessionId"
|
||||
:initial-prompt="smartEntryContext.prompt"
|
||||
:initial-files="smartEntryContext.files"
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import RequestsView from './RequestsView.vue'
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import RequestsView from './RequestsView.vue'
|
||||
import ApprovalCenterView from './ApprovalCenterView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import LogsView from './LogsView.vue'
|
||||
import LogDetailView from './LogDetailView.vue'
|
||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||
import SettingsView from './SettingsView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import LogsView from './LogsView.vue'
|
||||
import LogDetailView from './LogDetailView.vue'
|
||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||
import SettingsView from './SettingsView.vue'
|
||||
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
const knowledgeSummary = ref(null)
|
||||
const logsSummary = ref(null)
|
||||
const auditDetailOpen = ref(false)
|
||||
const employeeSummary = ref(null)
|
||||
const knowledgeSummary = ref(null)
|
||||
const logsSummary = ref(null)
|
||||
const auditDetailOpen = ref(false)
|
||||
|
||||
const {
|
||||
activeRange,
|
||||
activeView,
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
customRange,
|
||||
detailAlerts,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
handleNavigate,
|
||||
handleReject,
|
||||
handleRequestDeleted,
|
||||
handleRequestUpdated,
|
||||
navItems,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
requestSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
smartEntryContext,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
topBarView
|
||||
} = useAppShell()
|
||||
const {
|
||||
activeRange,
|
||||
activeView,
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
customRange,
|
||||
detailAlerts,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
handleNavigate,
|
||||
handleReject,
|
||||
handleRequestDeleted,
|
||||
handleRequestUpdated,
|
||||
navItems,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
requestSummary,
|
||||
workbenchSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
smartEntryContext,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
topBarView
|
||||
} = useAppShell()
|
||||
|
||||
const { companyProfile, currentUser, logout } = useSystemState()
|
||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
}
|
||||
</script>
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,429 +1,15 @@
|
||||
<template>
|
||||
<section class="approval-page">
|
||||
<!-- ───── Detail Modal Overlay ───── -->
|
||||
<Teleport to="body">
|
||||
<Transition name="detail-modal">
|
||||
<div v-if="false && selectedRow" class="detail-overlay" @click.self="selectedRow = null">
|
||||
<div class="detail-modal">
|
||||
<!-- Modal Header -->
|
||||
<header class="modal-header">
|
||||
<div class="header-left">
|
||||
<div class="req-badge">{{ selectedRow.id }}</div>
|
||||
<div class="header-title-group">
|
||||
<h2>{{ selectedRow.type }}审批详情</h2>
|
||||
<p>申请人:{{ selectedRow.applicant }} · {{ selectedRow.department }} · {{ selectedRow.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-indicator" :class="selectedRow.riskTone">
|
||||
<i class="mdi" :class="selectedRow.riskTone === 'high' ? 'mdi-alert-circle' : selectedRow.riskTone === 'medium' ? 'mdi-alert' : 'mdi-shield-check'"></i>
|
||||
<span>{{ selectedRow.risk }}</span>
|
||||
</div>
|
||||
<div class="header-indicator status" :class="selectedRow.statusTone">
|
||||
<span>{{ selectedRow.node }}</span>
|
||||
</div>
|
||||
<button class="close-btn" type="button" aria-label="关闭" @click="selectedRow = null">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<TravelRequestDetailView
|
||||
v-if="selectedRow"
|
||||
:request="selectedRow"
|
||||
back-label="返回审批列表"
|
||||
approval-mode
|
||||
@back-to-requests="closeSelectedDetail"
|
||||
@request-updated="handleDetailUpdated"
|
||||
@request-deleted="handleDetailDeleted"
|
||||
/>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="modal-progress">
|
||||
<div class="progress-track">
|
||||
<div v-for="(step, idx) in approvalSteps" :key="step.label" class="progress-node" :class="{ done: step.done, active: step.active, current: step.current }">
|
||||
<span class="node-dot">
|
||||
<i v-if="step.done" class="mdi mdi-check"></i>
|
||||
<template v-else>{{ step.index }}</template>
|
||||
</span>
|
||||
<div class="node-label">
|
||||
<strong>{{ step.label }}</strong>
|
||||
<small>{{ step.time }}</small>
|
||||
</div>
|
||||
<span v-if="idx < approvalSteps.length - 1" class="node-line" :class="{ filled: step.done || step.active }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="modal-body">
|
||||
<div class="body-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="body-main">
|
||||
<!-- 费用摘要 -->
|
||||
<article class="content-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<i class="mdi mdi-clipboard-text-outline"></i>
|
||||
<h3>费用摘要</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics-strip">
|
||||
<div class="metric-block amount">
|
||||
<span class="metric-label">报销金额</span>
|
||||
<strong class="metric-value">{{ selectedRow.amount }}</strong>
|
||||
</div>
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">SLA 剩余</span>
|
||||
<strong class="metric-value sla" :class="selectedRow.slaTone">
|
||||
<i class="mdi mdi-clock-outline"></i>
|
||||
{{ selectedRow.sla }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">费用明细</span>
|
||||
<strong class="metric-value">5 项</strong>
|
||||
</div>
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">附件材料</span>
|
||||
<strong class="metric-value">6 份</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-grid">
|
||||
<div v-for="item in summaryItems" :key="item.label" class="summary-cell">
|
||||
<div class="cell-icon"><i :class="item.icon"></i></div>
|
||||
<div class="cell-content">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<article class="content-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<i class="mdi mdi-receipt-text-outline"></i>
|
||||
<h3>费用明细</h3>
|
||||
</div>
|
||||
<span class="card-badge">合计 ¥6,920</span>
|
||||
</div>
|
||||
<div class="expense-table-wrap">
|
||||
<table class="expense-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>费用项目</th>
|
||||
<th>说明</th>
|
||||
<th class="right">金额</th>
|
||||
<th class="center">是否超标</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in expenseItems" :key="item.name">
|
||||
<td><strong>{{ item.name }}</strong></td>
|
||||
<td>{{ item.desc }}</td>
|
||||
<td class="right">{{ item.amount }}</td>
|
||||
<td class="center">
|
||||
<span class="over-badge" :class="item.tone">
|
||||
<i class="mdi" :class="item.tone === 'ok' ? 'mdi-check-circle' : 'mdi-alert-circle'"></i>
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2"><strong>合计</strong></td>
|
||||
<td class="right"><strong class="total-amount">¥6,920</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 审批意见 -->
|
||||
<article class="content-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<i class="mdi mdi-comment-text-outline"></i>
|
||||
<h3>审批意见</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opinion-wrap">
|
||||
<textarea rows="4" placeholder="请输入审批意见..."></textarea>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<aside class="body-side">
|
||||
<!-- AI 风险识别 -->
|
||||
<article class="side-card risk-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<i class="mdi mdi-robot-outline"></i>
|
||||
<h3>AI 风险识别</h3>
|
||||
</div>
|
||||
<div class="risk-total high">
|
||||
<span>综合风险</span>
|
||||
<strong>高</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="risk-items">
|
||||
<div v-for="risk in riskItems" :key="risk.text" class="risk-row" :class="risk.tone">
|
||||
<div class="risk-icon">
|
||||
<i :class="risk.icon"></i>
|
||||
</div>
|
||||
<span class="risk-text">{{ risk.text }}</span>
|
||||
<span class="risk-level" :class="risk.tone">{{ risk.level }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="risk-note">
|
||||
<strong>AI 审核建议</strong>
|
||||
<p>优先补齐酒店入住清单,并复核出租车发票抬头与超标费用说明;完成后可继续流转。</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 附件材料 -->
|
||||
<article class="side-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
<h3>附件材料</h3>
|
||||
</div>
|
||||
<span class="card-badge warn">1 份缺失</span>
|
||||
</div>
|
||||
<div class="attachment-list-side">
|
||||
<div v-for="file in attachments" :key="file.name" class="attachment-row" :class="{ missing: file.missing }">
|
||||
<div class="file-icon-sm" :class="file.iconClass">
|
||||
<i :class="file.icon"></i>
|
||||
</div>
|
||||
<div class="file-detail">
|
||||
<strong>{{ file.name }}</strong>
|
||||
<span>{{ file.size }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<footer class="modal-footer">
|
||||
<div class="footer-left">
|
||||
<button class="action-btn back" type="button" @click="selectedRow = null">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>返回列表</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<button class="action-btn supplement" type="button">
|
||||
<i class="mdi mdi-undo"></i>
|
||||
<span>补充材料</span>
|
||||
</button>
|
||||
<button class="action-btn reject" type="button">
|
||||
<i class="mdi mdi-close-circle-outline"></i>
|
||||
<span>驳回</span>
|
||||
</button>
|
||||
<button class="action-btn approve" type="button">
|
||||
<i class="mdi mdi-check-circle-outline"></i>
|
||||
<span>通过</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div v-if="selectedRow" class="approval-detail">
|
||||
<div class="detail-scroll">
|
||||
<article class="detail-hero panel">
|
||||
<div class="applicant-card">
|
||||
<div class="portrait">{{ selectedRow.avatar }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedRow.applicant }} <span>{{ selectedRow.department }}</span></h2>
|
||||
<p>提交时间 <strong>{{ selectedRow.time }}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-stat">
|
||||
<span>金额</span>
|
||||
<strong>{{ selectedRow.amount }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span>风险等级</span>
|
||||
<b :class="['risk-pill', selectedRow.riskTone]">{{ selectedRow.risk }}</b>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span>当前状态</span>
|
||||
<b class="state-pill">{{ selectedRow.node }}</b>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span>SLA 剩余时间</span>
|
||||
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> 剩余 {{ selectedRow.sla }}</strong>
|
||||
</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>当前进度</h3>
|
||||
</div>
|
||||
<div class="progress-line">
|
||||
<div v-for="step in approvalSteps" :key="step.label" class="progress-step" :class="{ active: step.active, current: step.current }">
|
||||
<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>按发生时间逐笔展示,附件与 AI 风险直接在表内完成核对。</p>
|
||||
</div>
|
||||
<span class="detail-total">{{ expenseTotal }}</span>
|
||||
</div>
|
||||
<div class="detail-expense-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>费用项目</th>
|
||||
<th>说明</th>
|
||||
<th>金额</th>
|
||||
<th>附件材料</th>
|
||||
<th>AI 风险识别</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<tr>
|
||||
<td class="expense-time">
|
||||
<strong>{{ item.time }}</strong>
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
</td>
|
||||
<td class="expense-type">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.category }}</span>
|
||||
</td>
|
||||
<td class="expense-desc">
|
||||
<strong>{{ item.desc }}</strong>
|
||||
<span>{{ item.detail }}</span>
|
||||
</td>
|
||||
<td class="expense-amount">
|
||||
<strong>{{ item.amount }}</strong>
|
||||
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
|
||||
</td>
|
||||
<td class="expense-attachment">
|
||||
<div class="expense-attachment-main">
|
||||
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
|
||||
<button
|
||||
v-if="item.attachments.length"
|
||||
class="inline-action"
|
||||
type="button"
|
||||
@click="toggleExpenseAttachments(item.id)"
|
||||
>
|
||||
{{ expandedExpenseId === item.id ? '收起附件' : '查看附件' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="attachment-hint">{{ item.attachmentHint }}</span>
|
||||
</td>
|
||||
<td class="expense-risk">
|
||||
<template v-if="showExpenseRisk(item)">
|
||||
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
|
||||
<p>{{ item.riskText }}</p>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
|
||||
<td colspan="6">
|
||||
<div class="expense-files">
|
||||
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
{{ file }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr class="total-row">
|
||||
<td colspan="3">合计</td>
|
||||
<td>{{ expenseTotal }}</td>
|
||||
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
|
||||
<td>1 项待补材料,1 项需补充超标说明</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
<h3>审批意见</h3>
|
||||
<textarea rows="3" placeholder="输入审批意见..."></textarea>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="detail-actions">
|
||||
<button class="back-action" type="button" @click="selectedRow = null">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>退回列表</span>
|
||||
</button>
|
||||
<div class="approval-action-group" aria-label="审批操作">
|
||||
<button class="approve-action" type="button" :disabled="actionBusy">
|
||||
<i class="mdi mdi-check-circle-outline"></i> 通过
|
||||
</button>
|
||||
<button
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="!canManageClaims || actionBusy"
|
||||
@click="handleReturnSelected"
|
||||
>
|
||||
<i class="mdi mdi-close-circle-outline"></i> 驳回
|
||||
</button>
|
||||
<button
|
||||
class="supplement-action"
|
||||
type="button"
|
||||
:disabled="!canManageClaims || actionBusy"
|
||||
@click="handleReturnSelected"
|
||||
>
|
||||
<i class="mdi mdi-undo"></i> 补充
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageClaims"
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="mdi mdi-trash-can-outline"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- ───── Approval List ───── -->
|
||||
<article v-else class="approval-list panel">
|
||||
<nav class="status-tabs" aria-label="审批状态">
|
||||
<button
|
||||
@@ -529,38 +115,6 @@
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="returnDialogOpen"
|
||||
badge="退回单据"
|
||||
badge-tone="warning"
|
||||
:title="`确认退回 ${selectedRow?.id || ''} 吗?`"
|
||||
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认退回"
|
||||
busy-text="退回中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-undo"
|
||||
:busy="actionBusy"
|
||||
@close="closeReturnDialog"
|
||||
@confirm="confirmReturnSelected"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
badge="删除单据"
|
||||
badge-tone="danger"
|
||||
:title="`确认删除 ${selectedRow?.id || ''} 吗?`"
|
||||
description="删除后该报销单及费用明细将不可恢复,请确认本次操作。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
busy-text="删除中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-trash-can-outline"
|
||||
:busy="actionBusy"
|
||||
@close="closeDeleteDialog"
|
||||
@confirm="confirmDeleteSelected"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -287,12 +287,10 @@
|
||||
class="history-row"
|
||||
>
|
||||
<strong>{{ item.action }}</strong>
|
||||
<div class="history-row-meta">
|
||||
<span class="history-row-owner">{{ item.owner }}</span>
|
||||
<small class="history-row-time">{{
|
||||
formatEmployeeHistoryTime(item.time || item.occurredAt)
|
||||
}}</small>
|
||||
</div>
|
||||
<span class="history-row-owner">{{ item.owner }}</span>
|
||||
<small class="history-row-time">{{
|
||||
formatEmployeeHistoryTime(item.time || item.occurredAt)
|
||||
}}</small>
|
||||
</div>
|
||||
<p v-if="!recentEmployeeHistory.length" class="manager-picker-empty">
|
||||
暂无变更记录
|
||||
|
||||
@@ -619,29 +619,45 @@
|
||||
v-if="activeReviewPayload"
|
||||
type="button"
|
||||
class="review-insight-switch-icon-btn"
|
||||
:class="{
|
||||
available: true,
|
||||
active: isReviewOverviewDrawer
|
||||
}"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
title="报销识别核对"
|
||||
aria-label="报销识别核对"
|
||||
@click="switchToReviewOverviewDrawer"
|
||||
>
|
||||
<i :class="isReviewOverviewDrawer ? 'mdi mdi-clipboard-check' : 'mdi mdi-clipboard-check-outline'"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="activeReviewPayload && reviewDocumentDrawerAvailable"
|
||||
type="button"
|
||||
class="review-insight-switch-icon-btn"
|
||||
:class="{
|
||||
available: reviewDocumentDrawerAvailable,
|
||||
active: reviewDocumentDrawerAvailable && isReviewDocumentDrawer
|
||||
}"
|
||||
:disabled="!reviewDocumentDrawerAvailable || submitting || reviewActionBusy"
|
||||
:title="reviewDocumentDrawerLabel"
|
||||
:aria-label="reviewDocumentDrawerLabel"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
title="单据识别"
|
||||
aria-label="单据识别"
|
||||
@click="toggleReviewDocumentDrawer"
|
||||
>
|
||||
<i :class="reviewDocumentDrawerIcon"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="activeReviewPayload"
|
||||
v-if="activeReviewPayload && reviewRiskDrawerAvailable"
|
||||
type="button"
|
||||
class="review-insight-switch-icon-btn risk"
|
||||
:class="{
|
||||
available: reviewRiskDrawerAvailable,
|
||||
active: reviewRiskDrawerAvailable && isReviewRiskDrawer
|
||||
}"
|
||||
:disabled="!reviewRiskDrawerAvailable || submitting || reviewActionBusy"
|
||||
:title="reviewRiskDrawerLabel"
|
||||
:aria-label="reviewRiskDrawerLabel"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
title="显示风险"
|
||||
aria-label="显示风险"
|
||||
@click="toggleReviewRiskDrawer"
|
||||
>
|
||||
<i :class="reviewRiskDrawerIcon"></i>
|
||||
@@ -656,8 +672,8 @@
|
||||
running: flowOverallStatusTone === 'running'
|
||||
}"
|
||||
:disabled="!reviewFlowDrawerAvailable || submitting || reviewActionBusy"
|
||||
:title="reviewFlowDrawerLabel"
|
||||
:aria-label="reviewFlowDrawerLabel"
|
||||
title="调用流程"
|
||||
aria-label="调用流程"
|
||||
@click="toggleReviewFlowDrawer"
|
||||
>
|
||||
<i :class="reviewFlowDrawerIcon"></i>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
@@ -61,94 +60,6 @@ function resolveRiskTone(riskFlags, riskSummary) {
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function resolveRiskItems(request) {
|
||||
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
|
||||
const items = riskFlags
|
||||
.map((item) => {
|
||||
const tone = resolveRiskTone([item], '')
|
||||
const text = String(item?.message || item?.label || item?.reason || '').trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text,
|
||||
level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低',
|
||||
tone,
|
||||
icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (items.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
const summary = String(request?.riskSummary || '').trim()
|
||||
if (summary && summary !== '无') {
|
||||
return summary.split(';').filter(Boolean).map((text) => ({
|
||||
text,
|
||||
level: '中',
|
||||
tone: 'medium',
|
||||
icon: 'mdi mdi-alert'
|
||||
}))
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: 'AI预审已通过,当前未发现额外风险。',
|
||||
level: '低',
|
||||
tone: 'low',
|
||||
icon: 'mdi mdi-shield-check'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveAttachmentMeta(name) {
|
||||
const normalized = String(name || '').trim()
|
||||
const lowerName = normalized.toLowerCase()
|
||||
if (lowerName.endsWith('.pdf')) {
|
||||
return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }
|
||||
}
|
||||
if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) {
|
||||
return { icon: 'mdi mdi-image', iconClass: 'img' }
|
||||
}
|
||||
return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' }
|
||||
}
|
||||
|
||||
function buildAttachments(expenseItems) {
|
||||
const seen = new Set()
|
||||
const attachments = []
|
||||
|
||||
for (const item of Array.isArray(expenseItems) ? expenseItems : []) {
|
||||
for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) {
|
||||
const normalized = String(fileName || '').trim()
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
seen.add(normalized)
|
||||
attachments.push({
|
||||
name: normalized,
|
||||
size: '已识别',
|
||||
...resolveAttachmentMeta(normalized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length) {
|
||||
return attachments
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: '当前无附件',
|
||||
size: '待补充',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
iconClass: 'miss',
|
||||
missing: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveSlaMeta(submittedAt) {
|
||||
const startAt = toDate(submittedAt)
|
||||
if (!startAt) {
|
||||
@@ -173,55 +84,8 @@ function resolveSlaMeta(submittedAt) {
|
||||
return { label, tone: 'safe', urgent: false }
|
||||
}
|
||||
|
||||
function buildHeroSummaryItems(request) {
|
||||
return [
|
||||
{ label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' },
|
||||
{ label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' },
|
||||
{ label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' },
|
||||
{ label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' }
|
||||
]
|
||||
}
|
||||
|
||||
function buildFlowItems(request) {
|
||||
return Array.isArray(request?.progressSteps)
|
||||
? request.progressSteps.map((item) => ({
|
||||
label: item.label,
|
||||
desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理',
|
||||
time: item.time,
|
||||
icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline',
|
||||
current: item.current,
|
||||
pending: !item.done && !item.current
|
||||
}))
|
||||
: []
|
||||
}
|
||||
|
||||
function canCurrentUserProcessRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
const currentName = String(currentUser?.name || '').trim()
|
||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
||||
|
||||
if (currentName && applicantName && currentName === applicantName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (canManageExpenseClaims(currentUser)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
node.includes('直属领导')
|
||||
|| node.includes('领导审批')
|
||||
|| node.includes('部门负责人')
|
||||
|| node.includes('负责人审批')
|
||||
)
|
||||
}
|
||||
|
||||
function buildApprovalRow(request) {
|
||||
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
|
||||
const riskItems = resolveRiskItems(request)
|
||||
const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : []
|
||||
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
|
||||
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
|
||||
|
||||
@@ -240,37 +104,35 @@ function buildApprovalRow(request) {
|
||||
node: request.workflowNode || '审批中',
|
||||
status: statusTone === 'urgent' ? '即将超时' : '待审批',
|
||||
statusTone,
|
||||
spotlight: riskTone === 'high' || statusTone === 'urgent',
|
||||
heroSummaryItems: buildHeroSummaryItems(request),
|
||||
summaryItems: buildHeroSummaryItems(request).slice(2),
|
||||
progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [],
|
||||
expenseItems,
|
||||
attachments: buildAttachments(expenseItems),
|
||||
riskItems,
|
||||
flowItems: buildFlowItems(request)
|
||||
spotlight: riskTone === 'high' || statusTone === 'urgent'
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ApprovalCenterView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TravelRequestDetailView,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const { markClaimViewed, syncPendingClaimIds } = useApprovalInbox()
|
||||
const activeTab = ref('全部待审')
|
||||
const selectedClaimId = ref('')
|
||||
const expandedExpenseId = ref(null)
|
||||
const listKeyword = ref('')
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
|
||||
watch(
|
||||
() => selectedClaimId.value,
|
||||
(claimId) => {
|
||||
if (claimId) {
|
||||
markClaimViewed(claimId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
@@ -278,14 +140,12 @@ export default {
|
||||
},
|
||||
set(value) {
|
||||
selectedClaimId.value = value?.claimId || ''
|
||||
expandedExpenseId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
let filteredRows = rows.value
|
||||
|
||||
// 根据标签筛选
|
||||
if (activeTab.value === '高风险') {
|
||||
filteredRows = filteredRows.filter((row) => row.riskTone === 'high')
|
||||
} else if (activeTab.value === '即将超时') {
|
||||
@@ -294,25 +154,20 @@ export default {
|
||||
filteredRows = []
|
||||
}
|
||||
|
||||
// 根据搜索关键词筛选
|
||||
if (listKeyword.value.trim()) {
|
||||
const keyword = listKeyword.value.trim().toLowerCase()
|
||||
filteredRows = filteredRows.filter((row) => {
|
||||
return (
|
||||
String(row.id || '').toLowerCase().includes(keyword) ||
|
||||
String(row.applicant || '').toLowerCase().includes(keyword) ||
|
||||
String(row.department || '').toLowerCase().includes(keyword) ||
|
||||
String(row.type || '').toLowerCase().includes(keyword) ||
|
||||
String(row.amount || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
filteredRows = filteredRows.filter((row) => (
|
||||
String(row.id || '').toLowerCase().includes(keyword)
|
||||
|| String(row.applicant || '').toLowerCase().includes(keyword)
|
||||
|| String(row.department || '').toLowerCase().includes(keyword)
|
||||
|| String(row.type || '').toLowerCase().includes(keyword)
|
||||
|| String(row.amount || '').toLowerCase().includes(keyword)
|
||||
))
|
||||
}
|
||||
|
||||
return filteredRows
|
||||
})
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const approvalEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
@@ -343,45 +198,6 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
const approvalSteps = computed(() => selectedRow.value?.progressSteps || [])
|
||||
const summaryItems = computed(() => selectedRow.value?.summaryItems || [])
|
||||
const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || [])
|
||||
const expenseItems = computed(() => selectedRow.value?.expenseItems || [])
|
||||
const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0))
|
||||
const uploadedExpenseCount = computed(
|
||||
() => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length
|
||||
)
|
||||
const attachments = computed(() => selectedRow.value?.attachments || [])
|
||||
const riskItems = computed(() => selectedRow.value?.riskItems || [])
|
||||
const flowItems = computed(() => selectedRow.value?.flowItems || [])
|
||||
|
||||
const currentProgressRingMotion = {
|
||||
initial: {
|
||||
scale: 1,
|
||||
opacity: 0.34
|
||||
},
|
||||
enter: {
|
||||
scale: [1, 1.42, 1.78],
|
||||
opacity: [0.34, 0.16, 0],
|
||||
transition: {
|
||||
duration: 3.2,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
repeatDelay: 0.85,
|
||||
ease: 'easeOut',
|
||||
times: [0, 0.5, 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showExpenseRisk(item) {
|
||||
return ['medium', 'high'].includes(String(item?.riskTone || '').trim())
|
||||
}
|
||||
|
||||
function toggleExpenseAttachments(id) {
|
||||
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
|
||||
}
|
||||
|
||||
function handleEmptyAction() {
|
||||
if (!rows.value.length) {
|
||||
void reload()
|
||||
@@ -391,74 +207,18 @@ export default {
|
||||
activeTab.value = '全部待审'
|
||||
}
|
||||
|
||||
function handleReturnSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
function closeSelectedDetail() {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = true
|
||||
async function handleDetailUpdated() {
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (!actionBusy.value) {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (!actionBusy.value) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(row.claimId, {
|
||||
reason: '审批中心退回,请申请人调整后重新提交。'
|
||||
})
|
||||
toast(`${row.id} 已退回待提交。`)
|
||||
returnDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(row.claimId)
|
||||
toast(payload?.message || `${row.id} 报销单已删除。`)
|
||||
deleteDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
async function handleDetailDeleted() {
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
@@ -466,15 +226,11 @@ export default {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchExpenseClaims()
|
||||
const mappedRows = Array.isArray(payload)
|
||||
? payload
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.filter((item) => item.approvalKey === 'in_progress')
|
||||
.filter((item) => canCurrentUserProcessRequest(item, currentUser.value))
|
||||
.map((item) => buildApprovalRow(item))
|
||||
: []
|
||||
const payload = await fetchApprovalExpenseClaims()
|
||||
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value)
|
||||
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
|
||||
rows.value = mappedRows
|
||||
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
|
||||
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
@@ -491,42 +247,21 @@ export default {
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
selectedRow,
|
||||
expandedExpenseId,
|
||||
listKeyword,
|
||||
tabs,
|
||||
filters,
|
||||
rows,
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
actionBusy,
|
||||
approvalEmptyState,
|
||||
approvalSteps,
|
||||
canManageClaims,
|
||||
closeDeleteDialog,
|
||||
closeReturnDialog,
|
||||
confirmDeleteSelected,
|
||||
confirmReturnSelected,
|
||||
deleteDialogOpen,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
currentProgressRingMotion,
|
||||
expenseItems,
|
||||
expenseTotal,
|
||||
uploadedExpenseCount,
|
||||
showExpenseRisk,
|
||||
toggleExpenseAttachments,
|
||||
attachments,
|
||||
riskItems,
|
||||
flowItems,
|
||||
handleEmptyAction,
|
||||
handleDeleteSelected,
|
||||
handleReturnSelected,
|
||||
loading,
|
||||
closeSelectedDetail,
|
||||
error,
|
||||
returnDialogOpen,
|
||||
reload
|
||||
filters,
|
||||
handleDetailDeleted,
|
||||
handleDetailUpdated,
|
||||
handleEmptyAction,
|
||||
listKeyword,
|
||||
loading,
|
||||
reload,
|
||||
rows,
|
||||
selectedRow,
|
||||
showEmpty,
|
||||
tabs,
|
||||
visibleRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,10 @@ function sameValues(left, right) {
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(Number(value)).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatEmployeeHistoryTime(value) {
|
||||
const raw = normalizeText(value)
|
||||
if (!raw) {
|
||||
@@ -269,13 +273,13 @@ function formatEmployeeHistoryTime(value) {
|
||||
)
|
||||
if (chineseMatched) {
|
||||
const [, year, month, day, hour, minute] = chineseMatched
|
||||
return `${year}年${Number(month)}月${Number(day)}日${Number(hour)}时${Number(minute)}分`
|
||||
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
|
||||
}
|
||||
|
||||
const isoMatched = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
|
||||
const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/)
|
||||
if (isoMatched) {
|
||||
const [, year, month, day, hour = '0', minute = '0'] = isoMatched
|
||||
return `${year}年${Number(month)}月${Number(day)}日${Number(hour)}时${Number(minute)}分`
|
||||
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
|
||||
}
|
||||
|
||||
return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
emits: ['ask', 'approve', 'reject', 'create-request', 'reload'],
|
||||
setup(props, { emit }) {
|
||||
const activeTab = ref('全部')
|
||||
const tabs = ['全部', '草稿', '审批中', '待补充', '已完成']
|
||||
const tabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
||||
const filters = ['报销状态', '报销类型', '所属主体']
|
||||
const listKeyword = ref('')
|
||||
|
||||
@@ -98,8 +98,9 @@ export default {
|
||||
const matchesTab =
|
||||
activeTab.value === '全部'
|
||||
|| (activeTab.value === '草稿' && row.approvalKey === 'draft')
|
||||
|| (activeTab.value === '待提交' && row.approvalKey === 'supplement' && row.status === 'returned')
|
||||
|| (activeTab.value === '审批中' && row.approvalKey === 'in_progress')
|
||||
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement')
|
||||
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement' && row.status !== 'returned')
|
||||
|| (activeTab.value === '已完成' && row.approvalKey === 'completed')
|
||||
|
||||
return matchesKeyword && matchesDateRange && matchesTab
|
||||
@@ -150,7 +151,7 @@ export default {
|
||||
artLabel: hasListFilters.value ? 'FILTER' : 'QUEUE',
|
||||
tips: hasListFilters.value
|
||||
? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型']
|
||||
: ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类']
|
||||
: ['已完成单据会保留在列表中便于追踪', '草稿、待提交、审批中和待补充会按真实状态实时归类']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import {
|
||||
buildLocalExtractionProgressMessages,
|
||||
buildLocalIntentPreview,
|
||||
summarizeSemanticIntentDetail,
|
||||
TRANSPORT_KEYWORD_PATTERN
|
||||
} from '../../utils/reimbursementTextInference.js'
|
||||
import {
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
fetchExpenseClaimDetail,
|
||||
@@ -284,7 +290,7 @@ const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
const CATEGORY_CONFIDENCE_KEYWORDS = {
|
||||
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
||||
hotel: [/住宿|酒店|宾馆|民宿/],
|
||||
transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/],
|
||||
transport: [TRANSPORT_KEYWORD_PATTERN],
|
||||
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||
@@ -304,13 +310,6 @@ const FLOW_MISSING_SLOT_LABELS = {
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
const FLOW_INTENT_KEYWORDS = {
|
||||
draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'],
|
||||
query: ['查询', '查一下', '多少', '明细', '统计'],
|
||||
risk_check: ['风险', '异常', '重复', '超标'],
|
||||
explain: ['为什么', '依据', '规则', '怎么']
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
function nowTime() {
|
||||
@@ -439,116 +438,6 @@ function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
function summarizeSemanticIntentDetail(semanticParse) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}
|
||||
|
||||
const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
|
||||
const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
|
||||
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}`
|
||||
}
|
||||
|
||||
function extractLocalFlowCandidates(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
|
||||
let time = ''
|
||||
const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (explicitTimeMatch?.[1]) {
|
||||
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else {
|
||||
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (dateMatch?.[1]) {
|
||||
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else if (/今天|今日/.test(compact)) {
|
||||
time = '今天'
|
||||
} else if (/昨天|昨日/.test(compact)) {
|
||||
time = '昨天'
|
||||
} else if (/前天/.test(compact)) {
|
||||
time = '前天'
|
||||
}
|
||||
}
|
||||
|
||||
let amount = ''
|
||||
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
|
||||
if (amountMatch?.[1]) {
|
||||
const numericValue = Number(amountMatch[1])
|
||||
if (Number.isFinite(numericValue)) {
|
||||
amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
let event = ''
|
||||
let expenseType = ''
|
||||
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
|
||||
event = '请客户吃饭'
|
||||
expenseType = '业务招待费'
|
||||
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
|
||||
event = '出差行程'
|
||||
expenseType = '差旅费'
|
||||
} else if (/打车|网约车|出租车|车费|停车/.test(compact)) {
|
||||
event = '交通出行'
|
||||
expenseType = '交通费'
|
||||
} else if (/住宿|酒店|宾馆/.test(compact)) {
|
||||
event = '住宿报销'
|
||||
expenseType = '住宿费'
|
||||
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
|
||||
event = '餐饮用餐'
|
||||
expenseType = '餐费'
|
||||
}
|
||||
|
||||
return {
|
||||
time,
|
||||
amount,
|
||||
event,
|
||||
expenseType
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) {
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return '初步识别为财务知识问答,正在准备检索范围'
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
|
||||
keywords.some((keyword) => compact.includes(keyword))
|
||||
)?.[0] || 'draft'
|
||||
const intentLabel = INTENT_LABELS[intentKey] || '处理'
|
||||
return `初步识别为报销场景,准备进入${intentLabel}`
|
||||
}
|
||||
|
||||
function buildLocalExtractionProgressMessages(rawText, options = {}) {
|
||||
const candidates = extractLocalFlowCandidates(rawText)
|
||||
const messages = []
|
||||
|
||||
messages.push('正在提取发生时间...')
|
||||
messages.push(
|
||||
candidates.time
|
||||
? `发现发生时间 ${candidates.time},继续提取金额...`
|
||||
: '暂未定位到明确时间,继续提取金额...'
|
||||
)
|
||||
messages.push(
|
||||
candidates.amount
|
||||
? `发现金额 ${candidates.amount},继续识别事件类型...`
|
||||
: '暂未定位到明确金额,继续识别事件类型...'
|
||||
)
|
||||
|
||||
if (candidates.event || candidates.expenseType) {
|
||||
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
|
||||
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
|
||||
} else {
|
||||
messages.push('正在识别事件类型和费用分类...')
|
||||
}
|
||||
|
||||
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
|
||||
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
@@ -2039,7 +1928,7 @@ function matchPresetSceneFromReason(reason) {
|
||||
if (/酒店|住宿/.test(compactReason)) {
|
||||
return '住宿报销'
|
||||
}
|
||||
if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) {
|
||||
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
|
||||
return '交通出行'
|
||||
}
|
||||
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
|
||||
@@ -3162,6 +3051,7 @@ export default {
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
|
||||
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
|
||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||
const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
|
||||
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
|
||||
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
||||
@@ -3175,7 +3065,7 @@ export default {
|
||||
: '报销识别核对'
|
||||
))
|
||||
const reviewDocumentDrawerLabel = computed(() => (
|
||||
isReviewDocumentDrawer.value ? '显示核对' : '显示票据'
|
||||
'单据识别'
|
||||
))
|
||||
const reviewDocumentDrawerIcon = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
@@ -3183,7 +3073,7 @@ export default {
|
||||
: 'mdi mdi-file-document-multiple-outline'
|
||||
))
|
||||
const reviewRiskDrawerLabel = computed(() => (
|
||||
isReviewRiskDrawer.value ? '显示核对' : '显示风险'
|
||||
'显示风险'
|
||||
))
|
||||
const reviewRiskDrawerIcon = computed(() => (
|
||||
isReviewRiskDrawer.value
|
||||
@@ -3191,7 +3081,7 @@ export default {
|
||||
: 'mdi mdi-shield-alert-outline'
|
||||
))
|
||||
const reviewFlowDrawerLabel = computed(() => (
|
||||
isReviewFlowDrawer.value ? '显示核对' : '显示流程'
|
||||
'调用流程'
|
||||
))
|
||||
const reviewFlowDrawerIcon = computed(() => (
|
||||
isReviewFlowDrawer.value
|
||||
@@ -3714,7 +3604,7 @@ export default {
|
||||
|
||||
function startSemanticFlowPreview(rawText, options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value)
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
|
||||
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
@@ -3867,7 +3757,12 @@ export default {
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse),
|
||||
summarizeSemanticIntentDetail(run.semantic_parse, {
|
||||
scenarioLabels: SCENARIO_LABELS,
|
||||
intentLabels: INTENT_LABELS,
|
||||
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
||||
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}),
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
)
|
||||
completePendingFlowStep(
|
||||
@@ -4393,34 +4288,36 @@ export default {
|
||||
insightPanelCollapsed.value = !insightPanelCollapsed.value
|
||||
}
|
||||
|
||||
function switchReviewDrawerMode(mode) {
|
||||
if (reviewDrawerMode.value === mode) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value = mode
|
||||
}
|
||||
|
||||
function switchToReviewOverviewDrawer() {
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
|
||||
}
|
||||
|
||||
function toggleReviewDocumentDrawer() {
|
||||
if (!reviewDocumentDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value =
|
||||
reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS)
|
||||
}
|
||||
|
||||
function toggleReviewRiskDrawer() {
|
||||
if (!reviewRiskDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value =
|
||||
reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_RISK
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
|
||||
}
|
||||
|
||||
function toggleReviewFlowDrawer() {
|
||||
if (!reviewFlowDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value =
|
||||
reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_FLOW
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW)
|
||||
}
|
||||
|
||||
function setInlineReviewFieldError(key, message) {
|
||||
@@ -5335,6 +5232,7 @@ export default {
|
||||
activeReviewPayload,
|
||||
activeReviewFilePreviews,
|
||||
reviewDrawerMode,
|
||||
isReviewOverviewDrawer,
|
||||
isReviewDocumentDrawer,
|
||||
isReviewRiskDrawer,
|
||||
isReviewFlowDrawer,
|
||||
@@ -5433,6 +5331,7 @@ export default {
|
||||
resolveFlowStepStatusLabel,
|
||||
resolveFlowStepDetail,
|
||||
toggleInsightPanel,
|
||||
switchToReviewOverviewDrawer,
|
||||
toggleReviewDocumentDrawer,
|
||||
toggleReviewRiskDrawer,
|
||||
toggleReviewFlowDrawer,
|
||||
|
||||
@@ -3,7 +3,9 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
|
||||
import {
|
||||
approveExpenseClaim,
|
||||
createExpenseClaimItem,
|
||||
deleteExpenseClaimItem,
|
||||
deleteExpenseClaimItemAttachment,
|
||||
@@ -15,8 +17,13 @@ import {
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
} from './travelRequestDetailInsights.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
@@ -30,21 +37,6 @@ const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'meeting',
|
||||
@@ -72,18 +64,10 @@ function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveLocationInputPlaceholder(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
|
||||
}
|
||||
|
||||
function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
@@ -191,9 +175,28 @@ function normalizeIsoDateValue(value) {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function resolveExpenseUploadHint(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
function extractAttachmentDisplayName(value) {
|
||||
@@ -216,6 +219,12 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||
const riskText = String(source?.riskText || '').trim()
|
||||
const filledAt = formatExpenseFilledTime(
|
||||
source?.filledAt
|
||||
|| source?.filled_at
|
||||
|| source?.createdAt
|
||||
|| source?.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
|
||||
@@ -226,6 +235,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
@@ -234,7 +244,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
amount: amountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
@@ -372,12 +382,21 @@ function mapIssueToAdvice(issue) {
|
||||
export default {
|
||||
name: 'TravelRequestDetailView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
ConfirmDialog,
|
||||
ReturnReasonDialog
|
||||
},
|
||||
props: {
|
||||
request: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
backLabel: {
|
||||
type: String,
|
||||
default: '返回报销列表'
|
||||
},
|
||||
approvalMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
@@ -392,10 +411,14 @@ export default {
|
||||
const deletingExpenseId = ref('')
|
||||
const pendingUploadExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const submitConfirmDialogOpen = ref(false)
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const approveBusy = ref(false)
|
||||
const approveConfirmDialogOpen = ref(false)
|
||||
const leaderOpinion = ref('')
|
||||
const expenseUploadInput = ref(null)
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
@@ -404,6 +427,7 @@ export default {
|
||||
const attachmentPreviewUrl = ref('')
|
||||
const attachmentPreviewName = ref('')
|
||||
const attachmentPreviewMediaType = ref('')
|
||||
const attachmentPreviewItemId = ref('')
|
||||
const expenseEditor = reactive({
|
||||
itemDate: '',
|
||||
itemType: 'other',
|
||||
@@ -455,13 +479,28 @@ export default {
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
|
||||
const isDirectManagerApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '直属领导审批'
|
||||
})
|
||||
const showLeaderApprovalPanel = computed(() =>
|
||||
Boolean(props.approvalMode)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& isDirectManagerApprovalStage.value
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canReturnRequest = computed(() =>
|
||||
canManageCurrentClaim.value
|
||||
canReturnExpenseClaims(currentUser.value)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canApproveRequest = computed(() =>
|
||||
showLeaderApprovalPanel.value
|
||||
&& canReturnExpenseClaims(currentUser.value)
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
@@ -474,6 +513,7 @@ export default {
|
||||
|| submitBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| approveBusy.value
|
||||
|| creatingExpense.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
@@ -583,12 +623,8 @@ export default {
|
||||
})
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const expenseSummaryText = computed(
|
||||
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const detailNote = computed(
|
||||
() =>
|
||||
@@ -599,7 +635,49 @@ export default {
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => item.invoiceId)
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
index,
|
||||
name: resolveAttachmentDisplayName(item) || `第 ${index + 1} 条附件`,
|
||||
metadata: resolveAttachmentMeta(item)
|
||||
}))
|
||||
)
|
||||
const currentAttachmentPreviewIndex = computed(() =>
|
||||
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
|
||||
)
|
||||
const currentAttachmentPreviewEntry = computed(() => {
|
||||
const index = currentAttachmentPreviewIndex.value
|
||||
return index >= 0 ? attachmentPreviewEntries.value[index] : null
|
||||
})
|
||||
const attachmentPreviewIndexLabel = computed(() => {
|
||||
const currentIndex = currentAttachmentPreviewIndex.value
|
||||
const total = attachmentPreviewEntries.value.length
|
||||
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
|
||||
})
|
||||
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
|
||||
const currentAttachmentPreviewInsight = computed(() => {
|
||||
const entry = currentAttachmentPreviewEntry.value
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
|
||||
})
|
||||
const currentAttachmentPreviewRiskCards = computed(() => {
|
||||
const entry = currentAttachmentPreviewEntry.value
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
return buildAttachmentRiskCards({
|
||||
expenseItems: [entry.item],
|
||||
attachmentMetaByItemId: expenseAttachmentMeta
|
||||
})
|
||||
})
|
||||
|
||||
function applyLocalExpenseItemPatch(itemId, patch) {
|
||||
expenseItems.value = rebuildExpenseItems(
|
||||
@@ -617,36 +695,13 @@ export default {
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
}
|
||||
|
||||
function resolveAttachmentPreviewTitle(item) {
|
||||
const fileName = resolveAttachmentDisplayName(item)
|
||||
return fileName ? `预览附件:${fileName}` : '预览附件'
|
||||
}
|
||||
|
||||
function resolveAttachmentRecognition(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
const documentInfo = metadata?.document_info
|
||||
const requirementCheck = metadata?.requirement_check
|
||||
if (!documentInfo && !requirementCheck) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fields = Array.isArray(documentInfo?.fields)
|
||||
? documentInfo.fields
|
||||
.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.label && field.value)
|
||||
: []
|
||||
|
||||
return {
|
||||
documentTypeLabel:
|
||||
String(documentInfo?.document_type_label || '').trim()
|
||||
|| resolveDocumentTypeLabel(documentInfo?.document_type),
|
||||
requirementLabel: requirementCheck
|
||||
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
|
||||
: '待校验附件类型',
|
||||
requirementTone: requirementCheck
|
||||
? (requirementCheck.matches ? 'pass' : 'high')
|
||||
: 'medium',
|
||||
message: String(requirementCheck?.message || '').trim(),
|
||||
fields: fields.slice(0, 4).map((field) => `${field.label}:${field.value}`)
|
||||
}
|
||||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
|
||||
}
|
||||
|
||||
function buildAttachmentRiskNotice(attachment) {
|
||||
@@ -676,7 +731,7 @@ export default {
|
||||
|
||||
function canPreviewAttachment(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return Boolean(item.invoiceId && metadata?.previewable)
|
||||
return Boolean(item.invoiceId && metadata?.previewable !== false)
|
||||
}
|
||||
|
||||
function revokeAttachmentPreviewUrl() {
|
||||
@@ -692,6 +747,7 @@ export default {
|
||||
attachmentPreviewError.value = ''
|
||||
attachmentPreviewName.value = ''
|
||||
attachmentPreviewMediaType.value = ''
|
||||
attachmentPreviewItemId.value = ''
|
||||
revokeAttachmentPreviewUrl()
|
||||
}
|
||||
|
||||
@@ -769,42 +825,16 @@ export default {
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const riskItems = expenseItems.value
|
||||
.map((item, index) => {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
if (!state || !['medium', 'high'].includes(state.tone)) {
|
||||
return ''
|
||||
}
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
})
|
||||
|
||||
const adviceText = String(state.suggestion || state.summary || '').trim()
|
||||
const prefix = state.tone === 'high' ? '优先整改' : '继续核对'
|
||||
return `第 ${index + 1} 条附件需${prefix}:${adviceText || '请根据系统提示补充或更换附件。'}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (!completionItems.length && !riskItems.length) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high')
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待补信息',
|
||||
summary: completionItems.length
|
||||
? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。'
|
||||
: '草稿信息已基本齐全,建议先处理附件风险后再提交审批。',
|
||||
items: [...completionItems, ...riskItems]
|
||||
}
|
||||
return buildAiAdviceViewModel({
|
||||
completionItems,
|
||||
riskCards
|
||||
})
|
||||
})
|
||||
|
||||
function startExpenseEdit(item) {
|
||||
@@ -836,12 +866,6 @@ export default {
|
||||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||||
return '请输入费用说明。'
|
||||
}
|
||||
if (
|
||||
isLocationRequiredExpenseType(expenseEditor.itemType)
|
||||
&& isPlaceholderValue(expenseEditor.itemLocation)
|
||||
) {
|
||||
return '请输入业务地点。'
|
||||
}
|
||||
|
||||
const amount = Number(expenseEditor.itemAmount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
@@ -890,7 +914,12 @@ export default {
|
||||
}
|
||||
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法上传附件。')
|
||||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -901,22 +930,29 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||||
async function loadAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !item?.invoiceId) {
|
||||
return
|
||||
}
|
||||
|
||||
closeAttachmentPreview()
|
||||
attachmentPreviewOpen.value = true
|
||||
attachmentPreviewLoading.value = true
|
||||
attachmentPreviewError.value = ''
|
||||
attachmentPreviewItemId.value = item.id
|
||||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
attachmentPreviewMediaType.value =
|
||||
String(metadata?.preview_kind || '').trim() === 'image'
|
||||
? 'image/png'
|
||||
: String(metadata?.media_type || '').trim()
|
||||
let metadata = resolveAttachmentMeta(item)
|
||||
|
||||
try {
|
||||
if (!metadata) {
|
||||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||||
}
|
||||
if (metadata?.previewable === false) {
|
||||
throw new Error('当前附件暂不支持直接预览。')
|
||||
}
|
||||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||||
attachmentPreviewMediaType.value =
|
||||
String(metadata?.preview_kind || '').trim() === 'image'
|
||||
? 'image/png'
|
||||
: String(metadata?.media_type || '').trim()
|
||||
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
|
||||
revokeAttachmentPreviewUrl()
|
||||
attachmentPreviewUrl.value = URL.createObjectURL(blob)
|
||||
@@ -928,11 +964,48 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeAttachmentPreview()
|
||||
attachmentPreviewOpen.value = true
|
||||
await loadAttachmentPreview(item)
|
||||
}
|
||||
|
||||
async function goToAttachmentPreview(offset) {
|
||||
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = attachmentPreviewEntries.value
|
||||
const currentIndex = currentAttachmentPreviewIndex.value
|
||||
const nextIndex = (currentIndex + offset + entries.length) % entries.length
|
||||
const nextEntry = entries[nextIndex]
|
||||
if (nextEntry?.item) {
|
||||
await loadAttachmentPreview(nextEntry.item)
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousAttachmentPreview() {
|
||||
void goToAttachmentPreview(-1)
|
||||
}
|
||||
|
||||
function goToNextAttachmentPreview() {
|
||||
void goToAttachmentPreview(1)
|
||||
}
|
||||
|
||||
async function uploadExpenseFile(item, file) {
|
||||
if (!item || !file) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
}
|
||||
|
||||
uploadingExpenseId.value = item.id
|
||||
|
||||
try {
|
||||
@@ -986,7 +1059,9 @@ export default {
|
||||
|
||||
async function handleExpenseFileChange(event) {
|
||||
const target = event?.target
|
||||
const file = target?.files?.[0]
|
||||
const fileList = target?.files
|
||||
const fileCount = fileList?.length || 0
|
||||
const file = fileList?.[0]
|
||||
const itemId = pendingUploadExpenseId.value
|
||||
pendingUploadExpenseId.value = ''
|
||||
|
||||
@@ -994,6 +1069,11 @@ export default {
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
if (fileCount > 1) {
|
||||
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!file || !itemId) {
|
||||
return
|
||||
}
|
||||
@@ -1059,11 +1139,12 @@ export default {
|
||||
savingExpenseId.value = item.id
|
||||
try {
|
||||
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
||||
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
|
||||
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
||||
item_date: expenseEditor.itemDate,
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: expenseEditor.itemLocation.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_amount: Number(expenseEditor.itemAmount),
|
||||
invoice_id: nextInvoiceId
|
||||
})
|
||||
@@ -1071,7 +1152,7 @@ export default {
|
||||
itemDate: expenseEditor.itemDate,
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: expenseEditor.itemLocation.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemAmount: Number(expenseEditor.itemAmount),
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
@@ -1096,7 +1177,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
function handleSubmit() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
return
|
||||
@@ -1107,6 +1188,30 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeSubmitConfirmDialog() {
|
||||
if (submitBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmSubmitRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -1119,6 +1224,7 @@ export default {
|
||||
} else {
|
||||
toast(`${request.value.id} 提交结果已更新。`)
|
||||
}
|
||||
submitConfirmDialogOpen.value = false
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||||
@@ -1190,7 +1296,7 @@ export default {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmReturnRequest() {
|
||||
async function confirmReturnRequest(payload) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
@@ -1198,9 +1304,7 @@ export default {
|
||||
|
||||
returnBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(request.value.claimId, {
|
||||
reason: '详情页退回,请申请人调整后重新提交。'
|
||||
})
|
||||
await returnExpenseClaim(request.value.claimId, payload)
|
||||
returnDialogOpen.value = false
|
||||
toast(`${request.value.id} 已退回待提交。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
@@ -1211,7 +1315,62 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function handleApproveRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canApproveRequest.value) {
|
||||
toast('当前节点不支持领导审批通过。')
|
||||
return
|
||||
}
|
||||
|
||||
approveConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeApproveConfirmDialog() {
|
||||
if (approveBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
approveConfirmDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmApproveRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||||
approveConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!canApproveRequest.value) {
|
||||
toast('当前节点不支持领导审批通过。')
|
||||
approveConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
approveBusy.value = true
|
||||
try {
|
||||
await approveExpenseClaim(request.value.claimId, {
|
||||
opinion: leaderOpinion.value.trim()
|
||||
})
|
||||
approveConfirmDialogOpen.value = false
|
||||
leaderOpinion.value = ''
|
||||
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '审批通过失败,请稍后重试。')
|
||||
} finally {
|
||||
approveBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
if (!canOpenAiEntry.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
@@ -1229,21 +1388,33 @@ export default {
|
||||
actionBusy,
|
||||
aiAdvice,
|
||||
attachmentPreviewError,
|
||||
attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading,
|
||||
attachmentPreviewMediaType,
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
approveBusy,
|
||||
approveConfirmDialogOpen,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry,
|
||||
canApproveRequest,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeApproveConfirmDialog,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
closeSubmitConfirmDialog,
|
||||
closeReturnDialog,
|
||||
confirmApproveRequest,
|
||||
confirmDeleteRequest,
|
||||
confirmSubmitRequest,
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
@@ -1258,39 +1429,43 @@ export default {
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseSummaryText,
|
||||
expenseTableColumnCount,
|
||||
expenseTotal,
|
||||
expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleApproveRequest,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
hasExpenseRiskColumn,
|
||||
heroFactItems,
|
||||
isDraftRequest,
|
||||
isEditableRequest,
|
||||
isTravelRequest,
|
||||
locationInputPlaceholder,
|
||||
openAiEntry,
|
||||
openAttachmentPreview,
|
||||
goToNextAttachmentPreview,
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
removeExpenseItem,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
submitConfirmDialogOpen,
|
||||
triggerExpenseUpload,
|
||||
uploadedExpenseCount,
|
||||
uploadingExpenseId,
|
||||
|
||||
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal file
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = normalizeText(value).toLowerCase()
|
||||
if (tone === 'pass') return 'pass'
|
||||
if (tone === 'high') return 'high'
|
||||
if (tone === 'medium') return 'medium'
|
||||
if (tone === 'low') return 'low'
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function normalizeRuleBasis(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeText(item)).filter(Boolean)
|
||||
}
|
||||
|
||||
const text = normalizeText(value)
|
||||
return text ? [text] : []
|
||||
}
|
||||
|
||||
export function buildAttachmentInsightViewModel(metadata, item = {}) {
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
|
||||
const documentInfo = metadata.document_info || {}
|
||||
const requirementCheck = metadata.requirement_check || null
|
||||
const analysis = metadata.analysis || null
|
||||
const documentTypeLabel =
|
||||
normalizeText(documentInfo.document_type_label) || resolveDocumentTypeLabel(documentInfo.document_type)
|
||||
const fields = Array.isArray(documentInfo.fields)
|
||||
? documentInfo.fields
|
||||
.map((field) => ({
|
||||
label: normalizeText(field?.label),
|
||||
value: normalizeText(field?.value)
|
||||
}))
|
||||
.filter((field) => field.label && field.value)
|
||||
.map((field) => `${field.label}:${field.value}`)
|
||||
: []
|
||||
const ruleBasis = uniqueTexts([
|
||||
...normalizeRuleBasis(analysis?.rule_basis || analysis?.ruleBasis),
|
||||
...normalizeRuleBasis(requirementCheck?.rule_basis || requirementCheck?.ruleBasis),
|
||||
normalizeText(requirementCheck?.message),
|
||||
documentTypeLabel ? `票据识别依据:系统将附件识别为${documentTypeLabel}。` : '',
|
||||
normalizeText(item?.name) ? `费用项目依据:当前明细为${normalizeText(item.name)}。` : ''
|
||||
])
|
||||
|
||||
return {
|
||||
fileName: normalizeText(metadata.file_name || item.attachmentHint || item.invoiceId),
|
||||
mediaType: normalizeText(metadata.media_type),
|
||||
previewable: metadata.previewable !== false,
|
||||
documentTypeLabel,
|
||||
requirementLabel: requirementCheck
|
||||
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
|
||||
: '待校验附件类型',
|
||||
requirementTone: requirementCheck
|
||||
? (requirementCheck.matches ? 'pass' : 'high')
|
||||
: 'medium',
|
||||
message: normalizeText(requirementCheck?.message),
|
||||
fields: fields.slice(0, 8),
|
||||
ruleBasis,
|
||||
analysis: analysis
|
||||
? {
|
||||
label: normalizeText(analysis.label) || 'AI提示',
|
||||
tone: normalizeTone(analysis.severity),
|
||||
headline: normalizeText(analysis.headline) || normalizeText(analysis.label) || 'AI提示',
|
||||
summary: normalizeText(analysis.summary),
|
||||
points: Array.isArray(analysis.points) ? analysis.points.map((point) => normalizeText(point)).filter(Boolean) : [],
|
||||
suggestion: normalizeText(analysis.suggestion)
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function buildCardSuggestion(analysis, insight) {
|
||||
return (
|
||||
normalizeText(analysis?.suggestion)
|
||||
|| normalizeText(insight?.message)
|
||||
|| '请根据规则依据核对附件和费用明细,必要时补充说明、更换附件或调整费用项目。'
|
||||
)
|
||||
}
|
||||
|
||||
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
|
||||
return {
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
tone,
|
||||
label,
|
||||
title: `第 ${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
|
||||
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
|
||||
summary: normalizeText(analysis?.summary),
|
||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||
suggestion: buildCardSuggestion(analysis, insight)
|
||||
}
|
||||
}
|
||||
|
||||
function parseReturnCount(flag) {
|
||||
const count = Number(flag?.return_count ?? flag?.returnCount ?? 0)
|
||||
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
|
||||
}
|
||||
|
||||
function resolveLatestManualReturnFlag(flags) {
|
||||
const manualReturnFlags = flags.filter(
|
||||
(flag) => flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return'
|
||||
)
|
||||
if (!manualReturnFlags.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return manualReturnFlags.reduce((latest, flag) => {
|
||||
const latestCount = parseReturnCount(latest)
|
||||
const nextCount = parseReturnCount(flag)
|
||||
if (nextCount !== latestCount) {
|
||||
return nextCount > latestCount ? flag : latest
|
||||
}
|
||||
|
||||
const latestTime = Date.parse(normalizeText(latest?.created_at || latest?.createdAt))
|
||||
const nextTime = Date.parse(normalizeText(flag?.created_at || flag?.createdAt))
|
||||
if (Number.isFinite(nextTime) && (!Number.isFinite(latestTime) || nextTime >= latestTime)) {
|
||||
return flag
|
||||
}
|
||||
|
||||
return latest
|
||||
}, manualReturnFlags[0])
|
||||
}
|
||||
|
||||
function buildManualReturnRiskCard(flag) {
|
||||
if (!flag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const returnCount = parseReturnCount(flag)
|
||||
const stageReturnCount = Number(flag.stage_return_count ?? flag.stageReturnCount ?? 0)
|
||||
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
|
||||
const riskPoints = Array.isArray(flag.risk_points || flag.riskPoints)
|
||||
? (flag.risk_points || flag.riskPoints).map((item) => normalizeText(item)).filter(Boolean)
|
||||
: []
|
||||
const risk = normalizeText(flag.message || flag.reason || flag.summary) || '审批人退回该单据,请补充后重新提交。'
|
||||
const ruleBasis = uniqueTexts([
|
||||
returnCount ? `累计退回 ${returnCount} 次。` : '',
|
||||
returnStage ? `本次退回环节:${returnStage}。` : '',
|
||||
stageReturnCount > 0 ? `该环节累计退回 ${Math.floor(stageReturnCount)} 次。` : '',
|
||||
...riskPoints.map((item) => `退回风险点:${item}。`)
|
||||
])
|
||||
|
||||
return {
|
||||
id: `manual-return-${returnCount || 'latest'}`,
|
||||
tone: 'medium',
|
||||
label: '退回原因',
|
||||
title: returnCount ? `第 ${returnCount} 次退回` : '审批退回',
|
||||
risk,
|
||||
summary: normalizeText(flag.reason),
|
||||
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
expenseItems = [],
|
||||
attachmentMetaByItemId = {},
|
||||
claimRiskFlags = []
|
||||
} = {}) {
|
||||
const attachmentCards = expenseItems.flatMap((item, index) => {
|
||||
if (!item?.invoiceId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const metadata = attachmentMetaByItemId[item.id]
|
||||
const insight = buildAttachmentInsightViewModel(metadata, item)
|
||||
const analysis = metadata?.analysis
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
if (!analysis || !['medium', 'high'].includes(tone)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const points = Array.isArray(analysis.points) && analysis.points.length
|
||||
? analysis.points
|
||||
: [analysis.summary || analysis.headline || analysis.label]
|
||||
|
||||
return points
|
||||
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
|
||||
.filter((card) => card.risk)
|
||||
})
|
||||
|
||||
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
|
||||
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
|
||||
const claimCards = normalizedClaimRiskFlags
|
||||
.map((flag, index) => {
|
||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
const risk = normalizeText(flag)
|
||||
return risk
|
||||
? {
|
||||
id: `claim-risk-${index}`,
|
||||
tone: 'medium',
|
||||
label: '单据风险',
|
||||
title: '单据风险提示',
|
||||
risk,
|
||||
summary: '',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const tone = normalizeTone(flag.severity)
|
||||
if (!['medium', 'high'].includes(tone)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: `claim-risk-${index}`,
|
||||
tone,
|
||||
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
|
||||
title: normalizeText(flag.label) || '单据风险提示',
|
||||
risk: normalizeText(flag.message || flag.reason || flag.summary),
|
||||
summary: normalizeText(flag.summary),
|
||||
ruleBasis: normalizeRuleBasis(flag.rule_basis || flag.ruleBasis).length
|
||||
? normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
|
||||
: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (latestManualReturnCard) {
|
||||
claimCards.unshift(latestManualReturnCard)
|
||||
}
|
||||
|
||||
return [...attachmentCards, ...claimCards]
|
||||
}
|
||||
|
||||
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
|
||||
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedRiskCards = riskCards.filter(Boolean)
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
],
|
||||
riskCards: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
summary: normalizedRiskCards.length
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user