feat: redesign system configuration workspace
This commit is contained in:
@@ -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'" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,54 @@ final result: passed
|
||||
|
||||
---
|
||||
|
||||
# 系统配置页面重构验收(2026-08-04)
|
||||
|
||||
- 源页面截图:`development_records/ui-evidence/system-config-2026-08-04/before.png`
|
||||
- 实现页面截图:`development_records/ui-evidence/system-config-2026-08-04/after.png`
|
||||
- 同屏对比图:`development_records/ui-evidence/system-config-2026-08-04/comparison.jpg`
|
||||
- 验收地址:`http://localhost:5174/`
|
||||
- 状态:管理员已登录 / 系统配置 / 用户与登录
|
||||
- 视口:1280 × 720 CSS px,设备像素密度 1
|
||||
- 源页面完整截图:1280 × 1998 px;实现页面完整截图:1280 × 1572 px
|
||||
- 归一化方式:两张截图均保持 1280 px 原始宽度和相同像素密度,不缩放;对比图仅在实现图底部补白至相同高度
|
||||
|
||||
## 完整视图与重点区域证据
|
||||
|
||||
- 修复前同时铺开 29 项配置,分散在三列高度不一的卡片中,阅读路径长、首屏没有重点,底部留白失衡。
|
||||
- 修复后增加运行摘要、左侧分类导航和右侧聚焦编辑区;一次只展示一个顶级分类,并在分类内按业务语义分组。
|
||||
- “用户与登录、AI 问答、外部服务、系统开关”四个分类均已逐项打开验证,29 项现有配置全部保留。
|
||||
- 首屏摘要展示短信模式、AI 模式、上下文消息数和最大并发数,管理员无需进入字段即可了解关键运行状态。
|
||||
|
||||
## 必查视觉面
|
||||
|
||||
- 字体与排版:标题、摘要值、分组标题、字段标签和说明形成稳定层级;长说明自然换行,不与控件挤压。
|
||||
- 间距与布局:左侧导航吸附,右侧表单采用统一双列网格;开关类配置使用整行设置项,控件垂直居中。
|
||||
- 颜色与设计令牌:沿用后台现有深墨绿、浅雾绿和蓝色主操作色,没有新增图片或大面积装饰色块。
|
||||
- 图片与资源:页面完全使用现有组件与 CSS,不依赖新增图片资产。
|
||||
- 文案:保留原配置名称和说明;新增“配置已同步 / 有未保存修改”状态,敏感字段增加提示标识。
|
||||
|
||||
## 交互与工程检查
|
||||
|
||||
- 已验证四个分类导航均可切换,并显示对应分组和配置数量。
|
||||
- 已验证 AI 超时从 60 改为 61 后出现“有未保存修改”且保存按钮启用;恢复为 60 后状态回到“配置已同步”且按钮禁用。
|
||||
- 本次验收未实际保存配置,未修改现有运行参数。
|
||||
- 保存流程已补充重复提交保护、保存中状态和失败提示,接口与原有配置读写逻辑保持不变。
|
||||
- 1280 px 视口下文档、页面容器和配置工作区 `scrollWidth` 均等于 `clientWidth`,无横向滚动。
|
||||
- 浏览器控制台错误和警告:0。
|
||||
- `npm run build`:通过。
|
||||
|
||||
## 对比历史
|
||||
|
||||
1. [P1] 初始问题:全部配置同时展示,三列信息密度过高,管理员难以定位配置且页面高度失控。
|
||||
2. 修复:按任务域拆成四个分类和十个业务分组,增加关键状态摘要与聚焦编辑工作区。
|
||||
3. [P2] 初始问题:保存按钮始终可点击,缺少未保存状态、保存中反馈和错误处理。
|
||||
4. 修复:增加基线对比、脏状态提示、按钮禁用规则、重复提交保护与真实失败提示。
|
||||
5. 复验:完整视图信息层级清晰,分类切换、状态恢复、响应式宽度和构建均通过;未发现剩余 P0、P1、P2 问题。
|
||||
|
||||
final result: passed
|
||||
|
||||
---
|
||||
|
||||
# Markdown 长回答排版修复验收(2026-07-17)
|
||||
|
||||
## 对照范围
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 425 KiB |
Reference in New Issue
Block a user