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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ export function createPreviewRuleDetailPayload() {
|
||||
config_json: {
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
tag: '财务规则',
|
||||
tag: '基础规则',
|
||||
detail_mode: 'spreadsheet',
|
||||
runtime_kind: 'travel_policy',
|
||||
scenario_category: '差旅',
|
||||
|
||||
@@ -125,7 +125,7 @@ export function buildStewardFieldCompletionRawText({
|
||||
'已识别信息:',
|
||||
...knownLines,
|
||||
'',
|
||||
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
||||
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
|
||||
].filter((line) => line !== '').join('\n')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) || '自动检测发现待补充项,暂未提交审批'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
50
web/tests/steward-plan-model-pending-flow.test.mjs
Normal file
50
web/tests/steward-plan-model-pending-flow.test.mjs
Normal 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')
|
||||
})
|
||||
@@ -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 \|\| ''/)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user