feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<SidebarRail
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:company-name="companyProfile.name"
|
||||
:company-logo="companyProfile.logo"
|
||||
:current-user="currentUser"
|
||||
@navigate="handleNavigate"
|
||||
@open-chat="openSmartEntry"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<div class="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<div class="app-sidebar">
|
||||
<SidebarRail
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:company-name="companyProfile.name"
|
||||
:company-logo="companyProfile.logo"
|
||||
:current-user="currentUser"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@navigate="handleNavigate"
|
||||
@open-chat="openSmartEntry"
|
||||
@logout="handleLogout"
|
||||
@toggle-collapse="toggleSidebarCollapsed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main
|
||||
class="main"
|
||||
@@ -49,7 +53,7 @@
|
||||
@update:active-range="activeRange = $event"
|
||||
@update:custom-range="customRange = $event"
|
||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||
@new-application="openExpenseApplicationDialog"
|
||||
@new-application="openExpenseApplicationCreate"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
@@ -91,6 +95,7 @@
|
||||
<TravelRequestDetailView
|
||||
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
|
||||
:request="selectedRequest"
|
||||
:back-label="activeView === 'documents' ? '返回单据中心' : '返回报销列表'"
|
||||
@back-to-requests="closeRequestDetail"
|
||||
@open-assistant="openSmartEntry"
|
||||
@request-updated="handleRequestUpdated"
|
||||
@@ -105,7 +110,7 @@
|
||||
:error="requestsError"
|
||||
@open-document="openRequestDetail"
|
||||
@create-request="openTravelCreate"
|
||||
@create-application="openExpenseApplicationDialog"
|
||||
@create-application="openExpenseApplicationCreate"
|
||||
@reload="reloadRequests"
|
||||
@summary-change="documentSummary = $event"
|
||||
/>
|
||||
@@ -146,12 +151,6 @@
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
/>
|
||||
|
||||
<ExpenseApplicationDialog
|
||||
v-if="expenseApplicationDialogOpen"
|
||||
@close="closeExpenseApplicationDialog"
|
||||
@confirmed="handleExpenseApplicationConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -161,7 +160,6 @@ 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 ExpenseApplicationDialog from '../components/shared/ExpenseApplicationDialog.vue'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
@@ -186,7 +184,11 @@ const knowledgeSummary = ref(null)
|
||||
const logsSummary = ref(null)
|
||||
const documentSummary = ref(null)
|
||||
const auditDetailOpen = ref(false)
|
||||
const expenseApplicationDialogOpen = ref(false)
|
||||
const sidebarCollapsed = ref(true)
|
||||
|
||||
function toggleSidebarCollapsed() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
const {
|
||||
activeRange,
|
||||
@@ -206,6 +208,7 @@ const {
|
||||
handleRequestDeleted,
|
||||
handleRequestUpdated,
|
||||
navItems,
|
||||
openExpenseApplicationCreate,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
@@ -229,19 +232,6 @@ const {
|
||||
const { companyProfile, currentUser, logout } = useSystemState()
|
||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||
|
||||
function openExpenseApplicationDialog() {
|
||||
expenseApplicationDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeExpenseApplicationDialog() {
|
||||
expenseApplicationDialogOpen.value = false
|
||||
}
|
||||
|
||||
function handleExpenseApplicationConfirmed() {
|
||||
expenseApplicationDialogOpen.value = false
|
||||
toast('费用申请字段已接入本体识别,后续会按申请审批流落单。')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
}
|
||||
|
||||
@@ -193,7 +193,10 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
|
||||
<td><strong class="doc-id">{{ row.documentNo }}</strong></td>
|
||||
<td>
|
||||
<span v-if="row.isNewDocument" class="new-document-badge">NEW</span>
|
||||
<strong class="doc-id">{{ row.documentNo }}</strong>
|
||||
</td>
|
||||
<td>{{ row.createdAtDisplay }}</td>
|
||||
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
|
||||
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
|
||||
@@ -259,31 +262,31 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
extractDateText,
|
||||
formatDocumentListTime,
|
||||
resolveDocumentSortTime,
|
||||
resolveDocumentStayTimeDisplay
|
||||
} from '../utils/documentCenterTime.js'
|
||||
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
||||
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||
|
||||
const DOCUMENT_TYPE_ALL = 'all'
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const SCENE_ALL = 'all'
|
||||
const DOCUMENT_SCOPE_ALL = '全部'
|
||||
const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
|
||||
const scopeTabs = [
|
||||
DOCUMENT_SCOPE_APPLICATION,
|
||||
DOCUMENT_SCOPE_REIMBURSEMENT,
|
||||
DOCUMENT_SCOPE_REVIEW,
|
||||
DOCUMENT_SCOPE_ARCHIVE
|
||||
]
|
||||
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
||||
const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
searchPlaceholder: '搜索单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '单据场景',
|
||||
dateLabel: '单据时间',
|
||||
statusTitle: '单据状态',
|
||||
statusTabs,
|
||||
showDocumentType: true
|
||||
},
|
||||
[DOCUMENT_SCOPE_APPLICATION]: {
|
||||
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
|
||||
sceneFallbackLabel: '申请场景',
|
||||
@@ -339,7 +342,7 @@ const emit = defineEmits([
|
||||
'summary-change'
|
||||
])
|
||||
|
||||
const activeScopeTab = ref(DOCUMENT_SCOPE_APPLICATION)
|
||||
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
|
||||
const activeStatusTab = ref('全部')
|
||||
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
||||
const activeScene = ref(SCENE_ALL)
|
||||
@@ -357,6 +360,7 @@ const archiveRows = ref([])
|
||||
const approvalRows = ref([])
|
||||
const supportingLoading = ref(false)
|
||||
const supportingError = ref('')
|
||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||
|
||||
const activeFilterConfig = computed(() =>
|
||||
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
|
||||
@@ -389,13 +393,14 @@ const ownedRows = computed(() =>
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
const allSummaryRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value, ...archiveRows.value]))
|
||||
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
|
||||
|
||||
const scopeNewCountMap = computed(() => ({
|
||||
[DOCUMENT_SCOPE_APPLICATION]: allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION).length,
|
||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT).length,
|
||||
[DOCUMENT_SCOPE_REVIEW]: approvalRows.value.length,
|
||||
[DOCUMENT_SCOPE_ARCHIVE]: archiveRows.value.length
|
||||
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION), viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
|
||||
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
|
||||
}))
|
||||
|
||||
const scopeTabItems = computed(() =>
|
||||
@@ -407,8 +412,10 @@ const scopeTabItems = computed(() =>
|
||||
)
|
||||
|
||||
const activeScopeRows = computed(() => {
|
||||
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
|
||||
|
||||
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
|
||||
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
||||
return nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
||||
}
|
||||
|
||||
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
|
||||
@@ -423,7 +430,7 @@ const activeScopeRows = computed(() => {
|
||||
return archiveRows.value
|
||||
}
|
||||
|
||||
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
||||
return nonArchivedRows.value
|
||||
})
|
||||
|
||||
const sceneFilterOptions = computed(() => {
|
||||
@@ -487,7 +494,7 @@ const showStayTimeColumn = computed(() =>
|
||||
)
|
||||
|
||||
const documentSummary = computed(() => {
|
||||
const rows = allSummaryRows.value
|
||||
const rows = nonArchivedRows.value
|
||||
return {
|
||||
total: rows.length,
|
||||
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
|
||||
@@ -507,9 +514,9 @@ const emptyState = computed(() => {
|
||||
title: '当前还没有申请单数据',
|
||||
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
|
||||
icon: 'mdi mdi-file-sign-outline',
|
||||
actionLabel: '发起申请',
|
||||
actionIcon: 'mdi mdi-file-plus-outline',
|
||||
tone: 'sky',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'emerald',
|
||||
artLabel: 'APPLY',
|
||||
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
|
||||
}
|
||||
@@ -522,9 +529,9 @@ const emptyState = computed(() => {
|
||||
? '可以清空当前分类下的筛选条件后再看看。'
|
||||
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
|
||||
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
|
||||
actionLabel: filtered ? '清空筛选' : '发起报销',
|
||||
actionIcon: filtered ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-plus-circle-outline',
|
||||
tone: filtered ? 'sky' : 'emerald',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'emerald',
|
||||
artLabel: filtered ? 'FILTER' : 'DOCS',
|
||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
|
||||
}
|
||||
@@ -543,13 +550,17 @@ function buildDocumentRow(request, options = {}) {
|
||||
const claimId = normalized.claimId || normalized.id || documentNo
|
||||
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
||||
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
||||
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
rawRequest: request,
|
||||
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
|
||||
documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
documentTypeLabel: '报销单',
|
||||
documentTypeCode,
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
@@ -560,6 +571,7 @@ function buildDocumentRow(request, options = {}) {
|
||||
archived,
|
||||
createdAtDisplay: formatDocumentListTime(createdAtSource),
|
||||
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
|
||||
isNewDocument: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
|
||||
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
||||
sortTime: resolveDocumentSortTime(updatedAtSource)
|
||||
}
|
||||
@@ -703,6 +715,8 @@ function changePageSize(size) {
|
||||
}
|
||||
|
||||
function openDocument(row) {
|
||||
writeDocumentScope(activeScopeTab.value, scopeTabs)
|
||||
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
|
||||
emit('open-document', row.rawRequest || row)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
<header class="assistant-header">
|
||||
<div class="assistant-header-main">
|
||||
<div>
|
||||
<h2>财务助手</h2>
|
||||
<p>个人财务中心 · 报销识别、票据核对与制度咨询,右侧会随处理进度展示识别结果与风险提示。</p>
|
||||
<h2>{{ assistantHeaderTitle }}</h2>
|
||||
<p>{{ assistantHeaderDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -60,7 +60,9 @@
|
||||
:key="shortcut.label"
|
||||
type="button"
|
||||
class="shortcut-chip"
|
||||
:disabled="submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
|
||||
:class="{ active: shortcut.active }"
|
||||
:aria-pressed="shortcut.active ? 'true' : 'false'"
|
||||
:disabled="shortcut.active || submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
|
||||
@click="runShortcut(shortcut)"
|
||||
>
|
||||
<i :class="shortcut.icon"></i>
|
||||
@@ -1313,6 +1315,22 @@
|
||||
@confirm="confirmDeleteCurrentSession"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="applicationSubmitConfirmDialog.open"
|
||||
badge="提交确认"
|
||||
badge-tone="primary"
|
||||
title="确认提交当前费用申请?"
|
||||
description="提交后申请将进入领导审核流程,并同步纳入预算管理口径,请确认关键申请信息和预计费用已经核对无误。"
|
||||
cancel-text="再检查一下"
|
||||
confirm-text="确认提交"
|
||||
busy-text="提交中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-send-check-outline"
|
||||
:busy="reviewActionBusy"
|
||||
@close="closeApplicationSubmitConfirm"
|
||||
@confirm="confirmApplicationSubmit"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="nextStepConfirmDialog.open"
|
||||
badge="提交确认"
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<article class="progress-card panel">
|
||||
<div class="progress-block">
|
||||
<div class="progress-head">
|
||||
<h3>{{ isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
|
||||
<h3>{{ isApplicationDocument ? '申请进度' : isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
|
||||
</div>
|
||||
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
|
||||
<div
|
||||
@@ -133,12 +133,18 @@
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>费用明细</h3>
|
||||
<h3>{{ isApplicationDocument ? '申请预算' : '费用明细' }}</h3>
|
||||
<p>
|
||||
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
|
||||
{{
|
||||
isApplicationDocument
|
||||
? '展示本次费用申请的预计金额,提交后纳入预算管理口径。'
|
||||
: isTravelRequest
|
||||
? '按出行时间逐笔核对票据与差旅规则。'
|
||||
: '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="detail-card-actions">
|
||||
<div v-if="!isApplicationDocument" class="detail-card-actions">
|
||||
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
|
||||
<i class="mdi mdi-robot-outline"></i>
|
||||
<span>智能录入</span>
|
||||
@@ -156,7 +162,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-expense-table">
|
||||
<div v-if="isApplicationDocument" class="detail-note readonly">
|
||||
<p>
|
||||
预计总费用:{{ request.amountDisplay }}。该金额用于领导审批和预算管理,无需补充任何报销票据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-expense-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -381,7 +393,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="expenseItems.length" class="expense-total-under-table">
|
||||
<div v-if="expenseItems.length && !isApplicationDocument" class="expense-total-under-table">
|
||||
<span>金额合计</span>
|
||||
<strong>{{ expenseTotal }}</strong>
|
||||
</div>
|
||||
@@ -476,7 +488,7 @@
|
||||
@click="handleReturnRequest"
|
||||
>
|
||||
<i class="mdi mdi-undo"></i>
|
||||
{{ returnBusy ? '退回中' : '退回单据' }}
|
||||
{{ returnBusy ? '退回中' : isApplicationDocument ? '退回申请' : '退回单据' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canApproveRequest"
|
||||
@@ -496,10 +508,12 @@
|
||||
@click="handleDeleteRequest"
|
||||
>
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
{{ deleteBusy ? '删除中' : '删除单据' }}
|
||||
{{ deleteBusy ? '删除中' : isApplicationDocument ? '删除申请' : '删除单据' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="detail-action-hint">当前单据已进入流程,详情页仅展示状态与费用明细。</p>
|
||||
<p v-else class="detail-action-hint">
|
||||
{{ isApplicationDocument ? '当前申请单已进入流程,详情页仅展示状态与申请信息。' : '当前单据已进入流程,详情页仅展示状态与费用明细。' }}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -633,7 +647,7 @@
|
||||
badge="提交确认"
|
||||
badge-tone="warning"
|
||||
:title="`确认提交 ${request.id} 吗?`"
|
||||
description="请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。"
|
||||
:description="isApplicationDocument ? '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' : '请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。'"
|
||||
cancel-text="返回核对"
|
||||
confirm-text="确认提交"
|
||||
busy-text="提交中..."
|
||||
@@ -649,14 +663,14 @@
|
||||
<strong>{{ request.documentNo || request.id }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>报销类型</span>
|
||||
<span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span>
|
||||
<strong>{{ request.typeLabel }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>报销金额</span>
|
||||
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
|
||||
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<div v-if="!isApplicationDocument" class="submit-confirm-row">
|
||||
<span>费用明细</span>
|
||||
<strong>{{ expenseItems.length }} 条 / {{ uploadedExpenseCount }} 张单据</strong>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
buildExpenseSceneSelectionActions
|
||||
} from '../../utils/expenseAssistantActions.js'
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
fetchExpenseClaims,
|
||||
@@ -143,11 +148,14 @@ import {
|
||||
resolveDocumentPreview
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
ASSISTANT_SESSION_MODE_OPTIONS,
|
||||
ASSISTANT_DISPLAY_NAME,
|
||||
FLOW_STEP_FALLBACKS,
|
||||
HOT_KNOWLEDGE_QUESTIONS,
|
||||
INTENT_LABELS,
|
||||
SCENARIO_LABELS,
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_APPROVAL,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
aiAvatar,
|
||||
@@ -156,6 +164,7 @@ import {
|
||||
buildMessageMeta,
|
||||
buildWelcomeInsight,
|
||||
createMessage,
|
||||
resolveAssistantSessionMode,
|
||||
resolveKnowledgeRankLabel,
|
||||
resolveKnowledgeRankTone,
|
||||
sanitizeRequest,
|
||||
@@ -195,6 +204,7 @@ const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
|
||||
const APPLICATION_SUBMIT_HREF = '#application-submit'
|
||||
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
|
||||
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
|
||||
const FLOW_STEP_STATUS_PENDING = 'pending'
|
||||
@@ -544,7 +554,6 @@ export default {
|
||||
resolveCurrentUserId,
|
||||
persistSessionState,
|
||||
applySessionState,
|
||||
clearKnowledgeSessionOnEntry,
|
||||
switchSessionType
|
||||
} = useTravelReimbursementSessionState({
|
||||
props,
|
||||
@@ -557,6 +566,10 @@ export default {
|
||||
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
||||
})
|
||||
const deleteSessionDialogOpen = ref(false)
|
||||
const applicationSubmitConfirmDialog = ref({
|
||||
open: false,
|
||||
message: null
|
||||
})
|
||||
const nextStepConfirmDialog = ref({
|
||||
open: false,
|
||||
message: null,
|
||||
@@ -566,6 +579,9 @@ export default {
|
||||
const deleteSessionBusy = ref(false)
|
||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
|
||||
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
|
||||
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
|
||||
const {
|
||||
flowRunId,
|
||||
flowSteps,
|
||||
@@ -640,6 +656,12 @@ export default {
|
||||
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
||||
}
|
||||
if (activeSessionType.value === SESSION_TYPE_APPLICATION) {
|
||||
return '例如:我想先申请一笔差旅费用,去上海支持项目部署。'
|
||||
}
|
||||
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
|
||||
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
|
||||
}
|
||||
return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。'
|
||||
})
|
||||
const currentIntentLabel = computed(() => {
|
||||
@@ -652,12 +674,11 @@ export default {
|
||||
agent: '知识回答'
|
||||
}
|
||||
: {
|
||||
welcome: '财务助手',
|
||||
welcome: activeAssistantMode.value?.label || '财务助手',
|
||||
agent: '处理中'
|
||||
}
|
||||
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
||||
})
|
||||
let knowledgeSessionResetPromise = Promise.resolve()
|
||||
const canDeleteCurrentSession = computed(
|
||||
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
|
||||
)
|
||||
@@ -1008,14 +1029,15 @@ export default {
|
||||
}
|
||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||
|
||||
const shortcuts = computed(() => [
|
||||
{
|
||||
label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答',
|
||||
icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline',
|
||||
const shortcuts = computed(() =>
|
||||
ASSISTANT_SESSION_MODE_OPTIONS.map((mode) => ({
|
||||
label: mode.label,
|
||||
icon: mode.icon,
|
||||
action: 'switch_view',
|
||||
targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE
|
||||
}
|
||||
])
|
||||
targetSessionType: mode.key,
|
||||
active: mode.key === activeSessionType.value
|
||||
}))
|
||||
)
|
||||
watch(
|
||||
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
||||
([payload]) => {
|
||||
@@ -1147,7 +1169,6 @@ export default {
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
void clearKnowledgeSessionOnEntry()
|
||||
currentInsight.value =
|
||||
currentInsight.value
|
||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
||||
@@ -1269,6 +1290,9 @@ export default {
|
||||
|
||||
async function runShortcut(shortcut) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
if (shortcut.active) {
|
||||
return
|
||||
}
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
return
|
||||
}
|
||||
@@ -1325,12 +1349,52 @@ export default {
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
function applySuggestedActionPrefill(action) {
|
||||
const prefillText = resolveSuggestedActionPrefill(action)
|
||||
if (!prefillText) {
|
||||
return false
|
||||
}
|
||||
|
||||
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
})
|
||||
persistSessionState()
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSuggestedAction(message, action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
if (message?.suggestedActionsLocked) return
|
||||
if (applySuggestedActionPrefill(action)) return
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
|
||||
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const targetSessionType = String(actionPayload.session_type || '').trim()
|
||||
if (!targetSessionType) return
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await switchSessionType(targetSessionType)
|
||||
if (carryText) {
|
||||
composerDraft.value = carryText
|
||||
}
|
||||
if (carryFiles.length) {
|
||||
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
|
||||
attachedFiles.value = fileMergeResult.files
|
||||
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'confirm_expense_intent') {
|
||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||
if (!originalMessage) return
|
||||
@@ -1571,6 +1635,60 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openApplicationSubmitConfirm(message) {
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: true,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function closeApplicationSubmitConfirm() {
|
||||
if (reviewActionBusy.value) {
|
||||
return
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmApplicationSubmit() {
|
||||
const message = applicationSubmitConfirmDialog.value.message
|
||||
if (!message || submitting.value || reviewActionBusy.value) {
|
||||
return
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null
|
||||
}
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const payload = await submitComposer({
|
||||
rawText: '确认提交',
|
||||
userText: '确认提交',
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true
|
||||
})
|
||||
const draftPayload = payload?.result?.draft_payload || {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
const claimId = String(draftPayload.claim_id || '').trim()
|
||||
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
|
||||
emit('draft-saved', {
|
||||
claimId,
|
||||
claimNo,
|
||||
status: 'submitted',
|
||||
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
|
||||
documentType: 'application'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkbenchBusy() {
|
||||
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
|
||||
}
|
||||
@@ -1796,6 +1914,12 @@ export default {
|
||||
}
|
||||
|
||||
const href = String(anchor.getAttribute('href') || '').trim()
|
||||
if (href === APPLICATION_SUBMIT_HREF) {
|
||||
event.preventDefault()
|
||||
openApplicationSubmitConfirm(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (href === REVIEW_NEXT_STEP_HREF) {
|
||||
event.preventDefault()
|
||||
openReviewNextStepConfirm(message)
|
||||
@@ -1890,16 +2014,16 @@ export default {
|
||||
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
|
||||
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
|
||||
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, applicationSubmitConfirmDialog, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
formatCurrency,
|
||||
isPlaceholderValue,
|
||||
isApplicationDocumentRequest,
|
||||
isRouteDescriptionExpenseType,
|
||||
isSyntheticLocationDisplay,
|
||||
isValidIsoDate,
|
||||
@@ -192,6 +193,10 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
|
||||
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const isTravelContext =
|
||||
requestModel?.detailVariant === 'travel' ||
|
||||
@@ -449,7 +454,8 @@ export default {
|
||||
)
|
||||
})
|
||||
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||
@@ -478,39 +484,59 @@ export default {
|
||||
&& canApproveLeaderExpenseClaims(currentUser.value)
|
||||
)
|
||||
|| (
|
||||
isFinanceApprovalStage.value
|
||||
!isApplicationDocument.value
|
||||
&& isFinanceApprovalStage.value
|
||||
&& isFinanceUser(currentUser.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||
const approvalOpinionPlaceholder = computed(() =>
|
||||
isFinanceApprovalStage.value
|
||||
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||||
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||||
)
|
||||
const approvalOpinionHint = computed(() =>
|
||||
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
|
||||
)
|
||||
const approvalOpinionPlaceholder = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
|
||||
}
|
||||
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||||
})
|
||||
const approvalOpinionHint = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '审核通过后将进入归档入账。'
|
||||
}
|
||||
return isApplicationDocument.value ? '审批通过后申请流程完成。' : '审批通过后将流转至财务审批。'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||||
const approvalConfirmDescription = computed(() =>
|
||||
isFinanceApprovalStage.value
|
||||
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
)
|
||||
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
|
||||
const approvalSuccessToast = computed(() =>
|
||||
isFinanceApprovalStage.value
|
||||
? `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||
const approvalConfirmDescription = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '确认后该申请单会完成直属领导审批,请确认申请信息与领导意见无误。'
|
||||
}
|
||||
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
})
|
||||
const approvalNextStage = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '归档入账'
|
||||
}
|
||||
return isApplicationDocument.value ? '审批完成' : '财务审批'
|
||||
})
|
||||
const approvalSuccessToast = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||
}
|
||||
return isApplicationDocument.value
|
||||
? `${request.value.id} 申请已审批通过。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
)
|
||||
})
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
isDraftRequest.value
|
||||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
||||
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
|
||||
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
|
||||
)
|
||||
const actionBusy = computed(() =>
|
||||
Boolean(savingExpenseId.value)
|
||||
@@ -562,7 +588,7 @@ export default {
|
||||
const heroFactItems = computed(() => [
|
||||
{
|
||||
key: 'document',
|
||||
label: '报销单号',
|
||||
label: isApplicationDocument.value ? '申请单号' : '报销单号',
|
||||
value: request.value.documentNo || request.value.id,
|
||||
icon: 'mdi mdi-camera-outline',
|
||||
valueClass: ''
|
||||
@@ -576,14 +602,14 @@ export default {
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '报销金额',
|
||||
label: isApplicationDocument.value ? '预计金额' : '报销金额',
|
||||
value: request.value.amountDisplay,
|
||||
icon: '',
|
||||
valueClass: 'amount'
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: isTravelRequest.value ? '差旅类型' : '报销类型',
|
||||
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
|
||||
value: request.value.typeLabel,
|
||||
icon: '',
|
||||
valueClass: ''
|
||||
@@ -600,7 +626,7 @@ export default {
|
||||
const progressSteps = computed(() =>
|
||||
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
||||
? request.value.progressSteps
|
||||
: buildFallbackProgressSteps()
|
||||
: buildFallbackProgressSteps(request.value)
|
||||
)
|
||||
|
||||
const currentProgressRingMotion = {
|
||||
@@ -1530,7 +1556,11 @@ export default {
|
||||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||||
if (claimStatus === 'submitted') {
|
||||
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
toast(
|
||||
isApplicationDocument.value
|
||||
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||
: `${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||
)
|
||||
} else if (claimStatus === 'supplement') {
|
||||
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
||||
} else {
|
||||
@@ -1577,7 +1607,7 @@ export default {
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||
deleteDialogOpen.value = false
|
||||
toast(payload?.message || `${request.value.id} 报销单已删除。`)
|
||||
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
||||
emit('request-deleted', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '删除单据失败,请稍后重试。')
|
||||
@@ -1722,7 +1752,7 @@ export default {
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
|
||||
@@ -9,14 +9,65 @@ import {
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||
export const SESSION_TYPE_APPLICATION = 'application'
|
||||
export const SESSION_TYPE_APPROVAL = 'approval'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
|
||||
export const ASSISTANT_SESSION_TYPES = [
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_APPROVAL,
|
||||
SESSION_TYPE_KNOWLEDGE
|
||||
]
|
||||
|
||||
export const ASSISTANT_SESSION_MODE_OPTIONS = [
|
||||
{
|
||||
key: SESSION_TYPE_APPLICATION,
|
||||
label: '申请助手',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
description: '只处理费用申请、事前审批、申请材料和申请状态'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_EXPENSE,
|
||||
label: '报销助手',
|
||||
icon: 'mdi mdi-receipt-text-plus-outline',
|
||||
description: '只处理报销发起、票据识别、草稿归集和报销状态'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_APPROVAL,
|
||||
label: '审核助手',
|
||||
icon: 'mdi mdi-clipboard-check-outline',
|
||||
description: '只处理待审单据、风险解释、审批动作和审核意见'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_KNOWLEDGE,
|
||||
label: '财务知识助手',
|
||||
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||
description: '只处理财务制度、标准规则、票据要求和政策解释'
|
||||
}
|
||||
]
|
||||
|
||||
export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) {
|
||||
const normalized = String(sessionType || '').trim()
|
||||
if (ASSISTANT_SESSION_TYPES.includes(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
const fallbackType = String(fallback || '').trim()
|
||||
return ASSISTANT_SESSION_TYPES.includes(fallbackType) ? fallbackType : SESSION_TYPE_EXPENSE
|
||||
}
|
||||
|
||||
export function resolveAssistantSessionMode(sessionType) {
|
||||
const normalized = normalizeAssistantSessionType(sessionType)
|
||||
return ASSISTANT_SESSION_MODE_OPTIONS.find((item) => item.key === normalized) || ASSISTANT_SESSION_MODE_OPTIONS[1]
|
||||
}
|
||||
|
||||
export const aiAvatar = '/assets/header.png'
|
||||
export const userAvatar = '/assets/person.png'
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
workbench: '来自个人工作台',
|
||||
topbar: '来自发起报销',
|
||||
application: '来自发起申请',
|
||||
detail: '来自智能录入',
|
||||
upload: '来自附件上传',
|
||||
requests: '来自报销列表'
|
||||
@@ -109,6 +160,42 @@ export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
}
|
||||
]
|
||||
|
||||
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '快速发起申请',
|
||||
prompt: '我想快速发起一笔费用申请,请先帮我判断申请类型并引导补充信息。',
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '查询申请状态',
|
||||
prompt: '帮我查询我的费用申请单状态,筛选最近的 5 条记录。',
|
||||
icon: 'mdi mdi-file-search-outline'
|
||||
},
|
||||
{
|
||||
label: '申请材料清单',
|
||||
prompt: '请告诉我发起费用申请通常需要准备哪些关键信息和附件。',
|
||||
icon: 'mdi mdi-clipboard-text-search-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const APPROVAL_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '待我审核',
|
||||
prompt: '帮我查询当前待我审核的单据,筛选最近的 5 条记录。',
|
||||
icon: 'mdi mdi-clipboard-list-outline'
|
||||
},
|
||||
{
|
||||
label: '审核风险说明',
|
||||
prompt: '帮我梳理待审核单据中需要重点关注的风险,并按高、中、低风险分类说明。',
|
||||
icon: 'mdi mdi-alert-circle-outline'
|
||||
},
|
||||
{
|
||||
label: '生成审核意见',
|
||||
prompt: '请根据当前待审核单据的风险点,帮我生成一段专业、克制的审核意见草稿。',
|
||||
icon: 'mdi mdi-text-box-edit-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
'差旅住宿标准按什么规则执行?',
|
||||
'酒店超标后如何申请例外报销?',
|
||||
@@ -418,7 +505,8 @@ export function buildWelcomeUserContext(user = {}) {
|
||||
}
|
||||
|
||||
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
||||
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
|
||||
label: question.length > 20 ? `${question.slice(0, 20)}…` : question,
|
||||
prompt: question,
|
||||
@@ -426,23 +514,58 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
|
||||
}))
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||
return APPLICATION_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
||||
return APPROVAL_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
||||
const ctx = buildWelcomeUserContext(user || {})
|
||||
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
|
||||
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。',
|
||||
'**欢迎来到个人财务中心 · 财务知识助手。** 我可以帮您查制度、报销标准、票据要求和常见财务问题,并保持知识问答对话独立记录。',
|
||||
'',
|
||||
'业务范围:财务制度、标准规则、票据要求和政策口径解释。发起申请、报销处理或审核动作请切换到对应助手。',
|
||||
'',
|
||||
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'**欢迎来到个人财务中心 · 申请助手。** 我会先判断您要处理的是费用申请、报销申请还是其他财务事项,再按对应流程引导补充信息。',
|
||||
'',
|
||||
'业务范围:费用申请、事前审批、申请材料清单和申请单状态。报销票据、审核处理和制度问答请切换到对应助手。',
|
||||
'',
|
||||
'您可以直接描述申请事项,或点击下方快捷操作开始发起申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'**欢迎来到个人财务中心 · 审核助手。** 我可以帮您查询待审单据、解释风险点、整理审核意见,并保持审核对话独立记录。',
|
||||
'',
|
||||
'业务范围:待审单据查询、审批动作、风险解释和审核意见草稿。申请、报销和制度问答请切换到对应助手。',
|
||||
'',
|
||||
'您可以直接输入要审核或查询的内容,或点击下方快捷操作快速开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
@@ -456,16 +579,19 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销信息核对、待补项提醒和风险说明。',
|
||||
'**欢迎来到个人财务中心 · 报销助手。** 我可以陪您完成报销发起、票据识别、草稿归集、报销信息核对、待补项提醒和风险说明,并保持报销对话独立记录。',
|
||||
'',
|
||||
'业务范围:发起报销、票据识别、草稿归集、报销状态查询和报销信息核对。申请、审核和制度问答请切换到对应助手。',
|
||||
'',
|
||||
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
||||
const ctx = buildWelcomeUserContext(user || {})
|
||||
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '今日',
|
||||
@@ -476,11 +602,36 @@ export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SE
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '当前助手',
|
||||
metricValue: '申请助手',
|
||||
title: '申请助手',
|
||||
summary: `${ctx.honorific},这里会单独保存费用申请相关对话,不会混入报销、审核或知识问答记录。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '当前助手',
|
||||
metricValue: '审核助手',
|
||||
title: '审核助手',
|
||||
summary: `${ctx.honorific},这里会单独保存审核相关对话,适合查询待审单据、风险点和审核意见。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '助手状态',
|
||||
metricValue: '待您吩咐',
|
||||
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心',
|
||||
metricLabel: '当前助手',
|
||||
metricValue: '报销助手',
|
||||
title:
|
||||
entrySource === 'detail' && linkedRequest?.id
|
||||
? `已关联 ${linkedRequest.id}`
|
||||
: '报销助手',
|
||||
summary:
|
||||
entrySource === 'detail' && linkedRequest?.id
|
||||
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
|
||||
@@ -497,10 +648,10 @@ export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessio
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveInitialSessionType(conversation) {
|
||||
export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) {
|
||||
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
||||
const sessionType = String(stateJson?.session_type || '').trim()
|
||||
return sessionType || SESSION_TYPE_EXPENSE
|
||||
return normalizeAssistantSessionType(sessionType, fallback)
|
||||
}
|
||||
|
||||
export function buildInitialInsightFromConversation(conversation) {
|
||||
|
||||
@@ -49,6 +49,32 @@ export function normalizeExpenseType(value) {
|
||||
return String(value || '').trim() || 'other'
|
||||
}
|
||||
|
||||
export function isApplicationDocumentRequest(request) {
|
||||
const documentType = String(
|
||||
request?.documentTypeCode
|
||||
|| request?.document_type_code
|
||||
|| request?.documentType
|
||||
|| request?.document_type
|
||||
|| ''
|
||||
).trim()
|
||||
const claimNo = String(
|
||||
request?.claimNo
|
||||
|| request?.claim_no
|
||||
|| request?.documentNo
|
||||
|| request?.id
|
||||
|| ''
|
||||
).trim().toUpperCase()
|
||||
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
|
||||
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeLabel(value) {
|
||||
const normalized = normalizeExpenseType(value)
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|
||||
@@ -131,7 +157,41 @@ export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
|
||||
return resolveLocationDisplay(itemLocation, itemType)
|
||||
}
|
||||
|
||||
export function buildFallbackProgressSteps() {
|
||||
export function buildFallbackProgressSteps(requestModel = {}) {
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
const node = String(requestModel?.node || requestModel?.workflowNode || requestModel?.approvalStage || '').trim()
|
||||
const approvalKey = String(requestModel?.approvalKey || '').trim()
|
||||
const completed = approvalKey === 'completed' || /审批完成|申请完成|已完成/.test(node)
|
||||
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
|
||||
|
||||
return [
|
||||
{
|
||||
index: 1,
|
||||
label: '创建申请',
|
||||
time: completed || inLeaderApproval ? '已完成' : '进行中',
|
||||
done: completed || inLeaderApproval,
|
||||
active: true,
|
||||
current: !(completed || inLeaderApproval)
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
label: '直属领导审批',
|
||||
time: completed ? '已完成' : inLeaderApproval ? '进行中' : '待处理',
|
||||
done: completed,
|
||||
active: completed || inLeaderApproval,
|
||||
current: !completed && inLeaderApproval
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
label: '审批完成',
|
||||
time: completed ? '已完成' : '待处理',
|
||||
done: completed,
|
||||
active: completed,
|
||||
current: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
@@ -143,6 +203,10 @@ export function buildFallbackProgressSteps() {
|
||||
}
|
||||
|
||||
export function buildFallbackExpenseItems(request) {
|
||||
if (isApplicationDocumentRequest(request)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
buildExpenseItemViewModel({
|
||||
id: 'fallback-1',
|
||||
@@ -413,6 +477,10 @@ export function buildExpenseDraftIssues(item) {
|
||||
}
|
||||
|
||||
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const isTravelContext =
|
||||
requestModel?.detailVariant === 'travel' ||
|
||||
|
||||
@@ -91,7 +91,7 @@ function stripBusinessTimePrefix(text) {
|
||||
|
||||
function resolveDestinationFromText(text) {
|
||||
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
|
||||
const targetMatch = normalized.match(/(?:去|到|赴|前往)([^,,。;;]+)/u)
|
||||
const targetMatch = normalized.match(/(?:出差|去|到|赴|前往)([^,,。;;]+)/u)
|
||||
const targetText = String(targetMatch?.[1] || '').trim()
|
||||
if (!targetText) {
|
||||
return ''
|
||||
@@ -117,7 +117,7 @@ function resolveTripDaysFromText(text, businessTimeContext) {
|
||||
|
||||
function resolveReasonFromText(text, destination) {
|
||||
let reason = normalizeComposerText(text)
|
||||
.replace(/^(?:去|到|赴|前往)\s*/u, '')
|
||||
.replace(/^(?:出差|去|到|赴|前往)\s*/u, '')
|
||||
.trim()
|
||||
|
||||
if (destination && reason.startsWith(destination)) {
|
||||
|
||||
@@ -459,7 +459,21 @@ export function useTravelReimbursementFlow({
|
||||
detail: '正在根据当前票据新建报销草稿...'
|
||||
}
|
||||
}
|
||||
const config = configs[reviewAction] || {
|
||||
const defaultConfigBySessionType = {
|
||||
application: {
|
||||
key: 'application-review-preview',
|
||||
title: '申请信息核对',
|
||||
tool: 'user_agent.application_review_preview',
|
||||
detail: '正在整理申请事项和待补充信息...'
|
||||
},
|
||||
approval: {
|
||||
key: 'approval-review-preview',
|
||||
title: '审核信息核对',
|
||||
tool: 'user_agent.approval_review_preview',
|
||||
detail: '正在整理待审核单据、风险点和审核建议...'
|
||||
}
|
||||
}
|
||||
const config = configs[reviewAction] || defaultConfigBySessionType[String(activeSessionType.value || '').trim()] || {
|
||||
key: 'expense-review-preview',
|
||||
title: '报销信息核对',
|
||||
tool: 'user_agent.expense_review_preview',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
import { fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
import {
|
||||
clearAssistantSessionSnapshot,
|
||||
readAssistantSessionSnapshot,
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
filterPersistableFilePreviews
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
ASSISTANT_SESSION_TYPES,
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildInitialInsightFromConversation,
|
||||
buildWelcomeInsight,
|
||||
buildWelcomeQuickActions,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
hasMeaningfulSessionMessages,
|
||||
normalizeInitialConversationMessages,
|
||||
normalizeSnapshotMessages,
|
||||
normalizeAssistantSessionType,
|
||||
resolveInitialConversationId,
|
||||
resolveInitialDraftClaimId,
|
||||
resolveInitialSessionType,
|
||||
@@ -41,6 +43,10 @@ export function useTravelReimbursementSessionState({
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs = () => ({})
|
||||
}) {
|
||||
function resolveDefaultSessionTypeFromEntry() {
|
||||
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
|
||||
}
|
||||
|
||||
function refreshWelcomeQuickActions(messages, sessionType) {
|
||||
if (!Array.isArray(messages) || !messages.length) {
|
||||
return []
|
||||
@@ -58,8 +64,8 @@ export function useTravelReimbursementSessionState({
|
||||
))
|
||||
}
|
||||
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
|
||||
const sessionType = resolveInitialSessionType(conversation, fallbackSessionType)
|
||||
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
|
||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||
@@ -84,17 +90,18 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
function buildEmptySessionState(sessionType) {
|
||||
const normalizedSessionType = normalizeAssistantSessionType(sessionType, resolveDefaultSessionTypeFromEntry())
|
||||
return {
|
||||
sessionType,
|
||||
sessionType: normalizedSessionType,
|
||||
messages: [
|
||||
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
|
||||
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, normalizedSessionType, currentUser.value)
|
||||
],
|
||||
conversationId: '',
|
||||
draftClaimId: '',
|
||||
currentInsight: buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
sessionType,
|
||||
normalizedSessionType,
|
||||
currentUser.value
|
||||
),
|
||||
reviewFilePreviews: [],
|
||||
@@ -107,13 +114,16 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
}
|
||||
|
||||
function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
function buildPersistedSessionState(snapshot, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
|
||||
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
const sessionType = normalizeAssistantSessionType(
|
||||
state.sessionType || snapshot.sessionType || fallbackSessionType,
|
||||
fallbackSessionType
|
||||
)
|
||||
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
|
||||
if (
|
||||
!hasMeaningfulSessionMessages(restoredMessages)
|
||||
@@ -148,13 +158,16 @@ export function useTravelReimbursementSessionState({
|
||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||
}
|
||||
|
||||
const initialSessionType = resolveInitialSessionType(props.initialConversation)
|
||||
const defaultInitialSessionType = resolveDefaultSessionTypeFromEntry()
|
||||
const initialSessionType = props.initialConversation
|
||||
? resolveInitialSessionType(props.initialConversation, defaultInitialSessionType)
|
||||
: defaultInitialSessionType
|
||||
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
|
||||
const conversationInitialState = props.initialConversation
|
||||
? buildConversationSessionState(props.initialConversation, initialSessionType)
|
||||
: buildEmptySessionState(initialSessionType)
|
||||
const canRestorePersistedInitialState =
|
||||
props.entrySource === 'workbench'
|
||||
shouldPersistLocalSnapshot
|
||||
&& !String(props.initialPrompt || '').trim()
|
||||
&& !props.initialFiles.length
|
||||
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
||||
@@ -174,21 +187,22 @@ export function useTravelReimbursementSessionState({
|
||||
const conversationId = ref(initialSessionState.conversationId)
|
||||
const draftClaimId = ref(initialSessionState.draftClaimId)
|
||||
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
|
||||
const sessionSnapshots = ref({
|
||||
[SESSION_TYPE_EXPENSE]: null,
|
||||
[SESSION_TYPE_KNOWLEDGE]: null
|
||||
})
|
||||
const sessionSnapshots = ref(
|
||||
ASSISTANT_SESSION_TYPES.reduce((result, sessionType) => {
|
||||
result[sessionType] = null
|
||||
return result
|
||||
}, {})
|
||||
)
|
||||
const currentInsight = ref(initialSessionState.currentInsight)
|
||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const sessionSwitchBusy = ref(false)
|
||||
let knowledgeSessionResetPromise = Promise.resolve()
|
||||
|
||||
function buildPersistableSessionState(sessionState) {
|
||||
const state = sessionState || captureCurrentSessionState()
|
||||
return {
|
||||
sessionType: state.sessionType || SESSION_TYPE_EXPENSE,
|
||||
sessionType: normalizeAssistantSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
|
||||
messages: serializeSessionMessages(state.messages),
|
||||
conversationId: String(state.conversationId || '').trim(),
|
||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||
@@ -244,7 +258,7 @@ export function useTravelReimbursementSessionState({
|
||||
function applySessionState(sessionState) {
|
||||
const runtimeRefs = getSessionRuntimeRefs()
|
||||
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
|
||||
activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE
|
||||
activeSessionType.value = normalizeAssistantSessionType(nextState.sessionType, resolveDefaultSessionTypeFromEntry())
|
||||
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
|
||||
? nextState.messages
|
||||
: [
|
||||
@@ -287,38 +301,18 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
async function loadLatestSessionState(targetSessionType) {
|
||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, {
|
||||
preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE
|
||||
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
|
||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), normalizedTarget, {
|
||||
preferRecoverable: normalizedTarget === SESSION_TYPE_EXPENSE
|
||||
})
|
||||
if (payload?.found && payload.conversation) {
|
||||
return buildConversationSessionState(payload.conversation, targetSessionType)
|
||||
return buildConversationSessionState(payload.conversation, normalizedTarget)
|
||||
}
|
||||
return buildEmptySessionState(targetSessionType)
|
||||
}
|
||||
|
||||
function resetKnowledgeSessionSnapshot() {
|
||||
const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE)
|
||||
sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState
|
||||
|
||||
if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) {
|
||||
applySessionState(emptyKnowledgeState)
|
||||
}
|
||||
}
|
||||
|
||||
function clearKnowledgeSessionOnEntry() {
|
||||
resetKnowledgeSessionSnapshot()
|
||||
knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
|
||||
.catch((error) => {
|
||||
console.warn('Failed to clear knowledge session on entry:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
resetKnowledgeSessionSnapshot()
|
||||
})
|
||||
return knowledgeSessionResetPromise
|
||||
return buildEmptySessionState(normalizedTarget)
|
||||
}
|
||||
|
||||
async function switchSessionType(targetSessionType) {
|
||||
const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
|
||||
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
|
||||
return
|
||||
}
|
||||
@@ -338,7 +332,7 @@ export function useTravelReimbursementSessionState({
|
||||
const emptyState = buildEmptySessionState(normalizedTarget)
|
||||
sessionSnapshots.value[normalizedTarget] = emptyState
|
||||
applySessionState(emptyState)
|
||||
toast(error?.message || '?????????????????')
|
||||
toast(error?.message || '切换助手失败,请稍后重试。')
|
||||
} finally {
|
||||
sessionSwitchBusy.value = false
|
||||
}
|
||||
@@ -368,8 +362,6 @@ export function useTravelReimbursementSessionState({
|
||||
captureCurrentSessionState,
|
||||
applySessionState,
|
||||
loadLatestSessionState,
|
||||
resetKnowledgeSessionSnapshot,
|
||||
clearKnowledgeSessionOnEntry,
|
||||
switchSessionType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildAttachmentAssociationConfirmationMessage,
|
||||
buildUnsavedDraftAttachmentConfirmationMessage
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
@@ -238,6 +239,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
const sessionType = String(activeSessionType.value || '').trim()
|
||||
|
||||
if (normalizedText) {
|
||||
parts.push(normalizedText)
|
||||
@@ -245,7 +247,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
parts.push(
|
||||
isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
||||
: sessionType === 'application'
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
|
||||
: sessionType === 'approval'
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理审核风险和处理建议。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,6 +364,30 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
|
||||
attachmentCount: files.length,
|
||||
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
||||
reviewAction
|
||||
})
|
||||
if (scopeGuard && !systemGenerated && !reviewAction && !options.skipScopeGuard) {
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
messages.value.push(createMessage('assistant', scopeGuard.text, [], {
|
||||
meta: scopeGuard.meta,
|
||||
suggestedActions: scopeGuard.suggestedActions
|
||||
}))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
return null
|
||||
}
|
||||
|
||||
const hasUnsavedReviewDraft = Boolean(
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
@@ -521,7 +551,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
options.pendingText || (
|
||||
isKnowledgeSession.value
|
||||
? '正在整理财务知识答案...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
: activeSessionType.value === 'application'
|
||||
? '正在识别并整理申请核对信息...'
|
||||
: activeSessionType.value === 'approval'
|
||||
? '正在查询审核上下文并整理风险提示...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
),
|
||||
[],
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user