feat: redesign system configuration workspace

This commit is contained in:
2026-08-04 15:07:01 +08:00
parent c4de4e8c56
commit 3b9215cc3d
8 changed files with 901 additions and 153 deletions

View File

@@ -6,7 +6,6 @@ import AdminLoginView from "./components/AdminLoginView.vue";
import TableRowActions from "./components/TableRowActions.vue";
import {
systemSettingDefinitions,
systemSettingSections,
type SystemSettingValue,
} from "./config/systemSettings";
import { api, clearToken, getToken, saveToken } from "./services/api";
@@ -47,6 +46,9 @@ const UserManagementView = defineAsyncComponent(
const SsoIntegrationView = defineAsyncComponent(
() => import("./components/SsoIntegrationView.vue"),
);
const SystemConfigView = defineAsyncComponent(
() => import("./components/SystemConfigView.vue"),
);
const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard");
@@ -55,6 +57,8 @@ const loading = ref(false);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]);
const savingSystemSettings = ref(false);
const systemSettingsBaseline = ref<Record<string, string>>({});
const previewKnowledgeId = ref<number | null>(null);
const editingModelId = ref<number | null>(null);
@@ -174,6 +178,13 @@ const systemSettingValues = reactive<Record<string, SystemSettingValue>>({});
systemSettingDefinitions.forEach((setting) => {
systemSettingValues[setting.key] = setting.defaultValue;
});
const systemSettingsDirty = computed(() =>
systemSettingDefinitions.some(
(setting) =>
serializeSystemSettingValue(systemSettingValues[setting.key]) !==
systemSettingsBaseline.value[setting.key],
),
);
onMounted(async () => {
if (!getToken()) return;
@@ -490,20 +501,30 @@ function validateModelForm() {
}
async function saveSystemSettings() {
await Promise.all(
systemSettingDefinitions.map((setting) =>
api.saveConfig({
configKey: setting.key,
configValue: serializeSystemSettingValue(
systemSettingValues[setting.key],
),
description: setting.description,
}),
),
);
ElMessage.success("系统配置已保存");
configs.value = await api.configs();
hydrateSystemSettings();
if (savingSystemSettings.value || !systemSettingsDirty.value) return;
savingSystemSettings.value = true;
try {
await Promise.all(
systemSettingDefinitions.map((setting) =>
api.saveConfig({
configKey: setting.key,
configValue: serializeSystemSettingValue(
systemSettingValues[setting.key],
),
description: setting.description,
}),
),
);
configs.value = await api.configs();
hydrateSystemSettings();
ElMessage.success("系统配置已保存");
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "系统配置保存失败,请重试",
);
} finally {
savingSystemSettings.value = false;
}
}
function hydrateSystemSettings() {
@@ -516,6 +537,17 @@ function hydrateSystemSettings() {
configMap.get(setting.key),
);
});
systemSettingsBaseline.value = Object.fromEntries(
systemSettingDefinitions.map((setting) => [
setting.key,
serializeSystemSettingValue(systemSettingValues[setting.key]),
]),
);
}
function updateSystemSetting(key: string, value: SystemSettingValue) {
if (systemSettingValues[key] === value) return;
systemSettingValues[key] = value;
}
function normalizeSystemSettingValue(
@@ -1014,81 +1046,14 @@ async function clearFeishuCache() {
<SsoIntegrationView v-if="activeMenu === 'sso'" />
<template v-if="activeMenu === 'configs'">
<div class="page-head inline">
<div>
<h2>系统配置</h2>
<p>
按业务分组维护运行时配置默认每日额度已接入学员新增和导入
</p>
</div>
<el-button type="primary" @click="saveSystemSettings"
>保存系统配置</el-button
>
</div>
<section class="settings-grid">
<div
v-for="section in systemSettingSections"
:key="section.title"
class="settings-section"
>
<div class="settings-section-head">
<h3>{{ section.title }}</h3>
<p>{{ section.description }}</p>
</div>
<div class="settings-fields">
<label
v-for="setting in section.settings"
:key="setting.key"
class="setting-field"
>
<span>{{ setting.label }}</span>
<el-input-number
v-if="setting.type === 'number'"
v-model="systemSettingValues[setting.key]"
:min="setting.min"
:max="setting.max"
/>
<el-input
v-else-if="setting.type === 'text'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
/>
<el-input
v-else-if="setting.type === 'password'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
type="password"
show-password
/>
<el-select
v-else-if="setting.type === 'select'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
>
<el-option
v-for="option in setting.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-switch
v-else
v-model="systemSettingValues[setting.key]"
/>
<small>{{ setting.description }}</small>
<small
v-if="setting.valueHint"
class="setting-value-hint"
>
{{ setting.valueHint(systemSettingValues[setting.key]) }}
</small>
</label>
</div>
</div>
</section>
</template>
<SystemConfigView
v-if="activeMenu === 'configs'"
:values="systemSettingValues"
:saving="savingSystemSettings"
:dirty="systemSettingsDirty"
@update="updateSystemSetting"
@save="saveSystemSettings"
/>
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
<AttentionManagementView v-if="activeMenu === 'attention'" />

View File

@@ -0,0 +1,797 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import {
systemSettingSections,
type SystemSettingDefinition,
type SystemSettingValue,
} from "../config/systemSettings";
interface SettingGroupBlueprint {
title: string;
description: string;
keys: string[];
}
interface SettingGroup extends Omit<SettingGroupBlueprint, "keys"> {
settings: SystemSettingDefinition[];
}
const props = defineProps<{
values: Record<string, SystemSettingValue>;
saving: boolean;
dirty: boolean;
}>();
const emit = defineEmits<{
save: [];
update: [key: string, value: SystemSettingValue];
}>();
const groupBlueprints: Record<string, SettingGroupBlueprint[]> = {
用户与登录: [
{
title: "学员默认配置",
description: "新学员创建、导入时采用的默认资料和额度。",
keys: ["daily_chat_limit", "default_user_name_prefix"],
},
{
title: "登录与验证码",
description: "控制登录状态和验证码的有效时间。",
keys: ["access_token_expire_minutes", "sms_code_expire_minutes"],
},
{
title: "短信服务",
description: "在模拟短信与阿里云短信之间切换,并维护短信通道参数。",
keys: [
"mock_sms_enabled",
"mock_sms_code",
"aliyun_sms_access_key_id",
"aliyun_sms_access_key_secret",
"aliyun_sms_sign_name",
"aliyun_sms_template_code",
"aliyun_sms_template_param_key",
"aliyun_sms_endpoint",
],
},
],
"AI 问答": [
{
title: "回答体验",
description: "控制会话记忆、引用展示和模型请求的基本行为。",
keys: [
"ai_timeout_seconds",
"mock_model_enabled",
"chat_context_message_count",
"show_reference_sources",
],
},
{
title: "模型分流",
description: "决定正式问答在不同问题类型下如何选择模型。",
keys: ["chat_model_routing_mode"],
},
{
title: "并发与排队",
description: "控制集中提问时的执行上限、等待容量和超时保护。",
keys: [
"chat_max_active_requests",
"chat_max_queue_size",
"chat_queue_timeout_seconds",
"chat_active_lease_seconds",
],
},
],
外部服务: [
{
title: "飞书接入",
description: "配置飞书知识库直读和独立检索服务。",
keys: ["feishu_search_url", "feishu_app_id", "feishu_app_secret"],
},
{
title: "稳定性策略",
description: "控制飞书接口慢响应和临时失败时的处理方式。",
keys: ["feishu_timeout_seconds", "feishu_retry_count"],
},
],
系统开关: [
{
title: "审计与数据",
description: "控制后台审计信息和聊天记录导出能力。",
keys: ["show_operation_log_detail", "chat_export_enabled"],
},
{
title: "运行状态",
description: "用于系统维护期间的访问和提示策略。",
keys: ["maintenance_mode"],
},
],
};
const wideSettingKeys = new Set([
"chat_model_routing_mode",
"chat_max_active_requests",
"feishu_search_url",
]);
const activeSectionTitle = ref(systemSettingSections[0]?.title || "");
const activeSection = computed(
() =>
systemSettingSections.find(
(section) => section.title === activeSectionTitle.value,
) || systemSettingSections[0],
);
const activeGroups = computed<SettingGroup[]>(() => {
const section = activeSection.value;
if (!section) return [];
const byKey = new Map(section.settings.map((setting) => [setting.key, setting]));
const configuredKeys = new Set<string>();
const groups = (groupBlueprints[section.title] || []).map((group) => {
const settings = group.keys
.map((key) => byKey.get(key))
.filter((setting): setting is SystemSettingDefinition => Boolean(setting));
settings.forEach((setting) => configuredKeys.add(setting.key));
return { title: group.title, description: group.description, settings };
});
const ungrouped = section.settings.filter(
(setting) => !configuredKeys.has(setting.key),
);
if (ungrouped.length) {
groups.push({
title: "其他设置",
description: "尚未归入固定分组的新配置项。",
settings: ungrouped,
});
}
return groups.filter((group) => group.settings.length);
});
const statusItems = computed(() => [
{
label: "短信通道",
value: props.values.mock_sms_enabled ? "模拟验证码" : "阿里云短信",
tone: props.values.mock_sms_enabled ? "warning" : "success",
},
{
label: "AI 模式",
value: props.values.mock_model_enabled ? "模拟模型" : "真实模型",
tone: props.values.mock_model_enabled ? "warning" : "success",
},
{
label: "会话记忆",
value:
Number(props.values.chat_context_message_count) > 0
? `最近 ${props.values.chat_context_message_count}`
: "已关闭",
tone:
Number(props.values.chat_context_message_count) > 0 ? "neutral" : "warning",
},
{
label: "问答容量",
value: `${props.values.chat_max_active_requests || 0} 并发`,
tone: "neutral",
},
]);
function updateSetting(key: string, value: unknown) {
if (value === undefined || value === null) return;
emit("update", key, value as SystemSettingValue);
}
</script>
<template>
<section class="system-config-page">
<header class="system-config-header">
<div>
<div class="system-config-title-line">
<h2>系统配置</h2>
<span :class="['save-state', { dirty }]">
{{ dirty ? "有未保存修改" : "配置已同步" }}
</span>
</div>
<p>集中维护学员登录AI 问答飞书接入和系统运行策略</p>
</div>
<div class="system-config-actions">
<small>保存后立即对后续请求生效</small>
<el-button
type="primary"
:loading="saving"
:disabled="!dirty"
@click="emit('save')"
>
保存修改
</el-button>
</div>
</header>
<section class="config-status-strip" aria-label="当前关键配置摘要">
<div
v-for="item in statusItems"
:key="item.label"
class="config-status-item"
>
<span>{{ item.label }}</span>
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
</div>
</section>
<div class="config-workspace">
<nav class="config-section-nav" aria-label="系统配置分组">
<div class="config-nav-heading">
<strong>配置分类</strong>
<span>选择一个分类集中编辑</span>
</div>
<button
v-for="section in systemSettingSections"
:key="section.title"
type="button"
:class="{ active: activeSectionTitle === section.title }"
@click="activeSectionTitle = section.title"
>
<span>
<strong>{{ section.title }}</strong>
<small>{{ section.description }}</small>
</span>
<b>{{ section.settings.length }}</b>
</button>
</nav>
<section v-if="activeSection" class="config-editor-panel">
<header class="config-editor-heading">
<div>
<span>当前分类</span>
<h3>{{ activeSection.title }}</h3>
<p>{{ activeSection.description }}</p>
</div>
<strong>{{ activeSection.settings.length }} 项配置</strong>
</header>
<div class="config-groups">
<article
v-for="group in activeGroups"
:key="group.title"
class="config-group-card"
>
<header>
<h4>{{ group.title }}</h4>
<p>{{ group.description }}</p>
</header>
<div class="config-fields-grid">
<div
v-for="setting in group.settings"
:key="setting.key"
:class="[
'config-field',
`config-field-${setting.type}`,
{ wide: wideSettingKeys.has(setting.key) },
]"
>
<template v-if="setting.type === 'switch'">
<div class="config-switch-copy">
<strong>{{ setting.label }}</strong>
<small>{{ setting.description }}</small>
</div>
<el-switch
:model-value="Boolean(values[setting.key])"
@update:model-value="updateSetting(setting.key, $event)"
/>
</template>
<template v-else>
<label :for="`setting-${setting.key}`">
<strong>{{ setting.label }}</strong>
<span v-if="setting.type === 'password'">敏感配置</span>
</label>
<el-input-number
v-if="setting.type === 'number'"
:id="`setting-${setting.key}`"
:model-value="Number(values[setting.key])"
:min="setting.min"
:max="setting.max"
controls-position="right"
@update:model-value="updateSetting(setting.key, $event)"
/>
<el-input
v-else-if="setting.type === 'text'"
:id="`setting-${setting.key}`"
:model-value="String(values[setting.key] ?? '')"
:placeholder="setting.placeholder"
clearable
@update:model-value="updateSetting(setting.key, $event)"
/>
<el-input
v-else-if="setting.type === 'password'"
:id="`setting-${setting.key}`"
:model-value="String(values[setting.key] ?? '')"
:placeholder="setting.placeholder"
type="password"
show-password
@update:model-value="updateSetting(setting.key, $event)"
/>
<el-select
v-else-if="setting.type === 'select'"
:id="`setting-${setting.key}`"
:model-value="String(values[setting.key] ?? '')"
:placeholder="setting.placeholder"
@update:model-value="updateSetting(setting.key, $event)"
>
<el-option
v-for="option in setting.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<small>{{ setting.description }}</small>
<small
v-if="setting.valueHint"
class="config-value-hint"
>
{{ setting.valueHint(values[setting.key]) }}
</small>
</template>
</div>
</div>
</article>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.system-config-page {
display: grid;
gap: 18px;
min-width: 0;
padding-bottom: 24px;
}
.system-config-header {
display: flex;
gap: 24px;
align-items: flex-start;
justify-content: space-between;
}
.system-config-title-line {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.system-config-title-line h2 {
margin: 0;
}
.system-config-header p {
margin: 7px 0 0;
color: #687a75;
line-height: 1.6;
}
.save-state {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
background: #eaf3f0;
color: #55716a;
font-size: 12px;
font-weight: 600;
}
.save-state::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: #62a38d;
content: "";
}
.save-state.dirty {
background: #fff4df;
color: #95651b;
}
.save-state.dirty::before {
background: #d79a37;
}
.system-config-actions {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.system-config-actions small {
color: #83918d;
}
.system-config-actions .el-button {
min-width: 104px;
}
.config-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid #dfe9e5;
border-radius: 14px;
background: #fff;
}
.config-status-item {
display: grid;
gap: 7px;
min-width: 0;
padding: 15px 18px;
}
.config-status-item + .config-status-item {
border-left: 1px solid #e6eeeb;
}
.config-status-item span {
color: #7a8b86;
font-size: 12px;
}
.config-status-item strong {
overflow: hidden;
color: #2d463f;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-status-item .tone-success {
color: #197653;
}
.config-status-item .tone-warning {
color: #a26919;
}
.config-workspace {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 18px;
align-items: start;
min-width: 0;
}
.config-section-nav,
.config-editor-panel {
border: 1px solid #dfe8e5;
border-radius: 16px;
background: #fff;
}
.config-section-nav {
position: sticky;
top: 18px;
display: grid;
gap: 6px;
padding: 14px;
}
.config-nav-heading {
display: grid;
gap: 4px;
padding: 5px 7px 11px;
}
.config-nav-heading strong {
color: #29423b;
font-size: 14px;
}
.config-nav-heading span {
color: #879691;
font-size: 12px;
}
.config-section-nav button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
width: 100%;
padding: 12px;
border: 1px solid transparent;
border-radius: 11px;
background: transparent;
color: #49605a;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease;
}
.config-section-nav button:hover {
background: #f5f8f7;
}
.config-section-nav button.active {
border-color: #bcded2;
background: #edf7f3;
color: #176d53;
}
.config-section-nav button > span {
display: grid;
gap: 4px;
min-width: 0;
}
.config-section-nav button strong {
font-size: 14px;
}
.config-section-nav button small {
overflow: hidden;
color: #82908c;
font-size: 11px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-section-nav button b {
min-width: 26px;
padding: 4px 7px;
border-radius: 999px;
background: #eef3f1;
color: #71817c;
font-size: 11px;
text-align: center;
}
.config-section-nav button.active b {
background: #d9eee7;
color: #176d53;
}
.config-editor-panel {
min-width: 0;
overflow: hidden;
}
.config-editor-heading {
display: flex;
gap: 20px;
align-items: flex-start;
justify-content: space-between;
padding: 22px 24px;
border-bottom: 1px solid #e5ecea;
background: linear-gradient(135deg, #fbfdfc, #f4f9f7);
}
.config-editor-heading > div {
min-width: 0;
}
.config-editor-heading span {
color: #248064;
font-size: 12px;
font-weight: 700;
}
.config-editor-heading h3 {
margin: 6px 0 0;
color: #203b33;
font-size: 20px;
}
.config-editor-heading p {
margin: 7px 0 0;
color: #70827d;
font-size: 13px;
line-height: 1.6;
}
.config-editor-heading > strong {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 999px;
background: #e7f2ee;
color: #4d6d64;
font-size: 12px;
}
.config-groups {
display: grid;
gap: 16px;
padding: 20px;
}
.config-group-card {
overflow: hidden;
border: 1px solid #e1eae7;
border-radius: 13px;
background: #fff;
}
.config-group-card > header {
padding: 15px 18px;
border-bottom: 1px solid #e8efed;
background: #fafcfb;
}
.config-group-card h4 {
margin: 0;
color: #2c453e;
font-size: 15px;
}
.config-group-card header p {
margin: 5px 0 0;
color: #7b8c87;
font-size: 12px;
line-height: 1.55;
}
.config-fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
padding: 4px 18px 18px;
}
.config-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 15px 16px 2px 0;
}
.config-field:nth-child(even):not(.wide) {
padding-right: 0;
padding-left: 16px;
border-left: 1px solid #edf2f0;
}
.config-field.wide {
grid-column: 1 / -1;
padding-right: 0;
}
.config-field label {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.config-field label strong,
.config-switch-copy strong {
color: #324841;
font-size: 14px;
}
.config-field label span {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 999px;
background: #fff1ed;
color: #a4503f;
font-size: 10px;
font-weight: 600;
}
.config-field > small,
.config-switch-copy small {
color: #7b8985;
font-size: 12px;
line-height: 1.55;
}
.config-field :deep(.el-input-number),
.config-field :deep(.el-input),
.config-field :deep(.el-select) {
width: 100%;
}
.config-field :deep(.el-input__wrapper),
.config-field :deep(.el-select__wrapper),
.config-field :deep(.el-input-number) {
min-height: 40px;
}
.config-field-switch {
grid-template-columns: minmax(0, 1fr) auto;
gap: 20px;
align-items: center;
min-height: 70px;
padding-bottom: 12px;
}
.config-switch-copy {
display: grid;
gap: 5px;
min-width: 0;
}
.config-value-hint {
padding: 10px 12px;
border: 1px solid #cde4db;
border-radius: 9px;
background: #f2f9f6;
color: #2c6e58 !important;
}
@media (max-width: 1320px) {
.config-workspace {
grid-template-columns: 220px minmax(0, 1fr);
}
.config-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-status-item:nth-child(3) {
border-left: 0;
border-top: 1px solid #e6eeeb;
}
.config-status-item:nth-child(4) {
border-top: 1px solid #e6eeeb;
}
}
@media (max-width: 1080px) {
.system-config-header {
flex-direction: column;
}
.system-config-actions {
width: 100%;
justify-content: space-between;
}
.config-workspace {
grid-template-columns: 1fr;
}
.config-section-nav {
position: static;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-nav-heading {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.config-status-strip,
.config-section-nav,
.config-fields-grid {
grid-template-columns: 1fr;
}
.config-status-item + .config-status-item,
.config-status-item:nth-child(3),
.config-status-item:nth-child(4) {
border-top: 1px solid #e6eeeb;
border-left: 0;
}
.config-field,
.config-field:nth-child(even):not(.wide) {
padding-right: 0;
padding-left: 0;
border-left: 0;
}
}
</style>

View File

@@ -1678,67 +1678,6 @@ textarea {
margin-top: 12px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.settings-section {
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.settings-section-head h3 {
margin: 0;
font-size: 17px;
}
.settings-section-head p {
margin: 6px 0 0;
color: #667a73;
font-size: 13px;
line-height: 1.6;
}
.settings-fields {
display: grid;
gap: 14px;
margin-top: 16px;
}
.setting-field {
display: grid;
gap: 7px;
}
.setting-field > span {
color: #223631;
font-weight: 600;
}
.setting-field > small {
color: #667a73;
line-height: 1.5;
}
.setting-field > .setting-value-hint {
padding: 9px 11px;
border: 1px solid #cde6dc;
border-radius: 8px;
background: #f3faf7;
color: #246b52;
}
.setting-field .el-input-number,
.setting-field .el-input,
.setting-field .el-select {
width: 100%;
}
.el-drawer .el-date-editor.el-input {
width: 100%;
}
@@ -2343,8 +2282,7 @@ textarea {
@media (max-width: 1120px) {
.student-tools,
.user-list-toolbar,
.agent-workbench,
.settings-grid {
.agent-workbench {
grid-template-columns: 1fr;
}