feat(web): 工作台 AI 模式报销预审与文档查询模型拆分

- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出
- PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示
- 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试
- 新增 AI 文档卡片背景资源
This commit is contained in:
caoxiaozhu
2026-06-20 10:17:37 +08:00
parent 3d69f8501f
commit 304bbe1fd4
26 changed files with 3974 additions and 117 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -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;
}
}

View File

@@ -303,10 +303,121 @@
<div
v-if="message.content"
class="workbench-ai-answer-markdown"
v-html="renderInlineMarkdown(message.content)"
@click.capture="handleAiAnswerMarkdownClick($event)"
v-html="renderInlineConversationHtml(message.content)"
></div>
<div v-else-if="message.pending && !hasInlineThinking(message)" class="workbench-ai-pending-line">
<Transition name="structured-card-reveal" appear>
<div
v-if="message.applicationPreview"
class="workbench-ai-application-preview application-preview-shell"
aria-label="申请信息核对结果"
>
<div
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in resolveInlineApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click.stop="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@blur="commitInlineApplicationPreviewEditor(message)"
/>
<select
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
autofocus
@click.stop
@change="commitInlineApplicationPreviewEditor(message)"
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@blur="commitInlineApplicationPreviewEditor(message)"
>
<option value="">请选择</option>
<option
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
:key="`${message.id}-${row.key}-${option}`"
:value="option"
>
{{ option }}
</option>
</select>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="resolveInlineApplicationPreviewMissingFields(message).length"
class="application-preview-footer application-preview-footer-missing"
aria-live="polite"
>
<span class="application-preview-missing-prefix">当前还需要补充</span>
<span class="application-preview-missing-list">
<template
v-for="(field, index) in resolveInlineApplicationPreviewMissingFields(message)"
:key="`${message.id}-missing-${field}`"
>
<span class="application-preview-missing-chip">{{ field }}</span>
<span
v-if="index < resolveInlineApplicationPreviewMissingFields(message).length - 1"
class="application-preview-missing-separator"
></span>
</template>
</span>
<span class="application-preview-missing-suffix">点击表格字段补齐后费用测算会自动刷新</span>
</div>
<div
v-else-if="buildInlineApplicationPreviewFooterText(message)"
class="application-preview-footer workbench-ai-answer-markdown"
v-html="renderInlineConversationHtml(buildInlineApplicationPreviewFooterText(message))"
></div>
</div>
</Transition>
<div
v-if="!message.content && !message.applicationPreview && message.pending && !hasInlineThinking(message)"
class="workbench-ai-pending-line"
>
小财管家正在识别任务拆解流程并准备下一步建议...
</div>
@@ -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()
// 进入申请核对表:复用原有申请预览模型,一次性展示可编辑表格和自动测算结果。
async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) {
if (!conversationStarted.value) {
activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' })
}
function advanceAiApplicationDraft(answer, files = []) {
const fileNames = Array.from(files || [])
pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim()
aiExpenseDraft.value = null
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
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)))
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()
}

View File

@@ -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
})
}

View File

@@ -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')
}

View File

@@ -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*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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 [
`<a href="${sanitizedHref}"`,
' class="ai-html-action-link ai-html-action-link-application"',
' data-ai-action="open-application-detail"',
'>',
label,
'</a>'
].join('')
}
if (isDocumentDetailHref(href)) {
return [
`<a href="${sanitizedHref}"`,
' class="ai-html-action-link ai-html-action-link-document"',
' data-ai-action="open-document-detail"',
'>',
label,
'</a>'
].join('')
}
return `<a href="${sanitizedHref}" target="_blank" rel="noreferrer">${label}</a>`
}
function renderInlineImageHtml(alt = '', src = '') {
const sanitizedSrc = sanitizeImageSrc(src)
if (!sanitizedSrc) {
return escapeHtml(alt || src)
}
return [
`<img class="ai-html-inline-image" src="${sanitizedSrc}"`,
` alt="${escapeHtml(alt)}" loading="lazy" />`
].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, '<code>$1</code>')
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
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(`<p class="ai-html-paragraph">${placeholder}</p>`, '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 `<h${level} class="${className}">${renderInlineHtml(match[2])}</h${level}>`
}
function renderParagraph(lines = []) {
const text = lines.map((line) => String(line || '').trim()).filter(Boolean).join(' ')
return text ? `<p class="ai-html-paragraph">${renderInlineHtml(text)}</p>` : ''
}
function renderImageBlock(line = '') {
const image = parseImageLine(line)
if (!image) {
return ''
}
return [
'<figure class="ai-html-image-frame">',
`<img class="ai-html-image" src="${image.src}" alt="${escapeHtml(image.alt)}" loading="lazy" />`,
image.alt ? `<figcaption class="ai-html-image-caption">${escapeHtml(image.alt)}</figcaption>` : '',
'</figure>'
].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 [
'<section class="ai-html-focus-grid" aria-label="重点信息">',
...focusItems.map((item) => [
'<article class="ai-html-focus-card">',
`<span class="ai-html-focus-label">${renderInlineHtml(item.label)}</span>`,
`<p>${renderInlineHtml(item.body)}</p>`,
'</article>'
].join('')),
'</section>'
].join('')
}
return [
'<aside class="ai-html-callout">',
...normalizedItems.map((item) => `<p>${renderInlineHtml(item)}</p>`),
'</aside>'
].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 [
'<ul class="ai-html-steps">',
...structuredItems.map((item, index) => [
'<li>',
`<span class="ai-html-step-index">${index + 1}</span>`,
'<div class="ai-html-step-copy">',
`<strong>${renderInlineHtml(item.label)}</strong>`,
item.body ? `<p>${renderInlineHtml(item.body)}</p>` : '',
'</div>',
'</li>'
].join('')),
'</ul>'
].join('')
}
return [
'<ul class="ai-html-list">',
...parsedItems.map((item) => `<li>${renderInlineHtml(item)}</li>`),
'</ul>'
].join('')
}
function renderOrderedList(items = []) {
const parsedItems = items
.map((item) => String(item || '').trim().replace(/^\d+\.\s+/, '').trim())
.filter(Boolean)
return [
'<ol class="ai-html-list ai-html-list--ordered">',
...parsedItems.map((item) => `<li>${renderInlineHtml(item)}</li>`),
'</ol>'
].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 [
'<div class="ai-html-table-wrap">',
'<table>',
'<thead><tr>',
...header.map((cell) => `<th>${renderInlineHtml(cell)}</th>`),
'</tr></thead>',
'<tbody>',
...bodyRows.map((row) => [
'<tr>',
...header.map((_cell, index) => `<td>${renderInlineHtml(row[index] || '')}</td>`),
'</tr>'
].join('')),
'</tbody>',
'</table>',
'</div>'
].join('')
}
function renderCodeBlock(lines = []) {
const code = lines.join('\n').replace(/\n$/, '')
return `<pre class="ai-html-code"><code>${escapeHtml(code)}</code></pre>`
}
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('<hr class="ai-html-divider" />')
index += 1
continue
}
const paragraphLines = []
while (index < lines.length && !isSpecialBlockStart(lines, index)) {
paragraphLines.push(lines[index])
index += 1
}
blocks.push(renderParagraph(paragraphLines))
}
return restoreTrustedHtmlBlocks(
`<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`,
extracted.trustedHtmlBlocks
)
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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(/(?:(?<year>20\d{2})年?)?(?<month>\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(/(?:(?<year>20\d{2})年?)?(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?(?:至|到|~|-|—|)(?:(?<endMonth>\d{1,2})月)?(?<endDay>\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(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?<day>\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(/近(?<days>\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(/金额(?:在|为)?(?<min>\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|)(?<max>\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(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|| text.match(/(?<amount>\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(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|| text.match(/(?<amount>\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]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
const relatedMatch = compact.match(/(?<keyword>[\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(`<span class="ai-document-card__meta-item">${escapeHtml(record.locationLabel)}</span>`)
}
metaParts.push(`<span class="ai-document-card__meta-item">${escapeHtml(record.time || '待补充')}</span>`)
const metaHtml = `<div class="ai-document-card__meta">${metaParts.join('<span class="ai-document-card__dot">·</span>')}</div>`
return [
`<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`,
'<header class="ai-document-card__head">',
'<div class="ai-document-card__head-left">',
`<span class="ai-document-card__status">${escapeHtml(record.statusLabel)}</span>`,
`<span class="ai-document-card__type">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</span>`,
'</div>',
`<span class="ai-document-card__number">${escapeHtml(record.documentNo || '未编号单据')}</span>`,
'</header>',
'<div class="ai-document-card__body">',
`<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`,
'<div class="ai-document-card__owner-line">',
`<span class="ai-document-card__owner">${escapeHtml(record.ownerLabel)}</span>`,
'<span class="ai-document-card__dot">·</span>',
`<span class="ai-document-card__dept">${escapeHtml(record.departmentLabel)}</span>`,
'</div>',
'</div>',
'<footer class="ai-document-card__foot">',
metaHtml,
'<div class="ai-document-card__amount-block">',
`<span class="ai-document-card__amount-label">${escapeHtml(amountLabel)}</span>`,
`<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`,
'</div>',
href
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
: '',
'</footer>',
'</article>'
].join('')
}
function buildDocumentCardsHtml(records = []) {
return [
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据查询结果">',
...records.map((record) => buildDocumentCardHtml(record)),
'</section>',
'<!-- ai-trusted-html:end -->'
].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')
}

View File

@@ -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
}

View File

@@ -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'
})

View File

@@ -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*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\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(`<p>${placeholder}</p>\\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) : ''
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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)"
/>
<PersonalWorkbench
v-else

View File

@@ -1683,7 +1683,7 @@ export default {
)
)
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 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
const aiAdviceHint = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value) {
return isApplicationDocument.value
? '展示申请单已识别的风险点及原因,请逐条确认或补充说明后再提交给领导审批。'
: '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
}
return isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
: '展示系统已识别的风险点,便于审批和后续整改。'
))
})
const submitActionLabel = computed(() => {
return resolveSubmitActionLabel({

View File

@@ -252,6 +252,7 @@ export function useApplicationPreviewEditor({
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor,

View File

@@ -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, [])
})

View File

@@ -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元不含交通')
})

View File

@@ -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, /<div class="ai-html-flow">/)
assert.match(rendered, /<h3 class="ai-html-title">出差申请办理确认<\/h3>/)
assert.match(rendered, /<section class="ai-html-focus-grid" aria-label="重点信息">/)
assert.match(rendered, /<article class="ai-html-focus-card">[\s\S]*前置查询结果[\s\S]*当前未查到可关联单据/)
assert.match(rendered, /<article class="ai-html-focus-card">[\s\S]*需要您确认[\s\S]*需要您手动确认后我再继续办理/)
assert.match(rendered, /<ul class="ai-html-steps">[\s\S]*单据重叠核查[\s\S]*预算与审批预审/)
assert.doesNotMatch(rendered, /<blockquote>/)
assert.doesNotMatch(rendered, /<ul>\s*<li><strong>/)
})
test('AI conversation renderer supports tables and escapes unsafe HTML', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',
'',
'| 字段 | 内容 |',
'| --- | --- |',
'| 事由 | 辅助 <script>alert(1)</script> 部署 |',
'| 地点 | 上海 |'
].join('\n'))
assert.match(rendered, /<div class="ai-html-table-wrap">/)
assert.match(rendered, /<th>字段<\/th>/)
assert.match(rendered, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/)
assert.doesNotMatch(rendered, /<script>/)
})
test('AI conversation renderer renders application detail action links as buttons', () => {
const rendered = renderAiConversationHtml([
'| 单据编号 | 操作 |',
'| --- | --- |',
'| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |'
].join('\n'))
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/)
assert.match(rendered, /data-ai-action="open-application-detail"/)
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/)
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/)
})
test('AI conversation renderer renders document detail action links as buttons', () => {
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document"/)
assert.match(rendered, /data-ai-action="open-document-detail"/)
assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
})
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
const rendered = renderAiConversationHtml([
'### 图片材料',
'',
'![票据预览](https://example.com/receipt.png)',
'',
'内联图片:![危险](javascript:alert(1))'
].join('\n'))
assert.match(rendered, /<figure class="ai-html-image-frame">/)
assert.match(rendered, /<img class="ai-html-image" src="https:\/\/example\.com\/receipt\.png" alt="票据预览" loading="lazy" \/>/)
assert.match(rendered, /<figcaption class="ai-html-image-caption">票据预览<\/figcaption>/)
assert.doesNotMatch(rendered, /javascript:alert/)
})
test('AI conversation renderer keeps separated step bullets in one numbered sequence', () => {
const rendered = renderAiConversationHtml([
'点击下方 **确认发起出差申请** 后,我会继续完成:',
'',
'- **单据重叠核查**:检查同一时间段是否已有申请单,避免重复申请。',
'',
'- **预算与审批预审**:查看部门预算影响,判断是否可能增加预算管理者审核。',
'',
'- **申请表生成**:预审完成后,再展示完整申请表并自动预填已识别信息。'
].join('\n'))
assert.equal((rendered.match(/class="ai-html-steps"/g) || []).length, 1)
assert.match(rendered, /<span class="ai-html-step-index">1<\/span>[\s\S]*单据重叠核查/)
assert.match(rendered, /<span class="ai-html-step-index">2<\/span>[\s\S]*预算与审批预审/)
assert.match(rendered, /<span class="ai-html-step-index">3<\/span>[\s\S]*申请表生成/)
})

View File

@@ -0,0 +1,181 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAiDocumentQueryConditionSummary,
buildAiDocumentQueryMessage,
filterAiDocumentQueryRecords,
resolveAiDocumentQueryIntent
} from '../src/utils/aiDocumentQueryModel.js'
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
const today = '2026-06-20'
const claims = [
{
id: 'claim-1',
claim_no: 'CL-20260221001',
document_type_code: 'reimbursement',
expense_type: 'travel',
status: 'submitted',
reason: '上海出差报销',
employee_name: '曹小筑',
department_name: '交付部',
location: '上海',
occurred_at: '2026-02-21T09:00:00Z',
updated_at: '2026-02-22T10:00:00Z',
amount: 1200
},
{
id: 'app-1',
claim_no: 'AP-20260220001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'approved',
reason: '辅助国网仿生产服务器部署',
employee_name: '曹小筑',
department_name: '交付部',
location: '上海',
occurred_at: '2026-02-20T09:00:00Z',
updated_at: '2026-02-20T10:00:00Z',
amount: 3000
},
{
id: 'claim-2',
claim_no: 'CL-20260305001',
document_type_code: 'reimbursement',
expense_type: 'office',
status: 'draft',
reason: '办公用品采购',
occurred_at: '2026-03-05T09:00:00Z',
amount: 500
}
]
test('AI document query intent detects my document list questions', () => {
const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today })
assert.equal(intent?.source, 'mine')
assert.equal(intent?.documentType, 'all')
assert.equal(intent?.sourceLabel, '我的单据')
})
test('AI document query intent detects approval document questions', () => {
const intent = resolveAiDocumentQueryIntent('我有哪些审核单', { today })
assert.equal(intent?.source, 'approval')
assert.equal(intent?.sourceLabel, '待我审核的单据')
})
test('AI document query filters by month and document type', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些申请单', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.documentType, 'application')
assert.equal(intent?.timeRange?.start, '2026-02-01')
assert.equal(intent?.timeRange?.end, '2026-02-28')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
})
test('AI document query filters by single day and document type', () => {
const intent = resolveAiDocumentQueryIntent('2月20日发生的申请单有哪些', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.timeRange?.start, '2026-02-20')
assert.equal(intent?.timeRange?.end, '2026-02-20')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
})
test('AI document query combines natural-language filters', () => {
const intent = resolveAiDocumentQueryIntent('查一下2月审批中的差旅报销单金额超过1000上海相关的单据有哪些', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.documentType, 'reimbursement')
assert.equal(intent?.timeRange?.label, '2026年2月')
assert.equal(intent?.statusFilter?.label, '审批中')
assert.equal(intent?.expenseTypeFilter?.label, '差旅费')
assert.equal(intent?.keywordFilter?.label, '上海')
assert.equal(intent?.amountFilter?.min, 1000)
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001'])
assert.match(buildAiDocumentQueryConditionSummary(intent), /状态:审批中/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /费用类型:差旅费/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /关键词:上海/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额不少于1000元/)
})
test('AI document query excludes undated rows when a time condition is present', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const records = filterAiDocumentQueryRecords([
...claims,
{
id: 'no-date',
claim_no: 'CL-NO-DATE',
document_type_code: 'reimbursement',
expense_type: 'travel',
status: 'submitted',
reason: '缺少业务日期的单据',
amount: 800
}
], intent)
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001', 'AP-20260220001'])
})
test('AI document query message renders html document cards with detail actions', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const message = buildAiDocumentQueryMessage(intent, claims)
assert.match(message, /### 已查询到相关单据/)
assert.match(message, /<!-- ai-trusted-html:start -->/)
assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
// 申请单 app-1 状态为 approved → is-success 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(message, /<header class="ai-document-card__head">/)
assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/)
assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/)
assert.match(message, /<span class="ai-document-card__owner">曹小筑<\/span>/)
assert.match(message, /<span class="ai-document-card__dept">交付部<\/span>/)
assert.match(message, /<span class="ai-document-card__number">AP-20260220001<\/span>/)
assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/)
assert.match(message, /<div class="ai-document-card__meta">/)
assert.match(message, /<span class="ai-document-card__meta-item">上海<\/span>/)
assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/)
// 报销单 claim-1 状态为 submitted → is-pending 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/)
assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(message, /\| 单据编号 \|/)
assert.doesNotMatch(message, /^> /m)
})
test('AI document query html cards render as trusted card markup', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims))
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(rendered, /class="ai-document-card__head"/)
assert.match(rendered, /class="ai-document-card__meta"/)
assert.match(rendered, /class="ai-document-card__meta-item"/)
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/)
assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/)
assert.doesNotMatch(rendered, /<blockquote>/)
})
test('AI document query trusted html rejects unsafe card markup', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',
'',
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据查询结果" onclick="alert(1)">',
'<article class="ai-document-card"><script>alert(1)</script></article>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join('\n'))
assert.match(rendered, /<h3 class="ai-html-title">查询结果<\/h3>/)
assert.doesNotMatch(rendered, /ai-document-card-list/)
assert.doesNotMatch(rendered, /<script>/)
assert.doesNotMatch(rendered, /onclick=/)
})

View File

@@ -10,7 +10,8 @@ import {
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters
hasActiveArchiveListFilters,
resolveArchiveRiskTone
} from '../src/utils/archiveCenterListFilters.js'
const sampleRows = [
@@ -117,3 +118,35 @@ test('hasActiveArchiveListFilters detects active criteria', () => {
assert.equal(hasActiveArchiveListFilters({ risk: 'high' }), true)
assert.equal(hasActiveArchiveListFilters({ risk: 'all', type: 'all' }), false)
})
test('list risk tone hides budget governance risk from applicant for consistency with detail', () => {
// 申请单列表里,预算治理类风险(对申请人不可见)不应计入申请人看到的风险等级,
// 避免出现“列表显示中风险、详情却看不到”的不一致。
const applicationRequest = {
id: 'AP-202606200001',
documentTypeCode: 'application',
typeCode: 'travel_application',
employeeId: 'EMP-001',
employeeName: '张三'
}
const submitter = { id: 'EMP-001', name: '张三', employeeId: 'EMP-001' }
const riskFlags = [
{
source: 'budget_control',
severity: 'high',
message: '预算可用余额不足。',
business_stage: 'expense_application',
risk_domain: 'budget',
visibility_scope: 'budget_manager',
actionability: 'budget_governance'
}
]
const viewerForSubmitter = { request: applicationRequest, currentUser: submitter }
// 申请人:列表不应显示该预算风险 → tone 为 low、count 为 0
assert.equal(resolveArchiveRiskTone(riskFlags, '', viewerForSubmitter), 'low')
assert.equal(countClaimRisks(riskFlags, '', viewerForSubmitter), 0)
// 不传 viewerOptions 时保持向后兼容(原样统计)
assert.equal(resolveArchiveRiskTone(riskFlags, ''), 'high')
})

View File

@@ -343,7 +343,7 @@ test('documents center list renders risk level tags instead of status tags', ()
assert.match(documentsCenterView, /<th>风险等级<\/th>/)
assert.match(documentsCenterView, /<td data-label="风险等级">[\s\S]*class="risk-level-tags"[\s\S]*v-for="tag in row\.riskTags"/)
assert.match(documentsCenterView, /import \{ countClaimRisks, resolveArchiveRiskTone \} from '..\/utils\/archiveCenterListFilters\.js'/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary\)/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary, viewerOptions\)/)
assert.match(documentsCenterView, /riskTone: riskMeta\.tone,[\s\S]*riskLabel: riskMeta\.label,[\s\S]*riskCount: riskMeta\.count,[\s\S]*riskTags: riskMeta\.tags/)
assert.match(documentsCenterView, /function matchesRiskLevelTab\(row, tab\) \{[\s\S]*tab === '高风险'[\s\S]*row\.riskTone === 'high'/)
assert.match(documentListSharedStyles, /\.risk-level-tags\s*\{[\s\S]*display:\s*inline-flex;/)

View File

@@ -1543,7 +1543,7 @@ test('application preview merges rule center travel estimate into highlighted ro
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview blocks policy estimate when transport mode is missing', () => {
test('application preview calculates base policy estimate when transport mode is missing', () => {
const currentUser = { name: '李文静', grade: 'P5', location: '武汉' }
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
@@ -1551,9 +1551,15 @@ test('application preview blocks policy estimate when transport mode is missing'
{ today: '2026-06-09' }
)
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
assert.equal(request.canCalculate, false)
assert.equal(request.reason, '缺少出行方式')
assert.equal(request.payload, null)
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 4,
location: '上海',
grade: 'P5',
transport_mode: null,
origin_location: '武汉',
travel_date: '2026-02-20'
})
assert.equal(preview.missingFields.includes('出行方式'), true)
assert.equal(preview.readyToSubmit, false)
@@ -1586,14 +1592,17 @@ test('application preview blocks policy estimate when transport mode is missing'
assert.equal(blockedEstimatePreview.fields.transportMode, '')
assert.equal(blockedEstimatePreview.fields.transportEstimatedAmount, '')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '填写地点和天数后自动测算')
assert.equal(blockedEstimatePreview.fields.lodgingDailyCap, '250元/天')
assert.equal(blockedEstimatePreview.fields.subsidyDailyCap, '100元/天')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '交通待补充 + 住宿 1,000元 + 补贴 400元 = 1,400元4天不含交通')
assert.equal(blockedEstimatePreview.fields.amount, '1,400元不含交通')
assert.equal(blockedEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.reason, '辅助国网仿生产项目部署')
assert.equal(staleEstimatePreview.fields.transportMode, '火车')
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), false)
assert.equal(staleEstimatePreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用最终报销以实际票据金额为准')
assert.match(staleEstimatePreview.fields.policyEstimate, /交通 720元/)
assert.equal(staleEstimatePreview.fields.amount, '2,120元')
assert.equal(staleEstimatePreview.fields.transportMode, '')
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用')
assert.equal(staleEstimatePreview.fields.policyEstimate, '交通待补充 + 住宿 1,000元 + 补贴 400元 = 1,400元4天不含交通')
assert.equal(staleEstimatePreview.fields.amount, '1,400元不含交通')
})
test('application preview editor refreshes transport estimate after mode change', async () => {

View File

@@ -190,3 +190,87 @@ test('legacy risk text falls back to semantic visibility defaults', () => {
assert.equal(resolveRiskActionability(legacyFlag, { businessStage: 'reimbursement' }), 'fixable_by_submitter')
assert.equal(resolveRiskVisibilityScope(legacyFlag, { businessStage: 'reimbursement' }), 'submitter')
})
test('application submitter can see fixable policy/trip risks in detail', () => {
// 申请单申请人在详情页可见可自行整改的风险(信息完整性、差旅、金额),
// 以便申请时知晓风险及原因并补充修正。
const cards = [
{
id: 'application-fields-missing',
businessStage: 'expense_application',
tone: 'low',
risk: '差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。',
risk_domain: 'policy',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
},
{
id: 'budget-detail',
businessStage: 'expense_application',
tone: 'high',
risk: '预算可用余额不足。',
risk_domain: 'budget',
visibility_scope: 'budget_manager',
actionability: 'budget_governance'
},
{
id: 'profile-detail',
businessStage: 'expense_application',
tone: 'medium',
risk: '历史差旅画像异常。',
risk_domain: 'profile',
visibility_scope: 'leader',
actionability: 'review_decision'
}
]
const visibleCards = filterRiskCardsForVisibility(cards, { request: applicationRequest, currentUser: submitter })
// 申请人只可见 fixable_by_submitter 的信息完整性类风险,
// budget 走预算审批人、profile 走领导,申请人均不可见。
assert.deepEqual(visibleCards.map((card) => card.id), ['application-fields-missing'])
})
test('application leader can see review_decision risks that submitter cannot', () => {
// 审批人可见 review_decision 类风险(画像、审批流程等),
// 满足诉求2提交后领导能看到风险点。
const cards = [
{
id: 'profile-detail',
businessStage: 'expense_application',
tone: 'medium',
risk: '历史差旅画像异常。',
risk_domain: 'profile',
visibility_scope: 'leader',
actionability: 'review_decision'
},
{
id: 'application-fields-missing',
businessStage: 'expense_application',
tone: 'low',
risk: '差旅申请基础信息不完整。',
risk_domain: 'policy',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
}
]
const visibleCards = filterRiskCardsForVisibility(cards, {
request: applicationRequest,
currentUser: { id: 'EMP-P7', name: '直属领导' },
canViewApprovalRiskAdvice: true
})
assert.deepEqual(visibleCards.map((card) => card.id), ['profile-detail', 'application-fields-missing'])
})
test('application fixable risks derive submitter semantics without hardcoded leader fallback', () => {
// 验证申请单阶段 policy/trip/amount 域不再被硬编码为 leader/review_decision
// 而是沿用与报销单一致的 fixable_by_submitter 语义。
const policyFlag = {
source: 'submission_review',
severity: 'low',
message: '差旅申请基础信息不完整。',
business_stage: 'expense_application'
}
assert.equal(resolveRiskActionability(policyFlag, { businessStage: 'expense_application' }), 'fixable_by_submitter')
assert.equal(resolveRiskVisibilityScope(policyFlag, { businessStage: 'expense_application' }), 'submitter')
})

View File

@@ -645,8 +645,8 @@ test('AI advice template renders grouped section titles with completion before r
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /return '风险提示'/)
assert.match(detailViewScript, /isCurrentApplicant\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /isApplicationDocument\.value \? '申请风险提示' : '风险提示'/)
assert.match(detailViewScript, /return isEditableRequest\.value \? 'AI建议' : '风险提示'/)
assert.doesNotMatch(detailViewScript, /return '报销风险提示'/)
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)

View File

@@ -48,7 +48,13 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
assert.match(aiMode, /!candidates\.length/)
assert.match(aiMode, /ai_application_start_inline/)
assert.match(aiMode, /buildRequiredApplicationMissingText/)
assert.match(aiMode, /function startAiApplicationDraft/)
assert.match(aiMode, /function startAiApplicationPreview/)
assert.match(aiMode, /buildLocalApplicationPreview/)
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
assert.match(aiMode, /applicationPreview:\s*preview/)
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
})
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
@@ -72,9 +78,28 @@ test('AI mode attaches required application lookup result before steward plannin
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
})
test('AI mode automatically continues required application gate decisions from steward plan', () => {
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*startAiApplicationDraft\('travel', '差旅费'/)
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
test('AI mode handles document query prompts locally before steward planning', () => {
assert.match(aiMode, /resolveAiDocumentQueryIntent\(prompt/)
assert.match(aiMode, /async function handleAiDocumentQueryIntent/)
assert.match(aiMode, /buildAiDocumentQueryConditionSummary/)
assert.match(aiMode, /filterAiDocumentQueryRecords\(payload, intent\)/)
assert.match(aiMode, /fetchApprovalExpenseClaims/)
assert.match(aiMode, /buildAiDocumentQueryMessage/)
assert.match(aiMode, /AI_DOCUMENT_QUERY_STEP_DELAY_MS/)
assert.match(aiMode, /async function updateAiDocumentQueryThinking/)
assert.match(aiMode, /解析自然语言筛选条件/)
assert.match(aiMode, /查询业务单据接口/)
assert.match(aiMode, /组合筛选单据/)
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
})
test('AI mode continues required application gate decisions into table preview from steward plan', () => {
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt = ''\)/)
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt/)
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/)
assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/)
assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/)
assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/)
})

View File

@@ -194,6 +194,7 @@ test('personal workbench view swaps the traditional dashboard with the AI mode s
assert.match(workbenchView, /:sidebar-command="aiSidebarCommand"/)
assert.match(workbenchView, /@conversation-change="emit\('ai-conversation-change', \$event\)"/)
assert.match(workbenchView, /@conversation-history-change="emit\('ai-conversation-history-change', \$event\)"/)
assert.match(workbenchView, /@open-document="emit\('open-document', \$event\)"/)
assert.match(workbenchView, /<PersonalWorkbench[\s\S]*v-else[\s\S]*key="traditional"/)
assert.match(workbenchView, /workbenchMode:\s*\{[\s\S]*type:\s*String,[\s\S]*default:\s*'traditional'/)
assert.match(workbenchView, /aiSidebarCommand:\s*\{[\s\S]*type:\s*Object/)
@@ -233,7 +234,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiMode, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiMode, /workbench-ai-answer-card/)
assert.match(aiMode, /workbench-ai-answer-markdown/)
assert.match(aiMode, /v-html="renderInlineMarkdown\(message\.content\)"/)
assert.match(aiMode, /v-html="renderInlineConversationHtml\(message\.content\)"/)
assert.match(aiMode, /workbench-ai-message-actions/)
assert.match(aiMode, /workbench-ai-conversation-actions/)
assert.match(aiMode, /scrollInlineConversationToTop/)
@@ -257,17 +258,28 @@ test('AI mode screen follows the approved reference structure', () => {
assert.doesNotMatch(aiMode, /思考过程/)
assert.doesNotMatch(aiMode, /message\.pending \?/)
assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__foot\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
assert.match(aiMode, /import \{ useWorkbenchComposerDate \} from '\.\.\/\.\.\/composables\/useWorkbenchComposerDate\.js'/)
assert.match(aiMode, /loadAiWorkbenchConversationHistory/)
assert.match(aiMode, /saveAiWorkbenchConversation/)
assert.match(aiMode, /deleteAiWorkbenchConversation/)
assert.match(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
assert.match(aiMode, /import \{ renderAiConversationHtml \} from '\.\.\/\.\.\/utils\/aiConversationHtmlRenderer\.js'/)
assert.match(aiMode, /function renderInlineConversationHtml\(content\) \{[\s\S]*return renderAiConversationHtml\(content\)[\s\S]*\}/)
assert.doesNotMatch(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
assert.match(aiMode, /buildStewardPlanRequest/)
assert.match(aiMode, /buildStewardPlanMessageText/)
assert.match(aiMode, /buildStewardSuggestedActions/)
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change'\]\)/)
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiMode, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiMode, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiMode, /persistCurrentConversation\(\)/)
@@ -355,6 +367,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-card\s*\{[\s\S]*box-shadow:\s*none;[\s\S]*backdrop-filter:\s*none;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown\s*\{[\s\S]*line-height:\s*1\.86;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(h3\)\s*\{[\s\S]*font-size:\s*21px;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-focus-grid\)\s*\{[\s\S]*border-left:\s*3px solid/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-focus-card\)\s*\{[\s\S]*background:\s*transparent;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-step-index\)\s*\{[\s\S]*background:\s*transparent;[\s\S]*font-size:\s*17px;/)
assert.match(aiModeStyles, /\.workbench-ai-date-popover\s*\{[\s\S]*animation:\s*workbenchAiPopoverIn/)
assert.match(aiModeStyles, /\.workbench-ai-send-btn:not\(:disabled\)\s*\{[\s\S]*linear-gradient\(135deg,[\s\S]*#1d4ed8/)
assert.match(aiModeStyles, /\.workbench-ai-composer--inline\s*\{[\s\S]*min-height:\s*126px;[\s\S]*box-shadow:\s*none;/)