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:
@@ -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,
|
formatKB,
|
||||||
formatVersionTime,
|
formatVersionTime,
|
||||||
initWallEdit,
|
initWallEdit,
|
||||||
JSON_GROUP_FIELDS,
|
|
||||||
jsonGroupDraft,
|
|
||||||
miscToForm,
|
miscToForm,
|
||||||
miscToPayload,
|
miscToPayload,
|
||||||
normalizeAiCredsGroup,
|
normalizeAiCredsGroup,
|
||||||
@@ -53,7 +51,6 @@ import {
|
|||||||
normalizePromptsGroup,
|
normalizePromptsGroup,
|
||||||
normalizePushCredsGroup,
|
normalizePushCredsGroup,
|
||||||
parsePatternEntry,
|
parsePatternEntry,
|
||||||
parseJsonGroupDraft,
|
|
||||||
poolLanguages,
|
poolLanguages,
|
||||||
promptByteInfo,
|
promptByteInfo,
|
||||||
promptsOverCap,
|
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(密钥四件套展示态)', () => {
|
describe('secretFieldState(密钥四件套展示态)', () => {
|
||||||
it('未配置:input 空 + 无掩码', () => {
|
it('未配置:input 空 + 无掩码', () => {
|
||||||
const s = secretFieldState('', '', false)
|
const s = secretFieldState('', '', false)
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
ConfigCard(表单双列 grid + footer 回滚/备注/保存 + danger 重置行);
|
ConfigCard(表单双列 grid + footer 回滚/备注/保存 + danger 重置行);
|
||||||
预览卡头加副标题;CredsPane @navigate 接线 onGroupSelect;
|
预览卡头加副标题;CredsPane @navigate 接线 onGroupSelect;
|
||||||
8 组全实装后 v-else 占位死分支剪除(activeGroupLabel/ALL_NAV_ITEMS 同清)
|
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(超管可枚举全租户,租户管理
|
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
|
||||||
@@ -477,8 +481,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 通用 JSON 组 pane(终审 I-1:contact / engine / misc) -->
|
<!-- 联系方式 pane(Task 13 表单化——contact 组) -->
|
||||||
<template v-else-if="JSON_GROUPS.has(activeGroup)">
|
<template v-else-if="activeGroup === 'contact'">
|
||||||
<div v-loading="configLoading" class="group-pane">
|
<div v-loading="configLoading" class="group-pane">
|
||||||
<ElAlert
|
<ElAlert
|
||||||
v-if="configError"
|
v-if="configError"
|
||||||
@@ -489,12 +493,56 @@
|
|||||||
>
|
>
|
||||||
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
|
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
|
||||||
</ElAlert>
|
</ElAlert>
|
||||||
<JsonGroupPane
|
<ContactFormPane
|
||||||
v-else-if="activeSlug && configLoaded"
|
v-else-if="activeSlug && configLoaded"
|
||||||
:slug="activeSlug"
|
:slug="activeSlug"
|
||||||
:group-key="activeJsonGroup"
|
:group="snapshot?.contact"
|
||||||
:group="snapshot?.[activeJsonGroup]"
|
|
||||||
@saved="onPaneSaved"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -530,9 +578,10 @@
|
|||||||
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 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 VersionsPane from './VersionsPane.vue'
|
||||||
import type { JsonGroupKey } from './model'
|
|
||||||
import { confirmAndResetGroup } from './reset'
|
import { confirmAndResetGroup } from './reset'
|
||||||
|
|
||||||
defineOptions({ name: 'ConfigCenter' })
|
defineOptions({ name: 'ConfigCenter' })
|
||||||
@@ -587,16 +636,6 @@
|
|||||||
sub: '改坏了?这里一键回滚'
|
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')
|
const activeGroup = ref<GroupKey>('brand')
|
||||||
|
|
||||||
function onGroupSelect(index: string): void {
|
function onGroupSelect(index: string): void {
|
||||||
|
|||||||
@@ -873,68 +873,6 @@ export function scopedConfigJson(
|
|||||||
return JSON.stringify(view, null, 2)
|
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 退役配套) ----------------
|
// ---- contact / engine / misc 表单化(spec §5.4-5.7;JsonGroup 退役配套) ----------------
|
||||||
|
|
||||||
export interface KeyValueRow {
|
export interface KeyValueRow {
|
||||||
@@ -956,6 +894,7 @@ export function validateKeyValueRows(rows: KeyValueRow[], label: string): string
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 行→映射:空键行整行剔除(半行不报错,校验另有重复键拦截) */
|
||||||
function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
|
function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
|
||||||
const out: Record<string, string> = {}
|
const out: Record<string, string> = {}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -965,6 +904,7 @@ function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 映射→行:非字符串值静默剔除(后端 schema 使其不可达——防御读取) */
|
||||||
function recordToRows(value: unknown): KeyValueRow[] {
|
function recordToRows(value: unknown): KeyValueRow[] {
|
||||||
const src = asRecord(value) ?? {}
|
const src = asRecord(value) ?? {}
|
||||||
return Object.entries(src)
|
return Object.entries(src)
|
||||||
|
|||||||
Reference in New Issue
Block a user