chore: Phase 3 收尾——平台设置页+全页冒烟+文档

Task 9(Phase 3 最后任务):
- 平台设置页实装(settings/platform):推荐键表单(ai.* 三键 /
  smtp.* 四键,键名为前端约定——PlatformSetting 是自由 KV 表)+
  存储总览表格;保存 = 逐键 PUT 只提交脏键(与 config 组整组替换
  不同);密钥键哨兵交互与凭据页同款(已设置占位 / 清除 / 恢复
  保持 / 端口 1-65535 校验);页头注记 resolver 回退链未接线、
  现阶段仅存储。
- api/settings.ts:fetchPlatformSettings / putPlatformSetting 实装
  (GET items 列表 / PUT 单键 upsert 形态钉在 docstring)。
- vitest +14(model 10 + api 4):掩码判定后端镜像、归一/脏检测/
  提交值/校验/展示全覆盖;前端基线 139 → 153。
- 全页面浏览器冒烟通过(登录→强制改密→仪表盘双视角→租户管理→
  会话与线索→配置中心全组→统计分析→平台设置→退出→租户管理员
  视角菜单收敛)。
- CLAUDE.md:新增 Phase 3 完成状态段(基线 595/153、本地起法、
  admin 能力一览、前端约定);Phase 3 清理单结项 7/11/12(掩码
  哨兵 / 站点权限 / push enabled 显式化);Phase 4 清理单增补四项
  (components.dts 行尾漂移 / 推送探测 env 回退真址 / 生产反代
  /api/public / VITE_TENANT_PREVIEW_HOST 单域名)。README 现状
  刷新至 Phase 3。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 23:29:31 +08:00
parent 230eb2d86c
commit 65aad53e12
7 changed files with 933 additions and 24 deletions
+359 -7
View File
@@ -1,25 +1,377 @@
<!-- 平台设置(骨架占位页,仅超管可见) -->
<!--
平台设置(Phase 3 Task 9,仅超管——菜单 roles 已过滤,后端 require_super 兜底)
PlatformSetting 是自由 KV 表:本页提供推荐键名集合的表单(集团 AI 凭据
ai.* / 默认 SMTP smtp.*——键名是前端约定,不是后端 schema)+ 全量存储
总览表格。保存 = 逐键 PUT(只提交脏键:改了值的普通键 / 输入了新值的
密钥键 / 显式清除的密钥键),与 config 组的"整组替换"不同。
密钥哨兵交互(mask.ts,与凭据页 CredsPane 同款):GET 密钥键值已是
'__SET__' → 输入框留空 + 占位符「已设置,输入新值覆盖」;不动不提交;
「清除」按钮 = 显式清空意图(提交空串),可「恢复保持」撤销。
页头注记(CLAUDE.md Phase 3 取舍):resolver 的平台默认回退链尚未接入
platform_settings——本页现阶段仅存储与展示,引擎不读取这些值。
-->
<template>
<div class="page-placeholder">
<div class="settings-page">
<ElAlert
type="info"
:closable="false"
show-icon
title="平台默认值将在后续版本接入引擎回退链,当前本页仅存储与展示——引擎暂不读取这些值。"
class="page-note"
/>
<ElCard shadow="never">
<template #header>
<span class="card-title">平台设置</span>
<div class="card-header">
<span class="card-title">平台设置</span>
<span class="card-sub">仅超管可见 · 键值对直存(键名自由,本页推荐 ai.* / smtp.* 两组)</span>
</div>
</template>
<ElEmpty description="平台级设置与租户管理员账号管理(后续任务实现)" />
<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">
.page-placeholder {
.settings-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
padding: 16px;
width: 100%;
}
.card-title {
font-weight: 600;
.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>