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:
@@ -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' })
|
||||
|
||||
@@ -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 版本)——保存词表前的自动
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user