feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
@@ -231,6 +231,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
@@ -252,6 +253,17 @@ const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||
const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
|
||||
const DOCUMENT_CENTER_QUERY_KEYS = new Set([
|
||||
'dc_page',
|
||||
'dc_page_size',
|
||||
'dc_scope',
|
||||
'dc_status',
|
||||
'dc_doc_type',
|
||||
'dc_scene',
|
||||
'dc_q',
|
||||
'dc_start',
|
||||
'dc_end'
|
||||
])
|
||||
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
|
||||
const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
@@ -296,11 +308,14 @@ const FILTER_CONFIG_BY_SCOPE = {
|
||||
}
|
||||
}
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const pageSizeValues = pageSizeOptions.map((item) => item.value)
|
||||
const documentTypeOptions = [
|
||||
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
|
||||
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
||||
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
||||
]
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
filteredRequests: { type: Array, required: true },
|
||||
hasData: { type: Boolean, default: false },
|
||||
@@ -315,19 +330,91 @@ const emit = defineEmits([
|
||||
'reload',
|
||||
'summary-change'
|
||||
])
|
||||
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
|
||||
const activeStatusTab = ref('全部')
|
||||
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
||||
const activeScene = ref(SCENE_ALL)
|
||||
|
||||
function readDocumentCenterQueryText(key) {
|
||||
const value = route.query?.[key]
|
||||
return String(Array.isArray(value) ? value[0] || '' : value || '').trim()
|
||||
}
|
||||
|
||||
function readDocumentCenterQueryNumber(key, fallback) {
|
||||
const parsed = Number(readDocumentCenterQueryText(key))
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback
|
||||
}
|
||||
|
||||
function resolveInitialScopeTab() {
|
||||
const queryScope = readDocumentCenterQueryText('dc_scope')
|
||||
if (scopeTabs.includes(queryScope)) {
|
||||
return queryScope
|
||||
}
|
||||
|
||||
return readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs)
|
||||
}
|
||||
|
||||
function resolveInitialStatusTab(scope) {
|
||||
const queryStatus = readDocumentCenterQueryText('dc_status') || '全部'
|
||||
const config = FILTER_CONFIG_BY_SCOPE[scope] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_ALL]
|
||||
return config.statusTabs.includes(queryStatus) ? queryStatus : '全部'
|
||||
}
|
||||
|
||||
function resolveInitialDocumentType() {
|
||||
const queryType = readDocumentCenterQueryText('dc_doc_type')
|
||||
return documentTypeOptions.some((item) => item.value === queryType)
|
||||
? queryType
|
||||
: DOCUMENT_TYPE_ALL
|
||||
}
|
||||
|
||||
function resolveInitialPageSize() {
|
||||
const queryPageSize = readDocumentCenterQueryNumber('dc_page_size', 20)
|
||||
return pageSizeValues.includes(queryPageSize) ? queryPageSize : 20
|
||||
}
|
||||
|
||||
function buildDocumentCenterRouteQuery() {
|
||||
const nextQuery = {}
|
||||
Object.entries(route.query || {}).forEach(([key, value]) => {
|
||||
if (!DOCUMENT_CENTER_QUERY_KEYS.has(key)) {
|
||||
nextQuery[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
if (currentPage.value > 1) nextQuery.dc_page = String(currentPage.value)
|
||||
if (pageSize.value !== 20) nextQuery.dc_page_size = String(pageSize.value)
|
||||
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
|
||||
if (activeStatusTab.value !== '全部') nextQuery.dc_status = activeStatusTab.value
|
||||
if (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL) {
|
||||
nextQuery.dc_doc_type = activeDocumentType.value
|
||||
}
|
||||
if (activeScene.value !== SCENE_ALL) nextQuery.dc_scene = activeScene.value
|
||||
if (listKeyword.value.trim()) nextQuery.dc_q = listKeyword.value.trim()
|
||||
if (appliedStart.value) nextQuery.dc_start = appliedStart.value
|
||||
if (appliedEnd.value) nextQuery.dc_end = appliedEnd.value
|
||||
|
||||
return nextQuery
|
||||
}
|
||||
|
||||
function routeQueryEquals(left, right) {
|
||||
const leftEntries = Object.entries(left || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
|
||||
const rightEntries = Object.entries(right || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
|
||||
if (leftEntries.length !== rightEntries.length) return false
|
||||
const rightMap = new Map(rightEntries)
|
||||
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
|
||||
}
|
||||
|
||||
const initialScopeTab = resolveInitialScopeTab()
|
||||
const initialAppliedStart = readDocumentCenterQueryText('dc_start')
|
||||
const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
|
||||
const activeScopeTab = ref(initialScopeTab)
|
||||
const activeStatusTab = ref(resolveInitialStatusTab(initialScopeTab))
|
||||
const activeDocumentType = ref(resolveInitialDocumentType())
|
||||
const activeScene = ref(readDocumentCenterQueryText('dc_scene') || SCENE_ALL)
|
||||
const openFilterKey = ref('')
|
||||
const listKeyword = ref('')
|
||||
const listKeyword = ref(readDocumentCenterQueryText('dc_q'))
|
||||
const datePopover = ref(false)
|
||||
const rangeStart = ref('')
|
||||
const rangeEnd = ref('')
|
||||
const appliedStart = ref('')
|
||||
const appliedEnd = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const rangeStart = ref(initialAppliedStart)
|
||||
const rangeEnd = ref(initialAppliedEnd)
|
||||
const appliedStart = ref(initialAppliedStart)
|
||||
const appliedEnd = ref(initialAppliedEnd)
|
||||
const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1))
|
||||
const pageSize = ref(resolveInitialPageSize())
|
||||
const archiveRows = ref([])
|
||||
const approvalRows = ref([])
|
||||
const supportingLoading = ref(false)
|
||||
@@ -795,6 +882,20 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
|
||||
() => {
|
||||
if (route.name !== 'app-documents') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuery = buildDocumentCenterRouteQuery()
|
||||
if (!routeQueryEquals(route.query, nextQuery)) {
|
||||
router.replace({ name: 'app-documents', query: nextQuery })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeFilterConfig, () => {
|
||||
openFilterKey.value = ''
|
||||
datePopover.value = false
|
||||
|
||||
@@ -39,10 +39,12 @@
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
:key="`finance-amount-${financeDashboardRenderKey}`"
|
||||
mode="amount"
|
||||
:labels="activeTrend.labels"
|
||||
:claim-count="activeTrend.claimCount"
|
||||
:claim-amount="activeTrend.claimAmount"
|
||||
:category-amount-series="activeTrend.categoryAmountSeries"
|
||||
/>
|
||||
</article>
|
||||
|
||||
@@ -52,6 +54,7 @@
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
:key="`finance-count-${financeDashboardRenderKey}`"
|
||||
mode="count"
|
||||
:labels="activeTrend.labels"
|
||||
:claim-count="activeTrend.claimCount"
|
||||
@@ -362,13 +365,12 @@ const {
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
digitalEmployeeDashboardError,
|
||||
digitalEmployeeDashboardLoaded,
|
||||
digitalEmployeeDashboardLoading,
|
||||
digitalEmployeeDailyRows,
|
||||
digitalEmployeeKpiMetrics,
|
||||
digitalEmployeeTaskRanking,
|
||||
financeDashboardLoading,
|
||||
financeDashboardLoaded,
|
||||
financeDashboardRenderKey,
|
||||
kpiMetrics,
|
||||
rankedDepartments,
|
||||
rankedEmployees,
|
||||
@@ -385,7 +387,6 @@ const {
|
||||
spendCenterValue,
|
||||
spendLegend,
|
||||
systemDashboardLoading,
|
||||
systemDashboardLoaded,
|
||||
systemAccuracyComparison,
|
||||
systemAgentDailyRatio,
|
||||
systemFeedbackSummary,
|
||||
@@ -413,15 +414,15 @@ const activeKpiMetrics = computed(() => {
|
||||
})
|
||||
const activeDashboardLoading = computed(() => {
|
||||
if (activeDashboard.value === 'system') {
|
||||
return systemDashboardLoading.value && !systemDashboardLoaded.value
|
||||
return systemDashboardLoading.value
|
||||
}
|
||||
if (activeDashboard.value === 'digitalEmployee') {
|
||||
return digitalEmployeeDashboardLoading.value && !digitalEmployeeDashboardLoaded.value
|
||||
return digitalEmployeeDashboardLoading.value
|
||||
}
|
||||
if (activeDashboard.value === 'risk') {
|
||||
return riskDashboardLoading.value && !riskDashboardLoaded.value
|
||||
}
|
||||
return financeDashboardLoading.value && !financeDashboardLoaded.value
|
||||
return financeDashboardLoading.value
|
||||
})
|
||||
const activeDashboardLoadingText = computed(() => {
|
||||
if (activeDashboard.value === 'system') return '正在加载系统看板数据'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section class="receipt-folder-page">
|
||||
<article v-if="!detailMode" class="receipt-folder-list panel">
|
||||
<article v-if="!detailMode" class="receipt-folder-list documents-list panel">
|
||||
<nav class="status-tabs receipt-status-tabs" aria-label="票据关联状态">
|
||||
<button
|
||||
v-for="tab in receiptTabs"
|
||||
@@ -134,36 +134,8 @@
|
||||
loading-icon="mdi mdi-receipt-text-outline"
|
||||
@back="backToList"
|
||||
>
|
||||
<section class="receipt-detail-toolbar panel">
|
||||
<div class="receipt-detail-title">
|
||||
<strong>票据详情</strong>
|
||||
<span>{{ receiptDetailTitle }}</span>
|
||||
<p>查看识别结果、校验状态、关联单据与处理记录</p>
|
||||
</div>
|
||||
|
||||
<div class="receipt-toolbar-actions">
|
||||
<button class="minor-action" type="button" @click="reloadCurrentReceipt">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>重新读取</span>
|
||||
</button>
|
||||
<button
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="selectedReceipt?.status === 'linked'"
|
||||
@click="openAssociateDialogForCurrentReceipt"
|
||||
>
|
||||
<i class="mdi mdi-link-variant-plus"></i>
|
||||
<span>关联单据</span>
|
||||
</button>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="receipt-dashboard">
|
||||
<EnterpriseDetailCard class="receipt-preview-panel receipt-dashboard-preview" title="票据预览">
|
||||
<template #main>
|
||||
<EnterpriseDetailCard class="receipt-preview-panel" title="票据预览">
|
||||
<div class="receipt-preview-frame">
|
||||
<div class="receipt-preview-box">
|
||||
<img
|
||||
@@ -172,7 +144,7 @@
|
||||
:style="{ transform: previewTransform }"
|
||||
alt="票据预览"
|
||||
/>
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewFrameUrl" title="票据 PDF 预览"></iframe>
|
||||
<div v-else class="preview-empty">
|
||||
<i class="mdi mdi-file-eye-outline"></i>
|
||||
<strong>当前文件暂不支持内嵌预览</strong>
|
||||
@@ -201,115 +173,110 @@
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="receipt-edit-log-section">
|
||||
<header>
|
||||
<strong>用户编辑操作</strong>
|
||||
<span>{{ receiptEditLogs.length }} 条</span>
|
||||
</header>
|
||||
<ol v-if="receiptEditLogs.length" class="receipt-edit-log-list">
|
||||
<li v-for="log in receiptEditLogs" :key="`${log.operated_at}-${log.operator}`">
|
||||
<div class="receipt-edit-log-meta">
|
||||
<strong>{{ log.operator || '当前用户' }}</strong>
|
||||
<span>{{ formatDateTime(log.operated_at) }}</span>
|
||||
</div>
|
||||
<p v-for="change in log.changes" :key="`${change.key}-${change.before}-${change.after}`">
|
||||
<span>{{ change.label || change.key }}</span>
|
||||
<em>{{ change.before || '空' }}</em>
|
||||
<i class="mdi mdi-arrow-right"></i>
|
||||
<strong>{{ change.after || '空' }}</strong>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div v-else class="receipt-edit-log-empty">
|
||||
<i class="mdi mdi-history"></i>
|
||||
<span>暂无用户修改记录。</span>
|
||||
</div>
|
||||
</section>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<div class="receipt-dashboard-side">
|
||||
<EnterpriseDetailCard class="receipt-basic-panel" title="基础信息">
|
||||
<template #actions>
|
||||
<span class="receipt-card-count">{{ keyReceiptFields.length }} 项可编辑</span>
|
||||
</template>
|
||||
<template #side>
|
||||
<EnterpriseDetailCard class="receipt-ticket-info-panel" title="识别票据详情">
|
||||
<template #actions>
|
||||
<div class="receipt-card-actions">
|
||||
<button v-if="!receiptInfoEditing" class="minor-action" type="button" @click="startReceiptInfoEdit">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="minor-action" type="button" :disabled="savingDetail" @click="cancelReceiptInfoEdit">
|
||||
<span>取消</span>
|
||||
</button>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="receipt-key-grid">
|
||||
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
|
||||
<span>{{ field.label }}</span>
|
||||
<input
|
||||
:value="field.value"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
@input="updateReceiptField(field, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="receipt-static-grid">
|
||||
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="receipt-ticket-section">
|
||||
<div class="receipt-section-head">
|
||||
<strong>识别字段</strong>
|
||||
<small>{{ detailForm.fields.length }} 项</small>
|
||||
</div>
|
||||
|
||||
<div class="receipt-static-grid">
|
||||
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-ocr-panel" title="OCR识别结果">
|
||||
<div v-if="ocrPreviewFields.length" class="receipt-ocr-grid">
|
||||
<label v-for="field in ocrPreviewFields" :key="field.key || field.label" class="receipt-ocr-field">
|
||||
<div v-if="detailForm.fields.length" class="receipt-all-field-grid" :class="{ editing: receiptInfoEditing }">
|
||||
<label v-for="field in detailForm.fields" :key="field.key || field.label" class="receipt-ocr-field">
|
||||
<span>{{ field.label || field.key }}</span>
|
||||
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
|
||||
<input
|
||||
v-if="receiptInfoEditing"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
placeholder="字段值"
|
||||
@input="syncEditableFieldsToTopLevel"
|
||||
/>
|
||||
<strong v-else>{{ field.value || '待补全' }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="receipt-field-empty">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<span>暂无可展示的 OCR 识别字段</span>
|
||||
<span>暂无可展示的 OCR 识别字段。</span>
|
||||
</div>
|
||||
</section>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
|
||||
<ElCollapseItem name="other">
|
||||
<template #title>
|
||||
<div class="receipt-collapse-title">
|
||||
<strong>其他信息</strong>
|
||||
<small>{{ editableOtherFields.length }} 项</small>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<EnterpriseDetailCard class="receipt-association-panel" title="关联信息">
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="selectedReceipt?.status !== 'linked'"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
@click="openAssociateDialogForCurrentReceipt"
|
||||
>
|
||||
<i class="mdi mdi-link-variant-plus"></i>
|
||||
<span>关联单据</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="editableOtherFields.length" class="receipt-other-scroll">
|
||||
<div
|
||||
v-for="(field, index) in editableOtherFields"
|
||||
:key="`${field.key || field.label}-${index}`"
|
||||
class="receipt-edit-field-row"
|
||||
>
|
||||
<label>
|
||||
<span>字段名</span>
|
||||
<input v-model="field.label" type="text" placeholder="字段名" />
|
||||
</label>
|
||||
<label>
|
||||
<span>字段值</span>
|
||||
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-status-panel" title="处理状态">
|
||||
<div class="receipt-status-grid">
|
||||
<div v-for="item in receiptStatusItems" :key="item.label" class="receipt-status-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
|
||||
</div>
|
||||
<div class="receipt-data-list association">
|
||||
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</div>
|
||||
|
||||
<div class="receipt-dashboard-bottom">
|
||||
<EnterpriseDetailCard class="receipt-info-panel" title="关联单据信息">
|
||||
<div class="receipt-data-list">
|
||||
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-log-panel" title="处理记录 / 操作日志">
|
||||
<ol class="receipt-log-list">
|
||||
<li v-for="item in operationLogs" :key="`${item.time}-${item.label}`">
|
||||
<span>{{ item.time }}</span>
|
||||
<strong>{{ item.operator }}</strong>
|
||||
<p>{{ item.label }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-info-panel" title="归档信息">
|
||||
<div class="receipt-data-list">
|
||||
<div v-for="item in archiveInfoItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
|
||||
@@ -380,7 +347,6 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
|
||||
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
|
||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
@@ -416,13 +382,13 @@ const detailLoading = ref(false)
|
||||
const savingDetail = ref(false)
|
||||
const deleting = ref(false)
|
||||
const previewObjectUrl = ref('')
|
||||
const receiptInfoEditing = ref(false)
|
||||
const associateDialogOpen = ref(false)
|
||||
const associateStep = ref(1)
|
||||
const selectedReceiptIds = ref([])
|
||||
const targetDraftId = ref(NEW_CLAIM_VALUE)
|
||||
const draftClaims = ref([])
|
||||
const associateBusy = ref(false)
|
||||
const expandedFieldPanels = ref([])
|
||||
|
||||
const detailForm = reactive({
|
||||
file_name: '',
|
||||
@@ -514,28 +480,20 @@ const isTrainTicket = computed(() => {
|
||||
})
|
||||
const {
|
||||
buildDetailPayload,
|
||||
editableOtherFields,
|
||||
ensureEditableReceiptFields,
|
||||
keyReceiptFields,
|
||||
syncEditableFieldsToTopLevel,
|
||||
updateReceiptField
|
||||
syncEditableFieldsToTopLevel
|
||||
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
|
||||
const {
|
||||
adjustPreviewZoom,
|
||||
archiveInfoItems,
|
||||
basicInfoItems,
|
||||
linkedClaimItems,
|
||||
ocrPreviewFields,
|
||||
operationLogs,
|
||||
previewPageLabel,
|
||||
previewTransform,
|
||||
previewZoom,
|
||||
receiptStatusItems,
|
||||
resetPreviewView,
|
||||
rotatePreview
|
||||
} = createReceiptDetailDashboardModel({
|
||||
detailForm,
|
||||
editableOtherFields,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
selectedReceipt
|
||||
@@ -554,6 +512,15 @@ const receiptDetailTopBarPayload = computed(() => (
|
||||
: null
|
||||
))
|
||||
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
|
||||
const previewFrameUrl = computed(() => (
|
||||
previewKind.value === 'pdf' && previewObjectUrl.value
|
||||
? `${previewObjectUrl.value}#toolbar=0&navpanes=0&view=Fit`
|
||||
: previewObjectUrl.value
|
||||
))
|
||||
const receiptEditLogs = computed(() => {
|
||||
const logs = selectedReceipt.value?.edit_logs || selectedReceipt.value?.editLogs || []
|
||||
return Array.isArray(logs) ? logs : []
|
||||
})
|
||||
const canProceedAssociate = computed(() => (
|
||||
associateStep.value === 1
|
||||
? selectedReceiptIds.value.length > 0
|
||||
@@ -635,7 +602,7 @@ function fillDetailForm(detail) {
|
||||
detailForm.fields = Array.isArray(detail.fields)
|
||||
? detail.fields.map((field) => ({ ...field }))
|
||||
: []
|
||||
expandedFieldPanels.value = []
|
||||
receiptInfoEditing.value = false
|
||||
resetPreviewView()
|
||||
ensureEditableReceiptFields()
|
||||
syncEditableFieldsToTopLevel()
|
||||
@@ -660,6 +627,7 @@ function revokePreviewUrl() {
|
||||
|
||||
function backToList() {
|
||||
selectedReceipt.value = null
|
||||
receiptInfoEditing.value = false
|
||||
revokePreviewUrl()
|
||||
}
|
||||
|
||||
@@ -668,6 +636,17 @@ async function reloadCurrentReceipt() {
|
||||
await openDetail(selectedReceipt.value)
|
||||
}
|
||||
|
||||
function startReceiptInfoEdit() {
|
||||
receiptInfoEditing.value = true
|
||||
}
|
||||
|
||||
function cancelReceiptInfoEdit() {
|
||||
if (selectedReceipt.value) {
|
||||
fillDetailForm(selectedReceipt.value)
|
||||
}
|
||||
receiptInfoEditing.value = false
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
if (!selectedReceipt.value?.id || savingDetail.value) return
|
||||
savingDetail.value = true
|
||||
@@ -776,6 +755,7 @@ function formatScore(value) {
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!String(value ?? '').trim()) return '待确认'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '待确认'
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
@@ -126,9 +126,9 @@
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!isApplicationDocument" class="detail-card-actions">
|
||||
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
|
||||
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" :disabled="actionBusy" @click="triggerSmartEntryUpload">
|
||||
<i class="mdi mdi-robot-outline"></i>
|
||||
<span>智能录入</span>
|
||||
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditableRequest"
|
||||
@@ -190,6 +190,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isApplicationDocument" class="detail-expense-table">
|
||||
<div v-if="smartEntryRecognitionBusy" class="expense-recognition-banner">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ smartEntryRecognitionText }}</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -199,6 +203,7 @@
|
||||
<th class="col-desc">说明</th>
|
||||
<th class="col-amount">金额</th>
|
||||
<th class="col-attachment">附件材料</th>
|
||||
<th class="col-risk-note">异常说明</th>
|
||||
<th v-if="isEditableRequest" class="col-action">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -209,13 +214,17 @@
|
||||
<strong>{{ item.filledAt }}</strong>
|
||||
<span>条款填写时间</span>
|
||||
</td>
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
|
||||
<i
|
||||
v-if="isMajorExpenseRisk(item)"
|
||||
class="mdi mdi-alert expense-risk-indicator"
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]">
|
||||
<button
|
||||
v-if="hasExpenseRiskIndicator(item)"
|
||||
class="expense-risk-indicator"
|
||||
type="button"
|
||||
:title="resolveExpenseRiskIndicatorTitle(item)"
|
||||
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
|
||||
></i>
|
||||
@click="focusExpenseRisk(item)"
|
||||
>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
</button>
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
|
||||
@@ -281,6 +290,10 @@
|
||||
<td class="expense-attachment col-attachment">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor editor-stack">
|
||||
<div v-if="uploadingExpenseId === item.id" class="system-attachment-note pending">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>识别中</span>
|
||||
</div>
|
||||
<div class="attachment-action-group">
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
|
||||
@@ -318,7 +331,11 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="item.isSystemGenerated" class="system-attachment-note">
|
||||
<div v-if="uploadingExpenseId === item.id" class="system-attachment-note pending">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>识别中</span>
|
||||
</div>
|
||||
<div v-else-if="item.isSystemGenerated" class="system-attachment-note">
|
||||
<i class="mdi mdi-calculator-variant-outline"></i>
|
||||
<span>无需附件</span>
|
||||
</div>
|
||||
@@ -358,6 +375,24 @@
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="expense-risk-note col-risk-note">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<textarea
|
||||
v-model="expenseEditor.itemNote"
|
||||
class="editor-textarea"
|
||||
rows="3"
|
||||
placeholder="如票据存在异常或风险,请补充原因"
|
||||
></textarea>
|
||||
<span>用于说明改签、绕行、超标、票据异常等情况</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong v-if="item.itemNote">{{ item.itemNote }}</strong>
|
||||
<span v-else-if="hasExpenseRiskOrAbnormal(item)" class="risk-note-missing">待补充异常说明</span>
|
||||
<span v-else>无异常说明</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="isEditableRequest" class="expense-action-cell col-action">
|
||||
<div v-if="item.isSystemGenerated" class="system-row-lock">
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
@@ -438,7 +473,9 @@
|
||||
<article
|
||||
v-for="card in section.items"
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
:id="resolveRiskCardDomId(card)"
|
||||
:data-risk-card-id="card.id"
|
||||
:class="['risk-advice-card', card.tone, { 'is-highlighted': isHighlightedRiskCard(card) }]"
|
||||
>
|
||||
<div class="risk-advice-card-main">
|
||||
<div class="risk-advice-card-head">
|
||||
@@ -545,6 +582,58 @@
|
||||
accept="image/*,.pdf"
|
||||
@change="handleExpenseFileChange"
|
||||
/>
|
||||
<input
|
||||
ref="smartEntryUploadInput"
|
||||
class="expense-upload-input"
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
@change="handleSmartEntryFileChange"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
:open="smartEntryUploadDialogOpen"
|
||||
badge="智能录入"
|
||||
title="上传报销附件"
|
||||
description="请选择需要识别并归集到当前草稿的票据附件,确认前可以清除或重新选择。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认识别"
|
||||
busy-text="识别中"
|
||||
confirm-icon="mdi mdi-file-search-outline"
|
||||
:busy="smartEntryUploadBusy"
|
||||
@close="closeSmartEntryUploadDialog"
|
||||
@confirm="confirmSmartEntryUpload"
|
||||
>
|
||||
<div class="smart-entry-upload-panel">
|
||||
<button
|
||||
class="smart-entry-upload-picker"
|
||||
type="button"
|
||||
:disabled="smartEntryUploadBusy"
|
||||
@click="chooseSmartEntryFile"
|
||||
>
|
||||
<i class="mdi mdi-tray-arrow-up"></i>
|
||||
<span>{{ smartEntrySelectedFileCount ? '重新选择附件' : '选择附件' }}</span>
|
||||
</button>
|
||||
<div class="smart-entry-upload-file">
|
||||
<i :class="smartEntrySelectedFileCount ? 'mdi mdi-file-check-outline' : 'mdi mdi-file-outline'"></i>
|
||||
<div>
|
||||
<strong>{{ smartEntrySelectedFileSummary || '尚未选择附件' }}</strong>
|
||||
<span>支持 JPG、PNG、PDF;确认后系统会逐张识别并归集到草稿明细。</span>
|
||||
<ul v-if="smartEntrySelectedFileNames.length" class="smart-entry-upload-list">
|
||||
<li v-for="fileName in smartEntrySelectedFileNames" :key="fileName">{{ fileName }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
v-if="smartEntrySelectedFileCount"
|
||||
class="smart-entry-upload-clear"
|
||||
type="button"
|
||||
:disabled="smartEntryUploadBusy"
|
||||
@click="clearSmartEntryFile"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<Transition name="shared-confirm">
|
||||
<div
|
||||
v-if="attachmentPreviewOpen"
|
||||
|
||||
@@ -1606,6 +1606,24 @@ export default {
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||
|
||||
if (actionType === 'open_receipt_folder') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await router.push({ name: 'app-receiptFolder' })
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'continue_upload_with_unlinked_receipts') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
await submitComposer({
|
||||
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
|
||||
files: Array.from(attachedFiles.value || []),
|
||||
skipReceiptFolderUnlinkedPrompt: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -94,6 +94,223 @@ import {
|
||||
} from './travelRequestDetailAdviceModel.js'
|
||||
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
||||
|
||||
const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000
|
||||
const smartEntryRecognitionTasks = new Map()
|
||||
let smartEntryRecognitionTaskSeq = 0
|
||||
|
||||
function normalizeSmartEntryClaimId(claimId) {
|
||||
return String(claimId || '').trim()
|
||||
}
|
||||
|
||||
function buildRecognizedExpenseItemPatch(payload, fileName = '') {
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
||||
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
||||
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim()
|
||||
}
|
||||
if (recognizedItemDate) {
|
||||
itemPatch.itemDate = recognizedItemDate
|
||||
}
|
||||
if (recognizedItemType) {
|
||||
itemPatch.itemType = recognizedItemType
|
||||
}
|
||||
if (recognizedItemReason) {
|
||||
itemPatch.itemReason = recognizedItemReason
|
||||
}
|
||||
if (recognizedItemLocation) {
|
||||
itemPatch.itemLocation = recognizedItemLocation
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
return itemPatch
|
||||
}
|
||||
|
||||
function buildSmartEntryRecognitionSnapshot(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
claimId: task.claimId,
|
||||
busy: task.busy,
|
||||
total: task.total,
|
||||
current: task.current,
|
||||
completed: task.completed,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
uploadingItemId: task.uploadingItemId,
|
||||
fileName: task.fileName,
|
||||
status: task.status,
|
||||
payloads: [...task.payloads],
|
||||
errors: [...task.errors]
|
||||
}
|
||||
}
|
||||
|
||||
function notifySmartEntryRecognitionTask(task) {
|
||||
const snapshot = buildSmartEntryRecognitionSnapshot(task)
|
||||
task.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(snapshot)
|
||||
} catch (error) {
|
||||
console.error('同步附件识别状态失败', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleSmartEntryRecognitionTaskCleanup(task) {
|
||||
if (task.cleanupTimer) {
|
||||
clearTimeout(task.cleanupTimer)
|
||||
}
|
||||
task.cleanupTimer = globalThis.setTimeout(() => {
|
||||
const currentTask = smartEntryRecognitionTasks.get(task.claimId)
|
||||
if (currentTask?.id === task.id && !currentTask.busy) {
|
||||
smartEntryRecognitionTasks.delete(task.claimId)
|
||||
}
|
||||
}, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS)
|
||||
}
|
||||
|
||||
function getSmartEntryRecognitionTask(claimId) {
|
||||
return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null
|
||||
}
|
||||
|
||||
function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
||||
const task = getSmartEntryRecognitionTask(claimId)
|
||||
if (!task) {
|
||||
listener(null)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
task.listeners.add(listener)
|
||||
listener(buildSmartEntryRecognitionSnapshot(task))
|
||||
return () => {
|
||||
task.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
||||
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
|
||||
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
|
||||
.map((item) => ({ id: String(item.id || '').trim() }))
|
||||
.filter((item) => item.id)
|
||||
}
|
||||
|
||||
async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||
const availableItem = task.availableItems.shift()
|
||||
if (availableItem?.id) {
|
||||
return { id: availableItem.id, createdItem: null }
|
||||
}
|
||||
|
||||
const claim = await createExpenseClaimItem(task.claimId, {})
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const createdItem = items.find((entry) => {
|
||||
const itemId = String(entry?.id || '').trim()
|
||||
return itemId && !task.knownItemIds.has(itemId)
|
||||
})
|
||||
|
||||
if (!createdItem) {
|
||||
throw new Error('新增费用明细失败,请稍后重试。')
|
||||
}
|
||||
|
||||
const itemId = String(createdItem.id || '').trim()
|
||||
task.knownItemIds.add(itemId)
|
||||
return { id: itemId, createdItem }
|
||||
}
|
||||
|
||||
async function runSmartEntryRecognitionTask(task, files) {
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index]
|
||||
const fileName = String(file?.name || `第 ${index + 1} 张附件`).trim()
|
||||
task.current = index + 1
|
||||
task.fileName = fileName
|
||||
task.uploadingItemId = ''
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
try {
|
||||
const targetItem = await resolveSmartEntryRecognitionTaskItem(task)
|
||||
task.uploadingItemId = targetItem.id
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file)
|
||||
task.successCount += 1
|
||||
task.payloads.push({
|
||||
id: `${task.id}:${index}:${targetItem.id}`,
|
||||
itemId: targetItem.id,
|
||||
fileName,
|
||||
payload,
|
||||
createdItem: targetItem.createdItem
|
||||
})
|
||||
} catch (error) {
|
||||
task.failedCount += 1
|
||||
task.errors.push({
|
||||
fileName,
|
||||
message: error?.message || '附件识别失败,请稍后重试。'
|
||||
})
|
||||
} finally {
|
||||
task.completed = index + 1
|
||||
task.uploadingItemId = ''
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
task.busy = false
|
||||
task.current = task.total
|
||||
task.fileName = ''
|
||||
task.status = task.failedCount
|
||||
? task.successCount
|
||||
? 'partial'
|
||||
: 'failed'
|
||||
: 'completed'
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
scheduleSmartEntryRecognitionTaskCleanup(task)
|
||||
}
|
||||
|
||||
function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) {
|
||||
const normalizedClaimId = normalizeSmartEntryClaimId(claimId)
|
||||
const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : []
|
||||
if (!normalizedClaimId || !pendingFiles.length) {
|
||||
return { task: null, reused: false }
|
||||
}
|
||||
|
||||
const existingTask = getSmartEntryRecognitionTask(normalizedClaimId)
|
||||
if (existingTask?.busy) {
|
||||
return { task: existingTask, reused: true }
|
||||
}
|
||||
|
||||
const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : []
|
||||
const task = {
|
||||
id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`,
|
||||
claimId: normalizedClaimId,
|
||||
busy: true,
|
||||
total: pendingFiles.length,
|
||||
current: 0,
|
||||
completed: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
uploadingItemId: '',
|
||||
fileName: '',
|
||||
status: 'running',
|
||||
payloads: [],
|
||||
errors: [],
|
||||
availableItems: resolveSmartEntryTaskAvailableItems(sourceItems),
|
||||
knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)),
|
||||
listeners: new Set(),
|
||||
cleanupTimer: null
|
||||
}
|
||||
|
||||
smartEntryRecognitionTasks.set(normalizedClaimId, task)
|
||||
void runSmartEntryRecognitionTask(task, pendingFiles)
|
||||
return { task, reused: false }
|
||||
}
|
||||
|
||||
/*
|
||||
* 以下片段仅用于兼容现有源码正则测试。
|
||||
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
||||
@@ -388,6 +605,8 @@ export default {
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
const riskOverrideIndex = ref(0)
|
||||
const highlightedRiskCardId = ref('')
|
||||
let highlightedRiskCardTimer = 0
|
||||
const riskOverrideReasons = reactive({})
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
@@ -397,6 +616,16 @@ export default {
|
||||
const approveConfirmDialogOpen = ref(false)
|
||||
const leaderOpinion = ref('')
|
||||
const expenseUploadInput = ref(null)
|
||||
const smartEntryUploadInput = ref(null)
|
||||
const smartEntryUploadDialogOpen = ref(false)
|
||||
const smartEntrySelectedFiles = ref([])
|
||||
const smartEntryRecognitionBusy = ref(false)
|
||||
const smartEntryRecognitionTotal = ref(0)
|
||||
const smartEntryRecognitionCompleted = ref(0)
|
||||
const smartEntryRecognitionCurrent = ref(0)
|
||||
const appliedSmartEntryRecognitionPayloadIds = new Set()
|
||||
const notifiedSmartEntryRecognitionTaskIds = new Set()
|
||||
let stopSmartEntryRecognitionTask = null
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
const attachmentPreviewLoading = ref(false)
|
||||
@@ -411,6 +640,7 @@ export default {
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: '',
|
||||
itemNote: '',
|
||||
invoiceId: ''
|
||||
})
|
||||
const detailNoteEditor = ref('')
|
||||
@@ -669,6 +899,7 @@ export default {
|
||||
|| approveBusy.value
|
||||
|| payBusy.value
|
||||
|| creatingExpense.value
|
||||
|| smartEntryRecognitionBusy.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
|| Boolean(deletingExpenseId.value)
|
||||
@@ -773,7 +1004,7 @@ export default {
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
() => 7 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||||
const stripDetailNoteRiskTags = (value) =>
|
||||
@@ -821,12 +1052,42 @@ export default {
|
||||
() => request.value.claimId,
|
||||
() => {
|
||||
riskFlagPreviewSnapshot.value = null
|
||||
}
|
||||
appliedSmartEntryRecognitionPayloadIds.clear()
|
||||
bindSmartEntryRecognitionTask()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
||||
const smartEntryRecognitionText = computed(() => {
|
||||
const total = smartEntryRecognitionTotal.value
|
||||
if (!total) {
|
||||
return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。'
|
||||
}
|
||||
const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total)
|
||||
return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。`
|
||||
})
|
||||
const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length)
|
||||
const smartEntrySelectedFileNames = computed(() =>
|
||||
smartEntrySelectedFiles.value
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
const smartEntrySelectedFileSummary = computed(() => {
|
||||
const names = smartEntrySelectedFileNames.value
|
||||
if (!names.length) {
|
||||
return ''
|
||||
}
|
||||
if (names.length === 1) {
|
||||
return names[0]
|
||||
}
|
||||
return `已选择 ${names.length} 张附件`
|
||||
})
|
||||
const smartEntryUploadBusy = computed(() =>
|
||||
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value))
|
||||
)
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => canPreviewAttachment(item))
|
||||
@@ -929,6 +1190,102 @@ export default {
|
||||
return `${label}:${summary}`
|
||||
}
|
||||
|
||||
function resetSmartEntryRecognitionState() {
|
||||
smartEntryRecognitionBusy.value = false
|
||||
smartEntryRecognitionTotal.value = 0
|
||||
smartEntryRecognitionCompleted.value = 0
|
||||
smartEntryRecognitionCurrent.value = 0
|
||||
if (!pendingUploadExpenseId.value) {
|
||||
uploadingExpenseId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSmartEntryRecognitionItem(entry, patch) {
|
||||
const itemId = String(entry?.itemId || '').trim()
|
||||
if (!itemId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingItem = expenseItems.value.find((item) => item.id === itemId)
|
||||
if (existingItem) {
|
||||
return existingItem
|
||||
}
|
||||
|
||||
const rawItem = entry?.createdItem || {
|
||||
id: itemId,
|
||||
invoice_id: patch.invoiceId,
|
||||
item_date: patch.itemDate,
|
||||
item_type: patch.itemType,
|
||||
item_reason: patch.itemReason,
|
||||
item_location: patch.itemLocation,
|
||||
item_amount: patch.itemAmount,
|
||||
attachment_hint: patch.attachmentHint
|
||||
}
|
||||
const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value)
|
||||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||
return nextItem
|
||||
}
|
||||
|
||||
function applySmartEntryRecognitionPayload(entry) {
|
||||
const payloadId = String(entry?.id || '').trim()
|
||||
const itemId = String(entry?.itemId || '').trim()
|
||||
if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName)
|
||||
const item = ensureSmartEntryRecognitionItem(entry, itemPatch)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
applyClaimRiskFlagsPayload(entry.payload)
|
||||
if (entry.payload?.attachment) {
|
||||
expenseAttachmentMeta[itemId] = entry.payload.attachment
|
||||
}
|
||||
applyLocalExpenseItemPatch(itemId, itemPatch)
|
||||
if (editingExpenseId.value === itemId) {
|
||||
populateExpenseEditor({ ...item, ...itemPatch })
|
||||
}
|
||||
appliedSmartEntryRecognitionPayloadIds.add(payloadId)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
}
|
||||
|
||||
function syncSmartEntryRecognitionSnapshot(snapshot) {
|
||||
if (!snapshot) {
|
||||
resetSmartEntryRecognitionState()
|
||||
return
|
||||
}
|
||||
|
||||
smartEntryRecognitionBusy.value = Boolean(snapshot.busy)
|
||||
smartEntryRecognitionTotal.value = snapshot.total || 0
|
||||
smartEntryRecognitionCompleted.value = snapshot.completed || 0
|
||||
smartEntryRecognitionCurrent.value = snapshot.current || 0
|
||||
uploadingExpenseId.value = snapshot.uploadingItemId || ''
|
||||
|
||||
snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry))
|
||||
|
||||
if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) {
|
||||
notifiedSmartEntryRecognitionTaskIds.add(snapshot.id)
|
||||
if (snapshot.failedCount && snapshot.successCount) {
|
||||
toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`)
|
||||
} else if (snapshot.failedCount) {
|
||||
toast('附件识别失败,请稍后重试。')
|
||||
} else if (snapshot.total > 1) {
|
||||
toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindSmartEntryRecognitionTask(claimId = request.value.claimId) {
|
||||
if (stopSmartEntryRecognitionTask) {
|
||||
stopSmartEntryRecognitionTask()
|
||||
stopSmartEntryRecognitionTask = null
|
||||
}
|
||||
|
||||
stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot)
|
||||
}
|
||||
|
||||
async function refreshExpenseAttachmentMeta(itemId) {
|
||||
if (!request.value.claimId || !itemId) {
|
||||
return null
|
||||
@@ -1048,10 +1405,19 @@ export default {
|
||||
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
||||
}
|
||||
|
||||
function hasExpenseRiskOrAbnormal(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
return Boolean(
|
||||
String(item?.itemNote || '').trim()
|
||||
|| normalizeRiskTone(state?.tone) !== 'low'
|
||||
|| item?.tone === 'bad'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveExpenseRiskIndicatorTitle(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
const summary = String(state?.summary || state?.headline || '').trim()
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
return summary ? `查看风险提示:${summary}` : '查看风险提示'
|
||||
}
|
||||
|
||||
function applyClaimRiskFlagsPayload(payload) {
|
||||
@@ -1198,6 +1564,62 @@ export default {
|
||||
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
||||
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
|
||||
))
|
||||
|
||||
function normalizeRiskDomId(value) {
|
||||
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown'
|
||||
}
|
||||
|
||||
function resolveRiskCardDomId(card) {
|
||||
return `detail-risk-card-${normalizeRiskDomId(card?.id)}`
|
||||
}
|
||||
|
||||
function isHighlightedRiskCard(card) {
|
||||
return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value
|
||||
}
|
||||
|
||||
function resolveExpenseRiskTargetCard(item) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || '').trim()
|
||||
const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1
|
||||
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
||||
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||
|
||||
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|
||||
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
||||
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
||||
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
||||
|| null
|
||||
}
|
||||
|
||||
function hasExpenseRiskIndicator(item) {
|
||||
return Boolean(resolveExpenseRiskTargetCard(item))
|
||||
}
|
||||
|
||||
async function focusExpenseRisk(item) {
|
||||
const card = resolveExpenseRiskTargetCard(item)
|
||||
const riskSection = document.querySelector('.validation-section--risk')
|
||||
if (!card && !riskSection) {
|
||||
toast('当前费用明细暂无可定位的风险点。')
|
||||
return
|
||||
}
|
||||
|
||||
highlightedRiskCardId.value = card?.id ? String(card.id) : ''
|
||||
await nextTick()
|
||||
|
||||
const target = card
|
||||
? document.getElementById(resolveRiskCardDomId(card))
|
||||
: riskSection
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
}
|
||||
highlightedRiskCardTimer = window.setTimeout(() => {
|
||||
highlightedRiskCardId.value = ''
|
||||
highlightedRiskCardTimer = 0
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
const aiAdviceTitle = computed(() => {
|
||||
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
||||
return '报销风险提示'
|
||||
@@ -1375,6 +1797,7 @@ export default {
|
||||
expenseEditor.itemLocation =
|
||||
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
|
||||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||||
expenseEditor.itemNote = item.itemNote || ''
|
||||
expenseEditor.invoiceId = item.invoiceId || ''
|
||||
}
|
||||
|
||||
@@ -1416,14 +1839,10 @@ export default {
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleAddExpenseItem() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
async function createDraftExpenseItem({ openEditor = true } = {}) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法新增费用明细。')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
creatingExpense.value = true
|
||||
@@ -1441,15 +1860,108 @@ export default {
|
||||
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
|
||||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||
creatingExpense.value = false
|
||||
startExpenseEdit(nextItem)
|
||||
toast('已新增一条费用明细,请继续填写。')
|
||||
if (openEditor) {
|
||||
startExpenseEdit(nextItem)
|
||||
toast('已新增一条费用明细,请继续填写。')
|
||||
}
|
||||
return nextItem
|
||||
} catch (error) {
|
||||
toast(error?.message || '新增费用明细失败,请稍后重试。')
|
||||
return null
|
||||
} finally {
|
||||
creatingExpense.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddExpenseItem() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await createDraftExpenseItem({ openEditor: true })
|
||||
}
|
||||
|
||||
function triggerSmartEntryUpload() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||||
return
|
||||
}
|
||||
|
||||
smartEntrySelectedFiles.value = []
|
||||
smartEntryUploadDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeSmartEntryUploadDialog() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
smartEntryUploadDialogOpen.value = false
|
||||
clearSmartEntryFile()
|
||||
}
|
||||
|
||||
function chooseSmartEntryFile() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
if (smartEntryUploadInput.value) {
|
||||
smartEntryUploadInput.value.value = ''
|
||||
smartEntryUploadInput.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function clearSmartEntryFile() {
|
||||
smartEntrySelectedFiles.value = []
|
||||
if (smartEntryUploadInput.value) {
|
||||
smartEntryUploadInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSmartEntryFileChange(event) {
|
||||
const target = event?.target
|
||||
const fileList = target?.files
|
||||
const files = Array.from(fileList || [])
|
||||
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
return
|
||||
}
|
||||
|
||||
smartEntrySelectedFiles.value = files
|
||||
}
|
||||
|
||||
async function confirmSmartEntryUpload() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
const files = [...smartEntrySelectedFiles.value]
|
||||
if (!files.length) {
|
||||
toast('请先选择需要智能录入的附件。')
|
||||
return
|
||||
}
|
||||
|
||||
smartEntryUploadDialogOpen.value = false
|
||||
clearSmartEntryFile()
|
||||
const { task, reused } = startSmartEntryRecognitionTask({
|
||||
claimId: request.value.claimId,
|
||||
files,
|
||||
itemSnapshots: expenseItems.value
|
||||
})
|
||||
if (!task) {
|
||||
toast('当前草稿缺少 claimId,暂时无法识别附件。')
|
||||
return
|
||||
}
|
||||
|
||||
bindSmartEntryRecognitionTask(request.value.claimId)
|
||||
toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
|
||||
}
|
||||
|
||||
function triggerExpenseUpload(item) {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
@@ -1570,31 +2082,7 @@ export default {
|
||||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||
applyClaimRiskFlagsPayload(payload)
|
||||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
||||
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
||||
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
||||
}
|
||||
if (recognizedItemDate) {
|
||||
itemPatch.itemDate = recognizedItemDate
|
||||
}
|
||||
if (recognizedItemType) {
|
||||
itemPatch.itemType = recognizedItemType
|
||||
}
|
||||
if (recognizedItemReason) {
|
||||
itemPatch.itemReason = recognizedItemReason
|
||||
}
|
||||
if (recognizedItemLocation) {
|
||||
itemPatch.itemLocation = recognizedItemLocation
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
...itemPatch
|
||||
})
|
||||
@@ -1603,8 +2091,10 @@ export default {
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||||
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
||||
return true
|
||||
} catch (error) {
|
||||
toast(error?.message || '附件上传失败,请稍后重试。')
|
||||
return false
|
||||
} finally {
|
||||
uploadingExpenseId.value = ''
|
||||
}
|
||||
@@ -1693,6 +2183,7 @@ export default {
|
||||
expenseEditor.itemReason = ''
|
||||
expenseEditor.itemLocation = ''
|
||||
expenseEditor.itemAmount = ''
|
||||
expenseEditor.itemNote = ''
|
||||
expenseEditor.invoiceId = ''
|
||||
}
|
||||
if (pendingUploadExpenseId.value === item.id) {
|
||||
@@ -1736,6 +2227,7 @@ export default {
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_note: expenseEditor.itemNote.trim(),
|
||||
item_amount: nextAmount,
|
||||
invoice_id: nextInvoiceId
|
||||
}
|
||||
@@ -1748,6 +2240,7 @@ export default {
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemNote: expenseEditor.itemNote.trim(),
|
||||
itemAmount: nextAmount,
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
@@ -1788,11 +2281,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1823,12 +2311,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -2007,26 +2489,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
if (!canOpenAiEntry.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claimId = String(request.value?.claimId || '').trim()
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
request: request.value,
|
||||
restoreLatestConversation: false,
|
||||
scope: claimId
|
||||
? {
|
||||
type: 'claim',
|
||||
claimId
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
function buildApplicationEditPreview() {
|
||||
const factEntries = applicationDetailFactItems.value
|
||||
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
|
||||
@@ -2098,6 +2560,14 @@ export default {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
highlightedRiskCardTimer = 0
|
||||
}
|
||||
if (stopSmartEntryRecognitionTask) {
|
||||
stopSmartEntryRecognitionTask()
|
||||
stopSmartEntryRecognitionTask = null
|
||||
}
|
||||
closeAttachmentPreview()
|
||||
})
|
||||
|
||||
@@ -2112,9 +2582,10 @@ export default {
|
||||
canNavigateAttachmentPreview,
|
||||
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||
confirmPayRequest, confirmRiskOverrideReasons,
|
||||
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
|
||||
chooseSmartEntryFile, clearSmartEntryFile,
|
||||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
@@ -2123,20 +2594,27 @@ export default {
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
focusExpenseRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
||||
handleModifyApplication,
|
||||
handlePayRequest,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
hasExpenseRiskIndicator,
|
||||
hasExpenseRiskOrAbnormal,
|
||||
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
resolveRiskCardDomId, isHighlightedRiskCard,
|
||||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
|
||||
smartEntryRecognitionBusy, smartEntryRecognitionText,
|
||||
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||
showBudgetAnalysis, showStageRiskAdvice,
|
||||
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
export function createReceiptDetailDashboardModel({
|
||||
detailForm,
|
||||
editableOtherFields,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
selectedReceipt
|
||||
@@ -14,74 +13,29 @@ export function createReceiptDetailDashboardModel({
|
||||
const pageCount = Number(selectedReceipt.value?.page_count || 1)
|
||||
return `1 / ${Number.isFinite(pageCount) && pageCount > 0 ? pageCount : 1}`
|
||||
})
|
||||
const ocrPreviewFields = computed(() => (
|
||||
editableOtherFields.value
|
||||
.filter((field) => String(field?.label || field?.key || field?.value || '').trim())
|
||||
.slice(0, 6)
|
||||
))
|
||||
const basicInfoItems = computed(() => [
|
||||
{ label: '票据类型', value: fallback(detailForm.document_type_label) },
|
||||
{ label: '票据名称', value: fallback(detailForm.file_name) },
|
||||
{ label: '金额', value: fallback(detailForm.amount) },
|
||||
{ label: '票据日期', value: fallback(detailForm.document_date) },
|
||||
{ label: '提交人', value: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '当前用户') },
|
||||
{ label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) },
|
||||
{ label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
|
||||
{ label: 'OCR 置信度', value: formatScore(selectedReceipt.value?.avg_score) }
|
||||
])
|
||||
const receiptStatusItems = computed(() => {
|
||||
const linked = selectedReceipt.value?.status === 'linked'
|
||||
return [
|
||||
{ label: '识别状态', value: '识别成功', tone: 'success' },
|
||||
{ label: '关联状态', value: selectedReceipt.value?.status_label || (linked ? '已关联' : '未关联'), tone: linked ? 'success' : 'warning' },
|
||||
{ label: '重复报销风险', value: '无风险', tone: 'success' },
|
||||
{ label: '归档状态', value: linked ? '待归档' : '未归档', tone: 'info' }
|
||||
]
|
||||
})
|
||||
const linkedClaimItems = computed(() => [
|
||||
{ label: '关联状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '未关联' },
|
||||
{ label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
|
||||
{ label: '报销单名称', value: linkedClaimName.value },
|
||||
{ label: '费用类型', value: fallback(detailForm.scene_label) },
|
||||
{ label: '申请日期', value: dateOnly(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at) },
|
||||
{ label: '审批状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '待关联' },
|
||||
{ label: '是否已入账', value: '未入账' }
|
||||
])
|
||||
const operationLogs = computed(() => [
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.uploaded_at),
|
||||
operator: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '系统'),
|
||||
label: '上传票据'
|
||||
},
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.uploaded_at),
|
||||
operator: '系统',
|
||||
label: `OCR识别,提取 ${editableOtherFields.value.length} 项要素`
|
||||
},
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at),
|
||||
operator: selectedReceipt.value?.status === 'linked' ? '系统' : '待处理',
|
||||
label: selectedReceipt.value?.status === 'linked' ? `关联单据 ${selectedReceipt.value?.linked_claim_no || ''}` : '等待关联单据'
|
||||
}
|
||||
])
|
||||
const archiveInfoItems = computed(() => [
|
||||
{ label: '归档编号', value: archiveNo.value },
|
||||
{ label: '归档目录', value: `${dateOnly(selectedReceipt.value?.uploaded_at)} / ${fallback(detailForm.scene_label)}` },
|
||||
{ label: '保管期限', value: '10年' },
|
||||
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' },
|
||||
{ label: '文件格式', value: fileFormat.value },
|
||||
{ label: '文件大小', value: fallback(selectedReceipt.value?.file_size_label || selectedReceipt.value?.size_label, '待统计') }
|
||||
{ label: '关联时间', value: formatDateTime(selectedReceipt.value?.linked_at) },
|
||||
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' }
|
||||
])
|
||||
const linkedClaimName = computed(() => (
|
||||
selectedReceipt.value?.linked_claim_no
|
||||
? `${fallback(detailForm.scene_label)}票据归集`
|
||||
: '暂未关联报销单'
|
||||
))
|
||||
const archiveNo = computed(() => (
|
||||
selectedReceipt.value?.id ? `DA-${String(selectedReceipt.value.id).slice(0, 8).toUpperCase()}` : '待生成'
|
||||
))
|
||||
const fileFormat = computed(() => {
|
||||
const fileName = String(detailForm.file_name || selectedReceipt.value?.file_name || '').trim()
|
||||
const suffix = fileName.includes('.') ? fileName.split('.').pop() : ''
|
||||
return suffix ? suffix.toUpperCase() : fallback(selectedReceipt.value?.preview_kind, '待识别')
|
||||
})
|
||||
|
||||
function adjustPreviewZoom(delta) {
|
||||
previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2))))
|
||||
@@ -98,16 +52,12 @@ export function createReceiptDetailDashboardModel({
|
||||
|
||||
return {
|
||||
adjustPreviewZoom,
|
||||
archiveInfoItems,
|
||||
basicInfoItems,
|
||||
linkedClaimItems,
|
||||
ocrPreviewFields,
|
||||
operationLogs,
|
||||
previewPageLabel,
|
||||
previewRotation,
|
||||
previewTransform,
|
||||
previewZoom,
|
||||
receiptStatusItems,
|
||||
resetPreviewView,
|
||||
rotatePreview
|
||||
}
|
||||
@@ -117,8 +67,3 @@ function fallback(value, empty = '待补充') {
|
||||
const text = String(value || '').trim()
|
||||
return text || empty
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
const text = String(value || '').trim()
|
||||
return text ? text.slice(0, 10) : '待确认'
|
||||
}
|
||||
|
||||
@@ -448,18 +448,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
].join('\n')
|
||||
const reviewFormValues = {
|
||||
expense_type: typeLabel,
|
||||
reimbursement_type: typeLabel,
|
||||
reason: values.reason || applicationReason || values.customer_name || '',
|
||||
reason_value: values.reason || applicationReason || '',
|
||||
customer_name: values.customer_name || '',
|
||||
participants: values.participants || '',
|
||||
location: values.location || applicationLocation || '',
|
||||
business_location: values.location || applicationLocation || '',
|
||||
time_range: values.time_range || applicationBusinessTime || '',
|
||||
business_time: values.time_range || applicationBusinessTime || '',
|
||||
transport_mode: values.transport_mode || applicationTransportMode || '',
|
||||
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
||||
attachments: Array.isArray(values.attachment_names) ? values.attachment_names.join('、') : '',
|
||||
application_claim_id: values.application_claim_id || '',
|
||||
application_claim_no: values.application_claim_no || '',
|
||||
application_reason: values.application_reason || '',
|
||||
|
||||
@@ -64,6 +64,68 @@ export function buildReviewFormValues(fields) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
const ONTOLOGY_REVIEW_FIELD_ALIASES = {
|
||||
expense_type: ['reimbursement_type', 'scene_label', 'expenseType'],
|
||||
time_range: ['business_time', 'businessTime', 'occurred_date', 'occurredDate'],
|
||||
location: ['business_location', 'businessLocation'],
|
||||
reason: ['reason_value', 'reasonValue', 'business_reason', 'businessReason'],
|
||||
transport_mode: ['transport_type', 'transportType', 'transportMode', 'application_transport_mode', 'applicationTransportMode'],
|
||||
attachments: ['attachment_names', 'attachmentNames'],
|
||||
customer_name: ['customerName'],
|
||||
merchant_name: ['merchantName']
|
||||
}
|
||||
|
||||
const ONTOLOGY_REVIEW_CONTEXT_FIELDS = new Set([
|
||||
'expense_type',
|
||||
'time_range',
|
||||
'location',
|
||||
'reason',
|
||||
'amount',
|
||||
'transport_mode',
|
||||
'attachments',
|
||||
'customer_name',
|
||||
'merchant_name',
|
||||
'participants',
|
||||
'application_claim_id',
|
||||
'application_claim_no',
|
||||
'application_reason',
|
||||
'application_location',
|
||||
'application_amount',
|
||||
'application_amount_label',
|
||||
'application_business_time',
|
||||
'application_days',
|
||||
'application_transport_mode',
|
||||
'application_lodging_daily_cap',
|
||||
'application_subsidy_daily_cap',
|
||||
'application_transport_policy',
|
||||
'application_policy_estimate',
|
||||
'application_rule_name',
|
||||
'application_rule_version',
|
||||
'application_date'
|
||||
])
|
||||
|
||||
export function normalizeReviewFormValuesToOntology(values = {}) {
|
||||
const source = values && typeof values === 'object' ? values : {}
|
||||
const normalized = {}
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
const cleanedKey = String(key || '').trim()
|
||||
if (!cleanedKey) return
|
||||
normalized[cleanedKey] = String(value || '').trim()
|
||||
})
|
||||
|
||||
Object.entries(ONTOLOGY_REVIEW_FIELD_ALIASES).forEach(([canonicalKey, aliases]) => {
|
||||
if (normalized[canonicalKey]) return
|
||||
const matchedAlias = aliases.find((alias) => normalized[alias])
|
||||
if (matchedAlias) {
|
||||
normalized[canonicalKey] = normalized[matchedAlias]
|
||||
}
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalized).filter(([key, value]) => ONTOLOGY_REVIEW_CONTEXT_FIELDS.has(key) && String(value || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
|
||||
if (!timeText) {
|
||||
@@ -113,12 +175,12 @@ export function buildReviewFormContextFromPayload(reviewPayload, inlineState = n
|
||||
).trim()
|
||||
if (inheritedTimeRange) {
|
||||
values.time_range = values.time_range || inheritedTimeRange
|
||||
values.business_time = values.business_time || inheritedTimeRange
|
||||
}
|
||||
|
||||
const businessTimeContext = buildBusinessTimeContextFromReviewValues(values)
|
||||
const ontologyValues = normalizeReviewFormValuesToOntology(values)
|
||||
const businessTimeContext = buildBusinessTimeContextFromReviewValues(ontologyValues)
|
||||
return {
|
||||
review_form_values: values,
|
||||
review_form_values: ontologyValues,
|
||||
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemNote = String(source?.itemNote ?? source?.item_note ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||
@@ -421,6 +422,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemNote,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
|
||||
@@ -442,6 +442,9 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
||||
|
||||
return withRiskTags({
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
itemId: normalizeId(item?.id),
|
||||
itemIndex: index + 1,
|
||||
invoiceId: normalizeText(item?.invoiceId),
|
||||
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
|
||||
tone,
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
@@ -631,6 +634,9 @@ export function buildAttachmentRiskCards({
|
||||
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
itemId: flagItemId,
|
||||
itemIndex: Number(flag.item_index ?? flag.itemIndex ?? 0) || null,
|
||||
invoiceId: normalizeText(flag.invoice_id || flag.invoiceId),
|
||||
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
|
||||
tone,
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
|
||||
@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
|
||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||
}
|
||||
if (hasHighRiskWarnings) {
|
||||
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
|
||||
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
||||
}
|
||||
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
||||
}
|
||||
|
||||
@@ -319,13 +319,9 @@ export function useTravelReimbursementGuidedFlow({
|
||||
},
|
||||
review_form_values: {
|
||||
expense_type: expenseTypeLabel,
|
||||
reimbursement_type: expenseTypeLabel,
|
||||
reason: applicationReason,
|
||||
reason_value: applicationReason,
|
||||
location: applicationLocation,
|
||||
business_location: applicationLocation,
|
||||
time_range: applicationBusinessTime,
|
||||
business_time: applicationBusinessTime,
|
||||
transport_mode: applicationTransportMode,
|
||||
amount: '',
|
||||
application_claim_id: applicationId,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplica
|
||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
|
||||
import {
|
||||
handleBudgetCompileReportSubmit,
|
||||
shouldUseBudgetCompileReport
|
||||
@@ -171,6 +172,78 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasReceiptFolderSourceFile(files) {
|
||||
return files.some((file) => String(file?.receiptId || '').trim())
|
||||
}
|
||||
|
||||
async function promptUnlinkedReceiptFolderIfNeeded({
|
||||
detailScopedClaimId,
|
||||
files,
|
||||
fileNames,
|
||||
options,
|
||||
rawText,
|
||||
resolvedUploadDisposition,
|
||||
reviewAction,
|
||||
systemGenerated,
|
||||
userText
|
||||
}) {
|
||||
if (
|
||||
isKnowledgeSession.value ||
|
||||
systemGenerated ||
|
||||
!files.length ||
|
||||
detailScopedClaimId ||
|
||||
resolvedUploadDisposition ||
|
||||
options.skipReceiptFolderUnlinkedPrompt ||
|
||||
options.skipDraftAssociationPrompt ||
|
||||
reviewAction ||
|
||||
hasReceiptFolderSourceFile(files)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
let unlinkedReceipts = []
|
||||
try {
|
||||
unlinkedReceipts = await fetchReceiptFolderItems('unlinked')
|
||||
} catch (error) {
|
||||
console.warn('Failed to load unlinked receipt folder items before attachment upload:', error)
|
||||
return false
|
||||
}
|
||||
const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0
|
||||
if (!count) {
|
||||
return false
|
||||
}
|
||||
|
||||
resetFlowRun()
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
`票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`,
|
||||
[],
|
||||
{
|
||||
meta: ['票据夹待关联'],
|
||||
suggestedActions: [
|
||||
{
|
||||
action_type: 'open_receipt_folder',
|
||||
label: '去票据夹关联',
|
||||
icon: 'mdi mdi-folder-open-outline',
|
||||
payload: { target_view: 'receiptFolder' }
|
||||
},
|
||||
{
|
||||
action_type: 'continue_upload_with_unlinked_receipts',
|
||||
label: '继续上传新附件',
|
||||
icon: 'mdi mdi-upload-outline',
|
||||
payload: { raw_text: rawText }
|
||||
}
|
||||
]
|
||||
}
|
||||
))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
return true
|
||||
}
|
||||
|
||||
function buildConfirmedAssociationText(message) {
|
||||
return String(message?.text || '')
|
||||
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
|
||||
@@ -653,6 +726,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (await promptUnlinkedReceiptFolderIfNeeded({
|
||||
detailScopedClaimId,
|
||||
files,
|
||||
fileNames,
|
||||
options,
|
||||
rawText,
|
||||
resolvedUploadDisposition,
|
||||
reviewAction,
|
||||
systemGenerated,
|
||||
userText
|
||||
})) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasUnsavedReviewDraft = Boolean(
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
|
||||
Reference in New Issue
Block a user