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
+78
View File
@@ -0,0 +1,78 @@
/**
* 平台设置 API 单元测试(Phase 3 Task 9)
*
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 的
* settings_router):GET 列表 {items:[{key,value}]} / PUT 单键 upsert
* {key,value}——与 config 组的"整组替换"不同,KV 表逐键写。
* HTTP 层行为已在 client.test.ts 覆盖,此处 mock http 只断言调用形态。
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/client', () => ({
http: { get: vi.fn(), put: vi.fn() }
}))
import { http } from '@/api/client'
import {
SETTINGS_ENDPOINTS,
fetchPlatformSettings,
putPlatformSetting
} from '@/api/settings'
describe('平台设置 API(src/api/settings.ts)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchPlatformSettings:GET /api/platform/settings(超管端点)', async () => {
const body = {
items: [
{ key: 'ai.api_key', value: '__SET__' },
{ key: 'ai.model', value: 'glm-4-flash' }
]
}
vi.mocked(http.get).mockResolvedValue(body)
const res = await fetchPlatformSettings()
expect(res).toEqual(body)
expect(http.get).toHaveBeenCalledWith(SETTINGS_ENDPOINTS.list, undefined)
})
it('fetchPlatformSettings:透传 RequestOptions(内联错误展示)', async () => {
vi.mocked(http.get).mockResolvedValue({ items: [] })
await fetchPlatformSettings({ showErrorMessage: false })
expect(http.get).toHaveBeenCalledWith(SETTINGS_ENDPOINTS.list, {
showErrorMessage: false
})
})
it('putPlatformSetting:PUT 单键 upsert,请求体 {key, value}', async () => {
const response = { key: 'smtp.host', value: 'smtp.exmail.qq.com' }
vi.mocked(http.put).mockResolvedValue(response)
const res = await putPlatformSetting(
{ key: 'smtp.host', value: 'smtp.exmail.qq.com' },
{ showErrorMessage: false }
)
expect(res).toEqual(response)
expect(http.put).toHaveBeenCalledWith(
SETTINGS_ENDPOINTS.put,
{ key: 'smtp.host', value: 'smtp.exmail.qq.com' },
{ showErrorMessage: false }
)
})
it('putPlatformSetting:密钥哨兵原样提交(后端还原库值)', async () => {
vi.mocked(http.put).mockResolvedValue({ key: 'ai.api_key', value: '__SET__' })
await putPlatformSetting({ key: 'ai.api_key', value: '__SET__' })
expect(http.put).toHaveBeenCalledWith(
SETTINGS_ENDPOINTS.put,
{ key: 'ai.api_key', value: '__SET__' },
undefined
)
})
})
+40 -4
View File
@@ -1,15 +1,26 @@
/**
* 平台设置 API 骨架(Phase 3 Task 3)
* 平台设置 API(Phase 3 Task 3 骨架;Task 9 实装调用函数)
*
* 端点事实源:app/platform/routes/config_routes.py 的 settings_router
* (/api/platform/settings,仅超管:集团 AI 凭据 / 默认 SMTP 等)。
* 本文件只建类型与端点常量——调用函数在 Task 9(平台设置)实装。
*
* 密钥掩码:key 名含 key/password/secret 的非空字符串值在列表响应中
* 掩码为 '__SET__'(嵌套 dict 递归两层);PUT 提交 '__SET__' = 保持库值。
* 形态(GET 列表 / PUT 单键 upsert——与 config 组的"整组替换"不同,
* 平台设置是 KV 表,逐键写):
* - GET → {items: [{key, value}]}(按 key 升序;value 任意 JSON——
* 后端 Text 列 json.dumps 直存,读回还原类型)
* - PUT → body {key, value}:同 key 再写 = 整值替换;响应 {key, value}
* (掩码视图)
*
* 密钥掩码:key 名含 key/password/secret 的非空字符串值在响应中
* 掩码为 '__SET__'(嵌套 dict 递归);PUT 提交 '__SET__' = 保持库值。
* 表单语义转换见 src/api/mask.ts。
*
* 注意(CLAUDE.md Phase 3 取舍):resolver 的平台默认回退链**尚未**接入
* platform_settings——本端点现阶段只做存储与展示,引擎不读取这些值。
*/
import { http, type RequestOptions } from './client'
/** 平台设置项(列表响应行;value 可能是掩码哨兵或任意 JSON) */
export interface PlatformSettingItem {
key: string
@@ -32,3 +43,28 @@ export const SETTINGS_ENDPOINTS = {
list: '/api/platform/settings',
put: '/api/platform/settings'
} as const
/**
* 平台设置列表(GET /api/platform/settings,仅超管):按 key 升序的
* 全量 KV。value 为掩码视图——密钥键(key 名含 key/password/secret)
* 的非空字符串值已是 '__SET__' 哨兵,调用方经 model 层的 normalize
* 转表单态、经 formatSettingValue 做展示,绝不久存原文。
*/
export function fetchPlatformSettings(
config?: RequestOptions
): Promise<PlatformSettingsResponse> {
return http.get<PlatformSettingsResponse>(SETTINGS_ENDPOINTS.list, config)
}
/**
* upsert 单个设置项(PUT /api/platform/settings):同 key 再写 = 整值
* 替换(无部分合并)。密钥键提交 '__SET__' 哨兵 = 保持库值(后端还原,
* 哨兵字面量绝不落库)。响应 {key, value} 为掩码视图。
* 本页按"仅提交脏键"调用——未改动 的键不发请求。
*/
export function putPlatformSetting(
body: PutPlatformSettingRequest,
config?: RequestOptions
): Promise<PlatformSettingItem> {
return http.put<PlatformSettingItem>(SETTINGS_ENDPOINTS.put, body, config)
}
@@ -0,0 +1,149 @@
/**
* 平台设置页模型测试(Phase 3 Task 9)
*
* 钉住 model.ts 与后端 config_routes settings_router 的契约:
* - 密钥判定镜像 _SETTING_SECRET_MARKERS(子串匹配 + 大小写不敏感——
* 'monkey' 命中 'key' 是与后端一致的行为,不"修正");
* - normalize:GET items → 表单(密钥输入恒空 + 掩码态单列;数值 →
* 字符串;缺键补空);
* - 脏检测:密钥键输入新值 / 显式清除才脏(不动不发请求);普通键
* trim 后比对;
* - 提交值:密钥走 secretToSubmit(新值 / 空串清除 / 哨兵保持);int
* 键空串 → null、合法整数 → 数值;
* - 展示:哨兵与密钥键非空串一律脱敏文案(后端掩码失效也不回显原文)。
*/
import { describe, expect, it } from 'vitest'
import { MASKED_SENTINEL } from '@/api/mask'
import {
AI_SETTING_FIELDS,
SMTP_SETTING_FIELDS,
ALL_SETTING_FIELDS,
buildSettingValue,
formatSettingValue,
isFieldDirty,
isRecommendedKey,
isSecretSettingKey,
normalizeSettingsItems,
validateSettingsForm
} from '../model'
const AI_KEY = AI_SETTING_FIELDS.find((f) => f.key === 'ai.api_key')!
const SMTP_PASSWORD = SMTP_SETTING_FIELDS.find((f) => f.key === 'smtp.password')!
const SMTP_PORT = SMTP_SETTING_FIELDS.find((f) => f.key === 'smtp.port')!
const BASE_URL = AI_SETTING_FIELDS.find((f) => f.key === 'ai.base_url')!
describe('平台设置模型(settings/platform/model.ts)', () => {
// ---- 密钥判定(后端镜像) ----------------------------------------------------------
it('isSecretSettingKey:key/password/secret 片段命中(子串 + 大小写不敏感)', () => {
expect(isSecretSettingKey('ai.api_key')).toBe(true)
expect(isSecretSettingKey('smtp.password')).toBe(true)
expect(isSecretSettingKey('client_secret')).toBe(true)
expect(isSecretSettingKey('API-KEY')).toBe(true)
// 子串匹配的后端同款行为:monkey 含 key 也命中(契约一致,不修正)
expect(isSecretSettingKey('monkey')).toBe(true)
expect(isSecretSettingKey('smtp.host')).toBe(false)
expect(isSecretSettingKey('ai.model')).toBe(false)
})
// ---- normalize ---------------------------------------------------------------------
it('normalizeSettingsItems:密钥键输入恒空 + 掩码态单列;数值转字符串;缺键补空', () => {
const state = normalizeSettingsItems([
{ key: 'ai.api_key', value: MASKED_SENTINEL },
{ key: 'ai.base_url', value: 'https://open.bigmodel.cn/api/paas/v4' },
{ key: 'smtp.port', value: 465 },
{ key: 'smtp.password', value: '' },
{ key: 'other.custom', value: { nested: true } }
])
expect(state.form['ai.api_key']).toBe('')
expect(state.masked['ai.api_key']).toBe(MASKED_SENTINEL)
expect(state.form['ai.base_url']).toBe('https://open.bigmodel.cn/api/paas/v4')
expect(state.form['smtp.port']).toBe('465')
// 空串密码 = 未设置(后端哨兵只掩非空)
expect(state.masked['smtp.password']).toBe('')
expect(state.form['ai.model']).toBe('')
expect(state.form['smtp.host']).toBe('')
expect(state.items).toHaveLength(5)
})
it('normalizeSettingsItems:后端掩码失效漏出原文 → 仍按哨兵处理(保守策略)', () => {
const state = normalizeSettingsItems([
{ key: 'ai.api_key', value: 'sk-leaked-plaintext' }
])
expect(state.masked['ai.api_key']).toBe(MASKED_SENTINEL)
expect(state.form['ai.api_key']).toBe('')
})
// ---- 脏检测 -------------------------------------------------------------------------
it('isFieldDirty:密钥键——输入新值 / 显式清除才脏,不动不提交', () => {
const maskedSet = MASKED_SENTINEL
// 未动:输入空 + 有效掩码 = 原掩码 → 不脏
expect(isFieldDirty(AI_KEY, '', maskedSet, maskedSet, '')).toBe(false)
// 输入新值 → 脏
expect(isFieldDirty(AI_KEY, 'sk-new', maskedSet, maskedSet, '')).toBe(true)
// 显式清除(有效掩码被置 '',原已设置)→ 脏(提交空串)
expect(isFieldDirty(AI_KEY, '', '', maskedSet, '')).toBe(true)
// 库中未设置 + 输入空 → 不脏(不发无意义的空 PUT)
expect(isFieldDirty(AI_KEY, '', '', '', '')).toBe(false)
})
it('isFieldDirty:普通键 trim 后比对(两端空白视为未改)', () => {
expect(isFieldDirty(BASE_URL, 'https://x.cn', '', '', 'https://x.cn')).toBe(false)
expect(isFieldDirty(BASE_URL, ' https://x.cn ', '', '', 'https://x.cn')).toBe(false)
expect(isFieldDirty(BASE_URL, 'https://y.cn', '', '', 'https://x.cn')).toBe(true)
expect(isFieldDirty(BASE_URL, '', '', '', 'https://x.cn')).toBe(true)
})
// ---- 提交值 -------------------------------------------------------------------------
it('buildSettingValue:密钥键走 secretToSubmit(新值 / 清除空串 / 哨兵保持)', () => {
expect(buildSettingValue(AI_KEY, 'sk-new', MASKED_SENTINEL)).toBe('sk-new')
expect(buildSettingValue(AI_KEY, '', '')).toBe('')
// 未动但被强制提交 → 哨兵(后端还原库值)
expect(buildSettingValue(AI_KEY, '', MASKED_SENTINEL)).toBe(MASKED_SENTINEL)
expect(buildSettingValue(SMTP_PASSWORD, '', MASKED_SENTINEL)).toBe(MASKED_SENTINEL)
})
it('buildSettingValue:int 键空串 → null、合法整数 → 数值、其余 trim 字符串', () => {
expect(buildSettingValue(SMTP_PORT, '', '')).toBeNull()
expect(buildSettingValue(SMTP_PORT, ' 465 ', '')).toBe(465)
// 非整数被 validate 前置拦截;此处字符串透传兜底(后端 value 任意 JSON)
expect(buildSettingValue(SMTP_PORT, 'abc', '')).toBe('abc')
expect(buildSettingValue(BASE_URL, ' https://x.cn ', '')).toBe('https://x.cn')
})
// ---- 校验 ---------------------------------------------------------------------------
it('validateSettingsForm:端口 1-65535 整数(空槽合法)', () => {
const form = { 'smtp.port': '465' }
expect(validateSettingsForm(SMTP_SETTING_FIELDS, form)).toEqual([])
expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '' })).toEqual([])
expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '0' })).toHaveLength(1)
expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '70000' })).toHaveLength(1)
expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': 'abc' })).toHaveLength(1)
expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '4.5' })).toHaveLength(1)
})
// ---- 展示 ---------------------------------------------------------------------------
it('formatSettingValue:哨兵 / 密钥键非空串 → 脱敏文案;其余原样或 JSON', () => {
expect(formatSettingValue('ai.api_key', MASKED_SENTINEL)).toBe('已设置(值已脱敏)')
expect(formatSettingValue('smtp.password', 'raw-secret')).toBe('已设置(值已脱敏)')
expect(formatSettingValue('smtp.host', 'smtp.qq.com')).toBe('smtp.qq.com')
expect(formatSettingValue('smtp.host', '')).toBe('""')
expect(formatSettingValue('x.flag', null)).toBe('null')
expect(formatSettingValue('x.flag', undefined)).toBe('—')
expect(formatSettingValue('x.flag', { a: 1 })).toBe('{"a":1}')
expect(formatSettingValue('x.list', [1, 2])).toBe('[1,2]')
})
it('推荐键集合:ai.* 三键 + smtp.* 四键', () => {
expect(ALL_SETTING_FIELDS).toHaveLength(7)
expect(isRecommendedKey('ai.api_key')).toBe(true)
expect(isRecommendedKey('smtp.port')).toBe(true)
expect(isRecommendedKey('other.custom')).toBe(false)
})
})
+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>
+265
View File
@@ -0,0 +1,265 @@
/**
* 平台设置页模型(Phase 3 Task 9)
*
* 消费方:settings/platform/index.vue。纯逻辑无 DOM 依赖,vitest 直接
* 覆盖(__tests__/model.test.ts)。
*
* 事实源对齐:
* - 后端 app/platform/routes/config_routes.py 的 settings_router:
* PlatformSetting 是**自由 KV 表**(key 1-64 字符,value 任意 JSON),
* 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp.*)
* 是前端约定,不是后端 schema;
* - 密钥判定镜像 _SETTING_SECRET_MARKERS:key 名不区分大小写含
* key/password/secret 片段即视为密钥(子串匹配——'monkey' 也会命中,
* 与后端行为一致是契约,不"修正");
* - GET 响应的密钥值已掩码为 '__SET__'(哨兵);PUT 提交哨兵 = 保持
* 库值(secretToSubmit,见 src/api/mask.ts)。
*
* 保存语义(与 config 组的"整组替换"不同):逐键 upsert——只 PUT 脏键
* (改了值的普通键 / 输入了新值的密钥键 / 显式清除的密钥键),未改动
* 的键不发请求。
*/
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
import type { PlatformSettingItem } from '@/api/settings'
// ---- 密钥判定(后端 _is_secret_setting_key 镜像) ----------------------------------
const SECRET_MARKERS = ['key', 'password', 'secret'] as const
/** key 名是否命中密钥片段(大小写不敏感子串匹配,与后端同款) */
export function isSecretSettingKey(key: string): boolean {
const lowered = key.toLowerCase()
return SECRET_MARKERS.some((marker) => lowered.includes(marker))
}
/**
* 任意非空字符串 → 掩码哨兵。比 === MASKED_SENTINEL 更保守:即使后端
* 掩码失效漏出原始密钥(理论不可达),也按「已设置」处理——展示层绝不
* 回显原文,提交层靠"不动不提交"天然不回传。与 config/center/model.ts
* 的 asSecretMasked 同策略(跨视图不互相引,双胞胎注记)。
*/
export function asSecretMasked(value: unknown): string {
return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : ''
}
// ---- 推荐键元数据 ------------------------------------------------------------------
/** 推荐键字段元数据(secret = 密钥哨兵交互;int = 整数规范化提交) */
export interface SettingFieldMeta {
key: string
label: string
placeholder: string
secret?: boolean
kind?: 'int'
}
/** 集团 AI 凭据(租户 ai 组空槽的回退源——回退链接线归后续版本) */
export const AI_SETTING_FIELDS: SettingFieldMeta[] = [
{
key: 'ai.api_key',
label: 'API Key',
placeholder: '集团统一密钥(如智谱 sk-…)',
secret: true
},
{
key: 'ai.base_url',
label: 'Base URL',
placeholder: 'https://open.bigmodel.cn/api/paas/v4'
},
{
key: 'ai.model',
label: '模型',
placeholder: '如 glm-4-flash'
}
]
/** 默认 SMTP(租户 email 组空槽的回退源——回退链接线归后续版本) */
export const SMTP_SETTING_FIELDS: SettingFieldMeta[] = [
{
key: 'smtp.host',
label: 'SMTP 服务器',
placeholder: '如 smtp.exmail.qq.com'
},
{
key: 'smtp.port',
label: 'SMTP 端口',
placeholder: '如 465(1-65535 整数)',
kind: 'int'
},
{
key: 'smtp.user',
label: 'SMTP 账号',
placeholder: '发件邮箱账号'
},
{
key: 'smtp.password',
label: 'SMTP 密码',
placeholder: '密码或授权码',
secret: true
}
]
/** 表单分组(页卡顺序) */
export const SETTING_FIELD_GROUPS: Array<{
id: 'ai' | 'smtp'
title: string
description: string
fields: SettingFieldMeta[]
}> = [
{
id: 'ai',
title: '集团 AI 凭据',
description: '租户 ai 组空槽的回退源(推荐键名 ai.*——键名自由,本页约定)',
fields: AI_SETTING_FIELDS
},
{
id: 'smtp',
title: '默认 SMTP',
description: '租户 email 组空槽的回退源(推荐键名 smtp.*——键名自由,本页约定)',
fields: SMTP_SETTING_FIELDS
}
]
/** 全部推荐键(顺序展开) */
export const ALL_SETTING_FIELDS: SettingFieldMeta[] =
SETTING_FIELD_GROUPS.flatMap((group) => group.fields)
// ---- 归一 / 脏检测 / 提交值 ---------------------------------------------------------
/** 表单态:推荐键 → 字符串输入(密钥输入恒空——「已设置」由占位符表达) */
export type PlatformSettingsForm = Record<string, string>
/** GET items → 表单 + 密钥掩码态 + 原始 items(表格展示) */
export interface PlatformSettingsState {
form: PlatformSettingsForm
/** 密钥键 → GET 掩码态(哨兵 = 库中已设置;'' = 未设置) */
masked: Record<string, string>
items: PlatformSettingItem[]
}
/** 存储值 → 表单串(字符串原样 / 有限数值 String / 缺省空 / 其余 JSON 展示) */
function valueToForm(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (value === null || value === undefined) return ''
try {
return JSON.stringify(value) ?? ''
} catch {
return ''
}
}
/** GET items → 表单态(缺键补空;密钥键输入恒空 + 掩码态单列) */
export function normalizeSettingsItems(
items: PlatformSettingItem[]
): PlatformSettingsState {
const byKey = new Map(items.map((item) => [item.key, item.value]))
const form: PlatformSettingsForm = {}
const masked: Record<string, string> = {}
for (const field of ALL_SETTING_FIELDS) {
const value = byKey.get(field.key)
if (field.secret) {
form[field.key] = ''
masked[field.key] = asSecretMasked(value)
} else {
form[field.key] = valueToForm(value)
}
}
return { form, masked, items }
}
/**
* 字段是否需要提交(脏检测):
* - 密钥键:输入了新值 → 脏;库中已设置(原掩码态哨兵)且显式清除
* (有效掩码态被置 '')→ 脏(提交空串 = 清空);其余不脏不发请求;
* - 普通键:trim 后与归一表单值不同 → 脏(trim 归一:' 465 ' → '465')。
*
* @param field 字段元数据
* @param input 当前输入
* @param effectiveMasked 有效掩码态(清除按钮按下时为 '',否则原掩码态)
* @param originalMasked GET 时的原掩码态(密钥键脏检测用)
* @param originalForm GET 归一出的表单值(普通键脏检测用)
*/
export function isFieldDirty(
field: SettingFieldMeta,
input: string,
effectiveMasked: string,
originalMasked: string,
originalForm: string
): boolean {
if (field.secret) {
if (input.trim() !== '') return true
return originalMasked === MASKED_SENTINEL && effectiveMasked === ''
}
return input.trim() !== originalForm.trim()
}
/**
* 脏字段的提交值:
* - 密钥键走 secretToSubmit(新值 → 新值;显式清除 → 空串;哨兵 → 保持
* 库值——"仅提交脏键"下单键 PUT 通常用不到哨兵分支,保留以对齐凭据
* 页往返契约);
* - int 键:空串 → null(未设置);合法整数 → 数值(后端 json.dumps 还原
* 类型);非法输入被 validateSettingsForm 前置拦截,此处字符串透传兜底;
* - 其余:trim 后字符串。
*/
export function buildSettingValue(
field: SettingFieldMeta,
input: string,
effectiveMasked: string
): unknown {
if (field.secret) {
return secretToSubmit(input.trim(), effectiveMasked)
}
const text = input.trim()
if (field.kind === 'int') {
if (text === '') return null
const n = Number(text)
return Number.isInteger(n) ? n : text
}
return text
}
/** 表单预检(只查非空输入的格式;空槽 = 未设置合法) */
export function validateSettingsForm(
fields: SettingFieldMeta[],
form: PlatformSettingsForm
): string[] {
const errors: string[] = []
for (const field of fields) {
if (field.kind !== 'int') continue
const text = (form[field.key] ?? '').trim()
if (text === '') continue
const n = Number(text)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
errors.push(`${field.label}(${field.key}):须为 1-65535 的整数(留空 = 未设置)`)
}
}
return errors
}
// ---- 存储总览表格展示 ---------------------------------------------------------------
/** 表格展示值:哨兵 / 密钥键非空串 → 脱敏文案;其余标量与 JSON 原样 */
export function formatSettingValue(key: string, value: unknown): string {
if (
value === MASKED_SENTINEL ||
(isSecretSettingKey(key) && typeof value === 'string' && value !== '')
) {
return '已设置(值已脱敏)'
}
if (value === null) return 'null'
if (value === undefined) return '—'
if (typeof value === 'string') return value === '' ? '""' : value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
/** 键是否属于本页推荐键集合(表格区分"可编辑键 / 其他已存键") */
export function isRecommendedKey(key: string): boolean {
return ALL_SETTING_FIELDS.some((field) => field.key === key)
}