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 }}
+
+
+
+
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+
+
小财管家正在识别任务、拆解流程并准备下一步建议...
@@ -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 [
+ `

`
+ ].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 [
+ '
',
+ `
`,
+ 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 [
+ '
',
+ ...structuredItems.map((item, index) => [
+ '- ',
+ `${index + 1}`,
+ '
',
+ `
${renderInlineHtml(item.label)}`,
+ item.body ? `
${renderInlineHtml(item.body)}
` : '',
+ '
',
+ ' '
+ ].join('')),
+ '
'
+ ].join('')
+ }
+
+ return [
+ '
',
+ ...parsedItems.map((item) => `- ${renderInlineHtml(item)}
`),
+ '
'
+ ].join('')
+}
+
+function renderOrderedList(items = []) {
+ const parsedItems = items
+ .map((item) => String(item || '').trim().replace(/^\d+\.\s+/, '').trim())
+ .filter(Boolean)
+ return [
+ '
',
+ ...parsedItems.map((item) => `- ${renderInlineHtml(item)}
`),
+ '
'
+ ].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) => `| ${renderInlineHtml(cell)} | `),
+ '
',
+ '',
+ ...bodyRows.map((row) => [
+ '',
+ ...header.map((_cell, index) => `| ${renderInlineHtml(row[index] || '')} | `),
+ '
'
+ ].join('')),
+ '',
+ '
',
+ '
'
+ ].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)}`,
+ '
',
+ '
',
+ '',
+ ''
+ ].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, / |