feat(ai): add personal expense application memory

This commit is contained in:
caoxiaozhu
2026-07-14 17:16:23 +08:00
parent 211f85d981
commit 54754b5502
36 changed files with 3579 additions and 74 deletions

View File

@@ -0,0 +1,203 @@
.application-memory-stack {
display: grid;
}
.application-memory-panel,
.application-learning-receipt-panel {
margin-top: 14px;
padding: 12px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
border-radius: 8px;
background: color-mix(in srgb, var(--theme-primary-soft, #eaf4fa) 52%, #ffffff);
}
.application-memory-panel-head,
.application-learning-receipt-panel > header {
display: flex;
align-items: flex-start;
gap: 9px;
}
.application-memory-panel-icon {
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
border-radius: 6px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
color: var(--theme-primary-active, #255b7d);
font-size: 16px;
}
.application-memory-panel-head > span:last-child {
min-width: 0;
display: grid;
gap: 3px;
}
.application-memory-panel-head strong,
.application-learning-receipt-panel > header strong {
color: #17324a;
font-size: 13px;
font-weight: 850;
line-height: 1.4;
}
.application-memory-panel-head small {
color: #52677a;
font-size: 12px;
line-height: 1.55;
}
.application-memory-list {
margin-top: 10px;
display: grid;
gap: 8px;
}
.application-memory-item {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 10px;
border: 1px solid #d7e4f2;
border-radius: 6px;
background: #ffffff;
}
.application-memory-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.application-memory-status {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--theme-primary-active, #255b7d);
font-size: 11px;
font-weight: 850;
}
.application-memory-copy > strong {
color: #0f172a;
font-size: 12px;
font-weight: 850;
line-height: 1.5;
overflow-wrap: anywhere;
}
.application-memory-copy > small,
.application-memory-copy > em {
color: #52677a;
font-size: 11px;
font-style: normal;
line-height: 1.5;
}
.application-memory-copy > em {
color: #708196;
}
.application-memory-forget-btn {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 11px;
border: 1px solid rgba(185, 28, 28, 0.24);
border-radius: 6px;
background: #fffafa;
color: #991b1b;
font-size: 12px;
font-weight: 800;
cursor: pointer;
transition: background 180ms ease, border-color 180ms ease, color 180ms ease;
}
.application-memory-forget-btn:hover:not(:disabled),
.application-memory-forget-btn:focus-visible {
border-color: rgba(185, 28, 28, 0.48);
background: #fef2f2;
color: #7f1d1d;
outline: none;
}
.application-memory-forget-btn:focus-visible {
box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.14);
}
.application-memory-forget-btn:disabled {
cursor: wait;
opacity: 0.52;
}
.application-learning-receipt-panel {
display: grid;
gap: 8px;
background: #f8fbff;
}
.application-learning-receipt-panel > header {
align-items: center;
color: var(--theme-primary-active, #255b7d);
}
.application-learning-receipt-panel > header i {
font-size: 17px;
}
.application-learning-receipt {
display: grid;
gap: 4px;
padding: 8px 10px;
border-left: 3px solid #d6a12a;
border-radius: 4px;
background: #ffffff;
}
.application-learning-receipt.is-applied {
border-left-color: var(--theme-primary, #3a7ca5);
}
.application-learning-receipt > div {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 5px 8px;
}
.application-learning-receipt strong {
color: #17324a;
font-size: 12px;
font-weight: 850;
}
.application-learning-receipt span,
.application-learning-receipt small {
color: #52677a;
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.application-memory-item {
grid-template-columns: 1fr;
}
.application-memory-forget-btn {
justify-self: stretch;
}
}
@media (prefers-reduced-motion: reduce) {
.application-memory-forget-btn {
transition: none;
}
}

View File

@@ -0,0 +1,94 @@
<template>
<div v-if="appliedMemories.length || learningReceipts.length" class="application-memory-stack">
<section
v-if="appliedMemories.length"
class="application-memory-panel"
aria-label="本次申请使用的历史偏好"
>
<header class="application-memory-panel-head">
<span class="application-memory-panel-icon" aria-hidden="true">
<i class="mdi mdi-history"></i>
</span>
<span>
<strong>个性化填充</strong>
<small>以下内容来自已确认的历史偏好您仍可直接修改上方字段</small>
</span>
</header>
<div class="application-memory-list">
<article
v-for="memory in appliedMemories"
:key="`${message.id}-memory-${memory.memoryId}`"
class="application-memory-item"
>
<div class="application-memory-copy">
<span class="application-memory-status">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
已按历史偏好填入
</span>
<strong>{{ memory.fieldLabel }}{{ memory.value }}</strong>
<small v-if="memory.message">{{ memory.message }}</small>
<em>{{ ui.resolveApplicationMemoryEvidenceText(memory) }}</em>
</div>
<button
type="button"
class="application-memory-forget-btn"
:disabled="ui.isForgettingApplicationMemory(memory)"
:aria-busy="ui.isForgettingApplicationMemory(memory)"
@click.stop="ui.forgetApplicationPreviewMemory(message, memory)"
>
<i
:class="ui.isForgettingApplicationMemory(memory) ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-delete-outline'"
aria-hidden="true"
></i>
<span>{{ ui.isForgettingApplicationMemory(memory) ? '正在忘记' : '忘记此偏好' }}</span>
</button>
</article>
</div>
</section>
<section
v-if="learningReceipts.length"
class="application-learning-receipt-panel"
role="status"
aria-live="polite"
aria-label="本次申请的个性化学习结果"
>
<header>
<i class="mdi mdi-lightbulb-on-outline" aria-hidden="true"></i>
<strong>本次选择已记录</strong>
</header>
<article
v-for="receipt in learningReceipts"
:key="`${message.id}-learning-${receipt.memoryId}-${receipt.fieldKey}`"
class="application-learning-receipt"
:class="`is-${receipt.status}`"
>
<div>
<strong>{{ ui.resolveApplicationLearningReceiptTitle(receipt) }}</strong>
<span>{{ receipt.fieldLabel }}{{ receipt.value }}</span>
</div>
<small>{{ receipt.message || ui.resolveApplicationMemoryEvidenceText(receipt) }}</small>
</article>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
message: {
type: Object,
required: true
},
ui: {
type: Object,
required: true
}
})
const appliedMemories = computed(() => props.ui.resolveAppliedApplicationPreviewMemories(props.message))
const learningReceipts = computed(() => props.ui.resolveVisibleApplicationLearningReceipts(props.message))
</script>
<style scoped src="../../assets/styles/components/travel-reimbursement-message-memory.css"></style>

View File

@@ -225,6 +225,8 @@
</div>
</div>
<TravelReimbursementMemoryPanel :message="message" :ui="ui" />
<div
v-if="ui.resolveApplicationPreviewMissingFields(message)?.length"
class="application-preview-footer application-preview-footer-missing"
@@ -686,12 +688,14 @@
<script>
import BudgetAssistantReport from './BudgetAssistantReport.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import TravelReimbursementMemoryPanel from './TravelReimbursementMemoryPanel.vue'
export default {
name: 'TravelReimbursementMessageItem',
components: {
BudgetAssistantReport,
EnterpriseSelect
EnterpriseSelect,
TravelReimbursementMemoryPanel
},
props: {
message: {

View File

@@ -7,6 +7,62 @@ import {
export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft'
export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit'
const AI_APPLICATION_MEMORY_FIELD_LABELS = {
amount: '预计金额',
application_type: '申请类型',
applicationType: '申请类型',
days: '出差天数',
location: '出差地点',
reason: '申请事由',
time: '出差时间',
time_range: '出差时间',
transport_mode: '出行方式',
transportMode: '出行方式'
}
export function normalizeAiApplicationMemory(item = {}) {
if (!item || typeof item !== 'object') return null
const memoryId = normalizeText(item.memoryId || item.memory_id)
const fieldKey = normalizeText(item.fieldKey || item.field_key)
const rawStatus = normalizeText(item.status).toLowerCase()
const status = rawStatus === 'active' ? 'applied' : rawStatus
if (!memoryId || !fieldKey || !status) return null
return {
memoryId,
fieldKey,
fieldLabel:
normalizeText(item.fieldLabel || item.field_label)
|| AI_APPLICATION_MEMORY_FIELD_LABELS[fieldKey]
|| fieldKey,
value: normalizeText(item.value),
status,
evidenceCount: Number(item.evidenceCount ?? item.evidence_count ?? 0) || 0,
approvedEvidenceCount:
Number(item.approvedEvidenceCount ?? item.approved_evidence_count ?? 0) || 0,
message: normalizeText(item.message)
}
}
export function normalizeAiApplicationMemories(items = []) {
return (Array.isArray(items) ? items : [])
.map((item) => normalizeAiApplicationMemory(item))
.filter(Boolean)
}
export function resolveAiApplicationPreviewMemories(preview = {}) {
const items = Array.isArray(preview?.memoryApplications)
? preview.memoryApplications
: preview?.memory_applications
return normalizeAiApplicationMemories(items)
}
export function resolveAiApplicationLearningReceipts(payload = {}) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload
const items = Array.isArray(result?.learning_receipts)
? result.learning_receipts
: result?.learningReceipts
return normalizeAiApplicationMemories(items)
}
export function createAiApplicationRequestId(prefix = 'application') {
const normalizedPrefix = normalizeText(prefix) || 'application'
@@ -42,6 +98,13 @@ export async function registerAiApplicationPreviewDecision({
const serverPreview = payload?.application_preview && typeof payload.application_preview === 'object'
? payload.application_preview
: {}
const memoryApplications = resolveAiApplicationPreviewMemories({
memoryApplications:
serverPreview.memoryApplications
?? serverPreview.memory_applications
?? normalizedPreview.memoryApplications
?? normalizedPreview.memory_applications
})
return normalizeApplicationPreview({
...normalizedPreview,
...serverPreview,
@@ -52,6 +115,7 @@ export async function registerAiApplicationPreviewDecision({
decisionId: normalizeText(payload?.decision_id || serverPreview.decisionId),
decisionSource: normalizeText(payload?.decision_source || serverPreview.decisionSource),
decisionExpiresAt: normalizeText(payload?.expires_at || serverPreview.decisionExpiresAt),
memoryApplications,
decisionTrackingStatus: 'registered'
})
}
@@ -199,3 +263,14 @@ export function runAiApplicationPreviewAction(params = {}, options = {}) {
...options
})
}
export function forgetAiApplicationMemory(memoryId, options = {}) {
const normalizedMemoryId = normalizeText(memoryId)
if (!normalizedMemoryId) {
return Promise.reject(new Error('缺少要忘记的偏好标识。'))
}
return apiRequest(`/expense-application-memories/${encodeURIComponent(normalizedMemoryId)}`, {
method: 'DELETE',
...options
})
}

View File

@@ -243,7 +243,13 @@ export default {
canRetryApplicationPreviewDecision,
canSaveApplicationPreviewDraft,
canSubmitApplicationPreview,
forgetApplicationPreviewMemory,
isForgettingApplicationMemory,
registerApplicationPreviewDecision,
resolveApplicationLearningReceiptTitle,
resolveApplicationMemoryEvidenceText,
resolveAppliedApplicationPreviewMemories,
resolveVisibleApplicationLearningReceipts,
retryApplicationPreviewDecision,
runApplicationPreviewAction,
saveApplicationPreviewDraft
@@ -590,7 +596,7 @@ export default {
submitComposerFromMessageHandlers = submitComposerMessageHandler
const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, forgetApplicationPreviewMemory, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isForgettingApplicationMemory, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationLearningReceiptTitle, resolveApplicationMemoryEvidenceText, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveAppliedApplicationPreviewMemories, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, resolveVisibleApplicationLearningReceipts, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
})
return {

View File

@@ -38,6 +38,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
riskFlags: [],
pendingAttachmentAssociation: null,
applicationPreview: null,
applicationLearningReceipts: [],
budgetReport: null,
stewardPlan: null,
operationFeedback: null,

View File

@@ -122,7 +122,11 @@ export function normalizeInitialConversationMessages(conversation) {
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [],
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null,
applicationLearningReceipts:
item.role === 'assistant' && Array.isArray(result?.learning_receipts)
? result.learning_receipts
: []
})
})
return markResolvedSuggestedActionMessages(restoredMessages)
@@ -176,6 +180,9 @@ export function serializeSessionMessages(messages) {
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
applicationLearningReceipts: Array.isArray(message.applicationLearningReceipts)
? message.applicationLearningReceipts
: [],
pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null,
applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed),
budgetReport: message.budgetReport || null,
@@ -203,6 +210,7 @@ export function hasMeaningfulSessionMessages(messages) {
|| message.queryPayload
|| message.draftPayload
|| message.applicationPreview
|| (Array.isArray(message.applicationLearningReceipts) && message.applicationLearningReceipts.length)
|| message.budgetReport
|| message.stewardPlan
|| message.operationFeedback

View File

@@ -279,7 +279,9 @@ export function createStewardDelegationHelpers({
}
function shouldPauseStewardApplicationPreview(preview = {}) {
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(preview)
// 仅缺出行方式时先交给认证签发端尝试应用个人记忆;其他缺失仍由 Steward 追问。
return missingFields.some((field) => String(field || '').trim() !== '出行方式')
}
function extractStewardCarryLine(text = '', label = '') {

View File

@@ -1,8 +1,14 @@
import { ref } from 'vue'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
createAiApplicationRequestId,
forgetAiApplicationMemory,
normalizeAiApplicationMemory,
registerAiApplicationPreviewDecision,
resolveAiApplicationLearningReceipts,
resolveAiApplicationPreviewMemories,
runAiApplicationPreviewAction
} from '../../services/aiApplicationPreviewActions.js'
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
@@ -11,6 +17,14 @@ const DECISION_STATUS_REGISTERED = 'registered'
const DECISION_STATUS_REGISTERING = 'registering'
const DECISION_STATUS_FAILED = 'registration_failed'
const DECISION_STATUS_UNREGISTERED = 'unregistered'
const VISIBLE_LEARNING_RECEIPT_STATUSES = new Set(['candidate', 'applied'])
const SERVER_RESOLVABLE_MISSING_FIELDS = new Set(['出行方式'])
const MEMORY_FIELD_ALIASES = {
application_type: 'applicationType',
manager_name: 'managerName',
time_range: 'time',
transport_mode: 'transportMode'
}
function resolveRefValue(value, fallback = null) {
if (value && typeof value === 'object' && 'value' in value) {
@@ -36,6 +50,18 @@ function resolveDecisionSourceText(preview = {}, sourceText = '') {
)
}
function canResolveApplicationPreviewOnServer(preview = {}) {
const missingFields = Array.isArray(preview?.missingFields) ? preview.missingFields : []
const validationIssues = Array.isArray(preview?.validationIssues)
? preview.validationIssues
: []
return Boolean(
!validationIssues.length
&& missingFields.length
&& missingFields.every((field) => SERVER_RESOLVABLE_MISSING_FIELDS.has(normalizeText(field)))
)
}
function resolveApplicationDraftPayload(message = {}, draftClaimId = '', linkedRequest = null) {
if (message?.draftPayload && typeof message.draftPayload === 'object') {
return message.draftPayload
@@ -59,6 +85,7 @@ export function useTravelReimbursementApplicationPreviewActions({
draftClaimId,
emitDraftSaved,
entrySource = 'application',
confirmForgetMemory = null,
linkedRequest,
persistSessionState = () => {},
reviewActionBusy,
@@ -66,6 +93,97 @@ export function useTravelReimbursementApplicationPreviewActions({
submitting,
toast = () => {}
} = {}) {
const forgettingApplicationMemoryIds = ref(new Set())
function resolveApplicationMemoryFieldKey(fieldKey = '') {
const normalized = normalizeText(fieldKey)
return MEMORY_FIELD_ALIASES[normalized] || normalized
}
function resolveAppliedApplicationPreviewMemories(message) {
const preview = normalizeApplicationPreview(message?.applicationPreview || {})
return resolveAiApplicationPreviewMemories(preview).filter((memory) => {
if (memory.status !== 'applied') return false
const fieldKey = resolveApplicationMemoryFieldKey(memory.fieldKey)
return normalizeText(preview.fields?.[fieldKey]) === normalizeText(memory.value)
})
}
function resolveVisibleApplicationLearningReceipts(message) {
return (Array.isArray(message?.applicationLearningReceipts)
? message.applicationLearningReceipts
: [])
.map((item) => normalizeAiApplicationMemory(item))
.filter((item) => item && VISIBLE_LEARNING_RECEIPT_STATUSES.has(item.status))
}
function resolveApplicationMemoryEvidenceText(memory = {}) {
const approvedCount = Number(memory.approvedEvidenceCount || 0)
const evidenceCount = Number(memory.evidenceCount || 0)
if (approvedCount > 0) {
return `${approvedCount} 次已审批记录支持${evidenceCount > approvedCount ? `,共 ${evidenceCount} 次证据` : ''}`
}
return evidenceCount > 0 ? `${evidenceCount} 次历史记录支持` : '来自已确认的历史记录'
}
function resolveApplicationLearningReceiptTitle(receipt = {}) {
return receipt.status === 'applied' ? '偏好已激活' : '已记录为候选偏好'
}
function isForgettingApplicationMemory(memory = {}) {
const memoryId = normalizeText(memory.memoryId || memory.memory_id)
return Boolean(memoryId && forgettingApplicationMemoryIds.value.has(memoryId))
}
async function requestForgetMemoryConfirmation(memory) {
const label = normalizeText(memory?.fieldLabel || memory?.field_label || memory?.fieldKey)
if (typeof confirmForgetMemory === 'function') {
return Boolean(await confirmForgetMemory(memory))
}
if (typeof globalThis.confirm === 'function') {
return globalThis.confirm(
`忘记${label ? `${label}` : '此项'}偏好?当前申请中的内容会保留,后续申请不再自动套用。`
)
}
return true
}
async function forgetApplicationPreviewMemory(message, memory = {}) {
const normalizedMemory = normalizeAiApplicationMemory(memory)
if (!message?.applicationPreview || !normalizedMemory) return false
if (isForgettingApplicationMemory(normalizedMemory)) return false
if (!(await requestForgetMemoryConfirmation(normalizedMemory))) return false
forgettingApplicationMemoryIds.value = new Set([
...forgettingApplicationMemoryIds.value,
normalizedMemory.memoryId
])
try {
await forgetAiApplicationMemory(normalizedMemory.memoryId)
const currentPreview = message.applicationPreview
const nextMemories = resolveAiApplicationPreviewMemories(currentPreview).filter(
(item) => item.memoryId !== normalizedMemory.memoryId
)
message.applicationPreview = {
...currentPreview,
memoryApplications: nextMemories
}
delete message.applicationPreview.memory_applications
message.applicationLearningReceipts = resolveVisibleApplicationLearningReceipts(message)
.filter((item) => item.memoryId !== normalizedMemory.memoryId)
persistSessionState()
toast('已忘记此偏好,当前申请内容保持不变。')
return true
} catch (error) {
toast(resolveErrorMessage(error, '偏好撤销失败,请稍后重试。'))
return false
} finally {
const nextBusyIds = new Set(forgettingApplicationMemoryIds.value)
nextBusyIds.delete(normalizedMemory.memoryId)
forgettingApplicationMemoryIds.value = nextBusyIds
}
}
function isInteractionBusy() {
return Boolean(
resolveRefValue(submitting, false)
@@ -127,7 +245,7 @@ export function useTravelReimbursementApplicationPreviewActions({
preserveCurrentFields = false
} = {}) {
const normalized = normalizeApplicationPreview(applicationPreview || {})
if (!normalized.readyToSubmit) {
if (!normalized.readyToSubmit && !canResolveApplicationPreviewOnServer(normalized)) {
return normalizeApplicationPreview({
...normalized,
decisionId: '',
@@ -271,6 +389,7 @@ export function useTravelReimbursementApplicationPreviewActions({
throw error
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
message.applicationLearningReceipts = resolveAiApplicationLearningReceipts(payload)
const nextDraftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: null
@@ -341,7 +460,13 @@ export function useTravelReimbursementApplicationPreviewActions({
canRetryApplicationPreviewDecision,
canSaveApplicationPreviewDraft,
canSubmitApplicationPreview,
forgetApplicationPreviewMemory,
isForgettingApplicationMemory,
registerApplicationPreviewDecision,
resolveApplicationLearningReceiptTitle,
resolveApplicationMemoryEvidenceText,
resolveAppliedApplicationPreviewMemories,
resolveVisibleApplicationLearningReceipts,
retryApplicationPreviewDecision,
runApplicationPreviewAction,
saveApplicationPreviewDraft

View File

@@ -32,6 +32,12 @@ export function useTravelReimbursementCreateViewUi(ctx) {
canSubmitApplicationPreview: ctx.canSubmitApplicationPreview,
retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision,
saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft,
resolveAppliedApplicationPreviewMemories: ctx.resolveAppliedApplicationPreviewMemories,
resolveVisibleApplicationLearningReceipts: ctx.resolveVisibleApplicationLearningReceipts,
resolveApplicationLearningReceiptTitle: ctx.resolveApplicationLearningReceiptTitle,
resolveApplicationMemoryEvidenceText: ctx.resolveApplicationMemoryEvidenceText,
isForgettingApplicationMemory: ctx.isForgettingApplicationMemory,
forgetApplicationPreviewMemory: ctx.forgetApplicationPreviewMemory,
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,