feat: 配置中心-版本历史(列表/两版对比/回滚/组级重置出厂)

Task 7d——Phase 3 配置中心四连收尾:

- API(api/config.ts):fetchConfigVersions / fetchConfigVersionDetail /
  rollbackConfig / resetConfigGroup 四函数补齐(骨架端点与类型已就位;
  响应形态对照 config_routes 实测——列表无 config 本体、详情整份+掩码、
  reset 响应与 PUT 分组保存同形)
- VersionsPane:版本 tab 实装(导航占位转正)——列表倒序(版本/时间/
  作者/备注,当前版标记)+ 详情抽屉整份 JSON;勾两版(限两个,第三个
  自动撤销最早勾选行)并排等宽 JSON 对比(spec §7.3 最低形态,范围
  下拉可聚焦单组——wall 20KB+ 场景);回滚确认框明示「将产生新版本,
  历史不变」→ 响应内新快照直接回写页头版本/快照/品牌表单,无需再 GET
- 组级重置出厂:六个已实装编辑卡(品牌/提示词/词表/邮件/推送/AI)头部
  危险色小按钮 → reset.ts 共用确认入口(明示组名与「恢复出厂档案值」)
  → POST config/{group}/reset → 表单/密钥态/版本刷新(RESET_GROUP_
  LABELS 九组镜像后端 CONFIG_GROUPS,含待实装的联系方式/引擎/杂项)
- 测试:vitest 120(+13——API 四函数调用形态 5 条 + model 辅助 8 条);
  后端不动 595;冒烟 jiancai:v1-v10 列表 → v1/v10 并排对比(push 组
  __SET__ 掩码可见)→ 回滚 v1 产 v11 且品牌回 v1 值 → 改占位存 v12 →
  重置品牌产 v13 表单回档案值 → 回滚 v10 复原现场(v14)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 21:39:16 +08:00
parent bf923d9cf7
commit 481243b958
10 changed files with 1166 additions and 16 deletions
+126 -1
View File
@@ -1,6 +1,6 @@
/**
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石;
* Task 7c——凭据测试端点)
* Task 7c——凭据测试端点;Task 7d——版本历史/回滚/组级重置)
*
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
@@ -13,6 +13,12 @@
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
* 数据)——两者 showErrorMessage: false 内联展示
* - GET versions / versions/{n}(Task 7d):列表不含 config 本体;详情含
* 整份快照(密钥 '__SET__' 掩码视图)
* - POST rollback(Task 7d):body 只带 {version}——目标版本复制为新版本
* (历史不可变),响应含新版本号 + 新快照
* - POST config/{group}/reset(Task 7d):无请求体(后端 reset 不读
* body),响应与 PUT 分组保存同形
* - 头像上传:multipart file 字段(FormData)
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
*/
@@ -25,9 +31,13 @@ vi.mock('@/api/client', () => ({
import { http } from '@/api/client'
import {
CONFIG_ENDPOINTS,
fetchConfigVersionDetail,
fetchConfigVersions,
fetchTenantConfig,
normalizeBrandGroup,
putConfigGroup,
resetConfigGroup,
rollbackConfig,
testEmail,
testPush,
testWall,
@@ -263,6 +273,121 @@ describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () =>
})
})
describe('版本历史 / 回滚 / 重置 API(Task 7d · 事实源 config_routes)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchConfigVersions:GET versions 列表(升序元数据,不含 config 本体)', async () => {
const response = {
slug: 'jiancai',
items: [
{
version: 1,
created_at: '2026-10-12T09:00:00',
updated_by: 'super#1',
comment: '创建向导初始版本'
},
{
version: 2,
created_at: '2026-10-13T10:30:00',
updated_by: 'tenant#3',
comment: '改主题色'
}
]
}
vi.mocked(http.get).mockResolvedValue(response)
const res = await fetchConfigVersions('jiancai', { showErrorMessage: false })
expect(res).toEqual(response)
expect(res.items[0]).not.toHaveProperty('config') // 列表行绝无 config 本体
expect(http.get).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/config/versions',
{ showErrorMessage: false }
)
})
it('fetchConfigVersionDetail:GET versions/{n} 详情(整份快照 + 掩码)', async () => {
const response = {
slug: 'jiancai',
version: 2,
created_at: '2026-10-13T10:30:00',
updated_by: 'tenant#3',
comment: '改主题色',
config: {
brand: { title: 'HESUPIPE Support' },
push: { api_key: '__SET__' }
}
}
vi.mocked(http.get).mockResolvedValue(response)
const res = await fetchConfigVersionDetail('jiancai', 2)
expect(res).toEqual(response)
expect(http.get).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/config/versions/2',
undefined
)
})
it('rollbackConfig:POST rollback,body 只带 {version}(note 不被后端读取)', async () => {
const response = {
slug: 'jiancai',
rollback_to: 2,
version: 11,
config: { brand: { title: 'HESUPIPE Support' } }
}
vi.mocked(http.post).mockResolvedValue(response)
const res = await rollbackConfig('jiancai', { version: 2 }, {
showErrorMessage: false
})
expect(res).toEqual(response)
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/config/rollback',
{ version: 2 }, // 回滚请求体只认 version——多余字段是死代码
{ showErrorMessage: false }
)
})
it('resetConfigGroup:POST config/{group}/reset,无请求体(后端不读 body)', async () => {
const response = {
slug: 'jiancai',
group: 'brand',
version: 12,
config: { title: 'AI Support', color: '#066fc0' }
}
vi.mocked(http.post).mockResolvedValue(response)
const res = await resetConfigGroup('jiancai', 'brand', { showErrorMessage: false })
expect(res).toEqual(response) // 响应与 PUT 分组保存同形(新版本 + 组掩码视图)
expect(http.post).toHaveBeenCalledTimes(1)
const [url, body, config] = vi.mocked(http.post).mock.calls[0]
expect(url).toBe('/api/platform/tenants/jiancai/config/brand/reset')
expect(body).toBeUndefined()
expect(config).toEqual({ showErrorMessage: false })
})
it('CONFIG_ENDPOINTS.versions / versionDetail / rollback / resetGroup:路径与后端一致', () => {
expect(CONFIG_ENDPOINTS.versions('jiancai')).toBe(
'/api/platform/tenants/jiancai/config/versions'
)
expect(CONFIG_ENDPOINTS.versionDetail('jiancai', 7)).toBe(
'/api/platform/tenants/jiancai/config/versions/7'
)
expect(CONFIG_ENDPOINTS.rollback('jiancai')).toBe(
'/api/platform/tenants/jiancai/config/rollback'
)
expect(CONFIG_ENDPOINTS.resetGroup('jiancai', 'push')).toBe(
'/api/platform/tenants/jiancai/config/push/reset'
)
})
})
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
+86
View File
@@ -33,6 +33,20 @@
* - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索
* 数据**(后端发空 messages 批量);不可达时 reason 分类
* dns/timeout/connect,auth_ok 三态(true/false/null=不定)。
*
* ## Task 7d 版本历史 / 回滚 / 重置契约(config_routes)
*
* - GET versions:升序 items(version/created_at/updated_by/comment),
* **不含 config 本体**(列表几十 KB × N 版本会白白撑爆响应);展示侧
* 自行倒序(最新在前);
* - GET versions/{n}:含整份快照(掩码视图——密钥字段 '__SET__',历史行
* 本身永远存原值);created_at 是 naive isoformat(无时区后缀);
* - POST rollback {version}:目标版本整份复制为新版本(历史不可变),
* 响应 {rollback_to, version(新), config(新快照掩码视图)};
* - POST config/{group}/reset:该组恢复出厂档案值(代码库当前模板,非
* 创建时快照),响应形态与 PUT 相同({group, version, config});
* 合法组 = config_validation.CONFIG_GROUPS(RESET_GROUP_LABELS 镜像);
* push 组重置可能因出厂 prefix 被其他租户占用而 400(跨租户查重)。
*/
import { http, type RequestOptions } from './client'
@@ -78,6 +92,12 @@ export interface PutConfigGroupResponse {
config: Record<string, unknown>
}
/**
* 组级重置响应(POST .../config/{group}/reset):与 PUT 分组保存同形
* (新版本号 + 该组出厂值的掩码视图——密钥字段 '__SET__')。
*/
export type ResetConfigGroupResponse = PutConfigGroupResponse
/** 配置版本行(GET .../config/versions;字段名与后端一致) */
export interface ConfigVersionItem {
version: number
@@ -217,6 +237,72 @@ export function putConfigGroup(
)
}
/**
* 版本列表(GET .../config/versions):升序 items(版本/时间/作者/备注),
* 不含 config 本体。展示侧自行倒序(最新在前)——后端列表只回元数据,
* 详情走 fetchConfigVersionDetail 按需取整份。
*/
export function fetchConfigVersions(
slug: string,
config?: RequestOptions
): Promise<ConfigVersionListResponse> {
return http.get<ConfigVersionListResponse>(
CONFIG_ENDPOINTS.versions(slug),
config
)
}
/**
* 单版本详情(GET .../config/versions/{n}):含整份快照(密钥字段为
* '__SET__' 掩码视图——历史行存原值但绝不回传)。版本不存在 → 404。
*/
export function fetchConfigVersionDetail(
slug: string,
version: number,
config?: RequestOptions
): Promise<ConfigVersionDetailResponse> {
return http.get<ConfigVersionDetailResponse>(
CONFIG_ENDPOINTS.versionDetail(slug, version),
config
)
}
/**
* 回滚(POST .../config/rollback):目标版本整份复制为新版本(历史不可变
* ——旧版本行永不改写),reload 热生效。响应含新版本号 + 新快照掩码视图
* (调用方据此回写当前配置态,无需再 GET config)。
*/
export function rollbackConfig(
slug: string,
body: ConfigRollbackRequest,
config?: RequestOptions
): Promise<ConfigRollbackResponse> {
return http.post<ConfigRollbackResponse>(
CONFIG_ENDPOINTS.rollback(slug),
body,
config
)
}
/**
* 组级重置出厂(POST .../config/{group}/reset,无请求体):该组恢复出厂
* 档案值(代码库当前模板,随平台升级演进——非创建时快照),产新版本 +
* reload 热生效。合法组见 model.ts 的 RESET_GROUP_LABELS(后端
* CONFIG_GROUPS 镜像,未知组 400);push 组重置可能因出厂 prefix 被其他
* 租户占用而 400(跨租户查重)——调用方 showErrorMessage: false 内联展示。
*/
export function resetConfigGroup(
slug: string,
group: ConfigGroupKey | string,
config?: RequestOptions
): Promise<ResetConfigGroupResponse> {
return http.post<ResetConfigGroupResponse>(
CONFIG_ENDPOINTS.resetGroup(slug, group),
undefined,
config
)
}
/**
* 词表试金石(POST .../wall/test):逐句墙判定预览。传 wall 覆盖 =
* 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动
+96 -3
View File
@@ -13,6 +13,9 @@
- 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据)
——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败
(401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定
- 重置出厂(Task 7d):三卡头部危险色小按钮 → 确认框 → POST
config/{group}/reset → 表单/密钥态回档案值 + emit saved;push 出厂
前缀被其他租户占用的 400 detail 内联展示
-->
<template>
<div class="creds-pane">
@@ -21,7 +24,18 @@
<template #header>
<div class="card-header">
<span class="card-title">邮件(email 组 · SMTP 发信)</span>
<span class="card-sub">空槽 = 未设置 → 回退平台环境默认;保存即写新版本并热生效</span>
<div class="header-side">
<span class="card-sub">空槽 = 未设置 → 回退平台环境默认;保存即写新版本并热生效</span>
<ElButton
size="small"
type="danger"
plain
:loading="resettingEmail"
@click="onResetGroup('email')"
>
重置出厂
</ElButton>
</div>
</div>
</template>
@@ -141,7 +155,18 @@
<template #header>
<div class="card-header">
<span class="card-title">推送(push 组 · 线索推送 UniService)</span>
<span class="card-sub">数值留空 = 平台默认;前缀全局唯一(跨租户查重)</span>
<div class="header-side">
<span class="card-sub">数值留空 = 平台默认;前缀全局唯一(跨租户查重)</span>
<ElButton
size="small"
type="danger"
plain
:loading="resettingPush"
@click="onResetGroup('push')"
>
重置出厂
</ElButton>
</div>
</div>
</template>
@@ -286,7 +311,18 @@
<template #header>
<div class="card-header">
<span class="card-title">AI(ai 组 · 模型服务)</span>
<span class="card-sub">密钥空槽 = 未设置 → 回退平台集团凭据(spec §5.5)</span>
<div class="header-side">
<span class="card-sub">密钥空槽 = 未设置 → 回退平台集团凭据(spec §5.5)</span>
<ElButton
size="small"
type="danger"
plain
:loading="resettingAi"
@click="onResetGroup('ai')"
>
重置出厂
</ElButton>
</div>
</div>
</template>
@@ -363,6 +399,7 @@
<script setup lang="ts">
import {
PUSH_PROBE_REASON_LABELS,
RESET_GROUP_LABELS,
buildAiCredsPayload,
buildEmailCredsPayload,
buildPushCredsPayload,
@@ -377,6 +414,7 @@
} from './model'
import { putConfigGroup, testEmail, testPush, type PushTestResponse } from '@/api/config'
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'CredsPane' })
@@ -587,6 +625,53 @@
}
}
// ---- 重置出厂(Task 7d:确认 → POST config/{group}/reset → 表单/密钥态
// 回档案值 + emit saved;出厂凭据多为空槽——normalize 后掩码态自然归空) ---------------
const resettingEmail = ref(false)
const resettingPush = ref(false)
const resettingAi = ref(false)
async function onResetGroup(group: 'email' | 'push' | 'ai'): Promise<void> {
const resetting =
group === 'email' ? resettingEmail
: group === 'push' ? resettingPush
: resettingAi
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, group)
if (!res) return // 用户取消确认
if (group === 'email') {
const state = normalizeEmailCredsGroup(res.config)
Object.assign(emailForm, state.form)
emailPasswordMasked.value = state.passwordMasked
emailPasswordCleared.value = false
} else if (group === 'push') {
const state = normalizePushCredsGroup(res.config)
Object.assign(pushForm, state.form)
pushApiKeyMasked.value = state.apiKeyMasked
pushApiKeyCleared.value = false
} else {
const state = normalizeAiCredsGroup(res.config)
Object.assign(aiForm, state.form)
aiApiKeyMasked.value = state.apiKeyMasked
aiApiKeyCleared.value = false
}
ElMessage.success(
`${RESET_GROUP_LABELS[group]}已重置出厂(版本 v${res.version},热生效)`
)
emit('saved', { version: res.version, group, config: res.config })
} catch (error) {
// 出厂档案缺失 / push 出厂前缀被其他租户占用(跨租户查重)的 400 detail
const errorRef =
group === 'email' ? emailError : group === 'push' ? pushError : aiError
errorRef.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
// ---- SMTP 测试邮件(email/test;用已保存配置真发一封) --------------------------------
async function onTestEmail(): Promise<void> {
@@ -671,6 +756,14 @@
font-size: 12px;
color: var(--art-gray-400);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
.pane-error {
+43 -1
View File
@@ -6,6 +6,8 @@
company_facts 50KB / 其余 100KB;超限红显 + 禁存(后端同样硬拒)
- system.md 编辑器旁固定 $DEFLECTION_POOL 说明卡:该占位符由安全词表
迂回话术池自动注入,勿在提示词里手写话术清单(话术单一事实源)
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/prompts/reset
→ emit saved 回写快照组 → watch 重置表单为出厂档案值
- 400 校验 detail(带字段路径)内联展示在保存按钮上方
-->
<template>
@@ -13,7 +15,18 @@
<template #header>
<div class="card-header">
<span class="card-title">提示词(prompts 组 · 四件套整组替换)</span>
<span class="card-sub">保存即写新版本并热生效——备注必填(AI 行为源头)</span>
<div class="header-side">
<span class="card-sub">保存即写新版本并热生效——备注必填(AI 行为源头)</span>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂
</ElButton>
</div>
</div>
</template>
@@ -111,6 +124,7 @@
type PromptsDraft
} from './model'
import { putConfigGroup } from '@/api/config'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'PromptsPane' })
@@ -190,6 +204,26 @@
saving.value = false
}
}
// ---- 重置出厂(Task 7d:确认 → POST reset → emit saved 回写快照组 →
// group 引用变化触发上方 watch 重置表单为出厂档案值) -------------------------------
const resetting = ref(false)
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, 'prompts')
if (!res) return // 用户取消确认
ElMessage.success(`提示词已重置出厂(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'prompts', config: res.config })
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
@@ -213,6 +247,14 @@
font-size: 12px;
color: var(--art-gray-400);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
.pane-error {
@@ -0,0 +1,486 @@
<!--
版本历史面板(Phase 3 Task 7d):列表 / 两版对比 / 回滚
- 列表:GET config/versions(升序元数据,不含 config 本体)→ 倒序展示
(版本/时间/作者/备注);行操作「详情」(抽屉整份 JSON)与「回滚」
- 两版对比:列表勾选两行(限两个——勾第三个自动撤销最早一行)→ 双版本
详情并排等宽 JSON 面板(spec §7.3 最低形态:纯并排展示,不做差异
高亮);范围下拉可聚焦单组(wall 组 20KB+ 时按组对比更可用);
密钥字段显示为 __SET__(后端对所有携带 config 的响应统一掩码)
- 回滚:确认框明示「将产生新版本,历史不变」→ POST rollback {version}
→ 刷新本列表 + emit changed(父层用响应内的新快照回写版本号与配置态,
无需再 GET config)
-->
<template>
<ElCard shadow="never" class="versions-pane">
<template #header>
<div class="card-header">
<span class="card-title">版本历史(versions · 只增不改)</span>
<span class="card-sub">回滚 / 重置 / 保存都产生新版本——历史版本永不改写</span>
</div>
</template>
<ElAlert
v-if="actionError"
type="error"
:closable="true"
:title="actionError"
class="pane-error"
@close="actionError = ''"
/>
<ElAlert
v-if="listError"
type="error"
:closable="false"
:title="listError"
class="pane-error"
>
<ElButton size="small" :loading="listLoading" @click="loadVersions">重试</ElButton>
</ElAlert>
<ElTable
ref="tableRef"
v-loading="listLoading"
:data="items"
size="small"
empty-text="暂无版本历史"
@selection-change="onSelectionChange"
>
<ElTableColumn type="selection" width="42" />
<ElTableColumn label="版本" width="120">
<template #default="{ row }">
<span class="mono">v{{ row.version }}</span>
<ElTag
v-if="row.version === currentVersion"
size="small"
type="success"
class="cur-tag"
>
当前
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="时间" width="160">
<template #default="{ row }">{{ formatVersionTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn prop="updated_by" label="作者" width="120" />
<ElTableColumn prop="comment" label="备注" min-width="180" show-overflow-tooltip />
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="{ row }">
<ElButton link size="small" @click="openDetail(row)">详情</ElButton>
<ElButton
link
type="danger"
size="small"
:loading="rollbackVersion === row.version"
@click="onRollback(row)"
>
回滚
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<!-- 两版对比(纯并排 JSON,spec §7.3 最低形态) -->
<div class="compare-section">
<div class="compare-head">
<span class="compare-title">两版对比</span>
<ElSelect v-model="compareScope" size="small" class="scope-select">
<ElOption :value="COMPARE_SCOPE_ALL" label="整份快照" />
<ElOption
v-for="key in scopeKeys"
:key="key"
:value="key"
:label="`仅 ${key} 组`"
/>
</ElSelect>
<span class="compare-hint">
勾选上表两行(限两个);密钥字段显示为 __SET__(后端已掩码)
</span>
</div>
<ElAlert
v-if="compareError"
type="error"
:closable="false"
:title="compareError"
class="pane-error"
/>
<div v-else-if="selected.length === 2" v-loading="compareLoading" class="compare-panels">
<div v-for="side in compareSides" :key="side.version" class="compare-panel">
<div class="panel-head">
<span class="mono">v{{ side.version }}</span>
<span class="panel-time">{{ formatVersionTime(side.created_at) }}</span>
<span class="panel-by">{{ side.updated_by }}</span>
</div>
<pre class="json-panel">{{ scopedConfigJson(side.config, compareScope) }}</pre>
</div>
</div>
<p v-else class="compare-tip">
在上方列表勾选两个版本后,这里并排展示两版配置 JSON(等宽字体)。
</p>
</div>
<!-- 单版详情抽屉(整份快照 JSON,密钥已掩码) -->
<ElDrawer v-model="drawerVisible" size="46%" :title="drawerTitle">
<div v-loading="detailLoading" class="drawer-body">
<ElAlert
v-if="detailError"
type="error"
:closable="false"
:title="detailError"
class="pane-error"
/>
<template v-else-if="detail">
<p class="detail-meta">
<span class="mono">v{{ detail.version }}</span> ·
{{ formatVersionTime(detail.created_at) }} · {{ detail.updated_by
}}<template v-if="detail.comment"> · {{ detail.comment }}</template>
</p>
<pre class="json-panel">{{ JSON.stringify(detail.config, null, 2) }}</pre>
</template>
</div>
</ElDrawer>
</ElCard>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import {
fetchConfigVersionDetail,
fetchConfigVersions,
rollbackConfig,
type ConfigVersionDetailResponse,
type ConfigVersionItem
} from '@/api/config'
import {
COMPARE_SCOPE_ALL,
descendingVersions,
formatVersionTime,
scopedConfigJson,
snapshotGroupKeys
} from './model'
defineOptions({ name: 'VersionsPane' })
const props = defineProps<{
/** 租户 slug(版本列表 / 详情 / 回滚目标) */
slug: string
/** 当前生效版本号(父层 version 态——列表「当前」标记;回滚后父层回写) */
currentVersion: number | null
}>()
const emit = defineEmits<{
/** 回滚成功:父层用新版本号 + 新快照回写配置态(含各 pane 的组值) */
(e: 'changed', payload: {
version: number
config: Record<string, Record<string, unknown>>
}): void
}>()
// ---- 版本列表(升序取回 → 倒序展示) ------------------------------------------------
const items = ref<ConfigVersionItem[]>([])
const listLoading = ref(false)
const listError = ref('')
const actionError = ref('')
/** 加载世代(slug 切换时丢弃在途响应) */
const loadEpoch = ref(0)
async function loadVersions(): Promise<void> {
const slug = props.slug
const epoch = ++loadEpoch.value
listLoading.value = true
listError.value = ''
actionError.value = ''
try {
const res = await fetchConfigVersions(slug, { showErrorMessage: false })
if (epoch !== loadEpoch.value || slug !== props.slug) return
items.value = descendingVersions(res.items)
clearCompare() // 列表刷新后勾选/对比态一律作废(行对象已换)
} catch (error) {
if (epoch !== loadEpoch.value) return
items.value = []
listError.value = error instanceof Error ? error.message : '版本列表加载失败'
} finally {
if (epoch === loadEpoch.value) listLoading.value = false
}
}
// ---- 两版对比(勾选限两个——第三个自动撤销最早勾选行) -------------------------------
const tableRef = ref<TableInstance | null>(null)
const selected = ref<ConfigVersionItem[]>([])
const compareSides = ref<ConfigVersionDetailResponse[]>([])
const compareLoading = ref(false)
const compareError = ref('')
const compareScope = ref<string>(COMPARE_SCOPE_ALL)
/** 对比范围选项:两版快照顶层键的并集(已勾选详情到达后稳定) */
const scopeKeys = computed(() => {
const keys = new Set<string>()
for (const side of compareSides.value) {
for (const key of snapshotGroupKeys(side.config)) keys.add(key)
}
return [...keys]
})
function onSelectionChange(rows: ConfigVersionItem[]): void {
if (rows.length > 2) {
// 勾满两个后再勾:撤销最早勾选的一行(selection 数组按勾选序追加)
tableRef.value?.toggleRowSelection(rows[0], false)
return
}
selected.value = rows
void loadCompare()
}
function clearCompare(): void {
selected.value = []
compareSides.value = []
compareError.value = ''
}
async function loadCompare(): Promise<void> {
if (selected.value.length !== 2) {
compareSides.value = []
compareError.value = ''
return
}
const slug = props.slug
const [a, b] = [...selected.value].sort((x, y) => x.version - y.version)
compareLoading.value = true
compareError.value = ''
try {
// 左 = 旧版(version 小),右 = 新版;任一失败整组不作废另一侧提示
const [left, right] = await Promise.all([
fetchConfigVersionDetail(slug, a.version, { showErrorMessage: false }),
fetchConfigVersionDetail(slug, b.version, { showErrorMessage: false })
])
if (slug !== props.slug) return // 加载期间已切换租户——丢弃
compareSides.value = [left, right]
} catch (error) {
compareSides.value = []
compareError.value = error instanceof Error ? error.message : '版本详情加载失败'
} finally {
compareLoading.value = false
}
}
// ---- 单版详情抽屉 -------------------------------------------------------------------
const drawerVisible = ref(false)
const detail = ref<ConfigVersionDetailResponse | null>(null)
const detailLoading = ref(false)
const detailError = ref('')
const drawerTitle = computed(() =>
detail.value ? `版本 v${detail.value.version} 详情` : '版本详情'
)
async function openDetail(row: ConfigVersionItem): Promise<void> {
drawerVisible.value = true
detail.value = null
detailError.value = ''
detailLoading.value = true
try {
detail.value = await fetchConfigVersionDetail(props.slug, row.version, {
showErrorMessage: false
})
} catch (error) {
detailError.value = error instanceof Error ? error.message : '版本详情加载失败'
} finally {
detailLoading.value = false
}
}
// ---- 回滚(目标版本复制为新版本——历史不可变) ----------------------------------------
const rollbackVersion = ref<number | null>(null)
async function onRollback(row: ConfigVersionItem): Promise<void> {
if (rollbackVersion.value !== null) return
try {
await ElMessageBox.confirm(
`将把配置整体回滚到版本 v${row.version}(全部组恢复为该版快照)。` +
'回滚将产生一个新版本并立即热生效——历史版本保持不变,可再次回滚。',
`回滚到 v${row.version}`,
{
type: 'warning',
confirmButtonText: `回滚到 v${row.version}`,
cancelButtonText: '取消'
}
)
} catch {
return // 用户取消
}
rollbackVersion.value = row.version
actionError.value = ''
try {
const res = await rollbackConfig(
props.slug,
{ version: row.version },
{ showErrorMessage: false }
)
ElMessage.success(`已回滚到 v${row.version}(新版本 v${res.version},热生效)`)
// 响应自带新快照掩码视图——父层直接回写,无需再 GET config
emit('changed', { version: res.version, config: res.config })
await loadVersions()
} catch (error) {
actionError.value = error instanceof Error ? error.message : '回滚失败'
} finally {
rollbackVersion.value = null
}
}
// ---- 装配(slug 切换 → 重置列表/抽屉/对比态并重载;immediate 触发首载) ---------------
// 注:watch immediate 在 setup 期同步跑,回调触达的状态(drawerVisible 等)
// 必须先声明——故装配放脚本末尾(WallPane 同款纪律)。
watch(
() => props.slug,
(slug) => {
if (!slug) return
items.value = []
drawerVisible.value = false
loadVersions()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.versions-pane {
flex: 1;
min-width: 0;
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 {
width: 100%;
margin-bottom: 12px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.cur-tag {
margin-left: 6px;
}
// ---- 两版对比 -----------------------------------------------------------------------
.compare-section {
margin-top: 16px;
}
.compare-head {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
.compare-title {
font-size: 14px;
font-weight: 600;
}
.scope-select {
width: 160px;
}
.compare-hint {
font-size: 12px;
color: var(--art-gray-400);
}
}
.compare-panels {
display: flex;
gap: 12px;
align-items: stretch;
min-height: 120px;
}
.compare-panel {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
.panel-head {
display: flex;
gap: 8px;
align-items: baseline;
margin-bottom: 6px;
.mono {
font-weight: 600;
}
.panel-time {
font-size: 12px;
color: var(--art-gray-500);
}
.panel-by {
font-size: 12px;
color: var(--art-gray-400);
}
}
}
.json-panel {
flex: 1;
min-width: 0;
margin: 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: var(--art-main-bg-color, var(--el-fill-color-lighter));
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.compare-tip {
margin: 0;
padding: 14px;
border: 1px dashed var(--el-border-color-lighter);
border-radius: 6px;
font-size: 13px;
color: var(--art-gray-400);
}
// ---- 详情抽屉 -----------------------------------------------------------------------
.drawer-body {
display: flex;
flex-direction: column;
min-height: 200px;
}
.detail-meta {
margin: 0 0 10px;
font-size: 13px;
color: var(--art-gray-500);
}
</style>
+43 -1
View File
@@ -11,6 +11,8 @@
- 保存链:保存词表前自动先跑一次试金石(当前表单值预演)——有
blocked/replaced 时二次确认「判定符合预期再保存」;PUT config/wall
整组提交;正则编译错误 400 的字段路径展示在内联错误区;版本 +1 提示
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/wall/reset
→ emit saved 回写快照组 → watch 重建编辑模型 = 出厂档案墙
-->
<template>
<div class="wall-pane">
@@ -18,7 +20,18 @@
<template #header>
<div class="card-header">
<span class="card-title">安全词表(wall 组 · 整组替换)</span>
<span class="card-sub">保存即写新版本并热生效;保存前自动跑试金石</span>
<div class="header-side">
<span class="card-sub">保存即写新版本并热生效;保存前自动跑试金石</span>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂
</ElButton>
</div>
</div>
</template>
@@ -332,6 +345,7 @@
type PoolField,
type WallEditModel
} from './model'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'WallPane' })
@@ -643,6 +657,26 @@
saving.value = false
}
}
// ---- 重置出厂(Task 7d:确认 → POST reset → emit saved 回写快照组 →
// group 引用变化触发上方 watch 重建编辑模型 = 出厂档案墙) --------------------------
const resetting = ref(false)
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, 'wall')
if (!res) return // 用户取消确认
ElMessage.success(`词表已重置出厂(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'wall', config: res.config })
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
@@ -673,6 +707,14 @@
font-size: 12px;
color: var(--art-gray-400);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
}
@@ -25,6 +25,7 @@ import {
PROMPTS_KEYS,
PUSH_PROBE_REASON_LABELS,
REMINDER_VARIANTS,
RESET_GROUP_LABELS,
TRIGGER_KEYS,
ZERO_NUMBER_KEYS,
asSecretMasked,
@@ -32,7 +33,10 @@ import {
buildEmailCredsPayload,
buildPushCredsPayload,
buildWallPayload,
COMPARE_SCOPE_ALL,
descendingVersions,
formatKB,
formatVersionTime,
initWallEdit,
normalizeAiCredsGroup,
normalizeEmailCredsGroup,
@@ -44,6 +48,8 @@ import {
rowToEntry,
rowsToEntries,
rowToSingleEntry,
scopedConfigJson,
snapshotGroupKeys,
touchstoneSentences,
utf8Bytes,
validateEmailCredsForm,
@@ -624,3 +630,78 @@ describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () =>
expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝')
})
})
// ---- 版本历史 / 两版对比 / 组级重置(Phase 3 Task 7d) -------------------------------
describe('RESET_GROUP_LABELS(后端 CONFIG_GROUPS 九组镜像)', () => {
it('覆盖全部可重置组(config_validation.CONFIG_GROUPS 同构)', () => {
expect(Object.keys(RESET_GROUP_LABELS).sort()).toEqual([
'ai',
'brand',
'contact',
'email',
'engine',
'misc',
'prompts',
'push',
'wall'
])
})
it('每组有非空中文标签(确认框明示组名用)', () => {
for (const label of Object.values(RESET_GROUP_LABELS)) {
expect(label.trim()).not.toBe('')
}
})
})
describe('formatVersionTime(naive isoformat → 展示串)', () => {
it('T 换空格、截到秒(不做 Date 解析——避免时区偏移)', () => {
expect(formatVersionTime('2026-10-13T10:30:05.123456')).toBe('2026-10-13 10:30:05')
expect(formatVersionTime('2026-10-12T09:00:00')).toBe('2026-10-12 09:00:00')
})
})
describe('descendingVersions(升序取回 → 最新在前)', () => {
it('按版本号倒序;不改动入参数组(后端返回值保持原序)', () => {
const items = [
{ version: 1, comment: 'a' },
{ version: 3, comment: 'c' },
{ version: 2, comment: 'b' }
]
const out = descendingVersions(items)
expect(out.map((i) => i.version)).toEqual([3, 2, 1])
expect(items.map((i) => i.version)).toEqual([1, 3, 2]) // 入参不动
})
})
describe('scopedConfigJson / snapshotGroupKeys(两版对比的范围视图)', () => {
const snapshot = {
brand: { title: 'HESUPIPE Support', color: '#066fc0' },
push: { api_key: '__SET__', prefix: 'jc' }
}
it('整份范围:顶层全组、两空格缩进 JSON(掩码哨兵原样呈现)', () => {
const json = scopedConfigJson(snapshot, COMPARE_SCOPE_ALL)
expect(json).toContain('"brand"')
expect(json).toContain('"push"')
expect(json).toContain('"__SET__"')
expect(JSON.parse(json)).toEqual(snapshot)
})
it('单组范围:只留该组键(wall 等大组按组聚焦)', () => {
const json = scopedConfigJson(snapshot, 'brand')
expect(JSON.parse(json)).toEqual({ brand: snapshot.brand })
})
it('缺组/空快照防御:快照无该组 → 空对象;空快照 → 空串', () => {
expect(scopedConfigJson(snapshot, 'misc')).toBe('{}')
expect(scopedConfigJson(null, COMPARE_SCOPE_ALL)).toBe('')
expect(scopedConfigJson(undefined, 'brand')).toBe('')
})
it('snapshotGroupKeys:顶层组键排序稳定;空值防御', () => {
expect(snapshotGroupKeys(snapshot)).toEqual(['brand', 'push'])
expect(snapshotGroupKeys(null)).toEqual([])
})
})
+95 -7
View File
@@ -1,7 +1,7 @@
<!--
配置中心(Phase 3 Task 7a:品牌与嵌入代码;Task 7b:提示词四件套与
安全词表试金石;Task 7c:凭据表单与测试端点;后续任务补版本)
- 页骨架:左侧组导航(品牌/提示词/安全词表/凭据实装;版本占位)
安全词表试金石;Task 7c:凭据表单与测试端点;Task 7d:版本历史与重置)
- 页骨架:左侧组导航(品牌/提示词/安全词表/凭据/版本实装)
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
@@ -18,6 +18,14 @@
交互——不动保存回传 '__SET__' 保持库值,「清除」= 提交空串);数值
前端预检 + 后端 400 双层;SMTP 测试邮件(email/test 真发一封)与
推送连通性探测(push/test 空批量,零线索数据,三态展示)
- 版本历史(Task 7d,VersionsPane):GET versions 列表(倒序展示)→
详情抽屉整份 JSON(密钥 '__SET__' 掩码);勾两版并排对比(spec §7.3
最低形态,范围可聚焦单组);回滚确认(明示产生新版本/历史不变)→
POST rollback → 响应内新快照直接回写本页配置态
- 组级重置出厂(Task 7d):各编辑卡头部危险色小按钮(品牌在本页;
提示词/词表/凭据三组在各 pane)→ 确认框明示组名与「恢复出厂档案值」
→ POST config/{group}/reset → 该组表单/版本刷新(合法组清单 =
后端 CONFIG_GROUPS 九组,reset.ts 的 RESET_GROUP_LABELS 镜像)
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
@@ -83,7 +91,7 @@
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
</ElMenuItem>
</ElMenu>
<p class="nav-tip">版本历史在后续任务实装(当前为占位)。</p>
<p class="nav-tip">版本只增不改——回滚/重置都产生新版本,历史可随时回看。</p>
</ElCard>
<!-- 右侧内容区 -->
@@ -106,7 +114,18 @@
<template #header>
<div class="card-header">
<span class="card-title">品牌(brand 组 · 整组替换语义)</span>
<span class="card-sub">保存即写新版本并热生效</span>
<div class="header-side">
<span class="card-sub">保存即写新版本并热生效</span>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onResetBrand"
>
重置出厂
</ElButton>
</div>
</div>
</template>
@@ -424,6 +443,18 @@
</div>
</template>
<!-- 版本 pane(Task 7d) -->
<template v-else-if="activeGroup === 'versions'">
<div class="group-pane">
<VersionsPane
v-if="activeSlug"
:slug="activeSlug"
:current-version="version"
@changed="onVersionsChanged"
/>
</div>
</template>
<!-- 其余组占位 -->
<ElCard v-else shadow="never" class="placeholder-card">
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
@@ -446,6 +477,8 @@
import PromptsPane from './PromptsPane.vue'
import WallPane from './WallPane.vue'
import CredsPane from './CredsPane.vue'
import VersionsPane from './VersionsPane.vue'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'ConfigCenter' })
@@ -456,7 +489,7 @@
const AVATAR_MAX_BYTES = 2 * 1024 * 1024
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表 / Task 7c 凭据实装;版本占位) ------
// ---- 左侧组导航(Task 7a 品牌 / 7b 提示词+词表 / 7c 凭据 / 7d 版本实装) ----------------
const GROUP_NAV = [
{ key: 'brand', label: '品牌' },
@@ -467,7 +500,13 @@
] as const
/** 已实装组(导航徽标) */
const IMPLEMENTED_GROUPS = new Set<GroupKey>(['brand', 'prompts', 'wall', 'creds'])
const IMPLEMENTED_GROUPS = new Set<GroupKey>([
'brand',
'prompts',
'wall',
'creds',
'versions'
])
type GroupKey = (typeof GROUP_NAV)[number]['key']
@@ -557,7 +596,8 @@
}
}
/** pane(提示词/词表)保存成功:回写版本号与快照组(pane 自持编辑态不重置) */
/** pane(提示词/词表/凭据)保存或重置成功:回写版本号与快照组(组引用
* 变化触发对应 pane 的 watch 重置表单——重置出厂即经此路径刷新表单) */
function onPaneSaved(payload: {
version: number
group: string
@@ -572,6 +612,46 @@
}
}
/** 版本 pane 回滚成功:响应自带新版本整份快照(掩码视图)——直接回写
* 版本号 + 快照 + 品牌表单(各 pane 组值经快照引用变化联动重置) */
function onVersionsChanged(payload: {
version: number
config: Record<string, Record<string, unknown>>
}): void {
version.value = payload.version
snapshot.value = payload.config
Object.assign(form, normalizeBrandGroup(payload.config.brand))
avatarImgFailed.value = false
}
// ---- 品牌组重置出厂(Task 7d:确认框 → POST config/brand/reset → 表单回档案值) -------
const resetting = ref(false)
async function onResetBrand(): Promise<void> {
if (!activeSlug.value || resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(activeSlug.value, 'brand')
if (!res) return // 用户取消确认
if (res.slug === activeSlug.value) {
// 重置期间未切换租户——回写版本/快照组/表单(出厂档案值)
version.value = res.version
if (snapshot.value) {
snapshot.value = { ...snapshot.value, brand: res.config }
}
Object.assign(form, normalizeBrandGroup(res.config))
avatarImgFailed.value = false
}
ElMessage.success(`品牌已重置出厂(版本 v${res.version},热生效)`)
} catch (error) {
// 出厂档案缺失等 400 detail 以 toast 呈现(表单级错误区留给加载失败)
ElMessage.error(error instanceof Error ? error.message : '重置失败')
} finally {
resetting.value = false
}
}
function reloadConfig(): void {
loadConfig(++configEpoch.value)
}
@@ -902,6 +982,14 @@
font-size: 12px;
color: var(--art-gray-400);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
}
+65 -3
View File
@@ -1,8 +1,9 @@
/**
* 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c)
* 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c;Task 7d 补
* 版本历史展示辅助)
*
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue。
* 纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue /
* VersionsPane.vue。纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
*
* 事实源对齐:
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
@@ -23,6 +24,7 @@
* 不动保存回传哨兵保持库值;「清除」= 提交空串)。
*/
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
import type { ConfigGroupKey } from '@/api/config'
// ---- 提示词四件套 -----------------------------------------------------------------
/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
@@ -675,3 +677,63 @@ export const PUSH_PROBE_REASON_LABELS: Record<string, string> = {
timeout: '连接超时',
connect: '连接被拒绝或网络不可达'
}
// ---- 版本历史 / 两版对比 / 组级重置(Phase 3 Task 7d) ------------------------------
/**
* 可重置组镜像(后端 config_validation.CONFIG_GROUPS = DEFAULT_CONFIG 键 +
* prompts,共 9 组;reset 路由同款 allowlist——未知组 400)。标签用于
* 确认框明示组名(「将把「品牌」组恢复为出厂档案值」)。
*/
export const RESET_GROUP_LABELS: Record<ConfigGroupKey, string> = {
brand: '品牌',
prompts: '提示词',
wall: '安全词表',
contact: '联系方式',
email: '邮件',
push: '推送',
ai: 'AI',
engine: '引擎',
misc: '杂项'
}
/**
* 版本时间展示:后端 created_at 是 naive isoformat(无时区后缀)——直接
* 字符串切形(T → 空格,截到秒),不做 Date 解析避免被浏览器当作 UTC
* 再偏移出错误时刻。
*/
export function formatVersionTime(iso: string): string {
return iso.replace('T', ' ').slice(0, 19)
}
/** 版本列表倒序(后端升序返回——展示最新在前) */
export function descendingVersions<T extends { version: number }>(
items: T[]
): T[] {
return [...items].sort((a, b) => b.version - a.version)
}
/** 对比范围哨兵:整份快照(其余值 = 单组键——wall 等大组 20KB+ 按组聚焦) */
export const COMPARE_SCOPE_ALL = '__all__'
/** 快照顶层组键(对比范围下拉选项;键序排序稳定) */
export function snapshotGroupKeys(
config: Record<string, unknown> | null | undefined
): string[] {
return config ? Object.keys(config).sort() : []
}
/**
* 范围视图 → 等宽面板 JSON 串(spec §7.3 最低形态:纯并排展示,不做差异
* 高亮)。密钥字段显示为 '__SET__'——后端对所有携带 config 的响应统一
* 掩码,历史行存原值但绝不回传。
*/
export function scopedConfigJson(
config: Record<string, unknown> | null | undefined,
scope: string
): string {
if (!config) return ''
const view =
scope === COMPARE_SCOPE_ALL ? config : { [scope]: config[scope] }
return JSON.stringify(view, null, 2)
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 组级重置出厂交互(Phase 3 Task 7d):各配置组编辑卡头部的「重置出厂」
* 危险色小按钮共用入口——确认框(明示组名与「恢复出厂档案值」)→
* POST config/{group}/reset。
*
* 返回值三态(调用方据此刷新该组表单 + 版本):
* - null:用户取消确认(非请求失败);
* - resolve:重置成功——响应 {group, version(新), config(出厂值掩码视图)},
* 调用方 emit 'saved' 回写父层快照组 → 组引用变化触发 pane 重置表单;
* - reject:请求失败(showErrorMessage: false,调用方内联展示 400 detail
* ——如 push 出厂前缀被其他租户占用的跨租户查重报错)。
*
* 后端事实源 config_routes.reset_config_group:出厂值 = 代码库当前模板
* (租户创建 profile 的组快照,缺的组取 DEFAULT_CONFIG 整组),产新版本
* + registry_reload 热生效。
*/
import { ElMessageBox } from 'element-plus'
import {
resetConfigGroup,
type ConfigGroupKey,
type ResetConfigGroupResponse
} from '@/api/config'
import { RESET_GROUP_LABELS } from './model'
export async function confirmAndResetGroup(
slug: string,
group: ConfigGroupKey
): Promise<ResetConfigGroupResponse | null> {
const label = RESET_GROUP_LABELS[group]
const confirmed = await ElMessageBox.confirm(
`将把「${label}」组(${group})恢复为出厂档案值,覆盖该组当前全部定制。` +
'重置会产生一个新版本并立即热生效——历史版本保留不变,可随时在「版本」页回滚。',
`重置出厂:${label}`,
{
type: 'warning',
confirmButtonText: '重置出厂',
cancelButtonText: '取消'
}
).then(
() => true,
() => false
)
if (!confirmed) return null
return resetConfigGroup(slug, group, { showErrorMessage: false })
}