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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ const reimbursementTrendHasSignal = computed(() =>
|
||||
)
|
||||
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
|
||||
const reimbursementTrendSignalLabel = computed(() =>
|
||||
reimbursementTrendHasSignal.value ? '来自你的真实单据' : '暂无单据时展示空走势'
|
||||
reimbursementTrendHasSignal.value ? '来自您的真实单据' : '暂无单据时展示空走势'
|
||||
)
|
||||
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
|
||||
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))
|
||||
|
||||
@@ -405,6 +405,8 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<WorkbenchAiFilePreviewDialog :runtime="workbenchAiRuntime" />
|
||||
|
||||
<Transition name="workbench-ai-confirm-fade">
|
||||
<div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation">
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import { proxyRefs } from 'vue'
|
||||
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
|
||||
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
|
||||
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
|
||||
import WorkbenchAiFilePreviewDialog from './workbench-ai/WorkbenchAiFilePreviewDialog.vue'
|
||||
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
v-model="runtime.assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
:placeholder="runtime.isAiModeInputLocked ? '费用测算中,请稍等...' : placeholder"
|
||||
:placeholder="runtime.isAiModeInputLocked ? runtime.aiModeInputLockMessage : placeholder"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@keydown.enter.exact.prevent="runtime.submitAiModePrompt"
|
||||
></textarea>
|
||||
|
||||
@@ -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>
|
||||
@@ -5,7 +5,17 @@
|
||||
:class="{ inline }"
|
||||
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">
|
||||
<i :class="file.icon"></i>
|
||||
</span>
|
||||
@@ -29,7 +39,7 @@
|
||||
class="workbench-ai-file-card__remove"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
:aria-label="`移除附件 ${file.name}`"
|
||||
@click="runtime.removeAiModeFile(file.key)"
|
||||
@click.stop="runtime.removeAiModeFile(file.key)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
|
||||
@@ -741,7 +741,7 @@ function buildFieldStepDescription() {
|
||||
if (activeSimulationResult.value?.recognized_fields?.length) {
|
||||
return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。`
|
||||
}
|
||||
if (draft.value.trim()) return '将使用你输入的文字抽取测试字段。'
|
||||
if (draft.value.trim()) return '将使用您输入的文字抽取测试字段。'
|
||||
return '识别完成或补充字段后进入确认。'
|
||||
}
|
||||
|
||||
@@ -756,7 +756,7 @@ function buildWelcomeMessage() {
|
||||
if (requiresAttachment.value) {
|
||||
return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。'
|
||||
}
|
||||
return '这条规则不需要上传附件。你可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
|
||||
return '这条规则不需要上传附件。您可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
|
||||
}
|
||||
|
||||
function clearFileInput() {
|
||||
|
||||
@@ -505,7 +505,7 @@
|
||||
<i class="mdi mdi-text-recognition"></i>
|
||||
</span>
|
||||
<strong>暂无结构化字段</strong>
|
||||
<p>当前只返回了摘要信息,你仍然可以直接修改上面的票据摘要。</p>
|
||||
<p>当前只返回了摘要信息,您仍然可以直接修改上面的票据摘要。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="ui.activeReviewDocument.warnings?.length" class="review-document-warning-list">
|
||||
|
||||
@@ -549,7 +549,7 @@
|
||||
{{ line }}
|
||||
</p>
|
||||
<p v-if="ui.canUseInlineSaveDraft(message)" class="review-inline-save-copy">
|
||||
请核查上面的关键信息。您也可以暂时不处理上述的这些内容,我可以帮你先保存为
|
||||
请核查上面的关键信息。您也可以暂时不处理上述的这些内容,我可以帮您先保存为
|
||||
<button
|
||||
type="button"
|
||||
class="review-inline-draft-link"
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.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 { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
|
||||
@@ -125,10 +130,8 @@ export function useAppShell() {
|
||||
|
||||
async function reloadWorkbenchApprovalRequests() {
|
||||
try {
|
||||
const payload = await fetchAllApprovalExpenseClaims()
|
||||
workbenchApprovalRequests.value = Array.isArray(payload)
|
||||
? payload.map((item) => mapExpenseClaimToRequest(item))
|
||||
: []
|
||||
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
workbenchApprovalRequests.value = extractExpenseClaimItems(payload).map((item) => mapExpenseClaimToRequest(item))
|
||||
} catch {
|
||||
workbenchApprovalRequests.value = []
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function useChat(activeView) {
|
||||
? `${c.id} 建议审批意见:费用归属与预算中心基本匹配,请补充必要说明后通过。`
|
||||
: '建议审批意见:当前单据存在待确认项,请先完成风险核查和附件补齐后再审批。'
|
||||
}
|
||||
return '收到。我可以继续帮你拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
|
||||
return '收到。我可以继续帮您拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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 { mapExpenseClaimToRequest } from './requests/requestClaimMapper.js'
|
||||
|
||||
@@ -103,8 +107,8 @@ export function useRequests() {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchAllExpenseClaims()
|
||||
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
|
||||
const payload = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
requests.value = extractExpenseClaimItems(payload).map((item) => mapExpenseClaimToRequest(item))
|
||||
loaded.value = true
|
||||
} catch (nextError) {
|
||||
if (!silent) {
|
||||
|
||||
@@ -111,6 +111,10 @@ export function useWorkbenchComposerDate({ draft, focusInput } = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
if (part === 'range-end') {
|
||||
return
|
||||
}
|
||||
|
||||
applyWorkbenchDateSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../useSystemState.js'
|
||||
import { useToast } from '../useToast.js'
|
||||
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
} from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
AI_MODE_ACTION_ITEMS,
|
||||
buildSelectedFileCards,
|
||||
shouldRunAiAttachmentAutoAssociation
|
||||
} from './workbenchAiComposerModel.js'
|
||||
import {
|
||||
@@ -32,6 +30,7 @@ import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicatio
|
||||
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
|
||||
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
|
||||
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
|
||||
import { useWorkbenchAiFilePreview } from './useWorkbenchAiFilePreview.js'
|
||||
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
||||
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
|
||||
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
||||
@@ -75,7 +74,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
normalizeRuntimeMessage,
|
||||
serializeRuntimeMessage
|
||||
} = messageRuntime
|
||||
|
||||
const {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
@@ -139,6 +137,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked: () => isAiModeInputLocked.value,
|
||||
resolveInputLockedMessage: () => resolveAiModeInputLockMessage(),
|
||||
selectedFiles,
|
||||
toast
|
||||
})
|
||||
@@ -154,6 +153,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
|
||||
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
@@ -167,14 +167,12 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
toast
|
||||
})
|
||||
|
||||
watch(selectedFiles, (files) => {
|
||||
attachmentFlow.primeAiModeReceiptContext(files)
|
||||
const filePreview = useWorkbenchAiFilePreview({
|
||||
attachmentFlow,
|
||||
conversationStarted,
|
||||
scrollInlineConversationToBottom,
|
||||
selectedFiles
|
||||
})
|
||||
|
||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
|
||||
...card,
|
||||
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
|
||||
})))
|
||||
const {
|
||||
hasInlineAttachmentOcrDetails,
|
||||
hasInlineThinking,
|
||||
@@ -319,9 +317,13 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
const applicationPreviewEstimatePending = computed(() => (
|
||||
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(() => (
|
||||
!isAiModeInputLocked.value && (
|
||||
!isAiModeInputLocked.value &&
|
||||
!hasAiModeReceiptRecognitionFailure.value && (
|
||||
Boolean(assistantDraft.value.trim()) ||
|
||||
selectedFiles.value.length > 0 ||
|
||||
Boolean(workbenchDateTagLabel.value)
|
||||
@@ -517,18 +519,47 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
|
||||
}
|
||||
|
||||
function renderInlineConversationHtml(content) {
|
||||
return renderAiConversationHtml(content)
|
||||
}
|
||||
function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) }
|
||||
|
||||
function buildInlinePromptText(rawPrompt, files = []) {
|
||||
const prompt = buildWorkbenchPromptText(rawPrompt)
|
||||
if (prompt) {
|
||||
return prompt
|
||||
}
|
||||
if (prompt) return prompt
|
||||
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) {
|
||||
const target = event?.target
|
||||
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 = []) {
|
||||
if (isAiModeInputLocked.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
if (!ensureAiModeCanStartConversation()) {
|
||||
return
|
||||
}
|
||||
const cleanPrompt = buildInlinePromptText(prompt, files)
|
||||
@@ -595,6 +625,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
}
|
||||
|
||||
function submitAiModePrompt() {
|
||||
if (!ensureAiModeCanStartConversation()) {
|
||||
return
|
||||
}
|
||||
if (!canSubmitAiModePrompt.value) {
|
||||
toast('请输入需求后再发送。')
|
||||
focusAiModeInput()
|
||||
@@ -604,6 +637,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
}
|
||||
|
||||
function runAiModeAction(item) {
|
||||
if (!ensureAiModeCanStartConversation()) {
|
||||
return
|
||||
}
|
||||
if (String(item?.label || '').trim() === '发起报销') {
|
||||
void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label)
|
||||
return
|
||||
@@ -624,9 +660,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
|
||||
}
|
||||
|
||||
function pushInlineUserMessage(text) {
|
||||
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
|
||||
}
|
||||
function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) }
|
||||
|
||||
function pushInlineApplicationActionUserMessage(text) {
|
||||
pushInlineUserMessage(text)
|
||||
@@ -637,13 +671,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
}
|
||||
|
||||
function resolveLatestInlineUserPrompt() {
|
||||
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
|
||||
return String(latestUserMessage?.content || '').trim()
|
||||
return String([...conversationMessages.value].reverse().find((message) => message.role === 'user')?.content || '').trim()
|
||||
}
|
||||
|
||||
function handleVoiceInput() {
|
||||
if (isAiModeInputLocked.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
if (!ensureAiModeCanStartConversation()) {
|
||||
return
|
||||
}
|
||||
toast('语音输入正在准备中,您可以先输入文字需求。')
|
||||
@@ -664,6 +696,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
}
|
||||
if (command.type === 'open-recent') {
|
||||
sessionCommands.openInlineRecentConversation(command.payload || {})
|
||||
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
|
||||
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -671,6 +705,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
onMounted(() => {
|
||||
loadSystemSettings()
|
||||
refreshConversationHistory()
|
||||
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
|
||||
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
|
||||
document.addEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
})
|
||||
|
||||
@@ -681,6 +717,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
return {
|
||||
activeConversationTitle,
|
||||
aiModeActionItems,
|
||||
aiModeInputLockMessage,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantInputRef,
|
||||
@@ -734,7 +771,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
resolveInlineThinkingEvents,
|
||||
runAiModeAction,
|
||||
scrollInlineConversationToTop,
|
||||
selectedFileCards,
|
||||
...filePreview,
|
||||
sending,
|
||||
setAssistantInputRef,
|
||||
setWorkbenchDateMode,
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
|
||||
import {
|
||||
CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||||
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION
|
||||
} from '../../views/scripts/travelReimbursementAssociationGateModel.js'
|
||||
@@ -102,6 +105,25 @@ export function useWorkbenchAiActionRouter({
|
||||
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) {
|
||||
void expenseFlow.startAiReimbursementAssociationGate(
|
||||
actionPayload.original_message || '我要报销',
|
||||
|
||||
@@ -191,7 +191,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
|
||||
return buildApplicationPreviewFooterMessage(normalized)
|
||||
}
|
||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||
return '申请核对表已补齐,费用测算已同步。您仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||
}
|
||||
|
||||
function buildInlineApplicationActionFailureText(error, isSubmit) {
|
||||
@@ -400,7 +400,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||
},
|
||||
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
|
||||
suggestedActions: buildInlineApplicationDetailAction(draftPayload)
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
|
||||
@@ -8,17 +8,17 @@ import {
|
||||
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
createExpenseClaimItem,
|
||||
extractExpenseClaimItems,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaims,
|
||||
uploadExpenseClaimItemAttachment
|
||||
} from '../../services/reimbursements.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { createAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
|
||||
import {
|
||||
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
buildInlineAttachmentOcrDetails
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
|
||||
import { useWorkbenchAiAttachmentAssociationJobs } from './useWorkbenchAiAttachmentAssociationJobs.js'
|
||||
|
||||
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
|
||||
const completed = status === 'completed'
|
||||
@@ -72,6 +72,7 @@ function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
|
||||
|
||||
export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
@@ -151,8 +152,11 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
).trim()
|
||||
return {
|
||||
status: 'recognized',
|
||||
label: detail ? `已识别票据 · ${detail}` : '已识别票据',
|
||||
title: detail ? `智能录入已完成,识别为${detail}` : '智能录入已完成'
|
||||
label: 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
|
||||
}
|
||||
|
||||
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 = []) {
|
||||
const attachmentNames = safeFiles
|
||||
.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 ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
|
||||
@@ -230,8 +252,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
return null
|
||||
}
|
||||
|
||||
const forceRefresh = Boolean(options.forceRefresh)
|
||||
const cached = aiModeReceiptContextCache.get(cacheKey)
|
||||
if (cached?.status === 'resolved') {
|
||||
if (!forceRefresh && cached?.status === 'resolved') {
|
||||
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
|
||||
return null
|
||||
}
|
||||
@@ -272,7 +295,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
|
||||
function primeAiModeReceiptContext(files = []) {
|
||||
pruneAiModeReceiptRecognitionState(files)
|
||||
const promise = startAiModeReceiptRecognition(files)
|
||||
const promise = startAiModeReceiptRecognition(files, { forceRefresh: true })
|
||||
if (promise && typeof promise.catch === 'function') {
|
||||
promise.catch((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) {
|
||||
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
|
||||
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
|
||||
@@ -509,47 +544,42 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
try {
|
||||
const collected = await collectAiModeReceiptContext(files)
|
||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const claims = extractExpenseClaimItems(claimsPayload)
|
||||
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
|
||||
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 receiptIds = attachmentJobFlow.extractReceiptIdsFromOcrDocuments(collected.ocrDocuments)
|
||||
if (!receiptIds.length) {
|
||||
throw new Error('当前附件没有持久化票据记录,请重新上传后再试。')
|
||||
}
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
|
||||
match,
|
||||
fileNames: files.map((file) => file?.name || ''),
|
||||
ocrDocuments: collected.ocrDocuments
|
||||
})
|
||||
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
const fileNames = files.map((file) => file?.name || '').filter(Boolean)
|
||||
const job = attachmentJobFlow.normalizeJob(await createAttachmentAssociationJob({
|
||||
receipt_ids: receiptIds,
|
||||
prompt,
|
||||
conversation_id: conversationId?.value || ''
|
||||
}))
|
||||
if (!job) {
|
||||
throw new Error('附件关联任务创建失败,请稍后重试。')
|
||||
}
|
||||
const runningMessageText = attachmentJobFlow.buildRunningMessage(job, fileNames)
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
createInlineMessage('assistant', runningMessageText, {
|
||||
id: pendingMessage.id,
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
|
||||
},
|
||||
attachmentOcrDetails,
|
||||
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
|
||||
includeOcrDetails: Boolean(attachmentOcrDetails)
|
||||
})
|
||||
attachmentAssociationJob: job
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
void attachmentJobFlow.pollJob({
|
||||
jobId: job.jobId,
|
||||
messageId: pendingMessage.id,
|
||||
fileNames,
|
||||
attachmentOcrDetails,
|
||||
initialJob: job
|
||||
})
|
||||
} catch (error) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
|
||||
@@ -574,8 +604,11 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
return {
|
||||
collectAiModeReceiptContext,
|
||||
confirmAiAttachmentAssociation,
|
||||
hasFailedAiModeReceiptRecognition,
|
||||
hasPendingAiModeReceiptRecognition,
|
||||
primeAiModeReceiptContext,
|
||||
requestAiAttachmentAssociationReply,
|
||||
resumePendingAiAttachmentAssociationJobs: attachmentJobFlow.resumePendingJobs,
|
||||
resolveAiModeReceiptRecognitionState,
|
||||
resolveAiAttachmentAssociationClaimNo
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ export function useWorkbenchAiComposerFiles({
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked,
|
||||
resolveInputLockedMessage = () => '请等待费用测算完成后再继续操作。',
|
||||
selectedFiles,
|
||||
toast
|
||||
}) {
|
||||
function triggerAiModeFileUpload() {
|
||||
if (isInputLocked()) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
toast(resolveInputLockedMessage() || '请等待当前任务完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
|
||||
@@ -111,7 +111,7 @@ export function useWorkbenchAiDocumentQueryFlow({
|
||||
{
|
||||
eventId: 'document-query-parse',
|
||||
title: '解析自然语言筛选条件',
|
||||
content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
createLinkedReimbursementDraftJob,
|
||||
fetchLinkedReimbursementDraftJob
|
||||
} from '../../services/linkedReimbursementDraftJobs.js'
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseStepPrompt,
|
||||
@@ -27,7 +34,11 @@ import {
|
||||
buildReimbursementAssociationSelectionText,
|
||||
buildReimbursementAssociationQueryFailedText,
|
||||
buildReimbursementDraftActions,
|
||||
buildReimbursementDraftContinuationText,
|
||||
buildReimbursementDraftSelectionText,
|
||||
buildStandaloneReimbursementDraftConfirmationActions,
|
||||
buildStandaloneReimbursementDraftConfirmationText,
|
||||
buildViewReimbursementDraftAction,
|
||||
fetchReimbursementAssociationClaims,
|
||||
filterReimbursementAssociationCandidates,
|
||||
filterReimbursementDraftCandidates,
|
||||
@@ -37,6 +48,10 @@ import {
|
||||
export { SESSION_TYPE_EXPENSE }
|
||||
|
||||
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() {
|
||||
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({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
@@ -70,6 +99,10 @@ export function useWorkbenchAiExpenseFlow({
|
||||
startAiApplicationPreview,
|
||||
fetchExpenseClaimsForAi = fetchExpenseClaims,
|
||||
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
|
||||
}) {
|
||||
function replaceInlineAssistantMessage(messageId, content = '', options = {}) {
|
||||
@@ -79,6 +112,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||
draftPayload: options.draftPayload || null,
|
||||
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
|
||||
text: options.text || content
|
||||
})
|
||||
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 = {}) {
|
||||
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||
if (!conversationStarted.value) {
|
||||
@@ -255,7 +350,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
aiExpenseDraft.value = 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
|
||||
} else {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
|
||||
@@ -267,7 +362,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchExpenseClaimsForAi()
|
||||
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
} catch {
|
||||
aiExpenseDraft.value = null
|
||||
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 = {}) {
|
||||
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
@@ -375,39 +586,28 @@ export function useWorkbenchAiExpenseFlow({
|
||||
application,
|
||||
application.original_message || resolveLatestInlineUserPrompt() || '我要报销'
|
||||
)
|
||||
const user = currentUser.value || {}
|
||||
const payload = await runOrchestratorForAi(
|
||||
{
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
conversation_id: null,
|
||||
message: submitOptions.rawText,
|
||||
context_json: {
|
||||
...buildWorkbenchUserContext(),
|
||||
...submitOptions.extraContext
|
||||
}
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
timeoutMessage: '生成报销草稿超时,请稍后重试。'
|
||||
const job = await createLinkedReimbursementDraftJobForAi({
|
||||
message: submitOptions.rawText,
|
||||
conversation_id: '',
|
||||
context_json: {
|
||||
...buildWorkbenchUserContext(),
|
||||
...submitOptions.extraContext
|
||||
}
|
||||
)
|
||||
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
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
} catch {
|
||||
const normalizedJob = normalizeLinkedDraftJob(job)
|
||||
if (!normalizedJob) {
|
||||
throw new Error('报销草稿生成任务创建失败,请稍后重试。')
|
||||
}
|
||||
await pollLinkedDraftJob({
|
||||
jobId: normalizedJob.jobId,
|
||||
pendingMessageId,
|
||||
claimNo,
|
||||
initialJob: normalizedJob
|
||||
})
|
||||
} catch (error) {
|
||||
replaceInlineAssistantMessage(
|
||||
pendingMessageId,
|
||||
'生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。',
|
||||
buildLinkedDraftFailedText(error),
|
||||
{
|
||||
suggestedActions: []
|
||||
}
|
||||
@@ -419,8 +619,12 @@ export function useWorkbenchAiExpenseFlow({
|
||||
|
||||
return {
|
||||
advanceAiExpenseDraft,
|
||||
cancelStandaloneReimbursementDraftCreation,
|
||||
linkAiExpenseApplication,
|
||||
promptAiReimbursementDraftContinuation,
|
||||
promptStandaloneReimbursementDraftCreation,
|
||||
pushInlineExpenseSceneSelectionPrompt,
|
||||
resumePendingLinkedReimbursementDraftJobs,
|
||||
startAiApplicationPreviewFromAction,
|
||||
startAiReimbursementAssociationGate,
|
||||
startAiExpenseDraft
|
||||
|
||||
183
web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js
Normal file
183
web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function useWorkbenchAiSessionCommands({
|
||||
|
||||
function openInlineSearchConversation(activateInlineConversation) {
|
||||
conversationMessages.value = [
|
||||
createInlineMessage('assistant', '你可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
|
||||
createInlineMessage('assistant', '您可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
|
||||
]
|
||||
stewardState.value = null
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
@@ -54,7 +54,7 @@ export function useWorkbenchAiSessionCommands({
|
||||
: [
|
||||
createInlineMessage(
|
||||
'assistant',
|
||||
'这条历史对话没有保存完整消息。你可以继续输入新的问题,小财管家会接着处理。'
|
||||
'这条历史对话没有保存完整消息。您可以继续输入新的问题,小财管家会接着处理。'
|
||||
)
|
||||
]
|
||||
conversationStarted.value = true
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
fetchStewardPlan,
|
||||
fetchStewardPlanStream
|
||||
} from '../../services/steward.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
fetchExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
buildStewardPlanMessageText,
|
||||
buildStewardPlanRequest,
|
||||
@@ -71,13 +74,13 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
|
||||
if (flow?.flowId === 'travel_application') {
|
||||
return [
|
||||
contextText || baseText,
|
||||
'这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
|
||||
'这类操作需要您手动确认。请点击下方 **确认发起出差申请**,我会在当前对话里生成完整申请表,并把已识别的信息自动预填。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
if (flow?.flowId === 'travel_reimbursement') {
|
||||
return [
|
||||
contextText || baseText,
|
||||
'这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
|
||||
'这类操作需要您手动确认。请点击下方 **确认关联已有申请单**,我会继续查询并展示可关联单据。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return baseText
|
||||
@@ -100,7 +103,7 @@ function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
|
||||
if (flow.flowId === 'travel_reimbursement') {
|
||||
return [{
|
||||
label: '确认关联已有申请单',
|
||||
description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
|
||||
description: '确认后查询您名下可关联的差旅申请单,并进入关联步骤。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: 'steward_confirm_flow',
|
||||
payload: {
|
||||
@@ -155,7 +158,7 @@ export function useWorkbenchAiStewardFlow({
|
||||
}
|
||||
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
|
||||
planRequest.context_json = {
|
||||
...(planRequest.context_json || {}),
|
||||
@@ -232,14 +235,14 @@ export function useWorkbenchAiStewardFlow({
|
||||
},
|
||||
{
|
||||
idleTimeoutMs: 90000,
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('流式服务')) {
|
||||
return fetchStewardPlan(payload, {
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
|
||||
})
|
||||
}
|
||||
throw error
|
||||
@@ -256,7 +259,7 @@ export function useWorkbenchAiStewardFlow({
|
||||
{
|
||||
eventId: 'init',
|
||||
title: '小财管家正在接入业务流程',
|
||||
content: '正在识别你的意图、上下文和附件信息。',
|
||||
content: '正在识别您的意图、上下文和附件信息。',
|
||||
status: 'running'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
buildAiApplicationPrecheckThinkingEvents,
|
||||
isAiApplicationPrecheckBlocking
|
||||
@@ -32,24 +31,6 @@ export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
|
||||
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 = {}) {
|
||||
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
|
||||
const body = String(source.body || source.markdown || '').trim()
|
||||
@@ -124,13 +105,11 @@ export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
||||
export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
|
||||
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
|
||||
const reference = info.claimNo || info.claimId
|
||||
const href = buildInlineApplicationActionDetailHref(info)
|
||||
const actionText = href ? `[查看](${href})` : '-'
|
||||
const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -155,8 +134,7 @@ export function buildInlineApplicationPreviewActionResultText(actionType, payloa
|
||||
statusLabel: '审批中',
|
||||
stageLabel: approvalStage || '直属领导审批',
|
||||
documentTypeLabel: '出差申请'
|
||||
}),
|
||||
'需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return [
|
||||
@@ -166,8 +144,7 @@ export function buildInlineApplicationPreviewActionResultText(actionType, payloa
|
||||
statusLabel: '草稿',
|
||||
stageLabel: '待提交',
|
||||
documentTypeLabel: '出差申请'
|
||||
}),
|
||||
'后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
@@ -266,7 +243,7 @@ export function buildInitialInlineApplicationSubmitThinkingEvents() {
|
||||
{
|
||||
eventId: 'application-precheck-overlap',
|
||||
title: '核查同时间段申请单',
|
||||
content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
|
||||
content: '正在查询您名下可见申请单,检查是否存在相同或重叠日期。',
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -151,6 +151,8 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
: suggestedActions,
|
||||
applicationPreview: options.applicationPreview || null,
|
||||
draftPayload: options.draftPayload || null,
|
||||
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
|
||||
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
|
||||
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
|
||||
text: options.text || normalizedContent,
|
||||
createdAt: options.createdAt || Date.now()
|
||||
@@ -166,6 +168,8 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentAssociationJob: message.attachmentAssociationJob || null,
|
||||
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||
text: message.text || message.content || ''
|
||||
})
|
||||
@@ -182,6 +186,8 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
|
||||
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null
|
||||
}
|
||||
}
|
||||
@@ -193,3 +199,52 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
21
web/src/services/attachmentAssociationJobs.js
Normal file
21
web/src/services/attachmentAssociationJobs.js
Normal 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)}`)
|
||||
}
|
||||
|
||||
13
web/src/services/linkedReimbursementDraftJobs.js
Normal file
13
web/src/services/linkedReimbursementDraftJobs.js
Normal 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}`)
|
||||
}
|
||||
@@ -74,6 +74,6 @@ export function buildAiApplicationSummary(draft) {
|
||||
lines.push(`- ${step.label}:${value || '待补充'}`)
|
||||
})
|
||||
|
||||
lines.push('', '如果哪一项需要修改,直接告诉我;确认无误后我会帮你整理成申请草稿内容,再提交到申请助手生成单据。')
|
||||
lines.push('', '如果哪一项需要修改,请直接告诉我;确认无误后我会帮您整理成申请草稿内容,再提交到申请助手生成单据。')
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -312,7 +312,7 @@ function buildUnsupportedBusinessScopeText(rawText, options = {}) {
|
||||
? `**小财管家暂时不处理「${text}」这类内容。**`
|
||||
: `**${message.title || '此意图系统不支持。'}**`
|
||||
const attachmentHint = options.attachmentCount
|
||||
? '你刚刚上传的附件我会先保留,切换到合适场景后可以继续使用。'
|
||||
? '您刚刚上传的附件我会先保留,切换到合适场景后可以继续使用。'
|
||||
: ''
|
||||
return [
|
||||
intro,
|
||||
@@ -325,9 +325,9 @@ function buildUnsupportedBusinessScopeText(rawText, options = {}) {
|
||||
'',
|
||||
message.body || '这条内容没有识别到当前系统支持的财务业务意图,暂时不能继续处理。',
|
||||
attachmentHint,
|
||||
'你可以直接点下面的场景继续,或者重新描述你的财务业务需求。',
|
||||
'您可以直接点下面的场景继续,或者重新描述您的财务业务需求。',
|
||||
'',
|
||||
message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
|
||||
message.retryHint || '请重新描述您的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isApplicationPreviewValueProvided,
|
||||
isTravelApplicationType,
|
||||
normalizeAmountFromOntology,
|
||||
normalizeApplicationLocationBoundary,
|
||||
normalizeApplicationTypeLabel,
|
||||
normalizeTypedOntologyAmount,
|
||||
parseApplicationDaysValue,
|
||||
@@ -61,6 +62,15 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
||||
const applicationType = String(fields.applicationType || '').trim()
|
||||
const transportMode = String(fields.transportMode || '').trim()
|
||||
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) {
|
||||
return {
|
||||
@@ -334,7 +344,9 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
|
||||
currentFields.applicationType
|
||||
),
|
||||
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),
|
||||
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
||||
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
|
||||
@@ -480,7 +492,7 @@ export function buildLocalApplicationPreviewMessage(preview) {
|
||||
: modelReviewStatus === 'failed'
|
||||
? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。'
|
||||
: modelReviewStatus === 'template'
|
||||
? '我已为你准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
|
||||
? '我已为您准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
|
||||
: '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -24,6 +24,37 @@ export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||
|
||||
export const APPLICATION_POLICY_PENDING_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 = '') {
|
||||
const label = String(applicationType || '').trim()
|
||||
@@ -142,6 +173,11 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
|
||||
export function resolveApplicationValidationIssues(fields = {}) {
|
||||
const issues = []
|
||||
const locationIssue = resolveApplicationLocationValidationIssue(fields.location)
|
||||
if (locationIssue) {
|
||||
issues.push(locationIssue)
|
||||
}
|
||||
|
||||
const rangeDaysText = resolveDaysFromDateRange(fields.time)
|
||||
const rangeDays = parseApplicationDaysValue(rangeDaysText)
|
||||
const explicitDays = parseApplicationDaysValue(fields.days)
|
||||
@@ -155,6 +191,61 @@ export function resolveApplicationValidationIssues(fields = {}) {
|
||||
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 = {}) {
|
||||
const status = String(preview?.modelReviewStatus || '').trim()
|
||||
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
|
||||
@@ -562,10 +653,10 @@ export function resolveApplicationTimeWithDefault(text, daysText = '', options =
|
||||
}
|
||||
|
||||
export function resolveApplicationLocation(text) {
|
||||
return resolveFirstMatch(text, [
|
||||
return normalizeApplicationLocationBoundary(resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n]+)/u,
|
||||
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
|
||||
])
|
||||
]))
|
||||
}
|
||||
|
||||
function looksLikeTransportPromptText(text) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const NON_RISK_SOURCES = new Set([
|
||||
'application_detail',
|
||||
'application_handoff',
|
||||
'application_link',
|
||||
'application_link_sync',
|
||||
'application_submission',
|
||||
'approval_routing',
|
||||
'budget_approval',
|
||||
@@ -24,6 +25,7 @@ const NON_RISK_EVENTS = new Set([
|
||||
'expense_application_submission',
|
||||
'expense_application_to_reimbursement_draft',
|
||||
'expense_reimbursement_application_linked',
|
||||
'expense_application_reimbursement_deleted',
|
||||
'expense_application_budget_approval',
|
||||
'sla_reminder',
|
||||
'reminder',
|
||||
|
||||
@@ -56,7 +56,7 @@ export function buildTravelPlanningNudgeMessage(preview = {}, draftPayload = {})
|
||||
const transportCopy = context.transportMode ? `、${context.transportMode}时间窗口` : '、交通方式比选'
|
||||
return [
|
||||
`本次${context.location}差旅申请已经提交。`,
|
||||
`如果你愿意,我可以继续按 ${timeCopy} 帮你整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议和还需要确认的事项。`
|
||||
`如果您愿意,我可以继续按 ${timeCopy} 帮您整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议,以及还需要确认的事项。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {
|
||||
const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : ''
|
||||
|
||||
return [
|
||||
'可以,先给你一版轻量行程规划,后续你可以继续补充偏好。',
|
||||
'可以,先给您一版轻量行程规划,后续您可以继续补充偏好。',
|
||||
'',
|
||||
claimLine,
|
||||
`行程时间:${context.time}${context.days ? `(${context.days})` : ''}`,
|
||||
@@ -113,7 +113,7 @@ export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {
|
||||
`酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`,
|
||||
'需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。',
|
||||
'',
|
||||
'你也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
|
||||
'您也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -261,9 +261,10 @@ import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchAllApprovalExpenseClaims,
|
||||
fetchAllArchivedExpenseClaims
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchArchivedExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
|
||||
import {
|
||||
@@ -684,8 +685,8 @@ async function loadSupportingRows() {
|
||||
supportingError.value = ''
|
||||
|
||||
const [approvalResult, archiveResult] = await Promise.allSettled([
|
||||
fetchAllApprovalExpenseClaims(),
|
||||
fetchAllArchivedExpenseClaims()
|
||||
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
|
||||
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
])
|
||||
|
||||
if (approvalResult.status === 'fulfilled') {
|
||||
|
||||
@@ -368,7 +368,11 @@ import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { useToast } from '../composables/useToast.js'
|
||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import {
|
||||
buildReceiptFile,
|
||||
deleteReceiptFolderItem,
|
||||
@@ -719,8 +723,8 @@ function closeAssociateDialog() {
|
||||
|
||||
async function loadDraftClaims() {
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
draftClaims.value = (Array.isArray(claims) ? claims : [])
|
||||
const claims = extractExpenseClaimItems(await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS))
|
||||
draftClaims.value = claims
|
||||
.filter((claim) => String(claim.status || '').trim().toLowerCase() === 'draft')
|
||||
.map((claim) => ({
|
||||
raw: claim,
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
<div class="steward-initial-recognition-copy">
|
||||
<strong>小财管家正在识别意图</strong>
|
||||
<p>我正在读取你的输入,准备拆解申请、报销和附件任务。</p>
|
||||
<p>我正在读取您的输入,准备拆解申请、报销和附件任务。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { computed, ref, watch } from 'vue'
|
||||
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
|
||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.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 {
|
||||
filterActionableRiskFlags,
|
||||
@@ -181,7 +185,7 @@ export default {
|
||||
actionIcon: null,
|
||||
tone: 'slate',
|
||||
artLabel: 'QUEUE',
|
||||
tips: ['当前仅展示你有权限处理的单据', '高风险和即将超时单据会优先高亮']
|
||||
tips: ['当前仅展示您有权限处理的单据', '高风险和即将超时单据会优先高亮']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +232,8 @@ export default {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchApprovalExpenseClaims()
|
||||
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value)
|
||||
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
const pendingRequests = listPendingApprovalRequests(extractExpenseClaimItems(payload), currentUser.value)
|
||||
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
|
||||
rows.value = mappedRows
|
||||
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus/es/comp
|
||||
|
||||
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchArchivedExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
ARCHIVE_FILTER_ALL,
|
||||
applyArchiveListFilters,
|
||||
@@ -244,8 +248,8 @@ export default {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchArchivedExpenseClaims()
|
||||
const mappedRows = (Array.isArray(payload) ? payload : [])
|
||||
const payload = await fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
const mappedRows = extractExpenseClaimItems(payload)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.filter(Boolean)
|
||||
.map((item) => buildArchiveRow(item))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useBackendHealth } from '../../composables/useBackendHealth.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
|
||||
const AUTO_RECOVER_INTERVAL_MS = 1500
|
||||
|
||||
export default {
|
||||
name: 'BackendUnavailableRouteView',
|
||||
setup() {
|
||||
@@ -11,24 +13,74 @@ export default {
|
||||
const { backendChecking, backendError, checkBackendHealth } = useBackendHealth()
|
||||
const { loggedIn, resolveEntryRoute } = useSystemState()
|
||||
const retrying = ref(false)
|
||||
let autoRecoverTimer = 0
|
||||
let autoRecovering = false
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
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() {
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
const ok = await checkBackendHealth({ force: true })
|
||||
if (ok) {
|
||||
await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' })
|
||||
stopAutoRecover()
|
||||
await navigateToAvailableRoute()
|
||||
}
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startAutoRecover()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRecover()
|
||||
})
|
||||
|
||||
return {
|
||||
backendChecking,
|
||||
retrying,
|
||||
|
||||
@@ -401,7 +401,7 @@ export default {
|
||||
})
|
||||
|
||||
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 {
|
||||
canShowTravelCalculator,
|
||||
|
||||
@@ -489,7 +489,7 @@ export function buildEmployeeEmptyState(options = {}) {
|
||||
title: hasEmployeeFilters ? '当前条件下没有匹配员工' : `“${activeTab}”里暂时没有员工`,
|
||||
desc: hasEmployeeFilters
|
||||
? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。'
|
||||
: '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。',
|
||||
: '这个状态标签下目前还没有记录,您可以切换到其他状态继续查看。',
|
||||
icon: hasEmployeeFilters ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline',
|
||||
actionLabel: hasEmployeeFilters ? '清空筛选' : '查看全部员工',
|
||||
actionIcon: hasEmployeeFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted',
|
||||
|
||||
@@ -155,13 +155,13 @@ export function buildStewardPlanMessageText(plan) {
|
||||
`${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}`
|
||||
)
|
||||
return [
|
||||
'### 我先帮你把步骤理清楚',
|
||||
'### 我先帮您把步骤理清楚',
|
||||
'',
|
||||
buildStewardPlanFriendlyIntro(normalized),
|
||||
'',
|
||||
...taskLines,
|
||||
'',
|
||||
'你看这个顺序是否合适?如果没问题,回复 **确定** 就行。我会先帮你进入第一步,需要补充的信息会在具体步骤里再温和提醒你。'
|
||||
'您看这个顺序是否合适?如果没问题,回复 **确定** 即可。我会先带您进入第一步,需要补充的信息会在具体步骤里再温和提醒。'
|
||||
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
|
||||
}
|
||||
|
||||
@@ -457,9 +457,9 @@ function buildPendingFlowConfirmationMessageText(normalized) {
|
||||
'',
|
||||
knownTable
|
||||
? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
|
||||
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
|
||||
: '我识别到这是一项财务事项,但还需要确认您要进入哪个流程。',
|
||||
'',
|
||||
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
|
||||
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定您是要补办申请,还是发起报销。',
|
||||
'',
|
||||
...candidateLines,
|
||||
'',
|
||||
@@ -471,14 +471,14 @@ function buildPendingFlowConfirmationMessageText(normalized) {
|
||||
|
||||
function buildGenericReimbursementIntentMessageText() {
|
||||
return [
|
||||
'### 我来带你发起报销',
|
||||
'### 我来带您发起报销',
|
||||
'',
|
||||
'你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。',
|
||||
'您现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带您填。',
|
||||
'',
|
||||
'1. **先选报销场景**',
|
||||
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
|
||||
'2. **再补关键材料**',
|
||||
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。',
|
||||
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮您核对是否需要关联事前申请。',
|
||||
'',
|
||||
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
|
||||
].join('\n')
|
||||
@@ -602,22 +602,26 @@ function buildTaskOrderTarget(task) {
|
||||
function buildTaskOrderActionDescription(task) {
|
||||
const agent = task.assignedAgentLabel || '对应助手'
|
||||
if (task.taskType === 'expense_application') {
|
||||
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
|
||||
// 申请类:先给行动,再说目的,主语后置
|
||||
return `这步交给${agent}——先把申请单草稿拉出来给您过目,没问题了再往下走。`
|
||||
}
|
||||
if (task.taskType === 'reimbursement') {
|
||||
if (isGenericReimbursementTask(task)) {
|
||||
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。`
|
||||
// 通用报销:换个句式,省掉主语,突出"先定方向"
|
||||
return `报销还差一个关键信息:具体是哪类费用。${agent}会先带您把报销场景定下来,再逐项补事由、时间、金额和票据。`
|
||||
}
|
||||
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
|
||||
// 有明确场景的报销:直接说动作,不绕弯
|
||||
return `票据、金额和制度口径,${agent}会一并核清楚;前一步确认后才会继续,不会越级往下推。`
|
||||
}
|
||||
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
|
||||
// 兜底:用"等您点头"的语气,区别于上面三条
|
||||
return `${agent}先把能核对的结果摆出来,真正动手前仍会等您点头。`
|
||||
}
|
||||
|
||||
function buildStewardPlanFriendlyIntro(normalized) {
|
||||
const taskCountText = normalized.tasks.length > 1
|
||||
? `${normalized.tasks.length} 个相关事项`
|
||||
: '1 个事项'
|
||||
return `我先看了一下,你这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让你每一步都能看清楚、确认后再继续。`
|
||||
return `我先看了一下,您这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让您每一步都能看清楚、确认后再继续。`
|
||||
}
|
||||
|
||||
function buildTaskOrderDescription(normalized) {
|
||||
@@ -627,12 +631,12 @@ function buildTaskOrderDescription(normalized) {
|
||||
return '处理顺序是:先创建申请单,再引导填写报销单。'
|
||||
}
|
||||
if (hasApplication) {
|
||||
return '我会先引导创建申请单并等待你确认。'
|
||||
return '我会先引导创建申请单,并等待您确认。'
|
||||
}
|
||||
if (hasReimbursement) {
|
||||
return '我会引导填写报销单并等待你确认。'
|
||||
return '我会引导填写报销单,并等待您确认。'
|
||||
}
|
||||
return '我会按识别顺序逐项推进,并在执行前等待你确认。'
|
||||
return '我会按识别顺序逐项推进,并在执行前等待您确认。'
|
||||
}
|
||||
|
||||
function buildNextTaskLead(task) {
|
||||
|
||||
@@ -567,7 +567,7 @@ export function buildRequiredApplicationSelectionText(expenseType, applications)
|
||||
`发起“${label}”报销前,需要先关联对应的申请单。`,
|
||||
'',
|
||||
`我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`,
|
||||
'选择后,我再继续向你收集本次报销依据。'
|
||||
'选择后,我会继续向您收集本次报销依据。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ export function buildRequiredApplicationMissingText(expenseType) {
|
||||
return [
|
||||
`发起“${label}”报销前,需要先关联对应的申请单。`,
|
||||
'',
|
||||
`我没有查到你名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
|
||||
`我没有查到您名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
|
||||
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -7,6 +7,27 @@ import {
|
||||
normalizeRequiredApplicationCandidate,
|
||||
resolveRequiredApplicationReimbursementType
|
||||
} 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'])
|
||||
|
||||
@@ -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_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) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
@@ -85,6 +103,26 @@ function formatAmount(value) {
|
||||
}).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) {
|
||||
return currentUser?.value && typeof currentUser.value === 'object'
|
||||
? currentUser.value
|
||||
@@ -111,9 +149,9 @@ export function isReimbursementAssociationQueryTimeoutError(error) {
|
||||
|
||||
export function buildReimbursementAssociationQueryFailedText(error) {
|
||||
if (isReimbursementAssociationQueryTimeoutError(error)) {
|
||||
return '查询可关联申请单超时。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
|
||||
return '查询可关联申请单超时。您可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
|
||||
}
|
||||
return '查询可关联申请单时出现异常。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
|
||||
return '查询可关联申请单时出现异常。您可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
|
||||
}
|
||||
|
||||
export async function fetchReimbursementAssociationClaims({
|
||||
@@ -189,7 +227,7 @@ export function normalizeReimbursementDraftCandidate(claim = {}) {
|
||||
amount_label: formatAmount(amount),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -210,11 +248,11 @@ export function buildReimbursementAssociationSelectionText(applications) {
|
||||
return [
|
||||
'### 可关联申请单',
|
||||
'',
|
||||
'我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。',
|
||||
'我先检查了您名下的报销草稿,没有查到可继续的报销草稿。',
|
||||
'',
|
||||
'我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。',
|
||||
'接下来先查询可关联申请单,为您筛选出名下已审批且尚未关联报销的记录。',
|
||||
'',
|
||||
`查到 ${candidates.length} 个已审批且尚未关联报销的申请单。你可以选择关联其中一个,也可以选择不关联、单独新建报销单。`,
|
||||
`查到 ${candidates.length} 个已审批且尚未关联报销的申请单。您可以从中选择一个进行关联,也可以不关联、直接单独新建报销单。`,
|
||||
'',
|
||||
buildReimbursementAssociationCardsHtml(candidates),
|
||||
'',
|
||||
@@ -224,11 +262,11 @@ export function buildReimbursementAssociationSelectionText(applications) {
|
||||
|
||||
export function buildReimbursementAssociationMissingText() {
|
||||
return [
|
||||
'我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。',
|
||||
'我先检查了您名下的报销草稿,没有查到可继续的报销草稿。',
|
||||
'',
|
||||
'我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。',
|
||||
'接下来先查询可关联申请单,为您筛选出名下已审批且尚未关联报销的记录。',
|
||||
'',
|
||||
'暂时没有查到已审批且尚未关联报销的申请单。你仍然可以选择单独新建报销单,后续按报销类型继续补充信息。'
|
||||
'暂时没有查到已审批且尚未关联报销的申请单。您仍然可以选择单独新建报销单,后续按报销类型继续补充信息。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -237,67 +275,16 @@ export function buildReimbursementDraftSelectionText(drafts) {
|
||||
return [
|
||||
'### 可继续报销草稿',
|
||||
'',
|
||||
'我先检查你名下是否有可继续的报销草稿。',
|
||||
'我先检查了您名下的报销草稿。',
|
||||
'',
|
||||
`查到 ${candidates.length} 个可继续的报销草稿。你可以先继续草稿;如果这次是新的报销,可以跳过草稿后再关联申请单新建报销单。`,
|
||||
`查到 ${candidates.length} 个可继续的报销草稿。您可以查看草稿详情,或继续把附件、说明关联到该草稿;如果这是一次新的报销,请独立新建报销单。`,
|
||||
'',
|
||||
buildReimbursementDraftCardsHtml(candidates),
|
||||
'',
|
||||
'请通过下方按钮确认继续草稿,或跳过草稿进入申请单关联。'
|
||||
'请通过下方三个按钮选择下一步。'
|
||||
].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 = '') {
|
||||
const sourceText = normalizeText(originalMessage) || '我要报销'
|
||||
return [
|
||||
@@ -331,7 +318,7 @@ function buildReimbursementDraftCardHtml(draft = {}) {
|
||||
const statusLabel = normalizeText(draft.status_label) || '草稿'
|
||||
const title = normalizeText(EXPENSE_TYPE_LABELS[normalizeLower(draft.expense_type)] || draft.expense_type) || '报销草稿'
|
||||
const summaryHtml = [
|
||||
buildAssociationCardFieldHtml('金额', draft.amount_label || draft.amount || '待确认', {
|
||||
buildAssociationCardFieldHtml('金额', formatDraftAmount(draft.amount, draft.amount_label), {
|
||||
valueClass: 'ai-document-card__amount'
|
||||
}),
|
||||
buildAssociationCardFieldHtml('更新时间', draft.created_at || '待确认')
|
||||
@@ -343,7 +330,7 @@ function buildReimbursementDraftCardHtml(draft = {}) {
|
||||
}),
|
||||
buildAssociationCardFieldHtml('事由', draft.reason || '待补充'),
|
||||
buildAssociationCardFieldHtml('单据类型', `报销单 · ${title}`),
|
||||
buildAssociationCardFieldHtml('操作', '使用下方按钮继续', {
|
||||
buildAssociationCardFieldHtml('操作', '使用下方按钮查看、关联或新建', {
|
||||
fieldClass: 'ai-document-card__field--action'
|
||||
})
|
||||
].join('')
|
||||
@@ -545,7 +532,7 @@ export function buildReimbursementAssociationThinkingEvents(stage = 'intent', op
|
||||
title: '检查报销草稿',
|
||||
content: currentOrder > 1
|
||||
? '已完成报销草稿检查,继续判断是否需要进入申请单关联。'
|
||||
: '正在查询你名下是否存在可继续的报销草稿。',
|
||||
: '正在查询您名下是否存在可继续的报销草稿。',
|
||||
status: resolveStatus(1)
|
||||
},
|
||||
{
|
||||
@@ -553,7 +540,7 @@ export function buildReimbursementAssociationThinkingEvents(stage = 'intent', op
|
||||
title: '查询可关联申请单',
|
||||
content: currentOrder > 2
|
||||
? `已完成申请单查询与筛选,命中 ${candidateCount} 张可推荐单据。`
|
||||
: '如未发现可继续草稿,就查询你名下已审批且尚未关联报销的申请单。',
|
||||
: '如未发现可继续草稿,就查询您名下已审批且尚未关联报销的申请单。',
|
||||
status: resolveStatus(2)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -53,8 +53,8 @@ export function buildExpenseIntentConfirmationMessage(rawText) {
|
||||
text
|
||||
? `我看到了「${text}」这类业务事项描述。`
|
||||
: '我看到了这类业务事项描述。',
|
||||
'但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
|
||||
'如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。'
|
||||
'但现在还不能确定您是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
|
||||
'如果您是要报销,请点击下面的“我要报销”,我会继续引导您选择具体报销场景。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -62,13 +62,13 @@ export function buildExpenseSceneSelectionMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
|
||||
const prefix = hasBusinessTime
|
||||
? '我已看到你提供了业务发生时间和报销意图。'
|
||||
? '我已看到您提供了业务发生时间和报销意图。'
|
||||
: '我已识别到这是报销申请。'
|
||||
|
||||
return [
|
||||
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`,
|
||||
`${prefix}请先选一下这笔费用属于哪一类,我再按对应流程继续。`,
|
||||
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
|
||||
'选完后我会把下一步需要准备的内容整理给你。'
|
||||
'选完后我会把下一步需要准备的内容整理给您。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ASSISTANT_SESSION_MODE_OPTIONS = [
|
||||
key: SESSION_TYPE_BUDGET,
|
||||
label: '预算编制助手',
|
||||
icon: 'mdi mdi-calculator-variant-outline',
|
||||
description: '帮助你进行预算编制与预算相关问题的整理'
|
||||
description: '帮助您进行预算编制与预算相关问题的整理'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
143
web/src/views/scripts/travelReimbursementDraftBranchModel.js
Normal file
143
web/src/views/scripts/travelReimbursementDraftBranchModel.js
Normal 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)
|
||||
]
|
||||
}
|
||||
@@ -214,7 +214,7 @@ export function buildGuidedExpenseTypeActions() {
|
||||
|
||||
export function buildGuidedReimbursementStartText() {
|
||||
return [
|
||||
'请问你要报销的类型?',
|
||||
'请问您要报销的类型?',
|
||||
'',
|
||||
'先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。'
|
||||
].join('\n')
|
||||
@@ -403,7 +403,7 @@ export function buildGuidedReimbursementSummaryText(state) {
|
||||
lines.push('')
|
||||
lines.push(
|
||||
linkedApplication
|
||||
? '如果关联信息无误,我可以直接生成报销草稿;后续由你在草稿详情中上传和归集票据。'
|
||||
? '如果关联信息无误,我可以直接生成报销草稿;后续由您在草稿详情中上传和归集票据。'
|
||||
: '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。'
|
||||
)
|
||||
return lines.join('\n')
|
||||
@@ -510,9 +510,9 @@ export function shouldConfirmGuidedInterruption(text, state) {
|
||||
|
||||
export function buildGuidedInterruptionText(text) {
|
||||
return [
|
||||
`我看到你刚才输入的是:“${normalizeText(text)}”。`,
|
||||
`我看到您刚才输入的是:“${normalizeText(text)}”。`,
|
||||
'',
|
||||
'这看起来像一个新的问题。你想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
|
||||
'这看起来像一个新的问题。您想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -543,9 +543,9 @@ export function createGuidedStatusQueryState() {
|
||||
|
||||
export function buildGuidedStatusQueryStartText() {
|
||||
return [
|
||||
'你想按什么条件查询单据状态?',
|
||||
'您想按什么条件查询单据状态?',
|
||||
'',
|
||||
'先选查询方式,我再向你收集对应条件。'
|
||||
'请先选查询方式,我再向您收集对应条件。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -329,16 +329,54 @@ export function buildReviewTodoItems(reviewPayload) {
|
||||
|
||||
|
||||
|
||||
// 待补充字段提示:每个字段给 2~3 种说法轮换,避免每次同一句。
|
||||
// 取值用 buildStableTemplateIndex 按字段签名稳定选取,保证同一字段在同一次会话里文案一致。
|
||||
const REVIEW_PENDING_HINT_COPY = {
|
||||
expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。',
|
||||
customer_name: '请补充客户单位全称。',
|
||||
time_range: '请补充业务发生日期或时间范围。',
|
||||
location: '请补充业务发生地点。',
|
||||
merchant_name: '请补充酒店或商户名称。',
|
||||
amount: '请补充本次费用金额。',
|
||||
reason: '请补充本次费用场景或事由。',
|
||||
participants: '请至少填写 1 名同行人员。',
|
||||
attachments: '请上传或关联对应票据附件。'
|
||||
expense_type: [
|
||||
'这笔费用先归个类,后续票据就按这个分类往下核。',
|
||||
'还差费用类型,定下来我才能匹配对应的标准和材料。'
|
||||
],
|
||||
customer_name: [
|
||||
'客户单位的全称填一下,合规校验要用。',
|
||||
'还差客户单位名称,补上就行。'
|
||||
],
|
||||
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) {
|
||||
@@ -355,10 +393,12 @@ function buildReviewPlainFollowupItem(item, pendingMode) {
|
||||
const key = String(item?.key || '').trim()
|
||||
const label = String(item?.title || item?.label || '').trim() || '待核查信息'
|
||||
if (pendingMode) {
|
||||
const hintFromPool = resolveReviewPendingHint(key, `${key}:${label}`)
|
||||
const fallbackHint = String(item?.hint || '').trim() || `请补充${label}`
|
||||
return {
|
||||
key: key || 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 = [
|
||||
({ 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 = [
|
||||
({ 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) {
|
||||
@@ -545,7 +575,7 @@ export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } =
|
||||
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
|
||||
const editHref = String(detailHref || '').trim() || '#review-quick-edit'
|
||||
lines.push(
|
||||
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
|
||||
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果您还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
|
||||
)
|
||||
}
|
||||
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))]
|
||||
|
||||
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) {
|
||||
@@ -671,7 +701,7 @@ export function buildReviewRecognitionNotes(reviewPayload) {
|
||||
if (documentCards.length) {
|
||||
notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`)
|
||||
} else {
|
||||
notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别')
|
||||
notes.push('当前还没有上传票据,这一轮主要依据您的文字描述完成初步识别')
|
||||
}
|
||||
|
||||
return notes
|
||||
@@ -686,7 +716,7 @@ export function buildReviewMissingHint(reviewPayload) {
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '当前关键信息已经齐全,这里无需再补充。'
|
||||
}
|
||||
return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。'
|
||||
return '下面这些字段还需要您再确认或补齐,补完后我就能继续往下处理。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
const normalized = String(scope || '').trim()
|
||||
|
||||
@@ -118,7 +118,7 @@ export function useTravelReimbursementStewardFollowupFlow({
|
||||
title: '判断下一步条件',
|
||||
content: nextMissing
|
||||
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
|
||||
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
|
||||
: '我会先等您确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
|
||||
function buildApplicationDateConflictMessage(conflict) {
|
||||
const claimNo = conflict?.claimNo || '已有申请'
|
||||
return [
|
||||
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
|
||||
'我先检查了您的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
|
||||
'',
|
||||
'已有申请:',
|
||||
`- **单号**:${claimNo}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildUnsavedDraftAttachmentConfirmationMessage } from './travelReimbursementAttachmentModel.js'
|
||||
import { REIMBURSEMENT_LIST_PREVIEW_PARAMS } from '../../services/reimbursements.js'
|
||||
|
||||
export async function handleDraftAssociationPreflight({
|
||||
activeReviewPayload,
|
||||
@@ -81,7 +82,7 @@ export async function handleDraftAssociationPreflight({
|
||||
!reviewAction
|
||||
) {
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
const queryPayload = buildDraftAssociationQueryPayload(claims)
|
||||
if (queryPayload?.records?.length) {
|
||||
resetFlowRun()
|
||||
|
||||
@@ -265,16 +265,16 @@ export function createStewardDelegationHelpers({
|
||||
'',
|
||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
||||
'',
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
|
||||
'请先告诉我您打算怎么出行:**火车、飞机或轮船**。我会根据您的选择生成申请核对表,并同步费用测算,再继续判断是否可以提交申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
`**还需要你补充:${missingFields.join('、')}。**`,
|
||||
`**还需要您补充:${missingFields.join('、')}。**`,
|
||||
'',
|
||||
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
|
||||
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表,并继续推进下一步。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -349,8 +349,8 @@ export function createStewardDelegationHelpers({
|
||||
eventId: `${eventPrefix}-intent`,
|
||||
title: '理解当前任务',
|
||||
content: taskSummary
|
||||
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。`
|
||||
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
|
||||
? `您确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。`
|
||||
: `您确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-known`,
|
||||
@@ -366,14 +366,14 @@ export function createStewardDelegationHelpers({
|
||||
eventId: `${eventPrefix}-gap`,
|
||||
title: '判断待补充信息',
|
||||
content: transportMissing
|
||||
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
|
||||
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||||
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先请您选择火车、飞机或轮船,不会直接推进提交。'
|
||||
: `这一步还缺少${missingInfo},我会先向您确认这些信息,不直接推进提交。`
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-ready`,
|
||||
title: '判断下一步动作',
|
||||
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
|
||||
content: `这一步的关键业务信息已形成核对结果。我会先请您检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
|
||||
})
|
||||
}
|
||||
return events
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
buildApplicationTemplatePreview,
|
||||
buildLocalApplicationPreviewMessage
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
fetchExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
@@ -198,7 +201,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
|
||||
openTravelCalculator?.()
|
||||
pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', {
|
||||
pushAssistant('差旅计算器已打开。您可以直接填写目的地、天数和金额,我会按规则中心标准帮您测算。', {
|
||||
meta: ['差旅计算器']
|
||||
})
|
||||
persistAndScroll()
|
||||
@@ -253,7 +256,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
|
||||
let claimsPayload = null
|
||||
try {
|
||||
claimsPayload = await fetchExpenseClaims()
|
||||
claimsPayload = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
} catch (error) {
|
||||
console.warn('Fetch reimbursement applications failed:', error)
|
||||
guidedFlowState.value = createEmptyGuidedFlowState()
|
||||
@@ -599,7 +602,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
await submitExistingComposer({
|
||||
rawText: pendingText,
|
||||
userText: pendingText,
|
||||
pendingText: '正在处理你的问题...',
|
||||
pendingText: '正在处理您的问题...',
|
||||
skipUserMessage: true
|
||||
})
|
||||
return true
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useTravelReimbursementStewardRuntimeDecision({
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
[
|
||||
'我理解你是在确认当前申请单,但这张申请单还不能提交。',
|
||||
'我理解您是在确认当前申请单,但这张申请单还不能提交。',
|
||||
'',
|
||||
missingFields.length
|
||||
? `还需要先补充:**${missingFields.join('、')}**。`
|
||||
@@ -438,7 +438,7 @@ export function useTravelReimbursementStewardRuntimeDecision({
|
||||
if (isStewardRuntimeCancelText(normalizedText)) {
|
||||
return {
|
||||
next_action: 'cancel_current_action',
|
||||
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。'
|
||||
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果您要重新规划,请直接告诉我新的财务事项。'
|
||||
}
|
||||
}
|
||||
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
|
||||
@@ -476,7 +476,7 @@ export function useTravelReimbursementStewardRuntimeDecision({
|
||||
return {
|
||||
next_action: 'ask_user',
|
||||
response_text: missingFields.length
|
||||
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。`
|
||||
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。您可以直接回复对应选项或填写具体内容。`
|
||||
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,16 @@ import {
|
||||
canUseBudgetAssistantSession
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||||
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
|
||||
buildReimbursementDraftContinuationText,
|
||||
buildReimbursementAssociationSubmitOptions,
|
||||
buildStandaloneReimbursementDraftConfirmationActions,
|
||||
buildStandaloneReimbursementDraftConfirmationText,
|
||||
buildViewReimbursementDraftAction,
|
||||
pushReimbursementAssociationPromptMessage
|
||||
} from './travelReimbursementAssociationGateModel.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||||
@@ -44,8 +51,10 @@ export function useTravelReimbursementSuggestedActions({
|
||||
composerDraft,
|
||||
composerFilesExpanded,
|
||||
composerTextareaRef,
|
||||
composerUploadIntent = { value: '' },
|
||||
createMessage,
|
||||
currentUser,
|
||||
draftClaimId = { value: '' },
|
||||
emit,
|
||||
fetchExpenseClaims = async () => ({ items: [] }),
|
||||
handleGuidedShortcut,
|
||||
@@ -248,6 +257,53 @@ export function useTravelReimbursementSuggestedActions({
|
||||
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 = {}) {
|
||||
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||||
@@ -288,6 +344,25 @@ export function useTravelReimbursementSuggestedActions({
|
||||
if (await handleGuidedSuggestedAction(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) {
|
||||
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
|
||||
@@ -9,6 +9,10 @@ const routerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/router/index.js', import.meta.url)),
|
||||
'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', () => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
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*\{/)
|
||||
})
|
||||
|
||||
@@ -268,7 +268,7 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
|
||||
)
|
||||
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
|
||||
assert.match(greetingGuard.text, /你可以直接点下面的场景继续/)
|
||||
assert.match(greetingGuard.text, /您可以直接点下面的场景继续/)
|
||||
assert.equal(guard.suggestedActions.length, 4)
|
||||
assert.equal(guard.blocked, true)
|
||||
assert.equal(guard.targetSessionType, '')
|
||||
@@ -466,6 +466,28 @@ test('application preview parses same-month shorthand date range', () => {
|
||||
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', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差3天,辅助国网仿生产服务器部署,火车',
|
||||
@@ -569,6 +591,40 @@ test('application preview trusts model-refined fields over noisy source candidat
|
||||
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', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请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, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
|
||||
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
|
||||
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
|
||||
|
||||
@@ -57,6 +57,9 @@ function testReceiptFolderViewSurface() {
|
||||
assert.match(view, /buildReceiptFile\(item\)/)
|
||||
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
|
||||
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() {
|
||||
@@ -160,18 +163,19 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
|
||||
function testAssistantUnlinkedReceiptPrompt() {
|
||||
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, /promptUnlinkedReceiptFolderIfNeeded/)
|
||||
assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/)
|
||||
assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/)
|
||||
assert.match(submitComposer, /open_receipt_folder/)
|
||||
assert.match(submitComposer, /continue_upload_with_unlinked_receipts/)
|
||||
assert.match(assistantView, /actionType === 'open_receipt_folder'/)
|
||||
assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
|
||||
assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/)
|
||||
assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/)
|
||||
assert.match(attachmentFlow, /fetchReceiptFolderItems\('unlinked'\)/)
|
||||
assert.match(attachmentFlow, /skipReceiptFolderUnlinkedPrompt/)
|
||||
assert.match(attachmentFlow, /open_receipt_folder/)
|
||||
assert.match(attachmentFlow, /continue_upload_with_unlinked_receipts/)
|
||||
assert.match(suggestedActions, /actionType === 'open_receipt_folder'/)
|
||||
assert.match(suggestedActions, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
|
||||
assert.match(suggestedActions, /actionType === 'continue_upload_with_unlinked_receipts'/)
|
||||
assert.match(suggestedActions, /skipReceiptFolderUnlinkedPrompt: true/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
37
web/tests/reimbursement-list-preview-fetch.test.mjs
Normal file
37
web/tests/reimbursement-list-preview-fetch.test.mjs
Normal 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\(\)/)
|
||||
})
|
||||
@@ -20,12 +20,12 @@ test('steward plan summary uses warm guidance copy for application flow', () =>
|
||||
next_action: 'confirm_create_application'
|
||||
})
|
||||
|
||||
assert.match(message, /我先帮你把步骤理清楚/)
|
||||
assert.match(message, /我先看了一下,你这次主要是 \*\*1 个事项\*\*/)
|
||||
assert.match(message, /我先帮您把步骤理清楚/)
|
||||
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.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)
|
||||
|
||||
assert.match(message, /我来带你发起报销/)
|
||||
assert.match(message, /你现在只说了要报销/)
|
||||
assert.match(message, /我来带您发起报销/)
|
||||
assert.match(message, /您现在只说了要报销/)
|
||||
assert.match(message, /先选报销场景/)
|
||||
assert.match(message, /差旅费、交通费、住宿费/)
|
||||
assert.doesNotMatch(message, /步骤混在一起/)
|
||||
|
||||
@@ -94,6 +94,10 @@ const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitDraftPreflightScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageHandlersScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -477,6 +481,12 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||
assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/)
|
||||
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, /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\)/)
|
||||
|
||||
@@ -434,6 +434,13 @@ test('AI advice ignores approval opinions and flow logs as risks', () => {
|
||||
severity: 'info',
|
||||
label: '财务审核通过',
|
||||
message: '周晓彤 已完成财务审核,进入归档入账。'
|
||||
},
|
||||
{
|
||||
source: 'application_link_sync',
|
||||
event_type: 'expense_application_reimbursement_deleted',
|
||||
severity: 'warning',
|
||||
label: '关联报销单已删除',
|
||||
message: '关联报销单 RDELETE01 已删除,申请单已回到待关联状态。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import test from 'node:test'
|
||||
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.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', () => {
|
||||
const [action] = buildStewardSuggestedActions({
|
||||
@@ -136,3 +140,108 @@ test('workbench reimbursement skip link action opens new reimbursement flow', ()
|
||||
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: '独立新建报销单'
|
||||
})
|
||||
})
|
||||
|
||||
46
web/tests/workbench-ai-application-result-card.test.mjs
Normal file
46
web/tests/workbench-ai-application-result-card.test.mjs
Normal 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, /点击卡片.*操作.*查看/)
|
||||
})
|
||||
@@ -9,9 +9,13 @@ function readSource(path) {
|
||||
|
||||
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
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 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 filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
|
||||
|
||||
function countOccurrences(source, pattern) {
|
||||
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, /: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\);/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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, /submitted:\s*'审批中'/)
|
||||
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
|
||||
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
|
||||
assert.doesNotMatch(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||
assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/)
|
||||
assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/)
|
||||
assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/)
|
||||
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/)
|
||||
assert.match(aiMode, /params\.set\('claim_id', claimId\)/)
|
||||
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
|
||||
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
|
||||
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
|
||||
|
||||
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
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,
|
||||
/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', () => {
|
||||
|
||||
@@ -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, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/)
|
||||
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, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/)
|
||||
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, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
|
||||
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
|
||||
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
|
||||
assert.match(aiModeSurface, /attachmentOcrDetails,\s*[\s\S]*includeOcrDetails: Boolean\(attachmentOcrDetails\)/)
|
||||
assert.match(aiModeSurface, /function extractReceiptIdsFromOcrDocuments\(documents = \[\]\)/)
|
||||
assert.match(aiModeSurface, /const receiptIds = attachmentJobFlow\.extractReceiptIdsFromOcrDocuments\(collected\.ocrDocuments\)/)
|
||||
assert.match(aiModeSurface, /await createAttachmentAssociationJob\(\{[\s\S]*receipt_ids: receiptIds,[\s\S]*conversation_id: conversationId\?\.value/)
|
||||
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, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/)
|
||||
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, /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, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
|
||||
assert.match(aiModeSurface, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
|
||||
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
|
||||
assert.match(aiModeSurface, /我已保留当前申请核对表/)
|
||||
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 aiModeReceiptRecognitionState = reactive\(\{\}\)/)
|
||||
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, /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, /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 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, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
|
||||
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
|
||||
|
||||
@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
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(
|
||||
join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
|
||||
@@ -40,7 +47,11 @@ function buildFlow(options = {}) {
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } },
|
||||
createLinkedReimbursementDraftJobForAi: options.createLinkedReimbursementDraftJobForAi,
|
||||
fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
|
||||
fetchLinkedReimbursementDraftJobForAi: options.fetchLinkedReimbursementDraftJobForAi,
|
||||
linkedDraftJobPollIntervalMs: options.linkedDraftJobPollIntervalMs ?? 0,
|
||||
linkedDraftJobMaxPolls: options.linkedDraftJobMaxPolls ?? 2,
|
||||
runOrchestratorForAi: options.runOrchestratorForAi,
|
||||
associationQueryTimeoutMs: options.associationQueryTimeoutMs,
|
||||
persistCurrentConversation: () => {
|
||||
@@ -61,6 +72,18 @@ function buildFlow(options = {}) {
|
||||
|
||||
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 () => {
|
||||
conversationStarted.value = false
|
||||
let queried = 0
|
||||
@@ -135,7 +158,7 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
|
||||
reason: '北京客户现场实施报销',
|
||||
location: '北京',
|
||||
status: 'draft',
|
||||
amount: 650,
|
||||
amount: '0.00',
|
||||
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, /查到 1 个可继续的报销草稿/)
|
||||
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.match(assistantMessage.content, /下方三个按钮/)
|
||||
assert.doesNotMatch(assistantMessage.content, /跳过草稿后再关联申请单/)
|
||||
assert.equal(assistantMessage.suggestedActions.length, 3)
|
||||
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
|
||||
assert.match(assistantMessage.suggestedActions[0].label, /继续草稿/)
|
||||
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_reimbursement_draft_check')
|
||||
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
conversationStarted.value = false
|
||||
const orchestratorCalls = []
|
||||
const createJobCalls = []
|
||||
const fetchJobCalls = []
|
||||
const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
|
||||
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) => {
|
||||
orchestratorCalls.push({ payload, options })
|
||||
return {
|
||||
@@ -303,16 +361,93 @@ test('linked application selection can create reimbursement draft from associati
|
||||
application_amount_label: '1,650元'
|
||||
})
|
||||
|
||||
assert.equal(orchestratorCalls.length, 1)
|
||||
assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft')
|
||||
assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
|
||||
assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001')
|
||||
assert.equal(orchestratorCalls.length, 0)
|
||||
assert.equal(createJobCalls.length, 1)
|
||||
assert.equal(createJobCalls[0].context_json.review_action, 'save_draft')
|
||||
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.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).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', () => {
|
||||
assert.match(
|
||||
personalWorkbenchAiMode,
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useWorkbenchComposerDate } from '../src/composables/useWorkbenchComposerDate.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user