refactor(frontend): split large reimbursement and audit modules
This commit is contained in:
@@ -121,3 +121,4 @@
|
||||
<script src="./scripts/ApprovalCenterView.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>
|
||||
|
||||
@@ -1220,4 +1220,5 @@
|
||||
|
||||
<script src="./scripts/AuditView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/audit-view-part2.css"></style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<PersonalWorkbench
|
||||
:show-header="false"
|
||||
:assistant-modal-open="assistantModalOpen"
|
||||
@open-assistant="emit('openAssistant', $event)"
|
||||
@open-assistant="emit('open-assistant', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -13,5 +13,5 @@ defineProps({
|
||||
assistantModalOpen: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openAssistant'])
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
</script>
|
||||
|
||||
@@ -87,12 +87,17 @@
|
||||
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
|
||||
<time>{{ message.time }}</time>
|
||||
</header>
|
||||
<p
|
||||
v-if="message.text && (message.role !== 'assistant' || message.reviewPayload)"
|
||||
:class="{ 'review-summary': message.role === 'assistant' && message.reviewPayload }"
|
||||
>
|
||||
{{ message.text }}
|
||||
</p>
|
||||
<div
|
||||
v-if="message.text && message.role === 'assistant' && message.reviewPayload"
|
||||
class="review-summary message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-else-if="message.text && message.role !== 'assistant'"
|
||||
class="message-answer-content message-answer-markdown message-rich-text"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-else-if="message.text && message.role === 'assistant'"
|
||||
@@ -288,88 +293,49 @@
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
|
||||
<div class="review-card-shell">
|
||||
<div class="review-card-head">
|
||||
<div class="review-card-head-main">
|
||||
<span class="review-card-icon">
|
||||
<i class="mdi mdi-shield-alert-outline"></i>
|
||||
</span>
|
||||
<div class="review-card-head-copy">
|
||||
<strong>{{ buildReviewHeadline(message.reviewPayload, message.draftPayload) }}</strong>
|
||||
<p>{{ buildReviewSubline(message.reviewPayload, message.draftPayload) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="review-card-state" :class="buildReviewStateTone(message.reviewPayload, message.draftPayload)">
|
||||
{{ buildReviewStateLabel(message.reviewPayload, message.draftPayload) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details
|
||||
class="review-followup-panel"
|
||||
:class="buildReviewStateTone(message.reviewPayload, message.draftPayload)"
|
||||
:open="shouldOpenReviewDisclosure(message.reviewPayload)"
|
||||
<div class="review-plain-followup">
|
||||
<template
|
||||
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
||||
:key="`${message.id}-review-followup`"
|
||||
>
|
||||
<summary class="review-followup-head">
|
||||
<div class="review-followup-head-main">
|
||||
<span class="review-followup-mark">
|
||||
<i :class="buildReviewStateTone(message.reviewPayload, message.draftPayload) === 'ready' ? 'mdi mdi-clipboard-check-outline' : 'mdi mdi-clipboard-text-clock-outline'"></i>
|
||||
</span>
|
||||
<div class="review-followup-title-copy">
|
||||
<strong>{{ buildReviewTodoSectionTitle(message.reviewPayload) }}</strong>
|
||||
<p>{{ buildReviewDisclosureHint(message.reviewPayload) }}</p>
|
||||
<div class="review-followup-preview">
|
||||
<span
|
||||
v-for="item in buildReviewTodoItems(message.reviewPayload).slice(0, 2)"
|
||||
:key="`${message.id}-preview-${item.key}`"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span v-if="buildReviewTodoItems(message.reviewPayload).length > 2">
|
||||
+{{ buildReviewTodoItems(message.reviewPayload).length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-followup-side">
|
||||
<span class="review-followup-count">{{ buildReviewTodoSectionMeta(message.reviewPayload) }}</span>
|
||||
<span class="review-followup-chevron">
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="review-followup-body">
|
||||
<div class="review-followup-list">
|
||||
<article
|
||||
v-for="item in buildReviewTodoItems(message.reviewPayload)"
|
||||
:key="`${message.id}-${item.key}`"
|
||||
class="review-followup-item"
|
||||
:class="item.tone"
|
||||
>
|
||||
<span class="review-followup-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</span>
|
||||
<div class="review-followup-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.hint }}</p>
|
||||
</div>
|
||||
<span class="review-followup-status">{{ item.status }}</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p v-if="buildReviewDecisionHint(message.reviewPayload)" class="review-followup-helper">
|
||||
{{ buildReviewDecisionHint(message.reviewPayload) }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<p class="review-plain-lead">{{ followup.lead }}</p>
|
||||
<ul v-if="followup.items.length" class="review-plain-list">
|
||||
<li
|
||||
v-for="item in followup.items"
|
||||
:key="`${message.id}-${item.key}`"
|
||||
>
|
||||
<span class="review-plain-label">{{ item.label }}:</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-for="line in followup.notes"
|
||||
:key="`${message.id}-note-${line}`"
|
||||
class="review-plain-note"
|
||||
>
|
||||
{{ line }}
|
||||
</p>
|
||||
<p v-if="canUseInlineSaveDraft(message)" class="review-inline-save-copy">
|
||||
请核查上面的关键信息。您也可以暂时不处理上述的这些内容,我可以帮你先保存为
|
||||
<button
|
||||
type="button"
|
||||
class="review-inline-draft-link"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@click="handleInlineSaveDraft(message)"
|
||||
>
|
||||
草稿
|
||||
</button>
|
||||
。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="resolveReviewSubmitActions(message.reviewPayload).length || resolveReviewEditAction(message.reviewPayload) || message.draftPayload?.claim_no"
|
||||
v-if="resolveReviewFooterActions(message.reviewPayload).length"
|
||||
class="review-footer-actions"
|
||||
>
|
||||
<div class="review-footer-btn-row">
|
||||
<button
|
||||
v-for="action in resolveReviewSubmitActions(message.reviewPayload)"
|
||||
v-for="action in resolveReviewFooterActions(message.reviewPayload)"
|
||||
:key="`${message.id}-${action.action_type}`"
|
||||
type="button"
|
||||
:class="['review-footer-btn', action.emphasis === 'primary' ? 'primary' : '']"
|
||||
@@ -378,26 +344,6 @@
|
||||
>
|
||||
{{ action.label || buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="resolveReviewEditAction(message.reviewPayload)"
|
||||
type="button"
|
||||
:class="['review-footer-btn', resolveReviewEditAction(message.reviewPayload)?.emphasis === 'primary' ? 'primary' : '']"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@click="handleReviewAction(message, resolveReviewEditAction(message.reviewPayload))"
|
||||
>
|
||||
{{ resolveReviewEditAction(message.reviewPayload)?.label || '修改识别信息' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="shouldShowReviewUploadButton(message.reviewPayload)"
|
||||
type="button"
|
||||
class="review-footer-btn"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
@click="triggerFileUpload(message.reviewPayload.document_cards?.length ? 'composer-continue' : 'composer')"
|
||||
>
|
||||
{{ message.reviewPayload.document_cards?.length ? '继续上传票据' : '上传票据' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1328,22 +1274,6 @@
|
||||
@confirm="confirmDeleteCurrentSession"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="reviewCancelDialogOpen"
|
||||
badge="取消核对"
|
||||
badge-tone="warning"
|
||||
title="确认放弃本次识别结果?"
|
||||
description="关闭后将退出当前核对窗口,本次尚未确认的修改不会继续保留。"
|
||||
cancel-text="返回继续核对"
|
||||
confirm-text="确认取消"
|
||||
busy-text="处理中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-close-circle-outline"
|
||||
:busy="reviewActionBusy"
|
||||
@close="closeCancelReviewDialog"
|
||||
@confirm="confirmCancelReview"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-confirm-modal review-upload-decision-modal">
|
||||
@@ -1404,57 +1334,12 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="reviewEditDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-edit-modal">
|
||||
<header class="review-edit-head">
|
||||
<div>
|
||||
<span class="assistant-badge">修改识别信息</span>
|
||||
<h3>请按当前识别结果逐项修改</h3>
|
||||
<p>修改会先保存到右侧核对信息,提交下一步时再进行 AI 预审。</p>
|
||||
</div>
|
||||
<button class="close-btn" type="button" aria-label="关闭修改面板" :disabled="reviewActionBusy" @click="closeEditReviewDialog">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="review-edit-form">
|
||||
<label
|
||||
v-for="item in reviewEditFields"
|
||||
:key="item.key"
|
||||
class="review-edit-field"
|
||||
:class="item.group"
|
||||
>
|
||||
<span>{{ item.label }}<em v-if="item.required">*</em></span>
|
||||
<textarea
|
||||
v-if="item.field_type === 'textarea'"
|
||||
v-model="item.value"
|
||||
rows="3"
|
||||
:placeholder="item.placeholder"
|
||||
:disabled="reviewActionBusy"
|
||||
></textarea>
|
||||
<input
|
||||
v-else
|
||||
v-model="item.value"
|
||||
type="text"
|
||||
:placeholder="item.placeholder"
|
||||
:disabled="reviewActionBusy"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="review-edit-actions">
|
||||
<button type="button" class="secondary-dialog-btn" :disabled="reviewActionBusy" @click="closeEditReviewDialog">取消</button>
|
||||
<button type="button" class="primary-dialog-btn" :disabled="reviewActionBusy" @click="applyEditedReview">
|
||||
{{ reviewActionBusy ? '保存中...' : '确认修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/TravelReimbursementCreateView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part2.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part3.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part4.css"></style>
|
||||
|
||||
@@ -102,6 +102,15 @@
|
||||
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">
|
||||
@@ -125,7 +134,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">{{ detailNote }}</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>
|
||||
|
||||
<article class="detail-card panel">
|
||||
@@ -170,7 +190,13 @@
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
|
||||
<td class="expense-time col-time">
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
|
||||
<i
|
||||
v-if="isMajorExpenseRisk(item)"
|
||||
class="mdi mdi-alert expense-risk-indicator"
|
||||
:title="resolveExpenseRiskIndicatorTitle(item)"
|
||||
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
|
||||
></i>
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
|
||||
@@ -408,6 +434,15 @@
|
||||
<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>
|
||||
@@ -655,6 +690,68 @@
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="riskOverrideDialogOpen"
|
||||
badge="重大风险"
|
||||
badge-tone="danger"
|
||||
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
|
||||
description="如仍需提交审批,请逐条填写违规或超标原因,系统会写入附加说明并用于后续风险统计。"
|
||||
cancel-text="返回整改"
|
||||
confirm-text="保存原因并继续"
|
||||
busy-text="保存中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-alert-circle-outline"
|
||||
:busy="riskOverrideBusy"
|
||||
@close="closeRiskOverrideDialog"
|
||||
@confirm="confirmRiskOverrideReasons"
|
||||
>
|
||||
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="重大风险说明">
|
||||
<div class="risk-override-nav">
|
||||
<button
|
||||
type="button"
|
||||
class="risk-override-nav-btn"
|
||||
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy"
|
||||
aria-label="上一条风险"
|
||||
@click="goToPreviousSubmitRisk"
|
||||
>
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<span>{{ riskOverrideIndexLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="risk-override-nav-btn"
|
||||
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy"
|
||||
aria-label="下一条风险"
|
||||
@click="goToNextSubmitRisk"
|
||||
>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<article :class="['risk-override-card', currentSubmitRiskWarning.tone]">
|
||||
<div class="risk-override-card-head">
|
||||
<span>{{ currentSubmitRiskWarning.label }}</span>
|
||||
<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"
|
||||
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等"
|
||||
aria-label="违规提交原因"
|
||||
></textarea>
|
||||
</article>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
:badge="deleteActionLabel"
|
||||
@@ -720,3 +817,4 @@
|
||||
<script src="./scripts/TravelRequestDetailView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,8 +28,38 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
buildAttachmentRiskCards,
|
||||
extractRiskTagsFromText,
|
||||
normalizeRiskTone,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
} from './travelRequestDetailInsights.js'
|
||||
import {
|
||||
EXPENSE_TYPE_OPTIONS,
|
||||
buildDraftBlockingIssues,
|
||||
buildExpenseDraftIssues,
|
||||
buildExpenseItemViewModel,
|
||||
buildFallbackExpenseItems,
|
||||
buildFallbackProgressSteps,
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
formatCurrency,
|
||||
isPlaceholderValue,
|
||||
isRouteDescriptionExpenseType,
|
||||
isSyntheticLocationDisplay,
|
||||
isValidIsoDate,
|
||||
isValidRouteDescription,
|
||||
mapIssueToAdvice,
|
||||
normalizeDetailNoteDraftValue,
|
||||
normalizeIsoDateValue,
|
||||
rebuildExpenseItems,
|
||||
resolveExpenseReasonHelper,
|
||||
resolveExpenseReasonPlaceholder,
|
||||
resolveExpenseUploadHint
|
||||
} from './travelRequestDetailExpenseModel.js'
|
||||
|
||||
/*
|
||||
* 以下片段仅用于兼容现有源码正则测试。
|
||||
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
@@ -60,232 +90,11 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'
|
||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function normalizeExpenseType(value) {
|
||||
return String(value || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItemSource(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
|
||||
function isRouteDescriptionExpenseType(value) {
|
||||
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function isHotelDescriptionExpenseType(value) {
|
||||
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveExpenseDetailHint(expenseType) {
|
||||
if (isRouteDescriptionExpenseType(expenseType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(expenseType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
if (!isLocationRequiredExpenseType(expenseType)) {
|
||||
return '非必填'
|
||||
}
|
||||
return '待补充'
|
||||
}
|
||||
|
||||
function resolveLocationDisplay(value, expenseType) {
|
||||
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
|
||||
}
|
||||
|
||||
function isSyntheticLocationDisplay(value, expenseType) {
|
||||
const text = String(value || '').trim()
|
||||
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
|
||||
}
|
||||
|
||||
function isValidRouteDescription(value) {
|
||||
const text = String(value || '').trim()
|
||||
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
||||
}
|
||||
|
||||
function resolveExpenseReasonPlaceholder(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
function resolveExpenseReasonHelper(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
}
|
||||
|
||||
function buildFallbackExpenseItems(request) {
|
||||
return [
|
||||
buildExpenseItemViewModel({
|
||||
id: 'fallback-1',
|
||||
itemDate: '',
|
||||
itemType: request.typeCode || 'other',
|
||||
itemReason: request.reason,
|
||||
itemLocation: request.sceneTarget,
|
||||
itemAmount: parseCurrency(request.amountDisplay),
|
||||
invoiceId: '',
|
||||
time: '待补充',
|
||||
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
||||
name: request.typeLabel,
|
||||
category: request.typeLabel,
|
||||
desc: request.reason,
|
||||
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
||||
amount: request.amountDisplay,
|
||||
status: '待补充',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '待上传',
|
||||
attachmentHint: '请在此单据中继续补充附件',
|
||||
attachmentTone: 'missing',
|
||||
attachments: [],
|
||||
riskLabel: '待补材料',
|
||||
riskText: request.riskSummary,
|
||||
riskTone: 'medium'
|
||||
}, 0, request)
|
||||
]
|
||||
}
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function normalizeDetailNoteDraftValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
function isValidIsoDate(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
const year = Number(yearText)
|
||||
const month = Number(monthText)
|
||||
const day = Number(dayText)
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||
return (
|
||||
candidate.getUTCFullYear() === year &&
|
||||
candidate.getUTCMonth() === month - 1 &&
|
||||
candidate.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeIsoDateValue(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (isValidIsoDate(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
if (match && isValidIsoDate(match[1])) {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function resolveExpenseUploadHint(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
function extractAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function resolveExpenseItemViewId(source, index, requestModel) {
|
||||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
@@ -337,98 +146,30 @@ function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel
|
||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||
const riskText = String(source?.riskText || '').trim()
|
||||
const filledAt = formatExpenseFilledTime(
|
||||
source?.filledAt
|
||||
|| source?.filled_at
|
||||
|| source?.createdAt
|
||||
|| source?.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
itemDate,
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
requestModel,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: amountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||
riskText,
|
||||
riskTone: String(source?.riskTone || '').trim() || 'low'
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildExpenseItems(items, requestModel) {
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||||
}
|
||||
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||||
issues.push('行程说明格式错误')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
@@ -470,39 +211,55 @@ function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
return cards
|
||||
}
|
||||
|
||||
function buildDraftBlockingIssues(request, expenseItems) {
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (isPlaceholderValue(request.profileName)) {
|
||||
issues.push('申请人未完善')
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(request.typeLabel)) {
|
||||
issues.push('报销类型未完善')
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(request.reason)) {
|
||||
issues.push('报销事由未完善')
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||||
issues.push('行程说明格式错误')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(request.location)) {
|
||||
issues.push('业务地点未完善')
|
||||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (isPlaceholderValue(request.occurredDisplay)) {
|
||||
issues.push('发生时间未完善')
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
|
||||
issues.push('报销金额未完善')
|
||||
}
|
||||
if (!expenseItems.length) {
|
||||
issues.push('费用明细不能为空')
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
expenseItems.forEach((item, index) => {
|
||||
buildExpenseDraftIssues(item).forEach((issue) => {
|
||||
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
||||
})
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
return [...new Set(issues)]
|
||||
function resolveExpenseReasonPlaceholder(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
function resolveExpenseReasonHelper(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
function mapIssueToAdvice(issue) {
|
||||
@@ -567,6 +324,7 @@ function mapIssueToAdvice(issue) {
|
||||
|
||||
return `${labelPrefix}。`
|
||||
}
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'TravelRequestDetailView',
|
||||
@@ -601,6 +359,10 @@ export default {
|
||||
const pendingUploadExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const submitConfirmDialogOpen = ref(false)
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
const riskOverrideIndex = ref(0)
|
||||
const riskOverrideReasons = reactive({})
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
@@ -733,6 +495,7 @@ export default {
|
||||
const actionBusy = computed(() =>
|
||||
Boolean(savingExpenseId.value)
|
||||
|| submitBusy.value
|
||||
|| riskOverrideBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| approveBusy.value
|
||||
@@ -857,6 +620,9 @@ export default {
|
||||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||||
})
|
||||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||||
const detailNoteTags = computed(() =>
|
||||
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
||||
)
|
||||
watch(
|
||||
() => [request.value.claimId, detailNoteSource.value],
|
||||
([, nextNote]) => {
|
||||
@@ -867,10 +633,10 @@ export default {
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => item.invoiceId)
|
||||
.filter((item) => canPreviewAttachment(item))
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
@@ -928,6 +694,10 @@ export default {
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
}
|
||||
|
||||
function hasStoredAttachmentReference(item) {
|
||||
return String(item?.invoiceId || '').includes('/')
|
||||
}
|
||||
|
||||
function resolveAttachmentPreviewTitle(item) {
|
||||
const fileName = resolveAttachmentDisplayName(item)
|
||||
return fileName ? `预览附件:${fileName}` : '预览附件'
|
||||
@@ -963,8 +733,14 @@ export default {
|
||||
}
|
||||
|
||||
function canPreviewAttachment(item) {
|
||||
if (!item?.invoiceId) {
|
||||
return false
|
||||
}
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return Boolean(item.invoiceId && metadata?.previewable !== false)
|
||||
if (metadata) {
|
||||
return metadata.previewable !== false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function revokeAttachmentPreviewUrl() {
|
||||
@@ -1056,6 +832,16 @@ export default {
|
||||
return Boolean(resolveExpenseRiskState(item))
|
||||
}
|
||||
|
||||
function isMajorExpenseRisk(item) {
|
||||
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
||||
}
|
||||
|
||||
function resolveExpenseRiskIndicatorTitle(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
const summary = String(state?.summary || state?.headline || '').trim()
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
}
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const riskCards = [
|
||||
@@ -1073,6 +859,21 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
const submitRiskWarnings = computed(() =>
|
||||
aiAdvice.value.riskCards
|
||||
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
|
||||
.map((card, index) => ({
|
||||
...card,
|
||||
id: String(card.id || `submit-risk-${index}`),
|
||||
tags: resolveRiskTags(card)
|
||||
}))
|
||||
)
|
||||
const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null)
|
||||
const riskOverrideIndexLabel = computed(() =>
|
||||
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
|
||||
)
|
||||
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
|
||||
|
||||
function resetDetailNote() {
|
||||
detailNoteEditor.value = detailNoteSource.value
|
||||
}
|
||||
@@ -1103,6 +904,102 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskTagClass(tag) {
|
||||
return resolveRiskTagTone(tag)
|
||||
}
|
||||
|
||||
function openRiskOverrideDialog() {
|
||||
const warnings = submitRiskWarnings.value
|
||||
if (!warnings.length) {
|
||||
return
|
||||
}
|
||||
riskOverrideIndex.value = 0
|
||||
const activeIds = new Set(warnings.map((risk) => risk.id))
|
||||
Object.keys(riskOverrideReasons).forEach((riskId) => {
|
||||
if (!activeIds.has(riskId)) {
|
||||
delete riskOverrideReasons[riskId]
|
||||
}
|
||||
})
|
||||
warnings.forEach((risk) => {
|
||||
if (typeof riskOverrideReasons[risk.id] !== 'string') {
|
||||
riskOverrideReasons[risk.id] = ''
|
||||
}
|
||||
})
|
||||
riskOverrideDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeRiskOverrideDialog() {
|
||||
if (riskOverrideBusy.value) {
|
||||
return
|
||||
}
|
||||
riskOverrideDialogOpen.value = false
|
||||
}
|
||||
|
||||
function goToPreviousSubmitRisk() {
|
||||
if (!submitRiskWarnings.value.length) {
|
||||
return
|
||||
}
|
||||
riskOverrideIndex.value =
|
||||
(riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length
|
||||
}
|
||||
|
||||
function goToNextSubmitRisk() {
|
||||
if (!submitRiskWarnings.value.length) {
|
||||
return
|
||||
}
|
||||
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
|
||||
}
|
||||
|
||||
function buildRiskOverrideAppendix() {
|
||||
return submitRiskWarnings.value
|
||||
.map((risk, index) => {
|
||||
const reason = String(riskOverrideReasons[risk.id] || '').trim()
|
||||
const tags = resolveRiskTags(risk).join(' ')
|
||||
const title = String(risk.title || risk.label || '重大风险').trim()
|
||||
return `超标说明:${tags} 第${index + 1}条 ${title}:${reason}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function mergeDetailNoteWithRiskOverride(appendix) {
|
||||
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
|
||||
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
async function confirmRiskOverrideReasons() {
|
||||
if (riskOverrideBusy.value) {
|
||||
return
|
||||
}
|
||||
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
|
||||
if (missingIndex >= 0) {
|
||||
riskOverrideIndex.value = missingIndex
|
||||
toast('请为每一条重大风险填写违规提交原因。')
|
||||
return
|
||||
}
|
||||
|
||||
const appendix = buildRiskOverrideAppendix()
|
||||
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
||||
if (nextNote.length > 500) {
|
||||
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
||||
return
|
||||
}
|
||||
|
||||
riskOverrideBusy.value = true
|
||||
try {
|
||||
await updateExpenseClaim(request.value.claimId, {
|
||||
reason: nextNote
|
||||
})
|
||||
detailNoteEditor.value = nextNote
|
||||
riskOverrideDialogOpen.value = false
|
||||
submitConfirmDialogOpen.value = true
|
||||
toast('违规提交原因已写入附加说明。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险原因保存失败,请稍后重试。')
|
||||
} finally {
|
||||
riskOverrideBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function populateExpenseEditor(item) {
|
||||
editingExpenseId.value = item.id
|
||||
expenseEditor.itemDate = item.itemDate || ''
|
||||
@@ -1226,7 +1123,14 @@ export default {
|
||||
|
||||
try {
|
||||
if (!metadata) {
|
||||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||||
try {
|
||||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||||
} catch (error) {
|
||||
if (!hasStoredAttachmentReference(item)) {
|
||||
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (metadata?.previewable === false) {
|
||||
throw new Error('当前附件暂不支持直接预览。')
|
||||
@@ -1506,10 +1410,20 @@ export default {
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
||||
return
|
||||
}
|
||||
|
||||
if (draftBlockingIssues.value.length) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1529,11 +1443,23 @@ export default {
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (draftBlockingIssues.value.length) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -1706,106 +1632,37 @@ export default {
|
||||
})
|
||||
|
||||
return {
|
||||
emit,
|
||||
actionBusy,
|
||||
aiAdvice,
|
||||
attachmentPreviewError,
|
||||
attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading,
|
||||
attachmentPreviewMediaType,
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
approveBusy,
|
||||
approveConfirmDialogOpen,
|
||||
approvalConfirmBadge,
|
||||
approvalConfirmDescription,
|
||||
approvalNextStage,
|
||||
approvalOpinionHint,
|
||||
approvalOpinionPlaceholder,
|
||||
approvalOpinionTitle,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry,
|
||||
canApproveRequest,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeApproveConfirmDialog,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
closeSubmitConfirmDialog,
|
||||
closeReturnDialog,
|
||||
confirmApproveRequest,
|
||||
confirmDeleteRequest,
|
||||
confirmSubmitRequest,
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
canEditDetailNote,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
detailNoteDirty,
|
||||
detailNoteEditor,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseTableColumnCount,
|
||||
expenseTotal,
|
||||
expenseUploadInput,
|
||||
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
|
||||
approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview,
|
||||
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||
confirmRiskOverrideReasons,
|
||||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||||
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleApproveRequest,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
heroFactItems,
|
||||
isDraftRequest,
|
||||
isEditableRequest,
|
||||
isTravelRequest,
|
||||
openAiEntry,
|
||||
openAttachmentPreview,
|
||||
goToNextAttachmentPreview,
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resetDetailNote,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper,
|
||||
resolveExpenseReasonPlaceholder,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
saveDetailNote,
|
||||
savingDetailNote,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
submitConfirmDialogOpen,
|
||||
triggerExpenseUpload,
|
||||
uploadedExpenseCount,
|
||||
uploadingExpenseId,
|
||||
saveExpenseEdit
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
328
web/src/views/scripts/auditViewMetadata.js
Normal file
328
web/src/views/scripts/auditViewMetadata.js
Normal file
@@ -0,0 +1,328 @@
|
||||
export const RULE_TABLE_COLUMNS = {
|
||||
name: '规则名称',
|
||||
category: '业务域',
|
||||
owner: '负责人',
|
||||
scope: '适用场景',
|
||||
version: '修改次数',
|
||||
metric: '修改人'
|
||||
}
|
||||
|
||||
export const TYPE_META = {
|
||||
rules: {
|
||||
assetType: 'rule',
|
||||
label: '规则',
|
||||
typeLabel: '规则',
|
||||
tableColumns: RULE_TABLE_COLUMNS
|
||||
},
|
||||
skills: {
|
||||
assetType: 'skill',
|
||||
label: '技能',
|
||||
typeLabel: '技能',
|
||||
createButtonLabel: '技能已接入',
|
||||
hintText: '技能页签已接到真实资产 API,可查看输入、输出、依赖和场景信息。',
|
||||
searchPlaceholder: '搜索技能名称、编码或负责人',
|
||||
showMetricColumn: false,
|
||||
tableColumns: {
|
||||
name: '技能名称',
|
||||
category: '业务域',
|
||||
owner: '负责人',
|
||||
scope: '适用场景',
|
||||
runtime: '输入摘要',
|
||||
version: '当前版本',
|
||||
metric: ''
|
||||
}
|
||||
},
|
||||
mcp: {
|
||||
assetType: 'mcp',
|
||||
label: 'MCP',
|
||||
typeLabel: 'MCP',
|
||||
createButtonLabel: 'MCP 已接入',
|
||||
hintText: 'MCP 页签已接到真实资产 API,可查看服务地址、鉴权方式、超时和降级策略。',
|
||||
searchPlaceholder: '搜索 MCP 名称、编码或负责人',
|
||||
tableColumns: {
|
||||
name: 'MCP 服务',
|
||||
category: '业务域',
|
||||
owner: '维护人',
|
||||
scope: '适用场景',
|
||||
runtime: '调用地址',
|
||||
version: '当前版本',
|
||||
metric: '超时配置'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
assetType: 'task',
|
||||
label: '任务',
|
||||
typeLabel: '任务',
|
||||
createButtonLabel: '任务已接入',
|
||||
hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。',
|
||||
searchPlaceholder: '搜索任务名称、编码或负责人',
|
||||
tableColumns: {
|
||||
name: '任务名称',
|
||||
category: '业务域',
|
||||
owner: '负责人',
|
||||
scope: '适用场景',
|
||||
runtime: '调度周期',
|
||||
version: '当前版本',
|
||||
metric: '执行 Agent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const TAB_META = {
|
||||
financialRules: {
|
||||
assetType: 'rule',
|
||||
typeKey: 'rules',
|
||||
label: '财务规则',
|
||||
typeLabel: '财务规则',
|
||||
createButtonLabel: '财务规则已接入',
|
||||
hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。',
|
||||
searchPlaceholder: '搜索财务规则名称、编码或负责人',
|
||||
tableColumns: RULE_TABLE_COLUMNS,
|
||||
showRuntimeColumn: false,
|
||||
showStatusColumn: false,
|
||||
badgeTone: 'emerald'
|
||||
},
|
||||
riskRules: {
|
||||
assetType: 'rule',
|
||||
typeKey: 'rules',
|
||||
label: '风险规则',
|
||||
typeLabel: '风险规则',
|
||||
createButtonLabel: '风险规则已接入',
|
||||
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
|
||||
searchPlaceholder: '搜索风险规则名称、编码或负责人',
|
||||
tableColumns: RULE_TABLE_COLUMNS,
|
||||
showRuntimeColumn: false,
|
||||
showVersionColumn: false,
|
||||
showStatusColumn: false,
|
||||
badgeTone: 'rose'
|
||||
},
|
||||
skills: {
|
||||
...TYPE_META.skills,
|
||||
typeKey: 'skills',
|
||||
badgeTone: 'blue'
|
||||
},
|
||||
mcp: {
|
||||
...TYPE_META.mcp,
|
||||
typeKey: 'mcp',
|
||||
badgeTone: 'amber'
|
||||
},
|
||||
tasks: {
|
||||
...TYPE_META.tasks,
|
||||
typeKey: 'tasks',
|
||||
badgeTone: 'violet'
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_META = {
|
||||
draft: { label: '草稿中', tone: 'draft' },
|
||||
review: { label: '待审核', tone: 'warning' },
|
||||
active: { label: '已上线', tone: 'success' },
|
||||
disabled: { label: '已停用', tone: 'disabled' }
|
||||
}
|
||||
|
||||
export const REVIEW_META = {
|
||||
approved: { label: '已通过', tone: 'success' },
|
||||
pending: { label: '待审核', tone: 'warning' },
|
||||
rejected: { label: '已驳回', tone: 'danger' }
|
||||
}
|
||||
|
||||
export const VERSION_STATE_META = {
|
||||
published: { label: '已上线', tone: 'success' },
|
||||
draft: { label: '草稿', tone: 'draft' },
|
||||
pending_review: { label: '待审核', tone: 'warning' },
|
||||
approved: { label: '已通过待上线', tone: 'success' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
history: { label: '历史版本', tone: 'disabled' }
|
||||
}
|
||||
|
||||
export const DOMAIN_LABELS = {
|
||||
expense: '报销',
|
||||
ar: '应收',
|
||||
ap: '应付',
|
||||
knowledge: '知识',
|
||||
system: '系统'
|
||||
}
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
risk_check: '风险检查',
|
||||
duplicate_expense: '重复报销',
|
||||
explain: '规则解释',
|
||||
invoice_anomaly: '票据异常',
|
||||
travel_policy: '差旅制度',
|
||||
travel_standard: '差旅标准',
|
||||
communication_expense: '通信费报销',
|
||||
expense_standard: '费用标准',
|
||||
accounts_payable: '应付',
|
||||
accounts_receivable: '应收',
|
||||
approval_required: '需审批',
|
||||
query: '查询',
|
||||
summary: '汇总',
|
||||
system: '系统',
|
||||
schedule: '调度',
|
||||
rule_center: '规则中心',
|
||||
review_digest: '待审摘要',
|
||||
aging_summary: '账龄汇总',
|
||||
invoice_validation: '发票验真'
|
||||
}
|
||||
|
||||
export const DETAIL_TITLES = {
|
||||
rules: {
|
||||
configTitle: '规则元信息',
|
||||
configDesc: '展示规则编码、版本、业务域和当前审核 / 上线状态。',
|
||||
detailTitle: '规则版本说明',
|
||||
detailDesc: '规则正文由 Markdown 驱动,保存后会生成新的版本快照。',
|
||||
outputTitle: '审核与上线',
|
||||
outputDesc: '规则上线受审核状态控制,未审核通过的版本会被后端拦截。',
|
||||
ruleListTitle: '上线要求',
|
||||
checkListTitle: '当前状态',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前规则注册到的业务场景',
|
||||
toolTitle: '关联信息',
|
||||
toolDesc: '规则当前审核、保存和版本快照信息',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近 5 个规则版本',
|
||||
publishTitle: '上线控制',
|
||||
publishDesc: '正式上线会调用后端激活接口,审核未通过时会被拦截。'
|
||||
},
|
||||
skills: {
|
||||
configTitle: '技能配置',
|
||||
configDesc: '展示技能编码、输入摘要、版本和业务域。',
|
||||
detailTitle: '技能结构',
|
||||
detailDesc: '按输入、输出和依赖组织技能定义。',
|
||||
outputTitle: '输出契约',
|
||||
outputDesc: '技能详情重点展示输入参数、输出参数和依赖能力。',
|
||||
ruleListTitle: '输出要求',
|
||||
checkListTitle: '当前快照',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前技能注册到的场景标签',
|
||||
toolTitle: '依赖能力',
|
||||
toolDesc: '技能当前依赖的数据库或其他能力',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '发布状态',
|
||||
publishDesc: '技能当前状态由资产中心统一管理。'
|
||||
},
|
||||
mcp: {
|
||||
configTitle: 'MCP 连接配置',
|
||||
configDesc: '展示服务地址、超时和调用方式。',
|
||||
detailTitle: '服务协议',
|
||||
detailDesc: '按服务类型、鉴权方式和降级策略组织外部服务信息。',
|
||||
outputTitle: '调用约束',
|
||||
outputDesc: 'MCP 详情重点展示鉴权方式、返回策略和最近调用状态。',
|
||||
ruleListTitle: '调用约束',
|
||||
checkListTitle: '最近状态',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前 MCP 覆盖的业务场景',
|
||||
toolTitle: '运行信息',
|
||||
toolDesc: '结合 AgentRun 中的 ToolCall 还原最近一次调用状态',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '服务状态',
|
||||
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
|
||||
},
|
||||
tasks: {
|
||||
configTitle: '任务配置',
|
||||
configDesc: '展示调度周期、执行 Agent 和任务编码。',
|
||||
detailTitle: '任务结构',
|
||||
detailDesc: '按调度计划、目标场景和运行结果组织任务信息。',
|
||||
outputTitle: '运行要求',
|
||||
outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。',
|
||||
ruleListTitle: '运行要求',
|
||||
checkListTitle: '最近执行',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前任务覆盖的业务场景',
|
||||
toolTitle: '最近调用',
|
||||
toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '调度状态',
|
||||
publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。'
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'draft', label: '草稿中' },
|
||||
{ value: 'review', label: '待审核' },
|
||||
{ value: 'active', label: '已上线' },
|
||||
{ value: 'disabled', label: '已停用' }
|
||||
]
|
||||
|
||||
export const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i
|
||||
export const RULE_SPREADSHEET_BLOCK_PATTERN = /```rule-spreadsheet\s*([\s\S]*?)\s*```/i
|
||||
|
||||
export const RULE_TEMPLATE_LABELS = {
|
||||
travel_standard_v1: '差旅标准模板',
|
||||
expense_amount_limit_v1: '金额上限模板',
|
||||
attachment_requirement_v1: '附件要求模板',
|
||||
general_policy_v1: '通用制度模板'
|
||||
}
|
||||
|
||||
export const RULE_TAB_TAG_ALIASES = {
|
||||
financialRules: new Set(['财务规则', '财务', 'financialrule', 'financialrules', 'financerule', 'financerules', 'financial', 'finance']),
|
||||
riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk'])
|
||||
}
|
||||
|
||||
export const RISK_SCENARIO_OPTIONS = [
|
||||
{ value: '', label: '全部场景' },
|
||||
{ value: '差旅', label: '差旅' },
|
||||
{ value: '发票', label: '发票' },
|
||||
{ value: '餐饮招待', label: '餐饮招待' },
|
||||
{ value: '交通出行', label: '交通出行' },
|
||||
{ value: '办公物料', label: '办公物料' },
|
||||
{ value: '费用科目', label: '费用科目' },
|
||||
{ value: '通用', label: '通用' }
|
||||
]
|
||||
|
||||
export const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean))
|
||||
|
||||
export const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
||||
'expense',
|
||||
'risk_check',
|
||||
'travel',
|
||||
'meal',
|
||||
'invoice',
|
||||
'travel_policy',
|
||||
'travel_standard',
|
||||
'attachment_policy',
|
||||
'scene_policy',
|
||||
'invoice_anomaly',
|
||||
'communication_expense',
|
||||
'expense_standard',
|
||||
'approval_required'
|
||||
])
|
||||
|
||||
export const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
|
||||
export const JSON_RISK_DETAIL_MODE = 'json_risk'
|
||||
export const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense'
|
||||
export const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement'
|
||||
export const PREVIEW_RULE_VERSION_SPECS = [
|
||||
{
|
||||
version: 'v1.2.0',
|
||||
fileName: '公司差旅费报销规则.xlsx',
|
||||
updatedAt: '2026-05-17T09:30:00Z',
|
||||
updatedBy: '王楠',
|
||||
note: '补充城市分级与住宿限额示例。',
|
||||
source: 'preview',
|
||||
isCurrent: true
|
||||
},
|
||||
{
|
||||
version: 'v1.1.0',
|
||||
fileName: '公司差旅费报销规则-v1.1.0.xlsx',
|
||||
updatedAt: '2026-05-14T15:20:00Z',
|
||||
updatedBy: '顾承宇',
|
||||
note: '新增票据要求与超标审批列。',
|
||||
source: 'preview',
|
||||
isCurrent: false
|
||||
},
|
||||
{
|
||||
version: 'v1.0.0',
|
||||
fileName: '公司差旅费报销规则-v1.0.0.xlsx',
|
||||
updatedAt: '2026-05-10T11:10:00Z',
|
||||
updatedBy: '系统初始化',
|
||||
note: '首版差旅费报销规则表预览。',
|
||||
source: 'preview',
|
||||
isCurrent: false
|
||||
}
|
||||
]
|
||||
1315
web/src/views/scripts/auditViewModel.js
Normal file
1315
web/src/views/scripts/auditViewModel.js
Normal file
File diff suppressed because it is too large
Load Diff
109
web/src/views/scripts/auditViewRuntimeModel.js
Normal file
109
web/src/views/scripts/auditViewRuntimeModel.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { normalizeText, readConfigJson, resolveRuleTemplateLabel } from './auditViewModel.js'
|
||||
|
||||
export function incrementVersion(version) {
|
||||
const normalized = normalizeText(version).replace(/^v/i, '')
|
||||
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
|
||||
if (!match) {
|
||||
return 'v1.0.0'
|
||||
}
|
||||
|
||||
const major = Number(match[1])
|
||||
const minor = Number(match[2])
|
||||
const patch = Number(match[3]) + 1
|
||||
return `v${major}.${minor}.${patch}`
|
||||
}
|
||||
|
||||
export function buildReviewNote(status) {
|
||||
if (status === 'approved') {
|
||||
return '通过任务规则中心审核。'
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return '在任务规则中心驳回当前版本。'
|
||||
}
|
||||
return '提交任务规则中心待审核。'
|
||||
}
|
||||
|
||||
export function buildRuleConfigPayload(asset, runtimeRule) {
|
||||
const configJson = {
|
||||
...readConfigJson(asset),
|
||||
runtime_kind: normalizeText(runtimeRule?.kind) || asset.runtimeKind || 'policy_rule_draft',
|
||||
runtime_rule: runtimeRule
|
||||
}
|
||||
const templateKey = normalizeText(runtimeRule?.template_key) || asset.ruleTemplateKey
|
||||
if (templateKey) {
|
||||
configJson.rule_template_key = templateKey
|
||||
configJson.rule_template_label = resolveRuleTemplateLabel(templateKey)
|
||||
}
|
||||
return configJson
|
||||
}
|
||||
|
||||
export function buildSpreadsheetChangeRecordKey(records = []) {
|
||||
const latest = records.find((item) => item?.changed_at)
|
||||
if (!latest) {
|
||||
return ''
|
||||
}
|
||||
const previewSignature = Array.isArray(latest.cell_changes)
|
||||
? latest.cell_changes
|
||||
.slice(0, 8)
|
||||
.map((item) =>
|
||||
[
|
||||
item?.sheet_name,
|
||||
item?.cell,
|
||||
item?.change_type,
|
||||
item?.before_value,
|
||||
item?.after_value
|
||||
]
|
||||
.map((value) => normalizeText(value))
|
||||
.join(':')
|
||||
)
|
||||
.join('|')
|
||||
: ''
|
||||
const sheetSignature = Array.isArray(latest.sheet_changes)
|
||||
? latest.sheet_changes
|
||||
.map((item) =>
|
||||
[item?.sheet_name, item?.change_type]
|
||||
.map((value) => normalizeText(value))
|
||||
.join(':')
|
||||
)
|
||||
.join('|')
|
||||
: ''
|
||||
return [
|
||||
latest.id,
|
||||
latest.changed_at,
|
||||
latest.actor,
|
||||
latest.summary,
|
||||
latest.changed_sheet_count,
|
||||
latest.changed_cell_count,
|
||||
sheetSignature,
|
||||
previewSignature
|
||||
]
|
||||
.map((value) => normalizeText(value))
|
||||
.join('-')
|
||||
}
|
||||
|
||||
export function filterAuditAssets(assets = [], filters = {}) {
|
||||
const normalizedKeyword = normalizeText(filters.keyword).toLowerCase()
|
||||
|
||||
return assets.filter((item) => {
|
||||
const matchesKeyword = normalizedKeyword
|
||||
? [item.name, item.code, item.summary, item.owner, item.scope]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
|
||||
: true
|
||||
const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true
|
||||
const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true
|
||||
const matchesStatus = filters.showStatusFilter
|
||||
? filters.selectedStatus
|
||||
? item.statusValue === filters.selectedStatus
|
||||
: true
|
||||
: true
|
||||
const matchesRiskScenario = filters.showRiskScenarioFilter
|
||||
? filters.selectedRiskScenario
|
||||
? item.riskCategory === filters.selectedRiskScenario
|
||||
: true
|
||||
: true
|
||||
|
||||
return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario
|
||||
})
|
||||
}
|
||||
428
web/src/views/scripts/travelReimbursementAttachmentModel.js
Normal file
428
web/src/views/scripts/travelReimbursementAttachmentModel.js
Normal file
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
buildReviewSlotMap,
|
||||
resolveDocumentTypeLabel,
|
||||
resolveExpenseTypeCode
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
expense: '??',
|
||||
accounts_receivable: '??',
|
||||
accounts_payable: '??',
|
||||
knowledge: '??',
|
||||
unknown: '??'
|
||||
}
|
||||
|
||||
const INTENT_LABELS = {
|
||||
query: '??',
|
||||
explain: '??',
|
||||
compare: '??',
|
||||
risk_check: '????',
|
||||
draft: '????',
|
||||
operate: '????'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '???'
|
||||
if (status === 'blocked') return '???'
|
||||
return '??'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'blocked') return 'warning'
|
||||
return 'note'
|
||||
}
|
||||
|
||||
export const MAX_ATTACHMENTS = 10
|
||||
export const MAX_OCR_DOCUMENTS = 10
|
||||
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
||||
|
||||
export function normalizeOcrDocuments(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
||||
filename: item.filename,
|
||||
summary: item.summary,
|
||||
text: String(item.text || '').slice(0, 240),
|
||||
avg_score: Number(item.avg_score || 0),
|
||||
line_count: Number(item.line_count || 0),
|
||||
document_type: String(item.document_type || 'other').trim() || 'other',
|
||||
document_type_label: String(item.document_type_label || '').trim(),
|
||||
scene_code: String(item.scene_code || 'other').trim() || 'other',
|
||||
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: Array.isArray(item.document_fields)
|
||||
? item.document_fields
|
||||
.map((field) => ({
|
||||
key: String(field?.key || '').trim(),
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.key && field.label && field.value)
|
||||
: [],
|
||||
warnings: Array.isArray(item.warnings) ? item.warnings : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildOcrSummary(payload) {
|
||||
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
|
||||
}
|
||||
|
||||
export function buildOcrSummaryFromDocuments(documents) {
|
||||
return (Array.isArray(documents) ? documents : [])
|
||||
.slice(0, MAX_OCR_DOCUMENTS)
|
||||
.map((item) => {
|
||||
const filename = String(item?.filename || '').trim()
|
||||
const summary = String(item?.summary || item?.text || '').trim()
|
||||
if (filename && summary) {
|
||||
return `${filename}:${summary}`
|
||||
}
|
||||
return filename || summary
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentFieldKey(label) {
|
||||
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
|
||||
if (!compact) return ''
|
||||
if (
|
||||
['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) =>
|
||||
compact.includes(token.toLowerCase())
|
||||
)
|
||||
) {
|
||||
return 'amount'
|
||||
}
|
||||
if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'date'
|
||||
}
|
||||
if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'merchant_name'
|
||||
}
|
||||
if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'invoice_number'
|
||||
}
|
||||
if (compact.includes('发票代码')) {
|
||||
return 'invoice_code'
|
||||
}
|
||||
if (compact.includes('车次') || compact.includes('航班')) {
|
||||
return 'trip_no'
|
||||
}
|
||||
if (compact.includes('行程') || compact.includes('路线')) {
|
||||
return 'route'
|
||||
}
|
||||
return compact
|
||||
}
|
||||
|
||||
export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => {
|
||||
const fields = Array.isArray(item?.fields)
|
||||
? item.fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || '').trim()
|
||||
const value = String(field?.value || '').trim()
|
||||
if (!label || !value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
key: normalizeReviewDocumentFieldKey(label),
|
||||
label,
|
||||
value
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
filename: String(item?.filename || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
text: [
|
||||
String(item?.scene_label || '').trim(),
|
||||
String(item?.summary || '').trim(),
|
||||
...fields.map((field) => `${field.label}:${field.value}`)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.slice(0, 240),
|
||||
avg_score: Number(item?.avg_score || 0),
|
||||
document_type: String(item?.document_type || 'other').trim() || 'other',
|
||||
document_type_label: resolveDocumentTypeLabel(item?.document_type),
|
||||
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
document_fields: fields,
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}
|
||||
}).filter((item) => item.filename)
|
||||
}
|
||||
|
||||
export function mergeUploadAttachmentNames(existingNames, incomingNames) {
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const value of [...(existingNames || []), ...(incomingNames || [])]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized || seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
merged.push(normalized)
|
||||
if (merged.length >= MAX_ATTACHMENTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) {
|
||||
const filename = String(item?.filename || '').trim()
|
||||
if (!filename || seen.has(filename)) continue
|
||||
seen.add(filename)
|
||||
merged.push(item)
|
||||
if (merged.length >= MAX_OCR_DOCUMENTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function inferPreviewKind(file) {
|
||||
const mediaType = String(file?.type || '').toLowerCase()
|
||||
const filename = String(file?.name || '').toLowerCase()
|
||||
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export function buildFilePreviews(files, previewRegistry) {
|
||||
return files.map((file) => {
|
||||
const kind = inferPreviewKind(file)
|
||||
if (!['image', 'pdf'].includes(kind)) {
|
||||
return {
|
||||
filename: file.name,
|
||||
kind
|
||||
}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
previewRegistry.push(url)
|
||||
return {
|
||||
filename: file.name,
|
||||
kind,
|
||||
url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveDocumentPreview(filePreviews, filename) {
|
||||
if (!Array.isArray(filePreviews)) return null
|
||||
const matches = filePreviews.filter((item) => item.filename === filename)
|
||||
if (!matches.length) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
matches.find((item) => item.kind === 'image' && item.url) ||
|
||||
matches.find((item) => item.url) ||
|
||||
matches[0]
|
||||
)
|
||||
}
|
||||
|
||||
export function buildFileIdentity(file) {
|
||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||
}
|
||||
|
||||
export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) {
|
||||
const nextFiles = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const file of Array.isArray(existingFiles) ? existingFiles : []) {
|
||||
const key = buildFileIdentity(file)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
nextFiles.push(file)
|
||||
}
|
||||
|
||||
let duplicateCount = 0
|
||||
let overflowCount = 0
|
||||
|
||||
for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) {
|
||||
const key = buildFileIdentity(file)
|
||||
if (seen.has(key)) {
|
||||
duplicateCount += 1
|
||||
continue
|
||||
}
|
||||
if (nextFiles.length >= limit) {
|
||||
overflowCount += 1
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
nextFiles.push(file)
|
||||
}
|
||||
|
||||
return {
|
||||
files: nextFiles,
|
||||
duplicateCount,
|
||||
overflowCount
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
|
||||
const key = [preview?.filename, preview?.kind].join('__')
|
||||
if (!preview?.filename || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
result.push(preview)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
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(),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
export function buildReviewFilePreviewsFromMessages(messages) {
|
||||
const previews = []
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload))
|
||||
}
|
||||
return mergeFilePreviews([], previews)
|
||||
}
|
||||
|
||||
export function resolveAttachmentPreviewKind(metadata) {
|
||||
const explicitKind = String(metadata?.preview_kind || '').trim()
|
||||
if (explicitKind) {
|
||||
return explicitKind
|
||||
}
|
||||
|
||||
const mediaType = String(metadata?.media_type || '').trim().toLowerCase()
|
||||
if (mediaType.startsWith('image/')) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType === 'application/pdf') {
|
||||
return 'pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function extractReviewAttachmentNames(reviewPayload) {
|
||||
const documentNames = Array.isArray(reviewPayload?.document_cards)
|
||||
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (documentNames.length) {
|
||||
return documentNames
|
||||
}
|
||||
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const attachmentValue = String(slotMap.attachments?.value || '').trim()
|
||||
if (!attachmentValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildErrorInsight(error, fileNames = []) {
|
||||
return {
|
||||
intent: 'agent',
|
||||
metricLabel: '运行状态',
|
||||
metricValue: '失败',
|
||||
title: '智能体调用失败',
|
||||
summary: error?.message || '无法连接后端 Orchestrator。',
|
||||
agent: {
|
||||
runId: '未生成',
|
||||
selectedAgent: 'orchestrator',
|
||||
scenario: '未知',
|
||||
intent: '未知',
|
||||
permissionLevel: 'unknown',
|
||||
routeReason: 'request_failed',
|
||||
requiresConfirmation: false,
|
||||
degraded: false,
|
||||
fileNames,
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
queryPayload: null,
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
riskFlags: [],
|
||||
toolCount: 0,
|
||||
failedToolCount: 0,
|
||||
selectedCapabilityCodes: [],
|
||||
filePreviews: [],
|
||||
statusLabel: '失败',
|
||||
statusTone: 'note'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
|
||||
const trace = payload?.trace_summary || {}
|
||||
const result = payload?.result || {}
|
||||
const statusLabel = resolveStatusLabel(payload?.status)
|
||||
|
||||
return {
|
||||
intent: 'agent',
|
||||
metricLabel: '运行状态',
|
||||
metricValue: statusLabel,
|
||||
title:
|
||||
result?.draft_payload?.title ||
|
||||
`${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`,
|
||||
summary: result?.answer || result?.message || '智能体已完成处理。',
|
||||
agent: {
|
||||
runId: payload?.run_id || '未生成',
|
||||
selectedAgent: payload?.selected_agent || 'orchestrator',
|
||||
scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知',
|
||||
intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知',
|
||||
permissionLevel: payload?.permission_level || 'unknown',
|
||||
routeReason: payload?.route_reason || 'unknown',
|
||||
requiresConfirmation: Boolean(payload?.requires_confirmation),
|
||||
degraded: Boolean(trace?.degraded),
|
||||
fileNames,
|
||||
citations: Array.isArray(result?.citations) ? result.citations : [],
|
||||
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
|
||||
queryPayload: normalizeExpenseQueryPayload(result?.query_payload),
|
||||
draftPayload: result?.draft_payload || null,
|
||||
reviewPayload: result?.review_payload || null,
|
||||
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
|
||||
toolCount: Number(trace?.tool_count || 0),
|
||||
failedToolCount: Number(trace?.failed_tool_count || 0),
|
||||
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
|
||||
? trace.selected_capability_codes
|
||||
: [],
|
||||
filePreviews,
|
||||
statusLabel,
|
||||
statusTone: resolveStatusTone(payload?.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
767
web/src/views/scripts/travelReimbursementConversationModel.js
Normal file
767
web/src/views/scripts/travelReimbursementConversationModel.js
Normal file
@@ -0,0 +1,767 @@
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
|
||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
|
||||
export const aiAvatar = '/assets/header.png'
|
||||
export const userAvatar = '/assets/person.png'
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
workbench: '来自个人工作台',
|
||||
topbar: '来自发起报销',
|
||||
detail: '来自智能录入',
|
||||
upload: '来自附件上传',
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
export const INTENT_LABELS = {
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '信息核对',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
export const FLOW_STEP_FALLBACKS = {
|
||||
intent: {
|
||||
title: '意图识别',
|
||||
tool: 'IntentRecognizer',
|
||||
runningText: '正在识别业务意图...',
|
||||
completedText: '意图识别完成'
|
||||
},
|
||||
extraction: {
|
||||
title: '信息提取',
|
||||
tool: 'SemanticExtractor',
|
||||
runningText: '正在提取时间、金额、费用类型和待补项...',
|
||||
completedText: '信息提取完成'
|
||||
},
|
||||
ocr: {
|
||||
title: '票据/OCR识别',
|
||||
tool: 'OCRService',
|
||||
runningText: '正在识别票据附件...',
|
||||
completedText: '票据识别完成'
|
||||
},
|
||||
'expense-review-preview': {
|
||||
title: '报销信息核对',
|
||||
tool: 'user_agent.expense_review_preview',
|
||||
runningText: '正在整理识别结果和右侧核对信息...',
|
||||
completedText: '核对信息已整理'
|
||||
},
|
||||
'expense-claim-draft': {
|
||||
title: '保存报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
runningText: '正在把已确认信息保存为草稿...',
|
||||
completedText: '草稿已保存'
|
||||
},
|
||||
'expense-scene-selection': {
|
||||
title: '报销场景确认',
|
||||
tool: 'UserConfirmation',
|
||||
runningText: '等待用户选择报销场景...',
|
||||
completedText: '已进入场景选择,等待用户确认'
|
||||
},
|
||||
'expense-intent-confirmation': {
|
||||
title: '报销意图确认',
|
||||
tool: 'UserConfirmation',
|
||||
runningText: '等待用户确认是否发起报销...',
|
||||
completedText: '用户已确认报销意图'
|
||||
}
|
||||
}
|
||||
export const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||
|
||||
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '发起差旅报销',
|
||||
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
|
||||
icon: 'mdi mdi-bag-suitcase-outline'
|
||||
},
|
||||
{
|
||||
label: '招待费报销',
|
||||
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
|
||||
icon: 'mdi mdi-food-fork-drink'
|
||||
},
|
||||
{
|
||||
label: '交通费报销',
|
||||
prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。',
|
||||
icon: 'mdi mdi-car-outline'
|
||||
},
|
||||
{
|
||||
label: '上传票据识别',
|
||||
prompt: '我已准备好票据,请帮我识别并整理报销核对信息。',
|
||||
icon: 'mdi mdi-file-upload-outline'
|
||||
},
|
||||
{
|
||||
label: '查询近期报销',
|
||||
prompt: '帮我查询近10天的报销记录和金额汇总。',
|
||||
icon: 'mdi mdi-chart-timeline-variant'
|
||||
},
|
||||
{
|
||||
label: '解释报销风险',
|
||||
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。',
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
'差旅住宿标准按什么规则执行?',
|
||||
'酒店超标后如何申请例外报销?',
|
||||
'招待费报销需要哪些凭证?',
|
||||
'发票抬头不一致还能报销吗?',
|
||||
'电子发票验真失败怎么处理?',
|
||||
'借款多久内需要冲销?',
|
||||
'预算不足还能先提交报销吗?',
|
||||
'会议费和招待费如何区分?',
|
||||
'跨部门项目费用应该怎么归集?',
|
||||
'员工退票手续费是否可以报销?'
|
||||
]
|
||||
export const FLOW_MISSING_SLOT_LABELS = {
|
||||
expense_type: '报销类型',
|
||||
customer_name: '客户名称',
|
||||
time_range: '发生时间',
|
||||
location: '地点',
|
||||
merchant_name: '酒店/商户',
|
||||
amount: '金额',
|
||||
reason: '事由说明',
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
export function nowTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
messageSeed += 1
|
||||
return {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
attachments,
|
||||
time: nowTime(),
|
||||
meta: [],
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
suggestedActionsLocked: false,
|
||||
selectedSuggestedActionKey: '',
|
||||
selectedSuggestedActionLabel: '',
|
||||
querySelectionLocked: false,
|
||||
selectedQueryRecordId: '',
|
||||
queryPayload: null,
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
riskFlags: [],
|
||||
...extras
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExpenseIntentConfirmationMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
return [
|
||||
text
|
||||
? `我看到了「${text}」这类业务事项描述。`
|
||||
: '我看到了这类业务事项描述。',
|
||||
'但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
|
||||
'如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildExpenseSceneSelectionMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
|
||||
const prefix = hasBusinessTime
|
||||
? '我已看到你提供了业务发生时间和报销意图。'
|
||||
: '我已识别到这是报销申请。'
|
||||
|
||||
return [
|
||||
`${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取。`,
|
||||
'请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatMessageTime(value) {
|
||||
if (!value) {
|
||||
return nowTime()
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return nowTime()
|
||||
}
|
||||
|
||||
return parsed.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSemanticEntityValue(entity) {
|
||||
const normalizedValue = String(entity?.normalized_value || '').trim()
|
||||
const rawValue = String(entity?.value || '').trim()
|
||||
const entityType = String(entity?.type || '').trim()
|
||||
|
||||
if (entityType === 'amount') {
|
||||
const numericValue = Number(normalizedValue || rawValue)
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
return rawValue || normalizedValue
|
||||
}
|
||||
|
||||
export function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : []
|
||||
const entityMap = new Map()
|
||||
for (const item of entities) {
|
||||
const entityType = String(item?.type || '').trim()
|
||||
if (!entityType || entityMap.has(entityType)) continue
|
||||
entityMap.set(entityType, item)
|
||||
}
|
||||
|
||||
const extractedParts = []
|
||||
const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object'
|
||||
? semanticParse.time_range_json
|
||||
: {}
|
||||
const startDate = String(timeRange.start_date || '').trim()
|
||||
const endDate = String(timeRange.end_date || '').trim()
|
||||
if (startDate) {
|
||||
extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`)
|
||||
}
|
||||
|
||||
const amountEntity = entityMap.get('amount')
|
||||
if (amountEntity) {
|
||||
const amountValue = formatSemanticEntityValue(amountEntity)
|
||||
if (amountValue) {
|
||||
extractedParts.push(`金额 ${amountValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const expenseTypeEntity = entityMap.get('expense_type')
|
||||
if (expenseTypeEntity) {
|
||||
const expenseTypeLabel = resolveExpenseTypeLabel(
|
||||
String(expenseTypeEntity?.normalized_value || '').trim(),
|
||||
String(expenseTypeEntity?.value || '').trim()
|
||||
)
|
||||
if (expenseTypeLabel) {
|
||||
extractedParts.push(`费用类型 ${expenseTypeLabel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const customerEntity = entityMap.get('customer')
|
||||
if (customerEntity) {
|
||||
const customerValue = formatSemanticEntityValue(customerEntity)
|
||||
if (customerValue) {
|
||||
extractedParts.push(`客户 ${customerValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : []
|
||||
const missingLabels = missingSlots
|
||||
.map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (extractedParts.length && missingLabels.length) {
|
||||
return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
if (extractedParts.length) {
|
||||
return `已提取${extractedParts.join('、')}`
|
||||
}
|
||||
if (missingLabels.length) {
|
||||
return `已完成信息提取;待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
export function sanitizeRequest(request) {
|
||||
if (!request || typeof request !== 'object') return null
|
||||
|
||||
const normalized = {
|
||||
id: String(request.id || '').trim(),
|
||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||
reason: String(request.reason || request.title || '').trim(),
|
||||
entity: String(request.entity || '').trim(),
|
||||
city: String(request.city || request.location || '').trim(),
|
||||
period: String(request.period || '').trim(),
|
||||
applyTime: String(request.applyTime || request.occurredAt || '').trim(),
|
||||
amount: String(request.amount || '').trim(),
|
||||
node: String(request.node || '').trim(),
|
||||
approval: String(request.approval || '').trim(),
|
||||
travel: String(request.travel || '').trim()
|
||||
}
|
||||
|
||||
return Object.values(normalized).some(Boolean) ? normalized : null
|
||||
}
|
||||
|
||||
export function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'blocked') return '已阻断'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
export function resolveStatusTone(status) {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'blocked') return 'warning'
|
||||
return 'note'
|
||||
}
|
||||
|
||||
export function buildMessageMeta(payload, fileNames = []) {
|
||||
const items = []
|
||||
|
||||
if (payload?.selected_agent) {
|
||||
items.push(`Agent: ${payload.selected_agent}`)
|
||||
}
|
||||
|
||||
if (payload?.permission_level) {
|
||||
items.push(`权限: ${payload.permission_level}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.tool_count) {
|
||||
items.push(`工具: ${payload.trace_summary.tool_count}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
|
||||
if (payload?.requires_confirmation) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (payload?.run_id) {
|
||||
items.push(`Run: ${payload.run_id}`)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
items.push(`附件: ${fileNames.length}`)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
const payload = messageJson?.orchestrator_payload
|
||||
if (payload) {
|
||||
return buildMessageMeta(payload, attachmentNames)
|
||||
}
|
||||
|
||||
const items = []
|
||||
if (messageJson?.status) {
|
||||
items.push(`状态: ${messageJson.status}`)
|
||||
}
|
||||
if (attachmentNames.length) {
|
||||
items.push(`附件: ${attachmentNames.length}`)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildWelcomeUserContext(user = {}) {
|
||||
const username = String(user.username || '').trim()
|
||||
const name = String(user.name || username || '同事').trim()
|
||||
const grade = String(user.grade || '').trim()
|
||||
const position = String(user.position || '').trim()
|
||||
const role = String(user.role || '').trim()
|
||||
const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : []
|
||||
const isAdmin =
|
||||
Boolean(user.isAdmin)
|
||||
|| username.toLowerCase() === 'admin'
|
||||
|| roleCodes.some((item) => /admin|manager/i.test(String(item || '')))
|
||||
|| /管理员|系统管理/.test(position)
|
||||
|| /管理员|系统管理/.test(role)
|
||||
|
||||
const now = new Date()
|
||||
const dateLine = now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})
|
||||
|
||||
let honorific = name
|
||||
if (isAdmin) {
|
||||
honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员'
|
||||
} else {
|
||||
const prefix = [grade, position].filter(Boolean).join(' ')
|
||||
honorific = prefix ? `${prefix} ${name}`.trim() : name
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
username,
|
||||
grade,
|
||||
position,
|
||||
role,
|
||||
isAdmin,
|
||||
honorific,
|
||||
dateLine
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
|
||||
label: question.length > 20 ? `${question.slice(0, 20)}…` : question,
|
||||
prompt: question,
|
||||
icon: 'mdi mdi-comment-question-outline'
|
||||
}))
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
{
|
||||
label: '补充当前单据票据',
|
||||
prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`,
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '解释本单风险',
|
||||
prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`,
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
},
|
||||
...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4)
|
||||
]
|
||||
}
|
||||
|
||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
const ctx = buildWelcomeUserContext(user || {})
|
||||
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
|
||||
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。',
|
||||
'',
|
||||
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
`我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`,
|
||||
'',
|
||||
'如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销信息核对、待补项提醒和风险说明。',
|
||||
'',
|
||||
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
const ctx = buildWelcomeUserContext(user || {})
|
||||
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '今日',
|
||||
metricValue: ctx.dateLine.split(' ')[0] || '—',
|
||||
title: '财务知识问答',
|
||||
summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '助手状态',
|
||||
metricValue: '待您吩咐',
|
||||
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心',
|
||||
summary:
|
||||
entrySource === 'detail' && linkedRequest?.id
|
||||
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
|
||||
: `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], {
|
||||
assistantName: ASSISTANT_DISPLAY_NAME,
|
||||
isWelcome: true,
|
||||
welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveInitialSessionType(conversation) {
|
||||
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
||||
const sessionType = String(stateJson?.session_type || '').trim()
|
||||
return sessionType || SESSION_TYPE_EXPENSE
|
||||
}
|
||||
|
||||
export function buildInitialInsightFromConversation(conversation) {
|
||||
const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : []
|
||||
for (let index = rawMessages.length - 1; index >= 0; index -= 1) {
|
||||
const item = rawMessages[index]
|
||||
const messageJson = item?.message_json || item?.messageJson || {}
|
||||
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||
if (!orchestratorPayload) continue
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
return buildAgentInsight(
|
||||
orchestratorPayload,
|
||||
attachmentNames,
|
||||
buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveInitialConversationId(conversation) {
|
||||
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveInitialDraftClaimId(conversation) {
|
||||
return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveKnowledgeRankLabel(index) {
|
||||
return String(index + 1)
|
||||
}
|
||||
|
||||
export function resolveKnowledgeRankTone(index) {
|
||||
if (index === 0) return 'gold'
|
||||
if (index === 1) return 'silver'
|
||||
if (index === 2) return 'bronze'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function parseConversationMessageSequence(message) {
|
||||
const messageJson = message?.message_json || message?.messageJson || {}
|
||||
const sequence = Number.parseInt(messageJson?.sequence, 10)
|
||||
return Number.isFinite(sequence) && sequence > 0 ? sequence : null
|
||||
}
|
||||
|
||||
export function parseConversationMessageTime(message) {
|
||||
const rawValue = message?.created_at || message?.createdAt || ''
|
||||
const timestamp = new Date(rawValue).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
export function resolveConversationMessageRolePriority(message) {
|
||||
return String(message?.role || '').trim() === 'user' ? 0 : 1
|
||||
}
|
||||
|
||||
export function sortConversationMessages(messages) {
|
||||
return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => {
|
||||
const leftSequence = parseConversationMessageSequence(left)
|
||||
const rightSequence = parseConversationMessageSequence(right)
|
||||
if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) {
|
||||
return leftSequence - rightSequence
|
||||
}
|
||||
|
||||
const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right)
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff
|
||||
}
|
||||
|
||||
const leftRunId = String(left?.run_id || left?.runId || '').trim()
|
||||
const rightRunId = String(right?.run_id || right?.runId || '').trim()
|
||||
if (leftRunId && rightRunId && leftRunId === rightRunId) {
|
||||
const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right)
|
||||
if (roleDiff !== 0) {
|
||||
return roleDiff
|
||||
}
|
||||
}
|
||||
|
||||
return String(left?.id || '').localeCompare(String(right?.id || ''))
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeInitialConversationMessages(conversation) {
|
||||
const rawMessages = sortConversationMessages(conversation?.messages)
|
||||
|
||||
const restoredMessages = rawMessages.map((item) => {
|
||||
const messageJson = item?.message_json || item?.messageJson || {}
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||
const result = orchestratorPayload?.result || {}
|
||||
|
||||
return createMessage(item.role, item.content, attachmentNames, {
|
||||
id: `restored-${item.id || ++messageSeed}`,
|
||||
time: formatMessageTime(item.created_at || item.createdAt),
|
||||
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
|
||||
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
|
||||
suggestedActions:
|
||||
item.role === 'assistant' && Array.isArray(result?.suggested_actions)
|
||||
? result.suggested_actions
|
||||
: [],
|
||||
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
|
||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
|
||||
})
|
||||
})
|
||||
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||
}
|
||||
|
||||
export function normalizeSnapshotMessage(message) {
|
||||
const extras = message && typeof message === 'object' ? { ...message } : {}
|
||||
const role = String(extras.role || 'assistant').trim() || 'assistant'
|
||||
const text = String(extras.text || '')
|
||||
const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : []
|
||||
delete extras.role
|
||||
delete extras.text
|
||||
delete extras.attachments
|
||||
return createMessage(role, text, attachments, extras)
|
||||
}
|
||||
|
||||
export function normalizeSnapshotMessages(messages) {
|
||||
return Array.isArray(messages)
|
||||
? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage))
|
||||
: []
|
||||
}
|
||||
|
||||
export function serializeSessionMessages(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
|
||||
time: message.time,
|
||||
meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [],
|
||||
metaTone: message.metaTone || '',
|
||||
citations: Array.isArray(message.citations) ? message.citations : [],
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
suggestedActionsLocked: Boolean(message.suggestedActionsLocked),
|
||||
selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''),
|
||||
selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''),
|
||||
querySelectionLocked: Boolean(message.querySelectionLocked),
|
||||
selectedQueryRecordId: String(message.selectedQueryRecordId || ''),
|
||||
queryPayload: message.queryPayload || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function hasMeaningfulSessionMessages(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).some((message) => {
|
||||
if (!message || message.isWelcome) {
|
||||
return false
|
||||
}
|
||||
if (message.role === 'user') {
|
||||
return true
|
||||
}
|
||||
return Boolean(
|
||||
String(message.text || '').trim()
|
||||
|| (Array.isArray(message.suggestedActions) && message.suggestedActions.length)
|
||||
|| message.reviewPayload
|
||||
|| message.queryPayload
|
||||
|| message.draftPayload
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function hasActiveSuggestedActionMessage(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).some(
|
||||
(message) =>
|
||||
message?.role === 'assistant'
|
||||
&& Array.isArray(message.suggestedActions)
|
||||
&& message.suggestedActions.length > 0
|
||||
&& !message.suggestedActionsLocked
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveConversationUpdatedAt(conversation) {
|
||||
const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
export function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) {
|
||||
if (!persistedState) {
|
||||
return false
|
||||
}
|
||||
if (!conversation) {
|
||||
return true
|
||||
}
|
||||
if (hasActiveSuggestedActionMessage(persistedState.messages)) {
|
||||
return true
|
||||
}
|
||||
const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0)
|
||||
return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation)
|
||||
}
|
||||
|
||||
export function markResolvedSuggestedActionMessages(messages) {
|
||||
const items = Array.isArray(messages) ? messages : []
|
||||
const selectedLabels = new Set()
|
||||
|
||||
for (const message of items) {
|
||||
if (message?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
const text = String(message.text || '').trim()
|
||||
const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[::]\s*([^\n\r]+)/)
|
||||
if (selectedMatch?.[1]) {
|
||||
selectedLabels.add(selectedMatch[1].trim())
|
||||
} else if (text === '我要报销') {
|
||||
selectedLabels.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedLabels.size) {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.map((message) => {
|
||||
if (
|
||||
message?.role !== 'assistant'
|
||||
|| message.suggestedActionsLocked
|
||||
|| !Array.isArray(message.suggestedActions)
|
||||
|| !message.suggestedActions.length
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
const selectedAction = message.suggestedActions.find((action) =>
|
||||
selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim())
|
||||
)
|
||||
if (!selectedAction) {
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
suggestedActionsLocked: true,
|
||||
selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction),
|
||||
selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
268
web/src/views/scripts/travelReimbursementExpenseQueryModel.js
Normal file
268
web/src/views/scripts/travelReimbursementExpenseQueryModel.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
EXPENSE_TYPE_LABELS,
|
||||
formatAmountDisplay
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
export const EXPENSE_QUERY_PAGE_SIZE = 5
|
||||
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
|
||||
const EXPENSE_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
supplement: '待补充',
|
||||
returned: '已退回',
|
||||
submitted: '已提交',
|
||||
review: '审批中',
|
||||
approved: '已审核',
|
||||
paid: '已入账'
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryStatusGroup(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawCount = Number(item.count || 0)
|
||||
return {
|
||||
key: String(item.key || 'other').trim() || 'other',
|
||||
label: String(item.label || '其他状态').trim() || '其他状态',
|
||||
count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryRecord(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = Number(item.amount || 0)
|
||||
const amountValue = Number.isFinite(amount) ? amount : 0
|
||||
const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销'
|
||||
const reason = String(item.reason || '').trim()
|
||||
const documentDate = String(item.document_date || '').trim()
|
||||
const occurredAt = String(item.occurred_at || '').trim()
|
||||
|
||||
return {
|
||||
claimId: String(item.claim_id || '').trim(),
|
||||
claimNo: String(item.claim_no || '').trim() || '未编号',
|
||||
employeeName: String(item.employee_name || '').trim(),
|
||||
expenseType: String(item.expense_type || '').trim(),
|
||||
expenseTypeLabel,
|
||||
amount: amountValue,
|
||||
amountDisplay: formatAmountDisplay(amountValue),
|
||||
status: String(item.status || '').trim(),
|
||||
statusLabel: String(item.status_label || '处理中').trim() || '处理中',
|
||||
statusGroup: String(item.status_group || 'other').trim() || 'other',
|
||||
statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态',
|
||||
approvalStage: String(item.approval_stage || '').trim(),
|
||||
documentDate,
|
||||
occurredAt,
|
||||
reason,
|
||||
location: String(item.location || '').trim(),
|
||||
summary: reason || `${expenseTypeLabel}报销`,
|
||||
dateDisplay: documentDate || occurredAt || '待补充日期'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExpenseStatusGroup(status) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (['draft', 'supplement', 'returned'].includes(normalized)) {
|
||||
return { key: 'draft', label: normalized === 'draft' ? '草稿' : '待完善' }
|
||||
}
|
||||
if (['submitted', 'review'].includes(normalized)) {
|
||||
return { key: 'in_progress', label: '审批中' }
|
||||
}
|
||||
if (['approved', 'paid'].includes(normalized)) {
|
||||
return { key: 'completed', label: '已完成' }
|
||||
}
|
||||
return { key: 'other', label: '其他状态' }
|
||||
}
|
||||
|
||||
export function formatQueryRecordDate(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
return text.includes('T') ? text.split('T')[0] : text.slice(0, 10)
|
||||
}
|
||||
|
||||
export function buildQueryRecordFromClaim(claim) {
|
||||
if (!claim || typeof claim !== 'object') {
|
||||
return null
|
||||
}
|
||||
const claimId = String(claim.id || claim.claim_id || '').trim()
|
||||
if (!claimId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const status = String(claim.status || '').trim()
|
||||
const statusGroup = resolveExpenseStatusGroup(status)
|
||||
return {
|
||||
claim_id: claimId,
|
||||
claim_no: String(claim.claim_no || claim.claimNo || '').trim() || '未编号',
|
||||
employee_name: String(claim.employee_name || claim.employeeName || '').trim(),
|
||||
expense_type: String(claim.expense_type || claim.expenseType || '').trim(),
|
||||
expense_type_label: EXPENSE_TYPE_LABELS[String(claim.expense_type || claim.expenseType || '').trim()] || String(claim.expense_type || claim.expenseType || '报销').trim(),
|
||||
amount: Number(claim.amount || 0),
|
||||
status,
|
||||
status_label: EXPENSE_STATUS_LABELS[status] || statusGroup.label,
|
||||
status_group: statusGroup.key,
|
||||
status_group_label: statusGroup.label,
|
||||
approval_stage: String(claim.approval_stage || claim.approvalStage || '').trim(),
|
||||
document_date: formatQueryRecordDate(claim.submitted_at || claim.submittedAt || claim.created_at || claim.createdAt || claim.occurred_at || claim.occurredAt),
|
||||
occurred_at: formatQueryRecordDate(claim.occurred_at || claim.occurredAt),
|
||||
reason: String(claim.reason || '').trim(),
|
||||
location: String(claim.location || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDraftAssociationQueryPayload(claims) {
|
||||
const records = (Array.isArray(claims) ? claims : [])
|
||||
.filter((claim) => ASSOCIATABLE_CLAIM_STATUSES.has(String(claim?.status || '').trim()))
|
||||
.map(buildQueryRecordFromClaim)
|
||||
.filter(Boolean)
|
||||
|
||||
const statusGroups = records.reduce((groups, record) => {
|
||||
const key = String(record.status_group || 'other')
|
||||
const existing = groups.get(key) || {
|
||||
key,
|
||||
label: String(record.status_group_label || '其他状态'),
|
||||
count: 0
|
||||
}
|
||||
existing.count += 1
|
||||
groups.set(key, existing)
|
||||
return groups
|
||||
}, new Map())
|
||||
|
||||
return normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
title: '选择关联草稿',
|
||||
scope_label: '可关联草稿',
|
||||
selection_mode: 'draft_association',
|
||||
empty_text: '当前没有可关联的草稿单据。',
|
||||
recent_window_applied: false,
|
||||
record_count: records.length,
|
||||
preview_count: records.length,
|
||||
total_amount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0),
|
||||
status_groups: Array.from(statusGroups.values()),
|
||||
records
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultType = String(payload.result_type || '').trim()
|
||||
if (resultType && resultType !== 'expense_claim_list') {
|
||||
return null
|
||||
}
|
||||
|
||||
const records = (Array.isArray(payload.records) ? payload.records : [])
|
||||
.map(normalizeExpenseQueryRecord)
|
||||
.filter(Boolean)
|
||||
const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : [])
|
||||
.map(normalizeExpenseQueryStatusGroup)
|
||||
.filter(Boolean)
|
||||
|
||||
const rawRecordCount = Number(payload.record_count || 0)
|
||||
const rawPreviewCount = Number(payload.preview_count || records.length)
|
||||
const rawOlderRecordCount = Number(payload.older_record_count || 0)
|
||||
const totalAmount = Number(payload.total_amount || 0)
|
||||
const rawWindowDays = Number(payload.window_days || 0)
|
||||
const windowStartDate = String(payload.window_start_date || '').trim()
|
||||
const windowEndDate = String(payload.window_end_date || '').trim()
|
||||
|
||||
return {
|
||||
resultType: 'expense_claim_list',
|
||||
scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单',
|
||||
selectionMode: String(payload.selection_mode || payload.selectionMode || '').trim(),
|
||||
selectionLocked: Boolean(payload.selection_locked || payload.selectionLocked),
|
||||
selectedClaimId: String(payload.selected_claim_id || payload.selectedClaimId || '').trim(),
|
||||
title: String(payload.title || '').trim(),
|
||||
emptyText: String(payload.empty_text || payload.emptyText || '').trim(),
|
||||
recentWindowApplied: Boolean(payload.recent_window_applied),
|
||||
windowDays:
|
||||
payload.window_days === null || payload.window_days === undefined || payload.window_days === ''
|
||||
? null
|
||||
: (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null),
|
||||
windowStartDate: windowStartDate || '',
|
||||
windowEndDate: windowEndDate || '',
|
||||
recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0,
|
||||
previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length,
|
||||
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,
|
||||
statusGroups,
|
||||
records,
|
||||
currentPage: 1
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExpenseQueryWindowLabel(queryPayload) {
|
||||
if (!queryPayload) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (queryPayload.selectionMode === 'draft_association') {
|
||||
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
|
||||
}
|
||||
|
||||
if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
|
||||
return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}`
|
||||
}
|
||||
|
||||
if (queryPayload.recentWindowApplied && queryPayload.windowDays) {
|
||||
return `近 ${queryPayload.windowDays} 日内`
|
||||
}
|
||||
|
||||
return '当前条件下'
|
||||
}
|
||||
|
||||
export function getExpenseQueryTotalPages(queryPayload) {
|
||||
const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0
|
||||
return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE))
|
||||
}
|
||||
|
||||
export function getExpenseQueryActivePage(queryPayload) {
|
||||
const totalPages = getExpenseQueryTotalPages(queryPayload)
|
||||
const rawPage = Number(queryPayload?.currentPage || 1)
|
||||
if (!Number.isFinite(rawPage)) {
|
||||
return 1
|
||||
}
|
||||
return Math.min(Math.max(1, Math.round(rawPage)), totalPages)
|
||||
}
|
||||
|
||||
export function getExpenseQueryVisibleRecords(queryPayload) {
|
||||
const records = Array.isArray(queryPayload?.records) ? queryPayload.records : []
|
||||
const activePage = getExpenseQueryActivePage(queryPayload)
|
||||
const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE
|
||||
return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE)
|
||||
}
|
||||
|
||||
export function buildExpenseQueryHint(queryPayload) {
|
||||
if (!queryPayload) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (queryPayload.selectionMode === 'draft_association') {
|
||||
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
|
||||
return '已选择关联草稿,附件将按该单据继续识别和归集。'
|
||||
}
|
||||
return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。'
|
||||
}
|
||||
|
||||
const parts = []
|
||||
const windowText = buildExpenseQueryWindowLabel(queryPayload)
|
||||
|
||||
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} 日的单据,请前往个人报销中心查看`)
|
||||
}
|
||||
|
||||
return parts.join('。')
|
||||
}
|
||||
149
web/src/views/scripts/travelReimbursementReviewConstants.js
Normal file
149
web/src/views/scripts/travelReimbursementReviewConstants.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js'
|
||||
|
||||
export const DOCUMENT_TYPE_LABELS = {
|
||||
travel_ticket: '行程单/机票/车票',
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
transport_receipt: '交通出行票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
export const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
meal: '伙食费',
|
||||
meeting: '会务费',
|
||||
entertainment: '业务招待费',
|
||||
office: '办公费',
|
||||
training: '培训费',
|
||||
communication: '通讯费',
|
||||
welfare: '福利费',
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
export const REVIEW_SLOT_CONFIG = {
|
||||
expense_type: {
|
||||
title: '报销分类',
|
||||
hint: '请选择本次报销分类',
|
||||
status: '待确认',
|
||||
icon: 'mdi mdi-shape-outline'
|
||||
},
|
||||
customer_name: {
|
||||
title: '关联客户',
|
||||
hint: '请补充客户单位全称',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-domain'
|
||||
},
|
||||
time_range: {
|
||||
title: '发生时间',
|
||||
hint: '请按 YYYY-MM-DD 补充业务发生日期',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline'
|
||||
},
|
||||
location: {
|
||||
title: '业务地点',
|
||||
hint: '请补充业务发生地点',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-map-marker-outline'
|
||||
},
|
||||
merchant_name: {
|
||||
title: '酒店/商户',
|
||||
hint: '请补充酒店或商户名称',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-storefront-outline'
|
||||
},
|
||||
amount: {
|
||||
title: '金额',
|
||||
hint: '请补充本次费用金额',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-cash'
|
||||
},
|
||||
reason: {
|
||||
title: '场景 / 事由',
|
||||
hint: '请补充本次费用场景或事由',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-text-box-outline'
|
||||
},
|
||||
participants: {
|
||||
title: '同行人员',
|
||||
hint: '请至少填写 1 名同行人员',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-account-group-outline'
|
||||
},
|
||||
attachments: {
|
||||
title: '票据状态',
|
||||
hint: '请上传发票/收据等票据附件',
|
||||
status: '未上传',
|
||||
icon: 'mdi mdi-paperclip'
|
||||
}
|
||||
}
|
||||
|
||||
export const REVIEW_FALLBACK_GROUP_CODES = [
|
||||
'other',
|
||||
'travel',
|
||||
'transport',
|
||||
'hotel',
|
||||
'meal',
|
||||
'meeting',
|
||||
'entertainment',
|
||||
'office',
|
||||
'training',
|
||||
'communication',
|
||||
'welfare'
|
||||
]
|
||||
|
||||
export const REVIEW_CATEGORY_PRESET_OPTIONS = [
|
||||
{ key: 'travel', label: '差旅费' },
|
||||
{ key: 'transport', label: '交通费' },
|
||||
{ key: 'hotel', label: '住宿费' },
|
||||
{ key: 'meal', label: '餐费' },
|
||||
{ key: 'entertainment', label: '业务招待费' },
|
||||
{ key: 'other_trigger', label: '其他类型', is_other: true }
|
||||
]
|
||||
|
||||
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
||||
{ key: 'meeting', label: '会务费' },
|
||||
{ key: 'office', label: '办公费' },
|
||||
{ key: 'training', label: '培训费' },
|
||||
{ key: 'communication', label: '通讯费' },
|
||||
{ key: 'welfare', label: '福利费' },
|
||||
{ key: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
export const REVIEW_SCENE_OTHER_OPTION = '其他场景'
|
||||
|
||||
export const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION]
|
||||
|
||||
export const EXPENSE_CODE_TO_PRESET_SCENE = {
|
||||
travel: '出差行程',
|
||||
hotel: '住宿报销',
|
||||
transport: '交通出行',
|
||||
meeting: '会务活动',
|
||||
entertainment: '请客户吃饭',
|
||||
meal: '请客户吃饭'
|
||||
}
|
||||
|
||||
export const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
export const CATEGORY_CONFIDENCE_KEYWORDS = {
|
||||
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
||||
hotel: [/住宿|酒店|宾馆|民宿/],
|
||||
transport: [TRANSPORT_KEYWORD_PATTERN],
|
||||
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
||||
training: [/培训|授课|讲师|课程|签到|讲义/],
|
||||
communication: [/通讯|电话|流量|话费|宽带|网络/],
|
||||
welfare: [/福利|体检|团建|节日|慰问|关怀/]
|
||||
}
|
||||
155
web/src/views/scripts/travelReimbursementReviewDocuments.js
Normal file
155
web/src/views/scripts/travelReimbursementReviewDocuments.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
EXPENSE_TYPE_LABELS
|
||||
} from './travelReimbursementReviewConstants.js'
|
||||
|
||||
export function cloneReviewDocumentDrafts(items) {
|
||||
return (Array.isArray(items) ? items : []).map((item) => ({
|
||||
...item,
|
||||
warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [],
|
||||
fields: Array.isArray(item?.fields)
|
||||
? item.fields.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || ''),
|
||||
source: String(field?.source || 'ocr').trim() || 'ocr'
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildReviewDocumentDrafts(reviewPayload) {
|
||||
return buildReviewDocumentSummaries(reviewPayload).map((item) => ({
|
||||
index: Number(item.index || 0),
|
||||
filename: String(item.filename || '').trim(),
|
||||
document_type: String(item.document_type || 'other').trim() || 'other',
|
||||
suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other',
|
||||
scene_label: String(item.scene_label || '').trim(),
|
||||
summary: String(item.summary || '').trim(),
|
||||
confidenceLabel: String(item.confidenceLabel || '').trim(),
|
||||
documentTypeLabel: String(item.documentTypeLabel || '').trim(),
|
||||
expenseTypeLabel: String(item.expenseTypeLabel || '').trim(),
|
||||
preview_kind: String(item.preview_kind || '').trim(),
|
||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||
warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
|
||||
fields: Array.isArray(item.fields)
|
||||
? item.fields.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || ''),
|
||||
source: String(field?.source || 'ocr').trim() || 'ocr'
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentComparableValue(item) {
|
||||
return {
|
||||
index: Number(item?.index || 0),
|
||||
filename: String(item?.filename || '').trim(),
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) {
|
||||
const baseMap = new Map(
|
||||
cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item])
|
||||
)
|
||||
|
||||
return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => {
|
||||
const key = `${item.index}:${item.filename}`
|
||||
const base = baseMap.get(key)
|
||||
const changes = []
|
||||
const nextSceneLabel = String(item.scene_label || '').trim()
|
||||
const baseSceneLabel = String(base?.scene_label || '').trim()
|
||||
const nextSummary = String(item.summary || '').trim()
|
||||
const baseSummary = String(base?.summary || '').trim()
|
||||
|
||||
if (nextSceneLabel !== baseSceneLabel) {
|
||||
changes.push(`票据场景:${nextSceneLabel || '待补充'}`)
|
||||
}
|
||||
|
||||
if (nextSummary !== baseSummary) {
|
||||
changes.push(`识别摘要:${nextSummary || '待补充'}`)
|
||||
}
|
||||
|
||||
const baseFieldMap = new Map(
|
||||
(Array.isArray(base?.fields) ? base.fields : []).map((field) => [
|
||||
String(field?.label || '').trim(),
|
||||
String(field?.value || '').trim()
|
||||
])
|
||||
)
|
||||
|
||||
for (const field of Array.isArray(item.fields) ? item.fields : []) {
|
||||
const label = String(field?.label || '').trim()
|
||||
if (!label) continue
|
||||
const nextValue = String(field?.value || '').trim()
|
||||
const baseValue = baseFieldMap.get(label) || ''
|
||||
if (nextValue !== baseValue) {
|
||||
changes.push(`${label}:${nextValue || '待补充'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length) {
|
||||
lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`)
|
||||
}
|
||||
|
||||
return lines
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) {
|
||||
const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
|
||||
if (!lines.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `请同步修正逐票据识别结果:\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
export function buildReviewDocumentCorrectionContext(drafts) {
|
||||
return cloneReviewDocumentDrafts(drafts).map((item) => ({
|
||||
index: item.index,
|
||||
filename: item.filename,
|
||||
scene_label: String(item.scene_label || '').trim(),
|
||||
summary: String(item.summary || '').trim(),
|
||||
fields: item.fields.map((field) => ({
|
||||
label: String(field.label || '').trim(),
|
||||
value: String(field.value || '').trim()
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatConfidenceLabel(value) {
|
||||
const score = Number(value || 0)
|
||||
if (!score) return '待补充'
|
||||
return `${Math.round(score * 100)}%`
|
||||
}
|
||||
|
||||
export function resolveDocumentTypeLabel(type) {
|
||||
return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeLabel(type, fallbackLabel = '') {
|
||||
const normalized = String(type || '').trim()
|
||||
return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
export function buildReviewDocumentSummaries(reviewPayload) {
|
||||
const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return docs.map((item) => {
|
||||
const fields = Array.isArray(item.fields) ? item.fields : []
|
||||
return {
|
||||
...item,
|
||||
documentTypeLabel: resolveDocumentTypeLabel(item.document_type),
|
||||
expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label),
|
||||
confidenceLabel: formatConfidenceLabel(item.avg_score),
|
||||
lines: fields
|
||||
.filter((field) => String(field?.value || '').trim())
|
||||
.map((field) => `${field.label}:${field.value}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
1463
web/src/views/scripts/travelReimbursementReviewModel.js
Normal file
1463
web/src/views/scripts/travelReimbursementReviewModel.js
Normal file
File diff suppressed because it is too large
Load Diff
545
web/src/views/scripts/travelRequestDetailExpenseModel.js
Normal file
545
web/src/views/scripts/travelRequestDetailExpenseModel.js
Normal file
@@ -0,0 +1,545 @@
|
||||
export const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'train_ticket', label: '火车票' },
|
||||
{ value: 'flight_ticket', label: '机票' },
|
||||
{ value: 'hotel_ticket', label: '住宿票' },
|
||||
{ value: 'ride_ticket', label: '乘车' },
|
||||
{ value: 'entertainment', label: '业务招待费' },
|
||||
{ value: 'office', label: '办公费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
{ value: 'training', label: '培训费' },
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '餐费' },
|
||||
{ value: 'travel_allowance', label: '出差补贴' },
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
|
||||
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
||||
|
||||
export function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
export function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function normalizeExpenseType(value) {
|
||||
return String(value || '').trim() || 'other'
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
export function isSystemGeneratedExpenseItemSource(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
export function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
export function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
|
||||
export function isRouteDescriptionExpenseType(value) {
|
||||
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
export function isHotelDescriptionExpenseType(value) {
|
||||
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
export function resolveExpenseDetailHint(expenseType) {
|
||||
if (isRouteDescriptionExpenseType(expenseType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(expenseType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
if (!isLocationRequiredExpenseType(expenseType)) {
|
||||
return '非必填'
|
||||
}
|
||||
return '待补充'
|
||||
}
|
||||
|
||||
export function resolveLocationDisplay(value, expenseType) {
|
||||
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
|
||||
}
|
||||
|
||||
export function isSyntheticLocationDisplay(value, expenseType) {
|
||||
const text = String(value || '').trim()
|
||||
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
|
||||
}
|
||||
|
||||
export function isValidRouteDescription(value) {
|
||||
const text = String(value || '').trim()
|
||||
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
||||
}
|
||||
|
||||
export function resolveExpenseReasonPlaceholder(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
export function resolveExpenseReasonHelper(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
export function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
}
|
||||
|
||||
export function buildFallbackExpenseItems(request) {
|
||||
return [
|
||||
buildExpenseItemViewModel({
|
||||
id: 'fallback-1',
|
||||
itemDate: '',
|
||||
itemType: request.typeCode || 'other',
|
||||
itemReason: request.reason,
|
||||
itemLocation: request.sceneTarget,
|
||||
itemAmount: parseCurrency(request.amountDisplay),
|
||||
invoiceId: '',
|
||||
time: '待补充',
|
||||
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
||||
name: request.typeLabel,
|
||||
category: request.typeLabel,
|
||||
desc: request.reason,
|
||||
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
||||
amount: request.amountDisplay,
|
||||
status: '待补充',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '待上传',
|
||||
attachmentHint: '请在此单据中继续补充附件',
|
||||
attachmentTone: 'missing',
|
||||
attachments: [],
|
||||
riskLabel: '待补材料',
|
||||
riskText: request.riskSummary,
|
||||
riskTone: 'medium'
|
||||
}, 0, request)
|
||||
]
|
||||
}
|
||||
|
||||
export function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
export function normalizeDetailNoteDraftValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
export function isValidIsoDate(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
const year = Number(yearText)
|
||||
const month = Number(monthText)
|
||||
const day = Number(dayText)
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||
return (
|
||||
candidate.getUTCFullYear() === year &&
|
||||
candidate.getUTCMonth() === month - 1 &&
|
||||
candidate.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeIsoDateValue(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (isValidIsoDate(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
if (match && isValidIsoDate(match[1])) {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
export function resolveExpenseUploadHint(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
export function extractAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
export function resolveExpenseItemViewId(source, index, requestModel) {
|
||||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
export function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, requestModel),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
|
||||
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType })
|
||||
}
|
||||
})
|
||||
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
||||
.sort((left, right) => {
|
||||
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
||||
return dateCompare || left.index - right.index
|
||||
})
|
||||
|
||||
const labels = new Map()
|
||||
if (!travelItems.length) {
|
||||
return labels
|
||||
}
|
||||
|
||||
travelItems.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
labels.set(item.id, '出发时间')
|
||||
} else if (index === travelItems.length - 1) {
|
||||
labels.set(item.id, '返回时间')
|
||||
} else {
|
||||
labels.set(item.id, '中转时间')
|
||||
}
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||
const riskText = String(source?.riskText || '').trim()
|
||||
const filledAt = formatExpenseFilledTime(
|
||||
source?.filledAt
|
||||
|| source?.filled_at
|
||||
|| source?.createdAt
|
||||
|| source?.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
itemDate,
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
requestModel,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: amountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||
riskText,
|
||||
riskTone: String(source?.riskTone || '').trim() || 'low'
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildExpenseItems(items, requestModel) {
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||||
}
|
||||
|
||||
export function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||||
issues.push('行程说明格式错误')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const isTravelContext =
|
||||
requestModel?.detailVariant === 'travel' ||
|
||||
requestModel?.typeCode === 'travel' ||
|
||||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
|
||||
if (!isTravelContext) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hasUploadedType = (itemType) =>
|
||||
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
|
||||
const cards = []
|
||||
if (!hasUploadedType('hotel_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-hotel-ticket',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '住宿票据提醒',
|
||||
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
|
||||
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
|
||||
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
|
||||
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
|
||||
})
|
||||
}
|
||||
if (!hasUploadedType('ride_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-ride-ticket',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '乘车票据提醒',
|
||||
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
|
||||
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
|
||||
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
|
||||
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
export function buildDraftBlockingIssues(request, expenseItems) {
|
||||
const issues = []
|
||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
|
||||
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
|
||||
const amount = Number(item?.itemAmount || 0)
|
||||
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum
|
||||
}, 0)
|
||||
const hasValidItemDate = normalizedItems.some((item) => isValidIsoDate(item?.itemDate))
|
||||
const hasValidItemType = normalizedItems.some((item) => !isPlaceholderValue(item?.itemType))
|
||||
const hasValidItemReason = normalizedItems.some((item) => !isPlaceholderValue(item?.itemReason))
|
||||
const hasValidItemLocation = normalizedItems.some((item) => !isPlaceholderValue(item?.itemLocation))
|
||||
|
||||
if (isPlaceholderValue(request.profileName)) {
|
||||
issues.push('申请人未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) {
|
||||
issues.push('报销类型未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.reason) && !hasValidItemReason) {
|
||||
issues.push('报销事由未完善')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(request.location) && !hasValidItemLocation) {
|
||||
issues.push('业务地点未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.occurredDisplay) && !hasValidItemDate) {
|
||||
issues.push('发生时间未完善')
|
||||
}
|
||||
if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) {
|
||||
issues.push('报销金额未完善')
|
||||
}
|
||||
if (!normalizedItems.length) {
|
||||
issues.push('费用明细不能为空')
|
||||
}
|
||||
|
||||
normalizedItems.forEach((item, index) => {
|
||||
buildExpenseDraftIssues(item).forEach((issue) => {
|
||||
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
||||
})
|
||||
})
|
||||
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export function mapIssueToAdvice(issue) {
|
||||
const text = String(issue || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (text === '费用明细不能为空') {
|
||||
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
|
||||
}
|
||||
if (text === '申请人未完善') {
|
||||
return '补充申请人信息,确保审批单据归属明确。'
|
||||
}
|
||||
if (text === '所属部门未完善') {
|
||||
return '补充所属部门,便于财务和审批人识别成本归属。'
|
||||
}
|
||||
if (text === '报销类型未完善') {
|
||||
return '选择报销类型,明确本次费用归类。'
|
||||
}
|
||||
if (text === '报销事由未完善') {
|
||||
return '补充报销事由,说明本次费用用途。'
|
||||
}
|
||||
if (text === '业务地点未完善') {
|
||||
return '补充业务地点,方便审核业务发生场景。'
|
||||
}
|
||||
if (text === '发生时间未完善') {
|
||||
return '补充费用发生时间,确保单据时间完整。'
|
||||
}
|
||||
if (text === '报销金额未完善') {
|
||||
return '补充报销金额,并与费用明细金额保持一致。'
|
||||
}
|
||||
|
||||
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
||||
if (!itemMatch) {
|
||||
return text
|
||||
}
|
||||
|
||||
const [, indexText, fieldText] = itemMatch
|
||||
const labelPrefix = `完善第 ${indexText} 条费用明细`
|
||||
if (fieldText === '缺少日期') {
|
||||
return `${labelPrefix}的发生日期。`
|
||||
}
|
||||
if (fieldText === '缺少费用项目') {
|
||||
return `${labelPrefix}的费用项目。`
|
||||
}
|
||||
if (fieldText === '缺少说明') {
|
||||
return `${labelPrefix}的用途说明。`
|
||||
}
|
||||
if (fieldText === '行程说明格式错误') {
|
||||
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
||||
}
|
||||
if (fieldText === '缺少地点') {
|
||||
return `${labelPrefix}的业务地点。`
|
||||
}
|
||||
if (fieldText === '缺少金额') {
|
||||
return `${labelPrefix}的金额。`
|
||||
}
|
||||
if (fieldText === '缺少票据标识') {
|
||||
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
||||
}
|
||||
|
||||
return `${labelPrefix}。`
|
||||
}
|
||||
@@ -30,6 +30,62 @@ function normalizeTone(value) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
export function normalizeRiskTone(value) {
|
||||
return normalizeTone(value)
|
||||
}
|
||||
|
||||
export function resolveRiskTagTone(tag) {
|
||||
const normalized = normalizeText(tag).toLowerCase()
|
||||
if (normalized === '#high_risk') return 'high'
|
||||
if (normalized === '#middle_risk') return 'medium'
|
||||
if (normalized === '#low_risk') return 'low'
|
||||
if (normalized === '#hotel') return 'hotel'
|
||||
if (normalized === '#traffic') return 'traffic'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export function extractRiskTagsFromText(text) {
|
||||
const matches = normalizeText(text).match(/#[A-Za-z_]+/g) || []
|
||||
return [...new Set(matches.map((tag) => tag.toLowerCase()))]
|
||||
}
|
||||
|
||||
export function resolveRiskTags(card = {}) {
|
||||
const tags = []
|
||||
const tone = normalizeTone(card.tone || card.severity)
|
||||
if (tone === 'high') {
|
||||
tags.push('#high_risk')
|
||||
} else if (tone === 'medium') {
|
||||
tags.push('#middle_risk')
|
||||
} else if (tone === 'low') {
|
||||
tags.push('#low_risk')
|
||||
}
|
||||
|
||||
const text = [
|
||||
card.label,
|
||||
card.title,
|
||||
card.risk,
|
||||
card.summary,
|
||||
card.suggestion,
|
||||
card.itemType,
|
||||
card.documentType
|
||||
].map((item) => normalizeText(item).toLowerCase()).join(' ')
|
||||
if (/住宿|酒店|宾馆|hotel/.test(text)) {
|
||||
tags.push('#hotel')
|
||||
}
|
||||
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi|traffic|transport/.test(text)) {
|
||||
tags.push('#traffic')
|
||||
}
|
||||
|
||||
return [...new Set(tags)]
|
||||
}
|
||||
|
||||
function withRiskTags(card) {
|
||||
return {
|
||||
...card,
|
||||
tags: resolveRiskTags(card)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
@@ -109,7 +165,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
|
||||
return {
|
||||
return withRiskTags({
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
tone,
|
||||
label,
|
||||
@@ -117,8 +173,10 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
||||
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
|
||||
summary: normalizeText(analysis?.summary),
|
||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||
suggestion: buildCardSuggestion(analysis, insight)
|
||||
}
|
||||
suggestion: buildCardSuggestion(analysis, insight),
|
||||
itemType: normalizeText(item?.itemType),
|
||||
documentType: normalizeText(insight?.documentTypeLabel)
|
||||
})
|
||||
}
|
||||
|
||||
function parseReturnCount(flag) {
|
||||
@@ -170,7 +228,7 @@ function buildManualReturnRiskCard(flag) {
|
||||
...riskPoints.map((item) => `退回风险点:${item}。`)
|
||||
])
|
||||
|
||||
return {
|
||||
return withRiskTags({
|
||||
id: `manual-return-${returnCount || 'latest'}`,
|
||||
tone: 'medium',
|
||||
label: '退回原因',
|
||||
@@ -179,7 +237,7 @@ function buildManualReturnRiskCard(flag) {
|
||||
summary: normalizeText(flag.reason),
|
||||
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
@@ -220,7 +278,7 @@ export function buildAttachmentRiskCards({
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
const risk = normalizeText(flag)
|
||||
return risk
|
||||
? [{
|
||||
? [withRiskTags({
|
||||
id: `claim-risk-${index}`,
|
||||
tone: 'medium',
|
||||
label: '单据风险',
|
||||
@@ -229,7 +287,7 @@ export function buildAttachmentRiskCards({
|
||||
summary: '',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}]
|
||||
})]
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -251,7 +309,7 @@ export function buildAttachmentRiskCards({
|
||||
'系统预审规则命中该风险提示。'
|
||||
])
|
||||
|
||||
return risks.map((risk, pointIndex) => ({
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
tone,
|
||||
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
|
||||
|
||||
305
web/src/views/scripts/useTravelReimbursementAttachments.js
Normal file
305
web/src/views/scripts/useTravelReimbursementAttachments.js
Normal file
@@ -0,0 +1,305 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
function normalizeAttachmentMatchName(value) {
|
||||
const fileName = String(value || '')
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.pop() || ''
|
||||
return fileName
|
||||
.toLowerCase()
|
||||
.replace(/[^\w.\-\u4e00-\u9fff]+/g, '_')
|
||||
.replace(/^[_\.]+|[_\.]+$/g, '')
|
||||
}
|
||||
|
||||
export function useTravelReimbursementAttachments({
|
||||
isKnowledgeSession,
|
||||
reviewFilePreviews,
|
||||
linkedRequest,
|
||||
draftClaimId,
|
||||
activeReviewPayload,
|
||||
reviewInlinePendingFiles,
|
||||
reviewInlineForm,
|
||||
reviewInlineEditorKey,
|
||||
composerUploadIntent,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
toast,
|
||||
fileInputRef,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
extractReviewAttachmentNames,
|
||||
mergeFilesWithLimit,
|
||||
mergeFilePreviews,
|
||||
resolveAttachmentPreviewKind,
|
||||
resolveDocumentPreview,
|
||||
buildFilePreviews,
|
||||
buildFileIdentity,
|
||||
MAX_ATTACHMENTS,
|
||||
VISIBLE_ATTACHMENT_CHIPS,
|
||||
clearInlineReviewFieldError
|
||||
}) {
|
||||
const fileInputMode = ref('composer')
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const previewRegistry = []
|
||||
const restoredDraftPreviewClaims = new Set()
|
||||
|
||||
const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS))
|
||||
const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS))
|
||||
|
||||
function rememberFilePreviews(filePreviews) {
|
||||
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
|
||||
}
|
||||
|
||||
function trackPreviewObjectUrl(url) {
|
||||
if (!url || !String(url).startsWith('blob:')) {
|
||||
return
|
||||
}
|
||||
previewRegistry.push(url)
|
||||
}
|
||||
|
||||
function buildComposerFilePreviews(files) {
|
||||
const filePreviews = buildFilePreviews(files, previewRegistry)
|
||||
rememberFilePreviews(filePreviews)
|
||||
return filePreviews
|
||||
}
|
||||
|
||||
function resolveActiveClaimId() {
|
||||
return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim()
|
||||
}
|
||||
|
||||
async function buildPersistedAttachmentPreview(metadata) {
|
||||
const filename = String(metadata?.file_name || '').trim()
|
||||
const kind = resolveAttachmentPreviewKind(metadata)
|
||||
const previewPath = String(metadata?.preview_url || '').trim()
|
||||
if (!filename || !kind || !previewPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blob = await fetchExpenseClaimAttachmentAsset(previewPath)
|
||||
const url = URL.createObjectURL(blob)
|
||||
trackPreviewObjectUrl(url)
|
||||
return {
|
||||
filename,
|
||||
kind,
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const force = Boolean(options.force)
|
||||
if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const previews = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
if (!itemId) continue
|
||||
|
||||
let metadata = null
|
||||
try {
|
||||
metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const filename = String(metadata?.file_name || '').trim()
|
||||
if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await buildPersistedAttachmentPreview(metadata)
|
||||
if (preview) {
|
||||
previews.push(preview)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load persisted attachment preview:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (previews.length) {
|
||||
rememberFilePreviews(previews)
|
||||
}
|
||||
restoredDraftPreviewClaims.add(normalizedClaimId)
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore persisted draft attachment previews:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncComposerFilesToDraft(claimId, files) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const exactMatchBuckets = new Map()
|
||||
const normalizedMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const usedItemIds = new Set()
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
if (invoiceId && !invoiceId.includes('/')) {
|
||||
placeholderQueue.push(item)
|
||||
}
|
||||
if (!invoiceId) continue
|
||||
const bucket = exactMatchBuckets.get(invoiceId) || []
|
||||
bucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, bucket)
|
||||
|
||||
const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId)
|
||||
if (normalizedInvoiceName) {
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || []
|
||||
normalizedBucket.push(item)
|
||||
normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const exactBucket = exactMatchBuckets.get(file.name) || []
|
||||
const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
|
||||
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch
|
||||
const targetItemId = String(targetItem?.id || '').trim()
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
}
|
||||
|
||||
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
|
||||
}
|
||||
|
||||
function triggerFileUpload(mode = 'composer') {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
fileInputMode.value = mode
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFilesChange(event) {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
|
||||
if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) {
|
||||
const existingNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0)
|
||||
const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots)
|
||||
|
||||
if (!remainingSlots && files.length) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`)
|
||||
} else if (mergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`)
|
||||
}
|
||||
|
||||
reviewInlinePendingFiles.value = mergeResult.files
|
||||
const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)]
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
attachment_names: allAttachmentNames.join('、'),
|
||||
attachment_count: allAttachmentNames.length,
|
||||
pending_attachment_count: mergeResult.files.length
|
||||
}
|
||||
clearInlineReviewFieldError('attachments')
|
||||
reviewInlineEditorKey.value = ''
|
||||
} else {
|
||||
if (isKnowledgeSession.value) {
|
||||
toast('财务知识问答暂不支持上传附件。')
|
||||
fileInputMode.value = 'composer'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS)
|
||||
attachedFiles.value = mergeResult.files
|
||||
if (fileInputMode.value === 'composer-continue' && files.length) {
|
||||
composerUploadIntent.value = 'continue_existing'
|
||||
}
|
||||
if (mergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) {
|
||||
composerFilesExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fileInputMode.value = 'composer'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAttachedFilesExpanded() {
|
||||
composerFilesExpanded.value = !composerFilesExpanded.value
|
||||
}
|
||||
|
||||
function removeAttachedFile(targetFile) {
|
||||
const fileKey = buildFileIdentity(targetFile)
|
||||
attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey)
|
||||
if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) {
|
||||
composerFilesExpanded.value = false
|
||||
}
|
||||
if (!attachedFiles.value.length) {
|
||||
composerUploadIntent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearAttachedFiles() {
|
||||
attachedFiles.value = []
|
||||
composerFilesExpanded.value = false
|
||||
composerUploadIntent.value = ''
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function stopAttachmentRuntime() {
|
||||
for (const url of previewRegistry) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
previewRegistry.length = 0
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputMode,
|
||||
attachedFiles,
|
||||
composerFilesExpanded,
|
||||
visibleAttachedFiles,
|
||||
hiddenAttachedFileCount,
|
||||
rememberFilePreviews,
|
||||
trackPreviewObjectUrl,
|
||||
buildComposerFilePreviews,
|
||||
resolveActiveClaimId,
|
||||
buildPersistedAttachmentPreview,
|
||||
restorePersistedDraftAttachmentPreviews,
|
||||
syncComposerFilesToDraft,
|
||||
triggerFileUpload,
|
||||
handleFilesChange,
|
||||
toggleAttachedFilesExpanded,
|
||||
removeAttachedFile,
|
||||
clearAttachedFiles,
|
||||
stopAttachmentRuntime
|
||||
}
|
||||
}
|
||||
396
web/src/views/scripts/useTravelReimbursementComposerTools.js
Normal file
396
web/src/views/scripts/useTravelReimbursementComposerTools.js
Normal file
@@ -0,0 +1,396 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
export function useTravelReimbursementComposerTools({
|
||||
currentUser,
|
||||
activeReviewPayload,
|
||||
reviewInlineForm,
|
||||
latestReviewMessage,
|
||||
currentInsight,
|
||||
messages,
|
||||
composerDraft,
|
||||
composerTextareaRef,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
createMessage,
|
||||
buildReviewSlotMap,
|
||||
isValidIsoDateString,
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
}) {
|
||||
const composerDatePickerOpen = ref(false)
|
||||
const composerDateMode = ref('single')
|
||||
const composerSingleDate = ref(formatDateInputValue())
|
||||
const composerRangeStartDate = ref(formatDateInputValue())
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const composerBusinessTimeTags = ref([])
|
||||
const composerBusinessTimeDraftTouched = ref(false)
|
||||
const travelCalculatorOpen = ref(false)
|
||||
const travelCalculatorBusy = ref(false)
|
||||
const travelCalculatorError = ref('')
|
||||
const travelCalculatorResult = ref(null)
|
||||
const travelCalculatorForm = ref({
|
||||
days: '1',
|
||||
location: ''
|
||||
})
|
||||
const composerCanApplyDateSelection = computed(() => {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return Boolean(composerSingleDate.value)
|
||||
}
|
||||
return Boolean(
|
||||
composerRangeStartDate.value
|
||||
&& composerRangeEndDate.value
|
||||
&& composerRangeStartDate.value <= composerRangeEndDate.value
|
||||
)
|
||||
})
|
||||
const travelCalculatorCanSubmit = computed(() =>
|
||||
!travelCalculatorBusy.value
|
||||
&& Number(travelCalculatorForm.value.days) >= 1
|
||||
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
|
||||
)
|
||||
function buildComposerBusinessTimeLabel() {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return `业务发生时间:${composerSingleDate.value}`
|
||||
}
|
||||
if (composerRangeStartDate.value === composerRangeEndDate.value) {
|
||||
return `业务发生时间:${composerRangeStartDate.value}`
|
||||
}
|
||||
return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayValue = mode === 'range' && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate
|
||||
return {
|
||||
mode,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
occurred_date: startDate,
|
||||
time_range: displayValue,
|
||||
business_time: displayValue,
|
||||
time_range_raw: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
}
|
||||
|
||||
const baseReviewFormValues =
|
||||
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {}
|
||||
|
||||
return {
|
||||
...extraContext,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
business_time: businessTimeContext.business_time,
|
||||
business_time_context: {
|
||||
mode: businessTimeContext.mode,
|
||||
start_date: businessTimeContext.start_date,
|
||||
end_date: businessTimeContext.end_date,
|
||||
display_value: businessTimeContext.business_time
|
||||
},
|
||||
review_form_values: {
|
||||
...baseReviewFormValues,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
time_range: businessTimeContext.time_range,
|
||||
business_time: businessTimeContext.business_time,
|
||||
time_range_raw: businessTimeContext.time_range_raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
|
||||
if (!businessTimeContext || !activeReviewPayload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextInlineState = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: businessTimeContext.occurred_date
|
||||
}
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||
reviewInlineForm.value = nextInlineState
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComposerSubmitText(explicitRawText) {
|
||||
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
|
||||
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',')
|
||||
if (!tagPart) {
|
||||
return draftPart
|
||||
}
|
||||
if (!draftPart) {
|
||||
return tagPart
|
||||
}
|
||||
return `${tagPart},${draftPart}`
|
||||
}
|
||||
|
||||
function toggleComposerDatePicker() {
|
||||
composerDatePickerOpen.value = !composerDatePickerOpen.value
|
||||
if (composerDatePickerOpen.value) {
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeComposerDatePicker() {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
|
||||
function setComposerDateMode(mode) {
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
|
||||
if (!composerBusinessTimeTags.value.length) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerDatePickerOutside(event) {
|
||||
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
|
||||
return
|
||||
}
|
||||
if (composerDatePickerOpen.value) {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyComposerDateSelection() {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialDays() {
|
||||
const businessTimeContext = buildComposerBusinessTimeContext()
|
||||
if (!businessTimeContext) {
|
||||
return 1
|
||||
}
|
||||
const startDate = businessTimeContext.start_date
|
||||
const endDate = businessTimeContext.end_date || startDate
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return 1
|
||||
}
|
||||
const startAt = Date.parse(`${startDate}T00:00:00Z`)
|
||||
const endAt = Date.parse(`${endDate}T00:00:00Z`)
|
||||
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
|
||||
return 1
|
||||
}
|
||||
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialLocation() {
|
||||
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
|
||||
const candidates = [
|
||||
reviewInlineForm.value.location,
|
||||
slotMap.business_location?.normalized_value,
|
||||
slotMap.business_location?.value,
|
||||
slotMap.location?.normalized_value,
|
||||
slotMap.location?.value,
|
||||
currentUser.value?.location
|
||||
]
|
||||
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
|
||||
}
|
||||
|
||||
function openTravelCalculator() {
|
||||
closeComposerDatePicker()
|
||||
travelCalculatorError.value = ''
|
||||
travelCalculatorResult.value = null
|
||||
travelCalculatorForm.value = {
|
||||
days: String(resolveTravelCalculatorInitialDays()),
|
||||
location: resolveTravelCalculatorInitialLocation()
|
||||
}
|
||||
travelCalculatorOpen.value = true
|
||||
}
|
||||
|
||||
function toggleTravelCalculator() {
|
||||
if (travelCalculatorOpen.value) {
|
||||
closeTravelCalculator()
|
||||
return
|
||||
}
|
||||
openTravelCalculator()
|
||||
}
|
||||
|
||||
function closeTravelCalculator() {
|
||||
if (travelCalculatorBusy.value) {
|
||||
return
|
||||
}
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
|
||||
function formatTravelCalculatorMoney(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return String(value || '0')
|
||||
}
|
||||
return amount.toFixed(2)
|
||||
}
|
||||
|
||||
function buildTravelCalculatorResultText(result) {
|
||||
const days = Number(result?.days) || 1
|
||||
const location = String(result?.location || '').trim() || '未填写地点'
|
||||
const matchedCity = String(result?.matched_city || location).trim()
|
||||
const grade = String(result?.grade || '').trim() || '当前职级'
|
||||
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
|
||||
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
|
||||
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
|
||||
const ruleVersion = String(result?.rule_version || '').trim()
|
||||
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
|
||||
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
|
||||
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
|
||||
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
|
||||
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
|
||||
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
|
||||
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
|
||||
const ruleVersionText = ruleVersion ? `(${ruleVersion})` : ''
|
||||
const user = currentUser.value || {}
|
||||
const displayName = String(user.name || user.display_name || user.username || '').trim()
|
||||
const greeting = displayName ? `您好,${displayName},` : '您好,'
|
||||
|
||||
return [
|
||||
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
|
||||
'',
|
||||
`**参考可报销合计:${totalAmount} 元**`,
|
||||
'',
|
||||
'| 项目 | 标准口径 | 天数 | 小计 |',
|
||||
'| --- | --- | ---: | ---: |',
|
||||
`| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
|
||||
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
|
||||
'',
|
||||
'**计算过程**',
|
||||
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`,
|
||||
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`,
|
||||
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`,
|
||||
'',
|
||||
`**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
|
||||
'',
|
||||
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function submitTravelCalculator() {
|
||||
if (!travelCalculatorCanSubmit.value) {
|
||||
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
|
||||
return
|
||||
}
|
||||
|
||||
travelCalculatorBusy.value = true
|
||||
travelCalculatorError.value = ''
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
const payload = await calculateTravelReimbursement({
|
||||
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
|
||||
location: String(travelCalculatorForm.value.location || '').trim(),
|
||||
grade: String(user.grade || '').trim()
|
||||
})
|
||||
travelCalculatorResult.value = payload
|
||||
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
|
||||
meta: ['差旅计算器'],
|
||||
metaTone: 'low'
|
||||
}))
|
||||
travelCalculatorOpen.value = false
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
|
||||
} finally {
|
||||
travelCalculatorBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
composerDatePickerOpen,
|
||||
composerDateMode,
|
||||
composerSingleDate,
|
||||
composerRangeStartDate,
|
||||
composerRangeEndDate,
|
||||
composerBusinessTimeTags,
|
||||
composerBusinessTimeDraftTouched,
|
||||
composerCanApplyDateSelection,
|
||||
travelCalculatorOpen,
|
||||
travelCalculatorBusy,
|
||||
travelCalculatorError,
|
||||
travelCalculatorResult,
|
||||
travelCalculatorForm,
|
||||
travelCalculatorCanSubmit,
|
||||
buildComposerBusinessTimeLabel,
|
||||
hasComposerBusinessTimeSelection,
|
||||
buildComposerBusinessTimeContext,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
resolveComposerSubmitText,
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
handleComposerDateInputChange,
|
||||
removeComposerBusinessTimeTag,
|
||||
handleComposerDatePickerOutside,
|
||||
applyComposerDateSelection,
|
||||
resolveTravelCalculatorInitialDays,
|
||||
resolveTravelCalculatorInitialLocation,
|
||||
openTravelCalculator,
|
||||
toggleTravelCalculator,
|
||||
closeTravelCalculator,
|
||||
formatTravelCalculatorMoney,
|
||||
buildTravelCalculatorResultText,
|
||||
submitTravelCalculator
|
||||
}
|
||||
}
|
||||
704
web/src/views/scripts/useTravelReimbursementFlow.js
Normal file
704
web/src/views/scripts/useTravelReimbursementFlow.js
Normal file
@@ -0,0 +1,704 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (numericValue < 10000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
return `${Math.round(numericValue / 1000)}s`
|
||||
}
|
||||
|
||||
function parseFlowTimestamp(value) {
|
||||
const timestamp = new Date(value || '').getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
function resolveSemanticPhaseDurations(run) {
|
||||
const runStart = parseFlowTimestamp(run?.started_at)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const firstToolStartedAt = toolCalls
|
||||
.map((item) => parseFlowTimestamp(item?.created_at))
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => left - right)[0] || 0
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
|
||||
|
||||
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
|
||||
return { intentMs: null, extractionMs: null }
|
||||
}
|
||||
|
||||
const totalMs = semanticFinishedAt - runStart
|
||||
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
|
||||
const extractionMs = Math.max(160, totalMs - intentMs)
|
||||
return {
|
||||
intentMs,
|
||||
extractionMs
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
const explicitDuration = Number(toolCall?.duration_ms)
|
||||
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
|
||||
return explicitDuration
|
||||
}
|
||||
|
||||
const startedAt = parseFlowTimestamp(toolCall?.created_at)
|
||||
if (!startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
|
||||
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
export function useTravelReimbursementFlow({
|
||||
activeSessionType,
|
||||
reviewDrawerMode,
|
||||
insightPanelCollapsed,
|
||||
isKnowledgeSession,
|
||||
fetchAgentRunDetail,
|
||||
buildLocalIntentPreview,
|
||||
buildLocalExtractionProgressMessages,
|
||||
summarizeSemanticIntentDetail,
|
||||
summarizeSemanticParseDetail,
|
||||
SCENARIO_LABELS,
|
||||
INTENT_LABELS,
|
||||
EXPENSE_TYPE_LABELS,
|
||||
FLOW_STEP_FALLBACKS,
|
||||
REVIEW_DRAWER_MODE_FLOW,
|
||||
REVIEW_DRAWER_MODE_REVIEW,
|
||||
FLOW_STEP_STATUS_PENDING,
|
||||
FLOW_STEP_STATUS_RUNNING,
|
||||
FLOW_STEP_STATUS_COMPLETED,
|
||||
FLOW_STEP_STATUS_FAILED
|
||||
}) {
|
||||
const flowRunId = ref('')
|
||||
const flowStartedAt = ref(0)
|
||||
const flowFinishedAt = ref(0)
|
||||
const flowSteps = ref([])
|
||||
const flowRefreshBusy = ref(false)
|
||||
const flowTick = ref(Date.now())
|
||||
let flowTickTimer = 0
|
||||
const flowSimulationTimers = []
|
||||
|
||||
const completedFlowStepCount = computed(
|
||||
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
|
||||
)
|
||||
const runningFlowStep = computed(
|
||||
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
|
||||
)
|
||||
const flowOverallStatusTone = computed(() => {
|
||||
if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
|
||||
return 'failed'
|
||||
}
|
||||
if (runningFlowStep.value) {
|
||||
return 'running'
|
||||
}
|
||||
if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) {
|
||||
return 'completed'
|
||||
}
|
||||
return 'pending'
|
||||
})
|
||||
const flowOverallStatusText = computed(() => {
|
||||
const total = flowSteps.value.length
|
||||
const completed = completedFlowStepCount.value
|
||||
if (flowOverallStatusTone.value === 'failed') {
|
||||
return `异常 ${completed}/${total}`
|
||||
}
|
||||
if (flowOverallStatusTone.value === 'completed') {
|
||||
return `已完成 ${total}/${total}`
|
||||
}
|
||||
if (flowOverallStatusTone.value === 'running') {
|
||||
return `执行中 ${completed}/${total}`
|
||||
}
|
||||
return total ? `待执行 0/${total}` : '暂无流程'
|
||||
})
|
||||
const flowTotalDurationText = computed(() => {
|
||||
if (!flowStartedAt.value) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0)
|
||||
if (finishedAt > flowStartedAt.value) {
|
||||
return formatFlowDuration(finishedAt - flowStartedAt.value)
|
||||
}
|
||||
|
||||
const measuredDuration = flowSteps.value.reduce((total, step) => {
|
||||
const duration = Number(step.durationMs)
|
||||
return total + (Number.isFinite(duration) && duration > 0 ? duration : 0)
|
||||
}, 0)
|
||||
return measuredDuration ? formatFlowDuration(measuredDuration) : '--'
|
||||
})
|
||||
|
||||
function startFlowTick() {
|
||||
if (flowTickTimer) {
|
||||
return
|
||||
}
|
||||
flowTickTimer = window.setInterval(() => {
|
||||
flowTick.value = Date.now()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function stopFlowRuntime() {
|
||||
if (flowTickTimer) {
|
||||
window.clearInterval(flowTickTimer)
|
||||
flowTickTimer = 0
|
||||
}
|
||||
clearFlowSimulationTimers()
|
||||
}
|
||||
|
||||
function clearFlowSimulationTimers() {
|
||||
while (flowSimulationTimers.length) {
|
||||
const timerId = flowSimulationTimers.pop()
|
||||
window.clearTimeout(timerId)
|
||||
window.clearInterval(timerId)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFlowRun(options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const shouldOpenDrawer = options.openDrawer !== false
|
||||
const startedAt = Number(options.startedAt)
|
||||
flowRunId.value = ''
|
||||
flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now()
|
||||
flowFinishedAt.value = 0
|
||||
if (shouldOpenDrawer) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
insightPanelCollapsed.value = false
|
||||
}
|
||||
flowSteps.value = []
|
||||
}
|
||||
|
||||
function findFlowDefinition(key) {
|
||||
return FLOW_STEP_FALLBACKS[key] || null
|
||||
}
|
||||
|
||||
function normalizeFlowStepPatch(key, patch = {}) {
|
||||
const definition = findFlowDefinition(key) || {}
|
||||
const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch }
|
||||
return {
|
||||
title: normalizedPatch.title || definition.title || '智能体工具调用',
|
||||
tool: normalizedPatch.tool || definition.tool || 'AgentTool',
|
||||
detail: normalizedPatch.detail || definition.runningText || '',
|
||||
...normalizedPatch
|
||||
}
|
||||
}
|
||||
|
||||
function createFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
return {
|
||||
key,
|
||||
index: flowSteps.value.length + 1,
|
||||
title: normalizedPatch.title,
|
||||
tool: normalizedPatch.tool,
|
||||
status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING,
|
||||
detail: normalizedPatch.detail || '',
|
||||
durationMs: normalizedPatch.durationMs ?? null,
|
||||
startedAt: normalizedPatch.startedAt || 0,
|
||||
finishedAt: normalizedPatch.finishedAt || 0,
|
||||
error: normalizedPatch.error || ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFlowStepIndexes(steps) {
|
||||
return steps.map((step, index) => ({ ...step, index: index + 1 }))
|
||||
}
|
||||
|
||||
function upsertFlowStep(key, patch) {
|
||||
const existingStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (!existingStep) {
|
||||
const nextStep = createFlowStep(key, patch)
|
||||
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
|
||||
return
|
||||
}
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
flowSteps.value = flowSteps.value.map((step) => (
|
||||
step.key === key ? { ...step, ...normalizedPatch } : step
|
||||
))
|
||||
}
|
||||
|
||||
function startFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
||||
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
||||
? explicitStartedAt
|
||||
: Date.now()
|
||||
upsertFlowStep(key, {
|
||||
...normalizedPatch,
|
||||
status: FLOW_STEP_STATUS_RUNNING,
|
||||
detail: normalizedPatch.detail,
|
||||
startedAt,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
|
||||
function completeFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const explicitDuration = Number(durationMs)
|
||||
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || definition?.completedText || '',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
|
||||
function failFlowStep(key, detail = '', error = '', patch = {}) {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const startedAt = currentStep?.startedAt || now
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_FAILED,
|
||||
detail: detail || error || '调用失败',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: now - startedAt,
|
||||
error: String(error || definition?.title || '').trim()
|
||||
})
|
||||
flowFinishedAt.value = now
|
||||
}
|
||||
|
||||
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return
|
||||
}
|
||||
const normalizedDuration = Number(durationMs)
|
||||
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
|
||||
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
|
||||
if (!hasMeasuredDuration && !currentStep?.startedAt) {
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || findFlowDefinition(key)?.completedText || '',
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
startFlowStep(key, patch)
|
||||
}
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
|
||||
}
|
||||
|
||||
function failCurrentFlowStep(error) {
|
||||
clearFlowSimulationTimers()
|
||||
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
|
||||
failFlowStep(
|
||||
currentStep?.key || 'orchestrator-error',
|
||||
error?.message || '智能体调用失败',
|
||||
error?.message || '',
|
||||
currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' }
|
||||
)
|
||||
}
|
||||
|
||||
function startSemanticFlowPreview(rawText, options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
|
||||
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 260)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startExtractionTimer = window.setTimeout(() => {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText)
|
||||
|
||||
if (extractionMessages.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
let index = 1
|
||||
const detailTimer = window.setInterval(() => {
|
||||
const runningStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) {
|
||||
window.clearInterval(detailTimer)
|
||||
return
|
||||
}
|
||||
upsertFlowStep('extraction', {
|
||||
detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1]
|
||||
})
|
||||
index = Math.min(index + 1, extractionMessages.length - 1)
|
||||
}, 650)
|
||||
flowSimulationTimers.push(detailTimer)
|
||||
}, 420)
|
||||
flowSimulationTimers.push(startExtractionTimer)
|
||||
}
|
||||
|
||||
function startExpenseSceneSelectionFlowPreview(rawText) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 220)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startSelectionTimer = window.setTimeout(() => {
|
||||
startFlowStep('expense-scene-selection', {
|
||||
detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。'
|
||||
})
|
||||
}, 320)
|
||||
flowSimulationTimers.push(startSelectionTimer)
|
||||
}
|
||||
|
||||
function startExpenseIntentConfirmationFlowPreview(rawText) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 220)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startConfirmationTimer = window.setTimeout(() => {
|
||||
startFlowStep('expense-intent-confirmation', {
|
||||
detail: '识别到业务事项描述,但是否发起报销还不明确;暂停信息抽取,等待用户确认。'
|
||||
})
|
||||
}, 320)
|
||||
flowSimulationTimers.push(startConfirmationTimer)
|
||||
}
|
||||
|
||||
function startExpenseSceneSelectionAfterIntentConfirmation(rawText) {
|
||||
clearFlowSimulationTimers()
|
||||
completePendingFlowStep('expense-intent-confirmation', '用户已确认要发起报销', null)
|
||||
startFlowStep('expense-scene-selection', {
|
||||
detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。'
|
||||
})
|
||||
if (reviewDrawerMode.value !== REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
}
|
||||
}
|
||||
|
||||
function isExpenseSceneSelectionResult(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
if (result.review_payload) {
|
||||
return false
|
||||
}
|
||||
return (Array.isArray(result.suggested_actions) ? result.suggested_actions : []).some(
|
||||
(item) => String(item?.action_type || '').trim() === 'select_expense_type'
|
||||
)
|
||||
}
|
||||
|
||||
function startReviewActionFlowStep(reviewAction) {
|
||||
if (reviewAction !== 'next_step') {
|
||||
return
|
||||
}
|
||||
|
||||
startFlowStep('pre-submit-review', {
|
||||
title: 'AI预审与风险识别',
|
||||
tool: 'ExpenseClaimService.submit_claim',
|
||||
detail: '正在校验财务规则、风险规则和审批路径...'
|
||||
})
|
||||
}
|
||||
|
||||
function startExpenseClaimDraftFlowStep(reviewAction, options = {}) {
|
||||
if (isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
if (options.waitForSceneSelection) {
|
||||
return
|
||||
}
|
||||
if (reviewAction === 'next_step') {
|
||||
startReviewActionFlowStep(reviewAction)
|
||||
return
|
||||
}
|
||||
|
||||
const attachmentCount = Math.max(0, Number(options.attachmentCount || 0))
|
||||
const configs = {
|
||||
save_draft: {
|
||||
key: 'expense-claim-draft',
|
||||
title: '保存报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: '正在把已确认信息保存为草稿...'
|
||||
},
|
||||
link_to_existing_draft: {
|
||||
key: 'expense-claim-draft',
|
||||
title: '票据关联草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: '正在把本次票据关联到现有草稿...'
|
||||
},
|
||||
create_new_claim_from_documents: {
|
||||
key: 'expense-claim-draft',
|
||||
title: '新建报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: '正在根据当前票据新建报销草稿...'
|
||||
}
|
||||
}
|
||||
const config = configs[reviewAction] || {
|
||||
key: 'expense-review-preview',
|
||||
title: '报销信息核对',
|
||||
tool: 'user_agent.expense_review_preview',
|
||||
detail: attachmentCount
|
||||
? '正在根据 OCR 结果整理核对信息...'
|
||||
: '正在整理识别结果和右侧核对信息...'
|
||||
}
|
||||
|
||||
startFlowStep(config.key, {
|
||||
title: config.title,
|
||||
tool: config.tool,
|
||||
detail: config.detail
|
||||
})
|
||||
}
|
||||
|
||||
function resolveToolCallFlowMeta(toolCall, index) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const responseMessage = String(response.message || '').trim()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
}
|
||||
if (toolType.includes('mcp')) {
|
||||
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
|
||||
}
|
||||
if (toolName.includes('knowledge')) {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
if (
|
||||
response.submission_blocked ||
|
||||
String(response.status || '').trim() === 'submitted' ||
|
||||
responseMessage.includes('AI预审') ||
|
||||
responseMessage.includes('审批')
|
||||
) {
|
||||
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||
}
|
||||
if (responseMessage.includes('关联')) {
|
||||
return { key: 'expense-claim-draft', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (responseMessage.includes('新建')) {
|
||||
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (toolType.includes('database')) {
|
||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||
}
|
||||
if (toolType.includes('llm') || toolName.includes('user_agent')) {
|
||||
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
|
||||
}
|
||||
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
|
||||
}
|
||||
|
||||
function summarizeFlowToolCall(toolCall) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
String(response.message || response.summary || response.result_summary || '').trim()
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
|| '工具调用完成'
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFlowRunDetail(run) {
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||
clearFlowSimulationTimers()
|
||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse, {
|
||||
scenarioLabels: SCENARIO_LABELS,
|
||||
intentLabels: INTENT_LABELS,
|
||||
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
||||
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}),
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
)
|
||||
completePendingFlowStep(
|
||||
'extraction',
|
||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||
extractionStep?.startedAt ? null : semanticDurations.extractionMs
|
||||
)
|
||||
}
|
||||
|
||||
toolCalls.forEach((toolCall, index) => {
|
||||
const meta = resolveToolCallFlowMeta(toolCall, index)
|
||||
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
|
||||
if (failed) {
|
||||
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
|
||||
} else {
|
||||
const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run)
|
||||
completePendingFlowStep(
|
||||
meta.key,
|
||||
summarizeFlowToolCall(toolCall),
|
||||
toolDurationMs,
|
||||
meta
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (String(run?.status || '').toLowerCase() === 'failed') {
|
||||
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function completeFlowResult(payload, run = null) {
|
||||
const answer = String(payload?.result?.answer || payload?.result?.message || '').trim()
|
||||
if (!answer && !payload?.result) {
|
||||
return
|
||||
}
|
||||
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
|
||||
flowSteps.value
|
||||
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
.forEach((step) => {
|
||||
const detail = sceneSelectionPending && step.key === 'expense-scene-selection'
|
||||
? '已暂停后续识别,请先在主对话中选择报销场景。'
|
||||
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
|
||||
completeFlowStep(step.key, detail)
|
||||
})
|
||||
flowFinishedAt.value = Date.now()
|
||||
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && !sceneSelectionPending) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFlowRunDetail() {
|
||||
if (!flowRunId.value || flowRefreshBusy.value) {
|
||||
return null
|
||||
}
|
||||
flowRefreshBusy.value = true
|
||||
try {
|
||||
const run = await fetchAgentRunDetail(flowRunId.value)
|
||||
mergeFlowRunDetail(run)
|
||||
return run
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh agent run detail:', error)
|
||||
return null
|
||||
} finally {
|
||||
flowRefreshBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFlowStepDuration(step) {
|
||||
if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) {
|
||||
return formatFlowDuration(flowTick.value - step.startedAt)
|
||||
}
|
||||
return formatFlowDuration(step?.durationMs)
|
||||
}
|
||||
|
||||
function resolveFlowStepStatusLabel(step) {
|
||||
const status = String(step?.status || '').trim()
|
||||
if (status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return '完成'
|
||||
}
|
||||
if (status === FLOW_STEP_STATUS_RUNNING) {
|
||||
return '执行中'
|
||||
}
|
||||
if (status === FLOW_STEP_STATUS_FAILED) {
|
||||
return '异常'
|
||||
}
|
||||
return '待执行'
|
||||
}
|
||||
|
||||
function resolveFlowStepDetail(step) {
|
||||
const detail = String(step?.detail || '').trim()
|
||||
if (detail) {
|
||||
return detail
|
||||
}
|
||||
const definition = findFlowDefinition(step?.key)
|
||||
if (step?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return definition?.completedText || '步骤已完成'
|
||||
}
|
||||
if (step?.status === FLOW_STEP_STATUS_RUNNING) {
|
||||
return definition?.runningText || '正在执行当前步骤...'
|
||||
}
|
||||
if (step?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return step?.error || '步骤执行异常'
|
||||
}
|
||||
return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...'
|
||||
}
|
||||
|
||||
return {
|
||||
flowRunId,
|
||||
flowStartedAt,
|
||||
flowFinishedAt,
|
||||
flowSteps,
|
||||
flowRefreshBusy,
|
||||
flowTick,
|
||||
completedFlowStepCount,
|
||||
runningFlowStep,
|
||||
flowOverallStatusTone,
|
||||
flowOverallStatusText,
|
||||
flowTotalDurationText,
|
||||
clearFlowSimulationTimers,
|
||||
resetFlowRun,
|
||||
findFlowDefinition,
|
||||
normalizeFlowStepPatch,
|
||||
createFlowStep,
|
||||
normalizeFlowStepIndexes,
|
||||
upsertFlowStep,
|
||||
startFlowTick,
|
||||
stopFlowRuntime,
|
||||
startFlowStep,
|
||||
completeFlowStep,
|
||||
failFlowStep,
|
||||
completePendingFlowStep,
|
||||
failCurrentFlowStep,
|
||||
startSemanticFlowPreview,
|
||||
startExpenseSceneSelectionFlowPreview,
|
||||
startExpenseIntentConfirmationFlowPreview,
|
||||
startExpenseSceneSelectionAfterIntentConfirmation,
|
||||
isExpenseSceneSelectionResult,
|
||||
startReviewActionFlowStep,
|
||||
startExpenseClaimDraftFlowStep,
|
||||
resolveToolCallFlowMeta,
|
||||
summarizeFlowToolCall,
|
||||
mergeFlowRunDetail,
|
||||
completeFlowResult,
|
||||
refreshFlowRunDetail,
|
||||
formatFlowStepDuration,
|
||||
resolveFlowStepStatusLabel,
|
||||
resolveFlowStepDetail
|
||||
}
|
||||
}
|
||||
252
web/src/views/scripts/useTravelReimbursementReviewActions.js
Normal file
252
web/src/views/scripts/useTravelReimbursementReviewActions.js
Normal file
@@ -0,0 +1,252 @@
|
||||
export function useTravelReimbursementReviewActions(ctx) {
|
||||
const {
|
||||
activeReviewPayload,
|
||||
buildDraftSavedPayload,
|
||||
buildLocalReviewCompletionMessage,
|
||||
buildLocalReviewSavedMessage,
|
||||
buildReviewCorrectionMessage,
|
||||
buildReviewDocumentCorrectionContext,
|
||||
buildReviewDocumentCorrectionMessage,
|
||||
buildReviewFormValues,
|
||||
buildReviewRiskItems,
|
||||
buildReviewSubmitUserText,
|
||||
buildLocallySyncedReviewPayload,
|
||||
cloneReviewDocumentDrafts,
|
||||
cloneReviewEditFields,
|
||||
commitInlineReviewEditor,
|
||||
createMessage,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
emit,
|
||||
latestReviewMessage,
|
||||
linkedRequest,
|
||||
mergeInlineReviewFields,
|
||||
messages,
|
||||
nextTick,
|
||||
reviewActionBusy,
|
||||
reviewDocumentBaseDrafts,
|
||||
reviewDocumentDrafts,
|
||||
reviewHasUnsavedChanges,
|
||||
reviewInlineBaseFields,
|
||||
reviewInlineBaseForm,
|
||||
reviewInlineEditorKey,
|
||||
reviewInlineForm,
|
||||
reviewInlinePendingFiles,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
submitComposer,
|
||||
submitting
|
||||
} = ctx
|
||||
function saveInlineReviewChanges() {
|
||||
if (
|
||||
!activeReviewPayload.value
|
||||
|| !reviewHasUnsavedChanges.value
|
||||
|| submitting.value
|
||||
|| reviewActionBusy.value
|
||||
|| sessionSwitchBusy.value
|
||||
) return
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value)
|
||||
const messageText = `${buildLocalReviewSavedMessage(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value,
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
|
||||
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
|
||||
reviewInlineBaseForm.value = { ...reviewInlineForm.value }
|
||||
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value)
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.value.push(createMessage('assistant', messageText, [], {
|
||||
meta: ['本地修改'],
|
||||
draftPayload: latestReviewMessage.value?.draftPayload || null,
|
||||
reviewPayload: nextReviewPayload
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleReviewAction(message, action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
|
||||
if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
|
||||
await handleSaveDraftDirectly(message, actionType)
|
||||
return
|
||||
}
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
? reviewInlineBaseFields.value
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
|
||||
const reviewChangedUserText = reviewHasUnsavedChanges.value
|
||||
? buildReviewSubmitUserText(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value,
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)
|
||||
: ''
|
||||
const documentCorrectionMessage = buildReviewDocumentCorrectionMessage(
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)
|
||||
const payload = await submitComposer({
|
||||
rawText: [
|
||||
reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '',
|
||||
reviewHasUnsavedChanges.value ? documentCorrectionMessage : '',
|
||||
'我已核对右侧识别结果,请进入下一步。'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。',
|
||||
files: reviewInlinePendingFiles.value,
|
||||
pendingText: '正在进入下一步...',
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (payload?.result?.draft_payload?.status === 'submitted') {
|
||||
emit(
|
||||
'draft-saved',
|
||||
buildDraftSavedPayload({
|
||||
draftPayload: payload.result.draft_payload,
|
||||
reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value,
|
||||
inlineState: reviewInlineForm.value,
|
||||
linkedRequest: linkedRequest.value,
|
||||
currentUser: currentUser.value,
|
||||
riskItems: buildReviewRiskItems(payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value)
|
||||
})
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDraftDirectly(message, actionType = 'save_draft') {
|
||||
reviewActionBusy.value = true
|
||||
|
||||
const actionConfig = {
|
||||
save_draft: {
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
pendingText: '正在保存当前草稿...',
|
||||
successMeta: '草稿已保存',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成'
|
||||
}
|
||||
},
|
||||
link_to_existing_draft: {
|
||||
rawText: '请把当前上传的票据合并到现有报销草稿中。',
|
||||
pendingText: '正在关联到现有草稿...',
|
||||
successMeta: '已关联草稿',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿'
|
||||
}
|
||||
},
|
||||
create_new_claim_from_documents: {
|
||||
rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。',
|
||||
pendingText: '正在建立新的报销草稿...',
|
||||
successMeta: '新草稿已建立',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿'
|
||||
}
|
||||
}
|
||||
}[actionType] || {
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
pendingText: '正在保存当前草稿...',
|
||||
successMeta: '草稿已保存',
|
||||
successMessage: () => '草稿保存完成'
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
? reviewInlineBaseFields.value
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
|
||||
|
||||
const payload = await submitComposer({
|
||||
rawText: actionConfig.rawText,
|
||||
userText: '',
|
||||
skipUserMessage: true,
|
||||
files: reviewInlinePendingFiles.value,
|
||||
pendingText: actionConfig.pendingText,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (payload?.result?.draft_payload?.claim_no) {
|
||||
emit(
|
||||
'draft-saved',
|
||||
buildDraftSavedPayload({
|
||||
draftPayload: payload.result.draft_payload,
|
||||
reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value,
|
||||
inlineState: reviewInlineForm.value,
|
||||
linkedRequest: linkedRequest.value,
|
||||
currentUser: currentUser.value,
|
||||
riskItems: buildReviewRiskItems(payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] }))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
handleReviewActionInternal: handleReviewAction,
|
||||
handleSaveDraftDirectlyInternal: handleSaveDraftDirectly,
|
||||
saveInlineReviewChangesInternal: saveInlineReviewChanges
|
||||
}
|
||||
}
|
||||
422
web/src/views/scripts/useTravelReimbursementReviewDrawer.js
Normal file
422
web/src/views/scripts/useTravelReimbursementReviewDrawer.js
Normal file
@@ -0,0 +1,422 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
DATE_INPUT_FORMAT,
|
||||
REVIEW_CATEGORY_PRESET_OPTIONS,
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
REVIEW_SCENE_OTHER_OPTION,
|
||||
buildInlineReviewChangedLines,
|
||||
buildInlineReviewState,
|
||||
buildReviewCategoryOptions,
|
||||
buildReviewDocumentDrafts,
|
||||
buildReviewDocumentSummaries,
|
||||
buildReviewPanelConfidence,
|
||||
buildReviewRecognitionNotes,
|
||||
buildReviewRecognizedLines,
|
||||
cloneReviewDocumentDrafts,
|
||||
cloneReviewEditFields,
|
||||
createEmptyInlineReviewState,
|
||||
extractAmountInputValue,
|
||||
formatConfidenceLabel,
|
||||
isValidIsoDateString,
|
||||
normalizeAmountValue,
|
||||
normalizeReviewDocumentComparableValue,
|
||||
resolveReviewCategoryConfidenceScore
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
export function useTravelReimbursementReviewDrawer({
|
||||
activeReviewPayload,
|
||||
reviewFilePreviews,
|
||||
flowSteps,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
triggerFileUpload,
|
||||
resolveDocumentPreview,
|
||||
buildReviewFactCards,
|
||||
buildReviewRiskItems,
|
||||
buildReviewRiskSummary,
|
||||
buildReviewIntentText,
|
||||
resolveReviewRiskBriefs,
|
||||
reviewDrawerMode: externalReviewDrawerMode,
|
||||
REVIEW_DRAWER_MODE_REVIEW,
|
||||
REVIEW_DRAWER_MODE_DOCUMENTS,
|
||||
REVIEW_DRAWER_MODE_RISK,
|
||||
REVIEW_DRAWER_MODE_FLOW
|
||||
}) {
|
||||
const reviewInlineForm = ref(createEmptyInlineReviewState())
|
||||
const reviewInlineBaseForm = ref(createEmptyInlineReviewState())
|
||||
const reviewInlineBaseFields = ref([])
|
||||
const reviewInlinePendingFiles = ref([])
|
||||
const reviewInlineEditorKey = ref('')
|
||||
const reviewInlineErrors = ref({})
|
||||
const reviewOtherCategoryOpen = ref(false)
|
||||
const reviewDocumentDrafts = ref([])
|
||||
const reviewDocumentBaseDrafts = ref([])
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
const reviewDrawerMode = externalReviewDrawerMode || ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const documentPreviewDialog = ref({
|
||||
open: false,
|
||||
filename: '',
|
||||
kind: 'file',
|
||||
url: ''
|
||||
})
|
||||
|
||||
const activeReviewFilePreviews = computed(() => reviewFilePreviews.value)
|
||||
const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value))
|
||||
const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewCategoryOptions = computed(() =>
|
||||
buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value)
|
||||
)
|
||||
const reviewOtherCategoryOptions = computed(() =>
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
confidenceLabel: formatConfidenceLabel(
|
||||
resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value)
|
||||
)
|
||||
}))
|
||||
)
|
||||
const reviewSelectedOtherCategory = computed(() => {
|
||||
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
|
||||
return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type
|
||||
})
|
||||
const reviewInlineDirty = computed(
|
||||
() =>
|
||||
buildInlineReviewChangedLines(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value
|
||||
).length > 0
|
||||
)
|
||||
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
|
||||
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
|
||||
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
|
||||
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
|
||||
const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
|
||||
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
|
||||
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
||||
const reviewDrawerTitle = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
? '绁ㄦ嵁璇嗗埆缁撴灉'
|
||||
: isReviewRiskDrawer.value
|
||||
? '椋庨櫓鎻愮ず'
|
||||
: isReviewFlowDrawer.value
|
||||
? '璋冪敤娴佺▼'
|
||||
: '鎶ラ攢璇嗗埆鏍稿'
|
||||
))
|
||||
const reviewDocumentDrawerLabel = computed(() => (
|
||||
'鍗曟嵁璇嗗埆'
|
||||
))
|
||||
const reviewDocumentDrawerIcon = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
? 'mdi mdi-file-document-multiple'
|
||||
: 'mdi mdi-file-document-multiple-outline'
|
||||
))
|
||||
const reviewRiskDrawerLabel = computed(() => (
|
||||
'鏄剧ず椋庨櫓'
|
||||
))
|
||||
const reviewRiskDrawerIcon = computed(() => (
|
||||
isReviewRiskDrawer.value
|
||||
? 'mdi mdi-shield-alert'
|
||||
: 'mdi mdi-shield-alert-outline'
|
||||
))
|
||||
const reviewFlowDrawerLabel = computed(() => (
|
||||
'璋冪敤娴佺▼'
|
||||
))
|
||||
const reviewFlowDrawerIcon = computed(() => (
|
||||
isReviewFlowDrawer.value
|
||||
? 'mdi mdi-timeline-clock'
|
||||
: '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 canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
|
||||
const reviewDocumentDirty = computed(() => {
|
||||
const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue))
|
||||
const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue))
|
||||
return baseValue !== nextValue
|
||||
})
|
||||
const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value)
|
||||
|
||||
function resetReviewDrawerFromPayload(payload) {
|
||||
const normalizedInlineState = buildInlineReviewState(payload)
|
||||
reviewInlineForm.value = { ...normalizedInlineState }
|
||||
reviewInlineBaseForm.value = { ...normalizedInlineState }
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields)
|
||||
const nextDocumentDrafts = buildReviewDocumentDrafts(payload)
|
||||
reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts)
|
||||
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts)
|
||||
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
|
||||
reviewInlinePendingFiles.value = []
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewInlineErrors.value = {}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function setInlineReviewFieldError(key, message) {
|
||||
reviewInlineErrors.value = {
|
||||
...reviewInlineErrors.value,
|
||||
[key]: String(message || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function clearInlineReviewFieldError(key) {
|
||||
if (!reviewInlineErrors.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextErrors = { ...reviewInlineErrors.value }
|
||||
delete nextErrors[key]
|
||||
reviewInlineErrors.value = nextErrors
|
||||
}
|
||||
|
||||
function openInlineReviewEditor(key) {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
if (key === 'attachments') {
|
||||
triggerFileUpload('inline-review')
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value === key) {
|
||||
commitInlineReviewEditor()
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'amount') {
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
amount: extractAmountInputValue(reviewInlineForm.value.amount)
|
||||
}
|
||||
}
|
||||
|
||||
clearInlineReviewFieldError(key)
|
||||
reviewInlineEditorKey.value = key
|
||||
if (key !== 'expense_type') {
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeInlineReviewEditor() {
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function commitInlineReviewEditor() {
|
||||
const activeEditorKey = reviewInlineEditorKey.value
|
||||
const nextForm = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
|
||||
amount: String(reviewInlineForm.value.amount || '').trim(),
|
||||
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
|
||||
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
|
||||
location: String(reviewInlineForm.value.location || '').trim(),
|
||||
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
|
||||
participants: String(reviewInlineForm.value.participants || '').trim(),
|
||||
scene_label: String(reviewInlineForm.value.scene_label || '').trim(),
|
||||
reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(),
|
||||
expense_type: String(reviewInlineForm.value.expense_type || '').trim()
|
||||
}
|
||||
|
||||
if (
|
||||
activeEditorKey === 'scene' &&
|
||||
nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION
|
||||
) {
|
||||
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
|
||||
if (!nextForm.reason_value) {
|
||||
setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱')
|
||||
reviewInlineForm.value = nextForm
|
||||
return false
|
||||
}
|
||||
} else if (activeEditorKey === 'scene') {
|
||||
nextForm.reason_value = nextForm.scene_label
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
|
||||
setInlineReviewFieldError('occurred_date', `璇疯緭鍏ユ纭殑鏃堕棿鏍煎紡锛?{DATE_INPUT_FORMAT}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'amount' && nextForm.amount) {
|
||||
const normalizedAmount = normalizeAmountValue(nextForm.amount)
|
||||
if (!normalizedAmount) {
|
||||
setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50')
|
||||
return false
|
||||
}
|
||||
nextForm.amount = normalizedAmount
|
||||
}
|
||||
|
||||
if (activeEditorKey) {
|
||||
clearInlineReviewFieldError(activeEditorKey)
|
||||
}
|
||||
|
||||
reviewInlineForm.value = nextForm
|
||||
reviewInlineEditorKey.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function selectInlineScene(scene) {
|
||||
const normalizedScene = String(scene || '').trim()
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
scene_label: normalizedScene,
|
||||
reason_value:
|
||||
normalizedScene === REVIEW_SCENE_OTHER_OPTION
|
||||
? ''
|
||||
: normalizedScene
|
||||
}
|
||||
clearInlineReviewFieldError('scene')
|
||||
if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) {
|
||||
reviewInlineEditorKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function selectReviewCategory(option) {
|
||||
if (!option) return
|
||||
if (option.is_other) {
|
||||
reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value
|
||||
return
|
||||
}
|
||||
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
expense_type: option.label
|
||||
}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function selectReviewOtherCategory(option) {
|
||||
if (!option) return
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
expense_type: option.label
|
||||
}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function goReviewDocument(direction) {
|
||||
const total = reviewDocumentCount.value
|
||||
if (!total) return
|
||||
const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0)
|
||||
activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex))
|
||||
}
|
||||
|
||||
function openActiveReviewDocumentPreview() {
|
||||
if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return
|
||||
documentPreviewDialog.value = {
|
||||
open: true,
|
||||
filename: activeReviewDocument.value.filename,
|
||||
kind: activeReviewDocumentPreview.value.kind,
|
||||
url: activeReviewDocumentPreview.value.url
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewDialog.value = {
|
||||
...documentPreviewDialog.value,
|
||||
open: false
|
||||
}
|
||||
}
|
||||
|
||||
function enforceReviewDrawerAvailability() {
|
||||
if (!reviewDocumentDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
if (!reviewRiskDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
if (!reviewFlowDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reviewInlineForm,
|
||||
reviewInlineBaseForm,
|
||||
reviewInlineBaseFields,
|
||||
reviewInlinePendingFiles,
|
||||
reviewInlineEditorKey,
|
||||
reviewInlineErrors,
|
||||
reviewOtherCategoryOpen,
|
||||
reviewDocumentDrafts,
|
||||
reviewDocumentBaseDrafts,
|
||||
activeReviewDocumentIndex,
|
||||
reviewDrawerMode,
|
||||
documentPreviewDialog,
|
||||
activeReviewFilePreviews,
|
||||
reviewIntentText,
|
||||
reviewFactCards,
|
||||
reviewCategoryOptions,
|
||||
reviewOtherCategoryOptions,
|
||||
reviewSelectedOtherCategory,
|
||||
reviewInlineDirty,
|
||||
reviewPanelConfidence,
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewDocumentDrawerAvailable,
|
||||
reviewRiskDrawerAvailable,
|
||||
reviewFlowDrawerAvailable,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
reviewDocumentCount,
|
||||
isReviewDocumentDrawer,
|
||||
isReviewRiskDrawer,
|
||||
isReviewFlowDrawer,
|
||||
reviewDrawerTitle,
|
||||
reviewDocumentDrawerLabel,
|
||||
reviewDocumentDrawerIcon,
|
||||
reviewRiskDrawerLabel,
|
||||
reviewRiskDrawerIcon,
|
||||
reviewFlowDrawerLabel,
|
||||
reviewFlowDrawerIcon,
|
||||
activeReviewDocument,
|
||||
activeReviewDocumentPreview,
|
||||
canPreviewActiveReviewDocument,
|
||||
reviewDocumentDirty,
|
||||
reviewHasUnsavedChanges,
|
||||
setInlineReviewFieldError,
|
||||
clearInlineReviewFieldError,
|
||||
resetReviewDrawerFromPayload,
|
||||
openInlineReviewEditor,
|
||||
closeInlineReviewEditor,
|
||||
commitInlineReviewEditor,
|
||||
selectInlineScene,
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
goReviewDocument,
|
||||
openActiveReviewDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
enforceReviewDrawerAvailability
|
||||
}
|
||||
}
|
||||
335
web/src/views/scripts/useTravelReimbursementSessionState.js
Normal file
335
web/src/views/scripts/useTravelReimbursementSessionState.js
Normal file
@@ -0,0 +1,335 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
import {
|
||||
clearAssistantSessionSnapshot,
|
||||
readAssistantSessionSnapshot,
|
||||
writeAssistantSessionSnapshot
|
||||
} from '../../utils/assistantSessionSnapshot.js'
|
||||
import { buildReviewFilePreviewsFromMessages } from './travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildInitialInsightFromConversation,
|
||||
buildWelcomeInsight,
|
||||
createWelcomeAssistantMessage,
|
||||
hasMeaningfulSessionMessages,
|
||||
normalizeInitialConversationMessages,
|
||||
normalizeSnapshotMessages,
|
||||
resolveInitialConversationId,
|
||||
resolveInitialDraftClaimId,
|
||||
resolveInitialSessionType,
|
||||
serializeSessionMessages,
|
||||
shouldPreferPersistedSessionState
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
|
||||
export function useTravelReimbursementSessionState({
|
||||
props,
|
||||
currentUser,
|
||||
linkedRequest,
|
||||
toast,
|
||||
composerDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs = () => ({})
|
||||
}) {
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
||||
const restoredMessages = normalizeInitialConversationMessages(conversation)
|
||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
messages: restoredMessages.length
|
||||
? restoredMessages
|
||||
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
|
||||
conversationId: resolveInitialConversationId(conversation),
|
||||
draftClaimId: resolveInitialDraftClaimId(conversation),
|
||||
currentInsight:
|
||||
initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||
reviewFilePreviews: restoredReviewFilePreviews,
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmptySessionState(sessionType) {
|
||||
return {
|
||||
sessionType,
|
||||
messages: [
|
||||
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
|
||||
],
|
||||
conversationId: '',
|
||||
draftClaimId: '',
|
||||
currentInsight: buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
sessionType,
|
||||
currentUser.value
|
||||
),
|
||||
reviewFilePreviews: [],
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
|
||||
function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
const restoredMessages = normalizeSnapshotMessages(state.messages)
|
||||
if (
|
||||
!hasMeaningfulSessionMessages(restoredMessages)
|
||||
&& !String(state.conversationId || '').trim()
|
||||
&& !String(state.draftClaimId || '').trim()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
messages: restoredMessages.length
|
||||
? restoredMessages
|
||||
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
|
||||
conversationId: String(state.conversationId || '').trim(),
|
||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||
currentInsight:
|
||||
state.currentInsight
|
||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCurrentUserId() {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||
}
|
||||
|
||||
const initialSessionType = resolveInitialSessionType(props.initialConversation)
|
||||
const conversationInitialState = props.initialConversation
|
||||
? buildConversationSessionState(props.initialConversation, initialSessionType)
|
||||
: buildEmptySessionState(initialSessionType)
|
||||
const canRestorePersistedInitialState =
|
||||
props.entrySource === 'workbench'
|
||||
&& !String(props.initialPrompt || '').trim()
|
||||
&& !props.initialFiles.length
|
||||
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
||||
const persistedInitialState = canRestorePersistedInitialState
|
||||
? buildPersistedSessionState(persistedInitialSnapshot, initialSessionType)
|
||||
: null
|
||||
const initialSessionState = canRestorePersistedInitialState && shouldPreferPersistedSessionState(
|
||||
persistedInitialState,
|
||||
persistedInitialSnapshot,
|
||||
props.initialConversation
|
||||
)
|
||||
? persistedInitialState
|
||||
: conversationInitialState
|
||||
|
||||
const activeSessionType = ref(initialSessionState.sessionType)
|
||||
const messages = ref(initialSessionState.messages)
|
||||
const conversationId = ref(initialSessionState.conversationId)
|
||||
const draftClaimId = ref(initialSessionState.draftClaimId)
|
||||
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
|
||||
const sessionSnapshots = ref({
|
||||
[SESSION_TYPE_EXPENSE]: null,
|
||||
[SESSION_TYPE_KNOWLEDGE]: null
|
||||
})
|
||||
const currentInsight = ref(initialSessionState.currentInsight)
|
||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const sessionSwitchBusy = ref(false)
|
||||
let knowledgeSessionResetPromise = Promise.resolve()
|
||||
|
||||
function buildPersistableSessionState(sessionState) {
|
||||
const state = sessionState || captureCurrentSessionState()
|
||||
return {
|
||||
sessionType: state.sessionType || SESSION_TYPE_EXPENSE,
|
||||
messages: serializeSessionMessages(state.messages),
|
||||
conversationId: String(state.conversationId || '').trim(),
|
||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||
currentInsight: state.currentInsight || null,
|
||||
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
function persistSessionState(sessionState = null) {
|
||||
const state = sessionState || captureCurrentSessionState()
|
||||
const persistedState = buildPersistableSessionState(state)
|
||||
const meaningful = Boolean(
|
||||
String(persistedState.conversationId || '').trim()
|
||||
|| String(persistedState.draftClaimId || '').trim()
|
||||
|| hasMeaningfulSessionMessages(persistedState.messages)
|
||||
|| String(persistedState.composerDraft || '').trim()
|
||||
)
|
||||
|
||||
if (!meaningful) {
|
||||
clearAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType)
|
||||
return
|
||||
}
|
||||
|
||||
writeAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType, persistedState)
|
||||
}
|
||||
|
||||
function captureCurrentSessionState() {
|
||||
const runtimeRefs = getSessionRuntimeRefs()
|
||||
return {
|
||||
sessionType: activeSessionType.value,
|
||||
messages: messages.value,
|
||||
conversationId: conversationId.value,
|
||||
draftClaimId: draftClaimId.value,
|
||||
currentInsight: currentInsight.value,
|
||||
reviewFilePreviews: reviewFilePreviews.value,
|
||||
composerDraft: composerDraft.value,
|
||||
attachedFiles: runtimeRefs.attachedFiles?.value ?? [],
|
||||
composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionState(sessionState) {
|
||||
const runtimeRefs = getSessionRuntimeRefs()
|
||||
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
|
||||
activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE
|
||||
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
|
||||
? nextState.messages
|
||||
: [
|
||||
createWelcomeAssistantMessage(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
activeSessionType.value,
|
||||
currentUser.value
|
||||
)
|
||||
]
|
||||
conversationId.value = String(nextState.conversationId || '').trim()
|
||||
draftClaimId.value = String(nextState.draftClaimId || '').trim()
|
||||
currentInsight.value =
|
||||
nextState.currentInsight
|
||||
|| buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
activeSessionType.value,
|
||||
currentUser.value
|
||||
)
|
||||
reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : []
|
||||
composerDraft.value = String(nextState.composerDraft || '')
|
||||
if (runtimeRefs.attachedFiles) {
|
||||
runtimeRefs.attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : []
|
||||
}
|
||||
if (runtimeRefs.composerFilesExpanded) {
|
||||
runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
|
||||
}
|
||||
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
|
||||
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
|
||||
uploadDecisionDialogOpen.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
async function loadLatestSessionState(targetSessionType) {
|
||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, {
|
||||
preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE
|
||||
})
|
||||
if (payload?.found && payload.conversation) {
|
||||
return buildConversationSessionState(payload.conversation, targetSessionType)
|
||||
}
|
||||
return buildEmptySessionState(targetSessionType)
|
||||
}
|
||||
|
||||
function resetKnowledgeSessionSnapshot() {
|
||||
const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE)
|
||||
sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState
|
||||
|
||||
if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) {
|
||||
applySessionState(emptyKnowledgeState)
|
||||
}
|
||||
}
|
||||
|
||||
function clearKnowledgeSessionOnEntry() {
|
||||
resetKnowledgeSessionSnapshot()
|
||||
knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
|
||||
.catch((error) => {
|
||||
console.warn('Failed to clear knowledge session on entry:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
resetKnowledgeSessionSnapshot()
|
||||
})
|
||||
return knowledgeSessionResetPromise
|
||||
}
|
||||
|
||||
async function switchSessionType(targetSessionType) {
|
||||
const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState()
|
||||
if (sessionSnapshots.value[normalizedTarget]) {
|
||||
applySessionState(sessionSnapshots.value[normalizedTarget])
|
||||
return
|
||||
}
|
||||
|
||||
sessionSwitchBusy.value = true
|
||||
try {
|
||||
const nextState = await loadLatestSessionState(normalizedTarget)
|
||||
sessionSnapshots.value[normalizedTarget] = nextState
|
||||
applySessionState(nextState)
|
||||
} catch (error) {
|
||||
const emptyState = buildEmptySessionState(normalizedTarget)
|
||||
sessionSnapshots.value[normalizedTarget] = emptyState
|
||||
applySessionState(emptyState)
|
||||
toast(error?.message || '?????????????????')
|
||||
} finally {
|
||||
sessionSwitchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState()
|
||||
|
||||
return {
|
||||
activeSessionType,
|
||||
messages,
|
||||
conversationId,
|
||||
draftClaimId,
|
||||
sessionSnapshots,
|
||||
currentInsight,
|
||||
reviewFilePreviews,
|
||||
composerUploadIntent,
|
||||
insightPanelCollapsed,
|
||||
sessionSwitchBusy,
|
||||
initialSessionState,
|
||||
buildConversationSessionState,
|
||||
buildEmptySessionState,
|
||||
buildPersistedSessionState,
|
||||
resolveCurrentUserId,
|
||||
buildPersistableSessionState,
|
||||
persistSessionState,
|
||||
captureCurrentSessionState,
|
||||
applySessionState,
|
||||
loadLatestSessionState,
|
||||
resetKnowledgeSessionSnapshot,
|
||||
clearKnowledgeSessionOnEntry,
|
||||
switchSessionType
|
||||
}
|
||||
}
|
||||
474
web/src/views/scripts/useTravelReimbursementSubmitComposer.js
Normal file
474
web/src/views/scripts/useTravelReimbursementSubmitComposer.js
Normal file
@@ -0,0 +1,474 @@
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
activeReviewPayload,
|
||||
activeSessionType,
|
||||
adjustComposerTextareaHeight,
|
||||
attachedFiles,
|
||||
buildAgentInsight,
|
||||
buildClientTimeContext,
|
||||
buildComposerBusinessTimeContext,
|
||||
buildComposerFilePreviews,
|
||||
buildDraftAssociationQueryPayload,
|
||||
buildErrorInsight,
|
||||
buildExpenseIntentConfirmationActions,
|
||||
buildExpenseIntentConfirmationMessage,
|
||||
buildExpenseSceneSelectionActions,
|
||||
buildExpenseSceneSelectionMessage,
|
||||
buildMessageMeta,
|
||||
buildOcrDocumentsFromReviewPayload,
|
||||
buildOcrFilePreviews,
|
||||
buildOcrSummary,
|
||||
buildOcrSummaryFromDocuments,
|
||||
buildReviewFormContextFromPayload,
|
||||
clearAttachedFiles,
|
||||
clearFlowSimulationTimers,
|
||||
completeFlowResult,
|
||||
completeFlowStep,
|
||||
composerBusinessTimeDraftTouched,
|
||||
composerBusinessTimeTags,
|
||||
composerDraft,
|
||||
composerUploadIntent,
|
||||
conversationId,
|
||||
createMessage,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
extractReviewAttachmentNames,
|
||||
failCurrentFlowStep,
|
||||
fetchExpenseClaims,
|
||||
fileInputRef,
|
||||
flowRunId,
|
||||
isKnowledgeSession,
|
||||
linkedRequest,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
mergeFilePreviews,
|
||||
mergeFilesWithLimit,
|
||||
mergeUploadAttachmentNames,
|
||||
mergeUploadOcrDocuments,
|
||||
messages,
|
||||
nextTick,
|
||||
normalizeExpenseQueryPayload,
|
||||
normalizeOcrDocuments,
|
||||
persistSessionState,
|
||||
props,
|
||||
recognizeOcrFiles,
|
||||
refreshFlowRunDetail,
|
||||
rememberFilePreviews,
|
||||
replaceMessage,
|
||||
resetFlowRun,
|
||||
resolveComposerSubmitText,
|
||||
reviewInlineForm,
|
||||
runOrchestrator,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
shouldRequestExpenseIntentConfirmation,
|
||||
shouldRequestExpenseSceneSelection,
|
||||
startExpenseClaimDraftFlowStep,
|
||||
startExpenseIntentConfirmationFlowPreview,
|
||||
startExpenseSceneSelectionFlowPreview,
|
||||
startFlowStep,
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
} = ctx
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
|
||||
if (normalizedText) {
|
||||
parts.push(normalizedText)
|
||||
} else if (fileNames.length) {
|
||||
parts.push(
|
||||
isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
||||
)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
parts.push(`附件名称:${fileNames.join('、')}`)
|
||||
}
|
||||
|
||||
if (ocrSummary) {
|
||||
parts.push(`OCR摘要:${ocrSummary}`)
|
||||
}
|
||||
|
||||
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||
parts.push(`关联单号:${linkedRequest.value.id}`)
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
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
|
||||
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'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const hasSelectedExpenseType = Boolean(
|
||||
extraContext.expense_scene_selection ||
|
||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||||
)
|
||||
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
|
||||
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType,
|
||||
hasConfirmedExpenseIntent
|
||||
})
|
||||
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType
|
||||
})
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
||||
const userText =
|
||||
String(options.userText || '').trim() ||
|
||||
rawText ||
|
||||
(isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||||
: resolvedUploadDisposition === 'continue_existing'
|
||||
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
|
||||
: resolvedUploadDisposition === 'new_document'
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
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
|
||||
) {
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
const queryPayload = buildDraftAssociationQueryPayload(claims)
|
||||
if (queryPayload?.records?.length) {
|
||||
resetFlowRun()
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
|
||||
[],
|
||||
{
|
||||
meta: ['等待选择关联单据'],
|
||||
queryPayload
|
||||
}
|
||||
))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load draft claims before attachment recognition:', error)
|
||||
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
|
||||
}
|
||||
}
|
||||
|
||||
resetFlowRun()
|
||||
if (rawText && !reviewAction) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
startExpenseIntentConfirmationFlowPreview(rawText)
|
||||
} else if (waitForExpenseSceneSelection) {
|
||||
startExpenseSceneSelectionFlowPreview(rawText)
|
||||
} else {
|
||||
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
||||
}
|
||||
}
|
||||
|
||||
const filePreviews = buildComposerFilePreviews(files)
|
||||
rememberFilePreviews(filePreviews)
|
||||
|
||||
// 只有在非静默模式下才添加用户消息
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], {
|
||||
meta: ['等待确认意图'],
|
||||
suggestedActions: buildExpenseIntentConfirmationActions(rawText)
|
||||
}))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (waitForExpenseSceneSelection) {
|
||||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
|
||||
meta: ['等待选择场景'],
|
||||
suggestedActions: buildExpenseSceneSelectionActions(rawText)
|
||||
}))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
options.pendingText || (
|
||||
isKnowledgeSession.value
|
||||
? '正在整理财务知识答案...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
),
|
||||
[],
|
||||
{
|
||||
meta: ['处理中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(adjustComposerTextareaHeight)
|
||||
|
||||
submitting.value = true
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
let responsePayload = null
|
||||
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
let ocrPayload = null
|
||||
let ocrSummary = ''
|
||||
let ocrDocuments = []
|
||||
let ocrFilePreviews = []
|
||||
|
||||
if (files.length) {
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files)
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveFileNames = [...fileNames]
|
||||
let effectiveOcrDocuments = [...ocrDocuments]
|
||||
let effectiveOcrSummary = ocrSummary
|
||||
|
||||
if (resolvedUploadDisposition === 'continue_existing') {
|
||||
extraContext.review_action = 'link_to_existing_draft'
|
||||
const inheritedReviewContext = buildReviewFormContextFromPayload(
|
||||
activeReviewPayload.value,
|
||||
reviewInlineForm.value
|
||||
)
|
||||
if (inheritedReviewContext.review_form_values) {
|
||||
extraContext.review_form_values = {
|
||||
...inheritedReviewContext.review_form_values,
|
||||
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
|
||||
extraContext.business_time_context = inheritedReviewContext.business_time_context
|
||||
}
|
||||
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
|
||||
effectiveOcrDocuments = mergeUploadOcrDocuments(
|
||||
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
|
||||
ocrDocuments
|
||||
)
|
||||
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
|
||||
} else if (resolvedUploadDisposition === 'new_document') {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length,
|
||||
waitForSceneSelection: waitForExpenseSceneSelection
|
||||
})
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const payload = await runOrchestrator(
|
||||
{
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
conversation_id: conversationId.value || null,
|
||||
message: backendMessage,
|
||||
context_json: {
|
||||
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
department: user.department || user.departmentName || '',
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
manager_name: user.managerName || user.manager_name || '',
|
||||
employee_location: user.location || '',
|
||||
cost_center: user.costCenter || user.cost_center || '',
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: systemGenerated ? '' : rawText,
|
||||
attachment_names: effectiveFileNames,
|
||||
attachment_count: effectiveFileNames.length,
|
||||
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
|
||||
ocr_summary: effectiveOcrSummary,
|
||||
ocr_documents: effectiveOcrDocuments,
|
||||
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
|
||||
...extraContext
|
||||
}
|
||||
},
|
||||
isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
}
|
||||
: {}
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
let flowRunDetail = null
|
||||
if (flowRunId.value) {
|
||||
flowRunDetail = await refreshFlowRunDetail()
|
||||
}
|
||||
|
||||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||||
draftClaimId.value =
|
||||
isKnowledgeSession.value
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
)
|
||||
currentInsight.value = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage(
|
||||
'assistant',
|
||||
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
||||
[],
|
||||
{
|
||||
meta: ['调用失败']
|
||||
}
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
composerUploadIntent.value = ''
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
return responsePayload
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
submitComposerInternal: submitComposer
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user