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,
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user