feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -207,7 +207,7 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
@@ -241,7 +241,7 @@ const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false)
const budgetDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(false)
const sidebarCollapsed = ref(true)
const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance')
let loginEntryTimer = null
@@ -393,4 +393,10 @@ onMounted(() => {
onBeforeUnmount(() => {
stopLoginEntryAnimation()
})
watch(activeView, (newView) => {
if (newView === 'workbench') {
sidebarCollapsed.value = true
}
}, { immediate: true })
</script>

View File

@@ -20,21 +20,36 @@
<i class="mdi mdi-magnify"></i>
<input v-model="budgetKeyword" type="search" placeholder="搜索预算编号、部门、编制人" />
</label>
<label class="budget-select-filter">
<span>年度</span>
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
</label>
<label class="budget-select-filter">
<span>季度</span>
<EnterpriseSelect v-model="filters.quarter" :options="quarterOptions" />
</label>
<label class="budget-select-filter">
<span>状态</span>
<EnterpriseSelect v-model="filters.status" :options="statusOptions" />
</label>
<DocumentDropdownFilter
id="year"
:active-filter-key="activeBudgetFilterKey"
:label="budgetYearFilterLabel"
title="年度"
:options="yearOptions"
:selected-value="filters.year"
@toggle="toggleBudgetFilter"
@select="selectBudgetFilter('year', $event)"
/>
<DocumentDropdownFilter
id="quarter"
:active-filter-key="activeBudgetFilterKey"
:label="budgetQuarterFilterLabel"
title="季度"
:options="quarterOptions"
:selected-value="filters.quarter"
@toggle="toggleBudgetFilter"
@select="selectBudgetFilter('quarter', $event)"
/>
<DocumentDropdownFilter
id="status"
:active-filter-key="activeBudgetFilterKey"
:label="budgetStatusFilterLabel"
title="状态"
:options="statusOptions"
:selected-value="filters.status"
@toggle="toggleBudgetFilter"
@select="selectBudgetFilter('status', $event)"
/>
</div>
<div class="document-actions">

View File

@@ -248,8 +248,25 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchArchivedExpenseClaims
} from '../services/reimbursements.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import {
buildDocumentViewedStatePatch,
buildDocumentsViewedStatePatches,
countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
mergeNotificationStatesIntoViewedDocumentKeys,
readDocumentScope,
readViewedDocumentKeys,
writeDocumentScope
} from '../utils/documentCenterNewState.js'
import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
@@ -860,9 +877,36 @@ function changePageSize(size) {
currentPage.value = 1
}
function applyRemoteViewedDocumentStates(states) {
viewedDocumentKeys.value = mergeNotificationStatesIntoViewedDocumentKeys(states, viewedDocumentKeys.value)
}
async function loadRemoteViewedDocumentKeys() {
try {
applyRemoteViewedDocumentStates(await fetchNotificationStates())
} catch {
// 接口不可用时保留本机已读缓存,避免影响单据中心主流程。
}
}
async function syncDocumentViewedPatches(patches) {
const normalizedPatches = (Array.isArray(patches) ? patches : [patches]).filter(Boolean)
if (!normalizedPatches.length) {
return
}
try {
applyRemoteViewedDocumentStates(await patchNotificationStates(normalizedPatches))
} catch {
// 本机状态已先落地;远端失败时等待下次操作或刷新重试。
}
}
function openDocument(row) {
writeDocumentScope(activeScopeTab.value, scopeTabs)
const viewedPatch = buildDocumentViewedStatePatch(row)
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
void syncDocumentViewedPatches([viewedPatch])
emit('open-document', row.rawRequest || row)
}
@@ -871,7 +915,9 @@ function markAllDocumentsRead() {
return
}
const viewedPatches = buildDocumentsViewedStatePatches(allReadableDocumentRows.value, viewedDocumentKeys.value)
viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value)
void syncDocumentViewedPatches(viewedPatches)
}
async function loadSupportingRows() {
@@ -879,30 +925,26 @@ async function loadSupportingRows() {
supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(),
fetchArchivedExpenseClaims()
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = excludeArchivedDocumentRows(
Array.isArray(approvalResult.value)
? approvalResult.value
extractExpenseClaimItems(approvalResult.value)
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
)
} else {
approvalRows.value = []
}
if (archiveResult.status === 'fulfilled') {
archiveRows.value = Array.isArray(archiveResult.value)
? archiveResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
: []
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
} else {
archiveRows.value = []
supportingError.value = archiveResult.reason instanceof Error
@@ -915,6 +957,7 @@ async function loadSupportingRows() {
function reloadAll() {
emit('reload')
void loadRemoteViewedDocumentKeys()
void loadSupportingRows()
}
@@ -963,6 +1006,7 @@ watch(documentSummary, (summary) => {
}, { immediate: true })
onMounted(() => {
void loadRemoteViewedDocumentKeys()
void loadSupportingRows()
})
@@ -970,6 +1014,7 @@ watch(
() => props.refreshToken,
(token, previousToken) => {
if (token && token !== previousToken) {
void loadRemoteViewedDocumentKeys()
void loadSupportingRows()
}
}

View File

@@ -380,143 +380,38 @@
/>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'department' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'department'"
aria-haspopup="dialog"
@click="toggleFilterPopover('department')"
>
<span class="picker-label">{{ selectedDepartment || '组织部门' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'department'"
class="picker-popover"
role="dialog"
aria-label="选择组织部门"
>
<header>
<strong>选择组织部门</strong>
<button type="button" aria-label="关闭组织部门选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedDepartment }"
@click="selectFilter('department', '')"
>
全部部门
</button>
<button
v-for="department in departmentOptions"
:key="department"
type="button"
class="picker-option"
:class="{ active: selectedDepartment === department }"
@click="selectFilter('department', department)"
>
{{ department }}
</button>
</div>
</div>
</div>
<DocumentDropdownFilter
id="department"
:active-filter-key="activeFilterPopover"
:label="departmentFilterLabel"
title="选择组织部门"
:options="departmentFilterOptions"
:selected-value="selectedDepartment"
@toggle="toggleFilterPopover"
@select="selectFilter('department', $event)"
/>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'grade' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'grade'"
aria-haspopup="dialog"
@click="toggleFilterPopover('grade')"
>
<span class="picker-label">{{ selectedGrade || '职级' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'grade'"
class="picker-popover"
role="dialog"
aria-label="选择职级"
>
<header>
<strong>选择职级</strong>
<button type="button" aria-label="关闭职级选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedGrade }"
@click="selectFilter('grade', '')"
>
全部职级
</button>
<button
v-for="grade in gradeOptions"
:key="grade"
type="button"
class="picker-option"
:class="{ active: selectedGrade === grade }"
@click="selectFilter('grade', grade)"
>
{{ grade }}
</button>
</div>
</div>
</div>
<DocumentDropdownFilter
id="grade"
:active-filter-key="activeFilterPopover"
:label="gradeFilterLabel"
title="选择职级"
:options="gradeFilterOptions"
:selected-value="selectedGrade"
@toggle="toggleFilterPopover"
@select="selectFilter('grade', $event)"
/>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'role' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'role'"
aria-haspopup="dialog"
@click="toggleFilterPopover('role')"
>
<span class="picker-label">{{ selectedRole || '系统角色' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'role'"
class="picker-popover"
role="dialog"
aria-label="选择系统角色"
>
<header>
<strong>选择系统角色</strong>
<button type="button" aria-label="关闭系统角色选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedRole }"
@click="selectFilter('role', '')"
>
全部角色
</button>
<button
v-for="role in roleFilterOptions"
:key="role"
type="button"
class="picker-option"
:class="{ active: selectedRole === role }"
@click="selectFilter('role', role)"
>
{{ role }}
</button>
</div>
</div>
</div>
<DocumentDropdownFilter
id="role"
:active-filter-key="activeFilterPopover"
:label="roleFilterLabel"
title="选择系统角色"
:options="roleDropdownOptions"
:selected-value="selectedRole"
@toggle="toggleFilterPopover"
@select="selectFilter('role', $event)"
/>
</div>
<div class="toolbar-actions">

View File

@@ -211,4 +211,5 @@ import viewModel from './scripts/LogsView.js'
export default viewModel
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
<style scoped src="../assets/styles/views/logs-view.css"></style>

View File

@@ -23,48 +23,49 @@
<span>{{ knowledgeSyncButtonLabel }}</span>
</button>
</div>
</header>
<div class="library-body">
<aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
</header>
<div class="library-body">
<aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="(folder, index) in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
:style="{ '--delay': `${index * 40}ms` }"
@click="activeFolder = folder.name"
>
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
</aside>
<section class="document-area" :class="{ 'read-only': !isAdmin }">
<div
v-if="isAdmin"
class="upload-zone"
:class="{ busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<section class="document-area" :class="{ 'read-only': !isAdmin }">
<div
v-if="isAdmin"
class="upload-zone"
:class="{ busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<TableLoadingState
v-if="loading && !visibleDocuments.length"
title="知识库文件同步中"
@@ -74,26 +75,27 @@
/>
<table class="knowledge-document-table">
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>归纳时间</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
</tr>
</thead>
<tbody>
<tr
v-for="(doc, index) in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
:style="{ '--delay': `${index * 50}ms` }"
@click="selectDocument(doc.id)"
>
<td data-label="文件名称">
<span class="file-name">
<i :class="doc.icon"></i>

View File

@@ -20,9 +20,21 @@
<i class="mdi mdi-magnify"></i>
<input v-model="keyword" type="search" placeholder="搜索文件名、票据类型、金额、关联单号..." />
</div>
<button class="filter-btn" type="button" @click="reloadReceipts">
<i class="mdi mdi-refresh"></i>
<span>刷新</span>
<DocumentDropdownFilter
v-for="control in receiptFilterControls"
:id="control.key"
:key="control.key"
:active-filter-key="openReceiptFilterKey"
:label="resolveReceiptFilterLabel(control)"
:title="control.label"
:options="control.options"
:selected-value="receiptFilters[control.key]"
@toggle="toggleReceiptFilter"
@select="selectReceiptFilter(control.key, $event)"
/>
<button v-if="hasActiveReceiptFilters" class="filter-btn clear-filter-btn" type="button" @click="clearReceiptFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
</div>
@@ -349,6 +361,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import DocumentDropdownFilter from '../components/shared/DocumentDropdownFilter.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
@@ -365,6 +378,7 @@ import {
} from '../services/receiptFolder.js'
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
const NEW_CLAIM_VALUE = '__new_claim__'
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
@@ -417,19 +431,17 @@ const activeRows = computed(() => {
return receipts.value
})
const showStatusColumn = computed(() => activeStatus.value !== 'linked')
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
if (!normalized) return activeRows.value
return activeRows.value.filter((item) => [
item.file_name,
item.document_type_label,
item.scene_label,
item.summary,
item.amount,
item.document_date,
item.linked_claim_no
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
const {
filteredRows,
hasActiveReceiptFilters,
openReceiptFilterKey,
receiptFilterControls,
receiptFilters,
clearReceiptFilters,
resolveReceiptFilterLabel,
selectReceiptFilter,
toggleReceiptFilter
} = createReceiptFolderListFilterModel({ receipts, activeRows, keyword })
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleRows = computed(() => {
@@ -531,7 +543,15 @@ const associatePrimaryLabel = computed(() => {
return associateStep.value === 1 ? '下一步' : '进入关联对话'
})
watch([activeStatus, keyword, pageSize], () => {
watch([
activeStatus,
keyword,
pageSize,
() => receiptFilters.documentType,
() => receiptFilters.scene,
() => receiptFilters.month,
() => receiptFilters.quality
], () => {
currentPage.value = 1
})

View File

@@ -90,14 +90,30 @@
</div>
</div>
<div ref="messageListRef" class="message-list" aria-live="polite">
<transition-group tag="div" name="message-row-reveal" ref="messageListRef" class="message-list" aria-live="polite">
<div
v-if="showStewardInitialRecognition"
class="steward-initial-recognition"
role="status"
aria-live="polite"
key="initial-recognition"
>
<div class="steward-initial-recognition-icon">
<i class="mdi mdi-brain"></i>
</div>
<div class="steward-initial-recognition-copy">
<strong>小财管家正在识别意图</strong>
<p>我正在读取你的输入准备拆解申请报销和附件任务</p>
</div>
</div>
<TravelReimbursementMessageItem
v-for="message in messages"
:key="message.id"
:message="message"
:ui="messageItemUi"
/>
</div>
</transition-group>
<form class="composer" @submit.prevent="submitComposer">
<input

View File

@@ -2,10 +2,10 @@ import { computed, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
@@ -47,6 +47,10 @@ function mapOptions(values, suffix = '') {
}))
}
function resolveOptionLabel(options, value, fallback) {
return (Array.isArray(options) ? options : []).find((option) => option.value === value)?.label || fallback
}
function resolveBudgetUpdatedAt(row) {
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
}
@@ -99,8 +103,8 @@ export default {
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
DocumentDropdownFilter,
EnterprisePagination,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
TableEmptyState,
@@ -116,6 +120,7 @@ export default {
const budgetLoading = ref(true)
const budgetError = ref('')
const selectedBudgetId = ref('')
const activeBudgetFilterKey = ref('')
const filters = ref({
year: '2026',
quarter: 'Q1',
@@ -158,6 +163,9 @@ export default {
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
)
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
const budgetYearFilterLabel = computed(() => resolveOptionLabel(yearOptions, filters.value.year, '年度'))
const budgetQuarterFilterLabel = computed(() => resolveOptionLabel(quarterOptions, filters.value.quarter, '季度'))
const budgetStatusFilterLabel = computed(() => resolveOptionLabel(statusOptions.value, filters.value.status, '状态'))
const filteredBudgetRows = computed(() =>
activeScopeRows.value
@@ -322,6 +330,15 @@ export default {
budgetPage.value = 1
}
function toggleBudgetFilter(key) {
activeBudgetFilterKey.value = activeBudgetFilterKey.value === key ? '' : key
}
function selectBudgetFilter(key, value) {
filters.value[key] = value
activeBudgetFilterKey.value = ''
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) return options
@@ -419,6 +436,7 @@ export default {
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
activeBudgetFilterKey,
activeBudgetScope,
budgetError,
budgetKeyword,
@@ -427,6 +445,9 @@ export default {
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,
budgetQuarterFilterLabel,
budgetStatusFilterLabel,
budgetYearFilterLabel,
backToList,
canAuditBudgetDrafts,
canEditBudget,
@@ -447,8 +468,10 @@ export default {
showEmpty,
showTable,
statusOptions,
selectBudgetFilter,
totalBudgetPages,
totalBudgetRows,
toggleBudgetFilter,
visibleBudgetRows,
yearOptions
}

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
@@ -449,10 +450,18 @@ function buildEmployeeSummary(employees) {
}
}
function mapSimpleFilterOptions(values, allLabel) {
return [
{ label: allLabel, value: '' },
...values.map((value) => ({ label: value, value }))
]
}
export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
DocumentDropdownFilter,
EnterprisePagination,
EnterpriseSelect,
TableLoadingState,
@@ -559,6 +568,12 @@ export default {
)
)
)
const departmentFilterOptions = computed(() => mapSimpleFilterOptions(departmentOptions.value, '全部部门'))
const gradeFilterOptions = computed(() => mapSimpleFilterOptions(gradeOptions.value, '全部职级'))
const roleDropdownOptions = computed(() => mapSimpleFilterOptions(roleFilterOptions.value, '全部角色'))
const departmentFilterLabel = computed(() => selectedDepartment.value || '组织部门')
const gradeFilterLabel = computed(() => selectedGrade.value || '职级')
const roleFilterLabel = computed(() => selectedRole.value || '系统角色')
const managerOptions = computed(() => {
const currentId = selectedEmployee.value?.id
@@ -1440,6 +1455,12 @@ export default {
selectedDepartment,
selectedGrade,
selectedRole,
departmentFilterLabel,
departmentFilterOptions,
gradeFilterLabel,
gradeFilterOptions,
roleDropdownOptions,
roleFilterLabel,
activeFilterPopover,
currentPage,
pageSize,

File diff suppressed because it is too large Load Diff

View File

@@ -203,7 +203,7 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
return Boolean(
budgetContext ||
(isBudgetContext && budgetContext) ||
(
text &&
(isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)

View File

@@ -0,0 +1,179 @@
import { computed, reactive, ref } from 'vue'
export const RECEIPT_FILTER_ALL = 'all'
const QUALITY_OPTIONS = [
{ value: RECEIPT_FILTER_ALL, label: '全部置信度' },
{ value: 'high', label: '高置信度' },
{ value: 'medium', label: '中等置信度' },
{ value: 'low', label: '低置信度' },
{ value: 'missing', label: '待确认' }
]
function normalizeText(value) {
return String(value ?? '').trim()
}
function getFilterValue(filters, key) {
return normalizeText(filters?.[key]) || RECEIPT_FILTER_ALL
}
function buildUniqueOptions(rows, valueKey, labelKey, allLabel) {
const seen = new Map()
for (const row of Array.isArray(rows) ? rows : []) {
const value = normalizeText(row?.[valueKey])
if (!value || seen.has(value)) continue
seen.set(value, normalizeText(row?.[labelKey]) || value)
}
return [
{ value: RECEIPT_FILTER_ALL, label: allLabel },
...Array.from(seen.entries())
.map(([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'zh-Hans-CN'))
]
}
function resolveReceiptMonth(row) {
const raw = normalizeText(row?.document_date) || normalizeText(row?.uploaded_at)
const match = raw.match(/^(\d{4})[-/年]?(\d{1,2})/)
if (!match) return ''
return `${match[1]}-${String(match[2]).padStart(2, '0')}`
}
function buildMonthOptions(rows) {
const months = new Set((Array.isArray(rows) ? rows : []).map(resolveReceiptMonth).filter(Boolean))
return [
{ value: RECEIPT_FILTER_ALL, label: '全部月份' },
...Array.from(months)
.sort((left, right) => right.localeCompare(left))
.map((value) => ({ value, label: `${value.replace('-', '年')}` }))
]
}
function resolveScore(row) {
const score = Number(row?.avg_score || 0)
return Number.isFinite(score) ? score : 0
}
function matchesQuality(row, quality) {
if (quality === RECEIPT_FILTER_ALL) return true
const score = resolveScore(row)
if (quality === 'missing') return score <= 0
if (quality === 'high') return score >= 0.9
if (quality === 'medium') return score >= 0.75 && score < 0.9
if (quality === 'low') return score > 0 && score < 0.75
return true
}
export function buildReceiptFilterControls(rows, filters) {
return [
{
key: 'documentType',
label: '票据类型',
options: buildUniqueOptions(rows, 'document_type', 'document_type_label', '全部类型')
},
{
key: 'scene',
label: '费用场景',
options: buildUniqueOptions(rows, 'scene_code', 'scene_label', '全部场景')
},
{
key: 'month',
label: '票据月份',
options: buildMonthOptions(rows)
},
{
key: 'quality',
label: '置信度',
options: QUALITY_OPTIONS
}
].map((control) => ({
...control,
value: getFilterValue(filters, control.key)
}))
}
export function applyReceiptListFilters(rows, filters) {
const documentType = getFilterValue(filters, 'documentType')
const scene = getFilterValue(filters, 'scene')
const month = getFilterValue(filters, 'month')
const quality = getFilterValue(filters, 'quality')
return (Array.isArray(rows) ? rows : []).filter((row) => (
(documentType === RECEIPT_FILTER_ALL || normalizeText(row?.document_type) === documentType)
&& (scene === RECEIPT_FILTER_ALL || normalizeText(row?.scene_code) === scene)
&& (month === RECEIPT_FILTER_ALL || resolveReceiptMonth(row) === month)
&& matchesQuality(row, quality)
))
}
export function buildReceiptFilterTokens(controls, filters) {
return (Array.isArray(controls) ? controls : [])
.map((control) => {
const value = getFilterValue(filters, control.key)
if (value === RECEIPT_FILTER_ALL) return ''
const option = control.options.find((item) => item.value === value)
return `${control.label}${option?.label || value}`
})
.filter(Boolean)
}
export function createReceiptFolderListFilterModel({ receipts, activeRows, keyword }) {
const openReceiptFilterKey = ref('')
const receiptFilters = reactive({
documentType: RECEIPT_FILTER_ALL,
scene: RECEIPT_FILTER_ALL,
month: RECEIPT_FILTER_ALL,
quality: RECEIPT_FILTER_ALL
})
const receiptFilterControls = computed(() => buildReceiptFilterControls(receipts.value, receiptFilters))
const hasActiveReceiptFilters = computed(() => Object.values(receiptFilters).some((value) => value !== RECEIPT_FILTER_ALL))
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
const filtered = applyReceiptListFilters(activeRows.value, receiptFilters)
if (!normalized) return filtered
return filtered.filter((item) => [
item.file_name,
item.document_type_label,
item.scene_label,
item.summary,
item.amount,
item.document_date,
item.linked_claim_no
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
function toggleReceiptFilter(key) {
openReceiptFilterKey.value = openReceiptFilterKey.value === key ? '' : key
}
function selectReceiptFilter(key, value) {
receiptFilters[key] = value
openReceiptFilterKey.value = ''
}
function resolveReceiptFilterLabel(control) {
return control.options.find((option) => option.value === receiptFilters[control.key])?.label || control.label
}
function clearReceiptFilters() {
receiptFilters.documentType = RECEIPT_FILTER_ALL
receiptFilters.scene = RECEIPT_FILTER_ALL
receiptFilters.month = RECEIPT_FILTER_ALL
receiptFilters.quality = RECEIPT_FILTER_ALL
openReceiptFilterKey.value = ''
}
return {
filteredRows,
hasActiveReceiptFilters,
openReceiptFilterKey,
receiptFilterControls,
receiptFilters,
clearReceiptFilters,
resolveReceiptFilterLabel,
selectReceiptFilter,
toggleReceiptFilter
}
}

View File

@@ -0,0 +1,131 @@
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
const APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP = {
applicationType: 'expense_type',
time: 'time_range',
location: 'location',
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
}
const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
applicationType: '申请类型',
time: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transportMode: '出行方式',
department: '所属部门',
applicant: '申请人',
grade: '职级'
}
function compactValue(value = '') {
return String(value || '').trim()
}
function resolveStewardCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function resolveTaskOntologyFields(task = null) {
const fields = task?.ontology_fields || task?.ontologyFields || {}
return fields && typeof fields === 'object' ? fields : {}
}
function resolveFieldValue(...candidates) {
for (const candidate of candidates) {
const value = compactValue(candidate)
if (value && !['待补充', '待测算', '未知'].includes(value)) {
return value
}
}
return ''
}
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
if (!task || typeof task !== 'object') {
return null
}
const canonicalKey = APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP[fieldKey] || ''
if (!canonicalKey) {
return { ...task }
}
const ontologyFields = {
...resolveTaskOntologyFields(task),
[canonicalKey]: value
}
const sourceMissingFields = Array.isArray(task.missing_fields)
? task.missing_fields
: Array.isArray(task.missingFields)
? task.missingFields
: []
return {
...task,
ontology_fields: ontologyFields,
missing_fields: sourceMissingFields.filter((field) => compactValue(field) !== canonicalKey)
}
}
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
const source = continuation && typeof continuation === 'object' ? continuation : {}
const currentTask = resolveStewardCurrentTask(source)
const updatedTask = buildUpdatedTask(currentTask, fieldKey, value)
if (!updatedTask) {
return source
}
return {
...source,
currentTask: updatedTask
}
}
export function buildStewardFieldCompletionRawText({
preview = {},
fieldKey = '',
fieldLabel = '',
value = '',
continuation = null
} = {}) {
const normalizedPreview = normalizeApplicationPreview(preview)
const fields = normalizedPreview.fields || {}
const currentTask = resolveStewardCurrentTask(continuation)
const ontologyFields = resolveTaskOntologyFields(currentTask)
const selectedLabel = compactValue(fieldLabel) || APPLICATION_PREVIEW_FIELD_LABEL_MAP[fieldKey] || '补充项'
const selectedValue = compactValue(value)
const transportMode = fieldKey === 'transportMode'
? selectedValue
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
const knownLines = [
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
['天数', resolveFieldValue(fields.days)],
['出行方式', transportMode]
]
.filter(([, fieldValue]) => fieldValue)
.map(([label, fieldValue]) => `${label}${fieldValue}`)
return [
'小财管家继续执行申请单字段补齐。',
`用户已补充:${selectedLabel}${selectedValue}`,
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
'',
'已识别信息:',
...knownLines,
'',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
].filter((line) => line !== '').join('\n')
}

View File

@@ -79,6 +79,25 @@ const FIELD_ALIASES = {
application_transport_mode: 'transport_mode'
}
const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'employee_name',
'department_name'
])
const FIELD_VALUE_DISPLAY_CONFIG = {
expense_type: {
travel: '差旅',
business_entertainment: '业务招待',
transportation: '交通费',
traffic: '交通费',
accommodation: '住宿费',
meal: '餐饮费'
}
}
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
const safeFiles = Array.isArray(files) ? files : []
return {
@@ -123,9 +142,10 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
tasks: Array.isArray(rawPlan.tasks)
? rawPlan.tasks.map((item) => {
const taskType = String(item.task_type || item.taskType || '')
const missingFields = Array.isArray(item.missing_fields || item.missingFields)
const rawMissingFields = Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: []
const missingFields = filterStewardBlockingMissingFields(rawMissingFields, taskType)
return {
taskId: String(item.task_id || item.taskId || ''),
taskType,
@@ -188,7 +208,7 @@ export function buildStewardPlanMessageText(plan) {
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const safeFields = filterStewardBlockingMissingFields(fields, taskType)
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
@@ -202,18 +222,44 @@ export function buildStewardFieldItems(fields = [], taskType = '') {
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '') {
export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) {
const includeHints = options.includeHints !== false
return buildStewardFieldItems(fields, taskType)
.map((item) => item.hint ? `${item.label}${item.hint}` : item.label)
.map((item) => includeHints && item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function filterStewardBlockingMissingFields(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
if (taskType !== 'expense_application') {
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
}
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) {
return false
}
seen.add(field)
return true
})
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${value}`
return `${field.label}${formatStewardFieldDisplayValue(field.key, value)}`
})
.join('')
}
@@ -246,6 +292,7 @@ export function buildStewardSuggestedActions(plan) {
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId,
steward_next_task_id: task?.taskId || '',
steward_current_task: buildStewardTaskPayload(task),
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
}
@@ -447,7 +494,11 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
}
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
const missingFields = formatStewardMissingFieldList(
task.missingFields || [],
task.taskType,
{ includeHints: false }
)
const lines = [
actionType === 'confirm_create_application'
? `小财管家已完成意图识别,请先创建申请单:${task.title || task.taskTypeLabel}`
@@ -458,8 +509,12 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
? missingFields
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: missingFields
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {
@@ -495,6 +550,12 @@ function resolveFieldDisplay(field, taskType = '') {
}
}
function formatStewardFieldDisplayValue(field, value) {
const key = normalizeFieldKey(field)
const normalizedValue = String(value || '').trim()
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
}
function buildRemainingTaskText(normalized, currentTaskId) {
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
if (!remainingTasks.length) {
@@ -512,13 +573,20 @@ function buildRemainingTaskText(normalized, currentTaskId) {
function buildRemainingTaskPayload(normalized, currentTaskId) {
return normalized.tasks
.filter((task) => task.taskId !== currentTaskId)
.map((task) => ({
task_id: task.taskId,
task_type: task.taskType,
title: task.title,
summary: task.summary,
assigned_agent: task.assignedAgent,
ontology_fields: task.ontologyFields || {},
missing_fields: task.missingFields || []
}))
.map((task) => buildStewardTaskPayload(task))
}
function buildStewardTaskPayload(task) {
if (!task) {
return null
}
return {
task_id: task.taskId || task.task_id || '',
task_type: task.taskType || task.task_type || '',
title: task.title || '',
summary: task.summary || '',
assigned_agent: task.assignedAgent || task.assigned_agent || '',
ontology_fields: task.ontologyFields || task.ontology_fields || {},
missing_fields: task.missingFields || task.missing_fields || []
}
}

View File

@@ -6,8 +6,10 @@ import {
} from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
export function useStewardPlanFlow({
activeSessionType,
@@ -174,7 +176,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -269,7 +271,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
}

View File

@@ -13,7 +13,10 @@ import {
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
applicationDateRangesOverlap,
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -21,16 +24,275 @@ 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 { fetchStewardSlotDecision } from '../../services/steward.js'
import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
applicationType: 'expense_type',
time: 'time_range',
location: 'location',
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
}
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
费用类型: 'expense_type',
申请类型: 'expense_type',
发生时间: 'time_range',
出发时间: 'time_range',
申请时间: 'time_range',
地点: 'location',
事由: 'reason',
金额: 'amount',
系统预估费用: 'amount',
出行方式: 'transport_mode',
附件: 'attachments',
'附件/凭证': 'attachments',
商户: 'merchant_name',
'商户/开票方': 'merchant_name',
客户: 'customer_name',
客户或项目对象: 'customer_name'
}
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
expense_type: 'applicationType',
time_range: 'time',
location: 'location',
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
}
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
expense_type: '费用类型',
time_range: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
department_name: '所属部门',
employee_name: '申请人',
employee_grade: '职级'
}
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'department_name',
'employee_name'
])
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
'cancelled',
'canceled',
'void',
'voided',
'deleted',
'已取消',
'已作废',
'作废',
'已删除'
])
function normalizeClaimListPayload(payload) {
if (Array.isArray(payload)) {
return payload
}
return Array.isArray(payload?.items) ? payload.items : []
}
function normalizeClaimRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
if (Array.isArray(flags)) {
return flags
}
return flags && typeof flags === 'object' ? [flags] : []
}
function extractApplicationDetailFromClaim(claim) {
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
if (found || !item || typeof item !== 'object') {
return found
}
const detail = item.application_detail || item.applicationDetail
return detail && typeof detail === 'object' ? detail : null
}, null)
}
function isApplicationClaimRecord(claim) {
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
return (
expenseType === 'application' ||
expenseType === 'expense_application' ||
expenseType.endsWith('_application') ||
claimNo.startsWith('AP-') ||
claimNo.startsWith('APP-') ||
Boolean(extractApplicationDetailFromClaim(claim))
)
}
function normalizeApplicationExpenseType(value) {
const text = String(value || '').trim().toLowerCase()
if (!text) {
return ''
}
if (text === 'travel_application' || /差旅|出差/.test(text)) {
return 'travel_application'
}
if (text === 'purchase_application' || /采购/.test(text)) {
return 'purchase_application'
}
if (text === 'meeting_application' || /会务|会议/.test(text)) {
return 'meeting_application'
}
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
return text === 'application' ? 'expense_application' : text
}
return 'expense_application'
}
function resolveClaimApplicationExpenseType(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return normalizeApplicationExpenseType(
claim?.expense_type ||
claim?.expenseType ||
detail.application_type ||
detail.applicationType ||
''
)
}
function isIgnoredApplicationDuplicateStatus(status) {
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
}
function resolveClaimApplicationDateRange(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return (
resolveApplicationDateRange(
detail.time ||
detail.time_range ||
detail.timeRange ||
detail.application_time ||
detail.applicationTime ||
detail.application_business_time ||
detail.applicationBusinessTime ||
detail.application_date ||
detail.applicationDate,
detail.days || detail.application_days || detail.applicationDays
) ||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
)
}
function formatApplicationDateRangeLabel(range) {
if (!range?.startDate) {
return '待确认'
}
return range.startDate === range.endDate ? range.startDate : `${range.startDate}${range.endDate}`
}
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
const preview = normalizeApplicationPreview(applicationPreview)
const fields = preview.fields || {}
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
if (!currentRange) {
return null
}
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
const claims = normalizeClaimListPayload(claimsPayload)
for (const claim of claims) {
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
continue
}
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
continue
}
const existingRange = resolveClaimApplicationDateRange(claim)
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
continue
}
return {
claim,
currentRange,
existingRange,
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
reason: String(claim?.reason || '').trim(),
location: String(claim?.location || '').trim()
}
}
return null
}
function buildApplicationDateConflictMessage(conflict) {
const claimNo = conflict?.claimNo || '已有申请'
return [
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
'',
'已有申请:',
`- **单号**${claimNo}`,
`- **申请时间**${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
conflict?.location ? `- **地点**${conflict.location}` : '',
conflict?.reason ? `- **事由**${conflict.reason}` : '',
`- **当前节点**${conflict?.status || '处理中'}`,
'',
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
'',
'请先查看已有申请,或修改本次出差时间后再继续。'
].filter(Boolean).join('\n')
}
function buildApplicationDateConflictActions(conflict) {
const actions = []
if (conflict?.claimId) {
actions.push({
action_type: 'open_application_detail',
label: '查看已有申请',
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
icon: 'mdi mdi-file-search-outline',
payload: {
claim_id: conflict.claimId,
claim_no: conflict.claimNo
}
})
}
actions.push({
action_type: 'prefill_composer',
label: '修改出差时间',
description: '在输入框中补充新的出差日期后继续。',
icon: 'mdi mdi-calendar-edit-outline',
payload: {
prompt_prefill: '修改出差时间为:'
}
})
return actions
}
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
@@ -145,8 +407,21 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
}
function isBlockingApplicationOntologyField(key = '') {
const normalizedKey = String(key || '').trim()
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
}
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
})
}
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
const normalized = normalizeApplicationPreview(preview)
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
if (!missingFields.includes('出行方式')) {
return []
}
@@ -158,77 +433,298 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label: mode,
description: `选择${mode}作为本次出行方式,并同步费用测算`,
description: `选择${mode}后,由小财管家继续查询票价并测算费用`,
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
payload: {
field_key: 'transportMode',
field_label: '出行方式',
value: mode
value: mode,
applicationPreview: normalized,
steward_delegated_field_completion: true
}
}))
}
function resolveStewardContinuationCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function normalizeCanonicalFieldList(fields = []) {
const normalized = []
if (!Array.isArray(fields)) {
return normalized
}
fields.forEach((field) => {
const key = String(field || '').trim()
if (key && !normalized.includes(key)) {
normalized.push(key)
}
})
return normalized
}
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
const normalizedPreview = normalizeApplicationPreview(preview)
const previewFields = normalizedPreview.fields || {}
const task = resolveStewardContinuationCurrentTask(continuation)
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
const fields = {}
Object.entries(taskFields || {}).forEach(([key, value]) => {
const normalizedKey = String(key || '').trim()
const normalizedValue = String(value || '').trim()
if (normalizedKey && normalizedValue) {
fields[normalizedKey] = normalizedValue
}
})
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
const value = String(previewFields[previewKey] || '').trim()
if (value && value !== '待补充' && !fields[ontologyKey]) {
fields[ontologyKey] = value
}
})
return fields
}
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
const task = resolveStewardContinuationCurrentTask(continuation)
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
if (taskMissingFields.length) {
return taskMissingFields
}
return resolveApplicationPreviewMissingFieldsForSteward(preview)
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
.filter((key, index, list) =>
key &&
isBlockingApplicationOntologyField(key) &&
!String(ontologyFields[key] || '').trim() &&
list.indexOf(key) === index
)
}
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
try {
return await fetchStewardSlotDecision({
task_type: 'expense_application',
user_message: String(rawText || '').trim(),
ontology_fields: ontologyFields,
missing_fields: missingFields,
task_context: {
steward_continuation: continuation || null,
application_preview: normalizeApplicationPreview(preview)
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
})
} catch (error) {
console.warn('Steward slot decision failed:', error)
return null
}
}
function formatStewardDecisionUserText(text = '') {
let formatted = String(text || '').trim()
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
formatted = formatted
.replace(new RegExp(`\\s*${escapedKey}\\s*`, 'g'), '')
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
})
return formatted.replace(/\s{2,}/g, ' ').trim()
}
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return fallbackText
}
const question = formatStewardDecisionUserText(decision.question || '')
const rationale = formatStewardDecisionUserText(decision.rationale || '')
const parts = [
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
'',
rationale ? `**原因是:${rationale}**` : '',
'',
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
].filter((item) => item !== '')
return parts.join('\n')
}
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return []
}
const normalizedPreview = normalizeApplicationPreview(preview)
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
const actions = Array.isArray(decision.options) ? decision.options : []
return actions.map((option) => {
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
return null
}
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
const value = String(option?.value || option?.label || '').trim()
const label = String(option?.label || value).trim()
const normalizedValue = fieldKey === 'transportMode'
? normalizeTransportModeOption(value || label, '')
: value
if (!fieldKey || !value || !label) {
return null
}
if (fieldKey === 'transportMode' && !normalizedValue) {
return null
}
return {
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label,
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
payload: {
field_key: fieldKey,
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
value: normalizedValue,
applicationPreview: normalizedPreview,
steward_delegated_field_completion: true
}
}
}).filter(Boolean)
}
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
`**还需要你补充:${missingFields.join('、')}。**`,
'',
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
].join('\n')
}
function shouldPauseStewardApplicationPreview(preview = {}) {
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
}
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
const text = String(summary || '').trim()
if (String(sessionType || '').trim() !== 'application') {
return text
}
return text
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,、;;\s]*/g, '')
.replace(/[,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
.replace(/[,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
.replace(/([,、;;。])\1+/g, '$1')
.replace(/[,、;;\s]+。/g, '。')
.replace(/[,、;;\s]+$/g, '')
.trim()
}
function summarizeApplicationPreviewForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return [
fields.time ? `时间:${fields.time}` : '',
fields.location ? `地点:${fields.location}` : '',
fields.reason ? `事由:${fields.reason}` : '',
fields.applicationType ? `类型:${fields.applicationType}` : ''
].filter(Boolean).join('')
}
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
const rawText = String(context.rawText || '').trim()
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
const taskSummary = sanitizeStewardDelegatedTaskSummary(
extractStewardCarryLine(rawText, '任务摘要'),
sessionType
)
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|| extractStewardCarryLine(rawText, '已识别信息')
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
const applicationMissingFields = context.applicationPreview
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
: []
const missingInfo = applicationMissingFields.length
? applicationMissingFields.join('、')
: carryMissingInfo
const events = [
{
eventId: `${eventPrefix}-confirm`,
title: '接收确认',
content: '已收到你的确认,小财管家继续推进当前任务。'
eventId: `${eventPrefix}-intent`,
title: '理解当前任务',
content: taskSummary
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}`
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
},
{
eventId: `${eventPrefix}-coordinate`,
title: '协调能力',
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
eventId: `${eventPrefix}-known`,
title: '核对已知信息',
content: identifiedInfo
? `当前已识别到:${identifiedInfo}`
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
}
]
const applicationMissingFields = context.applicationPreview
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
: []
if (applicationMissingFields.length) {
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '识别缺口',
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
title: '判断待补充信息',
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
eventId: `${eventPrefix}-ready`,
title: '判断下一步动作',
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
})
}
events.push(
{
eventId: `${eventPrefix}-output`,
title: '准备输出',
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
}
)
return events
}
@@ -257,6 +753,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
? finalExtras.suggestedActions
: []
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -287,11 +786,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
nextTick(scrollToBottom)
const chars = Array.from(String(eventData.content || ''))
for (let index = 0; index < chars.length; index += 1) {
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
@@ -304,14 +804,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const text = String(finalText || '')
message.text = ''
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.suggestedActions = pendingSuggestedActions
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
const chars = Array.from(text)
for (let index = 0; index < chars.length; index += 1) {
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
message.text = chars.slice(0, index + 1).join('')
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
@@ -670,7 +1172,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
async function buildApplicationPreviewWithModelReview(
rawText,
businessTimeContext = null,
sessionTypeOverride = '',
options = {}
) {
const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
@@ -697,6 +1204,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
if (options.skipModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'skipped'
}),
meta: ['申请核对预览', '结构化快路径']
}
}
try {
const ontology = await fetchOntologyParse(
{
@@ -828,7 +1345,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, {
if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, {
sessionType: effectiveSessionType,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
@@ -944,9 +1461,62 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType
effectiveSessionType,
{
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
}
)
const reviewStatus = String(meta?.[1] || '').trim()
let applicationDateConflict = null
try {
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
} catch (error) {
console.warn('Failed to check overlapping application dates:', error)
}
if (applicationDateConflict) {
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
'检测到同日期已有申请,已停止重复创建',
Date.now() - reviewStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
conflictText,
[],
{
meta: ['申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
}
))
} else {
await typeStewardDelegatedMessage(
pendingMessage.id,
conflictText,
{
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
}
persistSessionState()
nextTick(scrollToBottom)
return null
}
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
@@ -960,16 +1530,43 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
}
if (stewardDelegated) {
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
applicationPreview,
buildLocalApplicationPreviewMessage(applicationPreview)
)
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
const slotDecision = shouldFetchSlotDecision
? await fetchStewardApplicationSlotDecision(
applicationPreview,
rawText,
options.stewardContinuation || null
)
: null
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
const pauseForMissingFields = slotDecision
? String(slotDecision.next_action || '').trim() === 'ask_user'
: localPauseForMissingFields
const stewardApplicationText = buildStewardSlotDecisionMessage(
slotDecision,
applicationPreview,
fallbackStewardApplicationText
)
await typeStewardDelegatedMessage(
pendingMessage.id,
buildLocalApplicationPreviewMessage(applicationPreview),
stewardApplicationText,
{
meta,
applicationPreview,
applicationPreview: pauseForMissingFields ? null : applicationPreview,
suggestedActions: slotDecisionActions.length
? slotDecisionActions
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
@@ -1478,6 +2075,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
},
{
sessionType: effectiveSessionType,
rawText,
fileNames: effectiveFileNames,
stewardContinuation: options.stewardContinuation || null
}
)