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——试金石;
|
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石;
|
||||||
* Task 7c——凭据测试端点)
|
* Task 7c——凭据测试端点;Task 7d——版本历史/回滚/组级重置)
|
||||||
*
|
*
|
||||||
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
|
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
|
||||||
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
|
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
|
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
|
||||||
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
|
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
|
||||||
* 数据)——两者 showErrorMessage: false 内联展示
|
* 数据)——两者 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)
|
* - 头像上传:multipart file 字段(FormData)
|
||||||
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
|
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
|
||||||
*/
|
*/
|
||||||
@@ -25,9 +31,13 @@ vi.mock('@/api/client', () => ({
|
|||||||
import { http } from '@/api/client'
|
import { http } from '@/api/client'
|
||||||
import {
|
import {
|
||||||
CONFIG_ENDPOINTS,
|
CONFIG_ENDPOINTS,
|
||||||
|
fetchConfigVersionDetail,
|
||||||
|
fetchConfigVersions,
|
||||||
fetchTenantConfig,
|
fetchTenantConfig,
|
||||||
normalizeBrandGroup,
|
normalizeBrandGroup,
|
||||||
putConfigGroup,
|
putConfigGroup,
|
||||||
|
resetConfigGroup,
|
||||||
|
rollbackConfig,
|
||||||
testEmail,
|
testEmail,
|
||||||
testPush,
|
testPush,
|
||||||
testWall,
|
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 原样组 → 表单完整值)', () => {
|
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
|
||||||
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
|
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
|
||||||
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
|
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
|
||||||
|
|||||||
@@ -33,6 +33,20 @@
|
|||||||
* - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索
|
* - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索
|
||||||
* 数据**(后端发空 messages 批量);不可达时 reason 分类
|
* 数据**(后端发空 messages 批量);不可达时 reason 分类
|
||||||
* dns/timeout/connect,auth_ok 三态(true/false/null=不定)。
|
* 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'
|
import { http, type RequestOptions } from './client'
|
||||||
@@ -78,6 +92,12 @@ export interface PutConfigGroupResponse {
|
|||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组级重置响应(POST .../config/{group}/reset):与 PUT 分组保存同形
|
||||||
|
* (新版本号 + 该组出厂值的掩码视图——密钥字段 '__SET__')。
|
||||||
|
*/
|
||||||
|
export type ResetConfigGroupResponse = PutConfigGroupResponse
|
||||||
|
|
||||||
/** 配置版本行(GET .../config/versions;字段名与后端一致) */
|
/** 配置版本行(GET .../config/versions;字段名与后端一致) */
|
||||||
export interface ConfigVersionItem {
|
export interface ConfigVersionItem {
|
||||||
version: number
|
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 覆盖 =
|
* 词表试金石(POST .../wall/test):逐句墙判定预览。传 wall 覆盖 =
|
||||||
* 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动
|
* 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
- 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据)
|
- 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据)
|
||||||
——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败
|
——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败
|
||||||
(401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定
|
(401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定
|
||||||
|
- 重置出厂(Task 7d):三卡头部危险色小按钮 → 确认框 → POST
|
||||||
|
config/{group}/reset → 表单/密钥态回档案值 + emit saved;push 出厂
|
||||||
|
前缀被其他租户占用的 400 detail 内联展示
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="creds-pane">
|
<div class="creds-pane">
|
||||||
@@ -21,7 +24,18 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">邮件(email 组 · SMTP 发信)</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -141,7 +155,18 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">推送(push 组 · 线索推送 UniService)</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -286,7 +311,18 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">AI(ai 组 · 模型服务)</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -363,6 +399,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
PUSH_PROBE_REASON_LABELS,
|
PUSH_PROBE_REASON_LABELS,
|
||||||
|
RESET_GROUP_LABELS,
|
||||||
buildAiCredsPayload,
|
buildAiCredsPayload,
|
||||||
buildEmailCredsPayload,
|
buildEmailCredsPayload,
|
||||||
buildPushCredsPayload,
|
buildPushCredsPayload,
|
||||||
@@ -377,6 +414,7 @@
|
|||||||
} from './model'
|
} from './model'
|
||||||
import { putConfigGroup, testEmail, testPush, type PushTestResponse } from '@/api/config'
|
import { putConfigGroup, testEmail, testPush, type PushTestResponse } from '@/api/config'
|
||||||
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
|
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
|
||||||
|
import { confirmAndResetGroup } from './reset'
|
||||||
|
|
||||||
defineOptions({ name: 'CredsPane' })
|
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;用已保存配置真发一封) --------------------------------
|
// ---- SMTP 测试邮件(email/test;用已保存配置真发一封) --------------------------------
|
||||||
|
|
||||||
async function onTestEmail(): Promise<void> {
|
async function onTestEmail(): Promise<void> {
|
||||||
@@ -671,6 +756,14 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--art-gray-400);
|
color: var(--art-gray-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
|
||||||
|
.header-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-error {
|
.pane-error {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
company_facts 50KB / 其余 100KB;超限红显 + 禁存(后端同样硬拒)
|
company_facts 50KB / 其余 100KB;超限红显 + 禁存(后端同样硬拒)
|
||||||
- system.md 编辑器旁固定 $DEFLECTION_POOL 说明卡:该占位符由安全词表
|
- system.md 编辑器旁固定 $DEFLECTION_POOL 说明卡:该占位符由安全词表
|
||||||
迂回话术池自动注入,勿在提示词里手写话术清单(话术单一事实源)
|
迂回话术池自动注入,勿在提示词里手写话术清单(话术单一事实源)
|
||||||
|
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/prompts/reset
|
||||||
|
→ emit saved 回写快照组 → watch 重置表单为出厂档案值
|
||||||
- 400 校验 detail(带字段路径)内联展示在保存按钮上方
|
- 400 校验 detail(带字段路径)内联展示在保存按钮上方
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
@@ -13,7 +15,18 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">提示词(prompts 组 · 四件套整组替换)</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -111,6 +124,7 @@
|
|||||||
type PromptsDraft
|
type PromptsDraft
|
||||||
} from './model'
|
} from './model'
|
||||||
import { putConfigGroup } from '@/api/config'
|
import { putConfigGroup } from '@/api/config'
|
||||||
|
import { confirmAndResetGroup } from './reset'
|
||||||
|
|
||||||
defineOptions({ name: 'PromptsPane' })
|
defineOptions({ name: 'PromptsPane' })
|
||||||
|
|
||||||
@@ -190,6 +204,26 @@
|
|||||||
saving.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -213,6 +247,14 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--art-gray-400);
|
color: var(--art-gray-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
|
||||||
|
.header-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-error {
|
.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
|
blocked/replaced 时二次确认「判定符合预期再保存」;PUT config/wall
|
||||||
整组提交;正则编译错误 400 的字段路径展示在内联错误区;版本 +1 提示
|
整组提交;正则编译错误 400 的字段路径展示在内联错误区;版本 +1 提示
|
||||||
|
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/wall/reset
|
||||||
|
→ emit saved 回写快照组 → watch 重建编辑模型 = 出厂档案墙
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="wall-pane">
|
<div class="wall-pane">
|
||||||
@@ -18,7 +20,18 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">安全词表(wall 组 · 整组替换)</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -332,6 +345,7 @@
|
|||||||
type PoolField,
|
type PoolField,
|
||||||
type WallEditModel
|
type WallEditModel
|
||||||
} from './model'
|
} from './model'
|
||||||
|
import { confirmAndResetGroup } from './reset'
|
||||||
|
|
||||||
defineOptions({ name: 'WallPane' })
|
defineOptions({ name: 'WallPane' })
|
||||||
|
|
||||||
@@ -643,6 +657,26 @@
|
|||||||
saving.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -673,6 +707,14 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--art-gray-400);
|
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,
|
PROMPTS_KEYS,
|
||||||
PUSH_PROBE_REASON_LABELS,
|
PUSH_PROBE_REASON_LABELS,
|
||||||
REMINDER_VARIANTS,
|
REMINDER_VARIANTS,
|
||||||
|
RESET_GROUP_LABELS,
|
||||||
TRIGGER_KEYS,
|
TRIGGER_KEYS,
|
||||||
ZERO_NUMBER_KEYS,
|
ZERO_NUMBER_KEYS,
|
||||||
asSecretMasked,
|
asSecretMasked,
|
||||||
@@ -32,7 +33,10 @@ import {
|
|||||||
buildEmailCredsPayload,
|
buildEmailCredsPayload,
|
||||||
buildPushCredsPayload,
|
buildPushCredsPayload,
|
||||||
buildWallPayload,
|
buildWallPayload,
|
||||||
|
COMPARE_SCOPE_ALL,
|
||||||
|
descendingVersions,
|
||||||
formatKB,
|
formatKB,
|
||||||
|
formatVersionTime,
|
||||||
initWallEdit,
|
initWallEdit,
|
||||||
normalizeAiCredsGroup,
|
normalizeAiCredsGroup,
|
||||||
normalizeEmailCredsGroup,
|
normalizeEmailCredsGroup,
|
||||||
@@ -44,6 +48,8 @@ import {
|
|||||||
rowToEntry,
|
rowToEntry,
|
||||||
rowsToEntries,
|
rowsToEntries,
|
||||||
rowToSingleEntry,
|
rowToSingleEntry,
|
||||||
|
scopedConfigJson,
|
||||||
|
snapshotGroupKeys,
|
||||||
touchstoneSentences,
|
touchstoneSentences,
|
||||||
utf8Bytes,
|
utf8Bytes,
|
||||||
validateEmailCredsForm,
|
validateEmailCredsForm,
|
||||||
@@ -624,3 +630,78 @@ describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () =>
|
|||||||
expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝')
|
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:提示词四件套与
|
配置中心(Phase 3 Task 7a:品牌与嵌入代码;Task 7b:提示词四件套与
|
||||||
安全词表试金石;Task 7c:凭据表单与测试端点;后续任务补版本)
|
安全词表试金石;Task 7c:凭据表单与测试端点;Task 7d:版本历史与重置)
|
||||||
- 页骨架:左侧组导航(品牌/提示词/安全词表/凭据实装;版本占位)
|
- 页骨架:左侧组导航(品牌/提示词/安全词表/凭据/版本实装)
|
||||||
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
|
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
|
||||||
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
|
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
|
||||||
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
|
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
|
||||||
@@ -18,6 +18,14 @@
|
|||||||
交互——不动保存回传 '__SET__' 保持库值,「清除」= 提交空串);数值
|
交互——不动保存回传 '__SET__' 保持库值,「清除」= 提交空串);数值
|
||||||
前端预检 + 后端 400 双层;SMTP 测试邮件(email/test 真发一封)与
|
前端预检 + 后端 400 双层;SMTP 测试邮件(email/test 真发一封)与
|
||||||
推送连通性探测(push/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(超管可枚举全租户,租户管理
|
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
|
||||||
@@ -83,7 +91,7 @@
|
|||||||
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
|
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
<p class="nav-tip">版本历史在后续任务实装(当前为占位)。</p>
|
<p class="nav-tip">版本只增不改——回滚/重置都产生新版本,历史可随时回看。</p>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
|
|
||||||
<!-- 右侧内容区 -->
|
<!-- 右侧内容区 -->
|
||||||
@@ -106,7 +114,18 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">品牌(brand 组 · 整组替换语义)</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -424,6 +443,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<ElCard v-else shadow="never" class="placeholder-card">
|
||||||
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
|
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
|
||||||
@@ -446,6 +477,8 @@
|
|||||||
import PromptsPane from './PromptsPane.vue'
|
import PromptsPane from './PromptsPane.vue'
|
||||||
import WallPane from './WallPane.vue'
|
import WallPane from './WallPane.vue'
|
||||||
import CredsPane from './CredsPane.vue'
|
import CredsPane from './CredsPane.vue'
|
||||||
|
import VersionsPane from './VersionsPane.vue'
|
||||||
|
import { confirmAndResetGroup } from './reset'
|
||||||
|
|
||||||
defineOptions({ name: 'ConfigCenter' })
|
defineOptions({ name: 'ConfigCenter' })
|
||||||
|
|
||||||
@@ -456,7 +489,7 @@
|
|||||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024
|
const AVATAR_MAX_BYTES = 2 * 1024 * 1024
|
||||||
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
|
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
|
||||||
|
|
||||||
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表 / Task 7c 凭据实装;版本占位) ------
|
// ---- 左侧组导航(Task 7a 品牌 / 7b 提示词+词表 / 7c 凭据 / 7d 版本实装) ----------------
|
||||||
|
|
||||||
const GROUP_NAV = [
|
const GROUP_NAV = [
|
||||||
{ key: 'brand', label: '品牌' },
|
{ key: 'brand', label: '品牌' },
|
||||||
@@ -467,7 +500,13 @@
|
|||||||
] as const
|
] 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']
|
type GroupKey = (typeof GROUP_NAV)[number]['key']
|
||||||
|
|
||||||
@@ -557,7 +596,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pane(提示词/词表)保存成功:回写版本号与快照组(pane 自持编辑态不重置) */
|
/** pane(提示词/词表/凭据)保存或重置成功:回写版本号与快照组(组引用
|
||||||
|
* 变化触发对应 pane 的 watch 重置表单——重置出厂即经此路径刷新表单) */
|
||||||
function onPaneSaved(payload: {
|
function onPaneSaved(payload: {
|
||||||
version: number
|
version: number
|
||||||
group: string
|
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 {
|
function reloadConfig(): void {
|
||||||
loadConfig(++configEpoch.value)
|
loadConfig(++configEpoch.value)
|
||||||
}
|
}
|
||||||
@@ -902,6 +982,14 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--art-gray-400);
|
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。
|
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue /
|
||||||
* 纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
|
* VersionsPane.vue。纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
|
||||||
*
|
*
|
||||||
* 事实源对齐:
|
* 事实源对齐:
|
||||||
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
|
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
* 不动保存回传哨兵保持库值;「清除」= 提交空串)。
|
* 不动保存回传哨兵保持库值;「清除」= 提交空串)。
|
||||||
*/
|
*/
|
||||||
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
|
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
|
||||||
|
import type { ConfigGroupKey } from '@/api/config'
|
||||||
// ---- 提示词四件套 -----------------------------------------------------------------
|
// ---- 提示词四件套 -----------------------------------------------------------------
|
||||||
|
|
||||||
/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
|
/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
|
||||||
@@ -675,3 +677,63 @@ export const PUSH_PROBE_REASON_LABELS: Record<string, string> = {
|
|||||||
timeout: '连接超时',
|
timeout: '连接超时',
|
||||||
connect: '连接被拒绝或网络不可达'
|
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