feat(admin): contact/engine/misc 表单 pane + JsonGroup 退役

Task 13(feature/config-ux Batch 3):contact/engine/misc 三组从通用
JSON 整组编辑退役为专用表单卡——ContactFormPane(双开关 + 模板语言)、
EngineFormPane(限流数值 + context_seed_keys 高级折叠)、MiscFormPane
(国名 → 区号行编辑),共用新 KeyValueEditor 行编辑器(直改 reactive
rows,与 ScenarioCard 同契约);JsonGroupPane.vue 删除,model.ts 通用
JSON 组区块(JsonGroupKey/jsonGroupDraft/parseJsonGroupDraft/
JSON_GROUP_FIELDS)与对应测试一并退役;rowsToRecord/recordToRows 补
JSDoc(Task 12 评审折入)。

vitest 182 passed(185 − 3 旧 JSON 组用例);vue-tsc + vite build 零错误。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 20:23:26 +08:00
parent 4dd74d6682
commit 250ffdfa53
8 changed files with 760 additions and 373 deletions
@@ -0,0 +1,203 @@
<!--
联系方式表单(contact 组,Task 13 表单化——取代旧 JsonGroup 通用编辑实例):
两个行为开关(cc_completion / email_light_ask)+ 邮件模板语言清单。
表单映射纯函数在 ./model(contactToForm/contactToPayload——接管键覆写、
未知键透传);保存整组 PUT,400 detail 内联展示;重置出厂走 reset.ts。
-->
<template>
<ConfigCard
class="form-pane"
icon="solar:phone-line-duotone"
title="联系方式"
subtitle="AI 什么时候向客户要 WhatsApp / 邮箱"
info="两个开关控制 AI 的要联系方式行为;改动整组保存 · 立即生效 · 可回滚"
danger-label="恢复出厂:把联系方式行为重置为初始档案值"
>
<ElAlert
v-if="actionError"
type="error"
:closable="true"
:title="actionError"
class="pane-error"
@close="actionError = ''"
/>
<ElForm label-position="top" @submit.prevent>
<div class="form-grid">
<ElFormItem label="号码缺国家代码时自动补全并确认">
<ElSwitch v-model="form.ccCompletion" />
</ElFormItem>
<ElFormItem label="留了号码没留邮箱时补要一次邮箱">
<ElSwitch v-model="form.emailLightAsk" />
</ElFormItem>
<ElFormItem label="邮件模板语言" class="span-2">
<ElSelect
v-model="form.templateLanguages"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="语言代码如 en / zh,回车添加"
class="wide-control"
/>
<p class="field-hint">邮件通知使用的语言(暂未生效——引擎接线排期中)</p>
</ElFormItem>
</div>
</ElForm>
<template #footer-left>
<ElButton link type="primary" size="small" @click="emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput
v-model="saveComment"
maxlength="200"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton type="primary" :loading="saving" @click="onSave">保存</ElButton>
</template>
<template #danger>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂…
</ElButton>
</template>
</ConfigCard>
</template>
<script setup lang="ts">
import { RESET_GROUP_LABELS, contactToForm, contactToPayload, type ContactForm } from './model'
import { putConfigGroup } from '@/api/config'
import { confirmAndResetGroup } from './reset'
import ConfigCard from './ConfigCard.vue'
defineOptions({ name: 'ContactFormPane' })
const props = defineProps<{
/** 租户 slug(保存目标) */
slug: string
/** GET config 回显的 contact 组(原样快照;undefined = 快照无该组——
* 表单从空槽起步可建组,父层在快照加载完成后挂载本 pane) */
group: Record<string, unknown> | null | undefined
}>()
const emit = defineEmits<{
/** 保存/重置成功:父层更新版本号与快照组 */
(
e: 'saved',
payload: { version: number; group: 'contact'; config: Record<string, unknown> }
): void
/** footer 回滚入口:请求父层切到版本历史 */
(e: 'navigate', group: string): void
}>()
// ---- 编辑态(group 引用变化 = 保存回写/重置/快照重载 → 重置表单) ----------------------
const form = reactive<ContactForm>(contactToForm(undefined))
const saveComment = ref('')
const saving = ref(false)
const resetting = ref(false)
const actionError = ref('')
watch(
() => props.group,
(group) => {
Object.assign(form, contactToForm(group))
saveComment.value = ''
actionError.value = ''
},
{ immediate: true }
)
// ---- 保存(contactToPayload → PUT 整组;类型校验后端兜底) ----------------------------
async function onSave(): Promise<void> {
if (saving.value) return
actionError.value = ''
saving.value = true
try {
const res = await putConfigGroup(
props.slug,
'contact',
contactToPayload(form, props.group),
saveComment.value.trim() || undefined,
{ showErrorMessage: false }
)
saveComment.value = ''
ElMessage.success(`联系方式已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'contact', config: res.config })
} catch (error) {
// 400 校验 detail(带字段路径,如 "contact.template_languages: …")内联展示
actionError.value = error instanceof Error ? error.message : '保存失败'
} finally {
saving.value = false
}
}
// ---- 重置出厂(确认框 → POST config/contact/reset → emit saved;表单经
// 父层快照组引用变化联动重置为出厂档案值) ---------------------------------------------
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, 'contact')
if (!res) return // 用户取消确认
ElMessage.success(
`${RESET_GROUP_LABELS.contact}已重置出厂(版本 v${res.version},热生效)`
)
emit('saved', { version: res.version, group: 'contact', config: res.config })
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
.form-pane {
flex: 1;
min-width: 0;
width: 100%;
}
.pane-error {
margin-bottom: 12px;
}
// 表单区双列 grid(span-2 = 独占整行)——与品牌/凭据卡同款规则
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
.span-2 {
grid-column: span 2;
}
}
.wide-control {
width: 100%;
}
.field-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.6;
color: var(--art-gray-400);
}
.comment-input {
width: 220px;
}
</style>
@@ -0,0 +1,236 @@
<!--
聊天限流表单(engine 组,Task 13 表单化——取代旧 JsonGroup 通用编辑实例):
两个引擎级数值(留空 = 保持已存值,出厂缺省时省略键回退平台默认)+
会话上下文映射(context_seed_keys,高级折叠区——KeyValueEditor 行编辑)。
表单映射纯函数在 ./model(engineToForm/engineToPayload/validateEngineForm);
保存顺序纪律:两段校验合并非空即拦 → payload → PUT 整组;重置出厂走 reset.ts。
-->
<template>
<ConfigCard
class="form-pane"
icon="solar:widget-5-line-duotone"
title="聊天限流"
subtitle="防刷与并发的引擎级参数(一般不用改)"
info="数值留空 = 保持当前已存值;出厂缺省时省略该键回退平台默认"
danger-label="恢复出厂:把限流参数重置为初始档案值"
>
<ElAlert
v-if="actionError"
type="error"
:closable="true"
:title="actionError"
class="pane-error"
@close="actionError = ''"
/>
<ElForm label-position="top" @submit.prevent>
<div class="form-grid">
<ElFormItem label="发言限流 rate_limit_per_hour">
<ElInput
v-model="form.rateLimit"
placeholder="如 120(正整数,每 IP 每小时发言上限)"
class="mono-input"
/>
</ElFormItem>
<ElFormItem label="连接上限 ws_conn_limit">
<ElInput
v-model="form.wsConnLimit"
placeholder="如 5(每 IP 同时连接上限)"
class="mono-input"
/>
</ElFormItem>
</div>
</ElForm>
<ElCollapse class="seed-collapse">
<ElCollapseItem name="seed" title="会话上下文映射(context_seed_keys · 高级)">
<p class="section-note">
产品字段 → 会话上下文种子键的映射;空行保存时自动剔除,清空全部行 =
提交空映射。
</p>
<KeyValueEditor
:rows="form.seedRows"
key-placeholder="产品字段如 product_name"
value-placeholder="上下文键如 app_product_name"
/>
</ElCollapseItem>
</ElCollapse>
<template #footer-left>
<ElButton link type="primary" size="small" @click="emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput
v-model="saveComment"
maxlength="200"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton type="primary" :loading="saving" @click="onSave">保存</ElButton>
</template>
<template #danger>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂…
</ElButton>
</template>
</ConfigCard>
</template>
<script setup lang="ts">
import {
RESET_GROUP_LABELS,
engineToForm,
engineToPayload,
validateEngineForm,
validateKeyValueRows,
type EngineForm
} from './model'
import { putConfigGroup } from '@/api/config'
import { confirmAndResetGroup } from './reset'
import ConfigCard from './ConfigCard.vue'
import KeyValueEditor from './KeyValueEditor.vue'
defineOptions({ name: 'EngineFormPane' })
const props = defineProps<{
/** 租户 slug(保存目标) */
slug: string
/** GET config 回显的 engine 组(原样快照;undefined = 快照无该组——
* 表单从空槽起步可建组,父层在快照加载完成后挂载本 pane) */
group: Record<string, unknown> | null | undefined
}>()
const emit = defineEmits<{
/** 保存/重置成功:父层更新版本号与快照组 */
(
e: 'saved',
payload: { version: number; group: 'engine'; config: Record<string, unknown> }
): void
/** footer 回滚入口:请求父层切到版本历史 */
(e: 'navigate', group: string): void
}>()
// ---- 编辑态(group 引用变化 = 保存回写/重置/快照重载 → 重置表单) ----------------------
const form = reactive<EngineForm>(engineToForm(undefined))
const saveComment = ref('')
const saving = ref(false)
const resetting = ref(false)
const actionError = ref('')
/** 高级折叠区默认收起(引擎级参数——按需展开) */
const openSections = ref<string[]>([])
watch(
() => props.group,
(group) => {
Object.assign(form, engineToForm(group))
saveComment.value = ''
actionError.value = ''
},
{ immediate: true }
)
// ---- 保存(顺序纪律:两段校验合并 → payload → PUT 整组) -----------------------------
async function onSave(): Promise<void> {
if (saving.value) return
actionError.value = ''
const errors = [
...validateEngineForm(form),
...validateKeyValueRows(form.seedRows, '会话上下文映射')
]
if (errors.length > 0) {
actionError.value = errors.join(';')
return
}
saving.value = true
try {
const res = await putConfigGroup(
props.slug,
'engine',
engineToPayload(form, props.group),
saveComment.value.trim() || undefined,
{ showErrorMessage: false }
)
saveComment.value = ''
ElMessage.success(`聊天限流已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'engine', config: res.config })
} catch (error) {
// 400 校验 detail(带字段路径,如 "engine.ws_conn_limit: 须为正整数")内联展示
actionError.value = error instanceof Error ? error.message : '保存失败'
} finally {
saving.value = false
}
}
// ---- 重置出厂(确认框 → POST config/engine/reset → emit saved;表单经
// 父层快照组引用变化联动重置为出厂档案值) ---------------------------------------------
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, 'engine')
if (!res) return // 用户取消确认
ElMessage.success(
`${RESET_GROUP_LABELS.engine}已重置出厂(版本 v${res.version},热生效)`
)
emit('saved', { version: res.version, group: 'engine', config: res.config })
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
.form-pane {
flex: 1;
min-width: 0;
width: 100%;
}
.pane-error {
margin-bottom: 12px;
}
// 表单区双列 grid——与品牌/凭据卡同款规则
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
}
.mono-input {
width: 100%;
:deep(input) {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
}
}
.seed-collapse {
margin-top: 4px;
.section-note {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.6;
color: var(--art-gray-400);
}
}
.comment-input {
width: 220px;
}
</style>
@@ -1,254 +0,0 @@
<!--
通用 JSON 组编辑(Phase 3 终审 I-1:contact / engine / misc 三组)
- spec §7.3 要求配置中心覆盖全部配置组——这三组字段少 / 无密钥 /
结构低频变更,表单化收益低,统一走等宽 textarea 整组 JSON 编辑;
- 保存前 JSON.parse 预检(语法 + 顶层须为对象),类型/边界交后端
_check_contact/_check_engine/_check_misc 校验兜底(400 detail 带
字段路径,内联展示);
- 重置出厂接既有 reset.ts(确认框 → POST config/{group}/reset →
emit saved 回写快照组 → watch 重置编辑器为出厂档案值)。
-->
<template>
<ElCard shadow="never" class="form-card json-pane">
<template #header>
<div class="card-header">
<span class="card-title">
{{ RESET_GROUP_LABELS[groupKey] }}({{ groupKey }} 组 · 整组 JSON 编辑)
</span>
<div class="header-side">
<span class="card-sub">保存即写新版本并热生效</span>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂
</ElButton>
</div>
</div>
</template>
<ElAlert
v-if="actionError"
type="error"
:closable="true"
:title="actionError"
class="pane-error"
@close="actionError = ''"
/>
<!-- 字段说明(事实源 app/platform/defaults.py 的 DEFAULT_CONFIG) -->
<ul class="field-docs">
<li v-for="field in JSON_GROUP_FIELDS[groupKey]" :key="field.key">
<span class="mono field-key">{{ field.key }}</span>
<span class="field-desc">{{ field.desc }}</span>
</li>
</ul>
<ElInput
v-model="draft"
type="textarea"
:rows="16"
spellcheck="false"
class="json-editor"
placeholder='{ "key": "value" }'
/>
<div class="save-bar">
<ElInput
v-model="saveComment"
maxlength="200"
placeholder="保存备注(可选;写入版本历史 comment)"
class="comment-input"
/>
<ElButton type="primary" :loading="saving" @click="onSave">
保存(整组提交,热生效)
</ElButton>
</div>
</ElCard>
</template>
<script setup lang="ts">
import {
JSON_GROUP_FIELDS,
RESET_GROUP_LABELS,
jsonGroupDraft,
parseJsonGroupDraft,
type JsonGroupKey
} from './model'
import { putConfigGroup } from '@/api/config'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'JsonGroupPane' })
const props = defineProps<{
/** 租户 slug(保存目标) */
slug: string
/** 组键(contact / engine / misc——通用 pane 的三元实例) */
groupKey: JsonGroupKey
/** GET config 回显的组(原样快照;undefined = 快照里无该组 = 未存储
* 覆盖,编辑器从 {} 起步可建组——父层仅在快照加载完成后挂载本 pane) */
group: Record<string, unknown> | null | undefined
}>()
const emit = defineEmits<{
/** 保存/重置成功:父层更新版本号与快照组 */
(
e: 'saved',
payload: { version: number; group: JsonGroupKey; config: Record<string, unknown> }
): void
}>()
// ---- 编辑态(group 引用变化 = 保存回写/重置/快照重载 → 重置编辑器;
// 挂载契约见 props.group 注——父层快照加载后才挂载,无「未加载先存」窗口) ----------
const draft = ref('')
const saveComment = ref('')
const saving = ref(false)
const resetting = ref(false)
const actionError = ref('')
watch(
() => props.group,
(group) => {
draft.value = jsonGroupDraft(group)
saveComment.value = ''
actionError.value = ''
},
{ immediate: true }
)
// ---- 保存(前端 JSON 预检 → PUT 整组;类型校验后端兜底) ----------------------------
async function onSave(): Promise<void> {
if (saving.value) return
actionError.value = ''
const parsed = parseJsonGroupDraft(draft.value)
if (!parsed.ok) {
actionError.value = parsed.error
return
}
saving.value = true
try {
const res = await putConfigGroup(
props.slug,
props.groupKey,
parsed.value,
saveComment.value.trim() || undefined,
{ showErrorMessage: false }
)
saveComment.value = ''
ElMessage.success(
`${RESET_GROUP_LABELS[props.groupKey]}已保存(版本 v${res.version},热生效)`
)
emit('saved', { version: res.version, group: props.groupKey, config: res.config })
} catch (error) {
// 400 校验 detail(带字段路径,如 "engine.ws_conn_limit: 须为正整数")
actionError.value = error instanceof Error ? error.message : '保存失败'
} finally {
saving.value = false
}
}
// ---- 重置出厂(确认框 → POST config/{group}/reset → emit saved) --------------------
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, props.groupKey)
if (!res) return // 用户取消确认
ElMessage.success(
`${RESET_GROUP_LABELS[props.groupKey]}已重置出厂(版本 v${res.version},热生效)`
)
emit('saved', {
version: res.version,
group: props.groupKey,
config: res.config as Record<string, unknown>
})
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
.json-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;
}
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
.card-sub {
font-size: 12px;
color: var(--art-gray-400);
}
}
}
.pane-error {
margin-bottom: 12px;
}
.field-docs {
margin: 0 0 12px;
padding: 10px 12px 10px 30px;
list-style: disc;
border-radius: 6px;
background: var(--el-fill-color-lighter);
font-size: 12px;
line-height: 1.9;
.field-key {
margin-right: 8px;
font-weight: 600;
}
.field-desc {
color: var(--art-gray-500);
}
}
.json-editor {
:deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
}
}
.save-bar {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 10px;
.comment-input {
width: 320px;
}
}
.mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
</style>
@@ -0,0 +1,87 @@
<!--
键值对行编辑器(engine context_seed_keys / misc name_to_region_extras 共用)。
直接改传入的 reactive rows(与 ScenarioCard 同契约);行增删 + 双列输入。
-->
<template>
<div class="kv-editor">
<div v-for="(row, i) in rows" :key="i" class="kv-row">
<ElInput
v-model="row.key"
:placeholder="keyPlaceholder"
class="kv-key"
/>
<span class="kv-arrow">→</span>
<ElInput
v-model="row.value"
:placeholder="valuePlaceholder"
class="kv-value"
/>
<ElButton link type="danger" size="small" @click="removeRow(i)">
删除
</ElButton>
</div>
<ElButton size="small" text bg class="kv-add" @click="addRow">
+ 添加一行
</ElButton>
</div>
</template>
<script setup lang="ts">
import type { KeyValueRow } from './model'
defineOptions({ name: 'KeyValueEditor' })
const props = defineProps<{
/** 行数据(reactive——本组件直接增删改,父层保存时消费) */
rows: KeyValueRow[]
/** 键列 placeholder(随场景:产品字段 / 国名) */
keyPlaceholder: string
/** 值列 placeholder(随场景:上下文键 / 区号) */
valuePlaceholder: string
}>()
function removeRow(i: number): void {
props.rows.splice(i, 1)
}
function addRow(): void {
props.rows.push({ key: '', value: '' })
}
</script>
<style scoped lang="scss">
.kv-editor {
.kv-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
.kv-key {
flex: 1;
min-width: 0;
}
.kv-arrow {
flex-shrink: 0;
font-size: 12px;
color: var(--art-gray-400);
}
.kv-value {
flex: 1;
min-width: 0;
}
}
.kv-add {
margin-top: 2px;
}
}
// 键列等宽(映射键是标识符形态——mono 与词表正则/JSON 区同款)
:deep(.kv-key input) {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
}
</style>
@@ -0,0 +1,176 @@
<!--
国家区号映射表单(misc 组,Task 13 表单化——取代旧 JsonGroup 通用编辑实例):
name_to_region_extras 一层映射的行编辑(KeyValueEditor——一行一条
国名 → 区号)。表单映射纯函数在 ./model(miscToForm/miscToPayload——
未知键透传);保存前 validateKeyValueRows 预检(重复键拦截),空行
保存时自动剔除;重置出厂走 reset.ts。
-->
<template>
<ConfigCard
class="form-pane"
icon="solar:earth-line-duotone"
title="国家区号映射"
subtitle="客户用国名留言时,系统补全电话区号的额外规则"
info="一行一条:国名 → 区号;留空行保存时自动剔除"
danger-label="恢复出厂:把区号映射重置为初始档案值"
>
<ElAlert
v-if="actionError"
type="error"
:closable="true"
:title="actionError"
class="pane-error"
@close="actionError = ''"
/>
<KeyValueEditor
:rows="form.extrasRows"
key-placeholder="国名(如 saudi arabia)"
value-placeholder="区号(如 966)"
/>
<template #footer-left>
<ElButton link type="primary" size="small" @click="emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput
v-model="saveComment"
maxlength="200"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton type="primary" :loading="saving" @click="onSave">保存</ElButton>
</template>
<template #danger>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂…
</ElButton>
</template>
</ConfigCard>
</template>
<script setup lang="ts">
import {
RESET_GROUP_LABELS,
miscToForm,
miscToPayload,
validateKeyValueRows,
type MiscForm
} from './model'
import { putConfigGroup } from '@/api/config'
import { confirmAndResetGroup } from './reset'
import ConfigCard from './ConfigCard.vue'
import KeyValueEditor from './KeyValueEditor.vue'
defineOptions({ name: 'MiscFormPane' })
const props = defineProps<{
/** 租户 slug(保存目标) */
slug: string
/** GET config 回显的 misc 组(原样快照;undefined = 快照无该组——
* 表单从空槽起步可建组,父层在快照加载完成后挂载本 pane) */
group: Record<string, unknown> | null | undefined
}>()
const emit = defineEmits<{
/** 保存/重置成功:父层更新版本号与快照组 */
(
e: 'saved',
payload: { version: number; group: 'misc'; config: Record<string, unknown> }
): void
/** footer 回滚入口:请求父层切到版本历史 */
(e: 'navigate', group: string): void
}>()
// ---- 编辑态(group 引用变化 = 保存回写/重置/快照重载 → 重置表单) ----------------------
const form = reactive<MiscForm>(miscToForm(undefined))
const saveComment = ref('')
const saving = ref(false)
const resetting = ref(false)
const actionError = ref('')
watch(
() => props.group,
(group) => {
Object.assign(form, miscToForm(group))
saveComment.value = ''
actionError.value = ''
},
{ immediate: true }
)
// ---- 保存(validateKeyValueRows → payload → PUT 整组) -------------------------------
async function onSave(): Promise<void> {
if (saving.value) return
actionError.value = ''
const errors = validateKeyValueRows(form.extrasRows, '国家区号映射')
if (errors.length > 0) {
actionError.value = errors.join(';')
return
}
saving.value = true
try {
const res = await putConfigGroup(
props.slug,
'misc',
miscToPayload(form, props.group),
saveComment.value.trim() || undefined,
{ showErrorMessage: false }
)
saveComment.value = ''
ElMessage.success(`区号映射已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'misc', config: res.config })
} catch (error) {
// 400 校验 detail(带字段路径,如 "misc.name_to_region_extras: …")内联展示
actionError.value = error instanceof Error ? error.message : '保存失败'
} finally {
saving.value = false
}
}
// ---- 重置出厂(确认框 → POST config/misc/reset → emit saved;表单经
// 父层快照组引用变化联动重置为出厂档案值) ---------------------------------------------
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, 'misc')
if (!res) return // 用户取消确认
ElMessage.success(
`${RESET_GROUP_LABELS.misc}已重置出厂(版本 v${res.version},热生效)`
)
emit('saved', { version: res.version, group: 'misc', config: res.config })
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
.form-pane {
flex: 1;
min-width: 0;
width: 100%;
}
.pane-error {
margin-bottom: 12px;
}
.comment-input {
width: 220px;
}
</style>
@@ -44,8 +44,6 @@ import {
formatKB,
formatVersionTime,
initWallEdit,
JSON_GROUP_FIELDS,
jsonGroupDraft,
miscToForm,
miscToPayload,
normalizeAiCredsGroup,
@@ -53,7 +51,6 @@ import {
normalizePromptsGroup,
normalizePushCredsGroup,
parsePatternEntry,
parseJsonGroupDraft,
poolLanguages,
promptByteInfo,
promptsOverCap,
@@ -724,43 +721,6 @@ describe('scopedConfigJson / snapshotGroupKeys(两版对比的范围视图)'
})
})
describe('通用 JSON 组编辑(终审 I-1:contact/engine/misc)', () => {
it('jsonGroupDraft:组值 → 2 空格缩进等宽稿;null/undefined → 空对象', () => {
expect(jsonGroupDraft({ cc_completion: false })).toBe(
'{\n "cc_completion": false\n}'
)
expect(jsonGroupDraft(null)).toBe('{}')
expect(jsonGroupDraft(undefined)).toBe('{}')
})
it('parseJsonGroupDraft:语法错误 / 非对象顶层 → 带原因拒绝;对象通过', () => {
const bad = parseJsonGroupDraft('{ "a": ')
expect(bad.ok).toBe(false)
if (!bad.ok) expect(bad.error).toContain('JSON 语法错误')
const notObject = parseJsonGroupDraft('[1, 2]')
expect(notObject.ok).toBe(false)
if (!notObject.ok) expect(notObject.error).toContain('顶层须为 JSON 对象')
const ok = parseJsonGroupDraft('{"cc_completion": false}')
expect(ok.ok).toBe(true)
if (ok.ok) expect(ok.value).toEqual({ cc_completion: false })
})
it('JSON_GROUP_FIELDS:三组字段说明齐备(键与 DEFAULT_CONFIG 同名)', () => {
expect(Object.keys(JSON_GROUP_FIELDS).sort()).toEqual(['contact', 'engine', 'misc'])
expect(JSON_GROUP_FIELDS.contact.map((f) => f.key)).toEqual([
'cc_completion',
'email_light_ask',
'template_languages'
])
expect(JSON_GROUP_FIELDS.engine.map((f) => f.key)).toEqual([
'rate_limit_per_hour',
'ws_conn_limit',
'context_seed_keys'
])
expect(JSON_GROUP_FIELDS.misc.map((f) => f.key)).toEqual(['name_to_region_extras'])
})
})
describe('secretFieldState(密钥四件套展示态)', () => {
it('未配置:input 空 + 无掩码', () => {
const s = secretFieldState('', '', false)
+56 -17
View File
@@ -30,6 +30,10 @@
ConfigCard(表单双列 grid + footer 回滚/备注/保存 + danger 重置行);
预览卡头加副标题;CredsPane @navigate 接线 onGroupSelect;
8 组全实装后 v-else 占位死分支剪除(activeGroupLabel/ALL_NAV_ITEMS 同清)
- Task 13 表单化(feature/config-ux Batch 3):contact/engine/misc 三组
退役通用 JSON 整组编辑(旧 JsonGroup 组件删除),换 ContactFormPane /
EngineFormPane / MiscFormPane 三表单卡(ConfigCard 基线 + KeyValueEditor
行编辑——engine context_seed_keys / misc name_to_region_extras 共用)
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
@@ -477,8 +481,8 @@
</div>
</template>
<!-- 通用 JSON 组 pane(终审 I-1:contact / engine / misc) -->
<template v-else-if="JSON_GROUPS.has(activeGroup)">
<!-- 联系方式 pane(Task 13 表单化——contact 组) -->
<template v-else-if="activeGroup === 'contact'">
<div v-loading="configLoading" class="group-pane">
<ElAlert
v-if="configError"
@@ -489,12 +493,56 @@
>
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
</ElAlert>
<JsonGroupPane
<ContactFormPane
v-else-if="activeSlug && configLoaded"
:slug="activeSlug"
:group-key="activeJsonGroup"
:group="snapshot?.[activeJsonGroup]"
:group="snapshot?.contact"
@saved="onPaneSaved"
@navigate="onGroupSelect"
/>
</div>
</template>
<!-- 聊天限流 pane(Task 13 表单化——engine 组) -->
<template v-else-if="activeGroup === 'engine'">
<div v-loading="configLoading" class="group-pane">
<ElAlert
v-if="configError"
type="error"
:closable="false"
:title="configError"
class="pane-error"
>
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
</ElAlert>
<EngineFormPane
v-else-if="activeSlug && configLoaded"
:slug="activeSlug"
:group="snapshot?.engine"
@saved="onPaneSaved"
@navigate="onGroupSelect"
/>
</div>
</template>
<!-- 国家区号映射 pane(Task 13 表单化——misc 组) -->
<template v-else-if="activeGroup === 'misc'">
<div v-loading="configLoading" class="group-pane">
<ElAlert
v-if="configError"
type="error"
:closable="false"
:title="configError"
class="pane-error"
>
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
</ElAlert>
<MiscFormPane
v-else-if="activeSlug && configLoaded"
:slug="activeSlug"
:group="snapshot?.misc"
@saved="onPaneSaved"
@navigate="onGroupSelect"
/>
</div>
</template>
@@ -530,9 +578,10 @@
import PromptsPane from './PromptsPane.vue'
import WallPane from './WallPane.vue'
import CredsPane from './CredsPane.vue'
import JsonGroupPane from './JsonGroupPane.vue'
import ContactFormPane from './ContactFormPane.vue'
import EngineFormPane from './EngineFormPane.vue'
import MiscFormPane from './MiscFormPane.vue'
import VersionsPane from './VersionsPane.vue'
import type { JsonGroupKey } from './model'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'ConfigCenter' })
@@ -587,16 +636,6 @@
sub: '改坏了?这里一键回滚'
}
/** 通用 JSON 组(终审 I-1:contact/engine/misc 走 JsonGroupPane 一份实现) */
const JSON_GROUPS = new Set<GroupKey>(['contact', 'engine', 'misc'])
/** 模板绑定用的收窄视图(JSON 组分支下恒为该三键之一) */
const activeJsonGroup = computed<JsonGroupKey>(() =>
JSON_GROUPS.has(activeGroup.value)
? (activeGroup.value as JsonGroupKey)
: 'contact'
)
const activeGroup = ref<GroupKey>('brand')
function onGroupSelect(index: string): void {
+2 -62
View File
@@ -873,68 +873,6 @@ export function scopedConfigJson(
return JSON.stringify(view, null, 2)
}
// ---- 通用 JSON 组编辑(Phase 3 终审 I-1:contact / engine / misc) -------------------
/** 走通用 JSON pane 的组(无密钥字段、结构低频变更——表单化收益低) */
export type JsonGroupKey = 'contact' | 'engine' | 'misc'
/** 组取值 → 编辑器初稿(null 组 = 空对象;2 空格缩进等宽展示) */
export function jsonGroupDraft(
group: Record<string, unknown> | null | undefined
): string {
return JSON.stringify(group ?? {}, null, 2)
}
/** 编辑器解析结果:ok=false 时 error 带可直接展示的中文原因 */
export type JsonDraftParse =
| { ok: true; value: Record<string, unknown> }
| { ok: false; error: string }
/** 编辑稿预检:JSON 语法 + 顶层须为对象(后端 _check_* 类型校验兜底) */
export function parseJsonGroupDraft(text: string): JsonDraftParse {
let value: unknown
try {
value = JSON.parse(text)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return { ok: false, error: `JSON 语法错误——${detail}` }
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return { ok: false, error: '顶层须为 JSON 对象({ ... })——整组替换语义' }
}
// typeof 收窄只到 object(无索引签名)——JSON.parse 产物必为普通对象,断言收形
return { ok: true, value: value as Record<string, unknown> }
}
/** 三组的字段说明(pane 顶部展示;事实源 app/platform/defaults.py) */
export const JSON_GROUP_FIELDS: Record<
JsonGroupKey,
Array<{ key: string; desc: string }>
> = {
contact: [
{ key: 'cc_completion', desc: 'bool——cc 区号补全判定树开关' },
{ key: 'email_light_ask', desc: 'bool——dual-contact 分步双要的邮箱轻补' },
{
key: 'template_languages',
desc: 'string[]——邮件模板语言清单(运行期接线归 Phase 4,清理单 #8)'
}
],
engine: [
{ key: 'rate_limit_per_hour', desc: '正整数——聊天限流(次/小时/IP)' },
{ key: 'ws_conn_limit', desc: '正整数——每 IP WebSocket 连接上限' },
{
key: 'context_seed_keys',
desc: '映射——产品字段 → 会话上下文种子键(如 product_name → app_product_name)'
}
],
misc: [
{
key: 'name_to_region_extras',
desc: '映射——国名 → ISO 区域码扩展(resolve_region_code 消费)'
}
]
}
// ---- contact / engine / misc 表单化(spec §5.4-5.7;JsonGroup 退役配套) ----------------
export interface KeyValueRow {
@@ -956,6 +894,7 @@ export function validateKeyValueRows(rows: KeyValueRow[], label: string): string
return errors
}
/** 行→映射:空键行整行剔除(半行不报错,校验另有重复键拦截) */
function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
const out: Record<string, string> = {}
for (const row of rows) {
@@ -965,6 +904,7 @@ function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
return out
}
/** 映射→行:非字符串值静默剔除(后端 schema 使其不可达——防御读取) */
function recordToRows(value: unknown): KeyValueRow[] {
const src = asRecord(value) ?? {}
return Object.entries(src)