Refine travel reimbursement steward flow

Align planner, runtime rules, and policy assets so travel guidance
matches the updated reimbursement workflow.
This commit is contained in:
caoxiaozhu
2026-06-15 22:55:18 +08:00
parent 792741709a
commit 9f7b8b46a3
85 changed files with 9496 additions and 2555 deletions

View File

@@ -532,11 +532,10 @@
.message-answer-markdown :deep(table) {
width: 100%;
min-width: 560px;
min-width: 460px;
border: 0;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: #ffffff;
font-size: inherit;
}
@@ -548,25 +547,6 @@
text-align: left;
vertical-align: top;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
.message-answer-markdown :deep(th:first-child),
.message-answer-markdown :deep(td:first-child) {
width: 88px;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
.message-answer-markdown :deep(th:last-child),
.message-answer-markdown :deep(td:last-child) {
width: 112px;
text-align: right;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
.message-answer-markdown :deep(th) {
@@ -806,6 +786,30 @@
border-top: 1px solid #e6edf5;
}
.structured-card-reveal-enter-active .application-preview-row {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
animation-delay: 35ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
animation-delay: 70ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
animation-delay: 105ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
animation-delay: 140ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
animation-delay: 165ms;
}
.application-preview-row.editable {
cursor: pointer;
}

View File

@@ -1,4 +1,4 @@
.approval-page {
.approval-page {
width: 100%;
height: 100%;
min-height: 0;
@@ -861,6 +861,9 @@
}
.detail-expense-table {
--expense-editor-control-height: 34px;
--expense-editor-control-line-height: 16px;
--expense-editor-control-padding-y: calc((var(--expense-editor-control-height) - var(--expense-editor-control-line-height) - 2px) / 2);
min-width: 0;
overflow-x: auto;
}
@@ -940,10 +943,10 @@
.detail-expense-table .col-time { width: 10%; }
.detail-expense-table .col-filled-at { width: 13%; }
.detail-expense-table .col-type { width: 11%; }
.detail-expense-table .col-type { width: 14%; }
.detail-expense-table .col-desc { width: 15%; }
.detail-expense-table .col-amount { width: 9%; }
.detail-expense-table .col-attachment { width: 18%; }
.detail-expense-table .col-attachment { width: 15%; }
.detail-expense-table .col-risk-note { width: 15%; }
.detail-expense-table .col-action { width: 9%; }
@@ -1000,61 +1003,162 @@
grid-template-columns: minmax(0, 1fr);
}
.editor-input,
.editor-select,
.editor-textarea {
.editor-control,
.editor-select {
width: 100%;
min-height: 34px;
padding: 0 10px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
min-width: 0;
--el-component-size: var(--expense-editor-control-height) !important;
--el-component-size-small: var(--expense-editor-control-height) !important;
--el-input-height: var(--expense-editor-control-height) !important;
box-sizing: border-box !important;
}
.editor-select {
padding: 0;
border: 0;
background: transparent;
}
.editor-date-picker.el-date-editor {
--el-date-editor-width: 100%;
}
.editor-date-picker.editor-control :deep(.el-input__wrapper) {
gap: 4px;
padding: 0 7px !important;
}
.editor-date-picker.editor-control :deep(.el-input__prefix) {
flex: 0 0 14px;
width: 14px;
min-width: 14px;
margin: 0;
color: #94a3b8;
font-size: 13px;
}
.editor-date-picker.editor-control :deep(.el-input__prefix-inner) {
width: 14px;
font-size: 13px;
}
.editor-date-picker.editor-control :deep(.el-input__suffix) {
display: none !important;
}
.editor-control :deep(.el-input__wrapper),
.editor-control :deep(.el-select__wrapper),
.editor-select :deep(.el-select__wrapper),
.editor-date-picker :deep(.el-input__wrapper) {
box-sizing: border-box !important;
padding: 0 10px !important;
border-radius: 4px !important;
background: #fff !important;
box-shadow: 0 0 0 1px #d7e0ea inset !important;
display: flex !important;
align-items: center !important;
margin: 0 !important;
}
.editor-control:focus-within :deep(.el-input__wrapper),
.editor-control:focus-within :deep(.el-select__wrapper),
.editor-select:focus-within :deep(.el-select__wrapper),
.editor-date-picker:focus-within :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px var(--theme-primary) inset, 0 0 0 3px var(--theme-focus-ring);
}
.editor-control :deep(.el-input__inner),
.editor-select :deep(.el-select__selected-item),
.editor-select :deep(.el-select__placeholder),
.editor-date-picker :deep(.el-input__inner) {
height: var(--expense-editor-control-line-height) !important;
color: #0f172a;
font-size: 12px;
line-height: var(--expense-editor-control-line-height) !important;
}
.editor-textarea {
min-height: 68px;
padding: 8px 10px;
resize: vertical;
line-height: 1.45;
}
.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
overflow-y: auto;
resize: none;
}
.currency-editor {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
.editor-control :deep(.el-input__prefix),
.editor-control :deep(.el-input__suffix),
.editor-date-picker :deep(.el-input__prefix),
.editor-date-picker :deep(.el-input__suffix) {
display: inline-flex;
align-items: center;
gap: 8px;
}
.currency-editor span {
min-height: 34px;
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #f8fafc;
.editor-select :deep(.el-select__wrapper) {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
}
.editor-amount-input :deep(.el-input__prefix) {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
color: #334155;
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 800;
}
.editor-input:focus,
.editor-select:focus,
.editor-textarea:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
.editor-amount-input :deep(.el-input__prefix-inner) {
display: inline-flex;
align-items: center;
height: var(--expense-editor-control-line-height);
line-height: var(--expense-editor-control-line-height);
}
.cell-editor span {
.editor-amount-input.editor-control {
display: flex;
align-items: center;
}
.risk-note-editor-input.editor-control {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
}
.risk-note-editor-input.el-textarea {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
}
.risk-note-editor-input :deep(.el-textarea__inner) {
display: block !important;
box-sizing: border-box !important;
min-height: var(--expense-editor-control-height) !important;
height: var(--expense-editor-control-height);
line-height: var(--expense-editor-control-line-height) !important;
max-height: calc(var(--expense-editor-control-height) + var(--expense-editor-control-line-height) * 2) !important;
padding: var(--expense-editor-control-padding-y) 10px !important;
border-radius: 4px !important;
color: #0f172a !important;
font-size: 12px !important;
resize: none !important;
overflow-y: hidden !important;
box-shadow: 0 0 0 1px #d7e0ea inset !important;
vertical-align: middle !important;
margin: 0 !important;
}
.risk-note-editor-input :deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px var(--theme-primary) inset, 0 0 0 3px var(--theme-focus-ring);
}
/* 隐藏金额输入框的原生微调箭头 */
.editor-amount-input :deep(input::-webkit-outer-spin-button),
.editor-amount-input :deep(input::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
.editor-amount-input :deep(input[type=number]) {
-moz-appearance: textfield;
}
.cell-editor > span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 11px;
line-height: 1.45;
@@ -1432,6 +1536,16 @@
flex-wrap: wrap;
gap: 6px;
justify-content: center;
align-items: center;
}
.detail-expense-table .row-action-group .inline-action {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.risk-inline-tag {

View File

@@ -207,6 +207,7 @@
<template v-else>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"

View File

@@ -9,6 +9,7 @@ import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../servi
import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { ASSISTANT_SCOPE_SESSION_STEWARD } from '../utils/assistantSessionScope.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import {
@@ -371,6 +372,9 @@ export function useAppShell() {
if (!prompt) {
return fallbackSessionType
}
if (fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD) {
return fallbackSessionType
}
try {
const ontology = await fetchOntologyParse(

View File

@@ -52,6 +52,10 @@ const APPLICATION_FUTURE_OR_DURATION_PATTERN =
/明天|后天|下周|下月|近期|月底|预计|计划|安排|准备|将要|[0-9]+天|[一二两三四五六七八九十]+天/
const APPLICATION_ROUTE_PATTERN =
/(?:去|到|赴|前往)[^,。;;?!\n]{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|(?:出差|差旅)[^,。;;?!\n]{0,24}(?:[0-9]+天|[一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const AMBIGUOUS_TRAVEL_DATE_RANGE_PATTERN =
/(?:\d{1,2}月)?\d{1,2}(?:日|号)?(?:-|—|~||至|到)\d{1,2}(?:日|号)?[^,。;;?!\n]{0,32}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const EXPLICIT_APPLICATION_ACTION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|先申请|补办申请|补申请|补办出差申请|创建申请|提交申请/
const COMPLETED_EXPENSE_PATTERN =
/已经|已|昨天|前天|上周|上月|去年|花了|花销|消费|垫付|支付|付了|买了|采购了|招待了|发生了/
const EXPENSE_PATTERN =
@@ -141,6 +145,21 @@ export function hasExpenseApplicationIntentSignal(rawText) {
return hasBusinessSignal && planningScore + timingScore + routeScore >= 2
}
export function hasAmbiguousTravelFlowIntent(rawText) {
const text = normalizeText(rawText)
if (!text) {
return false
}
if (
EXPLICIT_APPLICATION_ACTION_PATTERN.test(text) ||
EXPENSE_PATTERN.test(text) ||
KNOWLEDGE_PATTERN.test(text)
) {
return false
}
return AMBIGUOUS_TRAVEL_DATE_RANGE_PATTERN.test(text)
}
function resolveScopeConfig(sessionType) {
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
}
@@ -151,6 +170,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
return ''
}
if (hasAmbiguousTravelFlowIntent(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
const applicationMatched = hasExpenseApplicationIntentSignal(text)
const expenseMatched = EXPENSE_PATTERN.test(text)
const approvalMatched = APPROVAL_PATTERN.test(text)

View File

@@ -3,6 +3,7 @@ import {
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
ASSISTANT_SCOPE_SESSION_STEWARD,
hasAmbiguousTravelFlowIntent,
hasExpenseApplicationIntentSignal,
hasReimbursementIntentSignal,
inferAssistantScopeTarget
@@ -64,6 +65,10 @@ export function resolveWorkbenchSessionTypeFromOntology(ontology, rawText, fallb
return fallback
}
if (hasAmbiguousTravelFlowIntent(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (applicationSignal && reimbursementSignal) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}

View File

@@ -228,7 +228,15 @@
<div class="expense-time-value">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
<ElDatePicker
v-model="expenseEditor.itemDate"
class="editor-date-picker editor-control"
type="date"
value-format="YYYY-MM-DD"
format="YYYY/MM/DD"
popper-class="detail-editor-date-popper"
:clearable="false"
/>
<span>{{ item.dayLabel }}</span>
</div>
</template>
@@ -242,7 +250,7 @@
<td class="expense-type col-type">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<EnterpriseSelect v-model="expenseEditor.itemType" class="editor-select" :options="expenseTypeOptions" size="small" />
<EnterpriseSelect v-model="expenseEditor.itemType" class="editor-select" :options="expenseTypeOptions" />
<span>编辑费用项目</span>
</div>
</template>
@@ -254,10 +262,10 @@
<td class="expense-desc col-desc">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<input
<ElInput
v-model="expenseEditor.itemReason"
class="editor-input"
type="text"
class="editor-input-control editor-control"
clearable
:placeholder="resolveExpenseReasonPlaceholder(expenseEditor.itemType)"
/>
<span>{{ resolveExpenseReasonHelper(expenseEditor.itemType) }}</span>
@@ -271,18 +279,18 @@
<td class="expense-amount col-amount">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<label class="currency-editor">
<span></span>
<input
v-model="expenseEditor.itemAmount"
class="editor-input"
type="number"
min="0"
step="0.01"
placeholder="输入金额"
/>
</label>
<span>保存后自动格式化为人民币</span>
<ElInput
v-model="expenseEditor.itemAmount"
class="editor-amount-input editor-control"
type="number"
inputmode="decimal"
min="0"
step="0.01"
placeholder="输入金额"
>
<template #prefix></template>
</ElInput>
<span>自动格式化</span>
</div>
</template>
<template v-else>
@@ -389,15 +397,15 @@
<td class="expense-risk-note col-risk-note">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<textarea
<ElInput
v-model="expenseEditor.itemNote"
class="editor-textarea risk-note-editor-textarea"
rows="1"
class="risk-note-editor-input editor-control"
type="textarea"
:rows="1"
resize="none"
placeholder="如票据存在异常或风险,请补充原因"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea>
<span>用于说明改签绕行超标票据异常等情况</span>
/>
<span>非必填若有异常则说明</span>
</div>
</template>
<template v-else>
@@ -886,3 +894,224 @@
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>
<style>
/* 强力锁定表格中输入框的高度,解决 scoped 模式下有前缀的 Element Plus 子组件无法被 :deep 成功匹配的局限性 */
.detail-expense-table .editor-control .el-input__wrapper,
.detail-expense-table .editor-control .el-select__wrapper,
.detail-expense-table .editor-select .el-select__wrapper,
.detail-expense-table .editor-date-picker .el-input__wrapper {
box-sizing: border-box !important;
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-control:not(.risk-note-editor-input),
.detail-expense-table .editor-date-picker.editor-control,
.detail-expense-table .editor-select {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-date-picker.editor-control {
display: flex !important;
align-items: center !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__wrapper {
gap: 4px !important;
padding-right: 7px !important;
padding-left: 7px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__inner,
.detail-expense-table .editor-input-control.editor-control .el-input__inner,
.detail-expense-table .editor-select .el-select__selected-item,
.detail-expense-table .editor-select .el-select__placeholder {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
font-size: 12px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix,
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
display: inline-flex !important;
align-items: center !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix {
flex: 0 0 14px !important;
width: 14px !important;
min-width: 14px !important;
margin: 0 !important;
color: #94a3b8 !important;
font-size: 13px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
display: none !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner {
width: 14px !important;
font-size: 13px !important;
}
.detail-expense-table .editor-amount-input.editor-control {
display: flex !important;
align-items: center !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__wrapper {
display: flex !important;
align-items: center !important;
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix,
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
display: inline-flex !important;
align-items: center !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-editor-date-popper.el-popper {
border: 1px solid rgba(148, 163, 184, .32) !important;
border-radius: 4px !important;
background: #ffffff !important;
box-shadow: 0 18px 42px rgba(15, 23, 42, .14) !important;
}
.detail-editor-date-popper .el-picker-panel {
border: 0 !important;
border-radius: 4px !important;
background: #ffffff !important;
color: #334155 !important;
}
.detail-editor-date-popper .el-date-picker__header {
height: 38px !important;
margin: 0 !important;
padding: 0 10px !important;
border-bottom: 1px solid #e2e8f0 !important;
display: flex !important;
align-items: center !important;
}
.detail-editor-date-popper .el-picker-panel__icon-btn {
appearance: none !important;
width: 24px !important;
height: 24px !important;
margin: 0 1px !important;
padding: 0 !important;
border: 0 !important;
border-radius: 4px !important;
background: transparent !important;
color: #64748b !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
transition: background-color 160ms var(--ease), color 160ms var(--ease) !important;
}
.detail-editor-date-popper .el-picker-panel__icon-btn:hover {
background: var(--theme-primary-soft) !important;
color: var(--theme-primary-active) !important;
}
.detail-editor-date-popper .el-date-picker__header-label {
color: #0f172a !important;
font-size: 13px !important;
font-weight: 800 !important;
}
.detail-editor-date-popper .el-picker-panel__content {
margin: 8px 10px 10px !important;
}
.detail-editor-date-popper .el-date-table th {
border-bottom: 1px solid #edf2f7 !important;
color: #64748b !important;
font-size: 11px !important;
font-weight: 800 !important;
}
.detail-editor-date-popper .el-date-table td {
width: 32px !important;
height: 30px !important;
padding: 2px !important;
}
.detail-editor-date-popper .el-date-table td .el-date-table-cell {
height: 28px !important;
padding: 0 !important;
}
.detail-editor-date-popper .el-date-table td .el-date-table-cell__text {
width: 26px !important;
height: 26px !important;
border-radius: 4px !important;
color: #334155 !important;
font-size: 12px !important;
line-height: 26px !important;
}
.detail-editor-date-popper .el-date-table td.available:hover .el-date-table-cell__text {
background: var(--theme-primary-soft) !important;
color: var(--theme-primary-active) !important;
}
.detail-editor-date-popper .el-date-table td.today .el-date-table-cell__text {
color: var(--theme-primary-active) !important;
font-weight: 850 !important;
}
.detail-editor-date-popper .el-date-table td.current .el-date-table-cell__text,
.detail-editor-date-popper .el-date-table td.selected .el-date-table-cell__text {
background: var(--theme-primary) !important;
color: #ffffff !important;
font-weight: 850 !important;
}
.detail-editor-date-popper .el-date-table td.prev-month .el-date-table-cell__text,
.detail-editor-date-popper .el-date-table td.next-month .el-date-table-cell__text {
color: #cbd5e1 !important;
}
.detail-editor-date-popper .el-date-table td.disabled .el-date-table-cell__text {
background: #f8fafc !important;
color: #cbd5e1 !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElDatePicker } from 'element-plus/es/components/date-picker/index.mjs'
import { ElInput } from 'element-plus/es/components/input/index.mjs'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -572,6 +574,8 @@ export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog,
ElDatePicker,
ElInput,
EnterpriseSelect,
StageRiskAdviceCard,
TravelRequestApprovalDialog,
@@ -1794,18 +1798,6 @@ export default {
riskOverrideDialogOpen.value = false
}
function resizeExpenseNoteInput(event) {
const target = event?.target
if (!target || typeof window === 'undefined') {
return
}
const style = window.getComputedStyle(target)
const lineHeight = Number.parseFloat(style.lineHeight) || 18
const maxHeight = lineHeight * 3 + 18
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}
function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) {
return
@@ -1883,10 +1875,6 @@ export default {
}
populateExpenseEditor(item)
void nextTick(() => {
const textarea = document.querySelector('.risk-note-editor-textarea')
resizeExpenseNoteInput({ target: textarea })
})
}
function validateExpenseEditor() {
@@ -2677,7 +2665,7 @@ export default {
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,

View File

@@ -45,11 +45,11 @@ export const TAB_META = {
financialRules: {
assetType: 'rule',
typeKey: 'rules',
label: '财务规则',
typeLabel: '财务规则',
createButtonLabel: '财务规则已接入',
hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。',
searchPlaceholder: '搜索财务规则名称、编码或负责人',
label: '基础规则',
typeLabel: '基础规则',
createButtonLabel: '基础规则已接入',
hintText: '仅展示 tag 为“基础规则”或“申请规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。',
searchPlaceholder: '搜索基础规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showStatusColumn: false,
@@ -203,7 +203,25 @@ export const RULE_TEMPLATE_LABELS = {
}
export const RULE_TAB_TAG_ALIASES = {
financialRules: new Set(['财务规则', '财务', 'financialrule', 'financialrules', 'financerule', 'financerules', 'financial', 'finance']),
financialRules: new Set([
'基础规则',
'基本规则',
'申请规则',
'财务规则',
'财务',
'basicrule',
'basicrules',
'applicationrule',
'applicationrules',
'preapprovalrule',
'preapprovalrules',
'financialrule',
'financialrules',
'financerule',
'financerules',
'financial',
'finance'
]),
riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk'])
}

View File

@@ -254,7 +254,7 @@ export function createPreviewRuleDetailPayload() {
config_json: {
severity: 'medium',
enabled: true,
tag: '财务规则',
tag: '基础规则',
detail_mode: 'spreadsheet',
runtime_kind: 'travel_policy',
scenario_category: '差旅',

View File

@@ -125,7 +125,7 @@ export function buildStewardFieldCompletionRawText({
'已识别信息:',
...knownLines,
'',
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
].filter((line) => line !== '').join('\n')
}

View File

@@ -98,8 +98,15 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
}
}
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
export function buildStewardPlanRequest({
rawText = '',
files = [],
currentUser = {},
conversationId = '',
stewardState = null
} = {}) {
const safeFiles = Array.isArray(files) ? files : []
const normalizedConversationId = String(conversationId || '').trim()
return {
message: String(rawText || '').trim(),
user_id: String(currentUser.username || currentUser.name || 'anonymous').trim() || 'anonymous',
@@ -111,6 +118,8 @@ export function buildStewardPlanRequest({ rawText = '', files = [], currentUser
context_json: {
entry_source: 'workbench',
session_type: 'steward',
conversation_id: normalizedConversationId,
steward_state: stewardState && typeof stewardState === 'object' ? stewardState : null,
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
username: currentUser.username || '',
name: currentUser.name || currentUser.username || '',
@@ -124,9 +133,13 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
const visibleThinkingEventCount = Number.isFinite(options.visibleThinkingEventCount)
? Number(options.visibleThinkingEventCount)
: Number(rawPlan.visibleThinkingEventCount || rawPlan.visible_thinking_event_count || 0)
const pendingFlowConfirmation = normalizePendingFlowConfirmation(rawPlan)
return {
planId: String(rawPlan.plan_id || rawPlan.planId || ''),
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
nextAction: String(rawPlan.next_action || rawPlan.nextAction || ''),
conversationId: String(rawPlan.conversation_id || rawPlan.conversationId || ''),
stewardState: rawPlan.steward_state || rawPlan.stewardState || null,
summary: String(rawPlan.summary || ''),
visibleThinkingEventCount,
initialSummaryOnly: Boolean(rawPlan.initial_summary_only || rawPlan.initialSummaryOnly || options.initialSummaryOnly),
@@ -185,12 +198,17 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
: [],
confirmationGroups: Array.isArray(rawPlan.confirmation_groups)
? rawPlan.confirmation_groups
: []
: [],
pendingFlowConfirmation,
candidateFlows: pendingFlowConfirmation.candidateFlows
}
}
export function buildStewardPlanMessageText(plan) {
const normalized = normalizeStewardPlan(plan)
if (isPendingFlowConfirmationPlan(normalized)) {
return buildPendingFlowConfirmationMessageText(normalized)
}
const nextContext = resolveNextActionContext(normalized)
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) =>
@@ -266,6 +284,28 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan)
if (isPendingFlowConfirmationPlan(normalized)) {
return normalized.candidateFlows.map((flow) => ({
label: flow.label,
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
icon: flow.flowId === 'travel_application'
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
steward_confirm_flow: true,
steward_plan_id: normalized.planId,
flow_id: flow.flowId,
session_type: flow.flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE,
selected_flow_label: flow.label,
carry_text: flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
}
}))
}
const nextContext = resolveNextActionContext(normalized)
if (!nextContext) {
return []
@@ -300,6 +340,70 @@ export function buildStewardSuggestedActions(plan) {
]
}
function normalizePendingFlowConfirmation(rawPlan = {}) {
const rawPending = rawPlan.pending_flow_confirmation || rawPlan.pendingFlowConfirmation || {}
const rawCandidates = Array.isArray(rawPlan.candidate_flows || rawPlan.candidateFlows)
? rawPlan.candidate_flows || rawPlan.candidateFlows
: rawPending?.candidate_flows || rawPending?.candidateFlows || []
const candidateFlows = Array.isArray(rawCandidates)
? rawCandidates
.map((item) => normalizeCandidateFlow(item))
.filter((item) => item.flowId)
: []
return {
status: String(rawPending?.status || '').trim(),
sourceMessage: String(rawPending?.source_message || rawPending?.sourceMessage || '').trim(),
reason: String(rawPending?.reason || '').trim(),
candidateFlows
}
}
function normalizeCandidateFlow(item = {}) {
const flowId = String(item.flow_id || item.flowId || '').trim()
if (!['travel_application', 'travel_reimbursement'].includes(flowId)) {
return { flowId: '' }
}
return {
flowId,
label: String(item.label || (flowId === 'travel_application' ? '补办出差申请' : '发起费用报销')).trim(),
confidence: Number(item.confidence || 0),
reason: String(item.reason || '').trim(),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields: Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: []
}
}
function isPendingFlowConfirmationPlan(normalized) {
return (
String(normalized?.nextAction || '').trim() === 'confirm_flow' ||
String(normalized?.planStatus || '').trim() === 'needs_flow_confirmation' ||
String(normalized?.pendingFlowConfirmation?.status || '').trim() === 'pending'
) && Array.isArray(normalized?.candidateFlows) && normalized.candidateFlows.length > 0
}
function buildPendingFlowConfirmationMessageText(normalized) {
const fields = normalized.candidateFlows[0]?.ontologyFields || {}
const knownParts = formatStewardOntologyFields(fields, 'expense_application')
const candidateLines = normalized.candidateFlows.map((flow, index) =>
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
)
return [
'### 需要先确认流程方向',
'',
knownParts
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。`
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
'',
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
'',
...candidateLines,
'',
'请先选择一个方向,我会继续整理对应材料。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask
@@ -508,6 +612,13 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? missingFields
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: missingFields
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {

View File

@@ -1,103 +0,0 @@
export const STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE = 3
export const STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE = 2
export function resolveStewardTypewriterNextIndex(chars = [], index = 0) {
const total = chars.length
const safeIndex = Math.max(0, Math.min(Number(index) || 0, total))
const tableStart = resolveMarkdownTableStart(chars, safeIndex)
if (tableStart >= 0) {
return resolveMarkdownTableBlockEnd(chars, tableStart)
}
const chunkSize = resolveStewardTypewriterChunkSize(chars, safeIndex)
const nextIndex = Math.min(total, safeIndex + chunkSize)
const crossedTableStart = findMarkdownTableLineStart(chars, safeIndex, nextIndex)
return crossedTableStart >= 0 ? crossedTableStart : nextIndex
}
function resolveStewardTypewriterChunkSize(chars = [], index = 0) {
const line = resolveCurrentTypewriterLine(chars, index)
const trimmed = line.trim()
if (!trimmed) return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
if (/^(#{1,6}\s+|[-*]\s+|\d+\.\s+)/.test(trimmed)) {
return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
}
return STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE
}
function resolveCurrentTypewriterLine(chars = [], index = 0) {
const safeIndex = Math.max(0, Math.min(Number(index) || 0, chars.length))
let start = safeIndex
while (start > 0 && chars[start - 1] !== '\n') {
start -= 1
}
let end = safeIndex
while (end < chars.length && chars[end] !== '\n') {
end += 1
}
return chars.slice(start, end).join('')
}
function resolveMarkdownTableStart(chars = [], index = 0) {
const currentLineStart = resolveCurrentLineStart(chars, index)
if (isMarkdownTableLine(resolveLine(chars, currentLineStart).trim())) {
return currentLineStart
}
if (chars[index] === '\n') {
const nextLineStart = index + 1
if (isMarkdownTableLine(resolveLine(chars, nextLineStart).trim())) {
return nextLineStart
}
}
return -1
}
function resolveMarkdownTableBlockEnd(chars = [], tableStart = 0) {
let cursor = tableStart
let blockEnd = tableStart
while (cursor < chars.length) {
const line = resolveLine(chars, cursor)
if (!isMarkdownTableLine(line.trim())) {
break
}
const lineEnd = cursor + line.length
blockEnd = chars[lineEnd] === '\n' ? lineEnd + 1 : lineEnd
cursor = blockEnd
}
return blockEnd
}
function findMarkdownTableLineStart(chars = [], start = 0, end = 0) {
const safeStart = Math.max(0, start)
const safeEnd = Math.min(chars.length, Math.max(safeStart, end))
for (let index = safeStart; index < safeEnd; index += 1) {
if (index !== 0 && chars[index - 1] !== '\n') continue
if (isMarkdownTableLine(resolveLine(chars, index).trim())) {
return index
}
}
return -1
}
function resolveCurrentLineStart(chars = [], index = 0) {
let start = Math.max(0, Math.min(Number(index) || 0, chars.length))
while (start > 0 && chars[start - 1] !== '\n') {
start -= 1
}
return start
}
function resolveLine(chars = [], start = 0) {
let end = Math.max(0, Math.min(Number(start) || 0, chars.length))
while (end < chars.length && chars[end] !== '\n') {
end += 1
}
return chars.slice(start, end).join('')
}
function isMarkdownTableLine(line = '') {
if (!line.includes('|')) return false
if (/^\|?[\s:|-]+\|[\s:|-]+/.test(line)) return true
return /^\|.+\|$/.test(line) || line.split('|').length >= 3
}

View File

@@ -1,255 +0,0 @@
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE
} from './travelReimbursementConversationModel.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
export function buildStewardContinuationAfterAction({
createMessage,
message,
completedLabel = '当前动作已完成'
}) {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_current_task: nextTask,
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardFollowupNextTitle(text = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
const nextMatch = String(text || '').match(/下一步[:]([^。\n]+)/u)
return nextMatch?.[1]?.trim() || '下一项财务任务'
}
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const firstAction = Array.isArray(actions) ? actions[0] : null
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
const carryText = String(actionPayload.carry_text || '').trim()
const finalText = String(finalMessage?.text || '').trim()
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: finalText.includes('申请单已完成')
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
},
{
eventId: `${eventPrefix}-next`,
title: '读取剩余任务',
content: nextSummary
? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}`
: `剩余队列里的下一项是“${nextTitle}”。`
},
{
eventId: `${eventPrefix}-gate`,
title: '判断下一步条件',
content: nextMissing
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
export async function pushStewardContinuationMessage({
finalMessage,
messages,
nextTick,
persistSessionState,
scrollToBottom
}) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
index = Math.min(chars.length, index + STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index)
finalMessage.text = chars.slice(0, index).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
nextTick(scrollToBottom)
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
export function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(
task?.missing_fields || task?.missingFields || [],
taskType,
{ includeHints: false }
)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
missingFields
? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。'
: '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
export function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}

View File

@@ -1,77 +0,0 @@
export const STEWARD_ASSISTANT_NAME = '小财管家'
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN = /^(确认|确定|确认提交|确定提交|提交|提交审批|确认审批|确认无误|核对无误|信息无误|无误|没问题|可以提交|确认进入审批|提交至审批流程|确认提交审批|同意提交)$/
const APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN = /^(不|否|否定|取消|暂不|先不|不确认|不提交|再检查|再看看|等等|等一下)/
const STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN = /^(继续|继续执行|下一步|继续下一步|开始下一步|处理下一项|继续处理|确认开始|确定开始|可以|好的|好|行)$/
const STEWARD_RUNTIME_CANCEL_TEXT_PATTERN = /^(取消|暂不|先不|不用|不要|不继续|不处理|先等等|等一下|停止|终止|算了)$/
const STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH = 12
const STEWARD_RUNTIME_BUSINESS_HINT_PATTERN = /(申请|报销|出差|差旅|招待|交通费|住宿费|餐费|发票|票据|费用|预算|借款|付款|审批|审核)/
const STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN = /(今天|明天|后天|昨天|前天|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}|我要|帮我|需要|创建|填写|处理|去|前往)/
const STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN = /(当前|这个|这一步|上面|上述|申请单|核对表|出行方式|交通方式|火车|高铁|动车|飞机|轮船|提交|审批|确认)/
export function isApplicationSubmitConfirmationText(value = '') {
const normalized = String(value || '')
.replace(/\s+/g, '')
.replace(/[,。.!?;:]/g, '')
if (!normalized || APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN.test(normalized)) {
return false
}
return APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN.test(normalized)
}
export function normalizeStewardRuntimeInputText(value = '') {
return String(value || '')
.replace(/\s+/g, '')
.replace(/[,。.!?;:]/g, '')
.trim()
}
export function isStewardRuntimeContinueText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN.test(normalized))
}
export function isStewardRuntimeCancelText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CANCEL_TEXT_PATTERN.test(normalized))
}
export function resolveStewardRuntimeTransportAlias(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
if (!normalized) {
return ''
}
const matchedModes = []
if (/火车|高铁|动车|列车|铁路/.test(normalized)) {
matchedModes.push('火车')
}
if (/飞机|机票|航班|航空/.test(normalized)) {
matchedModes.push('飞机')
}
if (/轮船|船票|客轮|渡轮|坐船/.test(normalized)) {
matchedModes.push('轮船')
}
return matchedModes.length === 1 ? matchedModes[0] : ''
}
export function shouldPlanNewStewardTasksLocally(rawText = '', runtimeState = {}) {
const text = String(rawText || '').trim()
const normalized = normalizeStewardRuntimeInputText(text)
if (
normalized.length < STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH ||
isApplicationSubmitConfirmationText(normalized) ||
isStewardRuntimeContinueText(normalized) ||
isStewardRuntimeCancelText(normalized)
) {
return false
}
if (!STEWARD_RUNTIME_BUSINESS_HINT_PATTERN.test(text) || !STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN.test(text)) {
return false
}
const waitingFor = String(runtimeState?.waiting_for || '').trim()
if (waitingFor && STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN.test(text)) {
return false
}
return true
}

View File

@@ -29,6 +29,19 @@ function normalizeText(value) {
return String(value || '').trim()
}
function normalizeRiskCardTitle(value, fallback = '单据风险提示') {
const title = normalizeText(value)
if (!title) {
return fallback
}
const normalizedTitle = title.replace(/^(?:AI\s*提示|AI\s*建议|智能提示|系统提示)\s*[:]\s*/i, '').trim()
if (!normalizedTitle || /^(?:AI\s*提示|AI\s*建议|智能提示|系统提示)$/i.test(normalizedTitle)) {
return fallback
}
return normalizedTitle
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
@@ -190,47 +203,6 @@ function normalizeIdList(value) {
return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))]
}
function buildExpenseItemIndexMap(expenseItems = []) {
const itemIndexById = new Map()
;(Array.isArray(expenseItems) ? expenseItems : []).forEach((item, index) => {
const itemId = normalizeId(item?.id)
if (itemId && !itemIndexById.has(itemId)) {
itemIndexById.set(itemId, index + 1)
}
})
return itemIndexById
}
function resolveRiskItemNumbers({ itemId = '', itemIds = [], itemIndex = null } = {}, expenseItems = []) {
const itemIndexById = buildExpenseItemIndexMap(expenseItems)
const itemNumbers = []
const explicitItemIndex = Number(itemIndex)
if (Number.isFinite(explicitItemIndex) && explicitItemIndex > 0) {
itemNumbers.push(Math.floor(explicitItemIndex))
}
const relatedItemIds = uniqueTexts([
normalizeId(itemId),
...normalizeIdList(itemIds)
])
relatedItemIds.forEach((relatedItemId) => {
const resolvedIndex = itemIndexById.get(relatedItemId)
if (resolvedIndex) {
itemNumbers.push(resolvedIndex)
}
})
return [...new Set(itemNumbers)].sort((left, right) => left - right)
}
function buildRiskTitleWithItemNumbers(title, itemNumbers = []) {
const cleanTitle = normalizeText(title) || '单据风险提示'
if (!itemNumbers.length || /^第\s*[\d、,\s]+\s*条[:]/.test(cleanTitle)) {
return cleanTitle
}
return `${itemNumbers.join('、')} 条:${cleanTitle}`
}
function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) {
@@ -489,7 +461,10 @@ function buildCardSuggestion(analysis, insight) {
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) {
const tone = normalizeTone(analysis?.severity)
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险'
const title = normalizeRiskCardTitle(
normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name),
'附件风险'
)
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
@@ -499,7 +474,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone,
label: resolveRiskLevelLabel(tone),
title: `${index + 1} 条:${title}`,
title,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
@@ -760,14 +735,9 @@ export function buildAttachmentRiskCards({
? flagItemIds
: inferRelatedItemIdsForRisk(flag, risks, expenseItems)
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
const relatedItemNumbers = resolveRiskItemNumbers({
itemId: flagItemId,
itemIds: relatedItemIds,
itemIndex
}, expenseItems)
const title = buildRiskTitleWithItemNumbers(
normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
relatedItemNumbers
const title = normalizeRiskCardTitle(
flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code,
'单据风险提示'
)
const summary = normalizeText(flag.summary || flag.message || flag.reason)
const ruleBasis = resolveClaimRiskRuleBasis(flag, {

View File

@@ -4,17 +4,18 @@ import {
buildStewardSuggestedActions,
normalizeStewardPlan
} from './stewardPlanModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
export function useStewardPlanFlow({
activeSessionType,
attachedFiles,
composerDraft,
conversationId,
currentUser,
fileInputRef,
messages,
@@ -30,6 +31,7 @@ export function useStewardPlanFlow({
submitting,
reviewActionBusy,
sessionSwitchBusy,
stewardState,
toast
}) {
const stewardTypewriterTimers = new Map()
@@ -108,9 +110,19 @@ export function useStewardPlanFlow({
const requestPayload = buildStewardPlanRequest({
rawText,
files,
currentUser: currentUser.value || {}
currentUser: currentUser.value || {},
conversationId: conversationId?.value || '',
stewardState: stewardState?.value || null
})
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId)
const nextConversationId = String(plan?.conversation_id || plan?.conversationId || '').trim()
if (nextConversationId && conversationId) {
conversationId.value = nextConversationId
}
const nextStewardState = plan?.steward_state || plan?.stewardState || null
if (nextStewardState && typeof nextStewardState === 'object' && stewardState) {
stewardState.value = nextStewardState
}
await waitForStewardThinkingQueue(streamRunId)
const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id)
const normalizedPlan = normalizeStewardPlan(plan, {
@@ -176,7 +188,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index = resolveStewardTypewriterNextIndex(chars, index)
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -188,7 +200,9 @@ export function useStewardPlanFlow({
...normalizedPlan,
streamStatus: 'typing'
}
nextTick(scrollToBottom)
if (index % 4 === 0 || index === total) {
nextTick(scrollToBottom)
}
}
const message = messages.value.find((item) => item.id === messageId)

View File

@@ -601,7 +601,7 @@ export function useTravelReimbursementFlow({
startFlowStep('pre-submit-review', {
title: '自动检测与风险识别',
tool: 'ExpenseClaimService.submit_claim',
detail: '正在校验基础规则、风险规则和审批路径...'
detail: '正在校验财务规则、风险规则和审批路径...'
})
}
@@ -847,7 +847,7 @@ export function useTravelReimbursementFlow({
if (String(response.status || '').trim() === 'submitted') {
return isApplicationSessionActive()
? '申请单提交成功'
: `已完成基础规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'

View File

@@ -126,6 +126,10 @@ export function useTravelReimbursementSessionState({
return buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
}
function normalizeStewardState(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
}
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const sessionType = resolveAccessibleSessionType(
resolveInitialSessionType(conversation, fallbackSessionType),
@@ -139,6 +143,7 @@ export function useTravelReimbursementSessionState({
sessionType,
messages: buildSessionMessages(restoredMessages, sessionType),
conversationId: resolveInitialConversationId(conversation),
stewardState: normalizeStewardState(conversation?.state_json?.steward_state || conversation?.stateJson?.stewardState),
draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: isStewardSessionType(sessionType)
? buildSessionInsight(sessionType)
@@ -162,6 +167,7 @@ export function useTravelReimbursementSessionState({
sessionType: normalizedSessionType,
messages: buildSessionMessages([], normalizedSessionType),
conversationId: '',
stewardState: null,
draftClaimId: '',
currentInsight: buildSessionInsight(normalizedSessionType),
reviewFilePreviews: [],
@@ -197,6 +203,7 @@ export function useTravelReimbursementSessionState({
sessionType,
messages: buildSessionMessages(restoredMessages, sessionType),
conversationId: String(state.conversationId || '').trim(),
stewardState: normalizeStewardState(state.stewardState),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: isStewardSessionType(sessionType)
? buildSessionInsight(sessionType)
@@ -248,6 +255,7 @@ export function useTravelReimbursementSessionState({
const activeSessionType = ref(initialSessionState.sessionType)
const messages = ref(initialSessionState.messages)
const conversationId = ref(initialSessionState.conversationId)
const stewardState = ref(normalizeStewardState(initialSessionState.stewardState))
const draftClaimId = ref(initialSessionState.draftClaimId)
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
const sessionSnapshots = ref(
@@ -268,6 +276,7 @@ export function useTravelReimbursementSessionState({
sessionType: resolveAccessibleSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
messages: serializeSessionMessages(state.messages),
conversationId: String(state.conversationId || '').trim(),
stewardState: normalizeStewardState(state.stewardState),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: state.currentInsight || null,
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
@@ -289,6 +298,7 @@ export function useTravelReimbursementSessionState({
String(persistedState.conversationId || '').trim()
|| String(persistedState.draftClaimId || '').trim()
|| hasMeaningfulSessionMessages(persistedState.messages)
|| Boolean(persistedState.stewardState)
|| String(persistedState.composerDraft || '').trim()
)
@@ -306,6 +316,7 @@ export function useTravelReimbursementSessionState({
sessionType: activeSessionType.value,
messages: messages.value,
conversationId: conversationId.value,
stewardState: stewardState.value,
draftClaimId: draftClaimId.value,
currentInsight: currentInsight.value,
reviewFilePreviews: reviewFilePreviews.value,
@@ -330,6 +341,7 @@ export function useTravelReimbursementSessionState({
activeSessionType.value
)
conversationId.value = String(nextState.conversationId || '').trim()
stewardState.value = normalizeStewardState(nextState.stewardState)
draftClaimId.value = String(nextState.draftClaimId || '').trim()
currentInsight.value = isStewardSessionType(activeSessionType.value)
? buildSessionInsight(activeSessionType.value)
@@ -399,6 +411,7 @@ export function useTravelReimbursementSessionState({
activeSessionType,
messages,
conversationId,
stewardState,
draftClaimId,
sessionSnapshots,
currentInsight,

View File

@@ -1,775 +0,0 @@
import { fetchStewardRuntimeDecision } from '../../services/steward.js'
import {
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
buildTravelPlanningNudgeMessage,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_STEWARD
} from './travelReimbursementConversationModel.js'
import {
APPLICATION_PREVIEW_FIELD_ACTION_SET,
STEWARD_ASSISTANT_NAME,
isApplicationSubmitConfirmationText,
isStewardRuntimeCancelText,
isStewardRuntimeContinueText,
normalizeStewardRuntimeInputText,
resolveStewardRuntimeTransportAlias,
shouldPlanNewStewardTasksLocally
} from './travelReimbursementStewardRuntimeTextModel.js'
import {
buildStewardContinuationAfterAction,
pushStewardContinuationMessage,
resolveStewardMissingFieldItems
} from './travelReimbursementStewardFollowupFlow.js'
export { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
export function useTravelReimbursementStewardRuntime(ctx) {
const {
activeSessionType,
applicationSubmitConfirmDialog,
attachedFiles,
composerDraft,
createMessage,
currentUser,
emit,
handleSuggestedAction,
isStewardSession,
linkedRequest,
messages,
nextTick,
persistSessionState,
props,
reviewActionBusy,
scrollToBottom,
sessionSwitchBusy,
submitComposer,
submitStewardPlan,
submitting,
toast,
adjustComposerTextareaHeight,
resolveCurrentUserId
} = ctx
function findLatestApplicationPreviewMessage() {
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
!message.applicationPreview ||
message.applicationSubmitConfirmed
) {
continue
}
return message
}
return null
}
function findPendingApplicationSubmitMessage() {
const message = findLatestApplicationPreviewMessage()
if (!message) {
return null
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
if (normalizedPreview.readyToSubmit) {
message.applicationPreview = normalizedPreview
return message
}
return null
}
function pushApplicationSubmitBlockedMessage(userText = '', message = null, options = {}) {
const normalizedPreview = normalizeApplicationPreview(message?.applicationPreview || {})
const missingFields = Array.isArray(normalizedPreview.missingFields)
? normalizedPreview.missingFields
: []
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
? normalizedPreview.validationIssues
: []
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
messages.value.push(createMessage(
'assistant',
[
'我理解你是在确认当前申请单,但这张申请单还不能提交。',
'',
missingFields.length
? `还需要先补充:**${missingFields.join('、')}**。`
: validationIssues.length
? `需要先修正:**${validationIssues[0].message}**`
: '请先把申请核对表中的待补充信息补齐。',
'',
'补齐后再输入“确认”,我会继续提交至审批流程。'
].join('\n'),
[],
{
assistantName: String(message?.assistantName || '').trim() || undefined,
meta: ['等待补充']
}
))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
async function handleApplicationSubmitConfirmationText(options = {}) {
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!isApplicationSubmitConfirmationText(rawText) || files.length) {
return false
}
const latestApplicationMessage = findLatestApplicationPreviewMessage()
if (!latestApplicationMessage) {
return false
}
const targetMessage = findPendingApplicationSubmitMessage()
if (!targetMessage) {
pushApplicationSubmitBlockedMessage(rawText, latestApplicationMessage)
return true
}
applicationSubmitConfirmDialog.value = {
open: true,
message: targetMessage
}
await confirmApplicationSubmit({ userText: rawText })
return true
}
function findPendingStewardSuggestedActionContext(decision = null) {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetTaskId = String(decision?.target_task_id || decision?.targetTaskId || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
if (targetMessageId && String(message.id || '') !== targetMessageId) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
return !targetTaskId ||
String(payload.steward_next_task_id || payload.target_task_id || '').trim() === targetTaskId
}) || message.suggestedActions[0]
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContext(decision = null) {
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
const payloadField = String(payload.field_key || payload.fieldKey || '').trim()
const payloadValue = String(payload.value || item?.label || '').trim()
return payloadField && (!fieldKey || payloadField === fieldKey) && (!fieldValue || payloadValue === fieldValue)
})
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContextByInput(rawText = '') {
const normalizedInput = normalizeStewardRuntimeInputText(rawText)
if (!normalizedInput) {
return null
}
const transportAlias = resolveStewardRuntimeTransportAlias(normalizedInput)
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const exactMatches = []
const fuzzyMatches = []
message.suggestedActions.forEach((action) => {
if (String(action?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return
}
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
const value = String(payload.value || action?.label || '').trim()
const label = String(action?.label || value).trim()
const tokens = [value, label]
.map((item) => normalizeStewardRuntimeInputText(item))
.filter(Boolean)
if (!fieldKey || !value || !tokens.length) {
return
}
if (tokens.includes(normalizedInput)) {
exactMatches.push({ message, action })
return
}
const actionTransportAlias = resolveStewardRuntimeTransportAlias(`${value}${label}`)
if (
transportAlias &&
(
tokens.includes(normalizeStewardRuntimeInputText(transportAlias)) ||
actionTransportAlias === transportAlias
)
) {
fuzzyMatches.push({ message, action })
return
}
if (tokens.some((token) => token.length >= 2 && normalizedInput.includes(token))) {
fuzzyMatches.push({ message, action })
}
})
if (exactMatches.length === 1) {
return exactMatches[0]
}
if (exactMatches.length > 1) {
return null
}
const uniqueFuzzyMatches = fuzzyMatches.filter((item, index, list) =>
list.findIndex((candidate) => candidate.action === item.action) === index
)
if (uniqueFuzzyMatches.length === 1) {
return uniqueFuzzyMatches[0]
}
if (uniqueFuzzyMatches.length > 1) {
return null
}
}
return null
}
function buildStewardRuntimeState() {
const latestApplicationMessage = findLatestApplicationPreviewMessage()
const applicationPreview = latestApplicationMessage?.applicationPreview
? normalizeApplicationPreview(latestApplicationMessage.applicationPreview)
: null
const applicationContinuation = latestApplicationMessage?.stewardContinuation || null
const pendingSlotContext = findPendingSlotSuggestedActionContext()
const pendingStewardContext = pendingSlotContext ? null : findPendingStewardSuggestedActionContext()
const pendingActionPayload = pendingStewardContext?.action?.payload && typeof pendingStewardContext.action.payload === 'object'
? pendingStewardContext.action.payload
: {}
const pendingSlotPayload = pendingSlotContext?.action?.payload && typeof pendingSlotContext.action.payload === 'object'
? pendingSlotContext.action.payload
: {}
const continuation = applicationContinuation || pendingStewardContext?.message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
const pendingApplication = latestApplicationMessage && applicationPreview
? {
message_id: String(latestApplicationMessage.id || '').trim(),
task_id: String(
applicationContinuation?.currentTaskId ||
applicationContinuation?.current_task_id ||
applicationContinuation?.currentTask?.task_id ||
applicationContinuation?.currentTask?.taskId ||
''
).trim(),
ready_to_submit: Boolean(applicationPreview.readyToSubmit),
missing_fields: Array.isArray(applicationPreview.missingFields) ? applicationPreview.missingFields : [],
fields: applicationPreview.fields || {}
}
: null
return {
waiting_for: pendingApplication
? (pendingApplication.ready_to_submit ? 'application_submit_confirmation' : 'application_field_completion')
: pendingSlotContext
? 'application_field_completion'
: pendingStewardContext
? 'steward_next_task_confirmation'
: '',
current_task: continuation?.currentTask || continuation?.current_task || null,
remaining_tasks: remainingTasks,
completed_tasks: messages.value
.filter((message) => message?.applicationSubmitConfirmed)
.map((message) => ({
message_id: String(message.id || '').trim(),
task_type: 'expense_application'
})),
pending_application: pendingApplication,
pending_steward_action: pendingStewardContext
? {
message_id: String(pendingStewardContext.message?.id || '').trim(),
action_type: String(pendingStewardContext.action?.action_type || '').trim(),
label: String(pendingStewardContext.action?.label || '').trim(),
target_task_id: String(pendingActionPayload.steward_next_task_id || pendingActionPayload.target_task_id || '').trim(),
payload: pendingActionPayload
}
: null,
pending_slot_action: pendingSlotContext
? {
message_id: String(pendingSlotContext.message?.id || '').trim(),
field_key: String(pendingSlotPayload.field_key || pendingSlotPayload.fieldKey || '').trim(),
label: String(pendingSlotContext.action?.label || '').trim(),
payload: pendingSlotPayload
}
: null
}
}
function hasActiveStewardRuntimeDecisionContext(runtimeState = {}) {
return Boolean(
String(runtimeState?.waiting_for || '').trim() ||
runtimeState?.pending_application ||
runtimeState?.pending_steward_action ||
runtimeState?.pending_slot_action ||
runtimeState?.current_task ||
(Array.isArray(runtimeState?.remaining_tasks) && runtimeState.remaining_tasks.length > 0) ||
(Array.isArray(runtimeState?.completed_tasks) && runtimeState.completed_tasks.length > 0)
)
}
function pushStewardRuntimeUserMessage(userText = '') {
const normalizedText = String(userText || '').trim()
if (!normalizedText) {
return false
}
messages.value.push(createMessage('user', normalizedText))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return true
}
function pushStewardRuntimeResponse(userText = '', decision = null, options = {}) {
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
const text = String(decision?.question || decision?.response_text || decision?.responseText || decision?.rationale || '').trim()
if (text) {
messages.value.push(createMessage('assistant', text, [], {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME]
}))
}
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
function buildStewardRuntimeFastPathDecision(rawText = '', runtimeState = {}) {
const normalizedText = String(rawText || '').trim()
if (!normalizedText) {
return null
}
if (shouldPlanNewStewardTasksLocally(normalizedText, runtimeState)) {
return {
next_action: 'plan_new_tasks'
}
}
if (isStewardRuntimeCancelText(normalizedText)) {
return {
next_action: 'cancel_current_action',
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。'
}
}
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
const payload = slotContext?.action?.payload && typeof slotContext.action.payload === 'object'
? slotContext.action.payload
: {}
if (slotContext) {
return {
next_action: 'fill_current_slot',
target_message_id: String(slotContext.message?.id || '').trim(),
field_key: String(payload.field_key || payload.fieldKey || '').trim(),
field_value: String(payload.value || slotContext.action?.label || normalizedText).trim()
}
}
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
if (runtimeState?.pending_application?.ready_to_submit) {
return {
next_action: 'submit_current_application',
target_message_id: runtimeState.pending_application.message_id || ''
}
}
if (runtimeState?.pending_steward_action) {
return {
next_action: 'continue_next_task',
target_message_id: runtimeState.pending_steward_action.message_id || '',
target_task_id: runtimeState.pending_steward_action.target_task_id || ''
}
}
}
if (String(runtimeState?.waiting_for || '').trim() === 'application_field_completion') {
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
const missingFields = Array.isArray(runtimeState?.pending_application?.missing_fields)
? runtimeState.pending_application.missing_fields
: []
return {
next_action: 'ask_user',
response_text: missingFields.length
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。`
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
}
}
}
return null
}
function shouldUseStewardRuntimeLlmDecision(rawText = '', runtimeState = {}) {
if (shouldPlanNewStewardTasksLocally(rawText, runtimeState)) {
return false
}
const normalizedText = normalizeStewardRuntimeInputText(rawText)
if (!normalizedText) {
return false
}
if (
isApplicationSubmitConfirmationText(normalizedText) ||
isStewardRuntimeContinueText(normalizedText) ||
isStewardRuntimeCancelText(normalizedText)
) {
return false
}
if (
findPendingSlotSuggestedActionContextByInput(normalizedText)
) {
return false
}
return true
}
async function executeStewardRuntimeDecision(decision = null, rawText = '', options = {}) {
const nextAction = String(decision?.next_action || decision?.nextAction || '').trim()
const userMessageAlreadyAdded = Boolean(options.userMessageAlreadyAdded)
if (nextAction === 'submit_current_application') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findPendingApplicationSubmitMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
if (!normalizedPreview.readyToSubmit) {
pushApplicationSubmitBlockedMessage(rawText, targetMessage, { userMessageAlreadyAdded })
return true
}
targetMessage.applicationPreview = normalizedPreview
applicationSubmitConfirmDialog.value = { open: true, message: targetMessage }
await confirmApplicationSubmit({ userText: rawText, skipUserMessage: userMessageAlreadyAdded })
return true
}
if (nextAction === 'continue_next_task') {
const context = findPendingStewardSuggestedActionContext(decision)
if (!context) {
return false
}
if (rawText && !userMessageAlreadyAdded) {
messages.value.push(createMessage('user', rawText))
}
context.action.confirmedByText = true
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
await handleSuggestedAction(context.message, context.action)
return true
}
if (nextAction === 'fill_current_slot') {
const context = findPendingSlotSuggestedActionContext(decision)
if (!context) {
return false
}
await handleSuggestedAction(context.message, {
...context.action,
label: String(decision?.field_value || decision?.fieldValue || context.action.label || '').trim(),
suppressUserEcho: userMessageAlreadyAdded
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true
}
return false
}
async function handleStewardRuntimeDecision(options = {}) {
if (!isStewardSession.value || options.skipStewardPlan) {
return false
}
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!rawText || files.length) {
return false
}
const runtimeState = buildStewardRuntimeState()
if (!hasActiveStewardRuntimeDecisionContext(runtimeState)) {
return false
}
const userMessageAlreadyAdded = options.skipUserMessage
? false
: pushStewardRuntimeUserMessage(rawText)
try {
const fastDecision = buildStewardRuntimeFastPathDecision(rawText, runtimeState)
if (fastDecision) {
if (String(fastDecision.next_action || fastDecision.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const fastExecuted = await executeStewardRuntimeDecision(fastDecision, rawText, { userMessageAlreadyAdded })
if (fastExecuted) {
return true
}
}
if (!shouldUseStewardRuntimeLlmDecision(rawText, runtimeState)) {
if (userMessageAlreadyAdded) {
pushStewardRuntimeResponse('', {
response_text: '我还需要先确认当前等待项。请回复系统刚刚追问的选项或具体补充内容。'
}, { userMessageAlreadyAdded: true })
return true
}
return false
}
const decision = await fetchStewardRuntimeDecision({
user_message: rawText,
session_type: SESSION_TYPE_STEWARD,
runtime_state: runtimeState,
context_json: {
entry_source: props.entrySource,
user_id: resolveCurrentUserId()
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家运行时决策超时,已回到当前上下文兜底处理。'
})
if (String(decision?.next_action || decision?.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const executed = await executeStewardRuntimeDecision(decision, rawText, { userMessageAlreadyAdded })
if (executed) {
return true
}
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
} catch (error) {
console.warn('Steward runtime decision failed:', error)
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
}
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
? normalizedPreview.validationIssues
: []
toast(
validationIssues.length
? validationIssues[0].message
: `请先补充:${normalizedPreview.missingFields.join('、')}`
)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
async function confirmApplicationSubmit(options = {}) {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
const stewardSubmitContinuation = message?.stewardContinuation || null
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: String(options.userText || '').trim() || '确认提交',
skipUserMessage: Boolean(options.skipUserMessage),
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
stewardContinuation: stewardSubmitContinuation,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
message.applicationSubmitConfirmed = true
emit('draft-saved', {
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction({
createMessage,
message,
completedLabel: '申请单已完成'
})
if (stewardFollowup) {
await pushStewardContinuationMessage({
finalMessage: stewardFollowup,
messages,
nextTick,
persistSessionState,
scrollToBottom
})
}
} finally {
reviewActionBusy.value = false
}
}
return {
closeApplicationSubmitConfirm,
confirmApplicationSubmit,
handleApplicationSubmitConfirmationText,
handleStewardRuntimeDecision,
isApplicationSubmitConfirmationText,
openApplicationSubmitConfirm,
resolveStewardMissingFieldItems
}
}

View File

@@ -17,9 +17,9 @@ import {
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
shouldRequireApplicationModelReview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
@@ -29,11 +29,11 @@ import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
@@ -44,13 +44,6 @@ const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
transportEstimatedAmount: 'transport_estimated_amount',
trainEstimatedAmount: 'train_estimated_amount',
flightEstimatedAmount: 'flight_estimated_amount',
hotelAmount: 'hotel_amount',
allowanceAmount: 'allowance_amount',
policyTotalAmount: 'policy_total_amount',
reimbursementAmount: 'reimbursement_amount',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
@@ -82,13 +75,6 @@ const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
transport_estimated_amount: 'transportEstimatedAmount',
train_estimated_amount: 'trainEstimatedAmount',
flight_estimated_amount: 'flightEstimatedAmount',
hotel_amount: 'hotelAmount',
allowance_amount: 'allowanceAmount',
policy_total_amount: 'policyTotalAmount',
reimbursement_amount: 'reimbursementAmount',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
@@ -101,13 +87,6 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
transport_estimated_amount: '交通费用预估',
train_estimated_amount: '火车费用预估',
flight_estimated_amount: '飞机费用预估',
hotel_amount: '住宿测算金额',
allowance_amount: '出差补贴金额',
policy_total_amount: '规则测算合计',
reimbursement_amount: '实际报销金额',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
@@ -118,13 +97,6 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'transport_estimated_amount',
'train_estimated_amount',
'flight_estimated_amount',
'hotel_amount',
'allowance_amount',
'policy_total_amount',
'reimbursement_amount',
'attachments',
'employee_no',
'department_name',
@@ -628,11 +600,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
@@ -725,10 +710,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
]
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '判断待补充信息',
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
@@ -821,11 +809,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const chars = Array.from(text)
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index)
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
nextTick(scrollToBottom)
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
Object.assign(message, finalExtras, {
@@ -849,39 +839,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function isSubmittedApplicationDraftPayload(draftPayload) {
return (
isApplicationDraftPayload(draftPayload)
String(draftPayload?.draft_type || '').trim() === 'expense_application'
&& String(draftPayload?.status || '').trim() === 'submitted'
)
}
function shouldExposeReviewPayloadForMessage(payload, options = {}) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) {
return false
}
return true
}
function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) {
if (exposeReviewPayload) {
return payload
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
return {
...payload,
result: {
...result,
review_payload: null
}
}
}
function buildOperationFeedbackState(context) {
if (!context) {
return null
@@ -1226,6 +1190,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
@@ -1234,8 +1204,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
const requireModelReview = shouldRequireApplicationModelReview(rawText)
if (options.skipModelReview && !requireModelReview) {
if (options.skipModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
@@ -2073,31 +2042,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
})
: null
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation })
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload })
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object'
? presentationPayload.result
: {}
const resultReviewPayload = presentationResult.review_payload || null
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions)
? presentationResult.suggested_actions
: []
const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], {
meta: buildMessageMeta(presentationPayload, effectiveFileNames),
citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [],
suggestedActions: resultSuggestedActions,
queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload),
draftPayload: presentationResult.draft_payload || null,
reviewPayload: resultReviewPayload,
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
reviewPanelScope: stewardDelegated
? ''
: resolveReviewPanelScope({
reviewPayload: resultReviewPayload,
reviewPayload: payload?.result?.review_payload || null,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [],
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null
@@ -2122,7 +2084,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} else {
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
presentationPayload,
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildStewardSuggestedActions
} from '../src/views/scripts/stewardPlanModel.js'
test('steward pending flow confirmation builds candidate actions', () => {
const actions = buildStewardSuggestedActions({
plan_id: 'steward-plan-pending-flow',
plan_status: 'needs_flow_confirmation',
next_action: 'confirm_flow',
pending_flow_confirmation: {
status: 'pending',
reason: '缺少申请或报销动作词。',
candidate_flows: [
{
flow_id: 'travel_application',
label: '补办出差申请',
confidence: 0.52,
ontology_fields: {
time_range: '2026-02-20',
location: '上海',
expense_type: 'travel',
reason: '辅助国网仿生产环境部署'
},
missing_fields: ['transport_mode']
},
{
flow_id: 'travel_reimbursement',
label: '发起费用报销',
confidence: 0.48,
ontology_fields: {
time_range: '2026-02-20',
location: '上海',
expense_type: 'travel',
reason: '辅助国网仿生产环境部署'
},
missing_fields: []
}
]
}
})
assert.equal(actions.length, 2)
assert.deepEqual(actions.map((item) => item.label), ['补办出差申请', '发起费用报销'])
assert.equal(actions[0].payload.steward_confirm_flow, true)
assert.equal(actions[0].payload.flow_id, 'travel_application')
assert.equal(actions[1].payload.flow_id, 'travel_reimbursement')
})

View File

@@ -438,7 +438,7 @@ test('AI advice hides generic auto review summaries when a specific hotel over-s
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].title, '第 1 条AI提示住宿金额超出报销标准')
assert.equal(riskCards[0].title, '住宿金额超出报销标准')
assert.equal(riskCards[0].tone, 'high')
})
@@ -636,7 +636,7 @@ test('route-level risk cards keep related item ids for every affected expense ro
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['travel-item-2', 'travel-item-3'])
assert.equal(riskCards[0].title, '第 2、3 条:多城市行程待说明')
assert.equal(riskCards[0].title, '多城市行程待说明')
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
})
@@ -810,10 +810,35 @@ test('expense detail table shows each item filled time from item creation time',
test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /<EnterpriseSelect[\s\S]*class="editor-select"/)
assert.match(detailViewTemplate, /<ElDatePicker[\s\S]*v-model="expenseEditor\.itemDate"/)
assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemReason"/)
assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemAmount"/)
assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemNote"[\s\S]*type="textarea"[\s\S]*:rows="1"/)
assert.doesNotMatch(detailViewTemplate, /<input[\s\S]*v-model="expenseEditor\./)
assert.doesNotMatch(detailViewTemplate, /<textarea[\s\S]*v-model="expenseEditor\./)
assert.match(detailViewScript, /import \{ ElDatePicker \} from 'element-plus\/es\/components\/date-picker\/index\.mjs'/)
assert.match(detailViewScript, /import \{ ElInput \} from 'element-plus\/es\/components\/input\/index\.mjs'/)
assert.match(detailViewScript, /ElDatePicker,[\s\S]*ElInput,/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
assert.match(detailViewTemplate, /class="editor-textarea risk-note-editor-textarea"[\s\S]*rows="1"/)
assert.match(detailViewTemplate, /@input="resizeExpenseNoteInput"/)
assert.match(detailViewStyle, /\.risk-note-editor-textarea[\s\S]*max-height: 78px/)
assert.doesNotMatch(detailViewTemplate, /@input="resizeExpenseNoteInput"/)
assert.doesNotMatch(detailViewTemplate, /用于说明改签、绕行、超标、票据异常等情况/)
assert.match(detailViewStyle, /\.detail-expense-table \.col-type \{ width: 14%; \}/)
assert.match(detailViewStyle, /\.detail-expense-table \.col-attachment \{ width: 15%; \}/)
assert.match(detailViewStyle, /\.detail-expense-table \{[\s\S]*--expense-editor-control-height: 34px;[\s\S]*--expense-editor-control-line-height: 16px;/)
assert.match(detailViewStyle, /\.editor-control \{/)
assert.match(detailViewStyle, /\.editor-control:not\(\.risk-note-editor-input\),[\s\S]*\.editor-select \{[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__wrapper\),[\s\S]*\.editor-date-picker :deep\(\.el-input__wrapper\) \{/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__wrapper\),[\s\S]*height: var\(--expense-editor-control-height\);[\s\S]*line-height: var\(--expense-editor-control-line-height\);/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__inner\),[\s\S]*height: var\(--expense-editor-control-line-height\)( !important)?;[\s\S]*line-height: var\(--expense-editor-control-line-height\)( !important)?;/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__prefix\),[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.doesNotMatch(detailViewStyle, /\.editor-input,\s*\.editor-select,\s*\.editor-textarea \{/)
assert.match(detailViewStyle, /\.editor-select \{[\s\S]*padding: 0;[\s\S]*border: 0;/)
assert.match(detailViewStyle, /\.editor-select :deep\(\.el-select__wrapper\) \{[\s\S]*min-height: var\(--expense-editor-control-height\);[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.match(detailViewStyle, /\.risk-note-editor-input\.el-textarea \{[\s\S]*min-height: var\(--expense-editor-control-height\);[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.match(detailViewStyle, /\.risk-note-editor-input :deep\(\.el-textarea__inner\) \{[\s\S]*min-height: var\(--expense-editor-control-height\) !important;[\s\S]*height: var\(--expense-editor-control-height\);[\s\S]*line-height: var\(--expense-editor-control-line-height\)( !important)?;[\s\S]*max-height: calc\(var\(--expense-editor-control-height\) \+ var\(--expense-editor-control-line-height\) \* 2\)( !important)?;[\s\S]*resize: none( !important)?;/)
assert.doesNotMatch(detailViewScript, /resizeExpenseNoteInput/)
assert.doesNotMatch(detailViewScript, /scrollHeight/)
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)

View File

@@ -7,6 +7,7 @@ import {
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
ASSISTANT_SCOPE_SESSION_STEWARD,
inferAssistantScopeTarget
} from '../src/utils/assistantSessionScope.js'
import {
@@ -48,6 +49,10 @@ test('workbench prompt applies travel phrases to application assistant scope', (
inferAssistantScopeTarget('准备去国网现场做仿生产环境部署差旅3天'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
inferAssistantScopeTarget('2月20-23日去上海出差辅助国网仿生产环境部署'),
ASSISTANT_SCOPE_SESSION_STEWARD
)
assert.equal(
inferAssistantScopeTarget('我要报销去北京的费用'),
ASSISTANT_SCOPE_SESSION_EXPENSE
@@ -103,6 +108,14 @@ test('workbench model routing maps ontology result before entering assistant', (
),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
travelOntology,
'2月20-23日去上海出差辅助国网仿生产环境部署',
ASSISTANT_SCOPE_SESSION_APPLICATION
),
ASSISTANT_SCOPE_SESSION_STEWARD
)
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
reimbursementOntology,
@@ -128,3 +141,16 @@ test('workbench model routing maps ontology result before entering assistant', (
ASSISTANT_SCOPE_SESSION_APPLICATION
)
})
test('workbench ambiguous travel flow uses steward fast path before ontology parsing', () => {
const fastPathIndex = appShellComposable.indexOf(
'fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD'
)
const ontologyParseIndex = appShellComposable.indexOf('fetchOntologyParse(')
assert.ok(fastPathIndex >= 0, 'expected steward fallback fast path in smart entry routing')
assert.ok(
fastPathIndex < ontologyParseIndex,
'expected steward fallback to return before slow ontology parsing'
)
})