feat: 增强知识库索引与设置页面模块化拆分
扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优 化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件 和 Hermes 员工同步子面板并重构样式,新增日志详情组件和 知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
@@ -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"
|
||||
|
||||
136
web/src/views/HermesEmployeeSettingsPanel.vue
Normal file
136
web/src/views/HermesEmployeeSettingsPanel.vue
Normal 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>
|
||||
309
web/src/views/LlmSettingsPanel.vue
Normal file
309
web/src/views/LlmSettingsPanel.vue
Normal 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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
163
web/src/views/MailSettingsPanel.vue
Normal file
163
web/src/views/MailSettingsPanel.vue
Normal 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>
|
||||
@@ -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;">建议尺寸 64x64,PNG/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>
|
||||
|
||||
59
web/src/views/scripts/HermesEmployeeSettingsPanel.js
Normal file
59
web/src/views/scripts/HermesEmployeeSettingsPanel.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
194
web/src/views/scripts/LlmSettingsPanel.js
Normal file
194
web/src/views/scripts/LlmSettingsPanel.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
web/src/views/scripts/MailSettingsPanel.js
Normal file
20
web/src/views/scripts/MailSettingsPanel.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '其他费用' }
|
||||
]
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,7 +20,7 @@ const EXPENSE_RISK_LEVEL_LABELS = {
|
||||
medium: '中风险',
|
||||
warning: '中风险',
|
||||
low: '低风险',
|
||||
info: '低风险'
|
||||
info: '提示'
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryRiskItem(item, index = 0) {
|
||||
|
||||
@@ -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: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
ship_ticket: '轮船票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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('__')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user