Files
X-Financial/web/src/views/SettingsView.vue
2026-05-09 09:14:04 +00:00

648 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="settings-page">
<div class="settings-shell panel">
<aside class="settings-nav" aria-label="系统设置分类">
<div class="settings-nav-head">
<span class="nav-kicker">Settings</span>
<h2>系统设置</h2>
<p>已完成 {{ completedSectionCount }} / {{ sections.length }} 项配置敏感字段不会保存在浏览器草稿中</p>
</div>
<nav class="settings-nav-list">
<button
v-for="section in sections"
:key="section.id"
class="settings-nav-item"
:class="{
active: activeSection === section.id
}"
type="button"
@click="activateSection(section.id)"
>
<span class="nav-item-copy">
<strong>{{ section.label }}</strong>
<small>{{ section.desc }}</small>
</span>
</button>
</nav>
</aside>
<div class="settings-body">
<header class="settings-toolbar">
<div class="settings-toolbar-copy">
<span class="settings-breadcrumb">首页 / 系统设置 / {{ activeSectionConfig.label }}</span>
<h3>{{ activeSectionConfig.title }}</h3>
<p>{{ activeSectionConfig.longDesc }}</p>
</div>
<div class="settings-toolbar-actions">
<button class="save-button" type="button" @click="saveActiveSection">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ activeSectionConfig.actionLabel }}</span>
</button>
</div>
</header>
<div class="settings-content">
<template v-if="activeSection === 'profile'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>系统基本信息</h4>
<p>统一维护企业名称系统显示名称和版权信息保存后会同步更新当前系统品牌名称</p>
</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>
</label>
<label class="field">
<span><em>*</em> 企业名称</span>
<input v-model="pageState.companyForm.companyName" type="text" placeholder="请输入企业法定名称" />
</label>
<label class="field">
<span>企业编码</span>
<input v-model="pageState.companyForm.companyCode" type="text" placeholder="例如 XF-001" />
</label>
<label class="field field-wide">
<span><em>*</em> 系统显示名称</span>
<input v-model="pageState.companyForm.displayName" type="text" placeholder="请输入系统对内展示名称" />
</label>
<label class="field">
<span>备案号</span>
<input v-model="pageState.companyForm.recordNumber" type="text" placeholder="请输入备案号" />
</label>
<label class="field field-full">
<span><em>*</em> 版权信息</span>
<input
v-model="pageState.companyForm.copyright"
type="text"
placeholder="例如 Copyright © 2024-2026 X-Financial. All Rights Reserved."
/>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'admin'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>管理员账号</h4>
<p>维护最高权限管理员的登录账户密码和安全通知邮箱</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 管理员账号</span>
<input v-model="pageState.adminForm.adminAccount" type="text" placeholder="请输入管理员账号" />
</label>
<label class="field">
<span><em>*</em> 管理员邮箱</span>
<input v-model="pageState.adminForm.adminEmail" type="email" placeholder="请输入管理员邮箱" />
</label>
<label class="field">
<span>新密码</span>
<input
v-model="pageState.adminForm.newPassword"
type="password"
autocomplete="new-password"
placeholder="至少 5 位"
/>
</label>
<label class="field">
<span>确认密码</span>
<input
v-model="pageState.adminForm.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="再次输入管理员密码"
/>
</label>
</div>
</section>
<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>
<input v-model.number="pageState.adminForm.sessionTimeout" type="number" min="5" max="240" />
</label>
<label class="field">
<span>安全通知邮箱</span>
<input v-model="pageState.adminForm.noticeEmail" type="email" placeholder="用于接收安全提醒" />
</label>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'mfaEnabled')">
<span class="switch-copy">
<strong>开启双因素验证</strong>
<small>要求管理员使用附加验证步骤登录后台</small>
</span>
<span class="switch" :class="{ active: pageState.adminForm.mfaEnabled }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'strongPassword')">
<span class="switch-copy">
<strong>启用强密码策略</strong>
<small>管理员密码修改时需要满足强度要求</small>
</span>
<span class="switch" :class="{ active: pageState.adminForm.strongPassword }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'loginAlertEnabled')">
<span class="switch-copy">
<strong>异常登录提醒</strong>
<small>检测到高风险登录时向安全通知邮箱发送告警</small>
</span>
<span class="switch" :class="{ active: pageState.adminForm.loginAlertEnabled }"><i></i></span>
</button>
</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>
<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>VLM 模型设置</h4>
<p>用于票据图像等多模态识别场景的视觉语言模型配置</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('vlm')" @click="testModelConnection('vlm')">
<i :class="isModelTesting('vlm') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('vlm') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.vlmProvider" @change="applyProviderPreset('vlm')">
<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.vlmModel" type="text" placeholder="请输入 VLM 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.vlmEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.vlmApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('vlm')"
:placeholder="pageState.llmForm.vlmApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.vlmApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('vlm').message" class="test-feedback" :class="`is-${getModelTestState('vlm').status}`">
<i :class="getModelTestState('vlm').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('vlm').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('vlm').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>
</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">
<div class="card-head">
<div>
<h4>日志级别与留存</h4>
<p>定义系统记录粒度归档周期和告警接收人方便后续审计与排障</p>
</div>
</div>
<div class="chip-row">
<button
v-for="level in logLevels"
:key="level"
class="level-chip"
:class="{ active: pageState.logForm.level === level }"
type="button"
@click="pageState.logForm.level = level"
>
{{ level }}
</button>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 留存天数</span>
<input v-model.number="pageState.logForm.retentionDays" type="number" min="7" />
</label>
<label class="field">
<span>归档周期</span>
<select v-model="pageState.logForm.archiveCycle">
<option value="daily">按天归档</option>
<option value="weekly">按周归档</option>
<option value="monthly">按月归档</option>
</select>
</label>
<label class="field field-full">
<span><em>*</em> 日志路径</span>
<input v-model="pageState.logForm.logPath" type="text" placeholder="例如 server/logs/app.log" />
</label>
<label class="field field-full">
<span>告警邮箱</span>
<input v-model="pageState.logForm.alertEmail" type="email" placeholder="用于接收日志异常提醒" />
</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('logForm', 'operationAudit')">
<span class="switch-copy">
<strong>记录关键操作日志</strong>
<small>保存配置修改审批动作和账户管理等重要事件</small>
</span>
<span class="switch" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'loginAudit')">
<span class="switch-copy">
<strong>记录登录审计</strong>
<small>追踪登录来源登录结果和异常登录行为</small>
</span>
<span class="switch" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'maskSensitive')">
<span class="switch-copy">
<strong>敏感字段脱敏</strong>
<small>日志写入时自动隐藏密码密钥与认证令牌</small>
</span>
<span class="switch" :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>
</div>
</div>
</div>
</section>
</template>
<script src="./scripts/SettingsView.js"></script>
<style scoped src="../assets/styles/views/settings-view.css"></style>