feat(web): 工作台 AI 模式报销预审与文档查询模型拆分
- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出 - PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示 - 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试 - 新增 AI 文档卡片背景资源
This commit is contained in:
BIN
web/src/assets/ai-document-card-bg.png
Normal file
BIN
web/src/assets/ai-document-card-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
136
web/src/services/aiApplicationPreviewActions.js
Normal file
136
web/src/services/aiApplicationPreviewActions.js
Normal 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
|
||||
})
|
||||
}
|
||||
345
web/src/utils/aiApplicationPrecheckModel.js
Normal file
345
web/src/utils/aiApplicationPrecheckModel.js
Normal 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')
|
||||
}
|
||||
647
web/src/utils/aiConversationHtmlRenderer.js
Normal file
647
web/src/utils/aiConversationHtmlRenderer.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function sanitizeHref(href = '') {
|
||||
const value = String(href || '').trim()
|
||||
if (/^(https?:\/\/|#)/i.test(value)) {
|
||||
return escapeHtml(value)
|
||||
}
|
||||
return '#'
|
||||
}
|
||||
|
||||
function isApplicationDetailHref(href = '') {
|
||||
return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX)
|
||||
}
|
||||
|
||||
function isDocumentDetailHref(href = '') {
|
||||
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
||||
}
|
||||
|
||||
function sanitizeImageSrc(src = '') {
|
||||
const value = String(src || '').trim()
|
||||
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
|
||||
return escapeHtml(value)
|
||||
}
|
||||
if (/^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/=]+$/i.test(value)) {
|
||||
return escapeHtml(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function renderLinkHtml(label = '', href = '') {
|
||||
const sanitizedHref = sanitizeHref(href)
|
||||
if (isApplicationDetailHref(href)) {
|
||||
return [
|
||||
`<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
|
||||
)
|
||||
}
|
||||
784
web/src/utils/aiDocumentQueryModel.js
Normal file
784
web/src/utils/aiDocumentQueryModel.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return normalizeText(value).replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function normalizeDateText(value) {
|
||||
const text = normalizeText(value)
|
||||
const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (!matched) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
matched[1],
|
||||
String(matched[2]).padStart(2, '0'),
|
||||
String(matched[3]).padStart(2, '0')
|
||||
].join('-')
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
const text = normalizeDateText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(`${text}T00:00:00Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function resolveToday(options = {}) {
|
||||
return parseDate(options.today) || new Date()
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year, month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function buildMonthRange(year, month) {
|
||||
const normalizedMonth = String(month).padStart(2, '0')
|
||||
return {
|
||||
start: `${year}-${normalizedMonth}-01`,
|
||||
end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`,
|
||||
label: `${year}年${month}月`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimeRange(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
const today = resolveToday(options)
|
||||
const todayText = formatDate(today)
|
||||
|
||||
const explicitMonth = text.match(/(?:(?<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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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) : ''
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -252,6 +252,7 @@ export function useApplicationPreviewEditor({
|
||||
resolveApplicationPreviewRows,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
refreshApplicationPreviewEstimate,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor,
|
||||
|
||||
114
web/tests/ai-application-precheck-model.test.mjs
Normal file
114
web/tests/ai-application-precheck-model.test.mjs
Normal 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, [])
|
||||
})
|
||||
127
web/tests/ai-application-preview-actions.test.mjs
Normal file
127
web/tests/ai-application-preview-actions.test.mjs
Normal 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元(不含交通)')
|
||||
})
|
||||
100
web/tests/ai-conversation-html-renderer.test.mjs
Normal file
100
web/tests/ai-conversation-html-renderer.test.mjs
Normal 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, /<script>alert\(1\)<\/script>/)
|
||||
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([
|
||||
'### 图片材料',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'内联图片:)'
|
||||
].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]*申请表生成/)
|
||||
})
|
||||
181
web/tests/ai-document-query-model.test.mjs
Normal file
181
web/tests/ai-document-query-model.test.mjs
Normal 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, /<section class="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=/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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;/)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
@@ -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;/)
|
||||
|
||||
Reference in New Issue
Block a user