feat(web): AI 工作台文件预览/附件关联任务与草稿分支

- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览
- 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示
- 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿
- PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善
- DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配
- 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
This commit is contained in:
caoxiaozhu
2026-06-24 10:42:50 +08:00
parent 0264a4b5b4
commit ee730aa31c
73 changed files with 2528 additions and 379 deletions

View File

@@ -0,0 +1,276 @@
.workbench-ai-file-preview-mask {
--workbench-ai-preview-sidebar-offset: var(--sidebar-expanded-width, 304px);
--workbench-ai-preview-edge-padding: 24px;
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
grid-template-columns: var(--workbench-ai-preview-sidebar-offset) minmax(0, 1fr);
align-items: center;
justify-items: center;
padding: var(--workbench-ai-preview-edge-padding);
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
:global(.app.sidebar-collapsed) .workbench-ai-file-preview-mask {
--workbench-ai-preview-sidebar-offset: var(--sidebar-collapsed-width, 64px);
}
.workbench-ai-file-preview-dialog {
grid-column: 2;
justify-self: center;
width: min(
1160px,
calc(100vw - var(--workbench-ai-preview-sidebar-offset) - (var(--workbench-ai-preview-edge-padding) * 2))
);
max-height: calc(100vh - (var(--workbench-ai-preview-edge-padding) * 2));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
padding: 22px;
border: 1px solid rgba(var(--ai-theme-rgb), 0.16);
border-radius: 6px;
background: #ffffff;
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.2);
}
.workbench-ai-file-preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.workbench-ai-file-preview-badge {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 4px;
background: rgba(47, 124, 255, 0.11);
color: #1d4ed8;
font-size: 12px;
font-weight: 800;
}
.workbench-ai-file-preview-head h3 {
margin: 10px 0 0;
color: #0f172a;
font-size: 20px;
line-height: 1.4;
font-weight: 850;
}
.workbench-ai-file-preview-close {
width: 36px;
height: 36px;
display: inline-grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: rgba(255, 255, 255, 0.94);
color: #475569;
font-size: 18px;
}
.workbench-ai-file-preview-body {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
gap: 16px;
overflow: hidden;
}
.workbench-ai-file-preview-source,
.workbench-ai-file-preview-insight {
min-height: 0;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.workbench-ai-file-preview-source {
display: grid;
place-items: center;
background: #f8fafc;
}
.workbench-ai-file-preview-image,
.workbench-ai-file-preview-frame {
width: 100%;
height: 100%;
min-height: 520px;
border: 0;
object-fit: contain;
background: #ffffff;
}
.workbench-ai-file-preview-insight {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
align-content: start;
gap: 14px;
padding: 18px;
overflow-y: auto;
background: #ffffff;
}
.workbench-ai-file-preview-insight-head {
display: grid;
gap: 6px;
padding-bottom: 14px;
border-bottom: 1px solid #e2e8f0;
}
.workbench-ai-file-preview-insight-head span,
.workbench-ai-file-preview-section > span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.workbench-ai-file-preview-insight-head strong {
color: #0f172a;
font-size: 18px;
line-height: 1.35;
font-weight: 850;
}
.workbench-ai-file-preview-status {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 8px;
justify-self: start;
padding: 0 12px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #eff6ff;
color: #1d4ed8;
font-size: 13px;
font-weight: 800;
}
.workbench-ai-file-preview-status.is-recognizing i {
animation: workbenchAiOcrSpin 840ms linear infinite;
}
@keyframes workbenchAiOcrSpin {
to {
transform: rotate(360deg);
}
}
.workbench-ai-file-preview-status.is-recognized {
border-color: #bbf7d0;
background: #f0fdf4;
color: #047857;
}
.workbench-ai-file-preview-status.is-failed {
border-color: #fecaca;
background: #fff1f2;
color: #dc2626;
}
.workbench-ai-file-preview-section {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 4px;
background: #f8fafc;
}
.workbench-ai-file-preview-section dl {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
}
.workbench-ai-file-preview-section dt,
.workbench-ai-file-preview-section dd,
.workbench-ai-file-preview-section p {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.55;
}
.workbench-ai-file-preview-section dt {
color: #64748b;
font-weight: 750;
}
.workbench-ai-file-preview-section dd {
min-width: 0;
overflow-wrap: anywhere;
font-weight: 760;
}
.workbench-ai-file-preview-state {
min-height: 320px;
display: grid;
place-items: center;
gap: 10px;
color: #475569;
font-size: 14px;
font-weight: 750;
text-align: center;
}
.workbench-ai-file-preview-state i {
font-size: 26px;
}
.workbench-ai-preview-fade-enter-active,
.workbench-ai-preview-fade-leave-active {
transition: opacity 160ms ease;
}
.workbench-ai-preview-fade-enter-from,
.workbench-ai-preview-fade-leave-to {
opacity: 0;
}
@media (max-width: 900px) {
.workbench-ai-file-preview-mask {
--workbench-ai-preview-sidebar-offset: 0px;
--workbench-ai-preview-edge-padding: 12px;
grid-template-columns: minmax(0, 1fr);
padding: var(--workbench-ai-preview-edge-padding);
}
.workbench-ai-file-preview-dialog {
grid-column: 1;
width: calc(100vw - (var(--workbench-ai-preview-edge-padding) * 2));
max-height: calc(100vh - (var(--workbench-ai-preview-edge-padding) * 2));
padding: 14px;
}
.workbench-ai-file-preview-body {
grid-template-columns: 1fr;
overflow-y: auto;
}
.workbench-ai-file-preview-image,
.workbench-ai-file-preview-frame {
min-height: 300px;
max-height: 46vh;
}
.workbench-ai-file-preview-insight {
overflow: visible;
}
}
@media (prefers-reduced-motion: reduce) {
.workbench-ai-preview-fade-enter-active,
.workbench-ai-preview-fade-leave-active {
transition: none;
}
}

View File

@@ -286,7 +286,7 @@ const reimbursementTrendHasSignal = computed(() =>
) )
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value) const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
const reimbursementTrendSignalLabel = computed(() => const reimbursementTrendSignalLabel = computed(() =>
reimbursementTrendHasSignal.value ? '来自的真实单据' : '暂无单据时展示空走势' reimbursementTrendHasSignal.value ? '来自的真实单据' : '暂无单据时展示空走势'
) )
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label)) const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount)) const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))

View File

@@ -405,6 +405,8 @@
</div> </div>
</Transition> </Transition>
<WorkbenchAiFilePreviewDialog :runtime="workbenchAiRuntime" />
<Transition name="workbench-ai-confirm-fade"> <Transition name="workbench-ai-confirm-fade">
<div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation"> <div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation">
<div <div

View File

@@ -5,6 +5,7 @@ import { proxyRefs } from 'vue'
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif' import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue' import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue' import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
import WorkbenchAiFilePreviewDialog from './workbench-ai/WorkbenchAiFilePreviewDialog.vue'
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js' import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
const props = defineProps({ const props = defineProps({

View File

@@ -23,7 +23,7 @@
v-model="runtime.assistantDraft" v-model="runtime.assistantDraft"
maxlength="1000" maxlength="1000"
rows="3" rows="3"
:placeholder="runtime.isAiModeInputLocked ? '费用测算中,请稍等...' : placeholder" :placeholder="runtime.isAiModeInputLocked ? runtime.aiModeInputLockMessage : placeholder"
:disabled="runtime.isAiModeInputLocked" :disabled="runtime.isAiModeInputLocked"
@keydown.enter.exact.prevent="runtime.submitAiModePrompt" @keydown.enter.exact.prevent="runtime.submitAiModePrompt"
></textarea> ></textarea>

View File

@@ -0,0 +1,109 @@
<template>
<Transition name="workbench-ai-preview-fade">
<div
v-if="preview"
class="workbench-ai-file-preview-mask"
role="presentation"
@click.self="runtime.closeAiModeFilePreview"
>
<section class="workbench-ai-file-preview-dialog" role="dialog" aria-modal="true" @click.stop>
<header class="workbench-ai-file-preview-head">
<div>
<span class="workbench-ai-file-preview-badge">附件预览</span>
<h3>{{ preview.name }}</h3>
</div>
<button
type="button"
class="workbench-ai-file-preview-close"
aria-label="关闭附件预览"
@click="runtime.closeAiModeFilePreview"
>
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="workbench-ai-file-preview-body">
<div class="workbench-ai-file-preview-source">
<img
v-if="preview.sourceKind === 'image'"
:src="preview.sourceUrl"
:alt="preview.name"
class="workbench-ai-file-preview-image"
/>
<iframe
v-else-if="preview.sourceKind === 'pdf'"
:src="preview.sourceUrl"
class="workbench-ai-file-preview-frame"
title="附件 PDF 预览"
></iframe>
<div v-else class="workbench-ai-file-preview-state">
<i class="mdi mdi-file-eye-outline"></i>
<span>当前附件暂不支持直接预览</span>
</div>
</div>
<aside class="workbench-ai-file-preview-insight">
<div class="workbench-ai-file-preview-insight-head">
<span>识别信息</span>
<strong>{{ preview.documentTypeLabel }}</strong>
</div>
<div
class="workbench-ai-file-preview-status"
:class="`is-${preview.recognitionStatus}`"
:title="preview.recognitionStatusTitle"
>
<i v-if="preview.recognitionStatus === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="preview.recognitionStatus === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="preview.recognitionStatus === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<i v-else class="mdi mdi-file-search-outline"></i>
<span>{{ preview.recognitionStatusLabel }}</span>
</div>
<section class="workbench-ai-file-preview-section">
<span>文件信息</span>
<dl>
<template v-for="row in preview.fileInfoRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
</section>
<section v-if="preview.ocrFields.length" class="workbench-ai-file-preview-section">
<span>字段结果</span>
<dl>
<template v-for="field in preview.ocrFields" :key="`${field.label}-${field.value}`">
<dt>{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</template>
</dl>
</section>
<section v-if="preview.ocrSummary" class="workbench-ai-file-preview-section">
<span>识别摘要</span>
<p>{{ preview.ocrSummary }}</p>
</section>
<section v-if="preview.rawText" class="workbench-ai-file-preview-section">
<span>OCR 原文</span>
<p>{{ preview.rawText }}</p>
</section>
</aside>
</div>
</section>
</div>
</Transition>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
runtime: { type: Object, required: true }
})
const preview = computed(() => props.runtime.activeAiModeFilePreview || null)
</script>
<style scoped src="../../../assets/styles/components/workbench-ai-file-preview-dialog.css"></style>

View File

@@ -5,7 +5,17 @@
:class="{ inline }" :class="{ inline }"
aria-label="已选择附件" aria-label="已选择附件"
> >
<article v-for="file in runtime.selectedFileCards" :key="file.key" class="workbench-ai-file-card"> <article
v-for="file in runtime.selectedFileCards"
:key="file.key"
class="workbench-ai-file-card"
role="button"
tabindex="0"
:aria-label="`预览附件 ${file.name}`"
@click="runtime.openAiModeFilePreview(file.key)"
@keydown.enter.prevent="runtime.openAiModeFilePreview(file.key)"
@keydown.space.prevent="runtime.openAiModeFilePreview(file.key)"
>
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true"> <span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i> <i :class="file.icon"></i>
</span> </span>
@@ -29,7 +39,7 @@
class="workbench-ai-file-card__remove" class="workbench-ai-file-card__remove"
:disabled="runtime.isAiModeInputLocked" :disabled="runtime.isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`" :aria-label="`移除附件 ${file.name}`"
@click="runtime.removeAiModeFile(file.key)" @click.stop="runtime.removeAiModeFile(file.key)"
> >
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>

View File

@@ -741,7 +741,7 @@ function buildFieldStepDescription() {
if (activeSimulationResult.value?.recognized_fields?.length) { if (activeSimulationResult.value?.recognized_fields?.length) {
return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。` return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。`
} }
if (draft.value.trim()) return '将使用输入的文字抽取测试字段。' if (draft.value.trim()) return '将使用输入的文字抽取测试字段。'
return '识别完成或补充字段后进入确认。' return '识别完成或补充字段后进入确认。'
} }
@@ -756,7 +756,7 @@ function buildWelcomeMessage() {
if (requiresAttachment.value) { if (requiresAttachment.value) {
return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。' return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。'
} }
return '这条规则不需要上传附件。可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。' return '这条规则不需要上传附件。可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
} }
function clearFileInput() { function clearFileInput() {

View File

@@ -505,7 +505,7 @@
<i class="mdi mdi-text-recognition"></i> <i class="mdi mdi-text-recognition"></i>
</span> </span>
<strong>暂无结构化字段</strong> <strong>暂无结构化字段</strong>
<p>当前只返回了摘要信息仍然可以直接修改上面的票据摘要</p> <p>当前只返回了摘要信息仍然可以直接修改上面的票据摘要</p>
</div> </div>
<div v-if="ui.activeReviewDocument.warnings?.length" class="review-document-warning-list"> <div v-if="ui.activeReviewDocument.warnings?.length" class="review-document-warning-list">

View File

@@ -549,7 +549,7 @@
{{ line }} {{ line }}
</p> </p>
<p v-if="ui.canUseInlineSaveDraft(message)" class="review-inline-save-copy"> <p v-if="ui.canUseInlineSaveDraft(message)" class="review-inline-save-copy">
请核查上面的关键信息您也可以暂时不处理上述的这些内容我可以帮先保存为 请核查上面的关键信息您也可以暂时不处理上述的这些内容我可以帮先保存为
<button <button
type="button" type="button"
class="review-inline-draft-link" class="review-inline-draft-link"

View File

@@ -5,7 +5,12 @@ import { useNavigation, navItems } from './useNavigation.js'
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js' import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js' import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchExpenseClaimDetail
} from '../services/reimbursements.js'
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js' import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
@@ -125,10 +130,8 @@ export function useAppShell() {
async function reloadWorkbenchApprovalRequests() { async function reloadWorkbenchApprovalRequests() {
try { try {
const payload = await fetchAllApprovalExpenseClaims() const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
workbenchApprovalRequests.value = Array.isArray(payload) workbenchApprovalRequests.value = extractExpenseClaimItems(payload).map((item) => mapExpenseClaimToRequest(item))
? payload.map((item) => mapExpenseClaimToRequest(item))
: []
} catch { } catch {
workbenchApprovalRequests.value = [] workbenchApprovalRequests.value = []
} }

View File

@@ -55,7 +55,7 @@ export function useChat(activeView) {
? `${c.id} 建议审批意见:费用归属与预算中心基本匹配,请补充必要说明后通过。` ? `${c.id} 建议审批意见:费用归属与预算中心基本匹配,请补充必要说明后通过。`
: '建议审批意见:当前单据存在待确认项,请先完成风险核查和附件补齐后再审批。' : '建议审批意见:当前单据存在待确认项,请先完成风险核查和附件补齐后再审批。'
} }
return '收到。我可以继续帮拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。' return '收到。我可以继续帮拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
} }
function scrollToBottom() { function scrollToBottom() {

View File

@@ -1,6 +1,10 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { fetchAllExpenseClaims } from '../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchExpenseClaims
} from '../services/reimbursements.js'
import { formatDate, toDate } from './requests/requestShared.js' import { formatDate, toDate } from './requests/requestShared.js'
import { mapExpenseClaimToRequest } from './requests/requestClaimMapper.js' import { mapExpenseClaimToRequest } from './requests/requestClaimMapper.js'
@@ -103,8 +107,8 @@ export function useRequests() {
} }
try { try {
const payload = await fetchAllExpenseClaims() const payload = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] requests.value = extractExpenseClaimItems(payload).map((item) => mapExpenseClaimToRequest(item))
loaded.value = true loaded.value = true
} catch (nextError) { } catch (nextError) {
if (!silent) { if (!silent) {

View File

@@ -111,6 +111,10 @@ export function useWorkbenchComposerDate({ draft, focusInput } = {}) {
return return
} }
if (part === 'range-end') {
return
}
applyWorkbenchDateSelection() applyWorkbenchDateSelection()
} }

View File

@@ -1,5 +1,4 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../useSystemState.js' import { useSystemState } from '../useSystemState.js'
import { useToast } from '../useToast.js' import { useToast } from '../useToast.js'
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js' import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
@@ -19,7 +18,6 @@ import {
} from '../../utils/aiDocumentDetailReference.js' } from '../../utils/aiDocumentDetailReference.js'
import { import {
AI_MODE_ACTION_ITEMS, AI_MODE_ACTION_ITEMS,
buildSelectedFileCards,
shouldRunAiAttachmentAutoAssociation shouldRunAiAttachmentAutoAssociation
} from './workbenchAiComposerModel.js' } from './workbenchAiComposerModel.js'
import { import {
@@ -32,6 +30,7 @@ import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicatio
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js' import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js' import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js' import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
import { useWorkbenchAiFilePreview } from './useWorkbenchAiFilePreview.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js' import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js' import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js' import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
@@ -75,7 +74,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
normalizeRuntimeMessage, normalizeRuntimeMessage,
serializeRuntimeMessage serializeRuntimeMessage
} = messageRuntime } = messageRuntime
const { const {
applicationPreviewEditor, applicationPreviewEditor,
resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorControl,
@@ -139,6 +137,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
fileInputRef, fileInputRef,
focusAiModeInput, focusAiModeInput,
isInputLocked: () => isAiModeInputLocked.value, isInputLocked: () => isAiModeInputLocked.value,
resolveInputLockedMessage: () => resolveAiModeInputLockMessage(),
selectedFiles, selectedFiles,
toast toast
}) })
@@ -154,6 +153,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({ const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime, aiAttachmentAssociationRuntime,
conversationId,
conversationMessages, conversationMessages,
createAiAttachmentAssociationId, createAiAttachmentAssociationId,
createInlineMessage, createInlineMessage,
@@ -167,14 +167,12 @@ export function usePersonalWorkbenchAiMode(props, emit) {
toast toast
}) })
watch(selectedFiles, (files) => { const filePreview = useWorkbenchAiFilePreview({
attachmentFlow.primeAiModeReceiptContext(files) attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
}) })
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
const { const {
hasInlineAttachmentOcrDetails, hasInlineAttachmentOcrDetails,
hasInlineThinking, hasInlineThinking,
@@ -319,9 +317,13 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const applicationPreviewEstimatePending = computed(() => ( const applicationPreviewEstimatePending = computed(() => (
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message)) conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
)) ))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value) const isAiModeReceiptRecognitionPending = computed(() => attachmentFlow.hasPendingAiModeReceiptRecognition(selectedFiles.value))
const hasAiModeReceiptRecognitionFailure = computed(() => attachmentFlow.hasFailedAiModeReceiptRecognition(selectedFiles.value))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value || isAiModeReceiptRecognitionPending.value)
const aiModeInputLockMessage = computed(() => resolveAiModeInputLockMessage())
const canSubmitAiModePrompt = computed(() => ( const canSubmitAiModePrompt = computed(() => (
!isAiModeInputLocked.value && ( !isAiModeInputLocked.value &&
!hasAiModeReceiptRecognitionFailure.value && (
Boolean(assistantDraft.value.trim()) || Boolean(assistantDraft.value.trim()) ||
selectedFiles.value.length > 0 || selectedFiles.value.length > 0 ||
Boolean(workbenchDateTagLabel.value) Boolean(workbenchDateTagLabel.value)
@@ -517,18 +519,47 @@ export function usePersonalWorkbenchAiMode(props, emit) {
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value }) emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
} }
function renderInlineConversationHtml(content) { function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) }
return renderAiConversationHtml(content)
}
function buildInlinePromptText(rawPrompt, files = []) { function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt) const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) { if (prompt) return prompt
return prompt
}
return files.length ? '请帮我处理已上传的附件。' : '' return files.length ? '请帮我处理已上传的附件。' : ''
} }
function resolveAiModeInputLockMessage() {
if (isAiModeReceiptRecognitionPending.value) {
return '附件识别中,请稍等...'
}
if (applicationPreviewEstimatePending.value) {
return '费用测算中,请稍等...'
}
return ''
}
function resolveAiModeSubmitBlockedMessage() {
if (applicationPreviewEstimatePending.value) {
return '请等待费用测算完成后再继续操作。'
}
if (isAiModeReceiptRecognitionPending.value) {
return '附件 OCR 识别中,请稍等,识别完成后再继续对话。'
}
if (hasAiModeReceiptRecognitionFailure.value) {
return '请先移除识别失败的附件或重新上传。'
}
return ''
}
function ensureAiModeCanStartConversation() {
const blockedMessage = resolveAiModeSubmitBlockedMessage()
if (!blockedMessage) {
return true
}
toast(blockedMessage)
focusAiModeInput()
return false
}
function handleAiAnswerMarkdownClick(event) { function handleAiAnswerMarkdownClick(event) {
const target = event?.target const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -546,8 +577,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} }
function startInlineConversation(prompt, entry = {}, files = []) { function startInlineConversation(prompt, entry = {}, files = []) {
if (isAiModeInputLocked.value) { if (!ensureAiModeCanStartConversation()) {
toast('请等待费用测算完成后再继续操作。')
return return
} }
const cleanPrompt = buildInlinePromptText(prompt, files) const cleanPrompt = buildInlinePromptText(prompt, files)
@@ -595,6 +625,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} }
function submitAiModePrompt() { function submitAiModePrompt() {
if (!ensureAiModeCanStartConversation()) {
return
}
if (!canSubmitAiModePrompt.value) { if (!canSubmitAiModePrompt.value) {
toast('请输入需求后再发送。') toast('请输入需求后再发送。')
focusAiModeInput() focusAiModeInput()
@@ -604,6 +637,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} }
function runAiModeAction(item) { function runAiModeAction(item) {
if (!ensureAiModeCanStartConversation()) {
return
}
if (String(item?.label || '').trim() === '发起报销') { if (String(item?.label || '').trim() === '发起报销') {
void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label) void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label)
return return
@@ -624,9 +660,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, []) void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
} }
function pushInlineUserMessage(text) { function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) }
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
}
function pushInlineApplicationActionUserMessage(text) { function pushInlineApplicationActionUserMessage(text) {
pushInlineUserMessage(text) pushInlineUserMessage(text)
@@ -637,13 +671,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} }
function resolveLatestInlineUserPrompt() { function resolveLatestInlineUserPrompt() {
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user') return String([...conversationMessages.value].reverse().find((message) => message.role === 'user')?.content || '').trim()
return String(latestUserMessage?.content || '').trim()
} }
function handleVoiceInput() { function handleVoiceInput() {
if (isAiModeInputLocked.value) { if (!ensureAiModeCanStartConversation()) {
toast('请等待费用测算完成后再继续操作。')
return return
} }
toast('语音输入正在准备中,您可以先输入文字需求。') toast('语音输入正在准备中,您可以先输入文字需求。')
@@ -664,6 +696,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} }
if (command.type === 'open-recent') { if (command.type === 'open-recent') {
sessionCommands.openInlineRecentConversation(command.payload || {}) sessionCommands.openInlineRecentConversation(command.payload || {})
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
} }
} }
) )
@@ -671,6 +705,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
onMounted(() => { onMounted(() => {
loadSystemSettings() loadSystemSettings()
refreshConversationHistory() refreshConversationHistory()
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
document.addEventListener('click', handleWorkbenchDatePickerOutside) document.addEventListener('click', handleWorkbenchDatePickerOutside)
}) })
@@ -681,6 +717,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return { return {
activeConversationTitle, activeConversationTitle,
aiModeActionItems, aiModeActionItems,
aiModeInputLockMessage,
applicationPreviewEditor, applicationPreviewEditor,
applicationSubmitConfirmOpen, applicationSubmitConfirmOpen,
assistantInputRef, assistantInputRef,
@@ -734,7 +771,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
resolveInlineThinkingEvents, resolveInlineThinkingEvents,
runAiModeAction, runAiModeAction,
scrollInlineConversationToTop, scrollInlineConversationToTop,
selectedFileCards, ...filePreview,
sending, sending,
setAssistantInputRef, setAssistantInputRef,
setWorkbenchDateMode, setWorkbenchDateMode,

View File

@@ -13,6 +13,9 @@ import {
} from './workbenchAiMessageModel.js' } from './workbenchAiMessageModel.js'
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js' import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
import { import {
CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
SKIP_REQUIRED_APPLICATION_LINK_ACTION, SKIP_REQUIRED_APPLICATION_LINK_ACTION,
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION
} from '../../views/scripts/travelReimbursementAssociationGateModel.js' } from '../../views/scripts/travelReimbursementAssociationGateModel.js'
@@ -102,6 +105,25 @@ export function useWorkbenchAiActionRouter({
return return
} }
if (actionType === CONTINUE_REIMBURSEMENT_DRAFT_ACTION) {
expenseFlow.promptAiReimbursementDraftContinuation(actionPayload)
focusAiModeInput()
return
}
if (actionType === CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) {
expenseFlow.promptStandaloneReimbursementDraftCreation(
actionPayload.original_message || '我要报销',
action.label || '独立新建报销单'
)
return
}
if (actionType === CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) {
expenseFlow.cancelStandaloneReimbursementDraftCreation()
return
}
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) { if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
void expenseFlow.startAiReimbursementAssociationGate( void expenseFlow.startAiReimbursementAssociationGate(
actionPayload.original_message || '我要报销', actionPayload.original_message || '我要报销',

View File

@@ -191,7 +191,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
if (normalized.validationIssues?.length || normalized.missingFields?.length) { if (normalized.validationIssues?.length || normalized.missingFields?.length) {
return buildApplicationPreviewFooterMessage(normalized) return buildApplicationPreviewFooterMessage(normalized)
} }
return '申请核对表已补齐,费用测算已同步。仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。' return '申请核对表已补齐,费用测算已同步。仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
} }
function buildInlineApplicationActionFailureText(error, isSubmit) { function buildInlineApplicationActionFailureText(error, isSubmit) {
@@ -400,7 +400,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
streamStatus: 'completed', streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
}, },
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : [] suggestedActions: buildInlineApplicationDetailAction(draftPayload)
}) })
) )
persistCurrentConversation() persistCurrentConversation()

View File

@@ -8,17 +8,17 @@ import {
} from '../../views/scripts/travelReimbursementAttachmentModel.js' } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import { import {
createExpenseClaimItem, createExpenseClaimItem,
extractExpenseClaimItems,
fetchExpenseClaimDetail, fetchExpenseClaimDetail,
fetchExpenseClaims,
uploadExpenseClaimItemAttachment uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js' } from '../../services/reimbursements.js'
import { recognizeOcrFiles } from '../../services/ocr.js' import { recognizeOcrFiles } from '../../services/ocr.js'
import { createAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
import { import {
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION, AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
buildInlineAttachmentOcrDetails buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js' } from './workbenchAiMessageModel.js'
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js' import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
import { useWorkbenchAiAttachmentAssociationJobs } from './useWorkbenchAiAttachmentAssociationJobs.js'
function buildAiAttachmentAssociationThinkingEvents(status = 'running') { function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
const completed = status === 'completed' const completed = status === 'completed'
@@ -72,6 +72,7 @@ function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
export function useWorkbenchAiAttachmentAssociationFlow({ export function useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime, aiAttachmentAssociationRuntime,
conversationId,
conversationMessages, conversationMessages,
createAiAttachmentAssociationId, createAiAttachmentAssociationId,
createInlineMessage, createInlineMessage,
@@ -151,8 +152,11 @@ export function useWorkbenchAiAttachmentAssociationFlow({
).trim() ).trim()
return { return {
status: 'recognized', status: 'recognized',
label: detail ? `已识别票据 · ${detail}` : '已识别票据', label: detail ? `当前会话已识别 · ${detail}` : '当前会话已识别',
title: detail ? `智能录入已完成,识别为${detail}` : '智能录入已完成' title: detail
? `当前上传附件 OCR 已完成,识别为${detail}。本状态不代表票据夹已有记录。`
: '当前上传附件 OCR 已完成。本状态不代表票据夹已有记录。',
document: document && typeof document === 'object' ? document : null
} }
} }
@@ -171,6 +175,24 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return key ? aiModeReceiptRecognitionState[key] || null : null return key ? aiModeReceiptRecognitionState[key] || null : null
} }
function collectAiModeReceiptRecognitionFiles(files = []) {
return (Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
}
function hasPendingAiModeReceiptRecognition(files = []) {
return collectAiModeReceiptRecognitionFiles(files).some((file) => {
const state = resolveAiModeReceiptRecognitionState(file)
return !state || state.status === 'recognizing'
})
}
function hasFailedAiModeReceiptRecognition(files = []) {
return collectAiModeReceiptRecognitionFiles(files).some((file) => (
resolveAiModeReceiptRecognitionState(file)?.status === 'failed'
))
}
function buildAiModeReceiptBaseContext(safeFiles = [], ocrFiles = []) { function buildAiModeReceiptBaseContext(safeFiles = [], ocrFiles = []) {
const attachmentNames = safeFiles const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim()) .map((file) => String(file?.name || '').trim())
@@ -222,7 +244,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
} }
} }
function startAiModeReceiptRecognition(files = []) { function startAiModeReceiptRecognition(files = [], options = {}) {
const safeFiles = Array.isArray(files) ? files : [] const safeFiles = Array.isArray(files) ? files : []
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file)) const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles) const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
@@ -230,8 +252,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return null return null
} }
const forceRefresh = Boolean(options.forceRefresh)
const cached = aiModeReceiptContextCache.get(cacheKey) const cached = aiModeReceiptContextCache.get(cacheKey)
if (cached?.status === 'resolved') { if (!forceRefresh && cached?.status === 'resolved') {
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context) applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
return null return null
} }
@@ -272,7 +295,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
function primeAiModeReceiptContext(files = []) { function primeAiModeReceiptContext(files = []) {
pruneAiModeReceiptRecognitionState(files) pruneAiModeReceiptRecognitionState(files)
const promise = startAiModeReceiptRecognition(files) const promise = startAiModeReceiptRecognition(files, { forceRefresh: true })
if (promise && typeof promise.catch === 'function') { if (promise && typeof promise.catch === 'function') {
promise.catch((error) => { promise.catch((error) => {
console.warn('AI mode OCR preload failed:', error) console.warn('AI mode OCR preload failed:', error)
@@ -399,6 +422,18 @@ export function useWorkbenchAiAttachmentAssociationFlow({
}] }]
} }
const attachmentJobFlow = useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage,
persistCurrentConversation,
replaceInlineMessage,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast,
buildDetailActions: buildAiAttachmentAssociationDetailActions,
buildThinkingEvents: buildAiAttachmentAssociationResultThinkingEvents
})
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) { async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim() const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload) const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
@@ -509,47 +544,42 @@ export function useWorkbenchAiAttachmentAssociationFlow({
try { try {
const collected = await collectAiModeReceiptContext(files) const collected = await collectAiModeReceiptContext(files)
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files) const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 }) const receiptIds = attachmentJobFlow.extractReceiptIdsFromOcrDocuments(collected.ocrDocuments)
const claims = extractExpenseClaimItems(claimsPayload) if (!receiptIds.length) {
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments) throw new Error('当前附件没有持久化票据记录,请重新上传后再试。')
const associationRecord = match.best?.record || match.recommended?.record || null
const associationId = associationRecord?.claimId
? createAiAttachmentAssociationId()
: ''
if (associationId) {
aiAttachmentAssociationRuntime.set(associationId, {
files,
fileNames: files.map((file) => file?.name || '').filter(Boolean),
claimId: String(associationRecord.claimId || '').trim(),
claimNo: String(associationRecord.claimNo || '').trim(),
ocrPayload: collected.ocrPayload,
ocrSummary: collected.ocrSummary,
ocrDocuments: collected.ocrDocuments,
ocrFilePreviews: collected.ocrFilePreviews
})
} }
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({ const fileNames = files.map((file) => file?.name || '').filter(Boolean)
match, const job = attachmentJobFlow.normalizeJob(await createAttachmentAssociationJob({
fileNames: files.map((file) => file?.name || ''), receipt_ids: receiptIds,
ocrDocuments: collected.ocrDocuments prompt,
}) conversation_id: conversationId?.value || ''
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText) }))
if (!job) {
throw new Error('附件关联任务创建失败,请稍后重试。')
}
const runningMessageText = attachmentJobFlow.buildRunningMessage(job, fileNames)
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage( replaceInlineMessage(
pendingMessage.id, pendingMessage.id,
createInlineMessage('assistant', finalMessageText, { createInlineMessage('assistant', runningMessageText, {
id: pendingMessage.id, id: pendingMessage.id,
pending: true,
stewardPlan: { stewardPlan: {
streamStatus: 'completed', streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed') thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
}, },
attachmentOcrDetails, attachmentOcrDetails,
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, { attachmentAssociationJob: job
includeOcrDetails: Boolean(attachmentOcrDetails)
})
}) })
) )
persistCurrentConversation() persistCurrentConversation()
void attachmentJobFlow.pollJob({
jobId: job.jobId,
messageId: pendingMessage.id,
fileNames,
attachmentOcrDetails,
initialJob: job
})
} catch (error) { } catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。' const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
@@ -574,8 +604,11 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return { return {
collectAiModeReceiptContext, collectAiModeReceiptContext,
confirmAiAttachmentAssociation, confirmAiAttachmentAssociation,
hasFailedAiModeReceiptRecognition,
hasPendingAiModeReceiptRecognition,
primeAiModeReceiptContext, primeAiModeReceiptContext,
requestAiAttachmentAssociationReply, requestAiAttachmentAssociationReply,
resumePendingAiAttachmentAssociationJobs: attachmentJobFlow.resumePendingJobs,
resolveAiModeReceiptRecognitionState, resolveAiModeReceiptRecognitionState,
resolveAiAttachmentAssociationClaimNo resolveAiAttachmentAssociationClaimNo
} }

View File

@@ -0,0 +1,250 @@
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { fetchAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
const ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS = 1200
const ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS = 90
const ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
export function useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage,
persistCurrentConversation,
replaceInlineMessage,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast,
buildDetailActions,
buildThinkingEvents
}) {
const activeJobPolls = new Set()
function delay(milliseconds) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, milliseconds)
})
}
function normalizeJob(job = {}) {
const jobId = String(job?.job_id || job?.jobId || '').trim()
if (!jobId) {
return null
}
return {
jobId,
status: String(job?.status || 'queued').trim() || 'queued',
message: String(job?.message || '').trim(),
receiptIds: (Array.isArray(job?.receipt_ids) ? job.receipt_ids : job?.receiptIds || [])
.map((item) => String(item || '').trim())
.filter(Boolean),
claimId: String(job?.claim_id || job?.claimId || '').trim(),
claimNo: String(job?.claim_no || job?.claimNo || '').trim(),
uploadedCount: Number(job?.uploaded_count ?? job?.uploadedCount ?? 0) || 0,
skippedCount: Number(job?.skipped_count ?? job?.skippedCount ?? 0) || 0,
error: String(job?.error || '').trim()
}
}
function isPending(job = {}) {
return ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES.has(String(job?.status || '').trim())
}
function extractReceiptIdsFromOcrDocuments(documents = []) {
return Array.from(new Set(
(Array.isArray(documents) ? documents : [])
.map((document) => String(document?.receipt_id || document?.receiptId || '').trim())
.filter(Boolean)
))
}
function buildRunningMessage(job = {}, fileNames = []) {
const names = fileNames.filter(Boolean)
const attachmentLabel = names.length
? `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
: '已识别票据附件'
const statusText = String(job?.message || '').trim() || '正在后台匹配并归集票据附件。'
return [
'我已收到附件关联请求,正在后台继续处理。',
'',
`本次附件:${attachmentLabel}`,
`处理状态:${statusText}`,
'',
'您可以先离开当前会话,回来后我会继续查询任务结果。'
].join('\n')
}
function buildFailedMessage(job = {}) {
return String(job?.message || job?.error || '').trim() || '自动归集失败,请补充说明或重新上传附件后再试。'
}
function findMessage(messageId) {
return conversationMessages.value.find((message) => message.id === messageId) || null
}
function replaceJobMessage(messageId, content, options = {}) {
const sourceMessage = findMessage(messageId)
if (!sourceMessage) {
return false
}
replaceInlineMessage(
messageId,
createInlineMessage('assistant', content, {
id: messageId,
attachmentAssociationJob: options.attachmentAssociationJob || sourceMessage?.attachmentAssociationJob || null,
attachmentOcrDetails: options.attachmentOcrDetails || sourceMessage?.attachmentOcrDetails || null,
pending: Boolean(options.pending),
stewardPlan: options.stewardPlan || null,
suggestedActions: options.suggestedActions || []
})
)
return true
}
async function updateJobMessage({
job,
messageId,
fileNames = [],
attachmentOcrDetails = null
}) {
const normalizedJob = normalizeJob(job)
if (!normalizedJob) {
return false
}
if (!findMessage(messageId)) {
return true
}
if (normalizedJob.status === 'succeeded') {
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: normalizedJob.claimNo,
fileNames,
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
})
await streamOrSetInlineAssistantContent(messageId, finalMessageText)
replaceJobMessage(messageId, finalMessageText, {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildThinkingEvents('completed')
},
suggestedActions: buildDetailActions({
claimId: normalizedJob.claimId,
claimNo: normalizedJob.claimNo
})
})
notifyRequestUpdated?.({
claimId: normalizedJob.claimId,
claimNo: normalizedJob.claimNo,
source: 'ai-workbench-attachment-association-job',
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
})
persistCurrentConversation()
return true
}
if (normalizedJob.status === 'failed') {
replaceJobMessage(messageId, buildFailedMessage(normalizedJob), {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildThinkingEvents('failed')
}
})
persistCurrentConversation()
return true
}
replaceJobMessage(messageId, buildRunningMessage(normalizedJob, fileNames), {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildThinkingEvents('running')
}
})
persistCurrentConversation()
return false
}
async function pollJob({
jobId,
messageId,
fileNames = [],
attachmentOcrDetails = null,
initialJob = null
} = {}) {
const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeJobPolls.has(normalizedJobId)) {
return
}
activeJobPolls.add(normalizedJobId)
try {
let currentJob = initialJob ? normalizeJob(initialJob) : null
if (currentJob) {
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
if (done) {
return
}
}
for (let index = 0; index < ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS; index += 1) {
await delay(ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS)
currentJob = normalizeJob(await fetchAttachmentAssociationJob(normalizedJobId))
if (!currentJob) {
throw new Error('附件关联任务不存在或已失效。')
}
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
if (done) {
return
}
}
throw new Error('附件关联任务仍在后台处理中,稍后回到会话会继续刷新结果。')
} catch (error) {
const message = error?.message || '自动归集状态查询失败,请稍后回到会话查看。'
replaceJobMessage(messageId, message, {
attachmentAssociationJob: {
jobId: normalizedJobId,
status: 'failed',
message,
error: message
},
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildThinkingEvents('failed')
}
})
toast(message)
persistCurrentConversation()
} finally {
activeJobPolls.delete(normalizedJobId)
}
}
function resumePendingJobs() {
conversationMessages.value.forEach((message) => {
const job = normalizeJob(message.attachmentAssociationJob || null)
if (!job || !isPending(job)) {
return
}
void pollJob({
jobId: job.jobId,
messageId: message.id,
fileNames: message.attachmentOcrDetails?.fileNames || [],
attachmentOcrDetails: message.attachmentOcrDetails || null,
initialJob: job
})
})
}
return {
buildRunningMessage,
extractReceiptIdsFromOcrDocuments,
normalizeJob,
pollJob,
resumePendingJobs
}
}

View File

@@ -8,12 +8,13 @@ export function useWorkbenchAiComposerFiles({
fileInputRef, fileInputRef,
focusAiModeInput, focusAiModeInput,
isInputLocked, isInputLocked,
resolveInputLockedMessage = () => '请等待费用测算完成后再继续操作。',
selectedFiles, selectedFiles,
toast toast
}) { }) {
function triggerAiModeFileUpload() { function triggerAiModeFileUpload() {
if (isInputLocked()) { if (isInputLocked()) {
toast('请等待费用测算完成后再继续操作。') toast(resolveInputLockedMessage() || '请等待当前任务完成后再继续操作。')
return return
} }
fileInputRef.value?.click() fileInputRef.value?.click()

View File

@@ -111,7 +111,7 @@ export function useWorkbenchAiDocumentQueryFlow({
{ {
eventId: 'document-query-parse', eventId: 'document-query-parse',
title: '解析自然语言筛选条件', title: '解析自然语言筛选条件',
content: `正在从的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}`, content: `正在从的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}`,
status: 'running' status: 'running'
}, },
{ {

View File

@@ -1,5 +1,12 @@
import { fetchExpenseClaims } from '../../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
fetchExpenseClaims
} from '../../services/reimbursements.js'
import { runOrchestrator } from '../../services/orchestrator.js' import { runOrchestrator } from '../../services/orchestrator.js'
import {
createLinkedReimbursementDraftJob,
fetchLinkedReimbursementDraftJob
} from '../../services/linkedReimbursementDraftJobs.js'
import { import {
applyAiExpenseAnswer, applyAiExpenseAnswer,
buildAiExpenseStepPrompt, buildAiExpenseStepPrompt,
@@ -27,7 +34,11 @@ import {
buildReimbursementAssociationSelectionText, buildReimbursementAssociationSelectionText,
buildReimbursementAssociationQueryFailedText, buildReimbursementAssociationQueryFailedText,
buildReimbursementDraftActions, buildReimbursementDraftActions,
buildReimbursementDraftContinuationText,
buildReimbursementDraftSelectionText, buildReimbursementDraftSelectionText,
buildStandaloneReimbursementDraftConfirmationActions,
buildStandaloneReimbursementDraftConfirmationText,
buildViewReimbursementDraftAction,
fetchReimbursementAssociationClaims, fetchReimbursementAssociationClaims,
filterReimbursementAssociationCandidates, filterReimbursementAssociationCandidates,
filterReimbursementDraftCandidates, filterReimbursementDraftCandidates,
@@ -37,6 +48,10 @@ import {
export { SESSION_TYPE_EXPENSE } export { SESSION_TYPE_EXPENSE }
const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320 const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
const LINKED_DRAFT_JOB_POLL_INTERVAL_MS = 1200
const LINKED_DRAFT_JOB_MAX_POLLS = 100
const LINKED_DRAFT_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
const LINKED_DRAFT_RUNNING_PHRASE = '正在后台生成报销草稿'
function waitForReimbursementAssociationStep() { function waitForReimbursementAssociationStep() {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -44,6 +59,20 @@ function waitForReimbursementAssociationStep() {
}) })
} }
export function buildLinkedDraftRunningText(job = {}, claimNo = '') {
const statusText = String(job?.message || '').trim()
const shouldShowStatusText = Boolean(
statusText && !statusText.includes(LINKED_DRAFT_RUNNING_PHRASE)
)
return [
`已关联申请单${claimNo ? ` ${claimNo}` : ''},正在后台生成报销草稿...`,
shouldShowStatusText ? '' : null,
shouldShowStatusText ? `处理状态:${statusText}` : null,
'',
'您可以先离开当前会话,回来后我会继续查询任务结果。'
].filter((line) => line !== null).join('\n')
}
export function useWorkbenchAiExpenseFlow({ export function useWorkbenchAiExpenseFlow({
activateInlineConversation, activateInlineConversation,
aiExpenseDraft, aiExpenseDraft,
@@ -70,6 +99,10 @@ export function useWorkbenchAiExpenseFlow({
startAiApplicationPreview, startAiApplicationPreview,
fetchExpenseClaimsForAi = fetchExpenseClaims, fetchExpenseClaimsForAi = fetchExpenseClaims,
runOrchestratorForAi = runOrchestrator, runOrchestratorForAi = runOrchestrator,
createLinkedReimbursementDraftJobForAi = createLinkedReimbursementDraftJob,
fetchLinkedReimbursementDraftJobForAi = fetchLinkedReimbursementDraftJob,
linkedDraftJobPollIntervalMs = LINKED_DRAFT_JOB_POLL_INTERVAL_MS,
linkedDraftJobMaxPolls = LINKED_DRAFT_JOB_MAX_POLLS,
associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
}) { }) {
function replaceInlineAssistantMessage(messageId, content = '', options = {}) { function replaceInlineAssistantMessage(messageId, content = '', options = {}) {
@@ -79,6 +112,7 @@ export function useWorkbenchAiExpenseFlow({
stewardPlan: options.stewardPlan || null, stewardPlan: options.stewardPlan || null,
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [], suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
draftPayload: options.draftPayload || null, draftPayload: options.draftPayload || null,
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
text: options.text || content text: options.text || content
}) })
replaceInlineMessage(messageId, nextMessage) replaceInlineMessage(messageId, nextMessage)
@@ -113,6 +147,67 @@ export function useWorkbenchAiExpenseFlow({
) )
} }
function normalizeDraftActionPayload(payload = {}) {
return {
id: String(payload.id || payload.claim_id || payload.claimId || '').trim(),
claim_no: String(payload.claim_no || payload.claimNo || '').trim(),
original_message: String(payload.original_message || payload.originalMessage || '我要报销').trim() || '我要报销'
}
}
function pushPromptConversationUserMessage(text = '') {
const normalizedText = String(text || '').trim()
if (normalizedText) {
pushInlineUserMessage(normalizedText)
}
}
function promptAiReimbursementDraftContinuation(payload = {}) {
const draft = normalizeDraftActionPayload(payload)
const claimNo = draft.claim_no || '当前草稿'
if (!conversationStarted.value) {
activateInlineConversation({
title: `继续草稿 ${claimNo}`.trim().slice(0, 18) || '继续草稿'
})
}
assistantDraft.value = ''
closeWorkbenchDatePicker()
pushPromptConversationUserMessage(`继续关联草稿 ${claimNo}`)
conversationMessages.value.push(createInlineMessage('assistant', buildReimbursementDraftContinuationText(draft), {
meta: ['等待上传附件或说明'],
suggestedActions: [buildViewReimbursementDraftAction(draft, draft.original_message)]
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function promptStandaloneReimbursementDraftCreation(originalMessage = '我要报销', selectedLabel = '独立新建报销单') {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
const userText = String(selectedLabel || '独立新建报销单').trim() || '独立新建报销单'
if (!conversationStarted.value) {
activateInlineConversation({
title: userText.slice(0, 18) || '新建报销'
})
}
assistantDraft.value = ''
closeWorkbenchDatePicker()
pushPromptConversationUserMessage(userText)
conversationMessages.value.push(createInlineMessage('assistant', buildStandaloneReimbursementDraftConfirmationText(), {
meta: ['等待确认新建草稿'],
suggestedActions: buildStandaloneReimbursementDraftConfirmationActions(sourceText)
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function cancelStandaloneReimbursementDraftCreation() {
conversationMessages.value.push(createInlineMessage('assistant', '好的,本次先不新建报销草稿。您可以继续查看已有草稿,或补充新的报销说明。', {
meta: ['已取消新建']
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) { async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销' const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
if (!conversationStarted.value) { if (!conversationStarted.value) {
@@ -255,7 +350,7 @@ export function useWorkbenchAiExpenseFlow({
aiExpenseDraft.value = next aiExpenseDraft.value = next
if (isAiExpenseDraftComplete(next)) { if (isAiExpenseDraftComplete(next)) {
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮生成报销草稿。`)) conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮生成报销草稿。`))
aiExpenseDraft.value = null aiExpenseDraft.value = null
} else { } else {
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next))) conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
@@ -267,7 +362,7 @@ export function useWorkbenchAiExpenseFlow({
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) { async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
let claims = null let claims = null
try { try {
claims = await fetchExpenseClaimsForAi() claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
} catch { } catch {
aiExpenseDraft.value = null aiExpenseDraft.value = null
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。')) conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
@@ -322,6 +417,122 @@ export function useWorkbenchAiExpenseFlow({
} }
} }
function waitForLinkedDraftJobPoll() {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, linkedDraftJobPollIntervalMs)
})
}
function normalizeLinkedDraftJob(job = {}) {
const jobId = String(job?.job_id || job?.jobId || '').trim()
if (!jobId) {
return null
}
return {
jobId,
status: String(job?.status || 'queued').trim() || 'queued',
message: String(job?.message || '').trim(),
error: String(job?.error || '').trim(),
runId: String(job?.run_id || job?.runId || '').trim(),
applicationClaimNo: String(job?.application_claim_no || job?.applicationClaimNo || '').trim(),
draftPayload: job?.draft_payload && typeof job.draft_payload === 'object'
? job.draft_payload
: job?.draftPayload && typeof job.draftPayload === 'object'
? job.draftPayload
: null
}
}
function buildLinkedDraftFailedText(job = {}) {
return String(job?.message || job?.error || '').trim()
|| '生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,您可以稍后重试,或单独新建报销单。'
}
const activeLinkedDraftJobPolls = new Set()
async function pollLinkedDraftJob({
jobId,
pendingMessageId,
claimNo = '',
initialJob = null
}) {
const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
return
}
activeLinkedDraftJobPolls.add(normalizedJobId)
let currentJob = initialJob ? normalizeLinkedDraftJob(initialJob) : null
try {
for (let index = 0; index <= linkedDraftJobMaxPolls; index += 1) {
if (!currentJob && index > 0) {
currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId))
}
if (currentJob && !LINKED_DRAFT_JOB_PENDING_STATUSES.has(currentJob.status)) {
if (currentJob.status === 'succeeded') {
const draftPayload = currentJob.draftPayload || null
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
const content = draftClaimNo
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
replaceInlineAssistantMessage(pendingMessageId, content, {
draftPayload,
linkedReimbursementDraftJob: {
...currentJob,
applicationClaimNo: claimNo
},
suggestedActions: buildLinkedDraftAction(draftPayload)
})
aiExpenseDraft.value = null
persistCurrentConversation()
scrollInlineConversationToBottom()
return
}
throw new Error(buildLinkedDraftFailedText(currentJob))
}
if (currentJob) {
replaceInlineAssistantMessage(pendingMessageId, buildLinkedDraftRunningText(currentJob, claimNo), {
pending: true,
linkedReimbursementDraftJob: {
...currentJob,
applicationClaimNo: claimNo
},
suggestedActions: []
})
persistCurrentConversation()
}
await waitForLinkedDraftJobPoll()
currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId))
}
throw new Error('报销草稿仍在后台生成中,稍后回到会话会继续刷新结果。')
} finally {
activeLinkedDraftJobPolls.delete(normalizedJobId)
}
}
function resumePendingLinkedReimbursementDraftJobs() {
conversationMessages.value.forEach((message) => {
const job = normalizeLinkedDraftJob(message.linkedReimbursementDraftJob || null)
if (!job || !LINKED_DRAFT_JOB_PENDING_STATUSES.has(job.status)) {
return
}
void pollLinkedDraftJob({
jobId: job.jobId,
pendingMessageId: message.id,
claimNo: job.applicationClaimNo,
initialJob: job
}).catch((error) => {
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
linkedReimbursementDraftJob: {
...job,
status: 'failed',
message: error?.message || '报销草稿生成状态查询失败。'
}
})
persistCurrentConversation()
})
})
}
function buildLinkedDraftAction(draftPayload = {}) { function buildLinkedDraftAction(draftPayload = {}) {
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim() const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim() const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
@@ -375,39 +586,28 @@ export function useWorkbenchAiExpenseFlow({
application, application,
application.original_message || resolveLatestInlineUserPrompt() || '我要报销' application.original_message || resolveLatestInlineUserPrompt() || '我要报销'
) )
const user = currentUser.value || {} const job = await createLinkedReimbursementDraftJobForAi({
const payload = await runOrchestratorForAi(
{
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
conversation_id: null,
message: submitOptions.rawText, message: submitOptions.rawText,
conversation_id: '',
context_json: { context_json: {
...buildWorkbenchUserContext(), ...buildWorkbenchUserContext(),
...submitOptions.extraContext ...submitOptions.extraContext
} }
},
{
timeoutMs: 120000,
timeoutMessage: '生成报销草稿超时,请稍后重试。'
}
)
const draftPayload = payload?.result?.draft_payload || null
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
const content = draftClaimNo
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
replaceInlineAssistantMessage(pendingMessageId, content, {
draftPayload,
suggestedActions: buildLinkedDraftAction(draftPayload)
}) })
aiExpenseDraft.value = null const normalizedJob = normalizeLinkedDraftJob(job)
persistCurrentConversation() if (!normalizedJob) {
scrollInlineConversationToBottom() throw new Error('报销草稿生成任务创建失败,请稍后重试。')
} catch { }
await pollLinkedDraftJob({
jobId: normalizedJob.jobId,
pendingMessageId,
claimNo,
initialJob: normalizedJob
})
} catch (error) {
replaceInlineAssistantMessage( replaceInlineAssistantMessage(
pendingMessageId, pendingMessageId,
'生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。', buildLinkedDraftFailedText(error),
{ {
suggestedActions: [] suggestedActions: []
} }
@@ -419,8 +619,12 @@ export function useWorkbenchAiExpenseFlow({
return { return {
advanceAiExpenseDraft, advanceAiExpenseDraft,
cancelStandaloneReimbursementDraftCreation,
linkAiExpenseApplication, linkAiExpenseApplication,
promptAiReimbursementDraftContinuation,
promptStandaloneReimbursementDraftCreation,
pushInlineExpenseSceneSelectionPrompt, pushInlineExpenseSceneSelectionPrompt,
resumePendingLinkedReimbursementDraftJobs,
startAiApplicationPreviewFromAction, startAiApplicationPreviewFromAction,
startAiReimbursementAssociationGate, startAiReimbursementAssociationGate,
startAiExpenseDraft startAiExpenseDraft

View File

@@ -0,0 +1,183 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { buildSelectedFileCards } from './workbenchAiComposerModel.js'
function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim()
}
function formatFileSize(size) {
const bytes = Number(size || 0)
if (!Number.isFinite(bytes) || bytes <= 0) {
return '-'
}
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatFileTime(timestamp) {
const value = Number(timestamp || 0)
if (!Number.isFinite(value) || value <= 0) {
return '-'
}
return new Date(value).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function normalizePreviewField(field = {}) {
const value = normalizePreviewText(field.value ?? field.text)
if (!value) {
return null
}
return {
label: normalizePreviewText(field.label || field.key || field.name) || '识别字段',
value
}
}
function resolveDocumentPreviewUrl(document = null) {
return normalizePreviewText(document?.preview_data_url || document?.previewDataUrl)
}
function resolveSourceKind(sourceUrl, rawFile = {}) {
const type = normalizePreviewText(rawFile?.type).toLowerCase()
const name = normalizePreviewText(rawFile?.name).toLowerCase()
if (!sourceUrl) {
return 'unsupported'
}
if (sourceUrl.startsWith('data:image/') || type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/.test(name)) {
return 'image'
}
if (type === 'application/pdf' || /\.pdf$/.test(name)) {
return 'pdf'
}
return 'unsupported'
}
function createObjectUrl(rawFile) {
if (!rawFile || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return ''
}
return URL.createObjectURL(rawFile)
}
export function useWorkbenchAiFilePreview({
attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
}) {
const filePreviewState = ref({ open: false, key: '', objectUrl: '' })
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
function clearFilePreviewObjectUrl() {
const objectUrl = filePreviewState.value.objectUrl
if (objectUrl && objectUrl.startsWith('blob:') && typeof URL !== 'undefined') {
URL.revokeObjectURL(objectUrl)
}
}
function findSelectedFile(fileKey) {
const index = selectedFileCards.value.findIndex((file) => file.key === fileKey)
if (index < 0) {
return null
}
return {
card: selectedFileCards.value[index],
index,
rawFile: selectedFiles.value[index]
}
}
function openAiModeFilePreview(fileKey) {
const target = findSelectedFile(fileKey)
if (!target?.rawFile) {
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
open: true,
key: fileKey,
objectUrl: createObjectUrl(target.rawFile)
}
}
function closeAiModeFilePreview() {
clearFilePreviewObjectUrl()
filePreviewState.value = { open: false, key: '', objectUrl: '' }
}
const activeAiModeFilePreview = computed(() => {
if (!filePreviewState.value.open || !filePreviewState.value.key) {
return null
}
const target = findSelectedFile(filePreviewState.value.key)
if (!target) {
return null
}
const rawFile = target.rawFile
const recognitionState = attachmentFlow.resolveAiModeReceiptRecognitionState(rawFile) || target.card.ocrState || null
const document = recognitionState?.document || null
const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || []
const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean)
const documentPreviewUrl = resolveDocumentPreviewUrl(document)
const sourceUrl = documentPreviewUrl || filePreviewState.value.objectUrl
const sourceKind = documentPreviewUrl ? 'image' : resolveSourceKind(sourceUrl, rawFile)
const documentTypeLabel = normalizePreviewText(
document?.document_type_label ||
document?.scene_label ||
document?.document_type ||
target.card.typeLabel
)
return {
open: true,
key: target.card.key,
name: target.card.name,
sourceKind,
sourceUrl,
documentTypeLabel: documentTypeLabel || '待识别',
recognitionStatus: recognitionState?.status || 'idle',
recognitionStatusLabel: recognitionState?.label || '等待智能录入识别',
recognitionStatusTitle: recognitionState?.title || '',
fileInfoRows: [
{ label: '文件类型', value: target.card.typeLabel },
{ label: '文件大小', value: formatFileSize(rawFile?.size) },
{ label: '上传时间', value: formatFileTime(rawFile?.lastModified) }
],
ocrFields,
ocrSummary: normalizePreviewText(document?.summary),
rawText: normalizePreviewText(document?.text).slice(0, 600)
}
})
watch(selectedFiles, (files, previousFiles = []) => {
attachmentFlow.primeAiModeReceiptContext(files)
const fileCountChanged = files.length !== previousFiles.length
if (conversationStarted.value && fileCountChanged) {
scrollInlineConversationToBottom({ force: true })
}
if (filePreviewState.value.open && !findSelectedFile(filePreviewState.value.key)) {
closeAiModeFilePreview()
}
}, { flush: 'sync' })
onBeforeUnmount(() => {
closeAiModeFilePreview()
})
return {
activeAiModeFilePreview,
closeAiModeFilePreview,
openAiModeFilePreview,
selectedFileCards
}
}

View File

@@ -28,7 +28,7 @@ export function useWorkbenchAiSessionCommands({
function openInlineSearchConversation(activateInlineConversation) { function openInlineSearchConversation(activateInlineConversation) {
conversationMessages.value = [ conversationMessages.value = [
createInlineMessage('assistant', '可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。') createInlineMessage('assistant', '可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
] ]
stewardState.value = null stewardState.value = null
thinkingExpandedMessageIds.value = new Set() thinkingExpandedMessageIds.value = new Set()
@@ -54,7 +54,7 @@ export function useWorkbenchAiSessionCommands({
: [ : [
createInlineMessage( createInlineMessage(
'assistant', 'assistant',
'这条历史对话没有保存完整消息。可以继续输入新的问题,小财管家会接着处理。' '这条历史对话没有保存完整消息。可以继续输入新的问题,小财管家会接着处理。'
) )
] ]
conversationStarted.value = true conversationStarted.value = true

View File

@@ -2,7 +2,10 @@ import {
fetchStewardPlan, fetchStewardPlan,
fetchStewardPlanStream fetchStewardPlanStream
} from '../../services/steward.js' } from '../../services/steward.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
fetchExpenseClaims
} from '../../services/reimbursements.js'
import { import {
buildStewardPlanMessageText, buildStewardPlanMessageText,
buildStewardPlanRequest, buildStewardPlanRequest,
@@ -71,13 +74,13 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
if (flow?.flowId === 'travel_application') { if (flow?.flowId === 'travel_application') {
return [ return [
contextText || baseText, contextText || baseText,
'这类操作需要手动确认。请点击下方 **确认发起出差申请**,我在当前对话里生成完整申请表,并把已识别的信息自动预填。' '这类操作需要手动确认。请点击下方 **确认发起出差申请**,我在当前对话里生成完整申请表,并把已识别的信息自动预填。'
].filter(Boolean).join('\n\n') ].filter(Boolean).join('\n\n')
} }
if (flow?.flowId === 'travel_reimbursement') { if (flow?.flowId === 'travel_reimbursement') {
return [ return [
contextText || baseText, contextText || baseText,
'这类操作需要手动确认。请点击下方 **确认关联已有申请单**,我继续查询并展示可关联单据。' '这类操作需要手动确认。请点击下方 **确认关联已有申请单**,我继续查询并展示可关联单据。'
].filter(Boolean).join('\n\n') ].filter(Boolean).join('\n\n')
} }
return baseText return baseText
@@ -100,7 +103,7 @@ function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
if (flow.flowId === 'travel_reimbursement') { if (flow.flowId === 'travel_reimbursement') {
return [{ return [{
label: '确认关联已有申请单', label: '确认关联已有申请单',
description: '确认后查询名下可关联的差旅申请单,并进入关联步骤。', description: '确认后查询名下可关联的差旅申请单,并进入关联步骤。',
icon: 'mdi mdi-link-variant', icon: 'mdi mdi-link-variant',
action_type: 'steward_confirm_flow', action_type: 'steward_confirm_flow',
payload: { payload: {
@@ -155,7 +158,7 @@ export function useWorkbenchAiStewardFlow({
} }
try { try {
const claims = await fetchExpenseClaims() const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {}) const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
planRequest.context_json = { planRequest.context_json = {
...(planRequest.context_json || {}), ...(planRequest.context_json || {}),
@@ -232,14 +235,14 @@ export function useWorkbenchAiStewardFlow({
}, },
{ {
idleTimeoutMs: 90000, idleTimeoutMs: 90000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。' timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。'
} }
) )
} catch (error) { } catch (error) {
if (String(error?.message || '').includes('流式服务')) { if (String(error?.message || '').includes('流式服务')) {
return fetchStewardPlan(payload, { return fetchStewardPlan(payload, {
timeoutMs: 75000, timeoutMs: 75000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。' timeoutMessage: '小财管家仍在规划任务,已停止等待。可以稍后继续追问。'
}) })
} }
throw error throw error
@@ -256,7 +259,7 @@ export function useWorkbenchAiStewardFlow({
{ {
eventId: 'init', eventId: 'init',
title: '小财管家正在接入业务流程', title: '小财管家正在接入业务流程',
content: '正在识别的意图、上下文和附件信息。', content: '正在识别的意图、上下文和附件信息。',
status: 'running' status: 'running'
} }
] ]

View File

@@ -3,7 +3,6 @@ import {
buildLocalApplicationPreview, buildLocalApplicationPreview,
normalizeApplicationPreview normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js' } from '../../utils/expenseApplicationPreview.js'
import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
import { import {
buildAiApplicationPrecheckThinkingEvents, buildAiApplicationPrecheckThinkingEvents,
isAiApplicationPrecheckBlocking isAiApplicationPrecheckBlocking
@@ -32,24 +31,6 @@ export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text
} }
export function buildInlineApplicationActionDetailHref(reference = '') {
const source = reference && typeof reference === 'object' ? reference : { reference }
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
const fallback = String(source.reference || '').trim()
if (claimId || claimNo) {
const params = new URLSearchParams()
if (claimId) {
params.set('claim_id', claimId)
}
if (claimNo) {
params.set('claim_no', claimNo)
}
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
}
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
}
export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {} const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
const body = String(source.body || source.markdown || '').trim() const body = String(source.body || source.markdown || '').trim()
@@ -124,13 +105,11 @@ export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) { export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
const info = resolveInlineApplicationActionDocumentInfo(draftPayload) const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
const reference = info.claimNo || info.claimId const reference = info.claimNo || info.claimId
const href = buildInlineApplicationActionDetailHref(info)
const actionText = href ? `[查看](${href})` : '-'
const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel) const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
return [ return [
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |', '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 |',
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |', '| --- | --- | --- | --- | --- | --- | --- | --- |',
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |` `| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} |`
].join('\n') ].join('\n')
} }
@@ -155,8 +134,7 @@ export function buildInlineApplicationPreviewActionResultText(actionType, payloa
statusLabel: '审批中', statusLabel: '审批中',
stageLabel: approvalStage || '直属领导审批', stageLabel: approvalStage || '直属领导审批',
documentTypeLabel: '出差申请' documentTypeLabel: '出差申请'
}), })
'需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
].filter(Boolean).join('\n\n') ].filter(Boolean).join('\n\n')
} }
return [ return [
@@ -166,8 +144,7 @@ export function buildInlineApplicationPreviewActionResultText(actionType, payloa
statusLabel: '草稿', statusLabel: '草稿',
stageLabel: '待提交', stageLabel: '待提交',
documentTypeLabel: '出差申请' documentTypeLabel: '出差申请'
}), })
'后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
].filter(Boolean).join('\n\n') ].filter(Boolean).join('\n\n')
} }
@@ -266,7 +243,7 @@ export function buildInitialInlineApplicationSubmitThinkingEvents() {
{ {
eventId: 'application-precheck-overlap', eventId: 'application-precheck-overlap',
title: '核查同时间段申请单', title: '核查同时间段申请单',
content: '正在查询名下可见申请单,检查是否存在相同或重叠日期。', content: '正在查询名下可见申请单,检查是否存在相同或重叠日期。',
status: 'running' status: 'running'
}, },
{ {

View File

@@ -151,6 +151,8 @@ export function createWorkbenchAiMessageRuntime() {
: suggestedActions, : suggestedActions,
applicationPreview: options.applicationPreview || null, applicationPreview: options.applicationPreview || null,
draftPayload: options.draftPayload || null, draftPayload: options.draftPayload || null,
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null), attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
text: options.text || normalizedContent, text: options.text || normalizedContent,
createdAt: options.createdAt || Date.now() createdAt: options.createdAt || Date.now()
@@ -166,6 +168,8 @@ export function createWorkbenchAiMessageRuntime() {
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null, applicationPreview: message.applicationPreview || null,
draftPayload: message.draftPayload || null, draftPayload: message.draftPayload || null,
attachmentAssociationJob: message.attachmentAssociationJob || null,
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
attachmentOcrDetails: message.attachmentOcrDetails || null, attachmentOcrDetails: message.attachmentOcrDetails || null,
text: message.text || message.content || '' text: message.text || message.content || ''
}) })
@@ -182,6 +186,8 @@ export function createWorkbenchAiMessageRuntime() {
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null, applicationPreview: message.applicationPreview || null,
draftPayload: message.draftPayload || null, draftPayload: message.draftPayload || null,
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
attachmentOcrDetails: message.attachmentOcrDetails || null attachmentOcrDetails: message.attachmentOcrDetails || null
} }
} }
@@ -193,3 +199,52 @@ export function createWorkbenchAiMessageRuntime() {
serializeRuntimeMessage serializeRuntimeMessage
} }
} }
export function normalizeInlineAttachmentAssociationJob(job = null) {
if (!job || typeof job !== 'object') {
return null
}
const jobId = String(job.jobId || job.job_id || '').trim()
if (!jobId) {
return null
}
const status = String(job.status || 'queued').trim() || 'queued'
const receiptIds = (Array.isArray(job.receiptIds) ? job.receiptIds : job.receipt_ids || [])
.map((item) => String(item || '').trim())
.filter(Boolean)
return {
jobId,
status,
message: String(job.message || '').trim(),
receiptIds,
claimId: String(job.claimId || job.claim_id || '').trim(),
claimNo: String(job.claimNo || job.claim_no || '').trim(),
uploadedCount: Number(job.uploadedCount ?? job.uploaded_count ?? 0) || 0,
skippedCount: Number(job.skippedCount ?? job.skipped_count ?? 0) || 0,
error: String(job.error || '').trim()
}
}
export function normalizeInlineLinkedReimbursementDraftJob(job = null) {
if (!job || typeof job !== 'object') {
return null
}
const jobId = String(job.jobId || job.job_id || '').trim()
if (!jobId) {
return null
}
const draftPayload = job.draftPayload && typeof job.draftPayload === 'object'
? job.draftPayload
: job.draft_payload && typeof job.draft_payload === 'object'
? job.draft_payload
: null
return {
jobId,
status: String(job.status || 'queued').trim() || 'queued',
message: String(job.message || '').trim(),
error: String(job.error || '').trim(),
runId: String(job.runId || job.run_id || '').trim(),
applicationClaimNo: String(job.applicationClaimNo || job.application_claim_no || '').trim(),
draftPayload
}
}

View File

@@ -0,0 +1,21 @@
import { apiRequest } from './api.js'
function normalizeJobId(jobId) {
return String(jobId || '').trim()
}
export function createAttachmentAssociationJob(payload = {}) {
return apiRequest('/reimbursements/attachment-association-jobs', {
method: 'POST',
body: JSON.stringify(payload || {})
})
}
export function fetchAttachmentAssociationJob(jobId) {
const normalizedJobId = normalizeJobId(jobId)
if (!normalizedJobId) {
throw new Error('附件关联任务不存在或已失效。')
}
return apiRequest(`/reimbursements/attachment-association-jobs/${encodeURIComponent(normalizedJobId)}`)
}

View File

@@ -0,0 +1,13 @@
import { apiRequest } from './api.js'
export function createLinkedReimbursementDraftJob(payload = {}) {
return apiRequest('/reimbursements/linked-reimbursement-draft-jobs', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function fetchLinkedReimbursementDraftJob(jobId) {
const normalizedJobId = encodeURIComponent(String(jobId || '').trim())
return apiRequest(`/reimbursements/linked-reimbursement-draft-jobs/${normalizedJobId}`)
}

View File

@@ -74,6 +74,6 @@ export function buildAiApplicationSummary(draft) {
lines.push(`- ${step.label}${value || '待补充'}`) lines.push(`- ${step.label}${value || '待补充'}`)
}) })
lines.push('', '如果哪一项需要修改,直接告诉我;确认无误后我会帮整理成申请草稿内容,再提交到申请助手生成单据。') lines.push('', '如果哪一项需要修改,直接告诉我;确认无误后我会帮整理成申请草稿内容,再提交到申请助手生成单据。')
return lines.join('\n') return lines.join('\n')
} }

View File

@@ -312,7 +312,7 @@ function buildUnsupportedBusinessScopeText(rawText, options = {}) {
? `**小财管家暂时不处理「${text}」这类内容。**` ? `**小财管家暂时不处理「${text}」这类内容。**`
: `**${message.title || '此意图系统不支持。'}**` : `**${message.title || '此意图系统不支持。'}**`
const attachmentHint = options.attachmentCount const attachmentHint = options.attachmentCount
? '刚刚上传的附件我会先保留,切换到合适场景后可以继续使用。' ? '刚刚上传的附件我会先保留,切换到合适场景后可以继续使用。'
: '' : ''
return [ return [
intro, intro,
@@ -325,9 +325,9 @@ function buildUnsupportedBusinessScopeText(rawText, options = {}) {
'', '',
message.body || '这条内容没有识别到当前系统支持的财务业务意图,暂时不能继续处理。', message.body || '这条内容没有识别到当前系统支持的财务业务意图,暂时不能继续处理。',
attachmentHint, attachmentHint,
'可以直接点下面的场景继续,或者重新描述的财务业务需求。', '可以直接点下面的场景继续,或者重新描述的财务业务需求。',
'', '',
message.retryHint || '请重新描述的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。' message.retryHint || '请重新描述的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
].filter(Boolean).join('\n') ].filter(Boolean).join('\n')
} }

View File

@@ -18,6 +18,7 @@ import {
isApplicationPreviewValueProvided, isApplicationPreviewValueProvided,
isTravelApplicationType, isTravelApplicationType,
normalizeAmountFromOntology, normalizeAmountFromOntology,
normalizeApplicationLocationBoundary,
normalizeApplicationTypeLabel, normalizeApplicationTypeLabel,
normalizeTypedOntologyAmount, normalizeTypedOntologyAmount,
parseApplicationDaysValue, parseApplicationDaysValue,
@@ -61,6 +62,15 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
const applicationType = String(fields.applicationType || '').trim() const applicationType = String(fields.applicationType || '').trim()
const transportMode = String(fields.transportMode || '').trim() const transportMode = String(fields.transportMode || '').trim()
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode) const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
const blockingLocationIssue = (normalized.validationIssues || []).find((issue) => issue?.field === 'location')
if (blockingLocationIssue) {
return {
canCalculate: false,
reason: blockingLocationIssue.message || '地点需修正',
payload: null
}
}
if (!shouldEstimate || !days || !location) { if (!shouldEstimate || !days || !location) {
return { return {
@@ -334,7 +344,9 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
currentFields.applicationType currentFields.applicationType
), ),
time: resolveProvidedValue(ontologyFields.timeRange, currentFields.time), time: resolveProvidedValue(ontologyFields.timeRange, currentFields.time),
location: resolveProvidedValue(ontologyFields.location, currentFields.location), location: normalizeApplicationLocationBoundary(
resolveProvidedValue(ontologyFields.location, currentFields.location)
),
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason), reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
days: resolveProvidedValue(ontologyFields.days, currentFields.days), days: resolveProvidedValue(ontologyFields.days, currentFields.days),
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields), transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
@@ -480,7 +492,7 @@ export function buildLocalApplicationPreviewMessage(preview) {
: modelReviewStatus === 'failed' : modelReviewStatus === 'failed'
? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。' ? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。'
: modelReviewStatus === 'template' : modelReviewStatus === 'template'
? '我已为准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。' ? '我已为准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
: '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。' : '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。'
].join('\n') ].join('\n')
} }

View File

@@ -24,6 +24,37 @@ export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
export const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算' export const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
export const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用' export const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
const APPLICATION_LOCATION_BUSINESS_ACTION_PATTERN = /(?:支撑|支持|辅助|部署|上线|实施|验收|项目)/u
const APPLICATION_LOCATION_BUSINESS_OBJECT_PATTERN = /(?:服务器|系统|仿生产|生产环境|测试环境)/u
const APPLICATION_LOCATION_ADDRESS_HINT_PATTERN = /(?:省|市|区|县|自治州|州|镇|乡|街道|路|街|大道|园区|大厦|中心|基地|机场|车站|高铁站|火车站|酒店|楼|号)$/u
const APPLICATION_LOCATION_PREFIX_CANDIDATES = [
'北京',
'北京市',
'上海',
'上海市',
'天津',
'天津市',
'重庆',
'重庆市',
'广州',
'深圳',
'杭州',
'南京',
'苏州',
'成都',
'武汉',
'西安',
'郑州',
'长沙',
'青岛',
'厦门',
'宁波',
'合肥',
'济南',
'福州',
'九江',
'伊犁'
].sort((left, right) => right.length - left.length)
export function resolveApplicationTimeLabel(applicationType = '') { export function resolveApplicationTimeLabel(applicationType = '') {
const label = String(applicationType || '').trim() const label = String(applicationType || '').trim()
@@ -142,6 +173,11 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
export function resolveApplicationValidationIssues(fields = {}) { export function resolveApplicationValidationIssues(fields = {}) {
const issues = [] const issues = []
const locationIssue = resolveApplicationLocationValidationIssue(fields.location)
if (locationIssue) {
issues.push(locationIssue)
}
const rangeDaysText = resolveDaysFromDateRange(fields.time) const rangeDaysText = resolveDaysFromDateRange(fields.time)
const rangeDays = parseApplicationDaysValue(rangeDaysText) const rangeDays = parseApplicationDaysValue(rangeDaysText)
const explicitDays = parseApplicationDaysValue(fields.days) const explicitDays = parseApplicationDaysValue(fields.days)
@@ -155,6 +191,61 @@ export function resolveApplicationValidationIssues(fields = {}) {
return issues return issues
} }
export function resolveApplicationLocationValidationIssue(location = '') {
const text = normalizeApplicationLocationForValidation(location)
if (!text || ['待补充', '待确认', '未知', '暂无', '无'].includes(text)) {
return null
}
if (!applicationLocationMixesBusinessContent(text)) {
return null
}
return {
code: 'invalid_application_location',
field: 'location',
message: `地点“${text}”不是可直接用于差旅测算的真实地点,请填写城市或具体地址,例如“上海”;业务事项请放在事由中。`
}
}
function normalizeApplicationLocationForValidation(value) {
return normalizeApplicationLocationBoundary(value)
.replace(/^(?:地点|业务地点|发生地点|目的地)[:]/u, '')
.replace(/^(?:去|到|赴|前往)/u, '')
.replace(/[:,。;;、]+$/u, '')
}
export function normalizeApplicationLocationBoundary(value) {
const text = compactText(value)
.replace(/^(?:地点|业务地点|发生地点|目的地)[:]/u, '')
.replace(/^(?:去|到|赴|前往)/u, '')
.replace(/[:,。;;、]+$/u, '')
if (!text || /[,、]/u.test(text)) {
return text
}
if (
!APPLICATION_LOCATION_BUSINESS_ACTION_PATTERN.test(text) &&
!APPLICATION_LOCATION_BUSINESS_OBJECT_PATTERN.test(text)
) {
return text
}
const matchedPrefix = APPLICATION_LOCATION_PREFIX_CANDIDATES.find((candidate) => text.startsWith(candidate))
if (!matchedPrefix) {
return text
}
return matchedPrefix.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
}
function applicationLocationMixesBusinessContent(text) {
if (/[,、]/u.test(text)) {
return false
}
if (APPLICATION_LOCATION_BUSINESS_ACTION_PATTERN.test(text)) {
return true
}
return APPLICATION_LOCATION_BUSINESS_OBJECT_PATTERN.test(text) &&
!APPLICATION_LOCATION_ADDRESS_HINT_PATTERN.test(text)
}
function shouldTrustModelApplicationFields(preview = {}) { function shouldTrustModelApplicationFields(preview = {}) {
const status = String(preview?.modelReviewStatus || '').trim() const status = String(preview?.modelReviewStatus || '').trim()
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim() const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
@@ -562,10 +653,10 @@ export function resolveApplicationTimeWithDefault(text, daysText = '', options =
} }
export function resolveApplicationLocation(text) { export function resolveApplicationLocation(text) {
return resolveFirstMatch(text, [ return normalizeApplicationLocationBoundary(resolveFirstMatch(text, [
/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?<value>[^。;;\n]+)/u, /(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?<value>[^。;;\n]+)/u,
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u /(?:去|到|前往)(?<value>[\u4e00-\u9fa5,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
]) ]))
} }
function looksLikeTransportPromptText(text) { function looksLikeTransportPromptText(text) {

View File

@@ -9,6 +9,7 @@ const NON_RISK_SOURCES = new Set([
'application_detail', 'application_detail',
'application_handoff', 'application_handoff',
'application_link', 'application_link',
'application_link_sync',
'application_submission', 'application_submission',
'approval_routing', 'approval_routing',
'budget_approval', 'budget_approval',
@@ -24,6 +25,7 @@ const NON_RISK_EVENTS = new Set([
'expense_application_submission', 'expense_application_submission',
'expense_application_to_reimbursement_draft', 'expense_application_to_reimbursement_draft',
'expense_reimbursement_application_linked', 'expense_reimbursement_application_linked',
'expense_application_reimbursement_deleted',
'expense_application_budget_approval', 'expense_application_budget_approval',
'sla_reminder', 'sla_reminder',
'reminder', 'reminder',

View File

@@ -56,7 +56,7 @@ export function buildTravelPlanningNudgeMessage(preview = {}, draftPayload = {})
const transportCopy = context.transportMode ? `${context.transportMode}时间窗口` : '、交通方式比选' const transportCopy = context.transportMode ? `${context.transportMode}时间窗口` : '、交通方式比选'
return [ return [
`本次${context.location}差旅申请已经提交。`, `本次${context.location}差旅申请已经提交。`,
`如果愿意,我可以继续按 ${timeCopy}整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议还需要确认的事项。` `如果愿意,我可以继续按 ${timeCopy}整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议,以及还需要确认的事项。`
].join('\n') ].join('\n')
} }
@@ -103,7 +103,7 @@ export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {
const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : '' const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : ''
return [ return [
'可以,先给一版轻量行程规划,后续可以继续补充偏好。', '可以,先给一版轻量行程规划,后续可以继续补充偏好。',
'', '',
claimLine, claimLine,
`行程时间:${context.time}${context.days ? `${context.days}` : ''}`, `行程时间:${context.time}${context.days ? `${context.days}` : ''}`,
@@ -113,7 +113,7 @@ export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {
`酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`, `酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`,
'需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。', '需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。',
'', '',
'也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。' '也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
].filter(Boolean).join('\n') ].filter(Boolean).join('\n')
} }

View File

@@ -261,9 +261,10 @@ import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems, extractExpenseClaimItems,
fetchAllApprovalExpenseClaims, fetchApprovalExpenseClaims,
fetchAllArchivedExpenseClaims fetchArchivedExpenseClaims
} from '../services/reimbursements.js' } from '../services/reimbursements.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js' import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import { import {
@@ -684,8 +685,8 @@ async function loadSupportingRows() {
supportingError.value = '' supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([ const [approvalResult, archiveResult] = await Promise.allSettled([
fetchAllApprovalExpenseClaims(), fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
fetchAllArchivedExpenseClaims() fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
]) ])
if (approvalResult.status === 'fulfilled') { if (approvalResult.status === 'fulfilled') {

View File

@@ -368,7 +368,11 @@ import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue' import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useToast } from '../composables/useToast.js' import { useToast } from '../composables/useToast.js'
import { fetchExpenseClaims } from '../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchExpenseClaims
} from '../services/reimbursements.js'
import { import {
buildReceiptFile, buildReceiptFile,
deleteReceiptFolderItem, deleteReceiptFolderItem,
@@ -719,8 +723,8 @@ function closeAssociateDialog() {
async function loadDraftClaims() { async function loadDraftClaims() {
try { try {
const claims = await fetchExpenseClaims() const claims = extractExpenseClaimItems(await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS))
draftClaims.value = (Array.isArray(claims) ? claims : []) draftClaims.value = claims
.filter((claim) => String(claim.status || '').trim().toLowerCase() === 'draft') .filter((claim) => String(claim.status || '').trim().toLowerCase() === 'draft')
.map((claim) => ({ .map((claim) => ({
raw: claim, raw: claim,

View File

@@ -103,7 +103,7 @@
</div> </div>
<div class="steward-initial-recognition-copy"> <div class="steward-initial-recognition-copy">
<strong>小财管家正在识别意图</strong> <strong>小财管家正在识别意图</strong>
<p>我正在读取的输入准备拆解申请报销和附件任务</p> <p>我正在读取的输入准备拆解申请报销和附件任务</p>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,11 @@ import { computed, ref, watch } from 'vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js' import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims
} from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js' import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import { import {
filterActionableRiskFlags, filterActionableRiskFlags,
@@ -181,7 +185,7 @@ export default {
actionIcon: null, actionIcon: null,
tone: 'slate', tone: 'slate',
artLabel: 'QUEUE', artLabel: 'QUEUE',
tips: ['当前仅展示有权限处理的单据', '高风险和即将超时单据会优先高亮'] tips: ['当前仅展示有权限处理的单据', '高风险和即将超时单据会优先高亮']
} }
} }
@@ -228,8 +232,8 @@ export default {
error.value = '' error.value = ''
try { try {
const payload = await fetchApprovalExpenseClaims() const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value) const pendingRequests = listPendingApprovalRequests(extractExpenseClaimItems(payload), currentUser.value)
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item)) const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
rows.value = mappedRows rows.value = mappedRows
syncPendingClaimIds(mappedRows.map((item) => item.claimId)) syncPendingClaimIds(mappedRows.map((item) => item.claimId))

View File

@@ -3,7 +3,11 @@ import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus/es/comp
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchArchivedExpenseClaims
} from '../../services/reimbursements.js'
import { import {
ARCHIVE_FILTER_ALL, ARCHIVE_FILTER_ALL,
applyArchiveListFilters, applyArchiveListFilters,
@@ -244,8 +248,8 @@ export default {
error.value = '' error.value = ''
try { try {
const payload = await fetchArchivedExpenseClaims() const payload = await fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
const mappedRows = (Array.isArray(payload) ? payload : []) const mappedRows = extractExpenseClaimItems(payload)
.map((item) => mapExpenseClaimToRequest(item)) .map((item) => mapExpenseClaimToRequest(item))
.filter(Boolean) .filter(Boolean)
.map((item) => buildArchiveRow(item)) .map((item) => buildArchiveRow(item))

View File

@@ -1,9 +1,11 @@
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useBackendHealth } from '../../composables/useBackendHealth.js' import { useBackendHealth } from '../../composables/useBackendHealth.js'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
const AUTO_RECOVER_INTERVAL_MS = 1500
export default { export default {
name: 'BackendUnavailableRouteView', name: 'BackendUnavailableRouteView',
setup() { setup() {
@@ -11,24 +13,74 @@ export default {
const { backendChecking, backendError, checkBackendHealth } = useBackendHealth() const { backendChecking, backendError, checkBackendHealth } = useBackendHealth()
const { loggedIn, resolveEntryRoute } = useSystemState() const { loggedIn, resolveEntryRoute } = useSystemState()
const retrying = ref(false) const retrying = ref(false)
let autoRecoverTimer = 0
let autoRecovering = false
const statusMessage = computed(() => { const statusMessage = computed(() => {
return backendError.value || '后端服务尚未就绪,请先检查 FastAPI 和数据库连接。' return backendError.value || '后端服务尚未就绪,请先检查 FastAPI 和数据库连接。'
}) })
async function navigateToAvailableRoute() {
await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' })
}
function stopAutoRecover() {
if (!autoRecoverTimer) {
return
}
globalThis.clearInterval(autoRecoverTimer)
autoRecoverTimer = 0
}
async function recoverWhenBackendReady() {
if (autoRecovering) {
return
}
autoRecovering = true
try {
const ok = await checkBackendHealth({ force: true })
if (ok) {
stopAutoRecover()
await navigateToAvailableRoute()
}
} finally {
autoRecovering = false
}
}
function startAutoRecover() {
stopAutoRecover()
void recoverWhenBackendReady()
autoRecoverTimer = globalThis.setInterval(() => {
void recoverWhenBackendReady()
}, AUTO_RECOVER_INTERVAL_MS)
}
async function retry() { async function retry() {
retrying.value = true retrying.value = true
try { try {
const ok = await checkBackendHealth({ force: true }) const ok = await checkBackendHealth({ force: true })
if (ok) { if (ok) {
await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' }) stopAutoRecover()
await navigateToAvailableRoute()
} }
} finally { } finally {
retrying.value = false retrying.value = false
} }
} }
onMounted(() => {
startAutoRecover()
})
onBeforeUnmount(() => {
stopAutoRecover()
})
return { return {
backendChecking, backendChecking,
retrying, retrying,

View File

@@ -401,7 +401,7 @@ export default {
}) })
const { continueStewardApplicationFieldCompletion, handleSuggestedAction, isSuggestedActionSelected, runShortcut } = useTravelReimbursementSuggestedActions({ const { continueStewardApplicationFieldCompletion, handleSuggestedAction, isSuggestedActionSelected, runShortcut } = useTravelReimbursementSuggestedActions({
applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, createMessage, currentUser, emit, fetchExpenseClaims, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, composerUploadIntent, createMessage, currentUser, draftClaimId, emit, fetchExpenseClaims, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight
}) })
const { const {
canShowTravelCalculator, canShowTravelCalculator,

View File

@@ -489,7 +489,7 @@ export function buildEmployeeEmptyState(options = {}) {
title: hasEmployeeFilters ? '当前条件下没有匹配员工' : `${activeTab}”里暂时没有员工`, title: hasEmployeeFilters ? '当前条件下没有匹配员工' : `${activeTab}”里暂时没有员工`,
desc: hasEmployeeFilters desc: hasEmployeeFilters
? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。' ? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。'
: '这个状态标签下目前还没有记录,可以切换到其他状态继续查看。', : '这个状态标签下目前还没有记录,可以切换到其他状态继续查看。',
icon: hasEmployeeFilters ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline', icon: hasEmployeeFilters ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline',
actionLabel: hasEmployeeFilters ? '清空筛选' : '查看全部员工', actionLabel: hasEmployeeFilters ? '清空筛选' : '查看全部员工',
actionIcon: hasEmployeeFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted', actionIcon: hasEmployeeFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted',

View File

@@ -155,13 +155,13 @@ export function buildStewardPlanMessageText(plan) {
`${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}` `${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}`
) )
return [ return [
'### 我先帮把步骤理清楚', '### 我先帮把步骤理清楚',
'', '',
buildStewardPlanFriendlyIntro(normalized), buildStewardPlanFriendlyIntro(normalized),
'', '',
...taskLines, ...taskLines,
'', '',
'看这个顺序是否合适?如果没问题,回复 **确定** 就行。我会先帮你进入第一步,需要补充的信息会在具体步骤里再温和提醒。' '看这个顺序是否合适?如果没问题,回复 **确定** 即可。我会先带您进入第一步,需要补充的信息会在具体步骤里再温和提醒。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n') ].filter((line, index, lines) => line || lines[index - 1]).join('\n')
} }
@@ -457,9 +457,9 @@ function buildPendingFlowConfirmationMessageText(normalized) {
'', '',
knownTable knownTable
? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n') ? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
: '我识别到这是一项财务事项,但还需要确认要进入哪个流程。', : '我识别到这是一项财务事项,但还需要确认要进入哪个流程。',
'', '',
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定要补办申请还是发起报销。', normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定您是要补办申请还是发起报销。',
'', '',
...candidateLines, ...candidateLines,
'', '',
@@ -471,14 +471,14 @@ function buildPendingFlowConfirmationMessageText(normalized) {
function buildGenericReimbursementIntentMessageText() { function buildGenericReimbursementIntentMessageText() {
return [ return [
'### 我来带发起报销', '### 我来带发起报销',
'', '',
'现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带填。', '现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带填。',
'', '',
'1. **先选报销场景**', '1. **先选报销场景**',
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。', ' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
'2. **再补关键材料**', '2. **再补关键材料**',
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮核对是否需要关联事前申请。', ' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮核对是否需要关联事前申请。',
'', '',
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。' '点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
].join('\n') ].join('\n')
@@ -602,22 +602,26 @@ function buildTaskOrderTarget(task) {
function buildTaskOrderActionDescription(task) { function buildTaskOrderActionDescription(task) {
const agent = task.assignedAgentLabel || '对应助手' const agent = task.assignedAgentLabel || '对应助手'
if (task.taskType === 'expense_application') { if (task.taskType === 'expense_application') {
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。` // 申请类:先给行动,再说目的,主语后置
return `这步交给${agent}——先把申请单草稿拉出来给您过目,没问题了再往下走。`
} }
if (task.taskType === 'reimbursement') { if (task.taskType === 'reimbursement') {
if (isGenericReimbursementTask(task)) { if (isGenericReimbursementTask(task)) {
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。` // 通用报销:换个句式,省掉主语,突出"先定方向"
return `报销还差一个关键信息:具体是哪类费用。${agent}会先带您把报销场景定下来,再逐项补事由、时间、金额和票据。`
} }
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。` // 有明确场景的报销:直接说动作,不绕弯
return `票据、金额和制度口径,${agent}会一并核清楚;前一步确认后才会继续,不会越级往下推。`
} }
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。` // 兜底:用"等您点头"的语气,区别于上面三条
return `${agent}先把能核对的结果摆出来,真正动手前仍会等您点头。`
} }
function buildStewardPlanFriendlyIntro(normalized) { function buildStewardPlanFriendlyIntro(normalized) {
const taskCountText = normalized.tasks.length > 1 const taskCountText = normalized.tasks.length > 1
? `${normalized.tasks.length} 个相关事项` ? `${normalized.tasks.length} 个相关事项`
: '1 个事项' : '1 个事项'
return `我先看了一下,这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让每一步都能看清楚、确认后再继续。` return `我先看了一下,这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让每一步都能看清楚、确认后再继续。`
} }
function buildTaskOrderDescription(normalized) { function buildTaskOrderDescription(normalized) {
@@ -627,12 +631,12 @@ function buildTaskOrderDescription(normalized) {
return '处理顺序是:先创建申请单,再引导填写报销单。' return '处理顺序是:先创建申请单,再引导填写报销单。'
} }
if (hasApplication) { if (hasApplication) {
return '我会先引导创建申请单并等待确认。' return '我会先引导创建申请单并等待确认。'
} }
if (hasReimbursement) { if (hasReimbursement) {
return '我会引导填写报销单并等待确认。' return '我会引导填写报销单并等待确认。'
} }
return '我会按识别顺序逐项推进,并在执行前等待确认。' return '我会按识别顺序逐项推进,并在执行前等待确认。'
} }
function buildNextTaskLead(task) { function buildNextTaskLead(task) {

View File

@@ -567,7 +567,7 @@ export function buildRequiredApplicationSelectionText(expenseType, applications)
`发起“${label}”报销前,需要先关联对应的申请单。`, `发起“${label}”报销前,需要先关联对应的申请单。`,
'', '',
`我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`, `我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`,
'选择后,我继续向收集本次报销依据。' '选择后,我继续向收集本次报销依据。'
].join('\n') ].join('\n')
} }
@@ -576,7 +576,7 @@ export function buildRequiredApplicationMissingText(expenseType) {
return [ return [
`发起“${label}”报销前,需要先关联对应的申请单。`, `发起“${label}”报销前,需要先关联对应的申请单。`,
'', '',
`我没有查到名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`, `我没有查到名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。' '请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
].join('\n') ].join('\n')
} }

View File

@@ -7,6 +7,27 @@ import {
normalizeRequiredApplicationCandidate, normalizeRequiredApplicationCandidate,
resolveRequiredApplicationReimbursementType resolveRequiredApplicationReimbursementType
} from './travelReimbursementApplicationLinkModel.js' } from './travelReimbursementApplicationLinkModel.js'
import {
buildReimbursementDraftActions,
buildSkipRequiredApplicationLinkAction
} from './travelReimbursementDraftBranchModel.js'
export {
CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
buildContinueReimbursementDraftAction,
buildCreateStandaloneReimbursementDraftAction,
buildReimbursementDraftActions,
buildReimbursementDraftContinuationText,
buildSkipReimbursementDraftCheckAction,
buildSkipRequiredApplicationLinkAction,
buildStandaloneReimbursementDraftConfirmationActions,
buildStandaloneReimbursementDraftConfirmationText,
buildViewReimbursementDraftAction
} from './travelReimbursementDraftBranchModel.js'
const REIMBURSEMENT_DRAFT_STATUSES = new Set(['draft', 'supplement', 'returned']) const REIMBURSEMENT_DRAFT_STATUSES = new Set(['draft', 'supplement', 'returned'])
@@ -31,9 +52,6 @@ export const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS = 12000
const REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS = Object.freeze({ page: 1, pageSize: 100 }) const REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE = '查询可关联申请单超时,请稍后重试。' const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE = '查询可关联申请单超时,请稍后重试。'
export const SKIP_REQUIRED_APPLICATION_LINK_ACTION = 'skip_required_application_link'
export const SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION = 'skip_reimbursement_draft_check'
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -85,6 +103,26 @@ function formatAmount(value) {
}).format(numberValue)}` }).format(numberValue)}`
} }
function formatCompactDateTime(value) {
const text = normalizeText(value)
if (!text) {
return ''
}
const matched = text.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/)
if (matched) {
return `${matched[1]} ${matched[2]}`
}
return text
}
function formatDraftAmount(value, label = '') {
const explicitLabel = normalizeText(label)
if (explicitLabel) {
return explicitLabel
}
return formatAmount(value) || '待确认'
}
function resolveCurrentUser(currentUser) { function resolveCurrentUser(currentUser) {
return currentUser?.value && typeof currentUser.value === 'object' return currentUser?.value && typeof currentUser.value === 'object'
? currentUser.value ? currentUser.value
@@ -111,9 +149,9 @@ export function isReimbursementAssociationQueryTimeoutError(error) {
export function buildReimbursementAssociationQueryFailedText(error) { export function buildReimbursementAssociationQueryFailedText(error) {
if (isReimbursementAssociationQueryTimeoutError(error)) { if (isReimbursementAssociationQueryTimeoutError(error)) {
return '查询可关联申请单超时。可以稍后重试,也可以选择不关联申请单,单独新建报销单。' return '查询可关联申请单超时。可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
} }
return '查询可关联申请单时出现异常。可以稍后重试,也可以选择不关联申请单,单独新建报销单。' return '查询可关联申请单时出现异常。可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
} }
export async function fetchReimbursementAssociationClaims({ export async function fetchReimbursementAssociationClaims({
@@ -189,7 +227,7 @@ export function normalizeReimbursementDraftCandidate(claim = {}) {
amount_label: formatAmount(amount), amount_label: formatAmount(amount),
status, status,
status_label: STATUS_LABELS[status] || normalizeText(claim?.status_label || claim?.statusLabel || claim?.approval_stage || claim?.approvalStage || status), status_label: STATUS_LABELS[status] || normalizeText(claim?.status_label || claim?.statusLabel || claim?.approval_stage || claim?.approvalStage || status),
created_at: createdAt, created_at: formatCompactDateTime(createdAt),
application_date: createdAt application_date: createdAt
} }
} }
@@ -210,11 +248,11 @@ export function buildReimbursementAssociationSelectionText(applications) {
return [ return [
'### 可关联申请单', '### 可关联申请单',
'', '',
'我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。', '我先检查了您名下的报销草稿,没有查到可继续的报销草稿。',
'', '',
'先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。', '接下来先查询可关联申请单,为您筛选出名下已审批且未关联报销的记录。',
'', '',
`查到 ${candidates.length} 个已审批且尚未关联报销的申请单。可以选择关联其中一个,也可以选择不关联、单独新建报销单。`, `查到 ${candidates.length} 个已审批且尚未关联报销的申请单。可以从中选择一个进行关联,也可以不关联、直接单独新建报销单。`,
'', '',
buildReimbursementAssociationCardsHtml(candidates), buildReimbursementAssociationCardsHtml(candidates),
'', '',
@@ -224,11 +262,11 @@ export function buildReimbursementAssociationSelectionText(applications) {
export function buildReimbursementAssociationMissingText() { export function buildReimbursementAssociationMissingText() {
return [ return [
'我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。', '我先检查了您名下的报销草稿,没有查到可继续的报销草稿。',
'', '',
'先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。', '接下来先查询可关联申请单,为您筛选出名下已审批且未关联报销的记录。',
'', '',
'暂时没有查到已审批且尚未关联报销的申请单。仍然可以选择单独新建报销单,后续按报销类型继续补充信息。' '暂时没有查到已审批且尚未关联报销的申请单。仍然可以选择单独新建报销单,后续按报销类型继续补充信息。'
].join('\n') ].join('\n')
} }
@@ -237,67 +275,16 @@ export function buildReimbursementDraftSelectionText(drafts) {
return [ return [
'### 可继续报销草稿', '### 可继续报销草稿',
'', '',
'我先检查你名下是否有可继续的报销草稿。', '我先检查了您名下的报销草稿。',
'', '',
`查到 ${candidates.length} 个可继续的报销草稿。可以先继续草稿;如果这是新的报销,可以跳过草稿后再关联申请单新建报销单。`, `查到 ${candidates.length} 个可继续的报销草稿。可以查看草稿详情,或继续把附件、说明关联到该草稿;如果这是一次新的报销,请独立新建报销单。`,
'', '',
buildReimbursementDraftCardsHtml(candidates), buildReimbursementDraftCardsHtml(candidates),
'', '',
'请通过下方按钮确认继续草稿,或跳过草稿进入申请单关联。' '请通过下方三个按钮选择下一步。'
].join('\n') ].join('\n')
} }
export function buildSkipRequiredApplicationLinkAction(originalMessage = '') {
return {
label: '不关联,单独新建报销单',
description: '跳过申请单关联,继续选择报销类型并新建报销单。',
icon: 'mdi mdi-file-plus-outline',
action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION,
payload: {
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildSkipReimbursementDraftCheckAction(originalMessage = '') {
return {
label: '不用草稿,关联申请单新建报销单',
description: '跳过已有报销草稿,继续查询可关联申请单。',
icon: 'mdi mdi-file-search-outline',
action_type: SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
payload: {
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildReimbursementDraftActions(drafts, originalMessage = '') {
const sourceText = normalizeText(originalMessage) || '我要报销'
return [
...(Array.isArray(drafts) ? drafts : []).map((draft) => {
const claimNo = normalizeText(draft.claim_no) || '未编号草稿'
return {
label: `继续草稿 ${claimNo}`,
description: [
draft.status_label,
draft.created_at && `更新时间:${draft.created_at}`,
draft.location && `地点:${draft.location}`,
draft.amount_label && `金额:${draft.amount_label}`,
draft.reason && `事由:${draft.reason}`
].filter(Boolean).join(' · '),
icon: 'mdi mdi-file-document-edit-outline',
action_type: 'open_application_detail',
payload: {
claim_id: draft.id,
claim_no: draft.claim_no,
original_message: sourceText
}
}
}),
buildSkipReimbursementDraftCheckAction(sourceText)
]
}
export function buildReimbursementAssociationActions(applications, originalMessage = '') { export function buildReimbursementAssociationActions(applications, originalMessage = '') {
const sourceText = normalizeText(originalMessage) || '我要报销' const sourceText = normalizeText(originalMessage) || '我要报销'
return [ return [
@@ -331,7 +318,7 @@ function buildReimbursementDraftCardHtml(draft = {}) {
const statusLabel = normalizeText(draft.status_label) || '草稿' const statusLabel = normalizeText(draft.status_label) || '草稿'
const title = normalizeText(EXPENSE_TYPE_LABELS[normalizeLower(draft.expense_type)] || draft.expense_type) || '报销草稿' const title = normalizeText(EXPENSE_TYPE_LABELS[normalizeLower(draft.expense_type)] || draft.expense_type) || '报销草稿'
const summaryHtml = [ const summaryHtml = [
buildAssociationCardFieldHtml('金额', draft.amount_label || draft.amount || '待确认', { buildAssociationCardFieldHtml('金额', formatDraftAmount(draft.amount, draft.amount_label), {
valueClass: 'ai-document-card__amount' valueClass: 'ai-document-card__amount'
}), }),
buildAssociationCardFieldHtml('更新时间', draft.created_at || '待确认') buildAssociationCardFieldHtml('更新时间', draft.created_at || '待确认')
@@ -343,7 +330,7 @@ function buildReimbursementDraftCardHtml(draft = {}) {
}), }),
buildAssociationCardFieldHtml('事由', draft.reason || '待补充'), buildAssociationCardFieldHtml('事由', draft.reason || '待补充'),
buildAssociationCardFieldHtml('单据类型', `报销单 · ${title}`), buildAssociationCardFieldHtml('单据类型', `报销单 · ${title}`),
buildAssociationCardFieldHtml('操作', '使用下方按钮继续', { buildAssociationCardFieldHtml('操作', '使用下方按钮查看、关联或新建', {
fieldClass: 'ai-document-card__field--action' fieldClass: 'ai-document-card__field--action'
}) })
].join('') ].join('')
@@ -545,7 +532,7 @@ export function buildReimbursementAssociationThinkingEvents(stage = 'intent', op
title: '检查报销草稿', title: '检查报销草稿',
content: currentOrder > 1 content: currentOrder > 1
? '已完成报销草稿检查,继续判断是否需要进入申请单关联。' ? '已完成报销草稿检查,继续判断是否需要进入申请单关联。'
: '正在查询名下是否存在可继续的报销草稿。', : '正在查询名下是否存在可继续的报销草稿。',
status: resolveStatus(1) status: resolveStatus(1)
}, },
{ {
@@ -553,7 +540,7 @@ export function buildReimbursementAssociationThinkingEvents(stage = 'intent', op
title: '查询可关联申请单', title: '查询可关联申请单',
content: currentOrder > 2 content: currentOrder > 2
? `已完成申请单查询与筛选,命中 ${candidateCount} 张可推荐单据。` ? `已完成申请单查询与筛选,命中 ${candidateCount} 张可推荐单据。`
: '如未发现可继续草稿,就查询名下已审批且尚未关联报销的申请单。', : '如未发现可继续草稿,就查询名下已审批且尚未关联报销的申请单。',
status: resolveStatus(2) status: resolveStatus(2)
}, },
{ {

View File

@@ -53,8 +53,8 @@ export function buildExpenseIntentConfirmationMessage(rawText) {
text text
? `我看到了「${text}」这类业务事项描述。` ? `我看到了「${text}」这类业务事项描述。`
: '我看到了这类业务事项描述。', : '我看到了这类业务事项描述。',
'但现在还不能确定是要发起报销,还是要处理其他事项,所以我先暂停后续识别。', '但现在还不能确定是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
'如果你是想报销,请点击下面的“我要报销”,我继续引导选择具体报销场景。' '如果您是要报销,请点击下面的“我要报销”,我继续引导选择具体报销场景。'
].join('\n') ].join('\n')
} }
@@ -62,13 +62,13 @@ export function buildExpenseSceneSelectionMessage(rawText) {
const text = String(rawText || '').trim() const text = String(rawText || '').trim()
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text) const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
const prefix = hasBusinessTime const prefix = hasBusinessTime
? '我已看到提供了业务发生时间和报销意图。' ? '我已看到提供了业务发生时间和报销意图。'
: '我已识别到这是报销申请。' : '我已识别到这是报销申请。'
return [ return [
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`, `${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`,
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。', '差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
'选完后我会把下一步需要准备的内容整理给。' '选完后我会把下一步需要准备的内容整理给。'
].join('\n') ].join('\n')
} }

View File

@@ -57,7 +57,7 @@ export const ASSISTANT_SESSION_MODE_OPTIONS = [
key: SESSION_TYPE_BUDGET, key: SESSION_TYPE_BUDGET,
label: '预算编制助手', label: '预算编制助手',
icon: 'mdi mdi-calculator-variant-outline', icon: 'mdi mdi-calculator-variant-outline',
description: '帮助进行预算编制与预算相关问题的整理' description: '帮助进行预算编制与预算相关问题的整理'
} }
] ]

View File

@@ -0,0 +1,143 @@
export const SKIP_REQUIRED_APPLICATION_LINK_ACTION = 'skip_required_application_link'
export const SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION = 'skip_reimbursement_draft_check'
export const CONTINUE_REIMBURSEMENT_DRAFT_ACTION = 'continue_reimbursement_draft_association'
export const CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION = 'create_standalone_reimbursement_draft'
export const CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION = 'cancel_standalone_reimbursement_draft'
function normalizeText(value) {
return String(value || '').trim()
}
function resolvePrimaryReimbursementDraft(drafts = []) {
return (Array.isArray(drafts) ? drafts : []).find((draft) => (
normalizeText(draft?.id || draft?.claim_id || draft?.claimId || draft?.claim_no || draft?.claimNo)
)) || {}
}
function resolveDraftClaimNo(draft = {}) {
return normalizeText(draft.claim_no || draft.claimNo) || '未编号草稿'
}
function resolveDraftClaimId(draft = {}) {
return normalizeText(draft.id || draft.claim_id || draft.claimId)
}
export function buildSkipRequiredApplicationLinkAction(originalMessage = '') {
return {
label: '不关联,单独新建报销单',
description: '跳过申请单关联,继续选择报销类型并新建报销单。',
icon: 'mdi mdi-file-plus-outline',
action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION,
payload: {
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildSkipReimbursementDraftCheckAction(originalMessage = '') {
return {
label: '不用草稿,关联申请单新建报销单',
description: '跳过已有报销草稿,继续查询可关联申请单。',
icon: 'mdi mdi-file-search-outline',
action_type: SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
payload: {
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildViewReimbursementDraftAction(draft = {}, originalMessage = '') {
const claimNo = resolveDraftClaimNo(draft)
return {
label: `查看草稿 ${claimNo}`,
description: '打开草稿详情页核对已填内容。',
icon: 'mdi mdi-file-eye-outline',
action_type: 'open_application_detail',
payload: {
claim_id: resolveDraftClaimId(draft),
claim_no: normalizeText(draft.claim_no || draft.claimNo),
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildContinueReimbursementDraftAction(draft = {}, originalMessage = '') {
const claimNo = resolveDraftClaimNo(draft)
return {
label: `继续关联草稿 ${claimNo}`,
description: '上传相关附件或补充说明,继续完善这张草稿。',
icon: 'mdi mdi-link-variant',
action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
claim_id: resolveDraftClaimId(draft),
claim_no: normalizeText(draft.claim_no || draft.claimNo),
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildCreateStandaloneReimbursementDraftAction(originalMessage = '') {
return {
label: '独立新建报销单',
description: '不使用当前草稿,先确认是否创建新的报销草稿。',
icon: 'mdi mdi-file-plus-outline',
action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
original_message: normalizeText(originalMessage) || '我要报销'
}
}
}
export function buildStandaloneReimbursementDraftConfirmationText() {
return [
'是否新建草稿单据?',
'',
'确认后我会跳过当前草稿,按新的报销单据继续收集类型、附件和说明。'
].join('\n')
}
export function buildStandaloneReimbursementDraftConfirmationActions(originalMessage = '') {
const sourceText = normalizeText(originalMessage) || '我要报销'
return [
{
label: '新建草稿单据',
description: '进入单独新建报销单流程。',
icon: 'mdi mdi-file-plus-outline',
action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION,
payload: {
original_message: sourceText,
standalone_draft_confirmed: true
}
},
{
label: '暂不新建',
description: '保留当前对话,不创建新的报销草稿。',
icon: 'mdi mdi-close-circle-outline',
action_type: CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
original_message: sourceText
}
}
]
}
export function buildReimbursementDraftContinuationText(draft = {}) {
const claimNo = normalizeText(draft.claim_no || draft.claimNo) || '当前草稿'
return [
`已选择继续关联草稿 ${claimNo}`,
'',
'请上传相关的附件,或者补充说明。',
'',
'收到后我会围绕这张草稿继续整理票据和报销信息。'
].join('\n')
}
export function buildReimbursementDraftActions(drafts, originalMessage = '') {
const sourceText = normalizeText(originalMessage) || '我要报销'
const primaryDraft = resolvePrimaryReimbursementDraft(drafts)
return [
buildViewReimbursementDraftAction(primaryDraft, sourceText),
buildContinueReimbursementDraftAction(primaryDraft, sourceText),
buildCreateStandaloneReimbursementDraftAction(sourceText)
]
}

View File

@@ -214,7 +214,7 @@ export function buildGuidedExpenseTypeActions() {
export function buildGuidedReimbursementStartText() { export function buildGuidedReimbursementStartText() {
return [ return [
'请问要报销的类型?', '请问要报销的类型?',
'', '',
'先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。' '先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。'
].join('\n') ].join('\n')
@@ -403,7 +403,7 @@ export function buildGuidedReimbursementSummaryText(state) {
lines.push('') lines.push('')
lines.push( lines.push(
linkedApplication linkedApplication
? '如果关联信息无误,我可以直接生成报销草稿;后续由在草稿详情中上传和归集票据。' ? '如果关联信息无误,我可以直接生成报销草稿;后续由在草稿详情中上传和归集票据。'
: '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。' : '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。'
) )
return lines.join('\n') return lines.join('\n')
@@ -510,9 +510,9 @@ export function shouldConfirmGuidedInterruption(text, state) {
export function buildGuidedInterruptionText(text) { export function buildGuidedInterruptionText(text) {
return [ return [
`我看到刚才输入的是:“${normalizeText(text)}”。`, `我看到刚才输入的是:“${normalizeText(text)}”。`,
'', '',
'这看起来像一个新的问题。想继续填写当前引导,还是先暂停当前引导并处理这个问题?' '这看起来像一个新的问题。想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
].join('\n') ].join('\n')
} }
@@ -543,9 +543,9 @@ export function createGuidedStatusQueryState() {
export function buildGuidedStatusQueryStartText() { export function buildGuidedStatusQueryStartText() {
return [ return [
'想按什么条件查询单据状态?', '想按什么条件查询单据状态?',
'', '',
'先选查询方式,我再向收集对应条件。' '先选查询方式,我再向收集对应条件。'
].join('\n') ].join('\n')
} }

View File

@@ -329,16 +329,54 @@ export function buildReviewTodoItems(reviewPayload) {
// 待补充字段提示:每个字段给 2~3 种说法轮换,避免每次同一句。
// 取值用 buildStableTemplateIndex 按字段签名稳定选取,保证同一字段在同一次会话里文案一致。
const REVIEW_PENDING_HINT_COPY = { const REVIEW_PENDING_HINT_COPY = {
expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。', expense_type: [
customer_name: '请补充客户单位全称。', '这笔费用先归个类,后续票据就按这个分类往下核。',
time_range: '请补充业务发生日期或时间范围。', '还差费用类型,定下来我才能匹配对应的标准和材料。'
location: '请补充业务发生地点。', ],
merchant_name: '请补充酒店或商户名称。', customer_name: [
amount: '请补充本次费用金额。', '客户单位的全称填一下,合规校验要用。',
reason: '请补充本次费用场景或事由。', '还差客户单位名称,补上就行。'
participants: '请至少填写 1 名同行人员。', ],
attachments: '请上传或关联对应票据附件。' time_range: [
'业务是哪天发生的?日期或时间范围都行。',
'还差业务发生时间,补个准确日期。'
],
location: [
'这笔费用发生在哪儿?',
'业务地点还空着,补一下。'
],
merchant_name: [
'住的是哪家酒店、或是在哪家商户消费的?',
'还差酒店或商户名称。'
],
amount: [
'这笔费用多少钱?',
'金额还没填,补上才能继续。'
],
reason: [
'这笔费用是为什么发生的?简单说两句背景。',
'事由还空着,补一下方便审核。'
],
participants: [
'一起出差的还有谁?至少填 1 位。',
'同行人员还没填,补 1 名以上。'
],
attachments: [
'对应的票据传一下,或者关联已有的也行。',
'还差票据附件,上传或关联一张。'
]
}
function resolveReviewPendingHint(fieldKey, signature = '') {
const candidates = REVIEW_PENDING_HINT_COPY[fieldKey]
if (!Array.isArray(candidates) || !candidates.length) {
return ''
}
const index = buildStableTemplateIndex(`${fieldKey}:${signature}`, candidates.length)
return candidates[index]
} }
function normalizeReviewFollowupSentence(text) { function normalizeReviewFollowupSentence(text) {
@@ -355,10 +393,12 @@ function buildReviewPlainFollowupItem(item, pendingMode) {
const key = String(item?.key || '').trim() const key = String(item?.key || '').trim()
const label = String(item?.title || item?.label || '').trim() || '待核查信息' const label = String(item?.title || item?.label || '').trim() || '待核查信息'
if (pendingMode) { if (pendingMode) {
const hintFromPool = resolveReviewPendingHint(key, `${key}:${label}`)
const fallbackHint = String(item?.hint || '').trim() || `请补充${label}`
return { return {
key: key || label, key: key || label,
label, label,
text: normalizeReviewFollowupSentence(REVIEW_PENDING_HINT_COPY[key] || item?.hint || `请补充${label}`) text: normalizeReviewFollowupSentence(hintFromPool || fallbackHint)
} }
} }
@@ -370,30 +410,20 @@ function buildReviewPlainFollowupItem(item, pendingMode) {
} }
} }
// 4 条结构差异较大的轮播:①先点状态 ②先给选择 ③先共情 ④先给行动。
// 数量收敛后由 buildStableTemplateIndex 自动落到新范围,行为不变。
const REVIEW_PENDING_SUMMARY_TEMPLATES = [ const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”`, ({ issueSummary }) => `还差 ${issueSummary},下面都列出来了,补齐就能继续。急着走的话,点“草稿”先把当前进度存下来`,
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`, ({ issueSummary }) => `别急,这单还剩 ${issueSummary}。您可以选择现在就按下面的提示补,也可以稍后再回来——点“草稿”随时暂存。`,
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`, ({ issueSummary }) => `这边识别下来,${issueSummary} 还没到位。先看一眼下面的内容对不对,确认好了再往下;中途想停就点“草稿”。`,
({ issueSummary }) => `这笔报销还有 ${issueSummary}尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”`, ({ issueSummary }) => ` ${issueSummary}所以暂时还不能提交。照着下面几项补一下,补完就能进下一步;实在没空先存草稿也行`
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
] ]
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [ const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`, ({ issueSummary }) => `草稿存好了,但还差 ${issueSummary}后面传票时记得关联这张草稿,补齐了再提交审批。`,
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中`, ({ issueSummary }) => `这张草稿还留着 ${issueSummary} 没处理。您随时可以回来补,新增的附件我会归到这张草稿上,不会重复建单`,
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步`, ({ issueSummary }) => `进度保住了,不过 ${issueSummary} 还得补。建议把金额、票据这些先弄齐,再从草稿详情发起正式提交`,
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单`, ({ issueSummary }) => `草稿状态下还剩 ${issueSummary}。可以继续补字段和票据,补完了再提交;想先放着也没问题,草稿不会丢`
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
] ]
function buildStableTemplateIndex(signature, total) { function buildStableTemplateIndex(signature, total) {
@@ -545,7 +575,7 @@ export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } =
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) { if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
const editHref = String(detailHref || '').trim() || '#review-quick-edit' const editHref = String(detailHref || '').trim() || '#review-quick-edit'
lines.push( lines.push(
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。` `系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
) )
} }
return lines.join('\n\n') return lines.join('\n\n')
@@ -660,7 +690,7 @@ export function buildReviewRecognitionNotes(reviewPayload) {
const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))]
if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) {
notes.push(`时间已按的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) notes.push(`时间已按的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`)
} }
if (sourceLabels.length) { if (sourceLabels.length) {
@@ -671,7 +701,7 @@ export function buildReviewRecognitionNotes(reviewPayload) {
if (documentCards.length) { if (documentCards.length) {
notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`)
} else { } else {
notes.push('当前还没有上传票据,这一轮主要依据的文字描述完成初步识别') notes.push('当前还没有上传票据,这一轮主要依据的文字描述完成初步识别')
} }
return notes return notes
@@ -686,7 +716,7 @@ export function buildReviewMissingHint(reviewPayload) {
if (reviewPayload?.can_proceed) { if (reviewPayload?.can_proceed) {
return '当前关键信息已经齐全,这里无需再补充。' return '当前关键信息已经齐全,这里无需再补充。'
} }
return '下面这些字段还需要再确认或补齐,补完后我就能继续往下处理。' return '下面这些字段还需要再确认或补齐,补完后我就能继续往下处理。'
} }

View File

@@ -41,7 +41,7 @@ const REVIEW_RISK_LEVEL_META = {
} }
} }
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要(?:你|您)确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
export function normalizeReviewPanelScope(scope) { export function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim() const normalized = String(scope || '').trim()

View File

@@ -118,7 +118,7 @@ export function useTravelReimbursementStewardFollowupFlow({
title: '判断下一步条件', title: '判断下一步条件',
content: nextMissing content: nextMissing
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。` ? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
: '我会先等确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。' : '我会先等确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
} }
] ]
} }

View File

@@ -142,7 +142,7 @@ function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
function buildApplicationDateConflictMessage(conflict) { function buildApplicationDateConflictMessage(conflict) {
const claimNo = conflict?.claimNo || '已有申请' const claimNo = conflict?.claimNo || '已有申请'
return [ return [
'我先检查了的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。', '我先检查了的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
'', '',
'已有申请:', '已有申请:',
`- **单号**${claimNo}`, `- **单号**${claimNo}`,

View File

@@ -1,4 +1,5 @@
import { buildUnsavedDraftAttachmentConfirmationMessage } from './travelReimbursementAttachmentModel.js' import { buildUnsavedDraftAttachmentConfirmationMessage } from './travelReimbursementAttachmentModel.js'
import { REIMBURSEMENT_LIST_PREVIEW_PARAMS } from '../../services/reimbursements.js'
export async function handleDraftAssociationPreflight({ export async function handleDraftAssociationPreflight({
activeReviewPayload, activeReviewPayload,
@@ -81,7 +82,7 @@ export async function handleDraftAssociationPreflight({
!reviewAction !reviewAction
) { ) {
try { try {
const claims = await fetchExpenseClaims() const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
const queryPayload = buildDraftAssociationQueryPayload(claims) const queryPayload = buildDraftAssociationQueryPayload(claims)
if (queryPayload?.records?.length) { if (queryPayload?.records?.length) {
resetFlowRun() resetFlowRun()

View File

@@ -265,16 +265,16 @@ export function createStewardDelegationHelpers({
'', '',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`, `本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'', '',
'请先告诉我打算怎么出行:**火车、飞机或轮船**。我会根据的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。' '请先告诉我打算怎么出行:**火车、飞机或轮船**。我会根据的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n') ].join('\n')
} }
return [ return [
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。', '我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'', '',
`**还需要补充:${missingFields.join('、')}。**`, `**还需要补充:${missingFields.join('、')}。**`,
'', '',
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。` `请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
].join('\n') ].join('\n')
} }
@@ -349,8 +349,8 @@ export function createStewardDelegationHelpers({
eventId: `${eventPrefix}-intent`, eventId: `${eventPrefix}-intent`,
title: '理解当前任务', title: '理解当前任务',
content: taskSummary content: taskSummary
? `确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}` ? `确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}`
: `确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。` : `确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
}, },
{ {
eventId: `${eventPrefix}-known`, eventId: `${eventPrefix}-known`,
@@ -366,14 +366,14 @@ export function createStewardDelegationHelpers({
eventId: `${eventPrefix}-gap`, eventId: `${eventPrefix}-gap`,
title: '判断待补充信息', title: '判断待补充信息',
content: transportMissing content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。' ? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先请您选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向确认这些信息,不直接推进提交。` : `这一步还缺少${missingInfo},我会先向确认这些信息,不直接推进提交。`
}) })
} else { } else {
events.push({ events.push({
eventId: `${eventPrefix}-ready`, eventId: `${eventPrefix}-ready`,
title: '判断下一步动作', title: '判断下一步动作',
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。` content: `这一步的关键业务信息已形成核对结果。我会先请您检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
}) })
} }
return events return events

View File

@@ -4,7 +4,10 @@ import {
buildApplicationTemplatePreview, buildApplicationTemplatePreview,
buildLocalApplicationPreviewMessage buildLocalApplicationPreviewMessage
} from '../../utils/expenseApplicationPreview.js' } from '../../utils/expenseApplicationPreview.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js' import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
fetchExpenseClaims
} from '../../services/reimbursements.js'
import { import {
buildRequiredApplicationActions, buildRequiredApplicationActions,
buildRequiredApplicationMissingText, buildRequiredApplicationMissingText,
@@ -198,7 +201,7 @@ export function useTravelReimbursementGuidedFlow({
} }
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) { if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
openTravelCalculator?.() openTravelCalculator?.()
pushAssistant('差旅计算器已打开。可以直接填写目的地、天数和金额,我会按规则中心标准帮测算。', { pushAssistant('差旅计算器已打开。可以直接填写目的地、天数和金额,我会按规则中心标准帮测算。', {
meta: ['差旅计算器'] meta: ['差旅计算器']
}) })
persistAndScroll() persistAndScroll()
@@ -253,7 +256,7 @@ export function useTravelReimbursementGuidedFlow({
let claimsPayload = null let claimsPayload = null
try { try {
claimsPayload = await fetchExpenseClaims() claimsPayload = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
} catch (error) { } catch (error) {
console.warn('Fetch reimbursement applications failed:', error) console.warn('Fetch reimbursement applications failed:', error)
guidedFlowState.value = createEmptyGuidedFlowState() guidedFlowState.value = createEmptyGuidedFlowState()
@@ -599,7 +602,7 @@ export function useTravelReimbursementGuidedFlow({
await submitExistingComposer({ await submitExistingComposer({
rawText: pendingText, rawText: pendingText,
userText: pendingText, userText: pendingText,
pendingText: '正在处理的问题...', pendingText: '正在处理的问题...',
skipUserMessage: true skipUserMessage: true
}) })
return true return true

View File

@@ -80,7 +80,7 @@ export function useTravelReimbursementStewardRuntimeDecision({
messages.value.push(createMessage( messages.value.push(createMessage(
'assistant', 'assistant',
[ [
'我理解是在确认当前申请单,但这张申请单还不能提交。', '我理解是在确认当前申请单,但这张申请单还不能提交。',
'', '',
missingFields.length missingFields.length
? `还需要先补充:**${missingFields.join('、')}**。` ? `还需要先补充:**${missingFields.join('、')}**。`
@@ -438,7 +438,7 @@ export function useTravelReimbursementStewardRuntimeDecision({
if (isStewardRuntimeCancelText(normalizedText)) { if (isStewardRuntimeCancelText(normalizedText)) {
return { return {
next_action: 'cancel_current_action', next_action: 'cancel_current_action',
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果要重新规划,请直接告诉我新的财务事项。' response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果要重新规划,请直接告诉我新的财务事项。'
} }
} }
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText) const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
@@ -476,7 +476,7 @@ export function useTravelReimbursementStewardRuntimeDecision({
return { return {
next_action: 'ask_user', next_action: 'ask_user',
response_text: missingFields.length response_text: missingFields.length
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}可以直接回复对应选项或填写具体内容。` ? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}可以直接回复对应选项或填写具体内容。`
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。' : '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
} }
} }

View File

@@ -21,9 +21,16 @@ import {
canUseBudgetAssistantSession canUseBudgetAssistantSession
} from './travelReimbursementConversationModel.js' } from './travelReimbursementConversationModel.js'
import { import {
CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
SKIP_REQUIRED_APPLICATION_LINK_ACTION, SKIP_REQUIRED_APPLICATION_LINK_ACTION,
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION, SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
buildReimbursementDraftContinuationText,
buildReimbursementAssociationSubmitOptions, buildReimbursementAssociationSubmitOptions,
buildStandaloneReimbursementDraftConfirmationActions,
buildStandaloneReimbursementDraftConfirmationText,
buildViewReimbursementDraftAction,
pushReimbursementAssociationPromptMessage pushReimbursementAssociationPromptMessage
} from './travelReimbursementAssociationGateModel.js' } from './travelReimbursementAssociationGateModel.js'
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js' import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
@@ -44,8 +51,10 @@ export function useTravelReimbursementSuggestedActions({
composerDraft, composerDraft,
composerFilesExpanded, composerFilesExpanded,
composerTextareaRef, composerTextareaRef,
composerUploadIntent = { value: '' },
createMessage, createMessage,
currentUser, currentUser,
draftClaimId = { value: '' },
emit, emit,
fetchExpenseClaims = async () => ({ items: [] }), fetchExpenseClaims = async () => ({ items: [] }),
handleGuidedShortcut, handleGuidedShortcut,
@@ -248,6 +257,53 @@ export function useTravelReimbursementSuggestedActions({
persistSessionState() persistSessionState()
} }
function normalizeDraftActionPayload(payload = {}) {
return {
id: String(payload.id || payload.claim_id || payload.claimId || '').trim(),
claim_no: String(payload.claim_no || payload.claimNo || '').trim(),
original_message: String(payload.original_message || payload.originalMessage || '我要报销').trim() || '我要报销'
}
}
function pushDraftContinuationPrompt(actionPayload = {}) {
const draft = normalizeDraftActionPayload(actionPayload)
const claimNo = draft.claim_no || '当前草稿'
if (!draft.id) {
toast('当前没有可继续关联的草稿单据。')
return
}
draftClaimId.value = draft.id
composerUploadIntent.value = 'continue_existing'
messages.value.push(createMessage('user', `继续关联草稿 ${claimNo}`))
messages.value.push(createMessage('assistant', buildReimbursementDraftContinuationText(draft), [], {
meta: ['等待上传附件或说明'],
suggestedActions: [buildViewReimbursementDraftAction(draft, draft.original_message)]
}))
nextTick(scrollToBottom)
persistSessionState()
}
function pushStandaloneDraftCreationPrompt(originalMessage = '我要报销', selectedLabel = '独立新建报销单') {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
const userText = String(selectedLabel || '独立新建报销单').trim() || '独立新建报销单'
messages.value.push(createMessage('user', userText))
messages.value.push(createMessage('assistant', buildStandaloneReimbursementDraftConfirmationText(), [], {
meta: ['等待确认新建草稿'],
suggestedActions: buildStandaloneReimbursementDraftConfirmationActions(sourceText)
}))
nextTick(scrollToBottom)
persistSessionState()
}
function pushStandaloneDraftCreationCancelledPrompt() {
messages.value.push(createMessage('assistant', '好的,本次先不新建报销草稿。您可以继续查看已有草稿,或补充新的报销说明。', [], {
meta: ['已取消新建']
}))
nextTick(scrollToBottom)
persistSessionState()
}
async function pushExpenseAssociationGatePrompt(originalMessage, options = {}) { async function pushExpenseAssociationGatePrompt(originalMessage, options = {}) {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销' const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
startExpenseSceneSelectionAfterIntentConfirmation(sourceText) startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
@@ -288,6 +344,25 @@ export function useTravelReimbursementSuggestedActions({
if (await handleGuidedSuggestedAction(message, action)) return if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === CONTINUE_REIMBURSEMENT_DRAFT_ACTION) {
if (!lockSuggestedActionMessage(message, action)) return
pushDraftContinuationPrompt(action?.payload || {})
return
}
if (actionType === CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) {
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
if (!lockSuggestedActionMessage(message, action)) return
pushStandaloneDraftCreationPrompt(originalMessage, action?.label || '独立新建报销单')
return
}
if (actionType === CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) {
if (!lockSuggestedActionMessage(message, action)) return
pushStandaloneDraftCreationCancelledPrompt()
return
}
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) { if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销' const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
if (!lockSuggestedActionMessage(message, action)) return if (!lockSuggestedActionMessage(message, action)) return

View File

@@ -9,6 +9,10 @@ const routerScript = readFileSync(
fileURLToPath(new URL('../src/router/index.js', import.meta.url)), fileURLToPath(new URL('../src/router/index.js', import.meta.url)),
'utf8' 'utf8'
) )
const backendUnavailableScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/BackendUnavailableRouteView.js', import.meta.url)),
'utf8'
)
test('app route guard allows stale healthy state when health check times out', () => { test('app route guard allows stale healthy state when health check times out', () => {
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/) assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
@@ -49,3 +53,11 @@ test('backend health timeout does not block app rendering when stale fallback is
global.fetch = originalFetch global.fetch = originalFetch
} }
}) })
test('backend unavailable page automatically recovers after service startup race', () => {
assert.match(backendUnavailableScript, /onMounted\(\s*\(\)\s*=>\s*\{/)
assert.match(backendUnavailableScript, /startAutoRecover\(\)/)
assert.match(backendUnavailableScript, /globalThis\.setInterval/)
assert.match(backendUnavailableScript, /router\.replace\(loggedIn\.value \? resolveEntryRoute\(\) : \{ name: 'login' \}\)/)
assert.match(backendUnavailableScript, /onBeforeUnmount\(\s*\(\)\s*=>\s*\{/)
})

View File

@@ -268,7 +268,7 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH) Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
) )
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/) assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
assert.match(greetingGuard.text, /可以直接点下面的场景继续/) assert.match(greetingGuard.text, /可以直接点下面的场景继续/)
assert.equal(guard.suggestedActions.length, 4) assert.equal(guard.suggestedActions.length, 4)
assert.equal(guard.blocked, true) assert.equal(guard.blocked, true)
assert.equal(guard.targetSessionType, '') assert.equal(guard.targetSessionType, '')
@@ -466,6 +466,28 @@ test('application preview parses same-month shorthand date range', () => {
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/) assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
}) })
test('application preview splits compact destination and business purpose', () => {
const preview = buildLocalApplicationPreview(
'2026-02-20 至 2026-02-23去上海辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview blocks submit when date range conflicts with explicit days', () => { test('application preview blocks submit when date range conflicts with explicit days', () => {
const preview = buildLocalApplicationPreview( const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差3天辅助国网仿生产服务器部署火车', '申请2月20-23日去上海出差3天辅助国网仿生产服务器部署火车',
@@ -569,6 +591,40 @@ test('application preview trusts model-refined fields over noisy source candidat
assert.deepEqual(preview.validationIssues, []) assert.deepEqual(preview.validationIssues, [])
}) })
test('application preview normalizes model-refined location mixed with business content', () => {
const rawText = '申请2月20日-23日火车出差事由辅助国网仿生产服务器部署'
const preview = buildModelRefinedApplicationPreview(
buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-09' }),
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海辅助国网仿生产服务器', normalized_value: '上海辅助国网仿生产服务器' },
{ type: 'reason', value: '辅助国网仿生产服务器部署', normalized_value: '辅助国网仿生产服务器部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
],
time_range: {
start_date: '2026-02-20',
end_date: '2026-02-23'
},
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
const footer = buildApplicationPreviewFooterMessage(preview)
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
assert.match(footer, /#application-submit/)
assert.equal(estimateRequest.canCalculate, true)
assert.equal(estimateRequest.payload.location, '上海')
})
test('application preview blocks submit when transport candidates conflict', () => { test('application preview blocks submit when transport candidates conflict', () => {
const preview = buildLocalApplicationPreview( const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署出行方式飞机坐火车', '申请2月20-23日去上海出差4天辅助国网仿生产服务器部署出行方式飞机坐火车',
@@ -1054,7 +1110,7 @@ test('steward application missing transport blocks preview table', () => {
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/) assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/) assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/) assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.doesNotMatch(submitComposerScript, /请先告诉我打算怎么出行:\*\*火车、飞机或轮船\*\*/) assert.doesNotMatch(submitComposerScript, /请先告诉我打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(suggestedActionsScript, /payload\.applicationPreview/) assert.match(suggestedActionsScript, /payload\.applicationPreview/)
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/) assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)

View File

@@ -57,6 +57,9 @@ function testReceiptFolderViewSurface() {
assert.match(view, /buildReceiptFile\(item\)/) assert.match(view, /buildReceiptFile\(item\)/)
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/) assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
assert.match(view, /emit\('open-assistant'/) assert.match(view, /emit\('open-assistant'/)
assert.match(view, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(view, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/)
} }
function testReceiptFolderServiceContract() { function testReceiptFolderServiceContract() {
@@ -160,18 +163,19 @@ function testReceiptFolderDetailLayoutAdjustments() {
function testAssistantUnlinkedReceiptPrompt() { function testAssistantUnlinkedReceiptPrompt() {
const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js') const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js')
const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.js') const attachmentFlow = readProjectFile('web/src/views/scripts/travelReimbursementSubmitAttachmentFlow.js')
const suggestedActions = readProjectFile('web/src/views/scripts/useTravelReimbursementSuggestedActions.js')
assert.match(submitComposer, /fetchReceiptFolderItems/) assert.match(submitComposer, /fetchReceiptFolderItems/)
assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/) assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/)
assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/) assert.match(attachmentFlow, /fetchReceiptFolderItems\('unlinked'\)/)
assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/) assert.match(attachmentFlow, /skipReceiptFolderUnlinkedPrompt/)
assert.match(submitComposer, /open_receipt_folder/) assert.match(attachmentFlow, /open_receipt_folder/)
assert.match(submitComposer, /continue_upload_with_unlinked_receipts/) assert.match(attachmentFlow, /continue_upload_with_unlinked_receipts/)
assert.match(assistantView, /actionType === 'open_receipt_folder'/) assert.match(suggestedActions, /actionType === 'open_receipt_folder'/)
assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/) assert.match(suggestedActions, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/) assert.match(suggestedActions, /actionType === 'continue_upload_with_unlinked_receipts'/)
assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/) assert.match(suggestedActions, /skipReceiptFolderUnlinkedPrompt: true/)
} }
function run() { function run() {

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const root = process.cwd()
function readProjectFile(path) {
return readFileSync(join(root, path), 'utf8')
}
test('workbench and document list refreshes use preview pagination', () => {
const useRequests = readProjectFile('web/src/composables/useRequests.js')
const useAppShell = readProjectFile('web/src/composables/useAppShell.js')
const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue')
const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js')
const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js')
assert.match(useRequests, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(useRequests, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(useRequests, /fetchAllExpenseClaims\(\)/)
assert.match(useAppShell, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)
assert.match(approvalCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims\(\)/)
assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/)
})

View File

@@ -20,12 +20,12 @@ test('steward plan summary uses warm guidance copy for application flow', () =>
next_action: 'confirm_create_application' next_action: 'confirm_create_application'
}) })
assert.match(message, /我先帮把步骤理清楚/) assert.match(message, /我先帮把步骤理清楚/)
assert.match(message, /我先看了一下,这次主要是 \*\*1 个事项\*\*/) assert.match(message, /我先看了一下,这次主要是 \*\*1 个事项\*\*/)
assert.match(message, /为了不让步骤混在一起/) assert.match(message, /为了不让步骤混在一起/)
assert.match(message, /我会请申请助手先把申请单草稿整理出来/) assert.match(message, /这步交给申请助手——先把申请单草稿拉出来给您过目/)
assert.match(message, /看这个顺序是否合适/) assert.match(message, /看这个顺序是否合适/)
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒/) assert.match(message, /需要补充的信息会在具体步骤里再温和提醒/)
assert.doesNotMatch(message, /我会这样推进/) assert.doesNotMatch(message, /我会这样推进/)
assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/) assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/)
assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/) assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/)
@@ -59,8 +59,8 @@ test('steward plan summary guides bare reimbursement intent into scene selection
const message = buildStewardPlanMessageText(plan) const message = buildStewardPlanMessageText(plan)
assert.match(message, /我来带发起报销/) assert.match(message, /我来带发起报销/)
assert.match(message, /现在只说了要报销/) assert.match(message, /现在只说了要报销/)
assert.match(message, /先选报销场景/) assert.match(message, /先选报销场景/)
assert.match(message, /差旅费、交通费、住宿费/) assert.match(message, /差旅费、交通费、住宿费/)
assert.doesNotMatch(message, /步骤混在一起/) assert.doesNotMatch(message, /步骤混在一起/)

View File

@@ -94,6 +94,10 @@ const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8' 'utf8'
) )
const submitDraftPreflightScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
'utf8'
)
const messageHandlersScript = readFileSync( const messageHandlersScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)), fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)),
'utf8' 'utf8'
@@ -477,6 +481,12 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/) assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/) assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/)
assert.match(guidedFlowScript, /fetchExpenseClaims/) assert.match(guidedFlowScript, /fetchExpenseClaims/)
assert.match(guidedFlowScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(guidedFlowScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(submitDraftPreflightScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(submitDraftPreflightScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(guidedFlowScript, /fetchExpenseClaims\(\)/)
assert.doesNotMatch(submitDraftPreflightScript, /fetchExpenseClaims\(\)/)
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/) assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/) assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/) assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)

View File

@@ -434,6 +434,13 @@ test('AI advice ignores approval opinions and flow logs as risks', () => {
severity: 'info', severity: 'info',
label: '财务审核通过', label: '财务审核通过',
message: '周晓彤 已完成财务审核,进入归档入账。' message: '周晓彤 已完成财务审核,进入归档入账。'
},
{
source: 'application_link_sync',
event_type: 'expense_application_reimbursement_deleted',
severity: 'warning',
label: '关联报销单已删除',
message: '关联报销单 RDELETE01 已删除,申请单已回到待关联状态。'
} }
] ]
}) })

View File

@@ -4,6 +4,10 @@ import test from 'node:test'
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js' import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js' import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js' import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
import {
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
test('workbench steward application confirmation opens inline application preview directly', () => { test('workbench steward application confirmation opens inline application preview directly', () => {
const [action] = buildStewardSuggestedActions({ const [action] = buildStewardSuggestedActions({
@@ -136,3 +140,108 @@ test('workbench reimbursement skip link action opens new reimbursement flow', ()
label: '不关联,单独新建报销单' label: '不关联,单独新建报销单'
}) })
}) })
test('workbench draft continuation action asks for attachments or description', () => {
let continuationPayload = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: (payload) => {
continuationPayload = payload
},
promptStandaloneReimbursementDraftCreation: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '继续关联草稿 RE-202606-010',
action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(continuationPayload, {
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
})
})
test('workbench standalone draft action asks before creating a new reimbursement draft', () => {
let standalonePrompt = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: () => {},
promptStandaloneReimbursementDraftCreation: (sourceText, label) => {
standalonePrompt = { sourceText, label }
},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '独立新建报销单',
action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(standalonePrompt, {
sourceText: '我要报销',
label: '独立新建报销单'
})
})

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildInlineApplicationDetailAction,
buildInlineApplicationPreviewActionResultText
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../src/services/aiApplicationPreviewActions.js'
const draftPayload = {
claim_no: 'AUKSNUCFD',
claim_id: 'claim-1001',
approval_stage: '直属领导审批',
start_date: '2026-02-20',
location: '上海辅助国网仿生产服务器',
reason: '差旅费用申请'
}
test('application result card stays display-only while the detail shortcut keeps navigation', () => {
const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SUBMIT, {
result: { draft_payload: draftPayload }
})
const [detailAction] = buildInlineApplicationDetailAction(draftPayload)
assert.match(resultText, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(resultText, /\| 操作 \|/)
assert.doesNotMatch(resultText, /\[查看\]/)
assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/)
assert.equal(detailAction?.label, '查看单据详情')
assert.equal(detailAction?.action_type, 'open_application_detail')
assert.equal(detailAction?.payload?.claim_no, 'AUKSNUCFD')
})
test('saved draft result also avoids the duplicate in-card view guidance', () => {
const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SAVE_DRAFT, {
result: { draft_payload: draftPayload }
})
assert.match(resultText, /申请草稿已保存/)
assert.doesNotMatch(resultText, /\| 操作 \|/)
assert.doesNotMatch(resultText, /\[查看\]/)
assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/)
})

View File

@@ -9,9 +9,13 @@ function readSource(path) {
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue') const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html') const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue') const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue') const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue')
const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css')
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js') const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
function countOccurrences(source, pattern) { function countOccurrences(source, pattern) {
return source.match(pattern)?.length || 0 return source.match(pattern)?.length || 0
@@ -44,3 +48,57 @@ test('shared workbench file strip preserves OCR status badges', () => {
assert.match(fileStripComponent, /mdi mdi-text-recognition/) assert.match(fileStripComponent, /mdi mdi-text-recognition/)
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/) assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
}) })
test('AI mode primes attachment OCR synchronously after file selection', () => {
assert.match(
filePreviewRuntime,
/watch\(\s*selectedFiles,\s*\(files(?:,\s*previousFiles\s*=\s*\[\])?\)\s*=>\s*\{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/
)
})
test('AI mode keeps conversation anchored above selected attachments', () => {
assert.match(
filePreviewRuntime,
/watch\(\s*selectedFiles,\s*\(files,\s*previousFiles\s*=\s*\[\]\)\s*=>\s*\{[\s\S]*const fileCountChanged = files\.length !== previousFiles\.length[\s\S]*scrollInlineConversationToBottom\(\{\s*force:\s*true\s*\}\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/
)
assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*gap:\s*14px;/)
assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*scroll-padding-bottom:\s*42px;/)
})
test('AI mode lays selected attachments in a horizontal scroll strip', () => {
assert.match(aiModeStyles, /\.workbench-ai-file-strip\s*\{[\s\S]*flex-wrap:\s*nowrap;[\s\S]*overflow-x:\s*auto;[\s\S]*overflow-y:\s*hidden;/)
assert.match(aiModeStyles, /\.workbench-ai-file-card\s*\{[\s\S]*flex:\s*0 0 312px;/)
assert.match(fileStripComponent, /role="button"/)
assert.match(fileStripComponent, /@click="runtime\.openAiModeFilePreview\(file\.key\)"/)
assert.match(fileStripComponent, /@click\.stop="runtime\.removeAiModeFile\(file\.key\)"/)
})
test('AI mode attachment preview opens a split source and recognition dialog', () => {
assert.match(aiModeComponent, /import WorkbenchAiFilePreviewDialog from '\.\/workbench-ai\/WorkbenchAiFilePreviewDialog\.vue'/)
assert.match(aiModeTemplate, /<WorkbenchAiFilePreviewDialog :runtime="workbenchAiRuntime" \/>/)
assert.match(aiModeRuntime, /useWorkbenchAiFilePreview\(/)
assert.match(aiModeRuntime, /\.\.\.filePreview,/)
assert.match(filePreviewRuntime, /URL\.createObjectURL\(rawFile\)/)
assert.match(filePreviewRuntime, /attachmentFlow\.resolveAiModeReceiptRecognitionState\(rawFile\)/)
assert.match(filePreviewComponent, /class="workbench-ai-file-preview-source"/)
assert.match(filePreviewComponent, /class="workbench-ai-file-preview-insight"/)
assert.match(filePreviewComponent, /<img[\s\S]*v-if="preview\.sourceKind === 'image'"/)
assert.match(filePreviewComponent, /<iframe[\s\S]*v-else-if="preview\.sourceKind === 'pdf'"/)
assert.match(filePreviewComponent, /v-for="field in preview\.ocrFields"/)
})
test('AI mode attachment preview centers inside the main content area beside the sidebar', () => {
assert.match(filePreviewStyles, /--workbench-ai-preview-sidebar-offset:\s*var\(--sidebar-expanded-width,\s*304px\);/)
assert.match(
filePreviewStyles,
/\.workbench-ai-file-preview-mask\s*\{[\s\S]*grid-template-columns:\s*var\(--workbench-ai-preview-sidebar-offset\) minmax\(0,\s*1fr\);/
)
assert.match(
filePreviewStyles,
/\.workbench-ai-file-preview-dialog\s*\{[\s\S]*grid-column:\s*2;[\s\S]*justify-self:\s*center;/
)
assert.match(
filePreviewStyles,
/@media \(max-width:\s*900px\)\s*\{[\s\S]*--workbench-ai-preview-sidebar-offset:\s*0px;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\);/
)
})

View File

@@ -195,14 +195,14 @@ test('AI mode formats saved application draft as a detail table without continui
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/) assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
assert.match(aiMode, /submitted:\s*'审批中'/) assert.match(aiMode, /submitted:\s*'审批中'/)
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/) assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/) assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
assert.doesNotMatch(aiMode, /\[查看\]\(\$\{href\}\)/)
assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/) assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/)
assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/) assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/)
assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/) assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/)
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/) assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
assert.match(aiMode, /params\.set\('claim_id', claimId\)/) assert.match(aiMode, /action_type:\s*'open_application_detail'/)
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText') const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
@@ -227,7 +227,7 @@ test('AI mode formats saved application draft as a detail table without continui
executeBlock, executeBlock,
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/ /targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
) )
assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/) assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
}) })
test('AI mode locks application preview actions while estimate refresh is pending', () => { test('AI mode locks application preview actions while estimate refresh is pending', () => {

View File

@@ -244,9 +244,10 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/) assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/) assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/) assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/) assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/)
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft/) assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft/)
assert.match(aiModeSurface, /createAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/)
assert.match(aiModeSurface, /fetchAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/)
assert.match(aiModeSurface, /import \{ recognizeOcrFiles \} from '\.\.\/\.\.\/services\/ocr\.js'/) assert.match(aiModeSurface, /import \{ recognizeOcrFiles \} from '\.\.\/\.\.\/services\/ocr\.js'/)
assert.match(aiModeSurface, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/) assert.match(aiModeSurface, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/)
assert.match(aiModeSurface, /const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'/) assert.match(aiModeSurface, /const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'/)
@@ -269,10 +270,16 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/) assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/) assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/) assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/) assert.match(aiModeSurface, /function extractReceiptIdsFromOcrDocuments\(documents = \[\]\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/) assert.match(aiModeSurface, /const receiptIds = attachmentJobFlow\.extractReceiptIdsFromOcrDocuments\(collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/) assert.match(aiModeSurface, /await createAttachmentAssociationJob\(\{[\s\S]*receipt_ids: receiptIds,[\s\S]*conversation_id: conversationId\?\.value/)
assert.match(aiModeSurface, /attachmentOcrDetails,\s*[\s\S]*includeOcrDetails: Boolean\(attachmentOcrDetails\)/) assert.match(aiModeSurface, /attachmentAssociationJob: job/)
assert.match(aiModeSurface, /async function pollJob\(/)
assert.match(aiModeSurface, /fetchAttachmentAssociationJob\(normalizedJobId\)/)
assert.match(aiModeSurface, /function resumePendingJobs\(\)/)
assert.match(aiModeSurface, /resumePendingAiAttachmentAssociationJobs: attachmentJobFlow\.resumePendingJobs/)
assert.match(aiModeSurface, /attachmentFlow\.resumePendingAiAttachmentAssociationJobs\(\)/)
assert.match(aiModeSurface, /attachmentAssociationJob: normalizeInlineAttachmentAssociationJob/)
assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/) assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/)
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/) assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/) assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/)
@@ -388,7 +395,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/) assert.match(aiModeSurface, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/) assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/) assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/) assert.match(aiModeSurface, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/) assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
assert.match(aiModeSurface, /我已保留当前申请核对表/) assert.match(aiModeSurface, /我已保留当前申请核对表/)
assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/) assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/)
@@ -549,12 +557,25 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/) assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/)
assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/) assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/)
assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/) assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/)
assert.match(aiModeSurface, /function hasPendingAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /function hasFailedAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/) assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/)
assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/) assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/)
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `已识别票据/) assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `当前会话已识别/)
assert.match(aiModeSurface, /本状态不代表票据夹已有记录/)
assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/) assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/)
assert.match(aiModeSurface, /const isAiModeReceiptRecognitionPending = computed\(\(\) => attachmentFlow\.hasPendingAiModeReceiptRecognition\(selectedFiles\.value\)\)/)
assert.match(aiModeSurface, /const hasAiModeReceiptRecognitionFailure = computed\(\(\) => attachmentFlow\.hasFailedAiModeReceiptRecognition\(selectedFiles\.value\)\)/)
assert.match(aiModeSurface, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
assert.match(aiModeSurface, /!hasAiModeReceiptRecognitionFailure\.value[\s\S]*Boolean\(assistantDraft\.value\.trim\(\)\)/)
assert.match(aiModeSurface, /function resolveAiModeInputLockMessage\(\) \{[\s\S]*附件识别中,请稍等/)
assert.match(aiModeSurface, /hasAiModeReceiptRecognitionFailure\.value[\s\S]*请先移除识别失败的附件或重新上传/)
assert.match(aiModeSurface, /:placeholder="runtime\.isAiModeInputLocked \? runtime\.aiModeInputLockMessage : placeholder"/)
assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/) assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/) assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\], options = \{\}\)/)
assert.match(aiModeSurface, /const forceRefresh = Boolean\(options\.forceRefresh\)/)
assert.match(aiModeSurface, /if \(!forceRefresh && cached\?\.status === 'resolved'\) \{/)
assert.match(aiModeSurface, /startAiModeReceiptRecognition\(files, \{ forceRefresh: true \}\)/)
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/) assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/) assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/) assert.match(aiModeSurface, /buildFileIdentity\(file\)/)

View File

@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { useWorkbenchAiExpenseFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js' import {
buildLinkedDraftRunningText,
useWorkbenchAiExpenseFlow
} from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
import {
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
const personalWorkbenchAiMode = readFileSync( const personalWorkbenchAiMode = readFileSync(
join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'), join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
@@ -40,7 +47,11 @@ function buildFlow(options = {}) {
conversationStarted, conversationStarted,
createInlineMessage, createInlineMessage,
currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } }, currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } },
createLinkedReimbursementDraftJobForAi: options.createLinkedReimbursementDraftJobForAi,
fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi, fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
fetchLinkedReimbursementDraftJobForAi: options.fetchLinkedReimbursementDraftJobForAi,
linkedDraftJobPollIntervalMs: options.linkedDraftJobPollIntervalMs ?? 0,
linkedDraftJobMaxPolls: options.linkedDraftJobMaxPolls ?? 2,
runOrchestratorForAi: options.runOrchestratorForAi, runOrchestratorForAi: options.runOrchestratorForAi,
associationQueryTimeoutMs: options.associationQueryTimeoutMs, associationQueryTimeoutMs: options.associationQueryTimeoutMs,
persistCurrentConversation: () => { persistCurrentConversation: () => {
@@ -61,6 +72,18 @@ function buildFlow(options = {}) {
const conversationStarted = { value: false } const conversationStarted = { value: false }
test('linked reimbursement draft running text avoids duplicate status wording', () => {
const content = buildLinkedDraftRunningText(
{ message: '正在后台生成报销草稿...' },
'AVF9ST8TT'
)
const repeated = content.match(/正在后台生成报销草稿/g) || []
assert.equal(repeated.length, 1)
assert.doesNotMatch(content, /处理状态:\s*正在后台生成报销草稿/)
assert.match(content, /回来后我会继续查询任务结果/)
})
test('reimbursement intent checks drafts before recommending approved application documents', async () => { test('reimbursement intent checks drafts before recommending approved application documents', async () => {
conversationStarted.value = false conversationStarted.value = false
let queried = 0 let queried = 0
@@ -135,7 +158,7 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
reason: '北京客户现场实施报销', reason: '北京客户现场实施报销',
location: '北京', location: '北京',
status: 'draft', status: 'draft',
amount: 650, amount: '0.00',
created_at: '2026-06-23T10:00:00Z' created_at: '2026-06-23T10:00:00Z'
}, },
{ {
@@ -160,10 +183,20 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
assert.match(assistantMessage.content, /先检查.*报销草稿/) assert.match(assistantMessage.content, /先检查.*报销草稿/)
assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/) assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/)
assert.match(assistantMessage.content, /RE-202606-010/) assert.match(assistantMessage.content, /RE-202606-010/)
assert.match(assistantMessage.content, /待确认/)
assert.doesNotMatch(assistantMessage.content, />0\.00</)
assert.match(assistantMessage.content, /2026-06-23 10:00/)
assert.doesNotMatch(assistantMessage.content, /T10:00:00Z/)
assert.doesNotMatch(assistantMessage.content, /AP-202606-001/) assert.doesNotMatch(assistantMessage.content, /AP-202606-001/)
assert.match(assistantMessage.content, /下方三个按钮/)
assert.doesNotMatch(assistantMessage.content, /跳过草稿后再关联申请单/)
assert.equal(assistantMessage.suggestedActions.length, 3)
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail') assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
assert.match(assistantMessage.suggestedActions[0].label, /继续草稿/) assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_reimbursement_draft_check') assert.equal(assistantMessage.suggestedActions[1].action_type, CONTINUE_REIMBURSEMENT_DRAFT_ACTION)
assert.equal(assistantMessage.suggestedActions[1].label, '继续关联草稿 RE-202606-010')
assert.equal(assistantMessage.suggestedActions[2].action_type, CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION)
assert.equal(assistantMessage.suggestedActions[2].label, '独立新建报销单')
}) })
test('reimbursement association gate shows thinking before querying and renders application cards', async () => { test('reimbursement association gate shows thinking before querying and renders application cards', async () => {
@@ -274,8 +307,33 @@ test('reimbursement association gate matches short username with returned employ
test('linked application selection can create reimbursement draft from association gate', async () => { test('linked application selection can create reimbursement draft from association gate', async () => {
conversationStarted.value = false conversationStarted.value = false
const orchestratorCalls = [] const orchestratorCalls = []
const createJobCalls = []
const fetchJobCalls = []
const { aiExpenseDraft, conversationMessages, flow } = buildFlow({ const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
fetchExpenseClaimsForAi: async () => ({ items: [] }), fetchExpenseClaimsForAi: async () => ({ items: [] }),
createLinkedReimbursementDraftJobForAi: async (payload) => {
createJobCalls.push(payload)
return {
job_id: 'linked-draft-job-1',
status: 'queued',
message: '已创建后台生成任务。'
}
},
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
fetchJobCalls.push(jobId)
return {
job_id: jobId,
status: 'succeeded',
message: '报销草稿已生成。',
draft_payload: {
claim_id: 'draft-linked-1',
claim_no: 'RE-202606-009',
status: 'draft',
expense_type: 'travel',
reason: '北京客户现场实施'
}
}
},
runOrchestratorForAi: async (payload, options) => { runOrchestratorForAi: async (payload, options) => {
orchestratorCalls.push({ payload, options }) orchestratorCalls.push({ payload, options })
return { return {
@@ -303,16 +361,93 @@ test('linked application selection can create reimbursement draft from associati
application_amount_label: '1,650元' application_amount_label: '1,650元'
}) })
assert.equal(orchestratorCalls.length, 1) assert.equal(orchestratorCalls.length, 0)
assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft') assert.equal(createJobCalls.length, 1)
assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001') assert.equal(createJobCalls[0].context_json.review_action, 'save_draft')
assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001') assert.equal(createJobCalls[0].context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
assert.equal(createJobCalls[0].context_json.review_form_values.application_claim_no, 'AP-202606-001')
assert.deepEqual(fetchJobCalls, ['linked-draft-job-1'])
assert.equal(aiExpenseDraft.value, null) assert.equal(aiExpenseDraft.value, null)
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/) assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/)
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009') assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009')
assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail') assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail')
}) })
test('linked reimbursement draft job resumes from pending history message', async () => {
conversationStarted.value = true
const fetchJobCalls = []
const { conversationMessages, flow } = buildFlow({
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
fetchJobCalls.push(jobId)
return {
job_id: jobId,
status: 'succeeded',
message: '报销草稿已生成。',
draft_payload: {
claim_id: 'draft-resumed-1',
claim_no: 'RE-202606-011',
status: 'draft',
expense_type: 'travel'
}
}
}
})
conversationMessages.value.push(createInlineMessage('assistant', '已关联申请单 AP-202606-001正在后台生成报销草稿...', {
id: 'pending-linked-draft-message',
pending: true,
linkedReimbursementDraftJob: {
jobId: 'linked-draft-job-resume',
status: 'queued',
applicationClaimNo: 'AP-202606-001'
}
}))
flow.resumePendingLinkedReimbursementDraftJobs()
await new Promise((resolve) => setTimeout(resolve, 5))
assert.deepEqual(fetchJobCalls, ['linked-draft-job-resume'])
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-011 已生成/)
assert.match(conversationMessages.value.at(-1).content, /AP-202606-001/)
assert.equal(conversationMessages.value.at(-1).pending, false)
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-011')
})
test('continuing an existing reimbursement draft prompts for attachments or description', () => {
conversationStarted.value = false
const { conversationMessages, flow } = buildFlow()
flow.promptAiReimbursementDraftContinuation({
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
})
assert.equal(conversationMessages.value.at(-2).role, 'user')
assert.equal(conversationMessages.value.at(-2).content, '继续关联草稿 RE-202606-010')
const assistantMessage = conversationMessages.value.at(-1)
assert.equal(assistantMessage.role, 'assistant')
assert.match(assistantMessage.content, /请上传相关的附件/)
assert.match(assistantMessage.content, /补充说明/)
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
})
test('standalone reimbursement draft branch asks before creating a new draft', () => {
conversationStarted.value = false
const { conversationMessages, flow } = buildFlow()
flow.promptStandaloneReimbursementDraftCreation('我要报销', '独立新建报销单')
assert.equal(conversationMessages.value.at(-2).role, 'user')
assert.equal(conversationMessages.value.at(-2).content, '独立新建报销单')
const assistantMessage = conversationMessages.value.at(-1)
assert.equal(assistantMessage.role, 'assistant')
assert.match(assistantMessage.content, /是否新建草稿单据/)
assert.equal(assistantMessage.suggestedActions[0].label, '新建草稿单据')
assert.equal(assistantMessage.suggestedActions[0].action_type, 'skip_required_application_link')
assert.equal(assistantMessage.suggestedActions[1].label, '暂不新建')
})
test('personal workbench routes reimbursement creation intent to association gate before steward', () => { test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match( assert.match(
personalWorkbenchAiMode, personalWorkbenchAiMode,

View File

@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import { useWorkbenchComposerDate } from '../src/composables/useWorkbenchComposerDate.js'
import { import {
buildWorkbenchDateLabel, buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection, canApplyWorkbenchDateSelection,
@@ -76,3 +78,34 @@ test('workbench date helper builds labels and inserts them into draft text', ()
) )
assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false) assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false)
}) })
test('workbench range end date changes keep the picker open until the user confirms', () => {
const draft = ref('')
let focusCount = 0
const dateRuntime = useWorkbenchComposerDate({
draft,
focusInput: () => {
focusCount += 1
}
})
dateRuntime.workbenchDatePickerOpen.value = true
dateRuntime.workbenchDateMode.value = 'range'
dateRuntime.workbenchRangeStartDate.value = '2026-02-20'
dateRuntime.workbenchRangeEndDate.value = '2026-03-23'
dateRuntime.handleWorkbenchDateInputChange('range-end')
assert.equal(dateRuntime.workbenchDatePickerOpen.value, true)
assert.equal(dateRuntime.workbenchDateTagLabel.value, '')
assert.equal(draft.value, '')
assert.equal(focusCount, 0)
dateRuntime.applyWorkbenchDateSelection()
assert.equal(dateRuntime.workbenchDatePickerOpen.value, false)
assert.equal(dateRuntime.workbenchDateTagLabel.value, '2026-02-20 至 2026-03-23')
assert.equal(draft.value, '')
assert.equal(dateRuntime.buildWorkbenchPromptText(), '2026-02-20 至 2026-03-23')
assert.equal(focusCount, 1)
})