diff --git a/web/src/assets/ai-document-card-bg.png b/web/src/assets/ai-document-card-bg.png new file mode 100644 index 0000000..74a7e0c Binary files /dev/null and b/web/src/assets/ai-document-card-bg.png differ diff --git a/web/src/assets/styles/components/personal-workbench-ai-mode.css b/web/src/assets/styles/components/personal-workbench-ai-mode.css index c5b120c..f89cd42 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -928,6 +928,11 @@ letter-spacing: 0; } +.workbench-ai-answer-markdown :deep(.ai-html-flow) { + display: grid; + gap: 16px; +} + .workbench-ai-answer-markdown :deep(h1), .workbench-ai-answer-markdown :deep(h2), .workbench-ai-answer-markdown :deep(h3), @@ -987,7 +992,331 @@ color: #475569; } -.workbench-ai-answer-markdown :deep(.markdown-table-wrap) { +.workbench-ai-answer-markdown :deep(.ai-html-callout) { + margin: 0; + padding: 14px 16px; + border-left: 3px solid rgba(37, 99, 235, 0.5); + border-radius: 12px; + background: rgba(239, 246, 255, 0.62); + color: #475569; +} + +.workbench-ai-answer-markdown :deep(.ai-html-focus-grid) { + display: grid; + gap: 0; + margin: 2px 0 18px; + padding-left: 22px; + border-left: 3px solid rgba(96, 165, 250, 0.66); +} + +.workbench-ai-answer-markdown :deep(.ai-html-focus-card) { + padding: 11px 0 16px; + border: 0; + border-radius: 0; + background: transparent; +} + +.workbench-ai-answer-markdown :deep(.ai-html-focus-card + .ai-html-focus-card) { + border-top: 1px solid rgba(226, 232, 240, 0.92); +} + +.workbench-ai-answer-markdown :deep(.ai-html-focus-label) { + display: block; + margin-bottom: 4px; + color: #1d4ed8; + font-size: 15px; + font-weight: 900; +} + +.workbench-ai-answer-markdown :deep(.ai-html-focus-card p) { + color: #475569; + font-size: 16px; + font-weight: 650; + line-height: 1.72; +} + +.workbench-ai-answer-markdown :deep(.ai-html-steps), +.workbench-ai-answer-markdown :deep(.ai-html-list) { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.workbench-ai-answer-markdown :deep(.ai-html-steps li) { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.workbench-ai-answer-markdown :deep(.ai-html-step-index) { + width: 34px; + min-height: 28px; + display: grid; + align-items: start; + justify-items: start; + padding-top: 1px; + border-radius: 0; + background: transparent; + color: #1d4ed8; + font-size: 17px; + font-weight: 900; + line-height: 1.45; +} + +.workbench-ai-answer-markdown :deep(.ai-html-step-copy) { + display: grid; + gap: 5px; +} + +.workbench-ai-answer-markdown :deep(.ai-html-step-copy > strong) { + color: #0f172a; + font-size: 17px; + line-height: 1.45; +} + +.workbench-ai-answer-markdown :deep(.ai-html-step-copy > p) { + color: #475569; + font-size: 16px; + font-weight: 620; + line-height: 1.72; +} + +.workbench-ai-answer-markdown :deep(.ai-html-list:not(.ai-html-steps)) { + padding-left: 18px; + list-style: disc; +} + +.workbench-ai-answer-markdown :deep(.ai-html-list--ordered) { + padding-left: 22px; + list-style: decimal; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card-list) { + display: grid; + gap: 12px; + margin-top: 18px; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card) { + position: relative; + display: grid; + gap: 12px; + padding: 16px 18px; + border: 1px solid rgba(226, 232, 240, 0.9); + border-left: 3px solid #cbd5e1; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04); + color: #334155; + animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card:hover) { + border-color: rgba(148, 163, 184, 0.7); + box-shadow: 0 2px 4px rgba(15, 23, 42, 0.05), 0 8px 20px rgba(15, 23, 42, 0.07); + transform: translateY(-1px); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card:nth-child(2)) { + animation-delay: 40ms; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card:nth-child(3)) { + animation-delay: 80ms; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card:nth-child(4)) { + animation-delay: 120ms; +} + +/* 状态语义色:左侧边条颜色随状态变化,一眼判断当前阶段 */ +.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending) { + border-left-color: #2563eb; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-success) { + border-left-color: #16a34a; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning) { + border-left-color: #d97706; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger) { + border-left-color: #dc2626; +} + +/* 卡片头部:状态 + 类型(左) · 单据编号(右) */ +.workbench-ai-answer-markdown :deep(.ai-document-card__head) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__head-left) { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__status) { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 9px; + border-radius: 6px; + background: rgba(148, 163, 184, 0.16); + color: #475569; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) { + background: rgba(37, 99, 235, 0.1); + color: #1d4ed8; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) { + background: rgba(22, 163, 74, 0.1); + color: #15803d; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) { + background: rgba(217, 119, 6, 0.1); + color: #b45309; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) { + background: rgba(220, 38, 38, 0.1); + color: #b91c1c; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__type) { + color: #64748b; + font-size: 12px; + font-weight: 500; + line-height: 1.3; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__number) { + flex: 0 0 auto; + color: #94a3b8; + font-size: 12px; + font-weight: 500; + line-height: 1.3; + overflow-wrap: anywhere; +} + +/* 卡片主体:事由(主焦点) + 申请人/部门(次焦点) */ +.workbench-ai-answer-markdown :deep(.ai-document-card__body) { + display: grid; + gap: 6px; + min-width: 0; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__reason) { + display: -webkit-box; + color: #0f172a; + font-size: 16px; + font-weight: 700; + line-height: 1.45; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__owner-line) { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__owner) { + color: #1e293b; + font-size: 13px; + font-weight: 600; + line-height: 1.3; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__dept) { + color: #64748b; + font-size: 13px; + font-weight: 500; + line-height: 1.3; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__dot) { + color: #cbd5e1; + font-size: 12px; + font-weight: 700; +} + +/* 卡片底部:辅助元信息(左) · 金额(右) · 操作 */ +.workbench-ai-answer-markdown :deep(.ai-document-card__foot) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid rgba(226, 232, 240, 0.9); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__meta) { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + flex: 1 1 auto; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__meta-item) { + color: #64748b; + font-size: 12px; + font-weight: 500; + line-height: 1.3; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) { + display: grid; + justify-items: end; + gap: 1px; + flex: 0 0 auto; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__amount-label) { + color: #94a3b8; + font-size: 11px; + font-weight: 500; + line-height: 1.2; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__amount) { + color: #0f172a; + font-size: 17px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__action) { + flex: 0 0 auto; +} + +.workbench-ai-answer-markdown :deep(.markdown-table-wrap), +.workbench-ai-answer-markdown :deep(.ai-html-table-wrap) { overflow-x: auto; margin-top: 18px; border: 1px solid rgba(226, 232, 240, 0.9); @@ -1013,6 +1342,384 @@ font-weight: 850; } +.workbench-ai-answer-markdown :deep(.ai-html-image-frame) { + margin: 0; + overflow: hidden; + border: 1px solid rgba(226, 232, 240, 0.9); + border-radius: 16px; + background: rgba(248, 250, 252, 0.74); +} + +.workbench-ai-answer-markdown :deep(.ai-html-image), +.workbench-ai-answer-markdown :deep(.ai-html-inline-image) { + max-width: 100%; + height: auto; + display: block; +} + +.workbench-ai-answer-markdown :deep(.ai-html-image) { + width: 100%; + object-fit: contain; +} + +.workbench-ai-answer-markdown :deep(.ai-html-inline-image) { + max-height: 220px; + margin: 8px 0; + border-radius: 12px; +} + +.workbench-ai-answer-markdown :deep(.ai-html-image-caption) { + display: block; + padding: 8px 12px; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.workbench-ai-answer-markdown :deep(.markdown-action-link), +.workbench-ai-answer-markdown :deep(.ai-html-action-link) { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.1); + color: #1d4ed8; + font-size: 13px; + font-weight: 850; + line-height: 1.2; + text-decoration: none; + white-space: nowrap; +} + +.workbench-ai-answer-markdown :deep(.markdown-action-link:hover), +.workbench-ai-answer-markdown :deep(.ai-html-action-link:hover) { + background: rgba(37, 99, 235, 0.16); + color: #1e40af; +} + +@keyframes workbenchDocumentCardReveal { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .workbench-ai-answer-markdown :deep(.ai-document-card) { + animation: none; + transition: none; + } +} + +@media (max-width: 720px) { + .workbench-ai-answer-markdown :deep(.ai-document-card) { + padding: 14px; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__head) { + align-items: flex-start; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__number) { + flex-basis: 100%; + text-align: left; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__foot) { + flex-wrap: wrap; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) { + justify-items: start; + order: 2; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__action) { + order: 3; + margin-left: auto; + } +} + +.workbench-ai-application-preview { + min-width: 0; + display: grid; + gap: 16px; + margin-top: 18px; +} + +.structured-card-reveal-enter-active, +.structured-card-reveal-leave-active { + transition: + opacity 260ms cubic-bezier(0.2, 0.8, 0.2, 1), + transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.structured-card-reveal-enter-from, +.structured-card-reveal-leave-to { + opacity: 0; + transform: translateY(10px) scale(0.99); +} + +.structured-card-reveal-enter-to, +.structured-card-reveal-leave-from { + opacity: 1; + transform: translateY(0) scale(1); +} + +.application-preview-table { + display: grid; + overflow: hidden; + border: 1px solid rgba(191, 219, 254, 0.72); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)), + #ffffff; + box-shadow: + 0 16px 34px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.98); + color: #334155; + font-size: 15px; +} + +.application-preview-row { + position: relative; + display: grid; + grid-template-columns: 148px minmax(0, 1fr); + min-height: 48px; + border-top: 1px solid rgba(226, 232, 240, 0.96); +} + +.structured-card-reveal-enter-active .application-preview-row { + animation: workbenchApplicationRowReveal 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:first-child { + border-top: 0; +} + +.application-preview-row.head { + min-height: 42px; + background: linear-gradient(180deg, rgba(239, 246, 255, 0.92), rgba(248, 250, 252, 0.98)); + color: #334155; + font-size: 13px; + font-weight: 900; +} + +.application-preview-row > span { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 10px 16px; +} + +.application-preview-label { + border-right: 1px solid rgba(226, 232, 240, 0.96); + background: rgba(248, 250, 252, 0.72); + color: #475569; + font-weight: 820; +} + +.application-preview-value { + position: relative; + color: #0f172a; + font-weight: 700; +} + +.application-preview-row.editable { + cursor: pointer; +} + +.application-preview-row.editable:hover, +.application-preview-row.editable:hover .application-preview-label, +.application-preview-row.editable:hover .application-preview-value { + background: rgba(239, 246, 255, 0.58); +} + +.application-preview-row.editable:focus-visible { + z-index: 1; + outline: 2px solid rgba(37, 99, 235, 0.42); + outline-offset: -2px; +} + +.application-preview-row.highlight .application-preview-label { + background: rgba(219, 234, 254, 0.76); + color: #1d4ed8; +} + +.application-preview-row.highlight .application-preview-value { + background: rgba(219, 234, 254, 0.44); + color: #1e40af; + font-weight: 850; +} + +.application-preview-row.missing { + background: rgba(37, 99, 235, 0.035); + box-shadow: inset 3px 0 0 rgba(37, 99, 235, 0.5); +} + +.application-preview-row.missing .application-preview-label { + background: rgba(219, 234, 254, 0.78); + color: #1d4ed8; + font-weight: 900; +} + +.application-preview-row.missing .application-preview-value { + background: rgba(239, 246, 255, 0.74); + font-weight: 850; +} + +.application-preview-text { + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.48; +} + +.application-preview-input { + width: 100%; + min-width: 0; + min-height: 34px; + padding: 0 10px; + border: 1px solid rgba(37, 99, 235, 0.46); + border-radius: 8px; + outline: none; + background: #ffffff; + color: #0f172a; + font: inherit; + font-weight: 720; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11); +} + +.application-preview-select { + cursor: pointer; +} + +.application-preview-edit-btn { + flex: 0 0 auto; + width: 28px; + height: 28px; + display: inline-grid; + place-items: center; + border: 1px solid rgba(37, 99, 235, 0.18); + border-radius: 8px; + background: rgba(239, 246, 255, 0.92); + color: #1d4ed8; + cursor: pointer; + opacity: 0; + transition: + opacity 160ms ease, + border-color 160ms ease, + background 160ms ease, + transform 160ms ease; +} + +.application-preview-edit-btn i { + font-size: 15px; +} + +.application-preview-row:hover .application-preview-edit-btn, +.application-preview-edit-btn:focus-visible { + opacity: 1; +} + +.application-preview-edit-btn:hover, +.application-preview-edit-btn:focus-visible { + border-color: rgba(37, 99, 235, 0.38); + background: rgba(219, 234, 254, 0.98); + transform: translateY(-1px); +} + +.application-preview-footer { + color: #334155; + font-size: 15px; + font-weight: 720; + line-height: 1.78; +} + +.application-preview-footer.workbench-ai-answer-markdown { + margin-top: 0; +} + +.application-preview-footer-missing { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 6px; + padding: 2px 0 0; + color: #334155; + font-size: 15px; + font-weight: 760; + line-height: 1.75; +} + +.application-preview-missing-prefix, +.application-preview-missing-suffix { + color: #334155; + font-weight: 850; +} + +.application-preview-missing-list { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.application-preview-missing-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border-radius: 8px; + background: rgba(37, 99, 235, 0.1); + color: #1d4ed8; + font-size: 13px; + font-weight: 900; +} + +.application-preview-missing-separator { + color: #1d4ed8; + font-weight: 820; +} + +@keyframes workbenchApplicationRowReveal { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + .workbench-ai-suggested-actions { display: flex; flex-wrap: wrap; @@ -1445,10 +2152,16 @@ .workbench-ai-panel-swap-leave-active, .workbench-ai-thinking-collapse-enter-active, .workbench-ai-thinking-collapse-leave-active, + .structured-card-reveal-enter-active, + .structured-card-reveal-leave-active, .workbench-ai-confirm-fade-enter-active, .workbench-ai-confirm-fade-leave-active, .workbench-ai-confirm-fade-enter-active .workbench-ai-confirm-dialog, .workbench-ai-confirm-fade-leave-active .workbench-ai-confirm-dialog { transition: none; } + + .structured-card-reveal-enter-active .application-preview-row { + animation: none; + } } diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index 312f415..d56e249 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -303,10 +303,121 @@
-
+ +
+
+
+ 字段 + 内容 +
+
+ {{ row.label }} + + + + + +
+
+ + + +
+
+ +
小财管家正在识别任务、拆解流程并准备下一步建议...
@@ -523,7 +634,7 @@ import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../../utils/aiWorkbenchConversationStore.js' -import { renderMarkdown } from '../../utils/markdown.js' +import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js' import { mergeComposerPrefill, resolveSuggestedActionPrefill @@ -549,25 +660,39 @@ import { isAiExpenseDraftComplete } from '../../utils/aiExpenseDraftModel.js' import { - applyAiApplicationAnswer, - buildAiApplicationStepPrompt, - buildAiApplicationSummary, - createAiApplicationDraft, - isAiApplicationDraftComplete -} from '../../utils/aiApplicationDraftModel.js' + buildApplicationPreviewFooterMessage, + buildApplicationPreviewRows, + buildApplicationTemplatePreview, + buildLocalApplicationPreview, + buildLocalApplicationPreviewMessage, + normalizeApplicationPreview +} from '../../utils/expenseApplicationPreview.js' +import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js' +import { + buildAiDocumentQueryConditionSummary, + buildAiDocumentQueryMessage, + filterAiDocumentQueryRecords, + resolveAiDocumentQueryIntent +} from '../../utils/aiDocumentQueryModel.js' import { buildRequiredApplicationActions, buildRequiredApplicationMissingText, buildRequiredApplicationSelectionText, filterRequiredApplicationCandidates } from '../../views/scripts/travelReimbursementApplicationLinkModel.js' -import { fetchExpenseClaims } from '../../services/reimbursements.js' +import { + calculateTravelReimbursement, + extractExpenseClaimItems, + fetchApprovalExpenseClaims, + fetchExpenseClaims +} from '../../services/reimbursements.js' const props = defineProps({ sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) } }) -const emit = defineEmits(['conversation-change', 'conversation-history-change']) +const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document']) +const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320 const { currentUser } = useSystemState() const { toast } = useToast() const assistantDraft = ref('') @@ -584,7 +709,6 @@ const activeConversationTitle = ref('') const sending = ref(false) const stewardState = ref(null) const aiExpenseDraft = ref(null) -const aiApplicationDraft = ref(null) const thinkingExpandedMessageIds = ref(new Set()) const thinkingCollapsedMessageIds = ref(new Set()) const deleteDialogOpen = ref(false) @@ -594,6 +718,24 @@ const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 const INLINE_ANSWER_STREAM_DELAY_MS = 24 const INLINE_AUTO_SCROLL_THRESHOLD = 96 const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260 +const AI_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' + +const { + applicationPreviewEditor, + resolveApplicationPreviewEditorControl, + resolveApplicationPreviewEditorOptions, + refreshApplicationPreviewEstimate, + isApplicationPreviewEditing, + openApplicationPreviewEditor, + commitApplicationPreviewEditor, + cancelApplicationPreviewEditor, + handleApplicationPreviewEditorKeydown +} = useApplicationPreviewEditor({ + persistSessionState: () => persistCurrentConversation(), + toast, + calculateTravelReimbursement, + currentUser +}) const { workbenchDatePickerOpen, @@ -753,6 +895,8 @@ function createInlineMessage(role, content, options = {}) { feedback: String(options.feedback || ''), stewardPlan: options.stewardPlan || null, suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [], + applicationPreview: options.applicationPreview || null, + text: options.text || normalizedContent, createdAt: options.createdAt || Date.now() } } @@ -807,7 +951,9 @@ function normalizeRuntimeMessage(message = {}) { pending: false, feedback: message.feedback || '', stewardPlan: message.stewardPlan || null, - suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [] + suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], + applicationPreview: message.applicationPreview || null, + text: message.text || message.content || '' }) } @@ -816,9 +962,11 @@ function serializeRuntimeMessage(message = {}) { id: message.id, role: message.role, content: message.content, + text: message.text || message.content || '', feedback: message.feedback || '', stewardPlan: message.stewardPlan || null, - suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [] + suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], + applicationPreview: message.applicationPreview || null } } @@ -887,8 +1035,59 @@ function activateInlineConversation(options = {}) { emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value }) } -function renderInlineMarkdown(content) { - return renderMarkdown(content) +function renderInlineConversationHtml(content) { + return renderAiConversationHtml(content) +} + +function resolveInlineApplicationPreviewRows(message) { + return buildApplicationPreviewRows(message?.applicationPreview || {}) +} + +function resolveInlineApplicationPreviewMissingFields(message) { + return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || [] +} + +function resolveInlineApplicationPreviewEditorControl(fieldKey) { + const control = resolveApplicationPreviewEditorControl(fieldKey) + return control === 'date' ? 'text' : control +} + +function syncInlineApplicationPreviewMessageContent(message) { + if (!message?.applicationPreview) { + return + } + const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview) + message.content = nextContent + message.text = nextContent +} + +async function commitInlineApplicationPreviewEditor(message) { + const committed = await commitApplicationPreviewEditor(message) + syncInlineApplicationPreviewMessageContent(message) + persistCurrentConversation() + return committed +} + +function handleInlineApplicationPreviewEditorKeydown(event, message) { + if (event.key === 'Enter') { + event.preventDefault() + void commitInlineApplicationPreviewEditor(message) + return + } + if (event.key === 'Escape') { + event.preventDefault() + cancelApplicationPreviewEditor() + return + } + handleApplicationPreviewEditorKeydown(event, message) +} + +function buildInlineApplicationPreviewFooterText(message) { + const normalized = normalizeApplicationPreview(message?.applicationPreview || {}) + if (normalized.validationIssues?.length || normalized.missingFields?.length) { + return buildApplicationPreviewFooterMessage(normalized) + } + return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以回复“保存草稿”或“提交申请”。' } function resolveInlineThinkingEvents(message) { @@ -1033,18 +1232,17 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) { return baseText } -function continueAiRequiredApplicationGateFromPlan(normalizedPlan) { +function continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt = '') { const flow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan) if (!flow) { return false } if (flow.flowId === 'travel_application') { aiExpenseDraft.value = null - startAiApplicationDraft('travel', '差旅费') + void startAiApplicationPreview('travel', '差旅费', prompt) return true } if (flow.flowId === 'travel_reimbursement') { - aiApplicationDraft.value = null startAiExpenseDraft('travel', '差旅费', true) return true } @@ -1125,6 +1323,191 @@ async function fetchInlineStewardPlan(messageId, payload) { } } +function parseAiDocumentDetailHref(href = '') { + const value = String(href || '').trim() + if (!value.startsWith(AI_DOCUMENT_DETAIL_HREF_PREFIX)) { + return null + } + const encodedReference = value.slice(AI_DOCUMENT_DETAIL_HREF_PREFIX.length) + if (!encodedReference) { + return null + } + try { + const reference = decodeURIComponent(encodedReference).trim() + return reference ? { reference } : null + } catch { + return { reference: encodedReference } + } +} + +function buildAiDocumentDetailRequest(detailReference = {}) { + const reference = String(detailReference.reference || '').trim() + const isApplication = /^APP?-/i.test(reference) + return { + id: reference, + claimId: reference, + claimNo: reference, + documentNo: reference, + documentType: isApplication ? 'application' : 'reimbursement', + documentTypeCode: isApplication ? 'application' : 'reimbursement', + source: 'workbench', + returnTo: 'workbench' + } +} + +function handleAiAnswerMarkdownClick(event) { + const target = event?.target + const link = target?.closest?.('a[href^="#ai-open-document-detail:"]') + if (!link) { + return + } + const detailReference = parseAiDocumentDetailHref(link.getAttribute('href')) + if (!detailReference) { + return + } + event.preventDefault() + event.stopPropagation() + emit('open-document', buildAiDocumentDetailRequest(detailReference)) +} + +function waitForAiDocumentQueryStep() { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, AI_DOCUMENT_QUERY_STEP_DELAY_MS) + }) +} + +async function updateAiDocumentQueryThinking(pendingMessage, thinkingEvents, streamStatus = 'streaming') { + const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage + message.stewardPlan = { + ...(message.stewardPlan || {}), + streamStatus, + thinkingEvents + } + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + await nextTick() +} + +function completeAiDocumentQueryEvent(events, eventId, content = '') { + return events.map((event) => ( + event.eventId === eventId + ? { + ...event, + content: content || event.content, + status: 'completed' + } + : event + )) +} + +function failAiDocumentQueryEvents(events) { + return events.map((event) => ({ + ...event, + status: event.status === 'completed' ? 'completed' : 'failed' + })) +} + +async function handleAiDocumentQueryIntent(prompt, pendingMessage) { + const intent = resolveAiDocumentQueryIntent(prompt) + if (!intent) { + return false + } + + const conditionSummary = buildAiDocumentQueryConditionSummary(intent) + let thinkingEvents = [ + { + eventId: 'document-query-parse', + title: '解析自然语言筛选条件', + content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`, + status: 'running' + }, + { + eventId: 'document-query-fetch', + title: '查询业务单据接口', + content: intent.source === 'approval' ? '等待调用待我审核单据接口。' : '等待调用我名下单据接口。', + status: 'pending' + }, + { + eventId: 'document-query-filter', + title: '组合筛选单据', + content: '等待接口返回后,再按已识别条件做二次筛选。', + status: 'pending' + } + ] + await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents) + await waitForAiDocumentQueryStep() + + thinkingEvents = completeAiDocumentQueryEvent(thinkingEvents, 'document-query-parse') + thinkingEvents = thinkingEvents.map((event) => ( + event.eventId === 'document-query-fetch' + ? { + ...event, + content: intent.source === 'approval' + ? '正在查询待我审核的单据,接口范围为待办/待审单据列表。' + : '正在查询我名下的单据,接口范围为当前用户可见单据列表。', + status: 'running' + } + : event + )) + await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents) + + try { + const payload = intent.source === 'approval' + ? await fetchApprovalExpenseClaims({ page: 1, pageSize: 100 }) + : await fetchExpenseClaims({ page: 1, pageSize: 100 }) + const rawCount = extractExpenseClaimItems(payload).length + const filteredRecords = filterAiDocumentQueryRecords(payload, intent) + thinkingEvents = completeAiDocumentQueryEvent( + thinkingEvents, + 'document-query-fetch', + `接口返回 ${rawCount} 张候选单据,开始按自然语言条件筛选。` + ) + thinkingEvents = thinkingEvents.map((event) => ( + event.eventId === 'document-query-filter' + ? { + ...event, + content: `按“${conditionSummary}”组合筛选,当前命中 ${filteredRecords.length} 张。`, + status: 'running' + } + : event + )) + await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents) + await waitForAiDocumentQueryStep() + + const finalMessageText = buildAiDocumentQueryMessage(intent, payload) + thinkingEvents = completeAiDocumentQueryEvent( + thinkingEvents, + 'document-query-filter', + `筛选完成,命中 ${filteredRecords.length} 张单据,已生成卡片结果。` + ) + replaceInlineMessage( + pendingMessage.id, + createInlineMessage('assistant', finalMessageText, { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'completed', + thinkingEvents + }, + suggestedActions: [] + }) + ) + } catch (error) { + const finalMessageText = error?.message || '查询单据时出现异常,请稍后再试。' + replaceInlineMessage( + pendingMessage.id, + createInlineMessage('assistant', finalMessageText, { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'failed', + thinkingEvents: failAiDocumentQueryEvents(thinkingEvents) + } + }) + ) + } + + persistCurrentConversation() + return true +} + async function requestInlineAssistantReply(prompt, entry = {}, files = []) { let shouldAutoScrollOnFinish = true const pendingMessage = createInlineMessage('assistant', '', { @@ -1145,6 +1528,11 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) { scrollInlineConversationToBottom() try { + if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) { + shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value + return + } + const planRequest = buildStewardPlanRequest({ rawText: prompt, files, @@ -1201,7 +1589,7 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) { suggestedActions: requiredApplicationContinuationFlow ? [] : buildStewardSuggestedActions(plan) }) ) - if (continueAiRequiredApplicationGateFromPlan(normalizedPlan)) { + if (continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt)) { shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value } persistCurrentConversation() @@ -1243,11 +1631,6 @@ function startInlineConversation(prompt, entry = {}, files = []) { return } - if (aiApplicationDraft.value && !isAiApplicationDraftComplete(aiApplicationDraft.value)) { - advanceAiApplicationDraft(cleanPrompt, files) - return - } - if (conversationId.value === AI_SEARCH_CONVERSATION_ID) { conversationId.value = '' conversationMessages.value = [] @@ -1362,7 +1745,11 @@ function handleInlineSuggestedAction(action = {}) { aiExpenseDraft.value = null const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel' const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费' - startAiApplicationDraft(expenseType, expenseTypeLabel) + void startAiApplicationPreview( + expenseType, + expenseTypeLabel, + actionPayload.carry_text || resolveLatestInlineUserPrompt() + ) return } if (actionType === 'select_expense_type') { @@ -1382,7 +1769,11 @@ function handleInlineSuggestedAction(action = {}) { aiExpenseDraft.value = null const expenseType = String(action?.payload?.expense_type || '').trim() const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim() - startAiApplicationDraft(expenseType, expenseTypeLabel) + void startAiApplicationPreview( + expenseType, + expenseTypeLabel, + action?.payload?.carry_text || resolveLatestInlineUserPrompt() + ) return } @@ -1423,6 +1814,46 @@ function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) } +function resolveLatestInlineUserPrompt() { + const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user') + return String(latestUserMessage?.content || '').trim() +} + +function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') { + const label = String(expenseTypeLabel || '').trim() + if (!label) { + return fallback + } + if (label.endsWith('费用申请') || label.endsWith('申请')) { + return label + } + if (label.endsWith('费用')) { + return `${label}申请` + } + if (label.endsWith('费')) { + return `${label.slice(0, -1)}费用申请` + } + return `${label}申请` +} + +function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '') { + const rawText = String(sourceText || '').trim() + const preview = rawText + ? buildLocalApplicationPreview(rawText, currentUser.value || {}) + : buildApplicationTemplatePreview(currentUser.value || {}) + const normalized = normalizeApplicationPreview(preview) + return normalizeApplicationPreview({ + ...normalized, + fields: { + ...(normalized.fields || {}), + applicationType: normalizeInlineApplicationTypeLabel( + expenseTypeLabel, + normalized.fields?.applicationType || '费用申请' + ) + } + }) +} + // 选定报销类型后,在当前对话页内启动逐项收集流程; // 差旅/招待需先查申请单,其余类型直接进入字段填写。 function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) { @@ -1537,32 +1968,28 @@ function linkAiExpenseApplication(application = {}) { scrollInlineConversationToBottom() } -// 进入申请草稿:在当前 AI 对话页内逐项收集出差申请要点, -// 不跳工作台、不调用旧 applyGuided 流程。 -function startAiApplicationDraft(expenseType, expenseTypeLabel) { - pushInlineUserMessage('在当前对话里先发起申请') - const draft = createAiApplicationDraft(expenseType, expenseTypeLabel) - aiApplicationDraft.value = draft - conversationMessages.value.push(createInlineMessage('assistant', buildAiApplicationStepPrompt(draft))) - persistCurrentConversation() - scrollInlineConversationToBottom() -} - -function advanceAiApplicationDraft(answer, files = []) { - const fileNames = Array.from(files || []) - pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : '')) - assistantDraft.value = '' - clearAiModeFiles() - - const next = applyAiApplicationAnswer(aiApplicationDraft.value, answer, fileNames) - aiApplicationDraft.value = next - - if (isAiApplicationDraftComplete(next)) { - conversationMessages.value.push(createInlineMessage('assistant', buildAiApplicationSummary(next))) - aiApplicationDraft.value = null - } else { - conversationMessages.value.push(createInlineMessage('assistant', buildAiApplicationStepPrompt(next))) +// 进入申请核对表:复用原有申请预览模型,一次性展示可编辑表格和自动测算结果。 +async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) { + if (!conversationStarted.value) { + activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' }) } + const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim() + aiExpenseDraft.value = null + assistantDraft.value = '' + removeWorkbenchDateTag() + closeWorkbenchDatePicker() + clearAiModeFiles() + if (options.pushUserMessage !== false) { + pushInlineUserMessage(options.userMessage || '确认发起出差申请') + } + const preview = await refreshApplicationPreviewEstimate( + buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText) + ) + const content = buildLocalApplicationPreviewMessage(preview) + conversationMessages.value.push(createInlineMessage('assistant', content, { + applicationPreview: preview, + text: content + })) persistCurrentConversation() scrollInlineConversationToBottom() } diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js new file mode 100644 index 0000000..079f87c --- /dev/null +++ b/web/src/services/aiApplicationPreviewActions.js @@ -0,0 +1,136 @@ +import { runOrchestrator } from './orchestrator.js' +import { + buildApplicationPreviewRows, + buildApplicationPreviewSubmitText, + normalizeApplicationPreview +} from '../utils/expenseApplicationPreview.js' + +export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft' +export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit' + +function normalizeText(value) { + return String(value || '').trim() +} + +function resolveUserValue(user = {}, ...keys) { + for (const key of keys) { + const value = normalizeText(user?.[key]) + if (value) return value + } + return '' +} + +function buildClientTimeContext() { + const now = new Date() + const locale = + typeof navigator !== 'undefined' && typeof navigator.language === 'string' + ? navigator.language + : 'zh-CN' + + return { + client_now_iso: now.toISOString(), + client_timezone_offset_minutes: now.getTimezoneOffset(), + client_locale: locale + } +} + +function buildApplicationPreviewSaveText(preview = {}) { + const rows = buildApplicationPreviewRows(preview) + return [ + '费用申请保存草稿', + ...rows.map((row) => `${row.label}:${row.value}`), + '', + '保存草稿' + ].join('\n') +} + +export function buildAiApplicationPreviewActionText(actionType, preview = {}) { + const normalized = normalizeApplicationPreview(preview) + return actionType === AI_APPLICATION_ACTION_SUBMIT + ? buildApplicationPreviewSubmitText(normalized) + : buildApplicationPreviewSaveText(normalized) +} + +export function buildAiApplicationPreviewActionPayload({ + actionType, + applicationPreview, + currentUser = {}, + conversationId = '', + draftPayload = null +} = {}) { + const normalizedPreview = normalizeApplicationPreview(applicationPreview || {}) + const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview) + const username = resolveUserValue(currentUser, 'username', 'account', 'email', 'name') || 'anonymous' + const name = resolveUserValue(currentUser, 'name', 'username') + const employeeNo = resolveUserValue(currentUser, 'employeeNo', 'employee_no') + const managerName = resolveUserValue(currentUser, 'managerName', 'manager_name', 'directManagerName', 'direct_manager_name') + const departmentName = resolveUserValue(currentUser, 'departmentName', 'department_name', 'department') + const position = resolveUserValue(currentUser, 'position', 'employeePosition', 'employee_position') + const grade = resolveUserValue(currentUser, 'grade', 'employeeGrade', 'employee_grade') + const roleCodes = Array.isArray(currentUser.roleCodes) + ? currentUser.roleCodes.map((item) => normalizeText(item)).filter(Boolean) + : [] + const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId) + const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT + + return { + source: 'user_message', + user_id: username, + conversation_id: normalizeText(conversationId) || null, + message, + context_json: { + role_codes: roleCodes, + is_admin: Boolean(currentUser.isAdmin), + name, + role: resolveUserValue(currentUser, 'role'), + department: departmentName, + department_name: departmentName, + position, + employee_position: position, + employeePosition: position, + grade, + employee_grade: grade, + employeeGrade: grade, + employee_no: employeeNo, + employeeNo, + manager_name: managerName, + managerName, + direct_manager_name: managerName, + directManagerName: managerName, + cost_center: resolveUserValue(currentUser, 'costCenter', 'cost_center'), + finance_owner_name: resolveUserValue(currentUser, 'financeOwnerName', 'finance_owner_name'), + ...buildClientTimeContext(), + session_type: 'application', + entry_source: 'workbench_ai_inline', + source: 'workbench', + document_type: 'expense_application', + application_stage: 'expense_application', + user_input_text: message, + application_preview: normalizedPreview, + ...(isSubmit + ? {} + : { + application_action: 'save_draft', + application_save_mode: true + }), + ...(draftClaimId + ? { + application_edit_claim_id: draftClaimId, + draft_claim_id: draftClaimId, + selected_claim_id: draftClaimId, + application_edit_mode: true + } + : {}) + } + } +} + +export function runAiApplicationPreviewAction(params = {}, options = {}) { + return runOrchestrator(buildAiApplicationPreviewActionPayload(params), { + timeoutMs: params.actionType === AI_APPLICATION_ACTION_SUBMIT ? 120000 : 75000, + timeoutMessage: params.actionType === AI_APPLICATION_ACTION_SUBMIT + ? '申请提交处理超时,请稍后重试。' + : '申请草稿保存超时,请稍后重试。', + ...options + }) +} diff --git a/web/src/utils/aiApplicationPrecheckModel.js b/web/src/utils/aiApplicationPrecheckModel.js new file mode 100644 index 0000000..9fdd174 --- /dev/null +++ b/web/src/utils/aiApplicationPrecheckModel.js @@ -0,0 +1,345 @@ +import { extractExpenseClaimItems } from '../services/reimbursements.js' +import { + isClaimOwnedByCurrentUser, + isExpenseApplicationClaim, + matchesRequiredApplicationExpenseType, + normalizeRequiredApplicationCandidate +} from '../views/scripts/travelReimbursementApplicationLinkModel.js' +import { + normalizeApplicationPreview, + resolveApplicationDateRange +} from './expenseApplicationPreview.js' + +const APPLICATION_BUDGET_REVIEW_THRESHOLD = 90 + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeMoney(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0 + } + const normalized = normalizeText(value).replace(/,/g, '') + const match = normalized.match(/-?\d+(?:\.\d+)?/) + const amount = match ? Number(match[0]) : 0 + return Number.isFinite(amount) && amount > 0 ? amount : 0 +} + +function formatMoney(value) { + const amount = normalizeMoney(value) + if (!amount) { + return '' + } + return `${new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: Number.isInteger(amount) ? 0 : 2, + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2 + }).format(amount)}元` +} + +function escapeMarkdownCell(value) { + return normalizeText(value).replace(/\|/g, '\\|') || '-' +} + +function buildApplicationDetailHref(item = {}) { + const claimNo = normalizeText(item.claimNo) + const reference = claimNo && claimNo !== '未编号申请单' + ? claimNo + : normalizeText(item.claimId) + return reference ? `#ai-open-application-detail:${encodeURIComponent(reference)}` : '' +} + +function buildApplicationDetailActionCell(item = {}) { + const href = buildApplicationDetailHref(item) + return href ? `[查看](${href})` : '-' +} + +function parseDate(value) { + const dateText = normalizeText(value) + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateText)) { + return null + } + const date = new Date(`${dateText}T00:00:00Z`) + return Number.isNaN(date.getTime()) ? null : date +} + +function resolveDateRange(value, daysText = '') { + const resolved = resolveApplicationDateRange(value, daysText) + if (!resolved) { + return null + } + const startText = normalizeText(resolved.startDate) + const endText = normalizeText(resolved.endDate || resolved.startDate) + const startDate = parseDate(startText) + const endDate = parseDate(endText) + if (!startDate || !endDate) { + return null + } + return startDate <= endDate + ? { startText, endText, startDate, endDate } + : { startText: endText, endText: startText, startDate: endDate, endDate: startDate } +} + +function rangesOverlap(left, right) { + return Boolean(left && right && left.startDate <= right.endDate && right.startDate <= left.endDate) +} + +function resolvePreviewDateRange(preview) { + const fields = normalizeApplicationPreview(preview).fields || {} + return resolveDateRange(fields.time, fields.days) +} + +function resolvePreviewAmount(preview) { + const normalized = normalizeApplicationPreview(preview) + const fields = normalized.fields || {} + const policyEstimate = normalized.policyEstimate && typeof normalized.policyEstimate === 'object' + ? normalized.policyEstimate + : {} + return normalizeMoney( + fields.amount || + fields.policyTotalAmount || + fields.reimbursementAmount || + policyEstimate.system_total_amount + ) +} + +function resolveApplicationClaims(claimsPayload, currentUser, expenseType) { + return extractExpenseClaimItems(claimsPayload) + .filter((claim) => ( + isExpenseApplicationClaim(claim) && + isClaimOwnedByCurrentUser(claim, currentUser) && + matchesRequiredApplicationExpenseType(claim, expenseType) + )) + .map((claim) => normalizeRequiredApplicationCandidate(claim)) +} + +function buildOverlapPrecheck(preview, claimsPayload, currentUser, expenseType) { + const targetRange = resolvePreviewDateRange(preview) + if (!targetRange) { + return { + status: 'unknown', + summary: '暂未识别到完整出差日期,无法判断是否与已有申请时间重叠。' + } + } + + const applications = resolveApplicationClaims(claimsPayload, currentUser, expenseType) + const matches = applications + .map((application) => { + const range = resolveDateRange(application.business_time) + return { + ...application, + range + } + }) + .filter((application) => rangesOverlap(targetRange, application.range)) + .slice(0, 3) + + if (!matches.length) { + return { + status: 'ok', + summary: `未发现 ${targetRange.startText} 至 ${targetRange.endText} 期间已有重叠的差旅申请单。`, + matches: [] + } + } + + return { + status: 'warning', + summary: `发现 ${matches.length} 张同时间段可能重叠的申请单,暂不能继续发起新的出差申请。`, + matches: matches.map((item) => ({ + claimId: item.id || '', + claimNo: item.claim_no || '未编号申请单', + time: item.business_time || '', + statusLabel: item.status_label || '', + reason: item.reason || '' + })) + } +} + +function isBlockingPrecheck(precheck = {}) { + return precheck?.overlap?.status === 'warning' +} + +function buildOverlapMatchTable(matches = []) { + const rows = Array.isArray(matches) ? matches : [] + if (!rows.length) { + return '' + } + return [ + '| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |', + '| --- | --- | --- | --- | --- |', + ...rows.map((item) => [ + escapeMarkdownCell(item.claimNo), + escapeMarkdownCell(item.time), + escapeMarkdownCell(item.statusLabel), + escapeMarkdownCell(item.reason), + buildApplicationDetailActionCell(item) + ].join(' | ')).map((row) => `| ${row} |`) + ].join('\n') +} + +function resolveBudgetNumbers(summary = {}) { + const totalAmount = normalizeMoney(summary.total_amount || summary.totalAmount) + const reservedAmount = normalizeMoney(summary.reserved_amount || summary.reservedAmount) + const consumedAmount = normalizeMoney(summary.consumed_amount || summary.consumedAmount) + const availableAmount = normalizeMoney(summary.available_amount || summary.availableAmount) + return { + totalAmount, + reservedAmount, + consumedAmount, + availableAmount, + usedAmount: reservedAmount + consumedAmount + } +} + +function buildBudgetPrecheck(preview, budgetSummary) { + const amount = resolvePreviewAmount(preview) + const missingFields = normalizeApplicationPreview(preview).missingFields || [] + if (!amount) { + const reason = missingFields.includes('出行方式') + ? '当前还缺出行方式,交通费用和申请总额暂未完成测算。' + : '当前申请总额暂未完成测算。' + return { + status: 'pending', + requiresBudgetReview: false, + summary: `${reason}补齐后会刷新预算占用;若达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 预算复核线或超预算,系统会增加预算管理者审核。` + } + } + + if (!budgetSummary || typeof budgetSummary !== 'object') { + return { + status: 'unknown', + requiresBudgetReview: false, + summary: `本次预计申请金额 ${formatMoney(amount)}。预算接口暂未返回,以提交时系统预算复核为准;若达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线或超预算,会增加预算管理者审核。` + } + } + + const budget = resolveBudgetNumbers(budgetSummary) + if (!budget.totalAmount) { + return { + status: 'unknown', + requiresBudgetReview: false, + summary: `本次预计申请金额 ${formatMoney(amount)}。当前部门预算总额暂未配置或暂未返回,提交时会继续做预算归口复核。` + } + } + + const afterUsed = budget.usedAmount + amount + const afterUsageRate = Number(((afterUsed / budget.totalAmount) * 100).toFixed(2)) + if (amount > budget.availableAmount) { + return { + status: 'warning', + requiresBudgetReview: true, + summary: `本次预计申请金额 ${formatMoney(amount)},当前可用预算 ${formatMoney(budget.availableAmount)},预计超出 ${formatMoney(amount - budget.availableAmount)},提交后需要预算管理者审核。` + } + } + if (afterUsageRate >= APPLICATION_BUDGET_REVIEW_THRESHOLD) { + return { + status: 'warning', + requiresBudgetReview: true, + summary: `本次预计申请金额 ${formatMoney(amount)},审批后预算占用约 ${afterUsageRate}%,达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线,提交后需要预算管理者审核。` + } + } + + return { + status: 'ok', + requiresBudgetReview: false, + summary: `本次预计申请金额 ${formatMoney(amount)},审批后预算占用约 ${afterUsageRate}%,未达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线。` + } +} + +export function buildAiApplicationPrecheck(preview = {}, { + claimsPayload = null, + budgetSummary = null, + currentUser = {}, + expenseType = 'travel', + budgetError = null +} = {}) { + const normalizedPreview = normalizeApplicationPreview(preview) + const budget = budgetError + ? { + status: 'unknown', + requiresBudgetReview: false, + summary: `预算接口暂未返回:${normalizeText(budgetError?.message || budgetError) || '当前无可用预算数据'}。提交时系统仍会按预算余额、风险规则判断是否增加预算管理者审核。` + } + : buildBudgetPrecheck(normalizedPreview, budgetSummary) + return { + overlap: buildOverlapPrecheck(normalizedPreview, claimsPayload, currentUser, expenseType), + budget, + missingFields: Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : [] + } +} + +export function buildAiApplicationPrecheckThinkingEvents(precheck = {}) { + const blocked = isBlockingPrecheck(precheck) + return [ + { + eventId: 'application-precheck-overlap', + title: '核查同时间段申请单', + content: precheck?.overlap?.summary || '已完成已有申请单核查。', + status: precheck?.overlap?.status === 'warning' ? 'completed' : 'completed' + }, + { + eventId: 'application-precheck-budget', + title: '评估预算与审批影响', + content: precheck?.budget?.summary || '已完成预算影响评估。', + status: 'completed' + }, + { + eventId: 'application-precheck-form', + title: blocked ? '暂停生成申请表' : '生成申请表草稿', + content: blocked + ? '因发现同时间段已有申请单,已暂停生成新的申请表,等待用户核对申请时间。' + : '已将识别到的时间、地点、事由和申请人信息预填到申请表。', + status: 'completed' + } + ] +} + +export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) { + if (isBlockingPrecheck(precheck)) { + const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches) + const lines = [ + '### 发现同时间段已有申请单', + '', + '**我已完成发起前的单据重叠核查**,当前不能继续生成新的出差申请表。', + '', + `> **时间重叠提醒**:${precheck?.overlap?.summary || '发现同时间段已有申请单,暂不能继续发起新的出差申请。'}`, + ] + if (matchTable) { + lines.push('', matchTable) + } + lines.push( + '', + '> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。', + '', + '我会先暂停本次申请表生成,不会开放保存草稿或提交入口。' + ) + return lines.join('\n') + } + + const normalized = normalizeApplicationPreview(preview) + const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : [] + const missingText = missingFields.length ? missingFields.join('、') : '暂无' + const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**' + const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**' + const lines = [ + '### 出差申请表草稿已生成', + '', + '**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。', + '', + `> ${overlapPrefix}:${precheck?.overlap?.summary || '已完成已有单据核查。'}`, + '', + `> ${budgetPrefix}:${precheck?.budget?.summary || '已完成预算影响评估。'}`, + '', + `> **仍需补充**:${missingText}`, + '', + '请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。' + ] + + if (missingFields.length) { + lines.push('', `当前还需要补充:**${missingText}**。`) + } else { + lines.push('', '信息已基本齐全,您可以保存草稿,或直接提交进入审批。') + } + + return lines.join('\n') +} diff --git a/web/src/utils/aiConversationHtmlRenderer.js b/web/src/utils/aiConversationHtmlRenderer.js new file mode 100644 index 0000000..83f5e76 --- /dev/null +++ b/web/src/utils/aiConversationHtmlRenderer.js @@ -0,0 +1,647 @@ +const ALLOWED_COLON_HEADING_TITLES = new Set([ + '基础信息识别结果', + '报销测算参考', + '补充信息' +]) + +const BUSINESS_FIELD_LABELS = new Set([ + '时间', + '地点', + '事由', + '金额', + '费用类型', + '报销类型', + '商户', + '商户/开票方', + '客户', + '客户/项目对象', + '附件', + '附件/凭证', + '出行方式' +]) + +const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' +const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' +const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g +const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' +const TRUSTED_HTML_ALLOWED_TAGS = new Set([ + 'section', + 'article', + 'header', + 'footer', + 'div', + 'span', + 'strong', + 'a' +]) +const TRUSTED_HTML_ALLOWED_ATTRS = new Set([ + 'aria-label', + 'class', + 'data-ai-action', + 'href' +]) + +function escapeHtml(value = '') { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function sanitizeHref(href = '') { + const value = String(href || '').trim() + if (/^(https?:\/\/|#)/i.test(value)) { + return escapeHtml(value) + } + return '#' +} + +function isApplicationDetailHref(href = '') { + return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX) +} + +function isDocumentDetailHref(href = '') { + return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX) +} + +function sanitizeImageSrc(src = '') { + const value = String(src || '').trim() + if (/^(https?:\/\/|blob:|\/)/i.test(value)) { + return escapeHtml(value) + } + if (/^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/=]+$/i.test(value)) { + return escapeHtml(value) + } + return '' +} + +function renderLinkHtml(label = '', href = '') { + const sanitizedHref = sanitizeHref(href) + if (isApplicationDetailHref(href)) { + return [ + `', + label, + '' + ].join('') + } + if (isDocumentDetailHref(href)) { + return [ + `', + label, + '' + ].join('') + } + return `${label}` +} + +function renderInlineImageHtml(alt = '', src = '') { + const sanitizedSrc = sanitizeImageSrc(src) + if (!sanitizedSrc) { + return escapeHtml(alt || src) + } + return [ + `${escapeHtml(alt)}` + ].join('') +} + +function renderInlineHtml(value = '') { + let html = escapeHtml(value) + html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_match, alt, src) => ( + renderInlineImageHtml(alt, src) + )) + html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|#[^\s)]+)\)/g, (_match, label, href) => ( + renderLinkHtml(label, href) + )) + html = html.replace(/`([^`]+)`/g, '$1') + html = html.replace(/\*\*([^*]+)\*\*/g, '$1') + html = html.replace(/__([^_]+)__/g, '$1') + return html +} + +function splitColonHeadingLine(line) { + const rawLine = String(line || '') + const trimmed = rawLine.trim() + if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) { + return [rawLine] + } + + const chineseColonIndex = trimmed.indexOf(':') + const asciiColonIndex = trimmed.indexOf(':') + const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0) + if (!colonIndexes.length) { + return [rawLine] + } + + const colonIndex = Math.min(...colonIndexes) + const title = trimmed.slice(0, colonIndex) + const body = trimmed.slice(colonIndex + 1).trim() + if (!ALLOWED_COLON_HEADING_TITLES.has(title)) { + return [rawLine] + } + return body ? [`### ${title}`, '', body] : [`### ${title}`] +} + +function normalizeBusinessFieldLine(line) { + const rawLine = String(line || '') + const trimmed = rawLine.trim() + if ( + !trimmed || + trimmed.startsWith('|') || + /^[-*+]\s/.test(trimmed) || + /^#{1,6}\s/.test(trimmed) + ) { + return rawLine + } + + const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u) + if (!match) { + return rawLine + } + const label = match[1].trim() + const value = match[2].trim() + if (!BUSINESS_FIELD_LABELS.has(label) || !value) { + return rawLine + } + return `- **${label}**:${value}` +} + +function normalizeConversationText(text = '') { + const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n') + const normalizedLines = [] + let inFence = false + + lines.forEach((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence + normalizedLines.push(line) + return + } + if (inFence) { + normalizedLines.push(line) + return + } + + const nextLines = splitColonHeadingLine(line) + if (nextLines[0]?.startsWith('### ') && normalizedLines.length) { + const previousLine = normalizedLines[normalizedLines.length - 1] + if (String(previousLine || '').trim()) { + normalizedLines.push('') + } + } + normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine))) + }) + + return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim() +} + +function hasOnlyTrustedHtmlTags(html = '') { + const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi + let match = tagPattern.exec(html) + while (match) { + const tagName = String(match[1] || '').toLowerCase() + if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) { + return false + } + const attrText = String(match[2] || '') + const attrPattern = /\s([:@\w-]+)\s*=/g + let attrMatch = attrPattern.exec(attrText) + while (attrMatch) { + const attrName = String(attrMatch[1] || '').toLowerCase() + if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) { + return false + } + attrMatch = attrPattern.exec(attrText) + } + match = tagPattern.exec(html) + } + return true +} + +function sanitizeTrustedHtmlBlock(html = '') { + const value = String(html || '').trim() + if (!value || !value.includes('class="ai-document-card-list"')) { + return '' + } + if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) { + return '' + } + if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) { + return '' + } + if (!hasOnlyTrustedHtmlTags(value)) { + return '' + } + const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim()) + if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) { + return '' + } + return value +} + +function extractTrustedHtmlBlocks(text = '') { + const trustedHtmlBlocks = [] + const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => { + const sanitizedHtml = sanitizeTrustedHtmlBlock(html) + if (!sanitizedHtml) { + return '' + } + const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}` + trustedHtmlBlocks.push(sanitizedHtml) + return `\n\n${placeholder}\n\n` + }) + return { content, trustedHtmlBlocks } +} + +function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) { + return trustedHtmlBlocks.reduce((nextHtml, block, index) => { + const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}` + const paragraphPattern = new RegExp(`

${placeholder}

`, 'g') + return nextHtml + .replace(paragraphPattern, block) + .replaceAll(placeholder, block) + }, html) +} + +function isFenceLine(line = '') { + return /^\s*(```|~~~)/.test(String(line || '')) +} + +function isHeadingLine(line = '') { + return /^#{1,6}\s+/.test(String(line || '').trim()) +} + +function isQuoteLine(line = '') { + return /^>\s?/.test(String(line || '').trim()) +} + +function isUnorderedListLine(line = '') { + return /^[-*+]\s+/.test(String(line || '').trim()) +} + +function isOrderedListLine(line = '') { + return /^\d+\.\s+/.test(String(line || '').trim()) +} + +function isHorizontalRuleLine(line = '') { + return /^(-{3,}|\*{3,}|_{3,})$/.test(String(line || '').trim()) +} + +function isTableDivider(line = '') { + const cells = parseTableRow(line) + return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) +} + +function isTableStart(lines, index) { + const current = String(lines[index] || '').trim() + const next = String(lines[index + 1] || '').trim() + return current.startsWith('|') && next.startsWith('|') && isTableDivider(next) +} + +function parseImageLine(line = '') { + const match = String(line || '').trim().match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/) + if (!match) { + return null + } + const src = sanitizeImageSrc(match[2]) + if (!src) { + return null + } + return { + alt: String(match[1] || '').trim(), + src + } +} + +function parseTableRow(line = '') { + const trimmed = String(line || '').trim() + if (!trimmed.startsWith('|')) { + return [] + } + return trimmed + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()) +} + +function splitLabelAndBody(rawText = '') { + const text = String(rawText || '').trim() + const strongMatch = text.match(/^\*\*([^*]+)\*\*[::]\s*(.*)$/u) + if (strongMatch) { + return { + label: strongMatch[1].trim(), + body: strongMatch[2].trim() + } + } + + const plainText = text.replace(/\*\*/g, '') + const match = plainText.match(/^([^::\n]{2,20})[::]\s*(.*)$/u) + if (!match) { + return null + } + return { + label: match[1].trim(), + body: match[2].trim() + } +} + +function isSpecialBlockStart(lines, index) { + const line = String(lines[index] || '').trim() + return ( + !line || + isFenceLine(line) || + isHeadingLine(line) || + isQuoteLine(line) || + isUnorderedListLine(line) || + isOrderedListLine(line) || + isHorizontalRuleLine(line) || + Boolean(parseImageLine(line)) || + isTableStart(lines, index) + ) +} + +function nextNonEmptyLineMatches(lines, index, predicate) { + let cursor = index + 1 + while (cursor < lines.length) { + const nextLine = String(lines[cursor] || '').trim() + if (nextLine) { + return predicate(nextLine) + } + cursor += 1 + } + return false +} + +function renderHeading(line = '') { + const match = String(line || '').trim().match(/^(#{1,6})\s+(.+)$/) + if (!match) { + return '' + } + const level = Math.min(Math.max(match[1].length, 2), 4) + const className = level === 3 ? 'ai-html-title' : `ai-html-title ai-html-title--level-${level}` + return `${renderInlineHtml(match[2])}` +} + +function renderParagraph(lines = []) { + const text = lines.map((line) => String(line || '').trim()).filter(Boolean).join(' ') + return text ? `

${renderInlineHtml(text)}

` : '' +} + +function renderImageBlock(line = '') { + const image = parseImageLine(line) + if (!image) { + return '' + } + return [ + '
', + `${escapeHtml(image.alt)}`, + image.alt ? `
${escapeHtml(image.alt)}
` : '', + '
' + ].join('') +} + +function renderQuoteBlock(items = []) { + const normalizedItems = items + .map((item) => String(item || '').replace(/^>\s?/, '').trim()) + .filter(Boolean) + if (!normalizedItems.length) { + return '' + } + + const focusItems = normalizedItems + .map((item) => splitLabelAndBody(item)) + .filter(Boolean) + if (focusItems.length === normalizedItems.length) { + return [ + '
', + ...focusItems.map((item) => [ + '
', + `${renderInlineHtml(item.label)}`, + `

${renderInlineHtml(item.body)}

`, + '
' + ].join('')), + '
' + ].join('') + } + + return [ + '' + ].join('') +} + +function renderUnorderedList(items = []) { + const parsedItems = items + .map((item) => String(item || '').trim().replace(/^[-*+]\s+/, '').trim()) + .filter(Boolean) + const structuredItems = parsedItems + .map((item) => splitLabelAndBody(item)) + .filter(Boolean) + + if (structuredItems.length === parsedItems.length && parsedItems.length > 0) { + return [ + '' + ].join('') + } + + return [ + '' + ].join('') +} + +function renderOrderedList(items = []) { + const parsedItems = items + .map((item) => String(item || '').trim().replace(/^\d+\.\s+/, '').trim()) + .filter(Boolean) + return [ + '
    ', + ...parsedItems.map((item) => `
  1. ${renderInlineHtml(item)}
  2. `), + '
' + ].join('') +} + +function renderTable(lines = []) { + const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length) + if (rows.length < 2) { + return '' + } + const header = rows[0] + const bodyRows = rows.slice(2) + + return [ + '
', + '', + '', + ...header.map((cell) => ``), + '', + '', + ...bodyRows.map((row) => [ + '', + ...header.map((_cell, index) => ``), + '' + ].join('')), + '', + '
${renderInlineHtml(cell)}
${renderInlineHtml(row[index] || '')}
', + '
' + ].join('') +} + +function renderCodeBlock(lines = []) { + const code = lines.join('\n').replace(/\n$/, '') + return `
${escapeHtml(code)}
` +} + +export function renderAiConversationHtml(content = '') { + const extracted = extractTrustedHtmlBlocks(content) + const normalized = normalizeConversationText(extracted.content) + if (!normalized) { + return '' + } + + const lines = normalized.split('\n') + const blocks = [] + let index = 0 + + while (index < lines.length) { + const line = String(lines[index] || '') + const trimmed = line.trim() + + if (!trimmed) { + index += 1 + continue + } + + if (isFenceLine(trimmed)) { + index += 1 + const codeLines = [] + while (index < lines.length && !isFenceLine(lines[index])) { + codeLines.push(lines[index]) + index += 1 + } + if (index < lines.length) { + index += 1 + } + blocks.push(renderCodeBlock(codeLines)) + continue + } + + if (isHeadingLine(trimmed)) { + blocks.push(renderHeading(trimmed)) + index += 1 + continue + } + + if (isTableStart(lines, index)) { + const tableLines = [] + while (index < lines.length && String(lines[index] || '').trim().startsWith('|')) { + tableLines.push(lines[index]) + index += 1 + } + blocks.push(renderTable(tableLines)) + continue + } + + if (parseImageLine(trimmed)) { + blocks.push(renderImageBlock(trimmed)) + index += 1 + continue + } + + if (isQuoteLine(trimmed)) { + const quoteItems = [] + while (index < lines.length) { + const current = String(lines[index] || '').trim() + if (isQuoteLine(current)) { + quoteItems.push(current) + index += 1 + continue + } + if (!current && isQuoteLine(String(lines[index + 1] || '').trim())) { + index += 1 + continue + } + break + } + blocks.push(renderQuoteBlock(quoteItems)) + continue + } + + if (isUnorderedListLine(trimmed)) { + const listItems = [] + while (index < lines.length) { + const current = String(lines[index] || '').trim() + if (isUnorderedListLine(current)) { + listItems.push(lines[index]) + index += 1 + continue + } + if (!current && nextNonEmptyLineMatches(lines, index, isUnorderedListLine)) { + index += 1 + continue + } + break + } + blocks.push(renderUnorderedList(listItems)) + continue + } + + if (isOrderedListLine(trimmed)) { + const listItems = [] + while (index < lines.length) { + const current = String(lines[index] || '').trim() + if (isOrderedListLine(current)) { + listItems.push(lines[index]) + index += 1 + continue + } + if (!current && nextNonEmptyLineMatches(lines, index, isOrderedListLine)) { + index += 1 + continue + } + break + } + blocks.push(renderOrderedList(listItems)) + continue + } + + if (isHorizontalRuleLine(trimmed)) { + blocks.push('
') + index += 1 + continue + } + + const paragraphLines = [] + while (index < lines.length && !isSpecialBlockStart(lines, index)) { + paragraphLines.push(lines[index]) + index += 1 + } + blocks.push(renderParagraph(paragraphLines)) + } + + return restoreTrustedHtmlBlocks( + `
${blocks.filter(Boolean).join('')}
`, + extracted.trustedHtmlBlocks + ) +} diff --git a/web/src/utils/aiDocumentQueryModel.js b/web/src/utils/aiDocumentQueryModel.js new file mode 100644 index 0000000..26e6c7e --- /dev/null +++ b/web/src/utils/aiDocumentQueryModel.js @@ -0,0 +1,784 @@ +import { extractExpenseClaimItems } from '../services/reimbursements.js' + +const DOCUMENT_QUERY_LIMIT = 8 + +const STATUS_LABELS = { + draft: '草稿', + submitted: '审批中', + pending: '待处理', + approved: '已审批', + completed: '已完成', + archived: '已归档', + returned: '已退回', + rejected: '已驳回', + pending_payment: '待付款', + paid: '已付款' +} + +const TYPE_LABELS = { + travel: '差旅费', + travel_application: '差旅费用申请', + expense_application: '费用申请', + application: '费用申请', + office: '办公用品费', + transport: '交通费', + hotel: '住宿费', + meal: '业务招待费', + entertainment: '业务招待费', + meeting: '会务费', + training: '培训费', + software: '软件服务费', + other: '其他费用' +} + +const STATUS_FILTERS = [ + { label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ }, + { label: '审批中', keys: ['submitted', 'pending'], pattern: /审批中|审核中|待审批|待审核|待审|已提交/ }, + { label: '已审批', keys: ['approved'], pattern: /已审批|审批通过|已通过|已批准/ }, + { label: '已完成', keys: ['completed'], pattern: /已完成|完成/ }, + { label: '已归档', keys: ['archived'], pattern: /已归档|归档/ }, + { label: '已退回', keys: ['returned'], pattern: /已退回|退回|待补充/ }, + { label: '已驳回', keys: ['rejected'], pattern: /已驳回|驳回|拒绝/ }, + { label: '待付款', keys: ['pending_payment'], pattern: /待付款|待支付/ }, + { label: '已付款', keys: ['paid'], pattern: /已付款|已支付/ } +] + +const EXPENSE_TYPE_FILTERS = [ + { label: '差旅费', codes: ['travel', 'travel_application'], pattern: /差旅|出差|差旅费|差旅费用/ }, + { label: '交通费', codes: ['transport'], pattern: /交通|火车|机票|打车|出租|网约车/ }, + { label: '住宿费', codes: ['hotel'], pattern: /住宿|酒店|宾馆/ }, + { label: '业务招待费', codes: ['meal', 'entertainment'], pattern: /招待|餐饮|宴请|客户餐/ }, + { label: '办公用品费', codes: ['office'], pattern: /办公|办公用品|采购/ }, + { label: '会务费', codes: ['meeting'], pattern: /会务|会议/ }, + { label: '培训费', codes: ['training'], pattern: /培训/ }, + { label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ } +] + +const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 2, + maximumFractionDigits: 2 +}) + +function normalizeText(value) { + return String(value ?? '').trim() +} + +function escapeHtml(value = '') { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function compactText(value) { + return normalizeText(value).replace(/\s+/g, '') +} + +function normalizeDateText(value) { + const text = normalizeText(value) + const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/) + if (!matched) { + return '' + } + return [ + matched[1], + String(matched[2]).padStart(2, '0'), + String(matched[3]).padStart(2, '0') + ].join('-') +} + +function parseDate(value) { + const text = normalizeDateText(value) + if (!text) { + return null + } + const date = new Date(`${text}T00:00:00Z`) + return Number.isNaN(date.getTime()) ? null : date +} + +function formatDate(date) { + return date.toISOString().slice(0, 10) +} + +function resolveToday(options = {}) { + return parseDate(options.today) || new Date() +} + +function lastDayOfMonth(year, month) { + return new Date(Date.UTC(year, month, 0)).getUTCDate() +} + +function buildMonthRange(year, month) { + const normalizedMonth = String(month).padStart(2, '0') + return { + start: `${year}-${normalizedMonth}-01`, + end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`, + label: `${year}年${month}月` + } +} + +function resolveTimeRange(prompt, options = {}) { + const text = compactText(prompt) + const today = resolveToday(options) + const todayText = formatDate(today) + + const explicitMonth = text.match(/(?:(?20\d{2})年?)?(?\d{1,2})月(?!\d{1,2})/) + if (explicitMonth?.groups) { + const year = Number(explicitMonth.groups.year || today.getUTCFullYear()) + const month = Number(explicitMonth.groups.month) + if (month >= 1 && month <= 12) { + return buildMonthRange(year, month) + } + } + + const explicitRange = text.match(/(?:(?20\d{2})年?)?(?\d{1,2})月(?\d{1,2})日?(?:至|到|~|-|—|–)(?:(?\d{1,2})月)?(?\d{1,2})日?/) + if (explicitRange?.groups) { + const year = Number(explicitRange.groups.year || today.getUTCFullYear()) + const startMonth = Number(explicitRange.groups.startMonth) + const endMonth = Number(explicitRange.groups.endMonth || startMonth) + const start = `${year}-${String(startMonth).padStart(2, '0')}-${String(explicitRange.groups.startDay).padStart(2, '0')}` + const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(explicitRange.groups.endDay).padStart(2, '0')}` + return { start, end, label: `${start} 至 ${end}` } + } + + const explicitDay = text.match(/(?:(?20\d{2})年?)?(?\d{1,2})月(?\d{1,2})日?/) + if (explicitDay?.groups) { + const year = Number(explicitDay.groups.year || today.getUTCFullYear()) + const value = `${year}-${String(explicitDay.groups.month).padStart(2, '0')}-${String(explicitDay.groups.day).padStart(2, '0')}` + return { start: value, end: value, label: value } + } + + if (/今天|今日/.test(text)) { + return { start: todayText, end: todayText, label: '今天' } + } + + if (/昨天/.test(text)) { + const date = new Date(today.getTime()) + date.setUTCDate(date.getUTCDate() - 1) + const value = formatDate(date) + return { start: value, end: value, label: '昨天' } + } + + if (/本月|这个月|当月/.test(text)) { + return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1) + } + + if (/上月|上个月/.test(text)) { + const date = new Date(today.getTime()) + date.setUTCMonth(date.getUTCMonth() - 1) + return buildMonthRange(date.getUTCFullYear(), date.getUTCMonth() + 1) + } + + if (/今年|本年/.test(text)) { + const year = today.getUTCFullYear() + return { start: `${year}-01-01`, end: `${year}-12-31`, label: `${year}年` } + } + + const recent = text.match(/近(?\d{1,3})天/) + if (recent?.groups?.days) { + const days = Math.max(1, Number(recent.groups.days)) + const start = new Date(today.getTime()) + start.setUTCDate(start.getUTCDate() - days + 1) + return { start: formatDate(start), end: todayText, label: `近${days}天` } + } + + return null +} + +function resolveDocumentType(prompt) { + const text = compactText(prompt) + if (/申请单|申请类单据|申请类/.test(text)) { + return 'application' + } + if (/报销单|报销类单据|报销类/.test(text)) { + return 'reimbursement' + } + return 'all' +} + +function resolveStatusFilter(prompt) { + const text = compactText(prompt) + return STATUS_FILTERS.find((item) => item.pattern.test(text)) || null +} + +function resolveExpenseTypeFilter(prompt) { + const text = compactText(prompt) + return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null +} + +function normalizeAmountText(value = '') { + const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/) + if (!matched) { + return null + } + const amount = Number(matched[0]) + return Number.isFinite(amount) ? amount : null +} + +function resolveAmountFilter(prompt) { + const text = compactText(prompt) + const range = text.match(/金额(?:在|为)?(?\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?\d+(?:\.\d+)?)(?:元)?/) + if (range?.groups) { + const min = normalizeAmountText(range.groups.min) + const max = normalizeAmountText(range.groups.max) + if (min !== null && max !== null) { + return { + min: Math.min(min, max), + max: Math.max(min, max), + label: `${Math.min(min, max)}-${Math.max(min, max)}元` + } + } + } + + const minMatch = text.match(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?\d+(?:\.\d+)?)(?:元)?/) + || text.match(/(?\d+(?:\.\d+)?)(?:元)?以上/) + if (minMatch?.groups?.amount) { + const min = normalizeAmountText(minMatch.groups.amount) + return min === null ? null : { min, max: null, label: `不少于${min}元` } + } + + const maxMatch = text.match(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?\d+(?:\.\d+)?)(?:元)?/) + || text.match(/(?\d+(?:\.\d+)?)(?:元)?以下/) + if (maxMatch?.groups?.amount) { + const max = normalizeAmountText(maxMatch.groups.amount) + return max === null ? null : { min: null, max, label: `不超过${max}元` } + } + return null +} + +function normalizeKeywordCandidate(value = '') { + return normalizeText(value) + .replace(/^(的|是|为|包含|含有)+/u, '') + .replace(/(相关的?|有关的?|单据|单子|申请单|报销单|审核单|审批单|有哪些|有吗|查询|查看)$/u, '') + .replace(/的$/u, '') + .trim() +} + +function resolveKeywordFilter(prompt) { + const text = normalizeText(prompt) + const compact = compactText(prompt) + const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[::\s]*(?[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u) + const relatedMatch = compact.match(/(?[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u) + const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '') + if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) { + return null + } + return { keyword, label: keyword } +} + +function resolveSource(prompt) { + const text = compactText(prompt) + if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) { + return { + source: 'approval', + sourceLabel: '待我审核的单据' + } + } + return { + source: 'mine', + sourceLabel: '我的单据' + } +} + +export function resolveAiDocumentQueryIntent(prompt, options = {}) { + const text = compactText(prompt) + if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) { + return null + } + if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) { + return null + } + const source = resolveSource(text) + const documentType = resolveDocumentType(text) + const statusFilter = resolveStatusFilter(text) + const expenseTypeFilter = resolveExpenseTypeFilter(text) + const keywordFilter = resolveKeywordFilter(prompt) + const amountFilter = resolveAmountFilter(text) + return { + ...source, + documentType, + documentTypeLabel: documentType === 'application' + ? '申请单' + : documentType === 'reimbursement' + ? '报销单' + : '全部单据', + timeRange: resolveTimeRange(text, options), + statusFilter, + expenseTypeFilter, + keywordFilter, + amountFilter + } +} + +function resolveDocumentNo(claim = {}) { + return normalizeText(claim.claim_no || claim.claimNo || claim.documentNo || claim.id || claim.claim_id) +} + +function resolveClaimId(claim = {}) { + return normalizeText(claim.id || claim.claim_id || claim.claimId || resolveDocumentNo(claim)) +} + +function resolveDocumentTypeCode(claim = {}) { + const explicitType = normalizeText( + claim.document_type_code + || claim.documentTypeCode + || claim.document_type + || claim.documentType + ).toLowerCase() + const expenseType = normalizeText(claim.expense_type || claim.expenseType || claim.typeCode).toLowerCase() + const documentNo = resolveDocumentNo(claim).toUpperCase() + if ( + explicitType === 'application' + || explicitType === 'expense_application' + || expenseType === 'application' + || expenseType.endsWith('_application') + || documentNo.startsWith('AP-') + || documentNo.startsWith('APP-') + ) { + return 'application' + } + return 'reimbursement' +} + +function resolveStatusLabel(claim = {}) { + const key = normalizeText(claim.status || claim.state || claim.approval_status || claim.approvalStatus).toLowerCase() + return normalizeText(claim.status_label || claim.statusLabel || claim.approval_stage || claim.approvalStage) || STATUS_LABELS[key] || '待确认' +} + +// 状态语义化分类,驱动卡片着色:进行中 / 正向终态 / 需关注 / 异常终态 +function resolveStatusTone(statusLabel = '') { + const text = normalizeText(statusLabel) + if (/草稿|已退回|退回|待补充/.test(text)) { + return 'is-warning' + } + if (/已驳回|驳回|已拒绝|拒绝/.test(text)) { + return 'is-danger' + } + if (/已批准|已审批|已完成|已付款|已支付|已归档|已报销/.test(text)) { + return 'is-success' + } + return 'is-pending' +} + +function resolveStatusKey(claim = {}) { + return normalizeText(claim.status || claim.state || claim.approval_status || claim.approvalStatus).toLowerCase() +} + +function resolveReason(claim = {}) { + return normalizeText(claim.reason || claim.business_reason || claim.description || claim.title || claim.note) || '未填写事由' +} + +function resolveExpenseTypeLabel(claim = {}) { + const key = normalizeText(claim.expense_type || claim.expenseType || claim.type_code || claim.typeCode).toLowerCase() + return TYPE_LABELS[key] || TYPE_LABELS.other +} + +function resolveExpenseTypeCode(claim = {}) { + return normalizeText(claim.expense_type || claim.expenseType || claim.type_code || claim.typeCode).toLowerCase() +} + +function pickText(source = {}, keys = [], fallback = '') { + for (const key of keys) { + const value = normalizeText(source[key]) + if (value) { + return value + } + } + return fallback +} + +function pickRawValue(source = {}, keys = []) { + for (const key of keys) { + const value = source[key] + if (value !== undefined && value !== null && normalizeText(value)) { + return value + } + } + return null +} + +function normalizeMoneyValue(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + const normalized = normalizeText(value).replace(/,/g, '') + if (!normalized) { + return null + } + const matched = normalized.match(/-?\d+(?:\.\d+)?/) + if (!matched) { + return null + } + const amount = Number(matched[0]) + return Number.isFinite(amount) ? amount : null +} + +function resolveAmountLabel(claim = {}) { + const rawValue = pickRawValue(claim, [ + 'amount', + 'total_amount', + 'totalAmount', + 'claimed_amount', + 'claimedAmount', + 'application_amount', + 'applicationAmount', + 'budget_amount', + 'budgetAmount', + 'estimated_amount', + 'estimatedAmount' + ]) + const amount = normalizeMoneyValue(rawValue) + return amount === null ? '待确认' : MONEY_FORMATTER.format(amount) +} + +function resolveAmountValue(claim = {}) { + return normalizeMoneyValue(pickRawValue(claim, [ + 'amount', + 'total_amount', + 'totalAmount', + 'claimed_amount', + 'claimedAmount', + 'application_amount', + 'applicationAmount', + 'budget_amount', + 'budgetAmount', + 'estimated_amount', + 'estimatedAmount' + ])) +} + +function resolveOwnerLabel(claim = {}) { + return pickText(claim, [ + 'applicant_name', + 'applicantName', + 'employee_name', + 'employeeName', + 'claimant_name', + 'claimantName', + 'created_by_name', + 'createdByName', + 'user_name', + 'userName', + 'applicant', + 'employee' + ], '未显示') +} + +function resolveDepartmentLabel(claim = {}) { + return pickText(claim, [ + 'department_name', + 'departmentName', + 'dept_name', + 'deptName', + 'org_name', + 'orgName', + 'department' + ], '未显示') +} + +function resolveLocationLabel(claim = {}) { + return pickText(claim, [ + 'location', + 'destination', + 'destination_city', + 'destinationCity', + 'city', + 'business_location', + 'businessLocation', + 'place' + ]) +} + +function resolveUpdatedDate(claim = {}) { + return normalizeDateText( + claim.updated_at + || claim.updatedAt + || claim.submitted_at + || claim.submittedAt + || claim.created_at + || claim.createdAt + ) +} + +function resolveRecordDate(claim = {}) { + return normalizeDateText( + claim.occurred_at + || claim.occurredAt + || claim.business_time + || claim.businessTime + || claim.submitted_at + || claim.submittedAt + || claim.created_at + || claim.createdAt + || claim.updated_at + || claim.updatedAt + ) +} + +function resolveTimeLabel(claim = {}, fallbackDate = '') { + const businessTime = pickText(claim, [ + 'business_time', + 'businessTime', + 'trip_time', + 'tripTime', + 'travel_time', + 'travelTime' + ]) + if (businessTime) { + return businessTime + } + + const startDate = normalizeDateText( + claim.start_date + || claim.startDate + || claim.trip_start_date + || claim.tripStartDate + || claim.departure_date + || claim.departureDate + ) + const endDate = normalizeDateText( + claim.end_date + || claim.endDate + || claim.trip_end_date + || claim.tripEndDate + || claim.return_date + || claim.returnDate + ) + if (startDate && endDate && startDate !== endDate) { + return `${startDate} 至 ${endDate}` + } + return startDate || endDate || fallbackDate || '待补充' +} + +function dateInRange(dateText, range) { + if (!range || !range.start || !range.end) { + return true + } + if (!dateText) { + return false + } + return dateText >= range.start && dateText <= range.end +} + +function toTimestamp(dateText) { + const date = parseDate(dateText) + return date ? date.getTime() : 0 +} + +function normalizeRecord(claim = {}) { + const documentType = resolveDocumentTypeCode(claim) + const documentNo = resolveDocumentNo(claim) + const date = resolveRecordDate(claim) + const updatedDate = resolveUpdatedDate(claim) + const reason = resolveReason(claim) + const expenseTypeCode = resolveExpenseTypeCode(claim) + const typeLabel = resolveExpenseTypeLabel(claim) + const statusLabel = resolveStatusLabel(claim) + const ownerLabel = resolveOwnerLabel(claim) + const departmentLabel = resolveDepartmentLabel(claim) + const locationLabel = resolveLocationLabel(claim) + return { + id: resolveClaimId(claim), + claimId: resolveClaimId(claim), + claimNo: documentNo, + documentNo, + documentType, + documentTypeLabel: documentType === 'application' ? '申请单' : '报销单', + expenseTypeCode, + typeLabel, + time: resolveTimeLabel(claim, date), + dateKey: date, + updatedTime: updatedDate || '未显示', + statusKey: resolveStatusKey(claim), + statusLabel, + statusTone: resolveStatusTone(statusLabel), + reason, + amountLabel: resolveAmountLabel(claim), + amountValue: resolveAmountValue(claim), + ownerLabel, + departmentLabel, + locationLabel, + searchableText: compactText([ + documentNo, + reason, + ownerLabel, + departmentLabel, + locationLabel, + typeLabel, + statusLabel + ].join(' ')) + } +} + +function matchesStatusFilter(record = {}, statusFilter = null) { + if (!statusFilter) { + return true + } + return statusFilter.keys.includes(record.statusKey) || statusFilter.label === record.statusLabel +} + +function matchesExpenseTypeFilter(record = {}, expenseTypeFilter = null) { + if (!expenseTypeFilter) { + return true + } + return expenseTypeFilter.codes.includes(record.expenseTypeCode) +} + +function matchesKeywordFilter(record = {}, keywordFilter = null) { + if (!keywordFilter?.keyword) { + return true + } + return record.searchableText.includes(compactText(keywordFilter.keyword)) +} + +function matchesAmountFilter(record = {}, amountFilter = null) { + if (!amountFilter) { + return true + } + if (record.amountValue === null || record.amountValue === undefined) { + return false + } + if (amountFilter.min !== null && amountFilter.min !== undefined && record.amountValue < amountFilter.min) { + return false + } + if (amountFilter.max !== null && amountFilter.max !== undefined && record.amountValue > amountFilter.max) { + return false + } + return true +} + +export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) { + const rows = extractExpenseClaimItems(claimsPayload) + .map((claim) => normalizeRecord(claim)) + .filter((record) => ( + !intent?.documentType || + intent.documentType === 'all' || + record.documentType === intent.documentType + )) + .filter((record) => dateInRange(record.dateKey, intent?.timeRange)) + .filter((record) => matchesStatusFilter(record, intent?.statusFilter)) + .filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter)) + .filter((record) => matchesKeywordFilter(record, intent?.keywordFilter)) + .filter((record) => matchesAmountFilter(record, intent?.amountFilter)) + .sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey)) + + return rows +} + +function buildDocumentDetailHref(record = {}) { + const reference = normalizeText(record.documentNo || record.claimNo || record.claimId || record.id) + return reference ? `#ai-open-document-detail:${encodeURIComponent(reference)}` : '' +} + +function buildDocumentCardHtml(record = {}) { + const href = buildDocumentDetailHref(record) + const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement' + const statusTone = record.statusTone || 'is-pending' + const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额' + + // footer 左侧辅助元信息:业务地点(可选)+ 时间 + const metaParts = [] + if (record.locationLabel) { + metaParts.push(`${escapeHtml(record.locationLabel)}`) + } + metaParts.push(`${escapeHtml(record.time || '待补充')}`) + const metaHtml = `
${metaParts.join('·')}
` + + return [ + `
`, + '
', + '
', + `${escapeHtml(record.statusLabel)}`, + `${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}`, + '
', + `${escapeHtml(record.documentNo || '未编号单据')}`, + '
', + '
', + `${escapeHtml(record.reason)}`, + '
', + `${escapeHtml(record.ownerLabel)}`, + '·', + `${escapeHtml(record.departmentLabel)}`, + '
', + '
', + '
', + metaHtml, + '
', + `${escapeHtml(amountLabel)}`, + `${escapeHtml(record.amountLabel)}`, + '
', + href + ? `查看详情` + : '', + '
', + '
' + ].join('') +} + +function buildDocumentCardsHtml(records = []) { + return [ + '', + '
', + ...records.map((record) => buildDocumentCardHtml(record)), + '
', + '' + ].join('\n') +} + +function buildQueryScopeText(intent = {}) { + return [ + intent.sourceLabel || '相关单据', + intent.documentTypeLabel && intent.documentTypeLabel !== '全部单据' ? intent.documentTypeLabel : '', + intent.timeRange?.label || '', + intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '', + intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '', + intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '', + intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '' + ].filter(Boolean).join(' / ') +} + +export function buildAiDocumentQueryConditionSummary(intent = {}) { + const conditions = [ + `查询来源:${intent.sourceLabel || '相关单据'}`, + `单据类型:${intent.documentTypeLabel || '全部单据'}`, + `时间范围:${intent.timeRange?.label || '不限'}`, + intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '', + intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '', + intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '', + intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '' + ].filter(Boolean) + return conditions.join(';') +} + +export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) { + const records = filterAiDocumentQueryRecords(claimsPayload, intent) + const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT) + const scopeText = buildQueryScopeText(intent) + + if (!records.length) { + return [ + '### 未查询到相关单据', + '', + `**查询范围**:${scopeText || '相关单据'}。`, + '', + '当前没有匹配的单据。可以继续告诉我更具体的单据类型、时间范围或状态,我会重新筛选。' + ].join('\n') + } + + const lines = [ + '### 已查询到相关单据', + '', + `**查询范围**:${scopeText || '相关单据'};共找到 **${records.length}** 张,先展示最近 **${visibleRecords.length}** 张。`, + '', + buildDocumentCardsHtml(visibleRecords) + ] + + if (records.length > visibleRecords.length) { + lines.push('', `还有 ${records.length - visibleRecords.length} 张未展示;可以继续补充时间、类型或状态缩小范围。`) + } + return lines.join('\n') +} diff --git a/web/src/utils/archiveCenterListFilters.js b/web/src/utils/archiveCenterListFilters.js index 9d994f5..c2a5a3d 100644 --- a/web/src/utils/archiveCenterListFilters.js +++ b/web/src/utils/archiveCenterListFilters.js @@ -3,13 +3,33 @@ import { isRiskSummaryWithRisk, normalizeRiskFlagTone } from './riskFlags.js' +import { canViewRiskForContext } from './riskVisibility.js' export const ARCHIVE_FILTER_ALL = 'all' -export function countClaimRisks(riskFlags, riskSummary) { +// 按当前查看者可见性过滤风险 flag,确保列表与详情页对同一用户展示一致的风险口径。 +// viewerOptions 为空时(如未提供用户上下文)原样返回,保持向后兼容。 +function filterRiskFlagsForViewer(riskFlags, viewerOptions) { + const flags = Array.isArray(riskFlags) ? riskFlags : [] + if (!viewerOptions || !viewerOptions.request) { + return flags + } + return flags.filter((flag) => { + if (!isActionableRiskFlag(flag)) { + return false + } + if (flag && typeof flag === 'object') { + return canViewRiskForContext(flag, viewerOptions) + } + return true + }) +} + +export function countClaimRisks(riskFlags, riskSummary, viewerOptions) { let count = 0 - for (const flag of Array.isArray(riskFlags) ? riskFlags : []) { + const visibleFlags = filterRiskFlagsForViewer(riskFlags, viewerOptions) + for (const flag of visibleFlags) { if (!isActionableRiskFlag(flag)) { continue } @@ -44,10 +64,11 @@ export function countClaimRisks(riskFlags, riskSummary) { return count } -export function resolveArchiveRiskTone(riskFlags, riskSummary) { +export function resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions) { let tone = 'low' - for (const flag of Array.isArray(riskFlags) ? riskFlags : []) { + const visibleFlags = filterRiskFlagsForViewer(riskFlags, viewerOptions) + for (const flag of visibleFlags) { if (!isActionableRiskFlag(flag)) { continue } diff --git a/web/src/utils/expenseApplicationPreview.js b/web/src/utils/expenseApplicationPreview.js index 4f3d22f..d69a811 100644 --- a/web/src/utils/expenseApplicationPreview.js +++ b/web/src/utils/expenseApplicationPreview.js @@ -756,14 +756,6 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser const transportMode = String(fields.transportMode || '').trim() const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode) - if (/差旅|出差/.test(applicationType) && !transportMode) { - return { - canCalculate: false, - reason: '缺少出行方式', - payload: null - } - } - if (!shouldEstimate || !days || !location) { return { canCalculate: false, @@ -794,12 +786,8 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser } export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) { - const resultTransportMode = String(result?.transport_mode || '').trim() const fields = { - ...(preview?.fields || {}), - ...(!String(preview?.fields?.transportMode || '').trim() && resultTransportMode - ? { transportMode: resultTransportMode } - : {}) + ...(preview?.fields || {}) } const hotelRate = formatPolicyMoney(result?.hotel_rate) const hotelAmount = formatPolicyMoney(result?.hotel_amount) @@ -808,6 +796,11 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, const matchedCity = String(result?.matched_city || fields.location || '').trim() const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim() if (isTravelApplicationType(fields.applicationType) && !String(fields.transportMode || '').trim()) { + const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1 + const baseTotalAmount = parseMoneyNumber(result?.hotel_amount) + parseMoneyNumber(result?.allowance_amount) + const baseTotalDisplay = Number.isFinite(baseTotalAmount) && baseTotalAmount > 0 + ? formatPolicyMoney(baseTotalAmount) + : '' return normalizeApplicationPreview({ ...preview, fields: { @@ -816,7 +809,10 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate), subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate), transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT, - policyEstimate: APPLICATION_POLICY_PENDING_TEXT, + policyEstimate: baseTotalDisplay + ? `交通待补充 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${baseTotalDisplay}元(${days}天,不含交通)` + : APPLICATION_POLICY_PENDING_TEXT, + amount: baseTotalDisplay ? `${baseTotalDisplay}元(不含交通)` : fields.amount, matchedCity, ruleName: String(result?.rule_name || '').trim(), ruleVersion: String(result?.rule_version || '').trim(), @@ -827,7 +823,7 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, transportQueryLatencyMs: '', transportEstimateSource: '', transportEstimateConfidence: '', - policyTotalAmount: '' + policyTotalAmount: baseTotalDisplay ? `${baseTotalDisplay}元(不含交通)` : '' }, policyEstimateStatus: 'pending' }) diff --git a/web/src/utils/markdown.js b/web/src/utils/markdown.js index 0a4425b..17507c0 100644 --- a/web/src/utils/markdown.js +++ b/web/src/utils/markdown.js @@ -25,6 +25,25 @@ const ACTION_LINK_CLASS_BY_HREF = { '#review-quick-edit': 'markdown-action-link-edit', '#review-risk-panel': 'markdown-action-link-risk' } +const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' +const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g +const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' +const TRUSTED_HTML_ALLOWED_TAGS = new Set([ + 'section', + 'article', + 'header', + 'footer', + 'div', + 'span', + 'strong', + 'a' +]) +const TRUSTED_HTML_ALLOWED_ATTRS = new Set([ + 'aria-label', + 'class', + 'data-ai-action', + 'href' +]) function escapeHtml(text) { return String(text || '') @@ -43,6 +62,9 @@ function renderRiskText(text) { function resolveActionLinkClass(href) { const normalizedHref = String(href || '').trim() + if (normalizedHref.startsWith(DOCUMENT_DETAIL_HREF_PREFIX)) { + return 'markdown-action-link-document' + } return ACTION_LINK_CLASS_BY_HREF[normalizedHref] || '' } @@ -214,7 +236,76 @@ function normalizeColonHeadings(text) { return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n') } -export function renderMarkdown(text = '') { - const normalized = normalizeColonHeadings(text).trim() - return normalized ? markdown.render(normalized) : '' +function hasOnlyTrustedHtmlTags(html = '') { + const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi + let match = tagPattern.exec(html) + while (match) { + const tagName = String(match[1] || '').toLowerCase() + if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) { + return false + } + const attrText = String(match[2] || '') + const attrPattern = /\s([:@\w-]+)\s*=/g + let attrMatch = attrPattern.exec(attrText) + while (attrMatch) { + const attrName = String(attrMatch[1] || '').toLowerCase() + if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) { + return false + } + attrMatch = attrPattern.exec(attrText) + } + match = tagPattern.exec(html) + } + return true +} + +function sanitizeTrustedHtmlBlock(html = '') { + const value = String(html || '').trim() + if (!value || !value.includes('class="ai-document-card-list"')) { + return '' + } + if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) { + return '' + } + if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) { + return '' + } + if (!hasOnlyTrustedHtmlTags(value)) { + return '' + } + const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim()) + if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) { + return '' + } + return value +} + +function extractTrustedHtmlBlocks(text = '') { + const trustedHtmlBlocks = [] + const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => { + const sanitizedHtml = sanitizeTrustedHtmlBlock(html) + if (!sanitizedHtml) { + return '' + } + const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}` + trustedHtmlBlocks.push(sanitizedHtml) + return `\n\n${placeholder}\n\n` + }) + return { content, trustedHtmlBlocks } +} + +function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) { + return trustedHtmlBlocks.reduce((nextHtml, block, index) => { + const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}` + const paragraphPattern = new RegExp(`

${placeholder}

\\n?`, 'g') + return nextHtml + .replace(paragraphPattern, block) + .replaceAll(placeholder, block) + }, html) +} + +export function renderMarkdown(text = '') { + const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text) + const normalized = normalizeColonHeadings(content).trim() + return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : '' } diff --git a/web/src/utils/riskVisibility.js b/web/src/utils/riskVisibility.js index b79412b..7396cb4 100644 --- a/web/src/utils/riskVisibility.js +++ b/web/src/utils/riskVisibility.js @@ -121,9 +121,6 @@ export function resolveRiskActionability(flag, options = {}) { if (source === 'attachment_analysis') { return 'fixable_by_submitter' } - if (stage === 'expense_application') { - return 'review_decision' - } if (['policy', 'invoice', 'trip', 'amount'].includes(domain)) { return 'fixable_by_submitter' } @@ -147,9 +144,6 @@ export function resolveRiskVisibilityScope(flag, options = {}) { if (actionability === 'fixable_by_submitter') { return 'submitter' } - if (stage === 'expense_application') { - return 'leader' - } return 'finance' } @@ -226,8 +220,10 @@ export function canViewRiskForContext(flag, options = {}) { return false } if (stage === 'expense_application') { + // 申请单阶段:申请人可见可自行整改的风险(信息完整性/差旅/金额等), + // 以便申请时知晓风险及原因;预算类仅预算审批人可见;其余(画像/审批流程)仅领导/审批人可见。 if (context.isCurrentApplicant) { - return false + return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter' } if (riskDomain === 'budget' || actionability === 'budget_governance' || visibilityScope === 'budget_manager') { return context.isBudgetReviewer diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index 2084b6c..d7288ff 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -258,6 +258,7 @@ import EnterprisePagination from '../components/shared/EnterprisePagination.vue' import TableEmptyState from '../components/shared/TableEmptyState.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue' import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js' +import { useSystemState } from '../composables/useSystemState.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { extractExpenseClaimItems, @@ -377,6 +378,8 @@ const emit = defineEmits([ 'summary-change' ]) +const { currentUser } = useSystemState() + function readDocumentCenterQueryText(key) { const value = route.query?.[key] return String(Array.isArray(value) ? value[0] || '' : value || '').trim() @@ -781,7 +784,12 @@ function resolveDocumentRiskFlags(row) { function buildDocumentRiskMeta(row) { const riskFlags = resolveDocumentRiskFlags(row) const riskSummary = row?.riskSummary || row?.risk - const count = countClaimRisks(riskFlags, riskSummary) + // 列表风险标签按当前查看者可见性过滤,与详情页口径一致: + // 申请人看不到的预算治理等风险不计入列表展示的风险等级。 + const viewerOptions = currentUser.value + ? { request: row || {}, currentUser: currentUser.value } + : null + const count = countClaimRisks(riskFlags, riskSummary, viewerOptions) if (!count) { const meta = RISK_TONE_META.none return { @@ -791,7 +799,7 @@ function buildDocumentRiskMeta(row) { } } - const tone = resolveArchiveRiskTone(riskFlags, riskSummary) + const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions) const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium return { ...meta, diff --git a/web/src/views/PersonalWorkbenchView.vue b/web/src/views/PersonalWorkbenchView.vue index 90b154d..913f06b 100644 --- a/web/src/views/PersonalWorkbenchView.vue +++ b/web/src/views/PersonalWorkbenchView.vue @@ -6,6 +6,7 @@ :sidebar-command="aiSidebarCommand" @conversation-change="emit('ai-conversation-change', $event)" @conversation-history-change="emit('ai-conversation-history-change', $event)" + @open-document="emit('open-document', $event)" /> 0) - || (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value) + || (!isEditableRequest.value && isCurrentApplicant.value && hasVisibleRiskCards.value) )) function normalizeRiskDomId(value) { @@ -1750,21 +1750,24 @@ export default { } const aiAdviceTitle = computed(() => { - if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) { - return '风险提示' + if (!isEditableRequest.value && isCurrentApplicant.value) { + return isApplicationDocument.value ? '申请风险提示' : '风险提示' } if (isEditableRequest.value && isApplicationDocument.value) { return '表单自查提示' } return isEditableRequest.value ? 'AI建议' : '风险提示' }) - const aiAdviceHint = computed(() => ( - !isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value - ? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。' - : isEditableRequest.value - ? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。') - : '展示系统已识别的风险点,便于审批和后续整改。' - )) + const aiAdviceHint = computed(() => { + if (!isEditableRequest.value && isCurrentApplicant.value) { + return isApplicationDocument.value + ? '展示申请单已识别的风险点及原因,请逐条确认或补充说明后再提交给领导审批。' + : '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。' + } + return isEditableRequest.value + ? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。') + : '展示系统已识别的风险点,便于审批和后续整改。' + }) const submitActionLabel = computed(() => { return resolveSubmitActionLabel({ diff --git a/web/src/views/scripts/useApplicationPreviewEditor.js b/web/src/views/scripts/useApplicationPreviewEditor.js index dddf541..80e6494 100644 --- a/web/src/views/scripts/useApplicationPreviewEditor.js +++ b/web/src/views/scripts/useApplicationPreviewEditor.js @@ -252,6 +252,7 @@ export function useApplicationPreviewEditor({ resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, + refreshApplicationPreviewEstimate, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor, diff --git a/web/tests/ai-application-precheck-model.test.mjs b/web/tests/ai-application-precheck-model.test.mjs new file mode 100644 index 0000000..aecf1ca --- /dev/null +++ b/web/tests/ai-application-precheck-model.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildAiApplicationPrecheck, + buildAiApplicationPrecheckMessage, + buildAiApplicationPrecheckThinkingEvents +} from '../src/utils/aiApplicationPrecheckModel.js' + +const preview = { + fields: { + applicationType: '差旅费用申请', + time: '2026-02-20 至 2026-02-23', + location: '上海', + reason: '辅助国网仿生产服务器部署', + amount: '2,120元', + days: '4天', + transportMode: '火车' + }, + missingFields: [] +} + +test('application precheck blocks application generation when existing application overlaps', () => { + const precheck = buildAiApplicationPrecheck(preview, { + currentUser: { name: '曹笑竹', departmentName: '技术部' }, + claimsPayload: { + items: [ + { + claim_no: 'AP-OVERLAP', + document_type: 'expense_application', + expense_type: 'travel_application', + employee_name: '曹笑竹', + status: 'submitted', + risk_flags_json: [ + { + source: 'application_detail', + application_detail: { + business_time: '2026-02-21 至 2026-02-22', + reason: '同时间段现场支持', + location: '上海' + } + } + ] + } + ] + }, + budgetSummary: { + total_amount: 10000, + reserved_amount: 8000, + consumed_amount: 500, + available_amount: 1500 + } + }) + + assert.equal(precheck.overlap.status, 'warning') + assert.match(precheck.overlap.summary, /可能重叠/) + assert.equal(precheck.overlap.matches[0].claimNo, 'AP-OVERLAP') + assert.equal(precheck.budget.status, 'warning') + assert.equal(precheck.budget.requiresBudgetReview, true) + assert.match(precheck.budget.summary, /预算管理者审核/) + + const message = buildAiApplicationPrecheckMessage(preview, precheck) + assert.match(message, /### 发现同时间段已有申请单/) + assert.match(message, /时间重叠提醒/) + assert.match(message, /AP-OVERLAP/) + assert.match(message, /\| 单据编号 \| 申请时间 \| 状态 \| 事由 \| 操作 \|/) + assert.match(message, /\| AP-OVERLAP \| 2026-02-21 至 2026-02-22 \| 审批中 \| 同时间段现场支持 \| \[查看\]\(#ai-open-application-detail:AP-OVERLAP\) \|/) + assert.match(message, /2026-02-21 至 2026-02-22/) + assert.match(message, /同时间段现场支持/) + assert.match(message, /请先检查本次申请时间是否填写正确/) + assert.doesNotMatch(message, /出差申请表草稿已生成/) +}) + +test('application precheck emits thinking events for overlap, budget, and form generation', () => { + const precheck = buildAiApplicationPrecheck(preview, { + currentUser: { name: '曹笑竹' }, + claimsPayload: [], + budgetSummary: { + total_amount: 10000, + reserved_amount: 1000, + consumed_amount: 1000, + available_amount: 8000 + } + }) + const events = buildAiApplicationPrecheckThinkingEvents(precheck) + + assert.equal(events.length, 3) + assert.deepEqual( + events.map((event) => event.eventId), + ['application-precheck-overlap', 'application-precheck-budget', 'application-precheck-form'] + ) + assert.match(events[1].content, /预算/) +}) + +test('application precheck ignores application candidates without parseable business time', () => { + const precheck = buildAiApplicationPrecheck(preview, { + currentUser: { name: '曹笑竹' }, + claimsPayload: { + items: [ + { + claim_no: 'AP-NO-TIME', + document_type: 'expense_application', + expense_type: 'travel_application', + employee_name: '曹笑竹', + status: 'submitted' + } + ] + }, + budgetSummary: {} + }) + + assert.equal(precheck.overlap.status, 'ok') + assert.deepEqual(precheck.overlap.matches, []) +}) diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs new file mode 100644 index 0000000..256a6cb --- /dev/null +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT, + buildAiApplicationPreviewActionPayload +} from '../src/services/aiApplicationPreviewActions.js' +import { + applyApplicationPolicyEstimateResult, + buildApplicationPolicyEstimateRequest, + buildLocalApplicationPreview +} from '../src/utils/expenseApplicationPreview.js' + +const applicationPreview = { + fields: { + applicationType: '差旅费用申请', + applicant: '曹笑竹', + grade: 'P5', + department: '技术部', + position: '财务智能化产品经理', + managerName: '向万红', + time: '2026-02-20 至 2026-02-23', + location: '上海', + reason: '辅助国网仿生产服务器部署', + days: '4天', + transportMode: '火车', + lodgingDailyCap: '250元/天', + subsidyDailyCap: '100元/天', + transportPolicy: '按交通费用预估表暂估', + policyEstimate: '交通 720元 + 住宿 1,000元 + 补贴 400元 = 2,120元(4天)', + amount: '2,120元' + } +} + +const currentUser = { + username: 'caoxiaozhu@xf.com', + name: '曹笑竹', + departmentName: '技术部', + position: '财务智能化产品经理', + grade: 'P5', + managerName: '向万红', + roleCodes: ['employee'] +} + +test('save application preview payload uses save draft action without submit wording', () => { + const payload = buildAiApplicationPreviewActionPayload({ + actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, + applicationPreview, + currentUser, + conversationId: 'inline-1' + }) + + assert.equal(payload.user_id, 'caoxiaozhu@xf.com') + assert.equal(payload.conversation_id, 'inline-1') + assert.equal(payload.context_json.session_type, 'application') + assert.equal(payload.context_json.review_action, undefined) + assert.equal(payload.context_json.application_action, 'save_draft') + assert.equal(payload.context_json.application_preview.fields.transportMode, '火车') + assert.match(payload.message, /费用申请保存草稿/) + assert.match(payload.message, /保存草稿/) + assert.doesNotMatch(payload.message, /确认提交/) +}) + +test('submit application preview payload keeps existing draft id for resubmission', () => { + const payload = buildAiApplicationPreviewActionPayload({ + actionType: AI_APPLICATION_ACTION_SUBMIT, + applicationPreview, + currentUser, + conversationId: 'inline-1', + draftPayload: { + claim_id: 'draft-001', + claim_no: 'AP-202602200001' + } + }) + + assert.equal(payload.context_json.review_action, undefined) + assert.equal(payload.context_json.application_edit_claim_id, 'draft-001') + assert.equal(payload.context_json.draft_claim_id, 'draft-001') + assert.match(payload.message, /费用申请确认提交/) + assert.match(payload.message, /确认提交/) +}) + +test('travel application preview calculates base standards before transport mode is selected', () => { + const preview = buildLocalApplicationPreview( + '2月20-23日去上海出差,辅助国网仿生产服务器部署', + { name: '曹笑竹', grade: 'P5', location: '武汉' }, + { today: '2026-06-20' } + ) + const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' }) + + assert.equal(request.canCalculate, true) + assert.deepEqual(request.payload, { + days: 4, + location: '上海', + grade: 'P5', + transport_mode: null, + origin_location: '武汉', + travel_date: '2026-02-20' + }) + + const estimatedPreview = applyApplicationPolicyEstimateResult(preview, { + days: 4, + location: '上海', + matched_city: '上海', + grade: 'P5', + hotel_rate: 450, + hotel_amount: 1800, + total_allowance_rate: 100, + allowance_amount: 400, + transport_mode: '火车', + transport_origin: '武汉', + transport_destination: '上海', + transport_estimated_amount: 720, + total_amount: 2200, + rule_name: '公司差旅费报销规则', + rule_version: 'v1.0.0' + }, { grade: 'P5', location: '武汉' }) + + assert.equal(estimatedPreview.fields.transportMode, '') + assert.equal(estimatedPreview.missingFields.includes('出行方式'), true) + assert.equal(estimatedPreview.fields.lodgingDailyCap, '450元/天') + assert.equal(estimatedPreview.fields.subsidyDailyCap, '100元/天') + assert.equal(estimatedPreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用') + assert.equal(estimatedPreview.fields.policyEstimate, '交通待补充 + 住宿 1,800元 + 补贴 400元 = 2,200元(4天,不含交通)') + assert.equal(estimatedPreview.fields.amount, '2,200元(不含交通)') +}) diff --git a/web/tests/ai-conversation-html-renderer.test.mjs b/web/tests/ai-conversation-html-renderer.test.mjs new file mode 100644 index 0000000..5a1cadd --- /dev/null +++ b/web/tests/ai-conversation-html-renderer.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js' + +test('AI conversation renderer turns business copy into spacious semantic HTML', () => { + const rendered = renderAiConversationHtml([ + '### 出差申请办理确认', + '', + '**我已在您的输入中提取到关键信息**,如下表所示:', + '', + '> **前置查询结果**:我已查询您名下可关联的差旅申请单,当前未查到可关联单据。', + '', + '> **需要您确认**:发起新的出差申请属于业务操作,需要您手动确认后我再继续办理。', + '', + '点击下方 **确认发起出差申请** 后,我会继续完成:', + '', + '- **单据重叠核查**:检查同一时间段是否已有申请单,避免重复申请。', + '- **预算与审批预审**:查看部门预算影响,判断是否可能增加预算管理者审核。' + ].join('\n')) + + assert.match(rendered, /
/) + assert.match(rendered, /

出差申请办理确认<\/h3>/) + assert.match(rendered, /
/) + assert.match(rendered, /
[\s\S]*前置查询结果[\s\S]*当前未查到可关联单据/) + assert.match(rendered, /
[\s\S]*需要您确认[\s\S]*需要您手动确认后我再继续办理/) + assert.match(rendered, /
    [\s\S]*单据重叠核查[\s\S]*预算与审批预审/) + assert.doesNotMatch(rendered, /
    /) + assert.doesNotMatch(rendered, /
      \s*
    • /) +}) + +test('AI conversation renderer supports tables and escapes unsafe HTML', () => { + const rendered = renderAiConversationHtml([ + '### 查询结果', + '', + '| 字段 | 内容 |', + '| --- | --- |', + '| 事由 | 辅助 部署 |', + '| 地点 | 上海 |' + ].join('\n')) + + assert.match(rendered, /
      /) + assert.match(rendered, /字段<\/th>/) + assert.match(rendered, /<script>alert\(1\)<\/script>/) + assert.doesNotMatch(rendered, /
', + '
', + '' + ].join('\n')) + + assert.match(rendered, /

查询结果<\/h3>/) + assert.doesNotMatch(rendered, /ai-document-card-list/) + assert.doesNotMatch(rendered, /