refactor(settings): 将设置页面重构为模态框形式并优化样式

重构设置功能为模态框形式,移除原设置页面路由及相关导航链接
新增 SettingsModal 组件集中管理设置功能,优化移动端适配
调整 ModelProvidersComponent 样式,移除冗余按钮和样式
统一用户管理组件样式,优化颜色和布局
This commit is contained in:
Wenjie Zhang 2025-12-19 14:41:23 +08:00
parent 69cd29803c
commit 9ca01763ec
11 changed files with 730 additions and 527 deletions

View File

@ -343,9 +343,12 @@ const onGoingConvMessages = computed(() => {
: [];
});
const historyConversations = computed(() => {
return MessageProcessor.convertServerHistoryToMessages(currentThreadMessages.value);
});
const conversations = computed(() => {
const historyConvs = MessageProcessor.convertServerHistoryToMessages(currentThreadMessages.value);
const threadState = currentThreadState.value;
const historyConvs = historyConversations.value;
// 线
if (onGoingConvMessages.value.length > 0) {
@ -369,7 +372,6 @@ const isMediumContainer = computed(() => localUIState.containerWidth <= 768);
// ==================== SCROLL & RESIZE HANDLING ====================
const chatContainerRef = ref(null);
const messageInputRef = ref(null);
const scrollController = new ScrollController('.chat');
let resizeObserver = null;

View File

@ -0,0 +1,349 @@
<template>
<div class="basic-settings-section">
<h3 class="section-title">检索配置</h3>
<div class="section">
<div class="card card-select">
<span class="label">{{ items?.default_model?.des || '默认对话模型' }}</span>
<ModelSelectorComponent
@select-model="handleChatModelSelect"
:model_spec="configStore.config?.default_model"
placeholder="请选择默认模型"
/>
</div>
<div class="card card-select">
<span class="label">{{ items?.fast_model.des }}</span>
<ModelSelectorComponent
@select-model="handleFastModelSelect"
:model_spec="configStore.config?.fast_model"
placeholder="请选择模型"
/>
</div>
<div class="card card-select">
<span class="label">{{ items?.embed_model.des }}</span>
<EmbeddingModelSelector
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
style="width: 320px"
/>
</div>
<div class="card card-select">
<span class="label">{{ items?.reranker.des }}</span>
<a-select style="width: 320px"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
>
<a-select-option
v-for="(name, idx) in rerankerChoices" :key="idx"
:value="name">{{ name }}
</a-select-option>
</a-select>
</div>
</div>
<h3 class="section-title">内容审查配置</h3>
<div class="section">
<div class="card">
<span class="label">{{ items?.enable_content_guard.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
</div>
<div class="card" v-if="configStore.config?.enable_content_guard">
<span class="label">{{ items?.enable_content_guard_llm.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard_llm"
@change="handleChange('enable_content_guard_llm', $event)"
/>
</div>
<div class="card card-select" v-if="configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm">
<span class="label">{{ items?.content_guard_llm_model.des }}</span>
<ModelSelectorComponent
@select-model="handleContentGuardModelSelect"
:model_spec="configStore.config?.content_guard_llm_model"
placeholder="请选择模型"
/>
</div>
</div>
<!-- 服务链接部分 -->
<div v-if="userStore.isAdmin">
<h3 class="section-title">服务链接</h3>
<p class="service-description">快速访问系统相关的外部服务需要将 localhost 替换为实际的 IP 地址</p>
<div class="services-grid">
<div class="service-link-card">
<div class="service-info">
<h4>Neo4j 浏览器</h4>
<p>图数据库管理界面</p>
</div>
<a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>API 接口文档</h4>
<p>系统接口文档和调试工具</p>
</div>
<a-button type="default" @click="openLink('http://localhost:5050/docs')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>MinIO 对象存储</h4>
<p>文件存储管理控制台</p>
</div>
<a-button type="default" @click="openLink('http://localhost:9001')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>Milvus WebUI</h4>
<p>向量数据库管理界面</p>
</div>
<a-button type="default" @click="openLink('http://localhost:9091/webui/')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h } from 'vue'
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { GlobalOutlined } from '@ant-design/icons-vue'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
const configStore = useConfigStore()
const userStore = useUserStore()
const items = computed(() => configStore.config._config_items)
const rerankerChoices = computed(() => {
return Object.keys(configStore?.config?.reranker_names || {}) || []
})
const preHandleChange = (key, e) => {
return true
}
const handleChange = (key, e) => {
if (!preHandleChange(key, e)) {
return
}
configStore.setConfigValue(key, e)
}
const handleChanges = (items) => {
for (const key in items) {
if (!preHandleChange(key, items[key])) {
return
}
}
configStore.setConfigValues(items)
}
const handleChatModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('default_model', spec)
}
}
const handleFastModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('fast_model', spec)
}
}
const handleContentGuardModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('content_guard_llm_model', spec)
}
}
const openLink = (url) => {
window.open(url, '_blank')
}
</script>
<style lang="less" scoped>
.basic-settings-section {
.settings-content {
max-width: 100%;
}
.section-title {
color: var(--gray-900);
font-size: 16px;
font-weight: 600;
margin: 24px 0 12px 0;
padding-bottom: 8px;
}
.service-description {
color: var(--gray-600);
font-size: 14px;
margin: 0 0 16px 0;
line-height: 1.5;
}
.section {
margin-top: 10px;
background-color: var(--gray-0);
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--gray-150);
}
.card {
display: flex;
align-items: center;
justify-content: space-between;
.label {
margin-right: 20px;
font-weight: 500;
color: var(--gray-800);
flex-shrink: 0;
min-width: 140px;
}
&.card-select {
align-items: flex-start;
gap: 12px;
.label {
margin-right: 0;
margin-top: 6px;
}
}
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
margin-top: 16px;
}
.service-link-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
transition: all 0.2s;
min-height: 70px;
&:hover {
box-shadow: 0 1px 8px var(--gray-200);
border-color: var(--main-200);
}
.service-info {
flex: 1;
margin-right: 16px;
h4 {
margin: 0 0 4px 0;
color: var(--gray-900);
font-size: 15px;
font-weight: 600;
}
p {
margin: 0;
color: var(--gray-600);
font-size: 13px;
line-height: 1.4;
}
}
}
}
//
@media (max-width: 768px) {
.basic-settings-section {
.section {
padding: 12px;
}
.card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 16px 0;
.label {
margin-right: 0;
margin-bottom: 8px;
}
&.card-select {
.label {
margin-top: 0;
}
}
}
.services-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.service-link-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
min-height: auto;
padding: 12px;
.service-info {
margin-right: 0;
margin-bottom: 8px;
}
}
}
}
//
@media (max-width: 480px) {
.basic-settings-section {
.section-title {
font-size: 15px;
}
.card {
.label {
font-size: 14px;
min-width: 120px;
}
}
.service-link-card {
.service-info {
h4 {
font-size: 14px;
}
p {
font-size: 12px;
}
}
}
}
}
</style>

View File

@ -108,23 +108,12 @@
<div class="provider-actions">
<a-button
type="text"
size="small"
class="expand-button"
@click.stop="openProviderConfig(item)"
title="配置模型"
>
<SettingOutlined />
</a-button>
<a-button
type="text"
size="small"
class="expand-button"
@click.stop="toggleExpand(item)"
>
<span class="icon-wrapper" :class="{'rotated': expandedModels[item]}">
<DownCircleOutlined />
</span>
</a-button>
</div>
</div>
<div class="card-body-wrapper" :class="{'expanded': expandedModels[item]}">
@ -784,20 +773,6 @@ const testCustomProvider = async (providerId, modelName) => {
.custom-providers-section {
margin-bottom: 24px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
}
.section-description {
margin: 0 0 16px 0;
color: var(--gray-600);
@ -850,13 +825,7 @@ const testCustomProvider = async (providerId, modelName) => {
.provider-actions {
display: flex;
gap: 8px;
.ant-btn {
display: flex;
align-items: center;
gap: 4px;
}
gap: 0px;
}
}
@ -898,19 +867,23 @@ const testCustomProvider = async (providerId, modelName) => {
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
}
.builtin-providers-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
.providers-stats {
display: flex;
@ -923,13 +896,13 @@ const testCustomProvider = async (providerId, modelName) => {
font-size: 12px;
&.available {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
background: var(--color-success-50);
color: var(--color-success-700);
}
&.unavailable {
background: rgba(249, 115, 22, 0.1);
color: #ea580c;
background: var(--color-warning-50);
color: var(--color-warning-700);
}
}
}
@ -1007,13 +980,13 @@ const testCustomProvider = async (providerId, modelName) => {
& > span {
margin-left: 6px;
user-select: all;
background-color: rgba(251, 146, 60, 0.15);
color: #d97706;
background-color: var(--color-warning-50);
color: var(--color-warning-700);
padding: 3px 8px;
border-radius: 6px;
font-weight: 600;
font-size: 11px;
border: 1px solid rgba(251, 146, 60, 0.2);
border: 1px solid var(--color-warning-100);
}
}
}
@ -1027,14 +1000,9 @@ const testCustomProvider = async (providerId, modelName) => {
background: var(--gray-0);
transition: all 0.3s ease;
&:hover {
background: var(--gray-50);
}
.model-title-container {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
h3 {
@ -1056,8 +1024,8 @@ const testCustomProvider = async (providerId, modelName) => {
}
.provider-id {
background: var(--gray-100);
color: var(--gray-600);
background: var(--gray-50);
color: var(--gray-900);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
@ -1095,7 +1063,7 @@ const testCustomProvider = async (providerId, modelName) => {
filter: grayscale(100%);
transition: filter 0.2s ease;
flex-shrink: 0;
background-color: white;
background-color: white; /* 确保图标背景为白色 */
border: 1px solid var(--gray-200);
img {
@ -1161,13 +1129,13 @@ const testCustomProvider = async (providerId, modelName) => {
& > span {
margin-left: 6px;
user-select: all;
background-color: rgba(251, 146, 60, 0.15);
color: #d97706;
background-color: var(--color-warning-50);
color: var(--color-warning-700);
padding: 3px 8px;
border-radius: 6px;
font-weight: 600;
font-size: 11px;
border: 1px solid rgba(251, 146, 60, 0.2);
border: 1px solid var(--color-warning-100);
}
}
@ -1215,7 +1183,6 @@ const testCustomProvider = async (providerId, modelName) => {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
padding: 16px;
background: var(--gray-10);
//
.card-models {

View File

@ -0,0 +1,313 @@
<template>
<a-modal
v-model:open="visible"
title="系统设置"
width="90%"
:style="{ maxWidth: '1200px', minWidth: '320px' }"
:footer="null"
@cancel="handleClose"
class="settings-modal"
:destroyOnClose="true"
:bodyStyle="{ padding: 0 }"
>
<div class="settings-container">
<!-- 侧边栏 (Desktop) -->
<div class="settings-sider" v-if="!isMobile">
<div
class="sider-item"
:class="{ activesec: activeTab === 'base' }"
@click="activeTab = 'base'"
v-if="userStore.isSuperAdmin"
>
<SettingOutlined class="icon" />
<span>基本设置</span>
</div>
<div
class="sider-item"
:class="{ activesec: activeTab === 'model' }"
@click="activeTab = 'model'"
v-if="userStore.isSuperAdmin"
>
<CodeOutlined class="icon" />
<span>模型配置</span>
</div>
<div
class="sider-item"
:class="{ activesec: activeTab === 'user' }"
@click="activeTab = 'user'"
v-if="userStore.isAdmin"
>
<UserOutlined class="icon" />
<span>用户管理</span>
</div>
</div>
<!-- 顶部导航 (Mobile) -->
<div class="settings-mobile-nav" v-else>
<div
class="nav-item"
:class="{ active: activeTab === 'base' }"
@click="activeTab = 'base'"
v-if="userStore.isSuperAdmin"
>
基本设置
</div>
<div
class="nav-item"
:class="{ active: activeTab === 'model' }"
@click="activeTab = 'model'"
v-if="userStore.isSuperAdmin"
>
模型配置
</div>
<div
class="nav-item"
:class="{ active: activeTab === 'user' }"
@click="activeTab = 'user'"
v-if="userStore.isAdmin"
>
用户管理
</div>
</div>
<!-- 内容区域 -->
<div class="settings-content-wrapper">
<div class="settings-content">
<div v-show="activeTab === 'base'" v-if="userStore.isSuperAdmin">
<BasicSettingsSection />
</div>
<div v-show="activeTab === 'model'" v-if="userStore.isSuperAdmin">
<h3>模型配置</h3>
<p>请在 <code>.env</code> 文件中配置对应的 APIKEY并重新启动服务</p>
<a-divider />
<ModelProvidersComponent />
</div>
<div v-show="activeTab === 'user'" v-if="userStore.isAdmin">
<UserManagementComponent />
</div>
</div>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useUserStore } from '@/stores/user'
import {
SettingOutlined,
CodeOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
import UserManagementComponent from '@/components/UserManagementComponent.vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'close'])
const userStore = useUserStore()
const activeTab = ref('base')
const windowWidth = ref(window?.innerWidth || 0)
const isMobile = computed(() => windowWidth.value <= 768)
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const handleClose = () => {
emit('close')
}
const updateWindowWidth = () => {
windowWidth.value = window?.innerWidth || 0
}
onMounted(() => {
window.addEventListener('resize', updateWindowWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateWindowWidth)
})
//
watch(() => props.visible, (newVal) => {
if (newVal) {
if (userStore.isSuperAdmin) {
activeTab.value = 'base'
} else if (userStore.isAdmin) {
activeTab.value = 'user'
}
}
})
</script>
<style lang="less" scoped>
.settings-modal {
:deep(.ant-modal-header) {
padding: 16px 24px;
border-bottom: 1px solid var(--gray-150);
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
}
:deep(.ant-modal-body) {
padding: 0;
height: calc(80vh - 55px); /* Header height approx 55px */
overflow: hidden;
}
:deep(.ant-modal-content) {
border-radius: 8px;
overflow: hidden;
max-height: 80vh;
display: flex;
flex-direction: column;
}
}
.settings-container {
display: flex;
height: 100%;
width: 100%;
}
/* Sidebar Styles - Matching SettingView.vue style */
.settings-sider {
width: 140px;
height: 100%;
// padding: 20px 20px 0 0; /* Matches SettingView .sider */
padding-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
overflow-y: auto;
.sider-item {
width: 100%;
padding: 6px 16px; /* Matches SettingView .sider > * */
cursor: pointer;
transition: all 0.1s; /* Matches SettingView */
text-align: left;
font-size: 15px; /* Matches SettingView */
border-radius: 8px;
color: var(--gray-700);
display: flex;
align-items: center;
gap: 10px;
.icon {
font-size: 14px; /* Slightly adjusted to align better, SettingView uses h() icon defaults */
}
&:hover {
background: var(--gray-50);
}
&.activesec {
background: var(--gray-100);
color: var(--main-700);
}
}
}
/* Content Area */
.settings-content-wrapper {
flex: 1;
height: 100%;
overflow-y: auto;
/* background-color: #fff; SettingView seems to use default background */
.settings-content {
padding: 0 20px; /* Matches SettingView .setting padding */
margin-bottom: 40px; /* Matches SettingView .setting margin-bottom */
h3 {
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
margin-top: 20px;
margin-bottom: 0.5em;
}
/* BasicSettingsSection has its own h3 styles which might conflict slightly but are mostly self-contained */
}
}
/* Mobile Styles */
.settings-mobile-nav {
display: flex;
overflow-x: auto;
border-bottom: 1px solid var(--gray-150);
background: var(--gray-0);
padding: 0 16px;
flex-shrink: 0;
.nav-item {
padding: 12px 16px;
white-space: nowrap;
cursor: pointer;
color: var(--gray-600);
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
&.active {
color: var(--main-color);
border-bottom-color: var(--main-color);
}
}
}
@media (max-width: 768px) {
.settings-container {
flex-direction: column;
}
.settings-content-wrapper {
.settings-content {
padding: 20px;
}
}
}
@media (max-width: 480px) {
.settings-modal {
:deep(.ant-modal) {
margin: 0;
max-width: 100vw;
width: 100vw !important;
height: 100vh;
top: 0;
padding: 0;
}
:deep(.ant-modal-content) {
height: 100vh;
border-radius: 0;
max-height: 100vh;
}
:deep(.ant-modal-body) {
height: calc(100vh - 55px);
}
}
}
</style>

View File

@ -158,7 +158,7 @@
</template>
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
//
@ -173,6 +173,9 @@ const router = useRouter();
const userStore = useUserStore();
const themeStore = useThemeStore();
// Inject settings modal methods
const { openSettingsModal } = inject('settingsModal', {});
//
const profileModalVisible = ref(false);
const avatarUploading = ref(false);
@ -245,7 +248,9 @@ const toggleTheme = () => {
//
const goToSetting = () => {
router.push('/setting');
if (openSettingsModal) {
openSettingsModal()
}
}
//

View File

@ -470,6 +470,7 @@ onMounted(() => {
<style lang="less" scoped>
.user-management {
margin-top: 20px;
min-height: 50vh;
.header-section {
display: flex;
@ -481,7 +482,7 @@ onMounted(() => {
.description {
font-size: 14px;
color: var(--gray-400);
color: var(--gray-600);
margin: 0;
line-height: 1.4;
margin-bottom: 16px;
@ -505,7 +506,7 @@ onMounted(() => {
.user-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
// padding: 16px;
@ -670,7 +671,7 @@ onMounted(() => {
.user-modal {
:deep(.ant-modal-header) {
padding: 20px 24px;
border-bottom: 1px solid var(--gray-800);
border-bottom: 1px solid var(--gray-150);
.ant-modal-title {
font-size: 16px;
@ -705,7 +706,7 @@ onMounted(() => {
}
.help-text {
color: var(--gray-400);
color: var(--gray-600);
font-size: 12px;
margin-top: 4px;
line-height: 1.3;

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, reactive, onMounted, useTemplateRef, computed } from 'vue'
import { ref, reactive, onMounted, useTemplateRef, computed, provide } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import {
GithubOutlined,
@ -15,6 +15,7 @@ import { storeToRefs } from 'pinia'
import UserInfoComponent from '@/components/UserInfoComponent.vue'
import DebugComponent from '@/components/DebugComponent.vue'
import TaskCenterDrawer from '@/components/TaskCenterDrawer.vue'
import SettingsModal from '@/components/SettingsModal.vue'
const configStore = useConfigStore()
const databaseStore = useDatabaseStore()
@ -35,6 +36,14 @@ const isLoadingStars = ref(false)
const showDebugModal = ref(false)
const htmlRefHook = useTemplateRef('htmlRefHook')
// Add state for settings modal
const showSettingsModal = ref(false)
// Provide settings modal methods to child components
const openSettingsModal = () => {
showSettingsModal.value = true
}
// Setup long press for debug modal
onLongPress(
htmlRefHook,
@ -116,6 +125,11 @@ const mainList = [{
activeIcon: BarChart3,
}
]
// Provide settings modal methods to child components
provide('settingsModal', {
openSettingsModal
})
</script>
<template>
@ -195,7 +209,6 @@ const mainList = [{
<div class="header-mobile">
<RouterLink to="/agent" class="nav-item" active-class="active">对话</RouterLink>
<RouterLink to="/database" class="nav-item" active-class="active">知识</RouterLink>
<RouterLink to="/setting" class="nav-item" active-class="active">设置</RouterLink>
</div>
<router-view v-slot="{ Component, route }" id="app-router-view">
<keep-alive v-if="route.meta.keepAlive !== false">
@ -218,6 +231,10 @@ const mainList = [{
<DebugComponent />
</a-modal>
<TaskCenterDrawer />
<SettingsModal
v-model:visible="showSettingsModal"
@close="() => showSettingsModal = false"
/>
</div>
</template>

View File

@ -77,20 +77,7 @@ const router = createRouter({
}
]
},
{
path: '/setting',
name: 'setting',
component: AppLayout,
children: [
{
path: '',
name: 'SettingComp',
component: () => import('../views/SettingView.vue'),
meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true }
}
]
},
{
{
path: '/dashboard',
name: 'dashboard',
component: AppLayout,

View File

@ -3,7 +3,7 @@
<a-empty>
<template #description>
<span>
前往 <router-link to="/setting" style="color: var(--main-color); font-weight: bold;">设置</router-link>
点击右上角用户头像中的"系统设置"来启用知识图
</span>
</template>
</a-empty>

View File

@ -16,10 +16,7 @@
<router-link to="/database" class="nav-link" v-if="userStore.isLoggedIn && userStore.isAdmin">
<span>知识库</span>
</router-link>
<router-link to="/setting" class="nav-link" v-if="userStore.isLoggedIn && userStore.isAdmin">
<span>设置</span>
</router-link>
</nav>
</nav>
<div class="header-actions">
<div class="github-link">
<a href="https://github.com/xerrors/Yuxi-Know" target="_blank">

View File

@ -1,435 +0,0 @@
<template>
<div class="setting-view">
<HeaderComponent title="设置" class="setting-header">
</HeaderComponent>
<div class="setting-container layout-container">
<div class="sider" v-if="state.windowWidth > 520">
<a-button type="text" v-if="userStore.isSuperAdmin" :class="{ activesec: state.section === 'base'}" @click="state.section='base'" :icon="h(SettingOutlined)"> 基本设置 </a-button>
<a-button type="text" v-if="userStore.isSuperAdmin" :class="{ activesec: state.section === 'model'}" @click="state.section='model'" :icon="h(CodeOutlined)"> 模型配置 </a-button>
<a-button type="text" :class="{ activesec: state.section === 'user'}" @click="state.section='user'" :icon="h(UserOutlined)" v-if="userStore.isAdmin"> 用户管理 </a-button>
</div>
<div class="setting" v-if="(state.windowWidth <= 520 || state.section === 'base') && userStore.isSuperAdmin">
<h3>检索配置</h3>
<div class="section">
<div class="card card-select">
<span class="label">{{ items?.default_model?.des || '默认对话模型' }}</span>
<ModelSelectorComponent
@select-model="handleChatModelSelect"
:model_spec="configStore.config?.default_model"
placeholder="请选择默认模型"
/>
</div>
<div class="card card-select">
<span class="label">{{ items?.fast_model.des }}</span>
<ModelSelectorComponent
@select-model="handleFastModelSelect"
:model_spec="configStore.config?.fast_model"
placeholder="请选择模型"
/>
</div>
<div class="card card-select">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="label">{{ items?.embed_model.des }}</span>
<!-- <a-button
size="small"
:loading="state.checkingStatus"
@click="checkAllModelStatus"
:disabled="state.checkingStatus"
>
检查状态
</a-button> -->
</div>
<EmbeddingModelSelector
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
style="width: 320px"
/>
</div>
<div class="card card-select">
<span class="label">{{ items?.reranker.des }}</span>
<a-select style="width: 320px"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
>
<a-select-option
v-for="(name, idx) in rerankerChoices" :key="idx"
:value="name">{{ name }}
</a-select-option>
</a-select>
</div>
</div>
<h3>内容审查配置</h3>
<div class="section">
<!-- 内容审查配置 -->
<div class="card">
<span class="label">{{ items?.enable_content_guard.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
</div>
<div class="card" v-if="configStore.config?.enable_content_guard">
<span class="label">{{ items?.enable_content_guard_llm.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard_llm"
@change="handleChange('enable_content_guard_llm', $event)"
/>
</div>
<div class="card card-select" v-if="configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm">
<span class="label">{{ items?.content_guard_llm_model.des }}</span>
<ModelSelectorComponent
@select-model="handleContentGuardModelSelect"
:model_spec="configStore.config?.content_guard_llm_model"
placeholder="请选择模型"
/>
</div>
</div>
<!-- 服务链接部分 -->
<div v-if="userStore.isAdmin">
<h3>服务链接</h3>
<p>快速访问系统相关的外部服务需要将 localhost 替换为实际的 IP 地址</p>
<div class="services-grid">
<div class="service-link-card">
<div class="service-info">
<h4>Neo4j 浏览器</h4>
<p>图数据库管理界面</p>
</div>
<a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>API 接口文档</h4>
<p>系统接口文档和调试工具</p>
</div>
<a-button type="default" @click="openLink('http://localhost:5050/docs')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>MinIO 对象存储</h4>
<p>文件存储管理控制台</p>
</div>
<a-button type="default" @click="openLink('http://localhost:9001')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>Milvus WebUI</h4>
<p>向量数据库管理界面</p>
</div>
<a-button type="default" @click="openLink('http://localhost:9091/webui/')" :icon="h(GlobalOutlined)">
访问
</a-button>
</div>
</div>
</div>
</div>
<div class="setting" v-if="(state.windowWidth <= 520 || state.section === 'model') && userStore.isSuperAdmin">
<h3>模型配置</h3>
<p>请在 <code>.env</code> 文件中配置对应的 APIKEY并重新启动服务</p>
<ModelProvidersComponent />
</div>
<div class="setting" v-if="(state.windowWidth <= 520 || state.section === 'user') && userStore.isAdmin">
<UserManagementComponent />
</div>
</div>
</div>
</template>
<script setup>
import { message } from 'ant-design-vue';
import { computed, reactive, ref, h, watch, onMounted, onUnmounted } from 'vue'
import { useConfigStore } from '@/stores/config';
import { useUserStore } from '@/stores/user'
import {
SettingOutlined,
CodeOutlined,
FolderOutlined,
UserOutlined,
GlobalOutlined
} from '@ant-design/icons-vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue';
import UserManagementComponent from '@/components/UserManagementComponent.vue';
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue';
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue';
const configStore = useConfigStore()
const userStore = useUserStore()
const items = computed(() => configStore.config._config_items)
const state = reactive({
loading: false,
section: 'base',
windowWidth: window?.innerWidth || 0,
})
const rerankerChoices = computed(() => {
return Object.keys(configStore?.config?.reranker_names || {}) || []
})
const preHandleChange = (key, e) => {
return true
}
const handleChange = (key, e) => {
if (!preHandleChange(key, e)) {
return
}
configStore.setConfigValue(key, e)
}
const handleChanges = (items) => {
for (const key in items) {
if (!preHandleChange(key, items[key])) {
return
}
}
configStore.setConfigValues(items)
}
const updateWindowWidth = () => {
state.windowWidth = window?.innerWidth || 0
}
const handleChatModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('default_model', spec)
}
}
const handleFastModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('fast_model', spec)
}
}
const handleContentGuardModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('content_guard_llm_model', spec)
}
}
onMounted(() => {
updateWindowWidth()
window.addEventListener('resize', updateWindowWidth)
state.section = userStore.isSuperAdmin ? 'base' : 'user'
})
onUnmounted(() => {
window.removeEventListener('resize', updateWindowWidth)
})
const openLink = (url) => {
window.open(url, '_blank')
}
</script>
<style lang="less" scoped>
.setting-container {
--setting-header-height: 55px;
max-width: 1054px;
}
.setting-header {
height: var(--setting-header-height);
}
.setting-header p {
margin: 8px 0 0;
}
.setting-container {
padding: 0;
box-sizing: border-box;
display: flex;
position: relative;
min-height: calc(100vh - var(--setting-header-height));
}
.sider {
width: 180px;
height: 100%;
padding: 0 20px;
position: sticky;
top: var(--setting-header-height);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 20px;
& > * {
width: 100%;
height: auto;
padding: 6px 16px;
cursor: pointer;
transition: all 0.1s;
text-align: left;
font-size: 15px;
border-radius: 8px;
color: var(--gray-700);
&:hover {
background: var(--gray-50);
}
&.activesec {
background: var(--gray-100);
color: var(--main-700);
}
}
}
.setting {
width: 100%;
flex: 1;
margin: 0 auto;
height: 100%;
padding: 0 20px;
margin-bottom: 40px;
h3:not(:first-child) {
margin-top: 30px;
}
h3:first-child {
margin-top: 20px;
}
.section {
margin-top: 10px;
background-color: var(--gray-0);
padding: 12px 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--gray-150);
.content-guard-section {
h4 {
margin: 0 0 12px 0;
color: var(--gray-900);
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid var(--gray-150);
padding-bottom: 8px;
}
}
}
.card {
display: flex;
align-items: center;
justify-content: space-between;
// padding: 12px 0;
.label {
margin-right: 20px;
font-weight: 500;
color: var(--gray-800);
button {
margin-left: 10px;
height: 24px;
padding: 0 8px;
font-size: smaller;
}
}
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
margin-top: 20px;
}
.service-link-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
transition: all 0.2s;
min-height: 60px;
&:hover {
box-shadow: 0 1px 8px var(--gray-200);
}
.service-info {
flex: 1;
margin-right: 16px;
h4 {
margin: 0 0 4px 0;
color: var(--gray-900);
font-size: 15px;
font-weight: 600;
}
p {
margin: 0;
color: var(--gray-600);
font-size: 13px;
line-height: 1.4;
}
}
}
}
@media (max-width: 520px) {
.setting-container {
flex-direction: column;
}
.card.card-select {
gap: 0.75rem;
align-items: flex-start;
flex-direction: column;
}
.services-grid {
gap: 12px;
}
.service-link-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
min-height: auto;
padding: 12px;
.service-info {
text-align: left;
margin-bottom: 4px;
margin-right: 0;
}
}
}
</style>
<style lang="less">
// dropdown
.ant-dropdown-menu {
&.scrollable-menu {
max-height: 300px;
overflow-y: auto;
}
}
</style>