feat: 增强知识库索引与设置页面模块化拆分

扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 23:47:28 +08:00
parent 88ff04bef8
commit 5b388d08c0
84 changed files with 10170 additions and 2599 deletions

View File

@@ -0,0 +1,227 @@
/* 设置页表单/卡片/开关 — 供 SettingsView 与子面板各自 scoped 引入 */
.settings-card {
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 16px rgba(0, 0, 0, 0.03);
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.settings-card:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01), 0 10px 24px rgba(0, 0, 0, 0.04);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 24px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
margin: 0;
}
.settings-card > *:not(.card-head) {
padding-left: 24px;
padding-right: 24px;
margin-top: 24px;
}
.settings-card > *:not(.card-head):last-child {
margin-bottom: 24px;
}
.card-head-actions {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.card-head h4 {
margin: 0;
padding: 0;
color: #0f172a;
font-size: 15px;
font-weight: 700;
line-height: 1.2;
}
.card-head p {
margin: 4px 0 0 0;
padding: 0;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
align-items: start;
}
.compact-grid {
margin-bottom: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field-wide {
grid-column: span 2;
}
.field-full {
grid-column: 1 / -1;
}
.field span {
color: #475569;
font-size: 12.5px;
font-weight: 700;
line-height: 1.2;
}
.field em {
margin-right: 4px;
color: #ef4444;
font-style: normal;
}
.field input,
.field select {
width: 100%;
min-height: 44px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #ffffff;
color: #0f172a;
font-size: 13px;
line-height: 1.45;
transition: all 0.2s ease;
}
.field input::placeholder {
color: #94a3b8;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08);
background: #ffffff;
}
.switch-group {
display: grid;
gap: 12px;
}
.switch-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #ffffff;
text-align: left;
transition: all 0.25s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.01);
cursor: pointer;
}
.switch-row:hover {
border-color: rgba(16, 185, 129, 0.25);
background: linear-gradient(180deg, #ffffff 0%, rgba(16, 185, 129, 0.01) 100%);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.03);
transform: translateY(-1px);
}
.switch-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.switch-copy strong {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.switch-copy small {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
/* Switch Toggle Buttons */
.switch-btn {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
width: 48px;
height: 26px;
padding: 3px;
border: none;
border-radius: 99px;
background: #cbd5e1;
cursor: pointer;
outline: none;
transition: background-color 0.25s ease;
}
.switch-btn i {
width: 20px;
height: 20px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15);
transition: transform 0.25s cubic-bezier(0.25, 1, 0.5, 1);
}
.switch-btn.active {
background-color: #10b981;
}
.switch-btn.active i {
transform: translateX(22px);
}
.switch-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Mini Switch */
.switch-btn.mini {
width: 38px;
height: 20px;
padding: 2px;
}
.switch-btn.mini i {
width: 16px;
height: 16px;
}
.switch-btn.mini.active i {
transform: translateX(18px);
}

View File

@@ -0,0 +1,228 @@
/* Container */
.hermes-settings-container {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
/* Master Control Hero Card Active Hover & Color */
.hermes-hero-card.active {
border-color: rgba(16, 185, 129, 0.25);
background: linear-gradient(180deg, #ffffff 0%, rgba(16, 185, 129, 0.02) 100%);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.01), 0 8px 24px rgba(16, 185, 129, 0.05);
}
.hermes-hero-card .model-icon-box {
position: relative;
}
.hermes-hero-card .model-icon-box.active {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border-color: rgba(16, 185, 129, 0.15);
}
/* Pulse Dot */
.status-pulse-dot {
position: absolute;
top: -2px;
right: -2px;
width: 10px;
height: 10px;
background-color: #cbd5e1;
border: 2px solid #ffffff;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.status-pulse-dot.active {
background-color: #10b981;
animation: pulse-ring 1.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) infinite;
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.status-badge {
padding: 6px 12px;
border-radius: 99px;
background: #f1f5f9;
color: #64748b;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
transition: all 0.3s ease;
}
.status-badge.active {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
/* Task Section */
.hermes-tasks-section.disabled {
opacity: 0.6;
pointer-events: none;
}
/* Tasks Grid */
.hermes-task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
/* Task Card */
.hermes-task-card {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.01), 0 2px 8px rgba(0, 0, 0, 0.02);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.hermes-task-card:hover {
transform: translateY(-2px);
border-color: #cbd5e1;
box-shadow: 0 4px 6px rgba(15, 23, 42, 0.01), 0 10px 20px rgba(15, 23, 42, 0.04);
}
.task-card-header {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 20px;
}
.task-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
font-size: 20px;
transition: all 0.3s ease;
}
.task-meta-info {
flex: 1;
min-width: 0;
}
.task-meta-info strong {
display: block;
color: #0f172a;
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.task-meta-info small {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Task card colors (SaaS Gray) */
.task-icon-box.indigo,
.task-icon-box.warning,
.task-icon-box.danger,
.task-icon-box.info,
.task-icon-box.success,
.task-icon-box.primary,
.task-icon-box.secondary,
.task-icon-box.default {
background: #f8fafc;
color: #475569;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
/* Card Footer & Time picker */
.task-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 14px;
border-top: 1px dashed #f1f5f9;
}
.frequency-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
background: #f8fafc;
color: #94a3b8;
font-size: 11px;
font-weight: 600;
transition: all 0.3s ease;
}
.frequency-badge.active {
background: #f0f9ff;
color: #0284c7;
}
.time-picker-wrapper input[type="time"] {
padding: 5px 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-size: 12px;
font-weight: 600;
outline: none;
cursor: pointer;
transition: all 0.2s ease;
}
.time-picker-wrapper input[type="time"]:hover {
border-color: #cbd5e1;
background: #f1f5f9;
}
.time-picker-wrapper input[type="time"]:focus {
border-color: #10b981;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08);
}
.time-picker-placeholder {
color: #cbd5e1;
font-size: 11px;
font-weight: 600;
}
@media (max-width: 768px) {
.hermes-hero-card.active {
background: #ffffff;
}
.hermes-task-grid {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -759,7 +759,8 @@
.review-side-metric-card.editable:hover,
.review-side-metric-card.editing {
border-color: rgba(16, 185, 129, 0.34);
background: rgba(248, 252, 250, 0.92);
background: #ffffff;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.08);
transform: translateY(-1px);
}
@@ -768,10 +769,25 @@
height: 32px;
display: grid;
place-items: center;
border-radius: 10px;
background: rgba(240, 253, 244, 0.95);
color: #059669;
border-radius: 8px;
background: #f1f5f9;
border: 1px solid transparent;
color: #64748b;
font-size: 15px;
transition: all 0.2s ease;
}
.review-side-metric-card.editable:hover .review-side-metric-icon,
.review-side-metric-card.editing .review-side-metric-icon {
color: #059669;
border-color: rgba(16, 185, 129, 0.22);
background: #ecfdf5;
}
.review-side-metric-card.invalid .review-side-metric-icon {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.22);
background: #fef2f2;
}
.review-side-metric-copy {

View File

@@ -298,6 +298,16 @@
cursor: not-allowed;
}
.review-next-step-rich-copy {
margin-top: 30px;
color: #475569;
line-height: 1.64;
}
.review-next-step-rich-copy :deep(.markdown-action-paragraph) {
margin-top: 10px;
}
.review-section-card {
display: grid;
gap: 10px;

View File

@@ -1,6 +1,14 @@
.review-overlay {
display: flex;
align-items: center;
justify-content: center;
}
.review-preview-modal {
width: min(980px, calc(100vw - 40px));
max-height: min(92vh, calc(100vh - 32px));
margin: auto;
flex: none;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
@@ -63,48 +71,56 @@
}
.welcome-quick-actions-title {
margin: 0 0 22px;
margin: 0 0 16px !important;
color: #64748b;
font-size: 12px;
font-weight: 800;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.welcome-quick-action-grid {
display: flex;
flex-wrap: wrap;
gap: 7px;
gap: 12px;
margin-top: 16px;
}
.welcome-quick-action-btn {
min-height: 30px;
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 11px;
border: 1px solid rgba(191, 219, 254, 0.92);
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.94) 100%);
color: #1d4ed8;
gap: 6px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #ffffff;
color: #334155;
font-size: var(--wb-fs-chip);
font-weight: 700;
font-weight: 600;
line-height: 1.2;
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.07);
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
transition: all 0.2s ease;
}
.welcome-quick-action-btn i {
font-size: 13px;
color: #2563eb;
font-size: 14px;
color: #64748b;
transition: color 0.2s ease;
}
.welcome-quick-action-btn:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(59, 130, 246, 0.34);
box-shadow: 0 7px 14px rgba(59, 130, 246, 0.12);
border-color: #10b981;
background: #ecfdf5;
color: #059669;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1);
}
.welcome-quick-action-btn:hover:not(:disabled) i {
color: #059669;
}
.welcome-quick-action-btn:disabled {
opacity: 0.48;
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -522,8 +522,10 @@
.insight-panel-shell {
flex: none;
display: flex;
width: clamp(300px, 28vw, 420px);
min-width: 0;
min-height: 0;
max-width: 100%;
margin-left: 0;
overflow: hidden;
@@ -634,6 +636,30 @@
box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48);
}
.message-bubble-review-risk-low {
border-color: rgba(37, 99, 235, 0.72);
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.96));
box-shadow:
0 0 0 2px rgba(37, 99, 235, 0.12),
0 12px 24px rgba(37, 99, 235, 0.1);
}
.message-bubble-review-risk-medium {
border-color: rgba(217, 119, 6, 0.78);
background: linear-gradient(180deg, rgba(255, 251, 235, 0.76), rgba(255, 255, 255, 0.96));
box-shadow:
0 0 0 2px rgba(217, 119, 6, 0.14),
0 12px 24px rgba(217, 119, 6, 0.11);
}
.message-bubble-review-risk-high {
border-color: rgba(220, 38, 38, 0.78);
background: linear-gradient(180deg, rgba(254, 242, 242, 0.78), rgba(255, 255, 255, 0.96));
box-shadow:
0 0 0 2px rgba(220, 38, 38, 0.14),
0 12px 24px rgba(220, 38, 38, 0.11);
}
.message-meta {
display: flex;
align-items: center;
@@ -814,6 +840,26 @@
color: #1d4ed8;
}
.message-answer-markdown :deep(.markdown-action-link-next),
.message-answer-markdown :deep(.markdown-action-link-edit) {
color: #1d4ed8;
}
.message-answer-markdown :deep(.markdown-risk-text-low) {
color: #2563eb;
font-weight: 850;
}
.message-answer-markdown :deep(.markdown-risk-text-medium) {
color: #d97706;
font-weight: 850;
}
.message-answer-markdown :deep(.markdown-risk-text-high) {
color: #dc2626;
font-weight: 850;
}
.message-answer-markdown :deep(.markdown-table-wrap) {
width: 100%;
max-width: 100%;

View File

@@ -2,7 +2,8 @@
<aside class="rail" aria-label="主导航">
<div class="rail-brand">
<div class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<img v-if="companyLogo" :src="companyLogo" alt="System Logo" class="custom-logo" />
<svg v-else viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
@@ -57,6 +58,10 @@ const props = defineProps({
type: String,
default: ''
},
companyLogo: {
type: String,
default: ''
},
currentUser: {
type: Object,
default: () => ({
@@ -145,6 +150,14 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
display: grid;
place-items: center;
color: #07936f;
border-radius: 6px;
overflow: hidden;
}
.custom-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-mark svg {
@@ -195,10 +208,9 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
}
.nav-btn.active {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.16), rgba(16, 185, 129, 0.08));
border-color: rgba(16, 185, 129, 0.1);
background: #ecfdf5;
border-color: rgba(16, 185, 129, 0.12);
color: #059669;
box-shadow: inset 3px 0 0 #10b981;
}
.nav-icon {
@@ -224,7 +236,7 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
min-width: 0;
color: currentColor;
font-size: 14px;
font-weight: 750;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;

View File

@@ -0,0 +1,727 @@
<template>
<article class="knowledge-ingest-panel panel">
<header class="ingest-head">
<div>
<span class="eyebrow">LightRAG 知识归集</span>
<h3>{{ model.folder || '未指定目录' }}</h3>
<p>{{ model.phaseLabel }} · {{ model.statusLabel }}</p>
</div>
<div class="progress-ring" :aria-label="`归集进度 ${model.progress.percent}%`">
<strong>{{ model.progress.percent }}%</strong>
<span>进度</span>
</div>
</header>
<div class="progress-bar" aria-hidden="true">
<span :style="{ width: `${model.progress.percent}%` }"></span>
</div>
<div class="metric-strip">
<div v-for="metric in model.metrics" :key="metric.label" class="metric-tile">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
</div>
</div>
<div class="ingest-workspace">
<aside class="file-rail">
<button
v-for="document in model.documents"
:key="document.documentId"
type="button"
class="file-item"
:class="{ active: selectedDocumentId === document.documentId }"
@click="selectDocument(document.documentId)"
>
<i :class="documentIcon(document)"></i>
<span class="file-copy">
<strong>{{ document.name }}</strong>
<small>
{{ document.phaseLabel }} · {{ document.chunkCount }} chunk
</small>
</span>
<span class="mini-status" :class="document.statusTone">
{{ document.statusLabel }}
</span>
</button>
</aside>
<section v-if="selectedDocument" class="file-detail">
<div class="detail-topline">
<div>
<h4>{{ selectedDocument.name }}</h4>
<p>
{{ selectedDocument.folder || '根目录' }}
<span v-if="selectedDocument.extension"> · {{ selectedDocument.extension }}</span>
</p>
</div>
<span class="status-chip" :class="selectedDocument.statusTone">
{{ selectedDocument.statusLabel }}
</span>
</div>
<div class="detail-stats">
<div>
<span>原文字符</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.textChars) }}</strong>
</div>
<div>
<span>索引字符</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.indexedTextChars) }}</strong>
</div>
<div>
<span>Chunk</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.chunkCount) }}</strong>
</div>
<div>
<span>实体 / 关系</span>
<strong>
{{ formatKnowledgeMetric(selectedDocument.entityCount) }}
/
{{ formatKnowledgeMetric(selectedDocument.relationCount) }}
</strong>
</div>
</div>
<p v-if="selectedDocument.error" class="error-note">
{{ selectedDocument.error }}
</p>
<div class="detail-section-grid">
<section class="detail-section">
<div class="section-head">
<h5>Chunk 信息</h5>
<span>{{ selectedDocument.chunks.length }} </span>
</div>
<div v-if="selectedDocument.chunks.length" class="chunk-list">
<div v-for="chunk in selectedDocument.chunks" :key="chunk.id" class="chunk-row">
<span class="chunk-index">#{{ chunk.order + 1 }}</span>
<div>
<strong>{{ compactId(chunk.id) }}</strong>
<p>{{ chunk.summary || '暂无摘要' }}</p>
</div>
<small>{{ chunk.tokens }} tokens</small>
</div>
</div>
<div v-else class="compact-empty">暂无 chunk 明细</div>
</section>
<section class="detail-section">
<div class="section-head">
<h5>章节提取</h5>
<span>{{ selectedDocument.sectionCount }} </span>
</div>
<div v-if="selectedDocument.sections.length" class="section-list">
<div
v-for="section in selectedDocument.sections"
:key="section.title"
class="section-row"
>
<strong>{{ section.title }}</strong>
<p>{{ section.excerpt || '暂无章节摘要' }}</p>
</div>
</div>
<div v-else class="compact-empty">暂无章节信息</div>
</section>
</div>
<section class="detail-section">
<div class="section-head">
<h5>处理事件</h5>
<span>{{ selectedDocument.events.length }} </span>
</div>
<div v-if="selectedDocument.events.length" class="event-list">
<div
v-for="event in selectedDocument.events"
:key="`${event.at}-${event.message}`"
class="event-row"
:class="event.level"
>
<span></span>
<div>
<strong>{{ formatEventTime(event.at) }}</strong>
<p>{{ event.message }}</p>
</div>
</div>
</div>
<div v-else class="compact-empty">暂无处理事件</div>
</section>
</section>
</div>
<section class="graph-section">
<div class="section-head">
<h4>图谱形成</h4>
<span>
{{ formatKnowledgeMetric(model.graph.entityCount) }} 实体 ·
{{ formatKnowledgeMetric(model.graph.relationCount) }} 关系
</span>
</div>
<div class="graph-grid">
<div class="graph-pane">
<h5>实体</h5>
<div v-if="model.graph.entities.length" class="entity-cloud">
<span v-for="entity in model.graph.entities" :key="entity">{{ entity }}</span>
</div>
<div v-else class="compact-empty">暂无实体</div>
</div>
<div class="graph-pane">
<h5>关系</h5>
<div v-if="model.graph.relations.length" class="relation-list">
<div
v-for="relation in model.graph.relations"
:key="`${relation.source}-${relation.target}-${relation.type}`"
class="relation-row"
>
<strong>{{ relation.source }}</strong>
<i class="mdi mdi-arrow-right-thin"></i>
<strong>{{ relation.target }}</strong>
<span>{{ relation.type }}</span>
</div>
</div>
<div v-else class="compact-empty">暂无关系</div>
</div>
</div>
</section>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
buildKnowledgeIngestLogModel,
formatKnowledgeMetric
} from '../../utils/knowledgeIngestLogModel.js'
const props = defineProps({
run: {
type: Object,
required: true
}
})
const selectedDocumentId = ref('')
const model = computed(() => buildKnowledgeIngestLogModel(props.run))
const selectedDocument = computed(
() => model.value.documents.find((item) => item.documentId === selectedDocumentId.value) || null
)
watch(
() => model.value.selectedDocumentId,
(nextDocumentId) => {
if (!nextDocumentId) {
selectedDocumentId.value = ''
return
}
if (!selectedDocumentId.value || !model.value.documents.some((item) => item.documentId === selectedDocumentId.value)) {
selectedDocumentId.value = nextDocumentId
}
},
{ immediate: true }
)
function selectDocument(documentId) {
selectedDocumentId.value = documentId
}
function documentIcon(document) {
const extension = String(document?.extension || '').toLowerCase()
if (extension === 'pdf') return 'mdi mdi-file-pdf-box'
if (['doc', 'docx'].includes(extension)) return 'mdi mdi-file-word-box'
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'mdi mdi-file-excel-box'
if (['ppt', 'pptx'].includes(extension)) return 'mdi mdi-file-powerpoint-box'
return 'mdi mdi-file-document-outline'
}
function compactId(value) {
const text = String(value || '').trim()
if (text.length <= 18) return text || 'chunk'
return `${text.slice(0, 8)}...${text.slice(-6)}`
}
function formatEventTime(value) {
if (!value) return '刚刚'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
</script>
<style scoped>
.knowledge-ingest-panel {
display: grid;
gap: 14px;
padding: 18px;
}
.ingest-head {
display: flex;
justify-content: space-between;
gap: 18px;
}
.eyebrow {
color: #0f766e;
font-size: 12px;
font-weight: 800;
}
.ingest-head h3 {
margin: 5px 0 0;
color: #0f172a;
font-size: 18px;
}
.ingest-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
.progress-ring {
width: 72px;
height: 72px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 50%;
background: #f8fafc;
}
.progress-ring strong,
.progress-ring span {
grid-area: 1 / 1;
}
.progress-ring strong {
margin-top: -12px;
color: #0f172a;
font-size: 17px;
}
.progress-ring span {
margin-top: 26px;
color: #64748b;
font-size: 11px;
}
.progress-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #e5eaf0;
}
.progress-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0f766e, #2563eb);
transition: width 0.24s ease;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric-tile {
min-width: 0;
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
}
.metric-tile span,
.metric-tile small {
color: #64748b;
font-size: 12px;
}
.metric-tile strong {
color: #0f172a;
font-size: 18px;
line-height: 1.2;
}
.ingest-workspace {
display: grid;
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 2fr);
gap: 14px;
min-height: 360px;
}
.file-rail {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
}
.file-item {
width: 100%;
min-height: 58px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
text-align: left;
}
.file-item.active {
border-color: rgba(15, 118, 110, 0.38);
background: #f0fdfa;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.08);
}
.file-item > i {
color: #334155;
font-size: 24px;
}
.file-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.file-copy strong,
.file-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-copy strong {
color: #0f172a;
font-size: 13px;
}
.file-copy small {
color: #64748b;
font-size: 12px;
}
.mini-status,
.status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.mini-status.success,
.status-chip.success {
background: #dcfce7;
color: #166534;
}
.mini-status.warning,
.status-chip.warning {
background: #fef3c7;
color: #92400e;
}
.mini-status.danger,
.status-chip.danger {
background: #fee2e2;
color: #991b1b;
}
.mini-status.muted,
.status-chip.muted {
background: #eef2f7;
color: #475569;
}
.file-detail {
min-width: 0;
display: grid;
align-content: start;
gap: 12px;
}
.detail-topline {
display: flex;
justify-content: space-between;
gap: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #e5edf5;
}
.detail-topline h4 {
margin: 0;
color: #0f172a;
font-size: 16px;
}
.detail-topline p {
margin: 5px 0 0;
color: #64748b;
font-size: 12px;
}
.detail-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.detail-stats div {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.detail-stats span {
color: #64748b;
font-size: 12px;
}
.detail-stats strong {
color: #0f172a;
font-size: 13px;
}
.error-note {
margin: 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #991b1b;
font-size: 13px;
}
.detail-section-grid,
.graph-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-section,
.graph-section,
.graph-pane {
min-width: 0;
display: grid;
gap: 10px;
align-content: start;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.section-head h4,
.section-head h5,
.graph-pane h5 {
margin: 0;
color: #0f172a;
font-size: 14px;
}
.section-head span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.chunk-list,
.section-list,
.event-list,
.relation-list {
display: grid;
gap: 8px;
}
.chunk-row,
.section-row,
.event-row,
.relation-row {
min-width: 0;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
}
.chunk-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
}
.chunk-index {
color: #2563eb;
font-size: 12px;
font-weight: 850;
}
.chunk-row strong,
.section-row strong,
.event-row strong,
.relation-row strong {
color: #0f172a;
font-size: 12px;
}
.chunk-row p,
.section-row p,
.event-row p {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.chunk-row small {
color: #64748b;
font-size: 11px;
white-space: nowrap;
}
.section-row {
padding: 10px;
}
.event-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 10px;
}
.event-row > span {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 999px;
background: #2563eb;
}
.event-row.error > span {
background: #dc2626;
}
.entity-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.entity-cloud span {
max-width: 100%;
padding: 5px 9px;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 750;
}
.relation-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 9px 10px;
}
.relation-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.relation-row i {
color: #0f766e;
font-size: 18px;
}
.relation-row span {
padding: 3px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
}
.compact-empty {
min-height: 42px;
display: grid;
place-items: center;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
font-size: 12px;
}
@media (max-width: 980px) {
.metric-strip,
.detail-stats,
.detail-section-grid,
.graph-grid,
.ingest-workspace {
grid-template-columns: 1fr;
}
.file-rail {
max-height: 260px;
overflow: auto;
}
}
@media (max-width: 620px) {
.ingest-head,
.detail-topline {
flex-direction: column;
}
.progress-ring {
width: 64px;
height: 64px;
}
.file-item {
grid-template-columns: auto minmax(0, 1fr);
}
.mini-status {
grid-column: 2;
justify-self: start;
}
.relation-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings']
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
export const navItems = [
{
@@ -62,14 +62,6 @@ export const navItems = [
title: '任务规则中心',
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
},
{
id: 'logs',
label: '日志管理',
navHint: '查看 Hermes 调用与系统运行日志',
icon: icons.logs,
title: '日志管理',
desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
},
{
id: 'employees',
label: '员工管理',
@@ -78,6 +70,14 @@ export const navItems = [
title: '员工与组织管理',
desc: '维护员工账号、组织结构与角色权限。'
},
{
id: 'logs',
label: '日志管理',
navHint: '查看 Hermes 调用与系统运行日志',
icon: icons.logs,
title: '日志管理',
desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
},
{
id: 'settings',
label: '系统设置',
@@ -101,13 +101,34 @@ const viewRouteNames = {
settings: 'app-settings'
}
const routeNameViews = Object.fromEntries(
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
)
routeNameViews['app-request-detail'] = 'requests'
routeNameViews['app-log-detail'] = 'logs'
export function resolveAppViewFromRoute(route) {
const routeName = String(route?.name || '').trim()
if (routeNameViews[routeName]) {
return routeNameViews[routeName]
}
const metaView = String(route?.meta?.appView || '').trim()
return appViews.includes(metaView) ? metaView : 'overview'
}
export function resolveTargetRouteName(view) {
return viewRouteNames[view] || viewRouteNames.overview
}
export function useNavigation() {
const route = useRoute()
const router = useRouter()
const activeView = computed({
get() {
return route.meta.appView || 'overview'
return resolveAppViewFromRoute(route)
},
set(view) {
setView(view)
@@ -119,13 +140,13 @@ export function useNavigation() {
)
function setView(view) {
const targetName = viewRouteNames[view] || viewRouteNames.overview
const targetName = resolveTargetRouteName(view)
if (route.name === targetName) {
return
}
router.push({ name: targetName })
router.push({ name: targetName, params: {}, query: {}, hash: '' })
}
return { activeView, currentView, setView, navItems }

View File

@@ -13,12 +13,12 @@ const EXPENSE_TYPE_LABELS = {
ride_ticket: '乘车',
travel_allowance: '出差补贴',
entertainment: '业务招待费',
office: '办公费',
office: '办公用品费',
meeting: '会务费',
training: '培训费',
hotel: '住宿费',
transport: '交通费',
meal: '费',
meal: '业务招待费',
other: '其他费用'
}

View File

@@ -0,0 +1,486 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useSystemState } from './useSystemState.js'
import { fetchSettings, saveSettings } from '../services/settings.js'
import { useToast } from './useToast.js'
import {
isHermesEmployeeSettingsReady
} from '../utils/hermesEmployeeSettingsModel.js'
import {
LOG_LEVELS,
PROVIDER_OPTIONS,
SECTION_DEFINITIONS,
SESSION_RETENTION_OPTIONS,
buildDefaultState,
buildLlmPayload,
buildRenderPayload,
computeSectionStatus,
isModelConfigReady,
isRenderSecretMask,
maskConfiguredModelSecrets,
maskConfiguredRenderSecret,
mergeState,
normalizeValue,
persistSettings,
readStoredSettings
} from '../utils/settingsModelHelper.js'
export function useSettings() {
const { toast } = useToast()
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
const activeSection = ref('profile')
const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null)
const logoInputRef = ref(null)
const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS
const providerOptions = PROVIDER_OPTIONS
const sessionRetentionOptions = SESSION_RETENTION_OPTIONS
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
const activeSectionConfig = computed(
() => sections.find((section) => section.id === activeSection.value) || sections[0]
)
function updateBrandPreviewFromState(state) {
updateCompanyProfilePreview({
name: normalizeValue(state.companyForm.displayName),
code: normalizeValue(state.companyForm.companyCode),
adminEmail: normalizeValue(state.adminForm.adminEmail),
logo: state.companyForm.logo
})
}
function applyLoadedSnapshot(snapshot, options = {}) {
const {
mergeDraft = false,
preserveModelApiKeys = false,
preserveAdminPasswords = false,
preserveRenderSecret = false,
preserveMailPassword = false
} = options
const currentState = pageState.value
let nextState = mergeState(buildResolvedDefaults(), snapshot)
if (mergeDraft) {
nextState = mergeState(nextState, readStoredSettings())
}
if (preserveModelApiKeys) {
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
}
if (preserveAdminPasswords) {
nextState.adminForm.newPassword = currentState.adminForm.newPassword
nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword
}
if (preserveRenderSecret) {
nextState.renderForm.jwtSecret = currentState.renderForm.jwtSecret
}
if (preserveMailPassword) {
nextState.mailForm.password = currentState.mailForm.password
}
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
}
async function loadSettingsSnapshot() {
try {
const snapshot = await fetchSettings()
applyLoadedSnapshot(snapshot, { mergeDraft: true })
} catch (error) {
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
toast(error.message || '无法加载已保存设置,继续使用当前会话草稿。')
}
}
function buildSettingsPayload() {
return {
companyForm: { ...pageState.value.companyForm },
adminForm: { ...pageState.value.adminForm },
sessionForm: { ...pageState.value.sessionForm },
llmForm: buildLlmPayload(pageState.value.llmForm),
renderForm: buildRenderPayload(pageState.value.renderForm),
logForm: { ...pageState.value.logForm },
mailForm: { ...pageState.value.mailForm }
}
}
async function persistRemoteSettings(successMessage, options = {}) {
try {
const snapshot = await saveSettings(buildSettingsPayload())
applyLoadedSnapshot(snapshot, options)
toast(successMessage)
return true
} catch (error) {
toast(error.message || '设置保存失败,请稍后重试。')
return false
}
}
function activateSection(sectionId) {
sessionRetentionPickerOpen.value = false
activeSection.value = sectionId
}
function toggleBoolean(formKey, field) {
pageState.value[formKey][field] = !pageState.value[formKey][field]
}
function toggleSessionRetentionPicker() {
sessionRetentionPickerOpen.value = !sessionRetentionPickerOpen.value
}
function closeSessionRetentionPicker() {
sessionRetentionPickerOpen.value = false
}
function selectSessionRetentionDays(value) {
pageState.value.sessionForm.conversationRetentionDays = Number(value)
closeSessionRetentionPicker()
}
function handleDocumentPointerDown(event) {
if (!sessionRetentionPickerOpen.value) {
return
}
const target = event.target
if (sessionRetentionPickerRef.value?.contains(target)) {
return
}
closeSessionRetentionPicker()
}
function clearRenderSecretMask() {
if (isRenderSecretMask(pageState.value.renderForm.jwtSecret)) {
pageState.value.renderForm.jwtSecret = ''
}
}
async function saveProfileSection() {
const companyForm = pageState.value.companyForm
if (!normalizeValue(companyForm.companyName)) {
toast('请输入企业名称。')
return
}
if (!normalizeValue(companyForm.displayName)) {
toast('请输入系统显示名称。')
return
}
if (!normalizeValue(companyForm.copyright)) {
toast('请输入版权信息。')
return
}
pageState.value.mailForm.senderName = normalizeValue(companyForm.displayName)
await persistRemoteSettings('企业信息已保存并应用到当前系统。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveAdminSection() {
const adminForm = pageState.value.adminForm
if (!normalizeValue(adminForm.adminAccount)) {
toast('请输入管理员账号。')
return
}
if (!normalizeValue(adminForm.adminEmail)) {
toast('请输入管理员邮箱。')
return
}
if (Number(adminForm.sessionTimeout) < 5) {
toast('会话超时时间不能少于 5 分钟。')
return
}
if (adminForm.newPassword) {
if (adminForm.newPassword.length < 5) {
toast('管理员密码至少需要 5 位。')
return
}
if (adminForm.newPassword !== adminForm.confirmPassword) {
toast('两次输入的管理员密码不一致。')
return
}
}
await persistRemoteSettings('管理员安全设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: false,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
function toggleHermesMaster() {
pageState.value.hermesForm.masterEnabled = !pageState.value.hermesForm.masterEnabled
}
function toggleHermesFlag(field) {
pageState.value.hermesForm[field] = !pageState.value.hermesForm[field]
}
function toggleHermesTask(taskId) {
const schedule = pageState.value.hermesForm.schedules[taskId]
if (!schedule) {
return
}
const enabled = !(pageState.value.hermesForm.capabilities[taskId] && schedule.enabled)
pageState.value.hermesForm.capabilities[taskId] = enabled
schedule.enabled = enabled
}
function updateHermesTaskTime({ taskId, time }) {
const schedule = pageState.value.hermesForm.schedules[taskId]
if (!schedule) {
return
}
schedule.time = time
}
function saveHermesSection() {
if (!isHermesEmployeeSettingsReady(pageState.value.hermesForm)) {
toast('请至少开启一项 Hermes 能力,或关闭总控开关。')
return
}
persistSettings(pageState.value)
toast('数字员工设置已保存。')
}
async function saveSessionSection() {
const sessionForm = pageState.value.sessionForm
const retentionDays = Number(sessionForm.conversationRetentionDays)
if (retentionDays < 1 || retentionDays > 10) {
toast('会话保留天数必须在 1 到 10 天之间。')
return
}
await persistRemoteSettings('会话设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveLlmSection() {
const llmForm = pageState.value.llmForm
const modelConfigs = [
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
]
for (const [label, provider, model, endpoint] of modelConfigs) {
if (!isModelConfigReady(provider, model, endpoint)) {
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
return
}
}
await persistRemoteSettings('模型配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveRenderingSection() {
const renderForm = pageState.value.renderForm
if (renderForm.enabled && !normalizeValue(renderForm.publicUrl)) {
toast('启用 ONLYOFFICE 时请输入服务地址。')
return
}
if (renderForm.enabled && !normalizeValue(renderForm.jwtSecret) && !renderForm.jwtSecretConfigured) {
toast('启用 ONLYOFFICE 时请输入 JWT 密钥。')
return
}
await persistRemoteSettings('文件渲染配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: false,
preserveMailPassword: true
})
}
async function saveLogsSection() {
const logForm = pageState.value.logForm
if (!normalizeValue(logForm.level) || Number(logForm.retentionDays) <= 0) {
toast('请填写有效的日志级别和留存天数。')
return
}
if (!normalizeValue(logForm.logPath)) {
toast('请输入日志路径。')
return
}
await persistRemoteSettings('日志策略已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveMailSection() {
const mailForm = pageState.value.mailForm
if (!normalizeValue(mailForm.smtpHost) || Number(mailForm.port) <= 0) {
toast('请填写有效的 SMTP Host 和端口。')
return
}
if (!normalizeValue(mailForm.senderAddress) || !normalizeValue(mailForm.username)) {
toast('请填写发件人邮箱和 SMTP 登录账号。')
return
}
await persistRemoteSettings('邮箱配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: false
})
}
async function saveActiveSection() {
if (activeSection.value === 'profile') {
await saveProfileSection()
return
}
if (activeSection.value === 'admin') {
await saveAdminSection()
return
}
if (activeSection.value === 'session') {
await saveSessionSection()
return
}
if (activeSection.value === 'hermes') {
saveHermesSection()
return
}
if (activeSection.value === 'llm') {
await saveLlmSection()
return
}
if (activeSection.value === 'logs') {
await saveLogsSection()
return
}
if (activeSection.value === 'rendering') {
await saveRenderingSection()
return
}
await saveMailSection()
}
onMounted(() => {
if (typeof document !== 'undefined') {
document.addEventListener('pointerdown', handleDocumentPointerDown)
}
loadSettingsSnapshot()
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
}
})
function triggerLogoUpload() {
logoInputRef.value?.click()
}
function handleLogoUpload(event) {
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast('请上传图片文件。')
return
}
if (file.size > 2 * 1024 * 1024) {
toast('图片大小不能超过 2MB。')
return
}
const reader = new FileReader()
reader.onload = (e) => {
pageState.value.companyForm.logo = e.target.result
}
reader.readAsDataURL(file)
}
return {
activeSection,
activeSectionConfig,
activateSection,
clearRenderSecretMask,
completedSectionCount,
logLevels,
logoInputRef,
pageState,
providerOptions,
sessionRetentionOptions,
sessionRetentionPickerOpen,
sessionRetentionPickerRef,
saveActiveSection,
sectionStatus,
sections,
selectSessionRetentionDays,
toggleSessionRetentionPicker,
closeSessionRetentionPicker,
toggleBoolean,
toggleHermesFlag,
toggleHermesMaster,
toggleHermesTask,
updateHermesTaskTime,
triggerLogoUpload,
handleLogoUpload
}
}

View File

@@ -383,7 +383,8 @@ const { toast } = useToast()
const companyProfile = computed(() => ({
name: bootstrapState.value.company?.name || '',
code: bootstrapState.value.company?.code || '',
adminEmail: bootstrapState.value.company?.admin_email || ''
adminEmail: bootstrapState.value.company?.admin_email || '',
logo: bootstrapState.value.company?.logo || ''
}))
function updateCompanyProfilePreview(payload = {}) {
@@ -395,7 +396,8 @@ function updateCompanyProfilePreview(payload = {}) {
...currentCompany,
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.code !== undefined ? { code: payload.code } : {}),
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {})
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {}),
...(payload.logo !== undefined ? { logo: payload.logo } : {})
}
}
}

View File

@@ -31,7 +31,7 @@ export const documents = [
},
{
id: 'DOC-2026-0503',
type: '办公费报销',
type: '办公用品费报销',
typeTag: 'office',
applicant: '赵敏',
dept: '研发 · 平台组',
@@ -59,7 +59,7 @@ export const documents = [
}
]
export const docTypes = ['全部类型', '个人报销单', '业务招待费', '办公费报销', '会务费报销']
export const docTypes = ['全部类型', '个人报销单', '业务招待费', '办公用品费报销', '会务费报销']
export const docStatuses = ['全部状态', '草稿', '审批中', '已完成', '待补件']
export const docMonths = ['2026-05', '2026-04', '2026-03', '2026-02']

View File

@@ -2,8 +2,12 @@ const EXPENSE_SCENE_SELECTION_OPTIONS = [
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
{ key: 'entertainment', label: '业务招待费', description: '客户接待、餐饮招待等费用', icon: 'mdi mdi-food-fork-drink' },
{ key: 'office', label: '办公费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink' },
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline' },
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline' },
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message' },
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
]

View File

@@ -0,0 +1,150 @@
/** 数字员工设置:面向管理员的简明任务列表(频率固定,仅可调执行时间) */
export const HERMES_SIMPLE_TASKS = [
{
id: 'knowledgeAggregation',
label: '知识库同步',
hint: '同步制度文档与知识索引',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'ruleReviewDigest',
label: '规则待审提醒',
hint: '汇总待审规则并推送管理员',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'riskSummary',
label: '风险每日巡检',
hint: '扫描报销、付款等风险信号',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'archiveDigest',
label: '归档周报',
hint: '汇总已归档报销单',
frequency: 'weekly',
frequencyLabel: '每周一',
weekday: 1
},
{
id: 'dailyStats',
label: '日报统计',
hint: '生成昨日报销与审批数据',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'monthlyStats',
label: '月报统计',
hint: '每月 1 号生成上月汇总',
frequency: 'monthly',
frequencyLabel: '每月 1 日',
monthDay: 1
},
{
id: 'yearlyStats',
label: '年报统计',
hint: '每年 1 月 1 号生成上年汇总',
frequency: 'yearly',
frequencyLabel: '每年 1 月 1 日',
month: 1,
monthDay: 1
}
]
function buildDefaultSchedules() {
const defaults = {
knowledgeAggregation: { enabled: true, frequency: 'daily', time: '00:00', weekday: 1, monthDay: 1, month: 1 },
ruleReviewDigest: { enabled: true, frequency: 'daily', time: '18:00', weekday: 5, monthDay: 1, month: 1 },
riskSummary: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
archiveDigest: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 },
dailyStats: { enabled: true, frequency: 'daily', time: '08:30', weekday: 1, monthDay: 1, month: 1 },
monthlyStats: { enabled: true, frequency: 'monthly', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
yearlyStats: { enabled: false, frequency: 'yearly', time: '10:00', weekday: 1, monthDay: 1, month: 1 }
}
for (const task of HERMES_SIMPLE_TASKS) {
const schedule = defaults[task.id]
if (!schedule) {
continue
}
schedule.frequency = task.frequency
if (task.weekday != null) {
schedule.weekday = task.weekday
}
if (task.monthDay != null) {
schedule.monthDay = task.monthDay
}
if (task.month != null) {
schedule.month = task.month
}
}
return defaults
}
export function buildDefaultHermesEmployeeForm() {
return {
masterEnabled: true,
notifyOnFailure: true,
capabilities: {
knowledgeAggregation: true,
ruleReviewDigest: true,
riskSummary: true,
archiveDigest: false,
dailyStats: true,
monthlyStats: true,
yearlyStats: false
},
schedules: buildDefaultSchedules()
}
}
export function mergeHermesEmployeeForm(override = {}) {
const defaults = buildDefaultHermesEmployeeForm()
const schedules = { ...defaults.schedules }
for (const [key, value] of Object.entries(override?.schedules || {})) {
schedules[key] = { ...defaults.schedules[key], ...value }
}
for (const task of HERMES_SIMPLE_TASKS) {
if (schedules[task.id]) {
schedules[task.id].frequency = task.frequency
if (task.weekday != null) {
schedules[task.id].weekday = task.weekday
}
if (task.monthDay != null) {
schedules[task.id].monthDay = task.monthDay
}
if (task.month != null) {
schedules[task.id].month = task.month
}
}
}
return {
...defaults,
...override,
capabilities: {
...defaults.capabilities,
...(override?.capabilities || {})
},
schedules
}
}
export function isHermesTaskEnabled(form, taskId) {
return Boolean(form?.masterEnabled && form?.capabilities?.[taskId] && form?.schedules?.[taskId]?.enabled)
}
export function countEnabledHermesTasks(form) {
return HERMES_SIMPLE_TASKS.filter((task) => isHermesTaskEnabled(form, task.id)).length
}
export function isHermesEmployeeSettingsReady(form) {
return Boolean(!form?.masterEnabled || countEnabledHermesTasks(form) > 0)
}

View File

@@ -0,0 +1,315 @@
const KNOWLEDGE_INGEST_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const STATUS_META = {
queued: { label: '等待处理', tone: 'muted' },
running: { label: '处理中', tone: 'warning' },
succeeded: { label: '已完成', tone: 'success' },
failed: { label: '失败', tone: 'danger' },
skipped: { label: '已跳过', tone: 'muted' }
}
const PHASE_LABELS = {
queued: '进入队列',
indexing: '解析与索引',
indexed: '索引完成',
failed: '处理失败',
completed: '任务完成'
}
export function isKnowledgeIngestRun(run) {
const routeJson = asObject(run?.route_json)
return KNOWLEDGE_INGEST_JOB_TYPES.has(String(routeJson.job_type || '').trim())
}
export function buildKnowledgeIngestLogModel(run) {
const routeJson = asObject(run?.route_json)
const ingest = asObject(routeJson.knowledge_ingest)
const toolDocuments = extractToolDocuments(run)
const sourceDocuments = normalizeSourceDocuments(
ingest.documents,
toolDocuments,
routeJson.requested_document_ids
)
const documents = sourceDocuments.map(normalizeDocument)
const graph = normalizeGraph(ingest.graph, documents)
const progress = normalizeProgress(routeJson.progress, documents)
const currentDocumentId = String(ingest.current_document_id || '').trim()
return {
available: isKnowledgeIngestRun(run),
folder: String(routeJson.folder || '').trim(),
phase: String(ingest.phase || routeJson.phase || '').trim(),
phaseLabel: PHASE_LABELS[ingest.phase] || PHASE_LABELS[routeJson.phase] || '运行中',
status: String(ingest.status || run?.status || '').trim(),
statusLabel: resolveStatusMeta(ingest.status || run?.status).label,
statusTone: resolveStatusMeta(ingest.status || run?.status).tone,
progress,
currentDocumentId,
documents,
selectedDocumentId: resolveDefaultDocumentId(documents, currentDocumentId),
graph,
metrics: [
{
label: '文件',
value: `${progress.completedDocuments}/${progress.totalDocuments}`,
hint: `失败 ${progress.failedDocuments}`
},
{
label: 'Chunk',
value: formatNumber(graph.chunkCount),
hint: '已解析块'
},
{
label: '实体',
value: formatNumber(graph.entityCount),
hint: '图谱节点'
},
{
label: '关系',
value: formatNumber(graph.relationCount),
hint: '图谱边'
}
]
}
}
export function formatKnowledgeMetric(value) {
return formatNumber(value)
}
function normalizeSourceDocuments(ingestDocuments, toolDocuments, requestedDocumentIds) {
if (Array.isArray(ingestDocuments) && ingestDocuments.length) {
return ingestDocuments
}
if (Array.isArray(toolDocuments) && toolDocuments.length) {
return toolDocuments
}
if (Array.isArray(requestedDocumentIds)) {
return requestedDocumentIds
.map((documentId) => String(documentId || '').trim())
.filter(Boolean)
.map((documentId) => ({ document_id: documentId, name: documentId, status: 'queued' }))
}
return []
}
function extractToolDocuments(run) {
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
for (const toolCall of [...toolCalls].reverse()) {
const responseJson = asObject(toolCall?.response_json)
if (Array.isArray(responseJson.documents) && responseJson.documents.length) {
return responseJson.documents
}
}
return []
}
function normalizeDocument(rawDocument) {
const document = asObject(rawDocument)
const documentId = String(document.document_id || document.id || '').trim()
const status = String(document.status || 'queued').trim()
const phase = String(document.phase || status).trim()
const chunks = normalizeChunks(document.chunks)
const sections = normalizeSections(document.sections)
const entities = normalizeEntities(document.entities)
const relations = normalizeRelations(document.relations)
return {
documentId,
name: String(document.name || document.original_name || documentId || '未命名文件').trim(),
folder: String(document.folder || '').trim(),
extension: String(document.extension || '').trim(),
mimeType: String(document.mime_type || '').trim(),
status,
statusLabel: resolveStatusMeta(status).label,
statusTone: resolveStatusMeta(status).tone,
phase,
phaseLabel: PHASE_LABELS[phase] || PHASE_LABELS[status] || phase || '未开始',
startedAt: String(document.started_at || '').trim(),
finishedAt: String(document.finished_at || '').trim(),
error: String(document.error || '').trim(),
textChars: toNumber(document.text_chars),
indexedTextChars: toNumber(document.indexed_text_chars),
sectionCount: toNumber(document.section_count || sections.length),
chunkCount: toNumber(document.chunk_count || chunks.length),
chunkIds: normalizeTextList(document.chunk_ids),
chunks,
entityCount: toNumber(document.entity_count || entities.length),
relationCount: toNumber(document.relation_count || relations.length),
entities,
relations,
events: normalizeEvents(document.events)
}
}
function normalizeProgress(rawProgress, documents) {
const progress = asObject(rawProgress)
const totalDocuments = toNumber(progress.total_documents || documents.length)
const completedDocuments = toNumber(
progress.completed_documents || documents.filter((item) => item.status === 'succeeded').length
)
const failedDocuments = toNumber(
progress.failed_documents || documents.filter((item) => item.status === 'failed').length
)
const skippedDocuments = toNumber(progress.skipped_documents)
const percent = clampPercent(
progress.percent ?? calculatePercent(totalDocuments, completedDocuments + failedDocuments)
)
return {
totalDocuments,
completedDocuments,
failedDocuments,
skippedDocuments,
percent
}
}
function normalizeGraph(rawGraph, documents) {
const graph = asObject(rawGraph)
const fallbackEntities = dedupeTextList(documents.flatMap((item) => item.entities))
const fallbackRelations = dedupeRelations(documents.flatMap((item) => item.relations))
return {
chunkCount: toNumber(
graph.chunk_count || documents.reduce((total, item) => total + item.chunkCount, 0)
),
entityCount: toNumber(
graph.entity_count || documents.reduce((total, item) => total + item.entityCount, 0)
),
relationCount: toNumber(
graph.relation_count || documents.reduce((total, item) => total + item.relationCount, 0)
),
entities: normalizeTextList(graph.entities).length
? normalizeTextList(graph.entities)
: fallbackEntities,
relations: normalizeRelations(graph.relations).length
? normalizeRelations(graph.relations)
: fallbackRelations
}
}
function normalizeChunks(rawChunks) {
if (!Array.isArray(rawChunks)) return []
return rawChunks
.map((chunk, index) => {
const item = asObject(chunk)
return {
id: String(item.id || item._id || `chunk-${index + 1}`).trim(),
order: toNumber(item.order ?? item.chunk_order_index ?? index),
tokens: toNumber(item.tokens),
summary: String(item.summary || item.content || '').trim()
}
})
.sort((left, right) => left.order - right.order)
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections)) return []
return rawSections.map((section, index) => {
const item = asObject(section)
return {
title: String(item.title || `章节 ${index + 1}`).trim(),
excerpt: String(item.excerpt || '').trim()
}
})
}
function normalizeEvents(rawEvents) {
if (!Array.isArray(rawEvents)) return []
return rawEvents.map((event) => {
const item = asObject(event)
return {
at: String(item.at || '').trim(),
level: String(item.level || 'info').trim(),
message: String(item.message || '').trim()
}
})
}
function normalizeEntities(rawEntities) {
return normalizeTextList(rawEntities)
}
function normalizeRelations(rawRelations) {
if (!Array.isArray(rawRelations)) return []
return rawRelations
.map((relation) => {
const item = asObject(relation)
return {
source: String(item.source || item.from || '').trim(),
target: String(item.target || item.to || '').trim(),
type: String(item.type || '关联').trim()
}
})
.filter((item) => item.source && item.target)
}
function resolveDefaultDocumentId(documents, currentDocumentId) {
if (currentDocumentId && documents.some((item) => item.documentId === currentDocumentId)) {
return currentDocumentId
}
return (
documents.find((item) => item.status === 'running')?.documentId ||
documents.find((item) => item.status === 'failed')?.documentId ||
documents[0]?.documentId ||
''
)
}
function resolveStatusMeta(status) {
return STATUS_META[String(status || '').trim()] || STATUS_META.queued
}
function asObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
function normalizeTextList(value) {
if (!Array.isArray(value)) return []
return dedupeTextList(value)
}
function dedupeTextList(items) {
const result = []
const seen = new Set()
for (const item of items) {
const text = String(item || '').trim()
if (!text || seen.has(text)) continue
seen.add(text)
result.push(text)
}
return result
}
function dedupeRelations(items) {
const result = []
const seen = new Set()
for (const item of items) {
const source = String(item?.source || '').trim()
const target = String(item?.target || '').trim()
const type = String(item?.type || '关联').trim()
const key = `${source}::${target}::${type}`
if (!source || !target || seen.has(key)) continue
seen.add(key)
result.push({ source, target, type })
}
return result
}
function calculatePercent(total, done) {
if (!total) return 0
return Math.round((done / total) * 100)
}
function clampPercent(value) {
const numericValue = toNumber(value)
return Math.max(0, Math.min(100, numericValue))
}
function formatNumber(value) {
const numericValue = toNumber(value)
return Number.isFinite(numericValue) ? numericValue.toLocaleString('zh-CN') : '0'
}
function toNumber(value) {
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}

View File

@@ -12,8 +12,32 @@ const defaultParagraphOpen = markdown.renderer.rules.paragraph_open
const defaultLinkOpen = markdown.renderer.rules.link_open
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
const RISK_TEXT_CLASS_BY_LABEL = {
低风险: 'markdown-risk-text-low',
中风险: 'markdown-risk-text-medium',
高风险: 'markdown-risk-text-high'
}
const ACTION_LINK_CLASS_BY_HREF = {
'#confirm-attachment-association': 'markdown-action-link-confirm'
'#confirm-attachment-association': 'markdown-action-link-confirm',
'#review-next-step': 'markdown-action-link-next',
'#review-quick-edit': 'markdown-action-link-edit',
'#review-risk-panel': 'markdown-action-link-risk'
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderRiskText(text) {
return escapeHtml(text).replace(/低风险|中风险|高风险/g, (label) => {
const className = RISK_TEXT_CLASS_BY_LABEL[label]
return className ? `<span class="${className}">${label}</span>` : label
})
}
function resolveActionLinkClass(href) {
@@ -70,6 +94,8 @@ markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.text = (tokens, idx) => renderRiskText(tokens[idx]?.content)
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
if (blockquoteHasAttachmentHeading(tokens, idx)) {
tokens[idx].attrJoin('class', 'markdown-attachment-card')

View File

@@ -22,17 +22,17 @@ const DEFAULT_EXPENSE_TYPE_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '费',
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
office: '办公费',
office: '办公用品费',
training: '培训费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
}
export const TRANSPORT_KEYWORD_PATTERN = /交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费/
export const TRANSPORT_KEYWORD_PATTERN = /交通|市内交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费|高速费|油费/
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
@@ -104,21 +104,36 @@ export function inferLocalFlowCandidates(rawText) {
let event = ''
let expenseType = ''
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
event = '请客户吃饭'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/住宿|酒店|宾馆/.test(compact)) {
} else if (/客户.*吃饭|请客户.*吃饭|客户用餐|客户接待|商务接待|招待|宴请|请客/.test(compact)) {
event = '业务招待'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|飞机票|航班|高铁票|高铁|火车票|火车|动车|行程单|铁路客票/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (/住宿|住宿费|酒店|酒店发票|宾馆|民宿|房费|客房/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
event = '餐饮用餐'
expenseType = '费'
} else if (/餐费|工作餐|用餐|午餐|晚餐|早餐|餐饮|伙食|茶歇/.test(compact)) {
event = '业务招待'
expenseType = '业务招待费'
} else if (/会务|会议费|会议|参会|会场|场地费|论坛|展会/.test(compact)) {
event = '会务活动'
expenseType = '会务费'
} else if (/办公用品|办公耗材|办公设备|文具|打印纸|硒鼓|墨盒|键盘|鼠标|白板/.test(compact)) {
event = '办公采购'
expenseType = '办公用品费'
} else if (/培训|讲师费|课程费|教材|认证费|考试费/.test(compact)) {
event = '培训学习'
expenseType = '培训费'
} else if (/通讯费|话费|电话费|手机费|流量费|宽带费|网络费/.test(compact)) {
event = '通讯使用'
expenseType = '通讯费'
} else if (/福利费|团建|慰问|节日福利|体检费|员工关怀/.test(compact)) {
event = '员工福利'
expenseType = '福利费'
}
return {
@@ -232,7 +247,13 @@ export function buildLocalExtractionProgressMessages(rawText, options = {}) {
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
const pendingSlots = ['发生时间', '金额', attachmentHint]
if (candidates.expenseType === '业务招待费') {
pendingSlots.splice(2, 0, '客户名称', '参与人员')
} else if (candidates.expenseType === '住宿费') {
pendingSlots.splice(2, 0, '酒店/商户')
}
messages.push(`正在判断待补项:${pendingSlots.join('、')}`)
return messages
}

View File

@@ -54,13 +54,13 @@ const REQUEST_TYPE_META = {
secondaryStatusLabel: '票据状态'
},
meal: {
label: '费',
label: '业务招待费',
detailVariant: 'general',
tone: 'meeting',
secondaryStatusLabel: '票据状态'
},
office: {
label: '办公费',
label: '办公用品费',
detailVariant: 'general',
tone: 'office',
secondaryStatusLabel: '票据状态'

View File

@@ -0,0 +1,461 @@
import {
buildDefaultHermesEmployeeForm,
isHermesEmployeeSettingsReady,
mergeHermesEmployeeForm
} from './hermesEmployeeSettingsModel.js'
export const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
export const CURRENT_YEAR = new Date().getFullYear()
export const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
export const MODEL_SECRET_MASK = '********'
export const RENDER_SECRET_MASK = '********'
export const SECTION_DEFINITIONS = [
{
id: 'profile',
label: '企业信息',
title: '系统基本信息',
desc: '公司名称、品牌与版权',
longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。',
actionLabel: '保存企业信息'
},
{
id: 'admin',
label: '管理员安全',
title: '管理员账号与安全策略',
desc: '账号、密码与登录安全',
longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。',
actionLabel: '保存安全设置'
},
{
id: 'session',
label: '会话设置',
title: '会话留存设置',
desc: '会话保留天数',
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
actionLabel: '保存会话设置'
},
{
id: 'hermes',
label: '数字员工设置',
title: '数字员工设置',
desc: 'Hermes 自动任务',
longDesc: '选择需要自动执行的任务,并设置每天的执行时间。无需了解 Cron 或复杂调度规则。',
actionLabel: '保存数字员工设置'
},
{
id: 'llm',
label: '大语言模型',
title: '模型接入配置',
desc: '主模型、备份模型与检索模型',
longDesc: '集中维护主模型、备份模型、Embedding 模型和 Reranker 模型的接入参数,供 AI 助手和检索链路调用。',
actionLabel: '保存模型配置'
},
{
id: 'rendering',
label: '文件渲染',
title: '文件渲染',
desc: '文档预览服务与访问密钥',
longDesc: '维护文件渲染开关、文档服务对外地址和 JWT 密钥,后端回调地址继续由部署配置管理。',
actionLabel: '保存文件渲染配置'
},
{
id: 'logs',
label: '日志策略',
title: '日志与审计策略',
desc: '日志级别、留存与脱敏',
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
actionLabel: '保存日志策略'
},
{
id: 'mail',
label: '邮箱设置',
title: '邮箱通知配置',
desc: 'SMTP 与通知投递策略',
longDesc: '维护系统邮件发送配置和通知投递策略,审批、告警和摘要邮件都会依赖这里的设置。',
actionLabel: '保存邮箱配置'
}
]
export const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR']
export const PROVIDER_OPTIONS = [
'MiniMax',
'GLM',
'Kimi',
'Ali',
'Codex',
'Claude',
'Gemini',
CUSTOM_OPENAI_PROVIDER
]
export const PROVIDER_ENDPOINTS = {
MiniMax: 'https://api.minimaxi.com/v1',
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
Kimi: 'https://api.moonshot.ai/v1',
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
Codex: 'https://api.openai.com/v1',
Claude: 'https://api.anthropic.com/v1/',
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
[CUSTOM_OPENAI_PROVIDER]: ''
}
export const RERANKER_PROVIDER_ENDPOINTS = {
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
[CUSTOM_OPENAI_PROVIDER]: ''
}
export const LEGACY_PROVIDER_MAP = {
'OpenAI Compatible': 'Codex',
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
Ollama: CUSTOM_OPENAI_PROVIDER,
'自定义网关': CUSTOM_OPENAI_PROVIDER
}
export const MODEL_TEST_CONFIGS = {
main: {
label: '主模型',
providerKey: 'mainProvider',
modelKey: 'mainModel',
endpointKey: 'mainEndpoint',
apiKeyKey: 'mainApiKey',
capability: 'chat'
},
backup: {
label: '备份模型',
providerKey: 'backupProvider',
modelKey: 'backupModel',
endpointKey: 'backupEndpoint',
apiKeyKey: 'backupApiKey',
capability: 'chat'
},
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
},
reranker: {
label: 'Reranker 模型',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
capability: 'reranker'
}
}
export const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
value: index + 1,
label: `${index + 1}`
}))
export function normalizeValue(value) {
return String(value ?? '').trim()
}
export function normalizeProviderValue(value, fallback = 'Codex') {
const normalized = normalizeValue(value)
if (PROVIDER_OPTIONS.includes(normalized)) {
return normalized
}
if (LEGACY_PROVIDER_MAP[normalized]) {
return LEGACY_PROVIDER_MAP[normalized]
}
return fallback
}
export function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
export function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
export function buildDefaultState(companyProfile, currentUser) {
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
const adminEmail =
normalizeValue(companyProfile?.adminEmail) ||
normalizeValue(currentUser?.email) ||
'admin@example.com'
const adminAccount = normalizeValue(currentUser?.username) || 'superadmin'
return {
companyForm: {
companyName,
displayName: companyName,
companyCode,
logo: normalizeValue(companyProfile?.logo) || '',
recordNumber: '',
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
},
adminForm: {
adminAccount,
adminEmail,
newPassword: '',
confirmPassword: '',
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
noticeEmail: adminEmail,
mfaEnabled: true,
strongPassword: true,
loginAlertEnabled: true,
adminPasswordConfigured: false
},
sessionForm: {
conversationRetentionDays: 3
},
llmForm: {
mainProvider: 'Codex',
mainModel: 'codex-mini-latest',
mainEndpoint: getProviderEndpoint('Codex'),
mainApiKey: '',
mainApiKeyConfigured: false,
backupProvider: 'GLM',
backupModel: 'glm-5.1',
backupEndpoint: getProviderEndpoint('GLM'),
backupApiKey: '',
backupApiKeyConfigured: false,
embeddingProvider: 'GLM',
embeddingModel: 'Embedding-3',
embeddingEndpoint: getProviderEndpoint('GLM'),
embeddingApiKey: '',
embeddingApiKeyConfigured: false,
rerankerProvider: 'Ali',
rerankerModel: 'gte-rerank-v2',
rerankerEndpoint: getRerankerEndpoint('Ali'),
rerankerApiKey: '',
rerankerApiKeyConfigured: false
},
renderForm: {
enabled: false,
publicUrl: '',
jwtSecret: '',
jwtSecretConfigured: false
},
logForm: {
level: 'INFO',
retentionDays: 180,
archiveCycle: 'weekly',
logPath: 'server/logs/app.log',
alertEmail: adminEmail,
operationAudit: true,
loginAudit: true,
maskSensitive: true
},
hermesForm: buildDefaultHermesEmployeeForm(),
mailForm: {
smtpHost: 'smtp.exmail.qq.com',
port: 465,
encryption: 'SSL/TLS',
senderName: companyName,
senderAddress: adminEmail,
username: adminEmail,
password: '',
passwordConfigured: false,
alertEnabled: true,
digestEnabled: false,
digestTime: '09:00',
defaultReceiver: adminEmail
}
}
}
export function readStoredSettings() {
if (typeof window === 'undefined') {
return null
}
const raw = window.sessionStorage.getItem(SETTINGS_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw)
} catch {
return null
}
}
export function mergeState(baseState, overrideState) {
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
mergedLlmForm.embeddingProvider = normalizeProviderValue(
mergedLlmForm.embeddingProvider,
baseState.llmForm.embeddingProvider
)
mergedLlmForm.rerankerProvider = normalizeProviderValue(
mergedLlmForm.rerankerProvider,
baseState.llmForm.rerankerProvider
)
return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
hermesForm: mergeHermesEmployeeForm({
...baseState.hermesForm,
...(overrideState?.hermesForm || {})
}),
llmForm: mergedLlmForm,
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) }
}
}
export function sanitizeForStorage(state) {
return {
companyForm: { ...state.companyForm },
adminForm: {
...state.adminForm,
newPassword: '',
confirmPassword: ''
},
sessionForm: { ...state.sessionForm },
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
llmForm: {
...state.llmForm,
mainApiKey: '',
backupApiKey: '',
embeddingApiKey: '',
rerankerApiKey: ''
},
renderForm: {
...state.renderForm,
jwtSecret: ''
},
logForm: { ...state.logForm },
mailForm: {
...state.mailForm,
password: ''
}
}
}
export function getModelConfiguredKey(apiKeyKey) {
return `${apiKeyKey}Configured`
}
export function isModelSecretMask(value) {
return value === MODEL_SECRET_MASK
}
export function maskConfiguredModelSecrets(state) {
for (const config of MODEL_API_KEY_CONFIGS) {
const configuredKey = getModelConfiguredKey(config.apiKeyKey)
if (state.llmForm[configuredKey] && !normalizeValue(state.llmForm[config.apiKeyKey])) {
state.llmForm[config.apiKeyKey] = MODEL_SECRET_MASK
}
}
return state
}
export function buildLlmPayload(llmForm) {
const payload = { ...llmForm }
for (const config of MODEL_API_KEY_CONFIGS) {
if (isModelSecretMask(payload[config.apiKeyKey])) {
payload[config.apiKeyKey] = ''
}
}
return payload
}
export function isRenderSecretMask(value) {
return value === RENDER_SECRET_MASK
}
export function maskConfiguredRenderSecret(state) {
if (state.renderForm.jwtSecretConfigured && !normalizeValue(state.renderForm.jwtSecret)) {
state.renderForm.jwtSecret = RENDER_SECRET_MASK
}
return state
}
export function buildRenderPayload(renderForm) {
const payload = { ...renderForm }
if (isRenderSecretMask(payload.jwtSecret)) {
payload.jwtSecret = ''
}
return payload
}
export function persistSettings(state) {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(sanitizeForStorage(state)))
}
export function isModelConfigReady(provider, model, endpoint) {
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
}
export function computeSectionStatus(state) {
return {
profile: Boolean(
normalizeValue(state.companyForm.companyName) &&
normalizeValue(state.companyForm.displayName) &&
normalizeValue(state.companyForm.copyright)
),
admin: Boolean(
normalizeValue(state.adminForm.adminAccount) &&
normalizeValue(state.adminForm.adminEmail) &&
Number(state.adminForm.sessionTimeout) >= 5
),
session: Boolean(
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
Number(state.sessionForm.conversationRetentionDays) <= 10
),
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
llm: Boolean(
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
isModelConfigReady(
state.llmForm.embeddingProvider,
state.llmForm.embeddingModel,
state.llmForm.embeddingEndpoint
) &&
isModelConfigReady(
state.llmForm.rerankerProvider,
state.llmForm.rerankerModel,
state.llmForm.rerankerEndpoint
)
),
rendering: Boolean(
!state.renderForm.enabled ||
(normalizeValue(state.renderForm.publicUrl) &&
(normalizeValue(state.renderForm.jwtSecret) || state.renderForm.jwtSecretConfigured))
),
logs: Boolean(
normalizeValue(state.logForm.level) &&
Number(state.logForm.retentionDays) > 0 &&
normalizeValue(state.logForm.logPath)
),
mail: Boolean(
normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 &&
normalizeValue(state.mailForm.senderAddress) &&
normalizeValue(state.mailForm.username)
)
}
}

View File

@@ -4,6 +4,7 @@
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="companyProfile.name"
:company-logo="companyProfile.logo"
:current-user="currentUser"
@navigate="handleNavigate"
@open-chat="openSmartEntry"

View File

@@ -0,0 +1,136 @@
<template>
<div class="hermes-settings-container">
<!-- 主控制卡片 -->
<section class="settings-card hermes-hero-card" :class="{ active: hermesForm.masterEnabled }">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box" :class="{ active: hermesForm.masterEnabled }">
<i class="mdi mdi-robot"></i>
<span class="status-pulse-dot" :class="{ active: hermesForm.masterEnabled }"></span>
</div>
<div>
<h4>数字员工自动任务主控</h4>
<p>开启后系统将自动按计划调度后台数字员工执行知识同步规则待审风险扫描及数据统计任务</p>
</div>
</div>
<div class="card-head-actions">
<span class="status-badge" :class="{ active: hermesForm.masterEnabled }">
{{ hermesForm.masterEnabled ? '调度服务运行中' : '调度服务已禁用' }}
</span>
<button
class="switch-btn"
type="button"
:class="{ active: hermesForm.masterEnabled }"
aria-label="切换全局自动任务"
@click="$emit('toggle-master')"
>
<i></i>
</button>
</div>
</div>
</section>
<!-- 任务网格控制 -->
<section class="settings-card hermes-tasks-section" :class="{ disabled: !hermesForm.masterEnabled }">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box">
<i class="mdi mdi-clipboard-list-outline"></i>
</div>
<div>
<h4>自动任务项管理</h4>
<p>配置并调度具体的后台异步任务在设定的周期与执行时间定时运行</p>
</div>
</div>
<div class="card-head-actions">
<span class="section-badge">已启用 {{ activeTasksCount }} / {{ HERMES_SIMPLE_TASKS.length }} </span>
</div>
</div>
<ul class="hermes-task-grid">
<li
v-for="task in HERMES_SIMPLE_TASKS"
:key="task.id"
class="hermes-task-card"
:class="{ active: isTaskOn(task.id), disabled: !hermesForm.masterEnabled }"
>
<div class="task-card-header">
<div class="task-icon-box" :class="getTaskColorClass(task.id)">
<i class="mdi" :class="getTaskIcon(task.id)"></i>
</div>
<div class="task-meta-info">
<strong>{{ task.label }}</strong>
<small>{{ task.hint }}</small>
</div>
<button
class="switch-btn mini"
type="button"
:class="{ active: isTaskOn(task.id) }"
:disabled="!hermesForm.masterEnabled"
:aria-label="`${isTaskOn(task.id) ? '关闭' : '开启'}${task.label}`"
@click="$emit('toggle-task', task.id)"
>
<i></i>
</button>
</div>
<div class="task-card-footer">
<div class="frequency-badge" :class="{ active: isTaskOn(task.id) }">
<i class="mdi mdi-clock-outline"></i>
<span>{{ task.frequencyLabel }}</span>
</div>
<div v-if="isTaskOn(task.id)" class="time-picker-wrapper">
<input
:value="taskTime(task.id)"
type="time"
:disabled="!hermesForm.masterEnabled"
aria-label="设置执行时间"
@input="$emit('update-task-time', { taskId: task.id, time: $event.target.value })"
/>
</div>
<div v-else class="time-picker-placeholder">
<span>未开启</span>
</div>
</div>
</li>
</ul>
</section>
<!-- 额外设置 (任务告警通知) -->
<section class="settings-card hermes-extra-settings">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box">
<i class="mdi mdi-bell-ring-outline"></i>
</div>
<div>
<h4>任务告警通知</h4>
<p>配置自动任务运行出现故障限频或执行失败时的管理员实时提醒通知渠道</p>
</div>
</div>
</div>
<div class="switch-group">
<button
class="switch-row"
type="button"
:class="{ active: hermesForm.notifyOnFailure }"
aria-label="切换邮件通知"
@click="$emit('toggle-flag', 'notifyOnFailure')"
>
<span class="switch-copy">
<strong>任务失败时发送邮件通知管理员</strong>
<small>仅在自动任务执行出现故障或异常时触发告警保障后台服务的高可用性</small>
</span>
<span class="switch-btn" :class="{ active: hermesForm.notifyOnFailure }"><i></i></span>
</button>
</div>
</section>
</div>
</template>
<script src="./scripts/HermesEmployeeSettingsPanel.js"></script>
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
<style scoped src="../assets/styles/views/settings-view.css"></style>
<style scoped src="../assets/styles/views/settings-view-hermes.css"></style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="model-grid">
<!-- 主模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box purple">
<i class="mdi mdi-brain"></i>
</div>
<div>
<h4>主模型配置</h4>
<p>用于 AI 助手和主业务排队调度的默认模型接入</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('main')"
@click="testModelConnection('main')"
>
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="llmForm.mainProvider" @change="applyProviderPreset('main')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.mainApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('main')"
:placeholder="llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.mainApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
<i
:class="
getModelTestState('main').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('main').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('main').message }}</span>
</div>
</section>
<!-- 备份模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box orange">
<i class="mdi mdi-lifebuoy"></i>
</div>
<div>
<h4>备份模型配置</h4>
<p>主模型不可用或限频时用于兜底切换的备用模型接入</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('backup')"
@click="testModelConnection('backup')"
>
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="llmForm.backupProvider" @change="applyProviderPreset('backup')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.backupApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('backup')"
:placeholder="llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.backupApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
<i
:class="
getModelTestState('backup').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('backup').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('backup').message }}</span>
</div>
</section>
<!-- Embedding 模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box cyan">
<i class="mdi mdi-vector-combine"></i>
</div>
<div>
<h4>Embedding 模型配置</h4>
<p>用于向量检索知识库召回和语义匹配的嵌入模型设置</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('embedding')"
@click="testModelConnection('embedding')"
>
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="llmForm.embeddingProvider" @change="applyProviderPreset('embedding')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.embeddingApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('embedding').message"
class="test-feedback"
:class="`is-${getModelTestState('embedding').status}`"
>
<i
:class="
getModelTestState('embedding').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('embedding').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('embedding').message }}</span>
</div>
</section>
<!-- Reranker 模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box teal">
<i class="mdi mdi-filter-variant"></i>
</div>
<div>
<h4>Reranker 模型配置</h4>
<p>用于检索结果重排和语义精排的 Reranker 模型设置</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('reranker')"
@click="testModelConnection('reranker')"
>
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="llmForm.rerankerProvider" @change="applyProviderPreset('reranker')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.rerankerApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('reranker')"
:placeholder="llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('reranker').message"
class="test-feedback"
:class="`is-${getModelTestState('reranker').status}`"
>
<i
:class="
getModelTestState('reranker').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('reranker').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('reranker').message }}</span>
</div>
</section>
</div>
</template>
<script src="./scripts/LlmSettingsPanel.js"></script>
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
<style scoped src="../assets/styles/views/settings-view.css"></style>

View File

@@ -43,6 +43,11 @@
{{ hermesRunAlert.message }}
</article>
<KnowledgeIngestRunPanel
v-if="isKnowledgeIngestRunDetail"
:run="hermesRun"
/>
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
@@ -63,9 +68,9 @@
</div>
</article>
<article class="panel detail-card">
<article v-if="!isKnowledgeIngestRunDetail" class="panel detail-card">
<div class="card-head">
<h3>处理链路</h3>
<h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p>
</div>
<div v-if="(hermesRun.tool_calls || []).length" class="trace-steps">
@@ -92,7 +97,7 @@
</div>
</article>
<article v-if="selectedToolCall" class="panel detail-card">
<article v-if="selectedToolCall && !isKnowledgeIngestRunDetail" class="panel detail-card">
<div class="card-head">
<h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p>
@@ -194,6 +199,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KnowledgeIngestRunPanel from '../components/logs/KnowledgeIngestRunPanel.vue'
import { fetchAgentRunDetail } from '../services/agentAssets.js'
import { fetchSystemLogEntry } from '../services/systemLogs.js'
import {
@@ -204,6 +210,7 @@ import {
resolveAgentRunHeartbeat,
resolveAgentRunStatus
} from '../utils/agentRunMonitor.js'
import { isKnowledgeIngestRun } from '../utils/knowledgeIngestLogModel.js'
const SOURCE_LABELS = {
schedule: '定时任务',
@@ -223,6 +230,7 @@ let pollTimer = 0
const isHermes = computed(() => route.params.logKind === 'hermes')
const isSystem = computed(() => route.params.logKind === 'system')
const isKnowledgeIngestRunDetail = computed(() => isKnowledgeIngestRun(hermesRun.value))
const selectedToolCall = computed(() =>
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
)

View File

@@ -0,0 +1,163 @@
<template>
<div class="mail-panel-container">
<!-- Card 1: SMTP Server Configuration -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-send-outline"></i>
</div>
<div>
<h4>发信服务器配置</h4>
<p>维护系统发信的 SMTP 服务端点网络端口与身份验证凭证</p>
</div>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> SMTP Host</span>
<input v-model="mailForm.smtpHost" type="text" placeholder="例如 smtp.exmail.qq.com" />
</label>
<label class="field">
<span><em>*</em> 端口</span>
<input v-model.number="mailForm.port" type="number" min="1" max="65535" placeholder="SSL 默认 465" />
</label>
<label class="field">
<span>加密方式</span>
<select v-model="mailForm.encryption">
<option value="SSL/TLS">SSL/TLS</option>
<option value="STARTTLS">STARTTLS</option>
<option value="None"></option>
</select>
</label>
<label class="field">
<span><em>*</em> 登录账号</span>
<input v-model="mailForm.username" type="text" placeholder="请输入 SMTP 登录账号,通常为发信邮箱" />
</label>
<label class="field field-full">
<span>SMTP 密码</span>
<input
v-model="mailForm.password"
type="password"
autocomplete="new-password"
:placeholder="mailForm.passwordConfigured ? '已配置,如需修改请重新输入' : '请输入 SMTP 密码或客户端授权码'"
/>
<small v-if="mailForm.passwordConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>密保已加密安全托管</span>
</small>
</label>
</div>
</section>
<!-- Card 2: Sender Identity Card -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-account-outline"></i>
</div>
<div>
<h4>发信身份配置</h4>
<p>设置邮件外显名称及默认的邮件接收账户</p>
</div>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>发件人名称</span>
<input v-model="mailForm.senderName" type="text" placeholder="例如 X-Financial 财务系统" />
</label>
<label class="field">
<span><em>*</em> 发件人邮箱</span>
<input v-model="mailForm.senderAddress" type="email" placeholder="例如 admin@company.com" />
</label>
<label class="field field-full">
<span>默认接收邮箱</span>
<input v-model="mailForm.defaultReceiver" type="email" placeholder="审批待办等默认分发邮箱" />
</label>
</div>
</section>
<!-- Card 3: Notification Policy Card -->
<section class="settings-card notice-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-bell-ring-outline"></i>
</div>
<div>
<h4>通知策略</h4>
<p>控制各类业务消息是否通过邮件通知以及定时摘要发送频率</p>
</div>
</div>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleField('alertEnabled')">
<span class="switch-copy">
<strong>启用系统通知</strong>
<small>审批申请异常告警和核心系统事件可通过邮件实时触达用户</small>
</span>
<span class="switch-btn" :class="{ active: mailForm.alertEnabled }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleField('digestEnabled')">
<span class="switch-copy">
<strong>启用日报摘要</strong>
<small>每天定时发送系统运行概况与待办任务列表的邮件摘要</small>
</span>
<span class="switch-btn" :class="{ active: mailForm.digestEnabled }"><i></i></span>
</button>
</div>
<div class="digest-time-wrapper">
<div class="form-grid">
<label class="field" :class="{ disabled: !mailForm.digestEnabled }">
<span>摘要发送时间</span>
<input v-model="mailForm.digestTime" type="time" :disabled="!mailForm.digestEnabled" />
<small>设定每日发送邮件简报的具体时间点</small>
</label>
</div>
</div>
</section>
</div>
</template>
<script src="./scripts/MailSettingsPanel.js"></script>
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
<style scoped src="../assets/styles/views/settings-view.css"></style>
<style scoped>
.mail-panel-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.notice-card .switch-group {
margin-bottom: 20px;
}
.digest-time-wrapper {
margin-top: 4px;
}
.field.disabled {
opacity: 0.6;
}
.field.disabled input {
background: #f1f5f9;
cursor: not-allowed;
}
</style>

View File

@@ -5,7 +5,7 @@
<div class="settings-nav-head">
<span class="nav-kicker">Settings</span>
<h2>系统设置</h2>
<p>已完成 {{ completedSectionCount }} / {{ sections.length }} 项配置敏感字段不会保存在浏览器草稿中</p>
<p>已完成 {{ completedSectionCount }} / {{ sections.length }} 项配置</p>
</div>
<nav class="settings-nav-list">
@@ -47,18 +47,26 @@
<template v-if="activeSection === 'profile'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>系统基本信息</h4>
<p>统一维护企业名称系统显示名称和版权信息保存后会同步更新当前系统品牌名称</p>
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-domain"></i>
</div>
<div>
<h4>系统基本信息</h4>
<p>统一维护企业名称系统显示名称和版权信息保存后会同步更新当前系统品牌名称</p>
</div>
</div>
</div>
<div class="form-grid profile-grid">
<label class="field logo-field">
<span><em>*</em> 系统图标</span>
<div class="logo-tile" aria-hidden="true">
<i class="mdi mdi-domain"></i>
<div class="logo-tile" aria-hidden="true" @click="triggerLogoUpload" style="cursor: pointer; overflow: hidden;" title="点击上传图片">
<img v-if="pageState.companyForm.logo" :src="pageState.companyForm.logo" style="width:100%;height:100%;object-fit:contain;" />
<i v-else class="mdi mdi-domain"></i>
</div>
<input type="file" ref="logoInputRef" accept="image/*" style="display: none;" @change="handleLogoUpload" />
<small style="color:#64748b; font-size:12px; margin-top:4px;">建议尺寸 64x64PNG/JPG 格式</small>
</label>
<label class="field">
@@ -96,9 +104,14 @@
<template v-else-if="activeSection === 'admin'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>管理员账号</h4>
<p>维护最高权限管理员的登录账户密码和安全通知邮箱</p>
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-account-cog-outline"></i>
</div>
<div>
<h4>管理员账号</h4>
<p>维护最高权限管理员的登录账户密码和安全通知邮箱</p>
</div>
</div>
</div>
@@ -137,9 +150,14 @@
<section class="settings-card">
<div class="card-head">
<div>
<h4>登录安全策略</h4>
<p>控制会话超时登录提醒和管理员高风险操作的基础安全策略</p>
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-shield-lock-outline"></i>
</div>
<div>
<h4>登录安全策略</h4>
<p>控制会话超时登录提醒和管理员高风险操作的基础安全策略</p>
</div>
</div>
</div>
@@ -161,7 +179,7 @@
<strong>开启双因素验证</strong>
<small>要求管理员使用附加验证步骤登录后台</small>
</span>
<span class="switch" :class="{ active: pageState.adminForm.mfaEnabled }"><i></i></span>
<span class="switch-btn" :class="{ active: pageState.adminForm.mfaEnabled }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'strongPassword')">
@@ -169,7 +187,7 @@
<strong>启用强密码策略</strong>
<small>管理员密码修改时需要满足强度要求</small>
</span>
<span class="switch" :class="{ active: pageState.adminForm.strongPassword }"><i></i></span>
<span class="switch-btn" :class="{ active: pageState.adminForm.strongPassword }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'loginAlertEnabled')">
@@ -177,356 +195,154 @@
<strong>异常登录提醒</strong>
<small>检测到高风险登录时向安全通知邮箱发送告警</small>
</span>
<span class="switch" :class="{ active: pageState.adminForm.loginAlertEnabled }"><i></i></span>
<span class="switch-btn" :class="{ active: pageState.adminForm.loginAlertEnabled }"><i></i></span>
</button>
</div>
</section>
</template>
<template v-else-if="activeSection === 'session'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>会话保留策略</h4>
<p>控制智能体会话在系统中的保留时长超过保留期的历史会话会自动清理</p>
</div>
</div>
<div class="form-grid compact-grid">
<label class="field">
<span><em>*</em> 保留会话天数</span>
<div
ref="sessionRetentionPickerRef"
class="session-picker-filter"
:class="{ open: sessionRetentionPickerOpen }"
>
<button
class="session-picker-trigger"
type="button"
:aria-expanded="sessionRetentionPickerOpen"
aria-haspopup="dialog"
@click="toggleSessionRetentionPicker"
>
<span class="session-picker-label">{{ pageState.sessionForm.conversationRetentionDays }} </span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="sessionRetentionPickerOpen"
class="session-picker-popover"
role="dialog"
aria-label="选择会话保留天数"
>
<header>
<strong>选择会话保留天数</strong>
<button type="button" aria-label="关闭会话保留天数选择" @click="closeSessionRetentionPicker">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="session-picker-option-list">
<button
v-for="option in sessionRetentionOptions"
:key="option.value"
type="button"
class="session-picker-option"
:class="{ active: pageState.sessionForm.conversationRetentionDays === option.value }"
@click="selectSessionRetentionDays(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<small>最小 1 最大 10 按会话最后活跃时间计算</small>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'llm'">
<div class="model-grid">
<section class="settings-card">
<div class="card-head">
<div>
<h4>主模型配置</h4>
<p>用于 AI 助手和主业务链路的默认模型接入</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('main')" @click="testModelConnection('main')">
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
</template>
<div class="form-grid">
<div class="field field-full">
<small class="secret-bound-state">
<i class="mdi mdi-source-branch"></i>
<span>保存后会同步写入 Hermes 配置外部 Hermes agent 也可通过后端共享接口读取这里的主模型配置</span>
</small>
</div>
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.mainProvider" @change="applyProviderPreset('main')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.mainApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('main')"
:placeholder="pageState.llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.mainApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
<i :class="getModelTestState('main').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('main').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('main').message }}</span>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>备份模型配置</h4>
<p>主模型不可用时用于兜底切换的备用模型接入</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('backup')" @click="testModelConnection('backup')">
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.backupProvider" @change="applyProviderPreset('backup')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.backupApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('backup')"
:placeholder="pageState.llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.backupApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
<i :class="getModelTestState('backup').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('backup').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('backup').message }}</span>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>Embedding 模型配置</h4>
<p>用于向量检索知识库召回和语义匹配的嵌入模型设置</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('embedding')" @click="testModelConnection('embedding')">
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.embeddingProvider" @change="applyProviderPreset('embedding')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.embeddingApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('embedding').message"
class="test-feedback"
:class="`is-${getModelTestState('embedding').status}`"
>
<i :class="getModelTestState('embedding').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('embedding').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('embedding').message }}</span>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>Reranker 模型配置</h4>
<p>用于检索结果重排和语义精排的 Reranker 模型设置</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('reranker')" @click="testModelConnection('reranker')">
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.rerankerProvider" @change="applyProviderPreset('reranker')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.rerankerApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('reranker')"
:placeholder="pageState.llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('reranker').message"
class="test-feedback"
:class="`is-${getModelTestState('reranker').status}`"
>
<i :class="getModelTestState('reranker').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('reranker').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('reranker').message }}</span>
</div>
</section>
</div>
</template>
<template v-else-if="activeSection === 'rendering'">
<section class="settings-card rendering-settings-card">
<div class="card-head">
<div>
<h4>ONLYOFFICE 服务配置</h4>
</div>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleBoolean('renderForm', 'enabled')">
<span class="switch-copy">
<strong>启用 ONLYOFFICE 文件渲染</strong>
<small>启用后知识库中的 Office 文件将优先走 ONLYOFFICE 在线预览</small>
</span>
<span class="switch" :class="{ active: pageState.renderForm.enabled }"><i></i></span>
</button>
</div>
<div class="form-grid">
<label class="field field-full">
<span><em>*</em> ONLYOFFICE 服务地址</span>
<input
v-model="pageState.renderForm.publicUrl"
type="text"
placeholder="例如 http://10.10.10.122:8082"
/>
</label>
<label class="field field-full">
<span><em>*</em> JWT 密钥</span>
<input
v-model="pageState.renderForm.jwtSecret"
type="password"
autocomplete="off"
@focus="clearRenderSecretMask"
:placeholder="pageState.renderForm.jwtSecretConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.renderForm.jwtSecretConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载预览签名会使用已保存密钥</span>
</small>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'logs'">
<section class="settings-card">
<template v-else-if="activeSection === 'session'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>日志级别与留存</h4>
<p>定义系统记录粒度归档周期和告警接收人方便后续审计与排障</p>
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-clock-time-three-outline"></i>
</div>
<div>
<h4>会话保留策略</h4>
<p>控制智能体会话在系统中的保留时长超过保留期的历史会话会自动清理</p>
</div>
</div>
</div>
<div class="form-grid compact-grid">
<label class="field">
<span><em>*</em> 保留会话天数</span>
<div
ref="sessionRetentionPickerRef"
class="session-picker-filter"
:class="{ open: sessionRetentionPickerOpen }"
>
<button
class="session-picker-trigger"
type="button"
:aria-expanded="sessionRetentionPickerOpen"
aria-haspopup="dialog"
@click="toggleSessionRetentionPicker"
>
<span class="session-picker-label">{{ pageState.sessionForm.conversationRetentionDays }} </span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="sessionRetentionPickerOpen"
class="session-picker-popover"
role="dialog"
aria-label="选择会话保留天数"
>
<header>
<strong>选择会话保留天数</strong>
<button type="button" aria-label="关闭会话保留天数选择" @click="closeSessionRetentionPicker">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="session-picker-option-list">
<button
v-for="option in sessionRetentionOptions"
:key="option.value"
type="button"
class="session-picker-option"
:class="{ active: pageState.sessionForm.conversationRetentionDays === option.value }"
@click="selectSessionRetentionDays(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<small>最小 1 最大 10 按会话最后活跃时间计算</small>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'hermes'">
<HermesEmployeeSettingsPanel
:hermes-form="pageState.hermesForm"
@toggle-master="toggleHermesMaster"
@toggle-flag="toggleHermesFlag"
@toggle-task="toggleHermesTask"
@update-task-time="updateHermesTaskTime"
/>
</template>
<template v-else-if="activeSection === 'llm'">
<LlmSettingsPanel :llm-form="pageState.llmForm" :provider-options="providerOptions" />
</template>
<template v-else-if="activeSection === 'rendering'">
<section class="settings-card rendering-settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-file-document-edit-outline"></i>
</div>
<div>
<h4>ONLYOFFICE 服务配置</h4>
</div>
</div>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleBoolean('renderForm', 'enabled')">
<span class="switch-copy">
<strong>启用 ONLYOFFICE 文件渲染</strong>
<small>启用后知识库中的 Office 文件将优先走 ONLYOFFICE 在线预览</small>
</span>
<span class="switch-btn" :class="{ active: pageState.renderForm.enabled }"><i></i></span>
</button>
</div>
<div class="form-grid">
<label class="field field-full">
<span><em>*</em> ONLYOFFICE 服务地址</span>
<input
v-model="pageState.renderForm.publicUrl"
type="text"
placeholder="例如 http://10.10.10.122:8082"
/>
</label>
<label class="field field-full">
<span><em>*</em> JWT 密钥</span>
<input
v-model="pageState.renderForm.jwtSecret"
type="password"
autocomplete="off"
@focus="clearRenderSecretMask"
:placeholder="pageState.renderForm.jwtSecretConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.renderForm.jwtSecretConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载预览签名会使用已保存密钥</span>
</small>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'logs'">
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-text-box-search-outline"></i>
</div>
<div>
<h4>日志级别与留存</h4>
<p>定义系统记录粒度归档周期和告警接收人方便后续审计与排障</p>
</div>
</div>
</div>
@@ -572,9 +388,14 @@
<section class="settings-card">
<div class="card-head">
<div>
<h4>审计策略</h4>
<p>决定是否记录关键操作登录行为以及是否对敏感字段进行脱敏处理</p>
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-eye-check-outline"></i>
</div>
<div>
<h4>审计策略</h4>
<p>决定是否记录关键操作登录行为以及是否对敏感字段进行脱敏处理</p>
</div>
</div>
</div>
@@ -584,7 +405,7 @@
<strong>记录关键操作日志</strong>
<small>保存配置修改审批动作和账户管理等重要事件</small>
</span>
<span class="switch" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
<span class="switch-btn" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'loginAudit')">
@@ -592,7 +413,7 @@
<strong>记录登录审计</strong>
<small>追踪登录来源登录结果和异常登录行为</small>
</span>
<span class="switch" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
<span class="switch-btn" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'maskSensitive')">
@@ -600,106 +421,14 @@
<strong>敏感字段脱敏</strong>
<small>日志写入时自动隐藏密码密钥与认证令牌</small>
</span>
<span class="switch" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
<span class="switch-btn" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
</button>
</div>
</section>
</template>
<template v-else>
<section class="settings-card">
<div class="card-head">
<div>
<h4>SMTP 基础配置</h4>
<p>维护系统发信地址认证账号和加密方式用于审批提醒与系统通知</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> SMTP Host</span>
<input v-model="pageState.mailForm.smtpHost" type="text" placeholder="请输入 SMTP Host" />
</label>
<label class="field">
<span><em>*</em> 端口</span>
<input v-model.number="pageState.mailForm.port" type="number" min="1" max="65535" />
</label>
<label class="field">
<span>加密方式</span>
<select v-model="pageState.mailForm.encryption">
<option value="SSL/TLS">SSL/TLS</option>
<option value="STARTTLS">STARTTLS</option>
<option value="None"></option>
</select>
</label>
<label class="field">
<span>发件人名称</span>
<input v-model="pageState.mailForm.senderName" type="text" placeholder="请输入发件人名称" />
</label>
<label class="field">
<span><em>*</em> 发件人邮箱</span>
<input v-model="pageState.mailForm.senderAddress" type="email" placeholder="请输入发件人邮箱" />
</label>
<label class="field">
<span><em>*</em> 登录账号</span>
<input v-model="pageState.mailForm.username" type="text" placeholder="请输入 SMTP 登录账号" />
</label>
<label class="field field-full">
<span>SMTP 密码</span>
<input
v-model="pageState.mailForm.password"
type="password"
autocomplete="off"
:placeholder="pageState.mailForm.passwordConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
</label>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>通知策略</h4>
<p>控制是否启用邮件通知日报摘要以及默认接收邮箱</p>
</div>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleBoolean('mailForm', 'alertEnabled')">
<span class="switch-copy">
<strong>启用系统通知</strong>
<small>审批异常告警和系统事件可通过邮件触达用户</small>
</span>
<span class="switch" :class="{ active: pageState.mailForm.alertEnabled }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('mailForm', 'digestEnabled')">
<span class="switch-copy">
<strong>启用日报摘要</strong>
<small>按固定时间发送系统运行与待办摘要</small>
</span>
<span class="switch" :class="{ active: pageState.mailForm.digestEnabled }"><i></i></span>
</button>
</div>
<div class="form-grid compact-grid">
<label class="field">
<span>摘要发送时间</span>
<input v-model="pageState.mailForm.digestTime" type="time" />
</label>
<label class="field">
<span>默认接收邮箱</span>
<input v-model="pageState.mailForm.defaultReceiver" type="email" placeholder="请输入默认接收邮箱" />
</label>
</div>
</section>
<template v-else-if="activeSection === 'mail'">
<MailSettingsPanel :mail-form="pageState.mailForm" />
</template>
</div>
</div>
@@ -709,4 +438,5 @@
<script src="./scripts/SettingsView.js"></script>
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
<style scoped src="../assets/styles/views/settings-view.css"></style>

View File

@@ -0,0 +1,59 @@
import { computed } from 'vue'
import { HERMES_SIMPLE_TASKS } from '../../utils/hermesEmployeeSettingsModel.js'
export default {
name: 'HermesEmployeeSettingsPanel',
props: {
hermesForm: {
type: Object,
required: true
}
},
emits: ['toggle-master', 'toggle-flag', 'toggle-task', 'update-task-time'],
setup(props) {
const TASK_METADATA = {
knowledgeAggregation: { icon: 'mdi-sync', color: 'indigo' },
ruleReviewDigest: { icon: 'mdi-bell-ring-outline', color: 'warning' },
riskSummary: { icon: 'mdi-shield-search', color: 'danger' },
archiveDigest: { icon: 'mdi-archive-outline', color: 'info' },
dailyStats: { icon: 'mdi-chart-line', color: 'success' },
monthlyStats: { icon: 'mdi-chart-bar', color: 'primary' },
yearlyStats: { icon: 'mdi-chart-pie', color: 'secondary' }
}
function getTaskIcon(taskId) {
return TASK_METADATA[taskId]?.icon || 'mdi-cog-outline'
}
function getTaskColorClass(taskId) {
return TASK_METADATA[taskId]?.color || 'default'
}
function isTaskOn(taskId) {
return Boolean(
props.hermesForm?.masterEnabled &&
props.hermesForm?.capabilities?.[taskId] &&
props.hermesForm?.schedules?.[taskId]?.enabled
)
}
function taskTime(taskId) {
return props.hermesForm?.schedules?.[taskId]?.time || '09:00'
}
const activeTasksCount = computed(() => {
return HERMES_SIMPLE_TASKS.filter(task => isTaskOn(task.id)).length
})
return {
HERMES_SIMPLE_TASKS,
isTaskOn,
taskTime,
getTaskIcon,
getTaskColorClass,
activeTasksCount
}
}
}

View File

@@ -0,0 +1,194 @@
import { ref } from 'vue'
import { testModelConnectivity } from '../../services/settings.js'
import { useToast } from '../../composables/useToast.js'
const MODEL_SECRET_MASK = '********'
const MODEL_TEST_CONFIGS = {
main: {
label: '主模型',
providerKey: 'mainProvider',
modelKey: 'mainModel',
endpointKey: 'mainEndpoint',
apiKeyKey: 'mainApiKey',
capability: 'chat'
},
backup: {
label: '备份模型',
providerKey: 'backupProvider',
modelKey: 'backupModel',
endpointKey: 'backupEndpoint',
apiKeyKey: 'backupApiKey',
capability: 'chat'
},
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
},
reranker: {
label: 'Reranker 模型',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
capability: 'reranker'
}
}
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
const PROVIDER_ENDPOINTS = {
MiniMax: 'https://api.minimaxi.com/v1',
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
Kimi: 'https://api.moonshot.ai/v1',
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
Codex: 'https://api.openai.com/v1',
Claude: 'https://api.anthropic.com/v1/',
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
[CUSTOM_OPENAI_PROVIDER]: ''
}
const RERANKER_PROVIDER_ENDPOINTS = {
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
[CUSTOM_OPENAI_PROVIDER]: ''
}
const LEGACY_PROVIDER_MAP = {
'OpenAI Compatible': 'Codex',
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
Ollama: CUSTOM_OPENAI_PROVIDER,
'自定义网关': CUSTOM_OPENAI_PROVIDER
}
function normalizeValue(value) {
return String(value ?? '').trim()
}
function normalizeProviderValue(value, fallback = 'Codex') {
const normalized = normalizeValue(value)
const providerOptions = Object.keys(PROVIDER_ENDPOINTS)
if (providerOptions.includes(normalized)) {
return normalized
}
if (LEGACY_PROVIDER_MAP[normalized]) {
return LEGACY_PROVIDER_MAP[normalized]
}
return fallback
}
function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
function isModelConfigReady(provider, model, endpoint) {
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
}
function isModelSecretMask(value) {
return value === MODEL_SECRET_MASK
}
export default {
name: 'LlmSettingsPanel',
props: {
llmForm: {
type: Object,
required: true
},
providerOptions: {
type: Array,
required: true
}
},
setup(props) {
const { toast } = useToast()
const modelTestState = ref({
main: { status: 'idle', message: '' },
backup: { status: 'idle', message: '' },
embedding: { status: 'idle', message: '' },
reranker: { status: 'idle', message: '' }
})
function applyProviderPreset(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
props.llmForm[config.providerKey] = provider
props.llmForm[config.endpointKey] =
testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
}
function getModelTestState(testKey) {
return modelTestState.value[testKey] || { status: 'idle', message: '' }
}
function isModelTesting(testKey) {
return getModelTestState(testKey).status === 'testing'
}
function clearModelSecretMask(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
if (isModelSecretMask(props.llmForm[config.apiKeyKey])) {
props.llmForm[config.apiKeyKey] = ''
}
}
async function testModelConnection(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const provider = props.llmForm[config.providerKey]
const model = props.llmForm[config.modelKey]
const endpoint = props.llmForm[config.endpointKey]
const apiKey = props.llmForm[config.apiKeyKey]
if (!isModelConfigReady(provider, model, endpoint)) {
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
modelTestState.value[testKey] = { status: 'error', message }
toast(message)
return
}
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
const payload = {
provider,
model,
endpoint,
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
capability: config.capability,
slot: testKey
}
try {
const result = await testModelConnectivity(payload)
modelTestState.value[testKey] = {
status: result.ok ? 'success' : 'error',
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
}
toast(modelTestState.value[testKey].message)
} catch (error) {
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
modelTestState.value[testKey] = { status: 'error', message }
toast(message)
}
}
return {
applyProviderPreset,
getModelTestState,
isModelTesting,
clearModelSecretMask,
testModelConnection
}
}
}

View File

@@ -0,0 +1,20 @@
export default {
name: 'MailSettingsPanel',
props: {
mailForm: {
type: Object,
required: true
}
},
setup(props) {
function toggleField(field) {
if (props.mailForm) {
props.mailForm[field] = !props.mailForm[field]
}
}
return {
toggleField
}
}
}

View File

@@ -1,914 +1,21 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchSettings, saveSettings, testModelConnectivity } from '../../services/settings.js'
import { useToast } from '../../composables/useToast.js'
const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
const CURRENT_YEAR = new Date().getFullYear()
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
const MODEL_SECRET_MASK = '********'
const RENDER_SECRET_MASK = '********'
const SECTION_DEFINITIONS = [
{
id: 'profile',
label: '企业信息',
title: '系统基本信息',
desc: '公司名称、品牌与版权',
longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。',
actionLabel: '保存企业信息'
},
{
id: 'admin',
label: '管理员安全',
title: '管理员账号与安全策略',
desc: '账号、密码与登录安全',
longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。',
actionLabel: '保存安全设置'
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import MailSettingsPanel from '../MailSettingsPanel.vue'
import { useSettings } from '../../composables/useSettings.js'
export default {
name: 'SettingsView',
components: {
HermesEmployeeSettingsPanel,
LlmSettingsPanel,
MailSettingsPanel
},
{
id: 'session',
label: '会话设置',
title: '会话留存设置',
desc: '会话保留天数',
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
actionLabel: '保存会话设置'
},
{
id: 'llm',
label: '大语言模型',
title: '模型接入配置',
desc: '主模型、备份模型与检索模型',
longDesc: '集中维护主模型、备份模型、Embedding 模型和 Reranker 模型的接入参数,供 AI 助手和检索链路调用。',
actionLabel: '保存模型配置'
},
{
id: 'rendering',
label: '文件渲染',
title: '文件渲染',
desc: '文档预览服务与访问密钥',
longDesc: '维护文件渲染开关、文档服务对外地址和 JWT 密钥,后端回调地址继续由部署配置管理。',
actionLabel: '保存文件渲染配置'
},
{
id: 'logs',
label: '日志策略',
title: '日志与审计策略',
desc: '日志级别、留存与脱敏',
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
actionLabel: '保存日志策略'
},
{
id: 'mail',
label: '邮箱设置',
title: '邮箱通知配置',
desc: 'SMTP 与通知投递策略',
longDesc: '维护系统邮件发送配置和通知投递策略,审批、告警和摘要邮件都会依赖这里的设置。',
actionLabel: '保存邮箱配置'
}
]
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR']
const PROVIDER_OPTIONS = [
'MiniMax',
'GLM',
'Kimi',
'Ali',
'Codex',
'Claude',
'Gemini',
CUSTOM_OPENAI_PROVIDER
]
const PROVIDER_ENDPOINTS = {
MiniMax: 'https://api.minimaxi.com/v1',
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
Kimi: 'https://api.moonshot.ai/v1',
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
Codex: 'https://api.openai.com/v1',
Claude: 'https://api.anthropic.com/v1/',
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
[CUSTOM_OPENAI_PROVIDER]: ''
}
const RERANKER_PROVIDER_ENDPOINTS = {
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
[CUSTOM_OPENAI_PROVIDER]: ''
}
const LEGACY_PROVIDER_MAP = {
'OpenAI Compatible': 'Codex',
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
Ollama: CUSTOM_OPENAI_PROVIDER,
'自定义网关': CUSTOM_OPENAI_PROVIDER
}
const MODEL_TEST_CONFIGS = {
main: {
label: '主模型',
providerKey: 'mainProvider',
modelKey: 'mainModel',
endpointKey: 'mainEndpoint',
apiKeyKey: 'mainApiKey',
capability: 'chat'
},
backup: {
label: '备份模型',
providerKey: 'backupProvider',
modelKey: 'backupModel',
endpointKey: 'backupEndpoint',
apiKeyKey: 'backupApiKey',
capability: 'chat'
},
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
},
reranker: {
label: 'Reranker 模型',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
capability: 'reranker'
}
}
const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
value: index + 1,
label: `${index + 1}`
}))
function normalizeValue(value) {
return String(value ?? '').trim()
}
function normalizeProviderValue(value, fallback = 'Codex') {
const normalized = normalizeValue(value)
if (PROVIDER_OPTIONS.includes(normalized)) {
return normalized
}
if (LEGACY_PROVIDER_MAP[normalized]) {
return LEGACY_PROVIDER_MAP[normalized]
}
return fallback
}
function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
function buildDefaultState(companyProfile, currentUser) {
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
const adminEmail =
normalizeValue(companyProfile?.adminEmail) ||
normalizeValue(currentUser?.email) ||
'admin@example.com'
const adminAccount = normalizeValue(currentUser?.username) || 'superadmin'
return {
companyForm: {
companyName,
displayName: companyName,
companyCode,
recordNumber: '',
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
},
adminForm: {
adminAccount,
adminEmail,
newPassword: '',
confirmPassword: '',
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
noticeEmail: adminEmail,
mfaEnabled: true,
strongPassword: true,
loginAlertEnabled: true,
adminPasswordConfigured: false
},
sessionForm: {
conversationRetentionDays: 3
},
llmForm: {
mainProvider: 'Codex',
mainModel: 'codex-mini-latest',
mainEndpoint: getProviderEndpoint('Codex'),
mainApiKey: '',
mainApiKeyConfigured: false,
backupProvider: 'GLM',
backupModel: 'glm-5.1',
backupEndpoint: getProviderEndpoint('GLM'),
backupApiKey: '',
backupApiKeyConfigured: false,
embeddingProvider: 'GLM',
embeddingModel: 'Embedding-3',
embeddingEndpoint: getProviderEndpoint('GLM'),
embeddingApiKey: '',
embeddingApiKeyConfigured: false,
rerankerProvider: 'Ali',
rerankerModel: 'gte-rerank-v2',
rerankerEndpoint: getRerankerEndpoint('Ali'),
rerankerApiKey: '',
rerankerApiKeyConfigured: false
},
renderForm: {
enabled: false,
publicUrl: '',
jwtSecret: '',
jwtSecretConfigured: false
},
logForm: {
level: 'INFO',
retentionDays: 180,
archiveCycle: 'weekly',
logPath: 'server/logs/app.log',
alertEmail: adminEmail,
operationAudit: true,
loginAudit: true,
maskSensitive: true
},
mailForm: {
smtpHost: 'smtp.exmail.qq.com',
port: 465,
encryption: 'SSL/TLS',
senderName: companyName,
senderAddress: adminEmail,
username: adminEmail,
password: '',
passwordConfigured: false,
alertEnabled: true,
digestEnabled: false,
digestTime: '09:00',
defaultReceiver: adminEmail
}
}
}
function readStoredSettings() {
if (typeof window === 'undefined') {
return null
}
const raw = window.sessionStorage.getItem(SETTINGS_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw)
} catch {
return null
}
}
function mergeState(baseState, overrideState) {
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
mergedLlmForm.embeddingProvider = normalizeProviderValue(
mergedLlmForm.embeddingProvider,
baseState.llmForm.embeddingProvider
)
mergedLlmForm.rerankerProvider = normalizeProviderValue(
mergedLlmForm.rerankerProvider,
baseState.llmForm.rerankerProvider
)
return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
llmForm: mergedLlmForm,
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) }
}
}
function sanitizeForStorage(state) {
return {
companyForm: { ...state.companyForm },
adminForm: {
...state.adminForm,
newPassword: '',
confirmPassword: ''
},
sessionForm: { ...state.sessionForm },
llmForm: {
...state.llmForm,
mainApiKey: '',
backupApiKey: '',
embeddingApiKey: '',
rerankerApiKey: ''
},
renderForm: {
...state.renderForm,
jwtSecret: ''
},
logForm: { ...state.logForm },
mailForm: {
...state.mailForm,
password: ''
}
}
}
function getModelConfiguredKey(apiKeyKey) {
return `${apiKeyKey}Configured`
}
function isModelSecretMask(value) {
return value === MODEL_SECRET_MASK
}
function maskConfiguredModelSecrets(state) {
for (const config of MODEL_API_KEY_CONFIGS) {
const configuredKey = getModelConfiguredKey(config.apiKeyKey)
if (state.llmForm[configuredKey] && !normalizeValue(state.llmForm[config.apiKeyKey])) {
state.llmForm[config.apiKeyKey] = MODEL_SECRET_MASK
}
}
return state
}
function buildLlmPayload(llmForm) {
const payload = { ...llmForm }
for (const config of MODEL_API_KEY_CONFIGS) {
if (isModelSecretMask(payload[config.apiKeyKey])) {
payload[config.apiKeyKey] = ''
}
}
return payload
}
function isRenderSecretMask(value) {
return value === RENDER_SECRET_MASK
}
function maskConfiguredRenderSecret(state) {
if (state.renderForm.jwtSecretConfigured && !normalizeValue(state.renderForm.jwtSecret)) {
state.renderForm.jwtSecret = RENDER_SECRET_MASK
}
return state
}
function buildRenderPayload(renderForm) {
const payload = { ...renderForm }
if (isRenderSecretMask(payload.jwtSecret)) {
payload.jwtSecret = ''
}
return payload
}
function persistSettings(state) {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(sanitizeForStorage(state)))
}
function isModelConfigReady(provider, model, endpoint) {
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
}
function computeSectionStatus(state) {
return {
profile: Boolean(
normalizeValue(state.companyForm.companyName) &&
normalizeValue(state.companyForm.displayName) &&
normalizeValue(state.companyForm.copyright)
),
admin: Boolean(
normalizeValue(state.adminForm.adminAccount) &&
normalizeValue(state.adminForm.adminEmail) &&
Number(state.adminForm.sessionTimeout) >= 5
),
session: Boolean(
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
Number(state.sessionForm.conversationRetentionDays) <= 10
),
llm: Boolean(
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
isModelConfigReady(
state.llmForm.embeddingProvider,
state.llmForm.embeddingModel,
state.llmForm.embeddingEndpoint
) &&
isModelConfigReady(
state.llmForm.rerankerProvider,
state.llmForm.rerankerModel,
state.llmForm.rerankerEndpoint
)
),
rendering: Boolean(
!state.renderForm.enabled ||
(normalizeValue(state.renderForm.publicUrl) &&
(normalizeValue(state.renderForm.jwtSecret) || state.renderForm.jwtSecretConfigured))
),
logs: Boolean(
normalizeValue(state.logForm.level) &&
Number(state.logForm.retentionDays) > 0 &&
normalizeValue(state.logForm.logPath)
),
mail: Boolean(
normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 &&
normalizeValue(state.mailForm.senderAddress) &&
normalizeValue(state.mailForm.username)
)
}
}
export default {
name: 'SettingsView',
setup() {
const { toast } = useToast()
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
const activeSection = ref('profile')
const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null)
const modelTestState = ref({
main: { status: 'idle', message: '' },
backup: { status: 'idle', message: '' },
embedding: { status: 'idle', message: '' },
reranker: { status: 'idle', message: '' }
})
const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS
const providerOptions = PROVIDER_OPTIONS
const sessionRetentionOptions = SESSION_RETENTION_OPTIONS
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
const activeSectionConfig = computed(
() => sections.find((section) => section.id === activeSection.value) || sections[0]
)
function updateBrandPreviewFromState(state) {
updateCompanyProfilePreview({
name: normalizeValue(state.companyForm.displayName),
code: normalizeValue(state.companyForm.companyCode),
adminEmail: normalizeValue(state.adminForm.adminEmail)
})
}
function applyLoadedSnapshot(snapshot, options = {}) {
const {
mergeDraft = false,
preserveModelApiKeys = false,
preserveAdminPasswords = false,
preserveRenderSecret = false,
preserveMailPassword = false
} = options
const currentState = pageState.value
let nextState = mergeState(buildResolvedDefaults(), snapshot)
if (mergeDraft) {
nextState = mergeState(nextState, readStoredSettings())
}
if (preserveModelApiKeys) {
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
}
if (preserveAdminPasswords) {
nextState.adminForm.newPassword = currentState.adminForm.newPassword
nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword
}
if (preserveRenderSecret) {
nextState.renderForm.jwtSecret = currentState.renderForm.jwtSecret
}
if (preserveMailPassword) {
nextState.mailForm.password = currentState.mailForm.password
}
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
}
async function loadSettingsSnapshot() {
try {
const snapshot = await fetchSettings()
applyLoadedSnapshot(snapshot, { mergeDraft: true })
} catch (error) {
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
toast(error.message || '无法加载已保存设置,继续使用当前会话草稿。')
}
}
function buildSettingsPayload() {
return {
companyForm: { ...pageState.value.companyForm },
adminForm: { ...pageState.value.adminForm },
sessionForm: { ...pageState.value.sessionForm },
llmForm: buildLlmPayload(pageState.value.llmForm),
renderForm: buildRenderPayload(pageState.value.renderForm),
logForm: { ...pageState.value.logForm },
mailForm: { ...pageState.value.mailForm }
}
}
async function persistRemoteSettings(successMessage, options = {}) {
try {
const snapshot = await saveSettings(buildSettingsPayload())
applyLoadedSnapshot(snapshot, options)
toast(successMessage)
return true
} catch (error) {
toast(error.message || '设置保存失败,请稍后重试。')
return false
}
}
function activateSection(sectionId) {
sessionRetentionPickerOpen.value = false
activeSection.value = sectionId
}
function toggleBoolean(formKey, field) {
pageState.value[formKey][field] = !pageState.value[formKey][field]
}
function toggleSessionRetentionPicker() {
sessionRetentionPickerOpen.value = !sessionRetentionPickerOpen.value
}
function closeSessionRetentionPicker() {
sessionRetentionPickerOpen.value = false
}
function selectSessionRetentionDays(value) {
pageState.value.sessionForm.conversationRetentionDays = Number(value)
closeSessionRetentionPicker()
}
function handleDocumentPointerDown(event) {
if (!sessionRetentionPickerOpen.value) {
return
}
const target = event.target
if (sessionRetentionPickerRef.value?.contains(target)) {
return
}
closeSessionRetentionPicker()
}
function applyProviderPreset(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const llmForm = pageState.value.llmForm
const provider = normalizeProviderValue(llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
llmForm[config.providerKey] = provider
llmForm[config.endpointKey] =
slot === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
}
function getModelTestState(testKey) {
return modelTestState.value[testKey] || { status: 'idle', message: '' }
}
function isModelTesting(testKey) {
return getModelTestState(testKey).status === 'testing'
}
function buildModelTestPayload(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const llmForm = pageState.value.llmForm
const apiKey = llmForm[config.apiKeyKey]
return {
provider: llmForm[config.providerKey],
model: llmForm[config.modelKey],
endpoint: llmForm[config.endpointKey],
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
capability: config.capability,
slot: testKey
}
}
function clearModelSecretMask(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
if (isModelSecretMask(pageState.value.llmForm[config.apiKeyKey])) {
pageState.value.llmForm[config.apiKeyKey] = ''
}
}
function clearRenderSecretMask() {
if (isRenderSecretMask(pageState.value.renderForm.jwtSecret)) {
pageState.value.renderForm.jwtSecret = ''
}
}
async function testModelConnection(testKey) {
const config = MODEL_TEST_CONFIGS[testKey]
const payload = buildModelTestPayload(testKey)
if (!isModelConfigReady(payload.provider, payload.model, payload.endpoint)) {
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
modelTestState.value[testKey] = { status: 'error', message }
toast(message)
return
}
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
try {
const result = await testModelConnectivity(payload)
modelTestState.value[testKey] = {
status: result.ok ? 'success' : 'error',
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
}
toast(modelTestState.value[testKey].message)
} catch (error) {
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
modelTestState.value[testKey] = { status: 'error', message }
toast(message)
}
}
async function saveProfileSection() {
const companyForm = pageState.value.companyForm
if (!normalizeValue(companyForm.companyName)) {
toast('请输入企业名称。')
return
}
if (!normalizeValue(companyForm.displayName)) {
toast('请输入系统显示名称。')
return
}
if (!normalizeValue(companyForm.copyright)) {
toast('请输入版权信息。')
return
}
pageState.value.mailForm.senderName = normalizeValue(companyForm.displayName)
await persistRemoteSettings('企业信息已保存并应用到当前系统。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveAdminSection() {
const adminForm = pageState.value.adminForm
if (!normalizeValue(adminForm.adminAccount)) {
toast('请输入管理员账号。')
return
}
if (!normalizeValue(adminForm.adminEmail)) {
toast('请输入管理员邮箱。')
return
}
if (Number(adminForm.sessionTimeout) < 5) {
toast('会话超时时间不能少于 5 分钟。')
return
}
if (adminForm.newPassword) {
if (adminForm.newPassword.length < 5) {
toast('管理员密码至少需要 5 位。')
return
}
if (adminForm.newPassword !== adminForm.confirmPassword) {
toast('两次输入的管理员密码不一致。')
return
}
}
await persistRemoteSettings('管理员安全设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: false,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveSessionSection() {
const sessionForm = pageState.value.sessionForm
const retentionDays = Number(sessionForm.conversationRetentionDays)
if (retentionDays < 1 || retentionDays > 10) {
toast('会话保留天数必须在 1 到 10 天之间。')
return
}
await persistRemoteSettings('会话设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveLlmSection() {
const llmForm = pageState.value.llmForm
const modelConfigs = [
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
]
for (const [label, provider, model, endpoint] of modelConfigs) {
if (!isModelConfigReady(provider, model, endpoint)) {
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
return
}
}
await persistRemoteSettings('模型配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveRenderingSection() {
const renderForm = pageState.value.renderForm
if (renderForm.enabled && !normalizeValue(renderForm.publicUrl)) {
toast('启用 ONLYOFFICE 时请输入服务地址。')
return
}
if (renderForm.enabled && !normalizeValue(renderForm.jwtSecret) && !renderForm.jwtSecretConfigured) {
toast('启用 ONLYOFFICE 时请输入 JWT 密钥。')
return
}
await persistRemoteSettings('文件渲染配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: false,
preserveMailPassword: true
})
}
async function saveLogsSection() {
const logForm = pageState.value.logForm
if (!normalizeValue(logForm.level) || Number(logForm.retentionDays) <= 0) {
toast('请填写有效的日志级别和留存天数。')
return
}
if (!normalizeValue(logForm.logPath)) {
toast('请输入日志路径。')
return
}
await persistRemoteSettings('日志策略已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveMailSection() {
const mailForm = pageState.value.mailForm
if (!normalizeValue(mailForm.smtpHost) || Number(mailForm.port) <= 0) {
toast('请填写有效的 SMTP Host 和端口。')
return
}
if (!normalizeValue(mailForm.senderAddress) || !normalizeValue(mailForm.username)) {
toast('请填写发件人邮箱和 SMTP 登录账号。')
return
}
await persistRemoteSettings('邮箱配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: false
})
}
async function saveActiveSection() {
if (activeSection.value === 'profile') {
await saveProfileSection()
return
}
if (activeSection.value === 'admin') {
await saveAdminSection()
return
}
if (activeSection.value === 'session') {
await saveSessionSection()
return
}
if (activeSection.value === 'llm') {
await saveLlmSection()
return
}
if (activeSection.value === 'logs') {
await saveLogsSection()
return
}
if (activeSection.value === 'rendering') {
await saveRenderingSection()
return
}
await saveMailSection()
}
onMounted(() => {
if (typeof document !== 'undefined') {
document.addEventListener('pointerdown', handleDocumentPointerDown)
}
loadSettingsSnapshot()
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
}
})
setup() {
const settings = useSettings()
return {
activeSection,
activeSectionConfig,
activateSection,
applyProviderPreset,
clearRenderSecretMask,
clearModelSecretMask,
completedSectionCount,
getModelTestState,
isModelTesting,
logLevels,
modelTestState,
pageState,
providerOptions,
sessionRetentionOptions,
sessionRetentionPickerOpen,
sessionRetentionPickerRef,
saveActiveSection,
sectionStatus,
sections,
selectSessionRetentionDays,
testModelConnection,
toggleSessionRetentionPicker,
closeSessionRetentionPicker,
toggleBoolean
...settings
}
}
}

View File

@@ -93,8 +93,11 @@ import {
buildReviewStateLabel,
buildReviewStateTone,
buildReviewPlainFollowupCopy,
buildReviewNextStepRichCopy,
buildReviewRiskLevelCounts,
resolveReviewFooterActions,
resolveReviewSaveDraftAction,
resolveReviewNextStepAction,
buildReviewPrimaryButtonLabel,
buildReviewIntentText,
buildReviewSceneValue,
@@ -129,6 +132,7 @@ import {
buildOcrSummaryFromDocuments,
buildReviewFilePreviewsFromReviewPayload,
extractReviewAttachmentNames,
isTemporaryPreviewUrl,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
@@ -169,6 +173,11 @@ const REVIEW_RISK_LEVEL_META = {
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
},
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
@@ -184,6 +193,9 @@ const REVIEW_DRAWER_MODE_FLOW = 'flow'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed'
@@ -326,15 +338,6 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
modelKey: 'scene_label',
placeholder: '请选择场景'
},
{
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
},
{
key: 'attachments',
label: '票据状态',
@@ -346,8 +349,20 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
}
]
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
cards.splice(cards.length - 1, 0, {
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
cards.splice(4, 0, {
cards.splice(cards.length - 1, 0, {
key: 'location',
label: '业务地点',
value: String(inlineState.location || '').trim() || '待补充',
@@ -432,18 +447,19 @@ function buildReviewRiskConversationText(item, detailTarget = {}) {
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const isInfo = String(item?.level || '').trim() === 'info'
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
lines.push('', `风险点${summary}`)
lines.push('', `${isInfo ? '提示内容' : '风险点'}${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `修改建议${suggestion}`)
lines.push('', `${isInfo ? '处理建议' : '修改建议'}${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
@@ -539,6 +555,11 @@ export default {
getSessionRuntimeRefs: () => sessionRuntimeRefs
})
const deleteSessionDialogOpen = ref(false)
const nextStepConfirmDialog = ref({
open: false,
message: null,
action: null
})
const reviewActionBusy = ref(false)
const deleteSessionBusy = ref(false)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
@@ -839,6 +860,7 @@ export default {
extractReviewAttachmentNames,
mergeFilesWithLimit,
mergeFilePreviews,
isTemporaryPreviewUrl,
resolveAttachmentPreviewKind,
resolveDocumentPreview,
buildFilePreviews,
@@ -1418,9 +1440,10 @@ export default {
nextTick(scrollToBottom)
}
function resolveReviewRiskDetailTarget() {
function resolveReviewDetailTarget(message = null) {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
message?.draftPayload,
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
@@ -1443,6 +1466,74 @@ export default {
}
}
function resolveReviewRiskDetailTarget() {
return resolveReviewDetailTarget()
}
function buildReviewNextStepRichCopyForMessage(message) {
const target = resolveReviewDetailTarget(message)
return buildReviewNextStepRichCopy(message?.reviewPayload, {
detailHref: target.href || ''
})
}
function buildMessageBubbleClass(message) {
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
return ''
}
const counts = buildReviewRiskLevelCounts(message.reviewPayload)
if (counts.high > 0) {
return 'message-bubble-review-risk-high'
}
if (counts.medium > 0) {
return 'message-bubble-review-risk-medium'
}
if (counts.low > 0) {
return 'message-bubble-review-risk-low'
}
return ''
}
function openReviewNextStepConfirm(message) {
const action = resolveReviewNextStepAction(message?.reviewPayload)
if (!action) {
return
}
nextStepConfirmDialog.value = {
open: true,
message,
action
}
}
function closeReviewNextStepConfirm() {
if (reviewActionBusy.value) {
return
}
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
async function confirmReviewNextStepSubmit() {
const message = nextStepConfirmDialog.value.message
const action = nextStepConfirmDialog.value.action
if (!message || !action || reviewActionBusy.value) {
return
}
try {
await handleReviewActionInternal(message, action)
} finally {
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
@@ -1665,6 +1756,31 @@ export default {
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href === REVIEW_NEXT_STEP_HREF) {
event.preventDefault()
openReviewNextStepConfirm(message)
return
}
if (href.startsWith(REVIEW_RISK_PANEL_HREF_PREFIX)) {
event.preventDefault()
if (reviewRiskDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
} else {
toast('当前没有需要额外处理的风险信息。')
}
return
}
if (href === REVIEW_QUICK_EDIT_HREF) {
event.preventDefault()
if (reviewOverviewDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
toast('已打开右侧核对信息,可以直接修改当前单据。')
}
return
}
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
@@ -1738,12 +1854,12 @@ export default {
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -68,13 +68,12 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'flight_ticket', label: '机票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'entertainment', label: '业务招待费' },
{ value: 'office', label: '办公费' },
{ value: 'office', label: '办公用品费' },
{ value: 'meeting', label: '会务费' },
{ value: 'training', label: '培训费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'travel_allowance', label: '出差补贴' },
{ value: 'other', label: '其他费用' }
]

View File

@@ -39,6 +39,26 @@ export const MAX_OCR_DOCUMENTS = 10
export const VISIBLE_ATTACHMENT_CHIPS = 2
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = [] } = {}) {
const names = (Array.isArray(fileNames) ? fileNames : [])
.map((item) => String(item || '').trim())
.filter(Boolean)
const attachmentLine = names.length
? `本次待归集附件:${names.length} 份(${names.join('、')}`
: '本次待归集附件:待识别'
return [
'当前这笔报销信息还没有保存为草稿。',
'',
'如果继续上传票据,我需要先把当前已识别的信息保存成一张草稿单据,再识别并归集本次附件。',
'',
attachmentLine,
'',
'',
`如果 **[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})**,我会先保存这笔未保存单据,再把此次上传的附件归集到该单据。`
].join('\n').trim()
}
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
@@ -333,6 +353,10 @@ export function resolveDocumentPreview(filePreviews, filename) {
)
}
export function isTemporaryPreviewUrl(url) {
return String(url || '').trim().toLowerCase().startsWith('blob:')
}
export function buildFileIdentity(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
@@ -374,18 +398,39 @@ export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_AT
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
const result = []
const seen = new Set()
const indexByKey = new Map()
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
const key = [preview?.filename, preview?.kind].join('__')
if (!preview?.filename || seen.has(key)) continue
seen.add(key)
result.push(preview)
if (!preview?.filename) continue
const existingIndex = indexByKey.get(key)
if (existingIndex === undefined) {
indexByKey.set(key, result.length)
result.push(preview)
continue
}
const existingPreview = result[existingIndex]
const nextUrl = String(preview?.url || '').trim()
const existingUrl = String(existingPreview?.url || '').trim()
if (nextUrl && (!existingUrl || isTemporaryPreviewUrl(existingUrl) || nextUrl !== existingUrl)) {
result[existingIndex] = preview
}
}
return result
}
export function filterPersistableFilePreviews(filePreviews) {
return (Array.isArray(filePreviews) ? filePreviews : [])
.filter((preview) => {
const filename = String(preview?.filename || '').trim()
const url = String(preview?.url || '').trim()
return filename && !isTemporaryPreviewUrl(url)
})
}
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''

View File

@@ -65,6 +65,12 @@ export const FLOW_STEP_FALLBACKS = {
runningText: '正在把已确认信息保存为草稿...',
completedText: '草稿已保存'
},
'attachment-association': {
title: '票据关联草稿',
tool: 'database.expense_claims.save_or_submit',
runningText: '正在把本次票据关联到已保存草稿...',
completedText: '票据已归集到草稿'
},
'expense-scene-selection': {
title: '报销场景确认',
tool: 'UserConfirmation',

View File

@@ -20,7 +20,7 @@ const EXPENSE_RISK_LEVEL_LABELS = {
medium: '中风险',
warning: '中风险',
low: '低风险',
info: '低风险'
info: '提示'
}
export function normalizeExpenseQueryRiskItem(item, index = 0) {

View File

@@ -4,6 +4,7 @@ export const DOCUMENT_TYPE_LABELS = {
travel_ticket: '行程单/机票/车票',
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
ship_ticket: '轮船票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
@@ -21,10 +22,10 @@ export const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '伙食费',
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
office: '办公费',
office: '办公用品费',
training: '培训费',
communication: '通讯费',
welfare: '福利费',
@@ -95,7 +96,6 @@ export const REVIEW_FALLBACK_GROUP_CODES = [
'hotel',
'meal',
'meeting',
'entertainment',
'office',
'training',
'communication',
@@ -106,14 +106,13 @@ export const REVIEW_CATEGORY_PRESET_OPTIONS = [
{ key: 'travel', label: '差旅费' },
{ key: 'transport', label: '交通费' },
{ key: 'hotel', label: '住宿费' },
{ key: 'meal', label: '费' },
{ key: 'entertainment', label: '业务招待费' },
{ key: 'meal', label: '业务招待费' },
{ key: 'office', label: '办公用品费' },
{ key: 'other_trigger', label: '其他类型', is_other: true }
]
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
{ key: 'meeting', label: '会务费' },
{ key: 'office', label: '办公费' },
{ key: 'training', label: '培训费' },
{ key: 'communication', label: '通讯费' },
{ key: 'welfare', label: '福利费' },
@@ -139,7 +138,7 @@ export const CATEGORY_CONFIDENCE_KEYWORDS = {
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
hotel: [/住宿|酒店|宾馆|民宿/],
transport: [TRANSPORT_KEYWORD_PATTERN],
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meeting: [/会务|会议|论坛|展会|参会|会场/],
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],

View File

@@ -156,7 +156,7 @@ export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyIn
buildReviewSlotMap(reviewPayload).expense_type?.value ||
''
)
if (['travel', 'hotel', 'transport'].includes(expenseType)) {
if (['travel', 'hotel'].includes(expenseType)) {
return true
}
@@ -164,8 +164,8 @@ export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyIn
const documentType = String(item?.document_type || '').trim().toLowerCase()
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
return (
['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) ||
['travel', 'hotel', 'transport'].includes(suggestedType)
['flight_itinerary', 'train_ticket', 'hotel_invoice'].includes(documentType) ||
['travel', 'hotel'].includes(suggestedType)
)
})
}
@@ -735,6 +735,7 @@ export function buildLocallySyncedReviewPayload(reviewPayload, inlineState = cre
can_proceed: canProceed,
missing_slots: allMissingSlots,
slot_cards: nextSlotCards,
edit_fields: mergeInlineReviewFields(reviewPayload.edit_fields || [], inlineState),
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
}
}
@@ -1359,12 +1360,58 @@ export function resolveReviewSaveDraftAction(reviewPayload) {
export function resolveReviewFooterActions(reviewPayload) {
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
const actionType = String(item?.action_type || '').trim()
return ['next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
return ['link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
})
}
export function buildReviewRiskLevelCounts(reviewPayload) {
return (Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []).reduce(
(counts, item) => {
const level = normalizeReviewRiskLevel(item?.level)
if (level === 'high' || level === 'medium' || level === 'low') {
counts[level] += 1
}
return counts
},
{ low: 0, medium: 0, high: 0 }
)
}
export function resolveReviewNextStepAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => String(item?.action_type || '').trim() === 'next_step'
) || null
)
}
export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } = {}) {
const nextStepAction = resolveReviewNextStepAction(reviewPayload)
if (!nextStepAction) {
return ''
}
const counts = buildReviewRiskLevelCounts(reviewPayload)
const riskSummary = `现存在 ${counts.low} 条低风险,${counts.medium} 条中风险,${counts.high} 条高风险,具体情况请看 [右侧](#review-risk-panel) 风险信息提示窗。`
const lines = [`系统识别您的单据已经填写完所有已知信息,${riskSummary}`]
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
const editHref = String(detailHref || '').trim() || '#review-quick-edit'
lines.push(
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
)
}
return lines.join('\n\n')
}
export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
const action = resolveReviewPrimaryAction(reviewPayload)
if (!action) return '确认'
@@ -1444,7 +1491,8 @@ export function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
if (normalized === 'info' || normalized === 'notice') return 'info'
if (normalized === 'low') return 'low'
if (normalized === 'high') return normalized
return 'low'
}

View File

@@ -6,17 +6,20 @@ export const EXPENSE_TYPE_OPTIONS = [
{ value: 'ferry_ticket', label: '轮船票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'entertainment', label: '业务招待费' },
{ value: 'office', label: '办公费' },
{ value: 'office', label: '办公用品费' },
{ value: 'meeting', label: '会务费' },
{ value: 'training', label: '培训费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'travel_allowance', label: '出差补贴' },
{ value: 'other', label: '其他费用' }
]
const LEGACY_EXPENSE_TYPE_LABELS = {
entertainment: '业务招待费'
}
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
@@ -47,7 +50,10 @@ export function normalizeExpenseType(value) {
}
export function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
const normalized = normalizeExpenseType(value)
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|| LEGACY_EXPENSE_TYPE_LABELS[normalized]
|| '其他费用'
}
export function isSystemGeneratedExpenseItemSource(source) {

View File

@@ -7,6 +7,7 @@ import {
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
ship_ticket: '轮船票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',

View File

@@ -33,6 +33,7 @@ export function useTravelReimbursementAttachments({
extractReviewAttachmentNames,
mergeFilesWithLimit,
mergeFilePreviews,
isTemporaryPreviewUrl,
resolveAttachmentPreviewKind,
resolveDocumentPreview,
buildFilePreviews,
@@ -117,7 +118,12 @@ export function useTravelReimbursementAttachments({
}
const filename = String(metadata?.file_name || '').trim()
if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) {
const existingPreview = resolveDocumentPreview(reviewFilePreviews.value, filename)
if (
!metadata?.previewable ||
!filename ||
(existingPreview?.url && !isTemporaryPreviewUrl(existingPreview.url))
) {
continue
}

View File

@@ -236,6 +236,7 @@ export function useTravelReimbursementFlow({
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
? explicitStartedAt
: Date.now()
flowFinishedAt.value = 0
upsertFlowStep(key, {
...normalizedPatch,
status: FLOW_STEP_STATUS_RUNNING,
@@ -446,7 +447,7 @@ export function useTravelReimbursementFlow({
detail: '正在把已确认信息保存为草稿...'
},
link_to_existing_draft: {
key: 'expense-claim-draft',
key: 'attachment-association',
title: '票据关联草稿',
tool: 'database.expense_claims.save_or_submit',
detail: '正在把本次票据关联到现有草稿...'
@@ -504,7 +505,7 @@ export function useTravelReimbursementFlow({
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
}
if (responseMessage.includes('关联')) {
return { key: 'expense-claim-draft', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
if (responseMessage.includes('新建')) {
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }

View File

@@ -59,7 +59,8 @@ export function useTravelReimbursementReviewDrawer({
open: false,
filename: '',
kind: 'file',
url: ''
url: '',
renderKey: ''
})
const activeReviewFilePreviews = computed(() => reviewFilePreviews.value)
@@ -364,7 +365,12 @@ export function useTravelReimbursementReviewDrawer({
open: true,
filename: activeReviewDocument.value.filename,
kind: activeReviewDocumentPreview.value.kind,
url: activeReviewDocumentPreview.value.url
url: activeReviewDocumentPreview.value.url,
renderKey: [
activeReviewDocument.value.filename,
activeReviewDocumentPreview.value.kind,
Date.now()
].join('__')
}
}

View File

@@ -6,7 +6,10 @@ import {
readAssistantSessionSnapshot,
writeAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildReviewFilePreviewsFromMessages } from './travelReimbursementAttachmentModel.js'
import {
buildReviewFilePreviewsFromMessages,
filterPersistableFilePreviews
} from './travelReimbursementAttachmentModel.js'
import {
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
@@ -106,7 +109,7 @@ export function useTravelReimbursementSessionState({
currentInsight:
state.currentInsight
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
composerDraft: String(state.composerDraft || ''),
attachedFiles: [],
composerFilesExpanded: false,
@@ -164,7 +167,7 @@ export function useTravelReimbursementSessionState({
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: state.currentInsight || null,
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
composerDraft: String(state.composerDraft || ''),
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)

View File

@@ -1,6 +1,7 @@
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage
buildAttachmentAssociationConfirmationMessage,
buildUnsavedDraftAttachmentConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
export function useTravelReimbursementSubmitComposer(ctx) {
@@ -103,10 +104,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
function buildConfirmedAssociationText(message) {
return String(message?.text || '').replace(
`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`,
'已确认'
)
return String(message?.text || '')
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
.replace(`[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确定')
}
function resolveReviewPanelScope({
@@ -159,6 +159,55 @@ export function useTravelReimbursementSubmitComposer(ctx) {
message.meta = ['已确认归集']
persistSessionState()
if (pending.mode === 'save_then_associate') {
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
const savePayload = await submitComposer({
rawText: '请先把当前已识别的报销信息保存为草稿,随后继续归集本次上传的附件。',
userText: '',
files: [],
skipUserMessage: true,
pendingText: '正在先保存未保存单据...',
systemGenerated: true,
extraContext: {
...runtime.extraContext,
...inheritedReviewContext,
review_action: 'save_draft'
}
})
const savedClaimId = String(savePayload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
const savedClaimNo = String(savePayload?.result?.draft_payload?.claim_no || '').trim()
if (!savedClaimId) {
toast('当前单据还没有保存成功,请稍后重试。')
return savePayload
}
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${savedClaimNo || '当前草稿'}`,
userText: `保存草稿并归集 ${runtime.fileNames.length} 份票据`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
skipUserMessage: true,
appendToCurrentFlow: true,
systemGenerated: true,
pendingText: savedClaimNo
? `草稿 ${savedClaimNo} 已保存,正在识别并归集附件...`
: '草稿已保存,正在识别并归集附件...',
associationConfirmed: true,
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: savedClaimId,
selected_claim_id: savedClaimId,
selected_claim_no: savedClaimNo,
attachment_association_confirmed: true
}
})
}
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
@@ -231,6 +280,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files
@@ -308,6 +358,47 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value &&
files.length &&
activeReviewPayload.value &&
!String(draftClaimId.value || '').trim() &&
!detailScopedClaimId &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
)
if (hasUnsavedReviewDraft) {
const associationId = createPendingAttachmentAssociationId()
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
filePreviews: buildComposerFilePreviews(files),
extraContext
})
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
buildUnsavedDraftAttachmentConfirmationMessage({ fileNames }),
[],
{
meta: ['等待确认保存并归集'],
pendingAttachmentAssociation: {
id: associationId,
mode: 'save_then_associate',
status: 'pending',
fileNames
}
}
))
nextTick(scrollToBottom)
persistSessionState()
return null
}
if (
!isKnowledgeSession.value &&
files.length &&
@@ -363,7 +454,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
resetFlowRun()
if (!appendToCurrentFlow) {
resetFlowRun()
} else {
clearFlowSimulationTimers()
}
if (rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) {

View File

@@ -7,7 +7,10 @@ import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage,
buildOcrFilePreviews,
buildReviewFilePreviewsFromReviewPayload
buildReviewFilePreviewsFromReviewPayload,
buildUnsavedDraftAttachmentConfirmationMessage,
filterPersistableFilePreviews,
mergeFilePreviews
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
import {
buildDraftAssociationQueryPayload,
@@ -99,6 +102,14 @@ test('attachment upload association uses conversation selection instead of legac
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const flowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const conversationSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
)
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
@@ -112,6 +123,26 @@ test('attachment upload association uses conversation selection instead of legac
submitComposerSource,
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
)
assert.match(submitComposerSource, /mode:\s*'save_then_associate'/)
assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/)
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
})
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {
const message = buildUnsavedDraftAttachmentConfirmationMessage({
fileNames: ['taxi.pdf']
})
const rendered = renderMarkdown(message)
assert.match(message, /当前这笔报销信息还没有保存为草稿/)
assert.match(message, /本次待归集附件1 份/)
assert.match(message, new RegExp(`\\*\\*\\[确定\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\*`))
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确定<\/a><\/strong>/)
})
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
@@ -137,6 +168,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
})
test('file preview cache replaces temporary object urls and never persists them', () => {
const merged = mergeFilePreviews(
[
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/old-preview' },
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
],
[
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/new-preview' },
{ filename: 'hotel.png', kind: 'image' }
]
)
assert.equal(merged.length, 2)
assert.equal(merged[0].url, 'blob:http://localhost/new-preview')
assert.equal(merged[1].url, 'data:image/png;base64,stable')
assert.deepEqual(filterPersistableFilePreviews(merged), [
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
])
})
test('draft association query keeps a single candidate selectable in the conversation', () => {
const payload = buildDraftAssociationQueryPayload([
{
@@ -182,6 +233,30 @@ test('expense query payload keeps structured risk items for claim-level risk dri
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
})
test('expense query info items render as prompts instead of low risk', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
records: [
{
claim_id: 'claim-info',
claim_no: 'EXP-202605-010',
amount: 59.1,
risk_flags: [
{
key: 'normal-tip',
level: 'info',
title: '票据提示',
summary: '票据已识别,当前没有异常。'
}
]
}
]
})
assert.equal(payload.records[0].riskItems[0].levelLabel, '提示')
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
})
test('expense query hint guides users to the reimbursement center after the top five results', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',

View File

@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import {
buildKnowledgeIngestLogModel,
isKnowledgeIngestRun
} from '../src/utils/knowledgeIngestLogModel.js'
function buildRun() {
return {
status: 'running',
route_json: {
job_type: 'knowledge_index_sync',
folder: '制度文件',
phase: 'indexing',
progress: {
total_documents: 2,
completed_documents: 1,
failed_documents: 0,
percent: 55
},
knowledge_ingest: {
status: 'running',
phase: 'indexing',
current_document_id: 'doc-2',
graph: {
chunk_count: 5,
entity_count: 3,
relation_count: 2,
entities: ['远光软件', '支出管理'],
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
},
documents: [
{
document_id: 'doc-1',
name: '公司支出管理办法.pdf',
folder: '制度文件',
extension: 'pdf',
status: 'succeeded',
phase: 'indexed',
chunk_count: 3,
entity_count: 2,
relation_count: 1,
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
},
{
document_id: 'doc-2',
name: '费用审批台账.xlsx',
folder: '制度文件',
extension: 'xlsx',
status: 'running',
phase: 'indexing',
chunk_count: 2,
entity_count: 1,
relation_count: 1
}
]
}
}
}
}
function testDetectsKnowledgeIngestRun() {
assert.equal(isKnowledgeIngestRun(buildRun()), true)
assert.equal(isKnowledgeIngestRun({ route_json: { job_type: 'daily_check' } }), false)
}
function testBuildsInteractiveModel() {
const model = buildKnowledgeIngestLogModel(buildRun())
assert.equal(model.available, true)
assert.equal(model.folder, '制度文件')
assert.equal(model.selectedDocumentId, 'doc-2')
assert.equal(model.documents.length, 2)
assert.equal(model.documents[0].statusLabel, '已完成')
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
assert.equal(model.graph.entityCount, 3)
assert.equal(model.graph.relations[0].source, '远光软件')
assert.equal(model.metrics[1].value, '5')
}
function testFallsBackToToolCallDocuments() {
const model = buildKnowledgeIngestLogModel({
status: 'succeeded',
route_json: {
job_type: 'knowledge_index_sync',
requested_document_ids: ['doc-1']
},
tool_calls: [
{
response_json: {
documents: [
{
document_id: 'doc-1',
name: '归集结果.docx',
status: 'succeeded',
chunk_count: 1
}
]
}
}
]
})
assert.equal(model.documents[0].name, '归集结果.docx')
assert.equal(model.graph.chunkCount, 1)
}
function run() {
testDetectsKnowledgeIngestRun()
testBuildsInteractiveModel()
testFallsBackToToolCallDocuments()
console.log('knowledge ingest log model tests passed')
}
run()

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import {
resolveAppViewFromRoute,
resolveTargetRouteName
} from '../src/composables/useNavigation.js'
function testDerivesViewFromRouteName() {
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
}
function testFallsBackToValidMeta() {
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'employees' } }), 'employees')
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'unknown' } }), 'overview')
}
function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
}
function run() {
testDerivesViewFromRouteName()
testFallsBackToValidMeta()
testResolvesMainRouteNames()
console.log('navigation route resolution tests passed')
}
run()

View File

@@ -24,6 +24,19 @@ test('local flow intent preview names transport expense for riding fare text', (
(item) => item.includes('交通出行') && item.includes('交通费')
)
)
assert.ok(
buildLocalExtractionProgressMessages(ridingFareMessage).some(
(item) => item.includes('正在判断待补项') && !item.includes('客户名称') && !item.includes('参与人员')
)
)
})
test('local flow recognizes broader reimbursement scene keywords', () => {
assert.equal(inferLocalFlowCandidates('报销会议场地费').expenseType, '会务费')
assert.equal(inferLocalFlowCandidates('报销打印纸和硒鼓').expenseType, '办公用品费')
assert.equal(inferLocalFlowCandidates('报销培训课程费').expenseType, '培训费')
assert.equal(inferLocalFlowCandidates('报销手机话费和流量费').expenseType, '通讯费')
assert.equal(inferLocalFlowCandidates('报销员工体检费').expenseType, '福利费')
})
test('semantic intent detail includes recognized expense type', () => {

View File

@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { buildReviewPlainFollowupCopy } from '../src/views/scripts/travelReimbursementReviewModel.js'
import {
buildLocallySyncedReviewPayload,
buildReviewNextStepRichCopy,
buildReviewPlainFollowupCopy,
isTravelReviewPayload,
resolveReviewFooterActions
} from '../src/views/scripts/travelReimbursementReviewModel.js'
import { renderMarkdown } from '../src/utils/markdown.js'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
@@ -33,6 +40,26 @@ const attachmentsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
'utf8'
)
const sessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const createViewBaseStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view.css', import.meta.url)),
'utf8'
)
const createViewPart2Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part2.css', import.meta.url)),
'utf8'
)
const createViewPart3Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part3.css', import.meta.url)),
'utf8'
)
const createViewPart4Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
'utf8'
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
@@ -58,6 +85,157 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
})
test('document review drawer fills sidebar height and preview dialog is centered', () => {
assert.match(createViewTemplate, /class="insight-body"[\s\S]*:class="\{ 'document-review-body': isReviewDocumentDrawer \}"/)
assert.match(createViewBaseStyles, /\.insight-panel-shell\s*\{[\s\S]*display:\s*flex;[\s\S]*min-height:\s*0;/)
assert.match(createViewPart2Styles, /\.insight-body\.document-review-body\s*\{[\s\S]*display:\s*flex;[\s\S]*overflow:\s*hidden;/)
assert.match(createViewPart2Styles, /\.review-ticket-drawer\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);[\s\S]*height:\s*100%;[\s\S]*overflow:\s*hidden;/)
assert.match(createViewPart2Styles, /\.review-document-stage\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/)
assert.match(createViewPart2Styles, /\.review-document-scroll\s*\{[\s\S]*max-height:\s*none;[\s\S]*min-height:\s*0;/)
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image\s*\{[\s\S]*place-items:\s*center;[\s\S]*min-height:\s*220px;/)
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*height:\s*auto;[\s\S]*object-fit:\s*contain;/)
assert.doesNotMatch(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*object-fit:\s*cover;/)
assert.match(createViewPart4Styles, /\.review-overlay\s*\{[\s\S]*align-items:\s*center;[\s\S]*justify-content:\s*center;/)
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
})
test('document preview avoids restored stale object urls', () => {
assert.match(createViewTemplate, /v-if="documentPreviewDialog\.kind === 'image'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(createViewTemplate, /v-else-if="documentPreviewDialog\.kind === 'pdf'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(reviewDrawerScript, /renderKey:\s*''/)
assert.match(reviewDrawerScript, /renderKey:\s*\[[\s\S]*Date\.now\(\)[\s\S]*\]\.join\('__'\)/)
assert.match(attachmentsScript, /isTemporaryPreviewUrl/)
assert.match(attachmentsScript, /existingPreview\?\.url && !isTemporaryPreviewUrl\(existingPreview\.url\)/)
assert.match(sessionStateScript, /filterPersistableFilePreviews\(state\.reviewFilePreviews\)/)
assert.doesNotMatch(sessionStateScript, /filterPersistableFilePreviews\(nextState\.reviewFilePreviews\)/)
})
test('local transport review no longer uses the travel hotel template', () => {
const reviewPayload = {
slot_cards: [
{
key: 'expense_type',
label: '报销类型',
value: '交通费',
normalized_value: 'transport',
status: 'identified'
}
],
document_cards: [
{
document_type: 'taxi_receipt',
suggested_expense_type: 'transport',
scene_label: '交通费'
}
]
}
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
assert.doesNotMatch(
createViewScript,
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
)
assert.match(createViewTemplate, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
assert.doesNotMatch(createViewTemplate, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
})
test('local save of changed reimbursement category updates edit fields too', () => {
const nextPayload = buildLocallySyncedReviewPayload(
{
can_proceed: false,
edit_fields: [
{ key: 'expense_type', label: '报销分类', value: '交通费' },
{ key: 'reason', label: '事由', value: '打车去客户现场' }
],
slot_cards: [
{
key: 'expense_type',
label: '报销类型',
value: '交通费',
normalized_value: 'transport',
required: true,
status: 'identified'
}
],
confirmation_actions: []
},
{
expense_type: '办公用品费',
reason_value: '右侧核对后改为办公用品费'
}
)
const expenseTypeField = nextPayload.edit_fields.find((item) => item.key === 'expense_type')
assert.equal(expenseTypeField.value, '办公用品费')
assert.equal(nextPayload.slot_cards[0].value, '办公用品费')
})
test('next step action uses rich text guidance and confirm dialog instead of footer button', () => {
const reviewPayload = {
can_proceed: true,
risk_briefs: [
{ level: 'low', title: '票据提示', content: '普通提示' }
],
confirmation_actions: [
{ action_type: 'save_draft', label: '保存为草稿' },
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
]
}
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
const rendered = renderMarkdown(copy)
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
assert.match(copy, /现存在 1 条低风险0 条中风险0 条高风险/)
assert.doesNotMatch(copy, /#review-risk-low/)
assert.doesNotMatch(copy, /#review-risk-medium/)
assert.doesNotMatch(copy, /#review-risk-high/)
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
assert.doesNotMatch(rendered, /markdown-risk-link-/)
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-high">高风险<\/span>/)
assert.doesNotMatch(rendered, /href="#review-risk-low"/)
assert.doesNotMatch(rendered, /href="#review-risk-medium"/)
assert.doesNotMatch(rendered, /href="#review-risk-high"/)
assert.match(rendered, /markdown-action-link-risk/)
assert.match(rendered, /markdown-action-link-next/)
assert.deepEqual(resolveReviewFooterActions(reviewPayload), [])
const highRiskCopy = buildReviewNextStepRichCopy(
{
...reviewPayload,
risk_briefs: [{ level: 'high', title: '金额超标' }]
},
{ detailHref: '/app/requests/claim-1' }
)
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(createViewTemplate, /class="review-next-step-rich-copy message-answer-markdown"[\s\S]*renderMarkdown\(buildReviewNextStepRichCopyForMessage\(message\)\)/)
assert.match(createViewTemplate, /class="message-bubble" :class="buildMessageBubbleClass\(message\)"/)
assert.match(createViewTemplate, /:open="nextStepConfirmDialog\.open"[\s\S]*title="确认提交当前单据?"[\s\S]*confirm-text="确认提交"/)
assert.match(createViewScript, /const REVIEW_NEXT_STEP_HREF = '#review-next-step'/)
assert.match(createViewScript, /buildReviewRiskLevelCounts/)
assert.match(createViewScript, /function buildMessageBubbleClass\(message\)/)
assert.match(createViewScript, /message-bubble-review-risk-high/)
assert.match(createViewScript, /message-bubble-review-risk-medium/)
assert.match(createViewScript, /message-bubble-review-risk-low/)
assert.match(createViewScript, /function openReviewNextStepConfirm\(message\)/)
assert.match(createViewScript, /async function confirmReviewNextStepSubmit\(\)/)
assert.match(createViewScript, /href === REVIEW_NEXT_STEP_HREF[\s\S]*openReviewNextStepConfirm\(message\)/)
assert.match(createViewScript, /href\.startsWith\(REVIEW_RISK_PANEL_HREF_PREFIX\)[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-low\s*\{[\s\S]*border-color:\s*rgba\(37,\s*99,\s*235,/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-medium\s*\{[\s\S]*border-color:\s*rgba\(217,\s*119,\s*6,/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-high\s*\{[\s\S]*border-color:\s*rgba\(220,\s*38,\s*38,/)
assert.doesNotMatch(createViewBaseStyles, /markdown-risk-link-low/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-low[\s\S]*color:\s*#2563eb/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-medium[\s\S]*color:\s*#d97706/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-high[\s\S]*color:\s*#dc2626/)
assert.match(createViewPart3Styles, /\.review-next-step-rich-copy\s*\{[\s\S]*margin-top:\s*30px;/)
})
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
assert.ok(riskItemsBlock, 'risk item builder should be present')
@@ -80,8 +258,12 @@ test('review risk drawer lists risk briefs without score and posts details into
)
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}\$\{summary\}/)
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}\$\{suggestion\}/)
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
@@ -98,7 +280,8 @@ test('review risk drawer lists risk briefs without score and posts details into
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)

View File

@@ -67,15 +67,15 @@ const attachmentMeta = {
severity: 'high',
label: '高风险',
headline: '票据类型不匹配',
summary: '交通票据挂在办公费明细下。',
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
summary: '交通票据挂在办公用品费明细下。',
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公用品费'],
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
}
}
test('attachment insight exposes recognition fields and rule basis', () => {
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
name: '办公费',
name: '办公用品费',
itemType: 'office'
})
@@ -90,7 +90,7 @@ test('AI advice card splits every attachment risk point with basis and suggestio
expenseItems: [
{
id: 'item-1',
name: '办公费',
name: '办公用品费',
invoiceId: 'taxi-invoice.pdf'
}
],
@@ -278,7 +278,7 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
tone: 'high',
label: '高风险',
title: '票据类型不匹配',
risk: '交通票据挂在办公费明细下。',
risk: '交通票据挂在办公用品费明细下。',
ruleBasis: ['附件类型与当前费用项目不匹配。'],
suggestion: '把费用项目调整为交通费。'
}