2026-10-13 23:29:31 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
平台设置(Phase 3 Task 9,仅超管——菜单 roles 已过滤,后端 require_super 兜底)
|
|
|
|
|
|
|
|
|
|
|
|
PlatformSetting 是自由 KV 表:本页提供推荐键名集合的表单(集团 AI 凭据
|
|
|
|
|
|
ai.* / 默认 SMTP smtp.*——键名是前端约定,不是后端 schema)+ 全量存储
|
|
|
|
|
|
总览表格。保存 = 逐键 PUT(只提交脏键:改了值的普通键 / 输入了新值的
|
|
|
|
|
|
密钥键 / 显式清除的密钥键),与 config 组的"整组替换"不同。
|
|
|
|
|
|
|
|
|
|
|
|
密钥哨兵交互(mask.ts,与凭据页 CredsPane 同款):GET 密钥键值已是
|
|
|
|
|
|
'__SET__' → 输入框留空 + 占位符「已设置,输入新值覆盖」;不动不提交;
|
|
|
|
|
|
「清除」按钮 = 显式清空意图(提交空串),可「恢复保持」撤销。
|
|
|
|
|
|
|
2026-10-14 10:10:52 +08:00
|
|
|
|
回退链已接线(Phase 4 Task 5A):ai.*/smtp.* 推荐键经后端内存快照接入
|
|
|
|
|
|
resolver 的平台默认回退链(租户槽位 → 平台设置 → env),保存后即时生效。
|
2026-10-13 23:29:31 +08:00
|
|
|
|
-->
|
2026-10-13 14:51:24 +08:00
|
|
|
|
<template>
|
2026-10-13 23:29:31 +08:00
|
|
|
|
<div class="settings-page">
|
2026-10-13 14:51:24 +08:00
|
|
|
|
<ElCard shadow="never">
|
|
|
|
|
|
<template #header>
|
2026-10-13 23:29:31 +08:00
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<span class="card-title">平台设置</span>
|
|
|
|
|
|
<span class="card-sub">仅超管可见 · 键值对直存(键名自由,本页推荐 ai.* / smtp.* 两组)</span>
|
|
|
|
|
|
</div>
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</template>
|
2026-10-13 23:29:31 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</ElCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-10-13 23:29:31 +08:00
|
|
|
|
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'
|
|
|
|
|
|
|
2026-10-13 14:51:24 +08:00
|
|
|
|
defineOptions({ name: 'PlatformSettings' })
|
2026-10-13 23:29:31 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 状态 ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
2026-10-13 23:29:31 +08:00
|
|
|
|
.settings-page {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
min-width: 0;
|
2026-10-13 14:51:24 +08:00
|
|
|
|
padding: 16px;
|
2026-10-13 23:29:31 +08:00
|
|
|
|
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);
|
2026-10-13 14:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 23:29:31 +08:00
|
|
|
|
.value-cell {
|
|
|
|
|
|
font-family: var(--art-font-family-code, monospace);
|
|
|
|
|
|
word-break: break-all;
|
2026-10-13 14:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|