Files
ai-kefu-admin/admin/src/views/settings/platform/index.vue
T
浅念 d734ed6d2a feat: resolver回退链接入platform_settings(三级回退/快照热刷新)
- app/platform/settings_store.py 新模块: lifespan 启动把 platform_settings
  全表快照进进程内存(load_snapshot/get/refresh,workers=1 单进程纪律);
  PUT /api/platform/settings 提交后 refresh 热刷新,回退链即时生效
- config_views email/ai 两组改三级回退: 租户槽位 → 平台设置快照
  (键名对齐平台设置页推荐键 ai.*/smtp.*)→ env settings(env 键名
  不变做最终兜底);push 无平台推荐键保持两级;smtp.port 自由 KV 容错
  归一(数字串转 int,脏值视同未设置落 env)
- conftest autouse 复位快照(resolver 直调测试隔离);新增
  test_platform_settings_fallback 六测(启动装载/三级链/端口容错/
  push 不接线钉死/PUT 即时生效)
- 平台设置页"仅存储"注记移除(CLAUDE.md 与前端页头同步改为已接线)
- 回归: 后端 627 passed(621+6)/ 前端 vitest 156 / build 零错误

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 10:10:52 +08:00

370 lines
11 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.
<!--
平台设置(Phase 3 Task 9,仅超管——菜单 roles 已过滤,后端 require_super 兜底)
PlatformSetting 是自由 KV 表:本页提供推荐键名集合的表单(集团 AI 凭据
ai.* / 默认 SMTP smtp.*——键名是前端约定,不是后端 schema)+ 全量存储
总览表格。保存 = 逐键 PUT(只提交脏键:改了值的普通键 / 输入了新值的
密钥键 / 显式清除的密钥键),与 config 组的"整组替换"不同。
密钥哨兵交互(mask.ts,与凭据页 CredsPane 同款):GET 密钥键值已是
'__SET__' → 输入框留空 + 占位符「已设置,输入新值覆盖」;不动不提交;
「清除」按钮 = 显式清空意图(提交空串),可「恢复保持」撤销。
回退链已接线(Phase 4 Task 5A):ai.*/smtp.* 推荐键经后端内存快照接入
resolver 的平台默认回退链(租户槽位 → 平台设置 → env),保存后即时生效。
-->
<template>
<div class="settings-page">
<ElCard shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">平台设置</span>
<span class="card-sub">仅超管可见 · 键值对直存(键名自由,本页推荐 ai.* / smtp.* 两组)</span>
</div>
</template>
<ElAlert
v-if="loadError"
type="error"
:closable="true"
:title="loadError"
class="pane-error"
@close="loadError = ''"
/>
<div v-loading="loading">
<!-- 推荐键分组表单 -->
<ElCard
v-for="group in SETTING_FIELD_GROUPS"
:key="group.id"
shadow="never"
class="form-card"
>
<template #header>
<div class="card-header">
<span class="card-title">{{ group.title }}</span>
<span class="card-sub">{{ group.description }}</span>
</div>
</template>
<ElAlert
v-if="groupErrors[group.id]"
type="error"
:closable="true"
:title="groupErrors[group.id]"
class="pane-error"
@close="groupErrors[group.id] = ''"
/>
<ElForm label-width="130px" @submit.prevent>
<ElFormItem
v-for="field in group.fields"
:key="field.key"
:label="field.label"
>
<!-- 密钥键:哨兵交互(占位符表达已设置;清除/恢复保持按钮) -->
<template v-if="field.secret">
<div class="secret-row">
<ElInput
v-model="form[field.key]"
type="password"
show-password
autocomplete="new-password"
:placeholder="placeholderFor(field)"
class="secret-input"
/>
<ElButton
v-if="masked[field.key] === MASKED_SENTINEL"
size="small"
:type="cleared[field.key] ? 'default' : 'danger'"
plain
@click="toggleClear(field)"
>
{{ cleared[field.key] ? '恢复保持' : '清除' }}
</ElButton>
</div>
<p v-if="cleared[field.key]" class="field-warn">
保存后将清除已存值(提交空串)
</p>
</template>
<!-- 普通键 -->
<ElInput
v-else
v-model="form[field.key]"
:placeholder="field.placeholder"
/>
<p class="field-key">键名:{{ field.key }}</p>
</ElFormItem>
</ElForm>
<div class="save-bar">
<ElButton
type="primary"
:loading="saving[group.id]"
:disabled="dirtyCount(group) === 0"
@click="onSaveGroup(group)"
>
保存{{ dirtyCount(group) > 0 ? `(${dirtyCount(group)} 项修改)` : '(无修改)' }}
</ElButton>
</div>
</ElCard>
<!-- 全量存储总览 -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">存储总览(库中全部键值对)</span>
<ElButton size="small" :loading="loading" @click="load">
刷新
</ElButton>
</div>
</template>
<p class="table-hint">
密钥键值一律脱敏展示;推荐键之外的键(历史脚本 / API 写入)暂不提供界面编辑。
</p>
<ElTable :data="items" size="small" border>
<ElTableColumn prop="key" label="键" min-width="180" />
<ElTableColumn label="值" min-width="260">
<template #default="{ row }">
<span class="value-cell">{{ formatSettingValue(row.key, row.value) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="类别" width="120">
<template #default="{ row }">
<ElTag v-if="isRecommendedKey(row.key)" size="small" type="info">
推荐键
</ElTag>
<ElTag v-else size="small" type="warning">其他</ElTag>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import {
ALL_SETTING_FIELDS,
SETTING_FIELD_GROUPS,
buildSettingValue,
formatSettingValue,
isFieldDirty,
isRecommendedKey,
normalizeSettingsItems,
validateSettingsForm,
type PlatformSettingsForm,
type SettingFieldMeta
} from './model'
import { fetchPlatformSettings, putPlatformSetting } from '@/api/settings'
import type { PlatformSettingItem } from '@/api/settings'
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
defineOptions({ name: 'PlatformSettings' })
// ---- 状态 ---------------------------------------------------------------------------
/** GET 归一出的表单态(脏检测基线) */
const original = ref(normalizeSettingsItems([]))
const form = reactive<PlatformSettingsForm>({})
/** 密钥键 → GET 掩码态(哨兵 = 已设置) */
const masked = ref<Record<string, string>>({})
/** 密钥键 → 显式清除意图(有效掩码态置 '',可撤销) */
const cleared = reactive<Record<string, boolean>>({})
const items = ref<PlatformSettingItem[]>([])
const loading = ref(false)
const loadError = ref('')
const saving = reactive<Record<string, boolean>>({})
const groupErrors = reactive<Record<string, string>>({})
// ---- 加载 / 回显 --------------------------------------------------------------------
async function load(): Promise<void> {
if (loading.value) return
loading.value = true
loadError.value = ''
try {
const res = await fetchPlatformSettings({ showErrorMessage: false })
applyState(normalizeSettingsItems(res.items))
} catch (error) {
loadError.value = error instanceof Error ? error.message : '加载失败'
} finally {
loading.value = false
}
}
/** GET / 保存后的回显:表单回基线、密钥输入清空、清除意图复位 */
function applyState(state: ReturnType<typeof normalizeSettingsItems>): void {
original.value = state
items.value = state.items
masked.value = state.masked
for (const field of ALL_SETTING_FIELDS) {
form[field.key] = state.form[field.key] ?? ''
cleared[field.key] = false
}
for (const group of SETTING_FIELD_GROUPS) {
groupErrors[group.id] = ''
}
}
onMounted(load)
// ---- 密钥交互(与凭据页 CredsPane 同款) ----------------------------------------------
function placeholderFor(field: SettingFieldMeta): string {
return secretPlaceholder(effectiveMasked(field))
}
function toggleClear(field: SettingFieldMeta): void {
cleared[field.key] = !cleared[field.key]
}
/** 有效掩码态:清除意图按下 → ''(提交空串 / 占位符回"未设置") */
function effectiveMasked(field: SettingFieldMeta): string {
return cleared[field.key] ? '' : (masked.value[field.key] ?? '')
}
// ---- 保存(逐键 PUT,只提交脏键) -----------------------------------------------------
function dirtyCount(group: (typeof SETTING_FIELD_GROUPS)[number]): number {
return group.fields.filter((field) => isFieldDirty(
field,
form[field.key] ?? '',
effectiveMasked(field),
original.value.masked[field.key] ?? '',
original.value.form[field.key] ?? ''
)).length
}
async function onSaveGroup(group: (typeof SETTING_FIELD_GROUPS)[number]): Promise<void> {
if (saving[group.id]) return
groupErrors[group.id] = ''
const errors = validateSettingsForm(group.fields, form)
if (errors.length > 0) {
groupErrors[group.id] = errors.join(';')
return
}
const dirty = group.fields.filter((field) => isFieldDirty(
field,
form[field.key] ?? '',
effectiveMasked(field),
original.value.masked[field.key] ?? '',
original.value.form[field.key] ?? ''
))
if (dirty.length === 0) return
saving[group.id] = true
const failed: string[] = []
try {
for (const field of dirty) {
try {
await putPlatformSetting(
{
key: field.key,
value: buildSettingValue(field, form[field.key] ?? '', effectiveMasked(field))
},
{ showErrorMessage: false }
)
} catch (error) {
failed.push(
`${field.key}:${error instanceof Error ? error.message : '保存失败'}`
)
}
}
await load()
if (failed.length > 0) {
groupErrors[group.id] = `部分键保存失败——${failed.join(';')}`
} else {
ElMessage.success(`已保存 ${dirty.length} 项设置`)
}
} finally {
saving[group.id] = false
}
}
</script>
<style scoped lang="scss">
.settings-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
padding: 16px;
width: 100%;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
.card-title {
font-weight: 600;
}
.card-sub {
font-size: 12px;
color: var(--art-gray-400);
}
}
.pane-error {
margin-bottom: 12px;
}
.form-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.field-key {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--art-gray-400);
}
.secret-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
.secret-input {
flex: 1;
min-width: 0;
}
}
.field-warn {
margin: 4px 0 0;
font-size: 12px;
color: var(--el-color-danger);
}
.save-bar {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.table-hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--art-gray-400);
}
.value-cell {
font-family: var(--art-font-family-code, monospace);
word-break: break-all;
}
</style>