feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -17,6 +17,7 @@
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
@@ -49,7 +50,7 @@
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -62,6 +63,7 @@
:class="{
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'logs-workarea': activeView === 'logs',
@@ -105,6 +107,7 @@
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<ArchiveCenterView v-else-if="activeView === 'archive'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
@@ -122,6 +125,7 @@
:initial-conversation="smartEntryContext.conversation"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
@@ -140,6 +144,7 @@ import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import LogsView from './LogsView.vue'
@@ -187,6 +192,7 @@ const {
search,
selectedRequest,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
toast,

View File

@@ -0,0 +1,141 @@
<template>
<section class="approval-page archive-page">
<TravelRequestDetailView
v-if="selectedRow"
:request="selectedRow"
back-label="返回归档列表"
@back-to-requests="closeSelectedDetail"
@request-updated="reload"
@request-deleted="reload"
/>
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="归档分类">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<div
v-for="dropdown in filterDropdowns"
:key="dropdown.key"
class="archive-dropdown-filter"
:class="{ open: openFilterKey === dropdown.key }"
>
<button class="filter-btn" type="button" @click="toggleFilterDropdown(dropdown.key)">
<span>{{ dropdown.label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === dropdown.key"
class="archive-dropdown-menu"
role="menu"
:aria-label="`${dropdown.label}筛选`"
>
<button
v-for="option in dropdown.options"
:key="`${dropdown.key}-${option.value}`"
type="button"
class="archive-dropdown-option"
:class="{ active: dropdown.activeValue === option.value }"
role="menuitem"
@click="selectFilterValue(dropdown.key, option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已归档入账的报销数据点击单据行查看详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="归档数据同步中"
message="正在加载公司已归档的报销单据"
icon="mdi mdi-archive-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>归档列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="archiveEmptyState.eyebrow"
:title="archiveEmptyState.title"
:description="archiveEmptyState.desc"
:icon="archiveEmptyState.icon"
:action-label="archiveEmptyState.actionLabel"
:action-icon="archiveEmptyState.actionIcon"
:tone="archiveEmptyState.tone"
:art-label="archiveEmptyState.artLabel"
:tips="archiveEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup>
<col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>归档节点</th>
<th>风险</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
{{ row.applicant }}
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td>{{ row.node }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><span class="status-tag archived">{{ row.status }}</span></td>
</tr>
</tbody>
</table>
</div>
</article>
</section>
</template>
<script src="./scripts/ArchiveCenterView.js"></script>
<style scoped src="../assets/styles/views/approval-center-view.css"></style>
<style scoped src="../assets/styles/views/approval-center-view-part2.css"></style>
<style scoped src="../assets/styles/views/archive-center-view.css"></style>

View File

@@ -24,7 +24,7 @@
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>

View File

@@ -1,6 +1,6 @@
<template>
<Teleport to="body">
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave">
<Transition name="assistant-modal" @after-enter="handleAssistantModalAfterEnter" @after-leave="emitCloseAfterLeave">
<div v-if="workbenchVisible" class="assistant-overlay">
<section class="assistant-modal">
<div class="assistant-header-actions">
@@ -127,7 +127,7 @@
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span
v-for="item in message.meta"
:key="item"
@@ -139,7 +139,7 @@
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length"
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
@@ -173,7 +173,7 @@
</div>
<details
v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length"
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.citations?.length"
class="message-detail-block message-citation-disclosure"
>
<summary>
@@ -197,7 +197,7 @@
class="message-detail-block expense-query-block"
>
<strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }}
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '5 条筛选结果') }}
</strong>
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
@@ -242,6 +242,20 @@
<span>{{ record.dateDisplay }}</span>
<span>{{ record.amountDisplay }}</span>
</div>
<div v-if="record.riskItems?.length" class="expense-query-risk-row">
<button
v-for="risk in record.riskItems"
:key="`${message.id}-${record.claimId}-${risk.key}`"
type="button"
class="expense-query-risk-chip"
:class="risk.level"
@click.stop="appendExpenseQueryRiskToConversation(record, risk)"
>
<span>{{ record.claimNo }}</span>
<strong>{{ risk.levelLabel }}</strong>
<em>{{ risk.title }}</em>
</button>
</div>
</div>
<i class="mdi mdi-chevron-right"></i>
</button>
@@ -289,15 +303,19 @@
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div>
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">
{{ buildExpenseQueryHint(message.queryPayload) }}
<p
v-if="buildExpenseQueryHint(message.queryPayload)"
class="expense-query-hint message-answer-markdown"
v-html="renderMarkdown(buildExpenseQueryHint(message.queryPayload))"
@click="handleAssistantMarkdownClick($event, message)"
>
</p>
</div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup">
<template
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
v-for="followup in [buildReviewPlainFollowupForMessage(message)]"
:key="`${message.id}-review-followup`"
>
<h3
@@ -684,7 +702,7 @@
<div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools">
<button
v-if="activeReviewPayload"
v-if="activeReviewPayload && reviewOverviewDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
@@ -836,7 +854,7 @@
<template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent">
<template v-if="activeReviewPayload">
<template v-if="!isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
<template v-if="reviewOverviewDrawerAvailable && !isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
<section class="review-side-card review-side-overview-card">
<div class="review-side-intent-row">
<i class="mdi mdi-account-outline"></i>
@@ -1221,7 +1239,7 @@
</button>
</template>
<section v-if="currentInsight.agent.citations?.length && !activeReviewPayload" class="insight-card">
<section v-if="currentInsight.agent.citations?.length && !currentInsight.agent.queryPayload && !activeReviewPayload" class="insight-card">
<div class="card-head">
<h4>制度依据</h4>
</div>
@@ -1284,30 +1302,6 @@
@confirm="confirmDeleteCurrentSession"
/>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">
<div class="review-upload-decision-copy">
<span class="assistant-badge">上传票据</span>
<h3>检测到你已有单据事件</h3>
<p>这次新上传的附件需要先确认处理方式你可以继续归集到上一笔单据也可以重新开启一张新单据</p>
</div>
<div class="review-confirm-actions review-upload-decision-actions">
<button type="button" class="primary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="continueExistingUpload">
继续
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="createNewUploadDocument">
新单据
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="closeUploadDecisionDialog">
取消
</button>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
<section class="review-preview-modal">

View File

@@ -97,20 +97,11 @@
</div>
<div v-if="canEditDetailNote" class="detail-note-editor">
<textarea
v-model="detailNoteEditor"
v-model="detailNoteEditorView"
maxlength="500"
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
aria-label="附加说明"
></textarea>
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
<span
v-for="tag in detailNoteTags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<div class="detail-note-editor-meta">
<span>仅草稿待提交状态可编辑提交后将作为明确说明展示</span>
<div class="detail-note-actions">
@@ -136,15 +127,6 @@
</div>
<div v-else class="detail-note readonly">
<p>{{ detailNote }}</p>
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
<span
v-for="tag in detailNoteTags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
</div>
</article>
@@ -178,8 +160,8 @@
<table>
<thead>
<tr>
<th class="col-time">时间</th>
<th class="col-filled-at">填写时间</th>
<th class="col-time">发生时间</th>
<th class="col-type">费用项目</th>
<th class="col-desc">说明</th>
<th class="col-amount">金额</th>
@@ -190,6 +172,10 @@
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
<i
v-if="isMajorExpenseRisk(item)"
@@ -208,10 +194,6 @@
<span>{{ item.dayLabel }}</span>
</template>
</td>
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td class="expense-type col-type">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
@@ -405,11 +387,11 @@
</div>
</article>
<article v-if="isEditableRequest" class="detail-card panel validation-card">
<article v-if="showAiAdvicePanel" class="detail-card panel validation-card">
<div class="validation-head">
<div>
<h3>AI建议</h3>
<p>按建议顺序补齐信息或处理风险后再发起审批</p>
<h3>{{ aiAdviceTitle }}</h3>
<p>{{ aiAdviceHint }}</p>
</div>
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div>
@@ -434,15 +416,6 @@
<span>{{ card.label }}</span>
<strong>{{ card.title }}</strong>
</div>
<div v-if="card.tags?.length" class="risk-card-tag-list" aria-label="风险标签">
<span
v-for="tag in card.tags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<p class="risk-advice-point">{{ card.risk }}</p>
<div class="risk-advice-meta">
<div>
@@ -733,15 +706,6 @@
<strong>{{ currentSubmitRiskWarning.title }}</strong>
</div>
<p>{{ currentSubmitRiskWarning.risk }}</p>
<div class="risk-card-tag-list" aria-label="风险标签">
<span
v-for="tag in currentSubmitRiskWarning.tags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
maxlength="160"

View File

@@ -6,6 +6,11 @@ import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import {
filterActionableRiskFlags,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24
@@ -37,10 +42,9 @@ function formatCurrency(value) {
}
function resolveRiskTone(riskFlags, riskSummary) {
if (Array.isArray(riskFlags)) {
const severities = riskFlags
.map((item) => String(item?.severity || '').trim().toLowerCase())
.filter(Boolean)
const actionableFlags = filterActionableRiskFlags(riskFlags)
if (actionableFlags.length) {
const severities = actionableFlags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
if (severities.includes('high')) {
return 'high'
@@ -53,7 +57,7 @@ function resolveRiskTone(riskFlags, riskSummary) {
}
}
if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') {
if (isRiskSummaryWithRisk(riskSummary)) {
return 'medium'
}

View File

@@ -0,0 +1,313 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
import {
ARCHIVE_FILTER_ALL,
applyArchiveListFilters,
buildArchiveMonthFilterOptions,
buildDepartmentFilterOptions,
buildTypeFilterOptions,
countClaimRisks,
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters,
resolveArchiveRiskTone
} from '../../utils/archiveCenterListFilters.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
{ value: 'none', label: '无风险' },
{ value: 'high', label: '高风险' },
{ value: 'medium', label: '中风险' },
{ value: 'low', label: '低风险' }
]
function formatCurrency(value) {
const amount = Number(value)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(Number.isFinite(amount) ? amount : 0)
}
function resolveArchiveTypeTab(request) {
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
if (expenseType === 'travel') {
return '差旅报销'
}
if (expenseType === 'entertainment') {
return '招待报销'
}
return '其他费用'
}
function buildArchiveRow(request) {
const normalized = normalizeRequestForUi(request)
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
const hasRisk = riskCount > 0
const archiveMonth = extractArchiveMonth(
normalized.updatedAt,
normalized.submittedAt,
normalized.createdAt,
normalized.occurredAt,
normalized.applyTime
)
return {
...normalized,
applicant: normalized.person,
avatar: String(normalized.person || '?').trim().slice(0, 1) || '?',
department: normalized.dept,
type: normalized.typeLabel,
amount: formatCurrency(normalized.amountValue),
time: normalized.applyTime,
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
node: normalized.workflowNode || '归档入账',
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: resolveArchiveTypeTab(normalized)
}
}
function resolveFilterLabel(options, activeValue, fallbackLabel) {
return options.find((item) => item.value === activeValue)?.label || fallbackLabel
}
export default {
name: 'ArchiveCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const activeTab = ref('全部归档')
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
const activeArchiveMonthFilter = ref(ARCHIVE_FILTER_ALL)
const openFilterKey = ref('')
const selectedClaimId = ref('')
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const typeFilterOptions = computed(() => buildTypeFilterOptions(rows.value))
const departmentFilterOptions = computed(() => buildDepartmentFilterOptions(rows.value))
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
const filterDropdowns = computed(() => [
{
key: 'risk',
label: riskFilterLabel.value,
options: RISK_FILTER_OPTIONS,
activeValue: activeRiskFilter.value
},
{
key: 'type',
label: typeFilterLabel.value,
options: typeFilterOptions.value,
activeValue: activeTypeFilter.value
},
{
key: 'department',
label: departmentFilterLabel.value,
options: departmentFilterOptions.value,
activeValue: activeDepartmentFilter.value
},
{
key: 'archiveMonth',
label: archiveMonthFilterLabel.value,
options: archiveMonthFilterOptions.value,
activeValue: activeArchiveMonthFilter.value
}
])
const selectedRow = computed({
get() {
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
},
set(value) {
selectedClaimId.value = value?.claimId || ''
}
})
const visibleRows = computed(() => applyArchiveListFilters(rows.value, {
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
}))
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const archiveEmptyState = computed(() => {
if (!rows.value.length) {
return {
eyebrow: '归档中心',
title: '当前还没有已归档单据',
desc: '财务终审通过并进入「归档入账」节点的报销单会自动汇总到这里,形成公司级财务归档库。',
icon: 'mdi mdi-archive-check-outline',
actionLabel: null,
actionIcon: null,
tone: 'slate',
artLabel: 'ARCHIVE',
tips: ['仅展示已归档入账的单据', '申请人仍可在报销中心查看自己的归档记录']
}
}
const filtersActive = hasActiveArchiveListFilters({
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
})
return {
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `${activeTab.value}”里暂时没有归档单据`,
desc: filtersActive
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
icon: 'mdi mdi-archive-outline',
actionLabel: null,
actionIcon: null,
tone: 'sky',
artLabel: filtersActive ? 'FILTER' : 'ARCHIVE',
tips: ['归档中心保存全公司归档数据', '非申请人无法在报销中心查看他人归档单']
}
})
function resetListFilters() {
activeTab.value = '全部归档'
activeRiskFilter.value = ARCHIVE_FILTER_ALL
activeTypeFilter.value = ARCHIVE_FILTER_ALL
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
activeArchiveMonthFilter.value = ARCHIVE_FILTER_ALL
listKeyword.value = ''
openFilterKey.value = ''
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
return
}
resetListFilters()
}
function toggleFilterDropdown(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
}
function selectFilterValue(key, value) {
if (key === 'risk') {
activeRiskFilter.value = value
} else if (key === 'type') {
activeTypeFilter.value = value
} else if (key === 'department') {
activeDepartmentFilter.value = value
} else if (key === 'archiveMonth') {
activeArchiveMonthFilter.value = value
}
openFilterKey.value = ''
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
return
}
if (!target.closest('.archive-dropdown-filter')) {
openFilterKey.value = ''
}
}
function closeSelectedDetail() {
selectedClaimId.value = ''
}
async function reload() {
loading.value = true
error.value = ''
try {
const payload = await fetchArchivedExpenseClaims()
const mappedRows = (Array.isArray(payload) ? payload : [])
.map((item) => mapExpenseClaimToRequest(item))
.filter(Boolean)
.map((item) => buildArchiveRow(item))
rows.value = mappedRows
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
} catch (nextError) {
rows.value = []
selectedClaimId.value = ''
error.value = nextError instanceof Error ? nextError.message : '归档中心加载失败。'
} finally {
loading.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
void reload()
return {
activeTab,
archiveEmptyState,
closeSelectedDetail,
error,
filterDropdowns,
handleEmptyAction,
listKeyword,
loading,
openFilterKey,
reload,
resetListFilters,
rows,
selectFilterValue,
selectedRow,
showEmpty,
tabs,
toggleFilterDropdown,
visibleRows
}
}
}

View File

@@ -27,7 +27,10 @@ import {
shouldRenderOnlyOfficePreview
} from './knowledgePreviewMode.js'
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
import { resolveInitialKnowledgeFolder } from './knowledgeFolderSelection.js'
import {
resolveInitialKnowledgeFolder,
resolveKnowledgeFolderIcon
} from './knowledgeFolderSelection.js'
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
const KNOWLEDGE_POLL_INTERVAL_MS = 5000
@@ -663,11 +666,12 @@ export default {
previewLoading,
shouldRenderOnlyOffice,
shouldRenderOnlyOfficeHostNode,
selectDocument,
selectPreviewPage,
selectedDocument,
syncingFolder,
totalCount,
selectDocument,
selectPreviewPage,
selectedDocument,
resolveKnowledgeFolderIcon,
syncingFolder,
totalCount,
totalPages,
triggerUpload,
uploadHint,

View File

@@ -181,12 +181,26 @@ const REVIEW_DRAWER_MODE_REVIEW = 'review'
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
const REVIEW_DRAWER_MODE_RISK = 'risk'
const REVIEW_DRAWER_MODE_FLOW = 'flow'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed'
const FLOW_STEP_STATUS_FAILED = 'failed'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values)
}
@@ -413,11 +427,13 @@ function buildReviewRiskItems(reviewPayload) {
.filter(Boolean)
}
function buildReviewRiskConversationText(item) {
function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
@@ -429,6 +445,9 @@ function buildReviewRiskConversationText(item) {
if (suggestion) {
lines.push('', `修改建议:${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n')
}
@@ -470,6 +489,10 @@ export default {
requestContext: {
type: Object,
default: null
},
invalidatedDraftClaimId: {
type: String,
default: ''
}
},
emits: ['close', 'draft-saved'],
@@ -484,10 +507,10 @@ export default {
const composerDraft = ref('')
const submitting = ref(false)
const workbenchVisible = ref(false)
const closeAfterBusy = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
let sessionRuntimeRefs = {}
const uploadDecisionDialogOpen = ref(false)
const {
activeSessionType,
messages,
@@ -511,7 +534,6 @@ export default {
linkedRequest,
toast,
composerDraft,
uploadDecisionDialogOpen,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs: () => sessionRuntimeRefs
@@ -568,8 +590,21 @@ export default {
FLOW_STEP_STATUS_COMPLETED,
FLOW_STEP_STATUS_FAILED
})
const hasScopedReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
return true
}
if (currentInsight.value.intent === 'agent' && agent) {
return false
}
return messages.value.some((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0
() => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
)
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
@@ -604,11 +639,31 @@ export default {
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
)
const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null
)
const activeReviewPayload = computed(
() => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null
[...messages.value].reverse().find((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
) ?? null
)
const activeReviewPanelScope = computed(() => {
const agent = currentInsight.value.agent || null
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
if (agent?.reviewPayload && agentScope) {
return agentScope
}
if (currentInsight.value.intent === 'agent' && agent) {
return ''
}
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
})
const activeReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
return agent.reviewPayload
}
if (currentInsight.value.intent === 'agent' && agent) {
return null
}
return latestReviewMessage.value?.reviewPayload || null
})
const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload)
const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver)
const {
@@ -634,6 +689,7 @@ export default {
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewOverviewDrawerAvailable,
reviewDocumentDrawerAvailable,
reviewRiskDrawerAvailable,
reviewFlowDrawerAvailable,
@@ -671,6 +727,7 @@ export default {
closeDocumentPreview
} = useTravelReimbursementReviewDrawer({
activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps,
submitting,
@@ -709,6 +766,7 @@ export default {
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
@@ -853,6 +911,7 @@ export default {
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
@@ -868,7 +927,6 @@ export default {
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
uploadDecisionDialogOpen,
toast
})
const canSubmit = computed(
@@ -906,8 +964,8 @@ export default {
}
])
watch(
() => activeReviewPayload.value,
(payload) => {
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK
@@ -989,11 +1047,51 @@ export default {
{ immediate: true }
)
watch(
() => props.invalidatedDraftClaimId,
(claimId) => {
clearExpenseSessionForDeletedClaim(claimId)
},
{ immediate: true }
)
watch(
() => workbenchVisible.value,
(visible) => {
if (visible) {
scrollToBottom()
} else {
maybeFinalizeDeferredClose()
}
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
maybeFinalizeDeferredClose()
}
)
watch(
() => messages.value.length,
() => {
if (!workbenchVisible.value) {
return
}
scrollToBottom()
}
)
onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside)
startFlowTick()
nextTick(() => {
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
})
void clearKnowledgeSessionOnEntry()
currentInsight.value =
@@ -1008,11 +1106,6 @@ export default {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
submitComposer()
} else {
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
})
@@ -1023,8 +1116,31 @@ export default {
})
function scrollToBottom() {
if (!messageListRef.value) return
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
const scrollOnce = () => {
const list = messageListRef.value
if (!list) {
return false
}
list.scrollTop = list.scrollHeight
return true
}
nextTick(() => {
if (scrollOnce()) {
return
}
requestAnimationFrame(() => {
scrollOnce()
requestAnimationFrame(scrollOnce)
})
})
}
function handleAssistantModalAfterEnter() {
scrollToBottom()
requestAnimationFrame(() => {
scrollToBottom()
})
}
function resetCurrentSessionState() {
@@ -1034,6 +1150,31 @@ export default {
resetFlowRun({ startedAt: 0, openDrawer: false })
}
function clearExpenseSessionForDeletedClaim(claimId) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return
}
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
const currentMatchesDeletedClaim =
activeSessionType.value === SESSION_TYPE_EXPENSE
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
return
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
if (currentMatchesDeletedClaim) {
resetCurrentSessionState()
toast('该草稿单据已删除,相关财务助手会话已清空。')
return
}
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
}
function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return
@@ -1071,31 +1212,6 @@ export default {
messages.value.splice(index, 1, nextMessage)
}
function closeUploadDecisionDialog() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
}
async function continueExistingUpload() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
composerUploadIntent.value = 'continue_existing'
await submitComposer({
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true
})
}
async function createNewUploadDocument() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
composerUploadIntent.value = ''
await submitComposer({
uploadDisposition: 'new_document',
skipUploadDecisionPrompt: true
})
}
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
await switchSessionType(shortcut.targetSessionType)
@@ -1218,6 +1334,9 @@ export default {
}
function switchToReviewOverviewDrawer() {
if (!reviewOverviewDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
}
@@ -1255,20 +1374,98 @@ export default {
function appendReviewRiskBriefToConversation(item) {
if (!item) return
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function appendExpenseQueryRiskToConversation(record, risk) {
if (!record || !risk) return
const claimId = String(record.claimId || '').trim()
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-request-detail',
params: { requestId: claimId }
})
: null
messages.value.push(createMessage(
'assistant',
buildReviewRiskConversationText(
{
title: `${claimNo} ${risk.levelLabel || '风险提示'}${risk.title || '风险提示'}`,
summary: risk.summary,
detail: risk.detail,
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
sourceLabel: risk.levelLabel,
level: risk.level
},
route?.href
? {
href: route.href,
label: `进入 ${claimNo} 详情重新填写`
}
: {}
),
[],
{
meta: [`${claimNo} 风险详情`],
metaTone: risk.level || 'medium'
}
))
nextTick(scrollToBottom)
}
function resolveReviewRiskDetailTarget() {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
linkedRequest.value
].filter(Boolean)
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
if (!claimId) {
return {}
}
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-request-detail',
params: { requestId: claimId }
})
return {
href: route.href,
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
function maybeFinalizeDeferredClose() {
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function requestCloseWorkbench() {
persistSessionState()
closeAfterBusy.value = isWorkbenchBusy()
workbenchVisible.value = false
}
function emitCloseAfterLeave() {
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
@@ -1317,7 +1514,6 @@ export default {
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId,
@@ -1469,6 +1665,12 @@ export default {
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
return
}
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
return
}
@@ -1492,8 +1694,25 @@ export default {
return handleSaveDraftDirectlyInternal(message, actionType)
}
function isDraftSavedReviewMessage(message) {
if (!message?.reviewPayload) {
return false
}
return Boolean(
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|| String(draftClaimId.value || '').trim()
|| String(resolveActiveClaimId() || '').trim()
)
}
function buildReviewPlainFollowupForMessage(message) {
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
savedDraft: isDraftSavedReviewMessage(message)
})
}
function canUseInlineSaveDraft(message) {
if (!message?.reviewPayload || message?.draftPayload?.claim_no) {
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
return false
}
return Boolean(resolveReviewSaveDraftAction(message.reviewPayload))
@@ -1515,16 +1734,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, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, 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, uploadDecisionDialogOpen,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, 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, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
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, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -29,10 +29,11 @@ import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards,
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
normalizeRiskTone,
resolveRiskTags,
resolveRiskTagTone
resolveRiskTags
} from './travelRequestDetailInsights.js'
import {
EXPENSE_TYPE_OPTIONS,
@@ -95,6 +96,26 @@ function normalizeDetailNoteDraftValue(value) {
return isPlaceholderValue(text) ? '' : text
}
function stripRiskTagsForDisplay(value) {
return String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
}
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
function buildTravelTimeLabelMap(items, requestModel) {
const travelItems = items
.map((item, index) => {
@@ -612,13 +633,36 @@ export default {
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const canEditDetailNote = computed(() => isDraftRequest.value)
const stripDetailNoteRiskTags = (value) =>
String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/:\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => {
if (detailNoteSource.value) {
return detailNoteSource.value
return stripDetailNoteRiskTags(detailNoteSource.value)
}
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
const detailNoteEditorView = computed({
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
set: (value) => {
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
}
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
const detailNoteTags = computed(() =>
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
@@ -689,6 +733,11 @@ export default {
return expenseAttachmentMeta[item.id] || null
}
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
}
function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -790,10 +839,6 @@ export default {
}
function resolveExpenseRiskState(item) {
if (!item.invoiceId) {
return null
}
if (uploadingExpenseId.value === item.id) {
return {
label: 'AI识别中',
@@ -818,6 +863,15 @@ export default {
}
}
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
if (claimRiskState) {
return claimRiskState
}
if (!item.invoiceId) {
return null
}
return {
label: '已上传',
tone: 'low',
@@ -843,13 +897,20 @@ export default {
}
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const completionItems = isEditableRequest.value
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
: []
const directRiskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags()
})
const hasActionableRiskCards = directRiskCards.some(
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
)
const riskCards = [
...buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
}),
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
...directRiskCards,
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
]
@@ -859,6 +920,14 @@ export default {
})
})
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
const aiAdviceHint = computed(() => (
isEditableRequest.value
? '按建议顺序补齐信息或处理风险后,再发起审批。'
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
@@ -904,10 +973,6 @@ export default {
}
}
function resolveRiskTagClass(tag) {
return resolveRiskTagTone(tag)
}
function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value
if (!warnings.length) {
@@ -1619,11 +1684,18 @@ export default {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value,
restoreLatestConversation: true
restoreLatestConversation: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
@@ -1632,7 +1704,7 @@ export default {
})
return {
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel,
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
@@ -1646,7 +1718,7 @@ export default {
currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
@@ -1655,12 +1727,12 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}

View File

@@ -8,3 +8,12 @@ export function resolveInitialKnowledgeFolder(folders, currentFolder = '') {
return normalizedFolders[0]?.name || ''
}
export function resolveKnowledgeFolderIcon(folder, activeFolder = '') {
const folderName = String(folder?.name || folder || '').trim()
const normalizedActiveFolder = String(activeFolder || '').trim()
return folderName && folderName === normalizedActiveFolder
? 'mdi mdi-folder-open'
: 'mdi mdi-folder'
}

View File

@@ -6,26 +6,26 @@ import {
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
const SCENARIO_LABELS = {
expense: '??',
accounts_receivable: '??',
accounts_payable: '??',
knowledge: '??',
unknown: '??'
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const INTENT_LABELS = {
query: '??',
explain: '??',
compare: '??',
risk_check: '????',
draft: '????',
operate: '????'
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '信息核对',
operate: '动作请求'
}
function resolveStatusLabel(status) {
if (status === 'succeeded') return '???'
if (status === 'blocked') return '???'
return '??'
if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '已阻断'
return '处理中'
}
function resolveStatusTone(status) {
@@ -123,6 +123,12 @@ function buildAssociationDocumentContentLines(document) {
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
}
function buildAssociationDocumentCard(lines) {
return (Array.isArray(lines) ? lines : [])
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
.join('\n')
}
export function buildAttachmentAssociationConfirmationMessage({
claimNo = '',
claimTitle = '',
@@ -144,13 +150,14 @@ export function buildAttachmentAssociationConfirmationMessage({
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document)
return [
`附件 ${index + 1}${filename}`,
.map((line) => String(line || '').replace(/^-\s*/, ''))
return buildAssociationDocumentCard([
`**附件 ${index + 1}${filename}**`,
'',
`附件类型:${typeLabel}`,
'',
...contentLines
].join('\n')
])
})
return [
@@ -158,14 +165,17 @@ export function buildAttachmentAssociationConfirmationMessage({
'',
documentBlocks.join('\n\n'),
'',
'',
'请问是否确定将票据信息归集到单据:',
'',
targetLines.join('\n'),
'',
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
'',
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
]
.filter((part) => String(part || '').trim())
.join('\n')
.replace(/\n{4,}/g, '\n\n\n')
.trim()
}
export function normalizeReviewDocumentFieldKey(label) {
@@ -235,6 +245,9 @@ export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
document_type_label: resolveDocumentTypeLabel(item?.document_type),
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
scene_label: String(item?.scene_label || '').trim(),
preview_kind: String(item?.preview_kind || '').trim(),
preview_data_url: String(item?.preview_data_url || '').trim(),
preview_url: String(item?.preview_url || '').trim(),
document_fields: fields,
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}
@@ -373,12 +386,32 @@ export function mergeFilePreviews(existingPreviews, incomingPreviews) {
return result
}
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim()
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
}
export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
@@ -389,7 +422,7 @@ export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)

View File

@@ -166,6 +166,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
queryPayload: null,
draftPayload: null,
reviewPayload: null,
reviewPanelScope: '',
riskFlags: [],
pendingAttachmentAssociation: null,
...extras
@@ -299,6 +300,7 @@ export function sanitizeRequest(request) {
if (!request || typeof request !== 'object') return null
const normalized = {
claimId: String(request.claimId || request.claim_id || '').trim(),
id: String(request.id || '').trim(),
typeLabel: String(request.typeLabel || request.category || '').trim(),
reason: String(request.reason || request.title || '').trim(),

View File

@@ -4,6 +4,7 @@ import {
} from './travelReimbursementReviewModel.js'
export const EXPENSE_QUERY_PAGE_SIZE = 5
export const EXPENSE_CENTER_HREF = '/app/requests'
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
const EXPENSE_STATUS_LABELS = {
draft: '草稿',
@@ -14,6 +15,36 @@ const EXPENSE_STATUS_LABELS = {
approved: '已审核',
paid: '已入账'
}
const EXPENSE_RISK_LEVEL_LABELS = {
high: '高风险',
medium: '中风险',
warning: '中风险',
low: '低风险',
info: '低风险'
}
export function normalizeExpenseQueryRiskItem(item, index = 0) {
if (!item || typeof item !== 'object') {
return null
}
const rawLevel = String(item.level || item.severity || '').trim().toLowerCase()
const level = EXPENSE_RISK_LEVEL_LABELS[rawLevel] ? rawLevel : 'medium'
const summary = String(item.summary || item.message || item.content || '').trim()
const detail = String(item.detail || item.description || summary).trim()
if (!summary && !detail) {
return null
}
return {
key: String(item.key || `${level}-${index}`).trim() || `${level}-${index}`,
level,
levelLabel: String(item.level_label || item.levelLabel || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
title: String(item.title || item.label || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
summary: summary || detail,
detail: detail || summary
}
}
export function normalizeExpenseQueryStatusGroup(item) {
if (!item || typeof item !== 'object') {
@@ -57,6 +88,9 @@ export function normalizeExpenseQueryRecord(item) {
occurredAt,
reason,
location: String(item.location || '').trim(),
riskItems: (Array.isArray(item.risk_flags) ? item.risk_flags : [])
.map((riskItem, index) => normalizeExpenseQueryRiskItem(riskItem, index))
.filter(Boolean),
summary: reason || `${expenseTypeLabel}报销`,
dateDisplay: documentDate || occurredAt || '待补充日期'
}
@@ -164,6 +198,7 @@ export function normalizeExpenseQueryPayload(payload) {
const rawRecordCount = Number(payload.record_count || 0)
const rawPreviewCount = Number(payload.preview_count || records.length)
const rawPreviewLimit = Number(payload.preview_limit || EXPENSE_QUERY_PAGE_SIZE)
const rawOlderRecordCount = Number(payload.older_record_count || 0)
const totalAmount = Number(payload.total_amount || 0)
const rawWindowDays = Number(payload.window_days || 0)
@@ -187,6 +222,7 @@ export function normalizeExpenseQueryPayload(payload) {
windowEndDate: windowEndDate || '',
recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0,
previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length,
previewLimit: Number.isFinite(rawPreviewLimit) ? Math.max(1, rawPreviewLimit) : EXPENSE_QUERY_PAGE_SIZE,
olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0,
hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more),
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
@@ -250,18 +286,13 @@ export function buildExpenseQueryHint(queryPayload) {
}
const parts = []
const windowText = buildExpenseQueryWindowLabel(queryPayload)
const previewLimit = Math.max(1, Number(queryPayload.previewLimit || EXPENSE_QUERY_PAGE_SIZE))
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) {
parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`)
}
if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) {
parts.push(`${windowText}${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount}`)
}
if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) {
parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`)
if (totalCount > previewLimit) {
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到报销中心查看`)
} else if (totalCount > 0) {
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入报销中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
}
return parts.join('。')

View File

@@ -30,6 +30,7 @@ export function buildReviewDocumentDrafts(reviewPayload) {
expenseTypeLabel: String(item.expenseTypeLabel || '').trim(),
preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(),
warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
fields: Array.isArray(item.fields)
? item.fields.map((field) => ({

View File

@@ -1260,6 +1260,19 @@ const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
]
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`,
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`,
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`,
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`,
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
]
function buildStableTemplateIndex(signature, total) {
const source = String(signature || '')
let hash = 0
@@ -1269,7 +1282,7 @@ function buildStableTemplateIndex(signature, total) {
return total ? hash % total : 0
}
function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
const issueParts = []
if (pendingCount) {
issueParts.push(`${pendingCount} 项信息待补充`)
@@ -1278,11 +1291,15 @@ function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
issueParts.push(`${riskCount} 条风险提醒`)
}
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
const templateIndex = buildStableTemplateIndex(signature || issueSummary, REVIEW_PENDING_SUMMARY_TEMPLATES.length)
return REVIEW_PENDING_SUMMARY_TEMPLATES[templateIndex]({ issueSummary })
const templates = options.savedDraft
? REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES
: REVIEW_PENDING_SUMMARY_TEMPLATES
const templateIndex = buildStableTemplateIndex(signature || issueSummary, templates.length)
return templates[templateIndex]({ issueSummary })
}
export function buildReviewPlainFollowupCopy(reviewPayload) {
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
const savedDraft = Boolean(options?.savedDraft)
const todoItems = buildReviewTodoItems(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
@@ -1297,7 +1314,9 @@ export function buildReviewPlainFollowupCopy(reviewPayload) {
return {
lead: '补充信息:',
tone: 'danger',
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
savedDraft
}),
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
notes: []
}

View File

@@ -2,6 +2,8 @@ export const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' },
{ value: 'flight_ticket', label: '机票' },
{ value: 'ship_ticket', label: '轮船票' },
{ value: 'ferry_ticket', label: '轮船票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'entertainment', label: '业务招待费' },
@@ -116,6 +118,13 @@ export function resolveExpenseReasonHelper(itemType) {
return '业务报销说明'
}
export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
if (isRouteDescriptionExpenseType(itemType) || isHotelDescriptionExpenseType(itemType)) {
return resolveExpenseReasonHelper(itemType)
}
return resolveLocationDisplay(itemLocation, itemType)
}
export function buildFallbackProgressSteps() {
return [
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
@@ -345,7 +354,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType),
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
amount: amountDisplay,
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',

View File

@@ -1,3 +1,9 @@
import {
isActionableRiskFlag,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
@@ -34,6 +40,62 @@ export function normalizeRiskTone(value) {
return normalizeTone(value)
}
function resolveFlagTone(flag) {
return normalizeRiskFlagTone(flag)
}
function isRiskTone(tone) {
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
}
function normalizeId(value) {
return normalizeText(value)
}
function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) {
return null
}
return claimRiskFlags.find((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
if (!isActionableRiskFlag(flag)) {
return false
}
const flagItemId = normalizeId(flag.item_id || flag.itemId)
const tone = resolveFlagTone(flag)
return flagItemId === itemId && isRiskTone(tone)
}) || null
}
export function buildItemClaimRiskState(item, claimRiskFlags = []) {
const flag = resolveItemRiskFlag(item, claimRiskFlags)
if (!flag) {
return null
}
const tone = resolveFlagTone(flag)
const label = normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险')
const points = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const summary = normalizeText(flag.summary || flag.message || flag.reason)
return {
label,
tone,
headline: normalizeText(flag.headline || flag.title) || label,
summary,
points,
suggestion: normalizeText(flag.suggestion) || '如业务确需提交,请在附加说明中补充特殊情况原因后继续提交。'
}
}
export function resolveRiskTagTone(tag) {
const normalized = normalizeText(tag).toLowerCase()
if (normalized === '#high_risk') return 'high'
@@ -99,6 +161,68 @@ function normalizeRuleBasis(value) {
return text ? [text] : []
}
function resolveClaimRiskRuleBasis(flag = {}, { risk = '', summary = '', tone = 'medium' } = {}) {
const explicitBasis = normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
if (explicitBasis.length) {
return explicitBasis
}
const source = normalizeText(flag.source)
const label = normalizeText(flag.label || flag.title || flag.name)
const corpus = [risk, summary, label].map((item) => normalizeText(item)).join(' ')
const basis = []
if (/高风险|中风险/.test(corpus)) {
basis.push(`风险文本已明确标记为${tone === 'high' ? '高风险' : '中风险'}`)
}
if (source === 'attachment_analysis' || /附件|票据|OCR|识别|发票/.test(corpus)) {
basis.push('附件识别或票据核验未完全通过,系统将该项同步为单据风险。')
}
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
basis.push('审批链校验未匹配到完整审批人信息,因此按中风险提醒。')
}
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
basis.push('金额或标准核算命中制度阈值,需要补充说明或人工复核。')
}
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
basis.push('历史报销风险次数达到预警条件,系统提示审批人重点关注。')
}
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
basis.push('单据、附件或审批信息存在缺失、不一致或待补充项。')
}
if (summary) {
basis.push(`风险汇总:${summary}`)
}
return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}`])
}
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
const explicitSuggestion = normalizeText(flag.suggestion)
if (explicitSuggestion) {
return explicitSuggestion
}
const corpus = [risk, summary, flag.label, flag.title].map((item) => normalizeText(item)).join(' ')
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
return '请先核对员工档案中的直属领导和审批链配置;如果信息无误,可由审批环节人工补充分配说明。'
}
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
return '请核对金额、天数、地点和职级标准;如确需超标,请在附加说明中写清楚业务原因和佐证材料。'
}
if (/附件|票据|OCR|识别|发票/.test(corpus)) {
return '请打开对应费用明细的附件预览,核对票据类型、金额、日期和说明;识别有误时先修正明细或重新上传附件。'
}
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
return '请核对近期同类报销记录,必要时补充本次费用与历史单据不同的业务背景。'
}
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
return '请按风险点补齐缺失信息,并核对费用明细与附件内容是否一致。'
}
return '请先核对上方触发原因;如属于真实业务例外,在附加说明中写清楚原因和佐证后再继续流转。'
}
export function buildAttachmentInsightViewModel(metadata, item = {}) {
if (!metadata) {
return null
@@ -245,6 +369,7 @@ export function buildAttachmentRiskCards({
attachmentMetaByItemId = {},
claimRiskFlags = []
} = {}) {
const attachmentRiskItemIds = new Set()
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
return []
@@ -257,6 +382,10 @@ export function buildAttachmentRiskCards({
if (!analysis || !['medium', 'high'].includes(tone)) {
return []
}
const itemId = normalizeId(item.id)
if (itemId) {
attachmentRiskItemIds.add(itemId)
}
const points = Array.isArray(analysis.points) && analysis.points.length
? analysis.points
@@ -276,6 +405,10 @@ export function buildAttachmentRiskCards({
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
return []
}
const risk = normalizeText(flag)
return risk
? [withRiskTags({
@@ -285,29 +418,41 @@ export function buildAttachmentRiskCards({
title: '单据风险提示',
risk,
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
ruleBasis: resolveClaimRiskRuleBasis({}, { risk, tone: 'medium' }),
suggestion: resolveClaimRiskSuggestion({}, { risk })
})]
: []
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
if (!isActionableRiskFlag(flag)) {
return []
}
const source = normalizeText(flag.source)
const flagItemId = normalizeId(flag.item_id || flag.itemId)
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
return []
}
const tone = resolveFlagTone(flag)
if (!isRiskTone(tone)) {
return []
}
const flagPoints = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const primaryRisk = normalizeText(flag.message || flag.reason || flag.summary)
const fallbackRisk = normalizeText(flag.description || flag.detail || flag.title || flag.label || flag.name)
const risks = flagPoints.length
? flagPoints
: [normalizeText(flag.message || flag.reason || flag.summary)].filter(Boolean)
const summary = normalizeText(flag.summary)
const ruleBasis = uniqueTexts([
...normalizeRuleBasis(flag.rule_basis || flag.ruleBasis),
summary ? `风险汇总:${summary}` : '',
'系统预审规则命中该风险提示。'
])
: [primaryRisk || fallbackRisk].filter(Boolean)
const summary = normalizeText(flag.summary || flag.message || flag.reason)
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
risk: risks[0] || primaryRisk || fallbackRisk,
summary,
tone
})
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
@@ -317,7 +462,7 @@ export function buildAttachmentRiskCards({
risk,
summary,
ruleBasis,
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
}))
})
.filter(Boolean)
@@ -328,6 +473,52 @@ export function buildAttachmentRiskCards({
return [...attachmentCards, ...claimCards]
}
function isNoRiskSummary(value) {
return !isRiskSummaryWithRisk(value)
}
function resolveClaimSummaryTone(request) {
const explicitTone = normalizeText(request?.riskTone || request?.risk_tone || request?.severity)
if (explicitTone) {
return normalizeTone(explicitTone)
}
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
if (/高风险|重大风险|严重|超标|违规/.test(summary)) {
return 'high'
}
if (/中风险|待关注|待复核|提醒|异常|风险/.test(summary)) {
return 'medium'
}
return 'medium'
}
export function buildClaimSummaryRiskCards(request = {}) {
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
if (isNoRiskSummary(summary)) {
return []
}
const tone = resolveClaimSummaryTone(request)
if (!isRiskTone(tone)) {
return []
}
return [withRiskTags({
id: 'claim-risk-summary',
tone,
label: tone === 'high' ? '高风险' : '中风险',
title: '单据风险提示',
risk: summary,
summary,
ruleBasis: resolveClaimRiskRuleBasis(
{ source: 'risk_summary', message: summary, severity: tone },
{ risk: summary, summary, tone }
),
suggestion: resolveClaimRiskSuggestion({ source: 'risk_summary' }, { risk: summary, summary })
})]
}
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)

View File

@@ -1,5 +1,171 @@
import { computed, nextTick, ref } from 'vue'
const COMMON_DESTINATION_PREFIXES = [
'上海',
'北京',
'广州',
'深圳',
'杭州',
'南京',
'苏州',
'成都',
'重庆',
'武汉',
'西安',
'天津',
'宁波',
'青岛',
'长沙',
'郑州',
'济南',
'合肥',
'福州',
'厦门',
'昆明',
'南昌',
'沈阳',
'大连',
'无锡',
'佛山',
'东莞'
]
const CHINESE_DAY_NUMBERS = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10
}
function normalizeComposerText(value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
function parseDayCount(value) {
const text = String(value || '').trim()
const numericValue = Number.parseInt(text, 10)
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue
}
if (text === '十') {
return 10
}
if (/^十[一二三四五六七八九]$/.test(text)) {
return 10 + (CHINESE_DAY_NUMBERS[text.slice(1)] || 0)
}
if (/^[一二两三四五六七八九]十$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10
}
if (/^[一二两三四五六七八九]十[一二三四五六七八九]$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10 + (CHINESE_DAY_NUMBERS[text.slice(2)] || 0)
}
return CHINESE_DAY_NUMBERS[text] || 0
}
function calculateBusinessDays(businessTimeContext) {
const startDate = String(businessTimeContext?.start_date || '').trim()
const endDate = String(businessTimeContext?.end_date || startDate).trim()
if (!startDate || !endDate || startDate > endDate) {
return 0
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 0
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function stripBusinessTimePrefix(text) {
return normalizeComposerText(text)
.replace(/^(?:业务)?发生时间[:]\s*[^,。\n]+(?:至\s*[^,。\n]+)?[,。\s]*/u, '')
.trim()
}
function resolveDestinationFromText(text) {
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
const targetMatch = normalized.match(/(?:去|到|赴|前往)([^,。;;]+)/u)
const targetText = String(targetMatch?.[1] || '').trim()
if (!targetText) {
return ''
}
const knownDestination = COMMON_DESTINATION_PREFIXES.find((item) => targetText.startsWith(item))
if (knownDestination) {
return knownDestination
}
const verbIndex = targetText.search(/支撑|支持|部署|实施|驻场|出差|拜访|处理|办理|参加|进行|协助|服务器|项目/u)
if (verbIndex > 0) {
return targetText.slice(0, verbIndex)
}
return targetText.slice(0, 12)
}
function resolveTripDaysFromText(text, businessTimeContext) {
const dayMatch = normalizeComposerText(text).match(/(?:出差|共|总计)?\s*([0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u)
const explicitDays = parseDayCount(dayMatch?.[1])
return explicitDays || calculateBusinessDays(businessTimeContext)
}
function resolveReasonFromText(text, destination) {
let reason = normalizeComposerText(text)
.replace(/^(?:去|到|赴|前往)\s*/u, '')
.trim()
if (destination && reason.startsWith(destination)) {
reason = reason.slice(destination.length).trim()
}
return reason
.replace(/[,。\s]*(?:出差|共|总计)?\s*(?:[0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u, '')
.replace(/[,。\s]*(?:申请|发起|办理)?(?:差旅费|差旅|费用)?报销(?:申请)?[。.!]?$/u, '')
.replace(/^[,。;;\s]+|[,。;;\s]+$/gu, '')
.trim()
}
export function buildStructuredComposerSubmitText(rawText, businessTimeContext = null) {
const normalizedText = normalizeComposerText(rawText)
const timeDisplay = String(
businessTimeContext?.business_time ||
businessTimeContext?.time_range ||
businessTimeContext?.display_value ||
''
).trim()
if (!timeDisplay || !normalizedText) {
return normalizedText
}
const bodyText = stripBusinessTimePrefix(normalizedText)
if (!bodyText) {
return `发生时间:${timeDisplay}`
}
const destination = resolveDestinationFromText(bodyText)
const reason = resolveReasonFromText(bodyText, destination)
const days = resolveTripDaysFromText(bodyText, businessTimeContext)
const lines = [`发生时间:${timeDisplay}`]
if (destination) {
lines.push(`地点:${destination}`)
}
if (reason) {
lines.push(`事由:${reason}`)
}
if (days > 0 && (days > 1 || /出差|差旅|至/.test(timeDisplay) || /出差|差旅/.test(bodyText))) {
lines.push(`天数:${days}`)
}
return lines.join('\n')
}
export function useTravelReimbursementComposerTools({
currentUser,
activeReviewPayload,
@@ -51,12 +217,12 @@ export function useTravelReimbursementComposerTools({
)
function buildComposerBusinessTimeLabel() {
if (composerDateMode.value === 'single') {
return `业务发生时间:${composerSingleDate.value}`
return `发生时间${composerSingleDate.value}`
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `业务发生时间:${composerRangeStartDate.value}`
return `发生时间${composerRangeStartDate.value}`
}
return `业务发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
return `发生时间${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
@@ -156,6 +322,14 @@ export function useTravelReimbursementComposerTools({
return `${tagPart}${draftPart}`
}
function resolveComposerDisplaySubmitText(rawText) {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return String(rawText || '').trim()
}
return buildStructuredComposerSubmitText(rawText, businessTimeContext)
}
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
@@ -377,6 +551,7 @@ export function useTravelReimbursementComposerTools({
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,

View File

@@ -26,6 +26,7 @@ import {
export function useTravelReimbursementReviewDrawer({
activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps,
submitting,
@@ -92,6 +93,11 @@ export function useTravelReimbursementReviewDrawer({
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
const normalizedReviewPanelScope = computed(() => {
const scope = String(activeReviewPanelScope?.value || '').trim()
return ['overview', 'documents', 'risk'].includes(scope) ? scope : ''
})
const reviewOverviewDrawerAvailable = computed(() => normalizedReviewPanelScope.value === 'overview')
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
@@ -135,22 +141,31 @@ export function useTravelReimbursementReviewDrawer({
: 'mdi mdi-timeline-clock-outline'
))
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
const activeReviewDocumentPreview = computed(() =>
activeReviewDocument.value
? (
resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
|| (
activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url
? {
filename: activeReviewDocument.value.filename,
kind: activeReviewDocument.value.preview_kind,
url: activeReviewDocument.value.preview_data_url
}
: null
)
)
: null
)
const activeReviewDocumentPreview = computed(() => {
const document = activeReviewDocument.value
if (!document) return null
const matchedPreview = resolveDocumentPreview(activeReviewFilePreviews.value, document.filename)
if (matchedPreview?.url) {
return matchedPreview
}
const inlineUrl = String(document.preview_url || document.preview_data_url || '').trim()
if (!inlineUrl) {
return null
}
const explicitKind = String(document.preview_kind || '').trim()
const inferredKind = inlineUrl.startsWith('data:image/') ? 'image' : explicitKind
if (inferredKind !== 'image') {
return null
}
return {
filename: document.filename,
kind: 'image',
url: inlineUrl
}
})
const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
const reviewDocumentDirty = computed(() => {
const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue))
@@ -170,9 +185,22 @@ export function useTravelReimbursementReviewDrawer({
activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
? REVIEW_DRAWER_MODE_RISK
: REVIEW_DRAWER_MODE_REVIEW
const hasDocuments = nextDocumentDrafts.length > 0
const hasRisks = resolveReviewRiskBriefs(payload).length > 0
const scope = normalizedReviewPanelScope.value
if (scope === 'documents' && hasDocuments) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
} else if (scope === 'risk' && hasRisks) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
} else if (scope === 'overview') {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
} else if (hasDocuments) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
} else if (hasRisks) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
} else {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {}
@@ -348,14 +376,27 @@ export function useTravelReimbursementReviewDrawer({
}
function enforceReviewDrawerAvailability() {
if (!reviewOverviewDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) {
if (reviewDocumentDrawerAvailable.value) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
} else if (reviewRiskDrawerAvailable.value) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
}
}
if (!reviewDocumentDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_RISK
}
if (!reviewRiskDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_DOCUMENTS
}
if (!reviewFlowDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
? REVIEW_DRAWER_MODE_REVIEW
: (reviewDocumentDrawerAvailable.value ? REVIEW_DRAWER_MODE_DOCUMENTS : REVIEW_DRAWER_MODE_RISK)
}
}
@@ -390,6 +431,7 @@ export function useTravelReimbursementReviewDrawer({
reviewRecognitionNotes,
reviewDocumentSummaries,
reviewDocumentCount,
reviewOverviewDrawerAvailable,
isReviewDocumentDrawer,
isReviewRiskDrawer,
isReviewFlowDrawer,

View File

@@ -29,7 +29,6 @@ export function useTravelReimbursementSessionState({
linkedRequest,
toast,
composerDraft,
uploadDecisionDialogOpen,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs = () => ({})
@@ -122,6 +121,7 @@ export function useTravelReimbursementSessionState({
}
const initialSessionType = resolveInitialSessionType(props.initialConversation)
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
const conversationInitialState = props.initialConversation
? buildConversationSessionState(props.initialConversation, initialSessionType)
: buildEmptySessionState(initialSessionType)
@@ -172,6 +172,10 @@ export function useTravelReimbursementSessionState({
}
function persistSessionState(sessionState = null) {
if (!shouldPersistLocalSnapshot) {
return
}
const state = sessionState || captureCurrentSessionState()
const persistedState = buildPersistableSessionState(state)
const meaningful = Boolean(
@@ -240,7 +244,6 @@ export function useTravelReimbursementSessionState({
}
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
uploadDecisionDialogOpen.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()

View File

@@ -61,6 +61,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
@@ -76,7 +77,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
uploadDecisionDialogOpen,
toast
} = ctx
@@ -109,6 +109,33 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
}
function resolveReviewPanelScope({
reviewPayload = null,
reviewAction = '',
fileCount = 0,
rawText = ''
} = {}) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return ''
}
const normalizedAction = String(reviewAction || '').trim()
const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0
const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0
const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || ''))
if (fileCount > 0 && documentCount > 0) {
return 'documents'
}
if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) {
return 'risk'
}
if (!normalizedAction && fileCount === 0) {
return 'overview'
}
return ''
}
async function confirmPendingAttachmentAssociation(message) {
if (submitting.value || sessionSwitchBusy.value) return null
@@ -137,7 +164,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
skipDraftAssociationPrompt: true,
pendingText: runtime.claimNo
? `正在将票据归集到草稿 ${runtime.claimNo}...`
@@ -189,26 +215,57 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return parts.join('\n')
}
function resolveDetailScopedClaimId() {
if (props.entrySource !== 'detail' || isKnowledgeSession.value) {
return ''
}
return String(
linkedRequest.value?.claimId ||
linkedRequest.value?.claim_id ||
''
).trim()
}
async function submitComposer(options = {}) {
if (submitting.value || sessionSwitchBusy.value) return null
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '')
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files
const detailScopedClaimId = resolveDetailScopedClaimId()
const detailScopedUpload = Boolean(detailScopedClaimId && files.length)
if (detailScopedClaimId) {
draftClaimId.value = detailScopedClaimId
}
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') ||
(detailScopedUpload ? 'continue_existing' : '')
if (fileMergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (!rawText && !files.length) return
const fileNames = files.map((file) => file.name)
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
const optionExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const detailScopedClaimNo = String(
linkedRequest.value?.documentNo ||
linkedRequest.value?.id ||
''
).trim()
const initialExtraContext = detailScopedClaimId
? {
...optionExtraContext,
draft_claim_id: detailScopedClaimId,
selected_claim_id: detailScopedClaimId,
selected_claim_no: detailScopedClaimNo,
detail_scope_claim_id: detailScopedClaimId
}
: optionExtraContext
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value
? initialExtraContext
@@ -217,7 +274,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
reviewAction === 'link_to_existing_draft'
reviewAction === 'link_to_existing_draft' ||
detailScopedUpload
)
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
@@ -238,10 +296,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
hasSelectedExpenseType
})
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent =
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
const userText =
String(options.userText || '').trim() ||
resolveComposerDisplaySubmitText(rawText) ||
rawText ||
(isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
@@ -254,19 +311,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
if (
!isKnowledgeSession.value &&
files.length &&
hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipUploadDecisionPrompt &&
!reviewAction
) {
uploadDecisionDialogOpen.value = true
return null
}
if (
!isKnowledgeSession.value &&
files.length &&
!hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
@@ -300,7 +344,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
} catch (error) {
console.warn('Failed to load draft claims before attachment recognition:', error)
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
'我暂时没能查询到可关联的草稿/待补单据,所以先不识别这批附件。请稍后重试,或从对应草稿进入后继续上传票据。',
[],
{
meta: ['单据查询失败']
}
))
nextTick(scrollToBottom)
persistSessionState()
toast(error?.message || '查询可关联草稿失败,请稍后重试。')
return null
}
}
@@ -602,14 +661,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
reviewPanelScope: resolveReviewPanelScope({
reviewPayload: payload?.result?.review_payload || null,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
replaceMessage(pendingMessage.id, assistantMessage)
currentInsight.value = buildAgentInsight(
const nextInsight = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
if (nextInsight.agent) {
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
}
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
persistSessionState()
nextTick(scrollToBottom)