feat: 配置中心-凭据(邮件推送AI表单/掩码交互/SMTP测试/推送探测端点)
- 后端 POST /tenants/{slug}/email/test:resolve_email_settings 当前配置
真发测试邮件(主题 [all-kefu] SMTP 测试邮件+租户名+UTC+8 时间);
回退后 host/user 仍空 → 400;成败如实返回 {sent, error},密钥不进响应
- 后端 POST /tenants/{slug}/push/test:连通性探测——与 _do_post 同款
连接形态 POST 空 messages 批量(红线:绝不携带线索数据);不可达
reason 分类 dns/timeout/connect(Windows 裸 ConnectError 文案兜底),
auth_ok 三态(2xx 真 / 401·403 假 / 其余 null),单次无重试
- email_service 抽 _smtp_send 共用连接逻辑(_send 契约不变)+
send_test_email(成败+错误串如实返回,不走吞异常 bool)
- 前端 CredsPane:邮件/推送/AI 三组凭据表单(数值/邮箱前端预检 +
后端 400 双层);密钥哨兵交互(__SET__ 占位符/不动保存保持库值/
清除按钮显式清空可撤销);SMTP 测试与推送探测三态内联展示
- 测试:pytest 新增 18(email/test 7 + push/test 11),全量 595 通过;
vitest 新增 20(model 哨兵往返/预检边界 + API 调用形态),107 通过
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石)
|
||||
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石;
|
||||
* Task 7c——凭据测试端点)
|
||||
*
|
||||
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
|
||||
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
|
||||
@@ -9,6 +10,9 @@
|
||||
* 包装),备注走 comment 查询参数,无备注时不带 params
|
||||
* - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演,
|
||||
* 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数
|
||||
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
|
||||
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
|
||||
* 数据)——两者 showErrorMessage: false 内联展示
|
||||
* - 头像上传:multipart file 字段(FormData)
|
||||
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
|
||||
*/
|
||||
@@ -24,6 +28,8 @@ import {
|
||||
fetchTenantConfig,
|
||||
normalizeBrandGroup,
|
||||
putConfigGroup,
|
||||
testEmail,
|
||||
testPush,
|
||||
testWall,
|
||||
uploadBrandAvatar
|
||||
} from '@/api/config'
|
||||
@@ -194,6 +200,69 @@ describe('试金石 API(testWall · Phase 3 Task 7b)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('testEmail:POST email/test,body 只带 {to}(收件人)', async () => {
|
||||
const response = { slug: 'jiancai', sent: true, error: null }
|
||||
vi.mocked(http.post).mockResolvedValue(response)
|
||||
|
||||
const res = await testEmail('jiancai', { to: 'you@example.com' }, {
|
||||
showErrorMessage: false
|
||||
})
|
||||
|
||||
expect(res).toEqual(response)
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/email/test',
|
||||
{ to: 'you@example.com' },
|
||||
{ showErrorMessage: false } // 400「SMTP 未配置」等内联展示,不走全局提示
|
||||
)
|
||||
})
|
||||
|
||||
it('testEmail:发送失败也走成功响应通道(sent=false + error 串,非异常)', async () => {
|
||||
const response = { slug: 'jiancai', sent: false, error: 'SMTPRecipientsRefused(...)' }
|
||||
vi.mocked(http.post).mockResolvedValue(response)
|
||||
|
||||
const res = await testEmail('jiancai', { to: 'you@example.com' })
|
||||
|
||||
expect(res.sent).toBe(false)
|
||||
expect(res.error).toContain('SMTP')
|
||||
})
|
||||
|
||||
it('testPush:POST push/test,无请求体(探测读已保存配置)', async () => {
|
||||
const response = {
|
||||
slug: 'jiancai',
|
||||
reachable: false,
|
||||
auth_ok: null,
|
||||
status: null,
|
||||
reason: 'dns',
|
||||
error: 'Failed to resolve.'
|
||||
}
|
||||
vi.mocked(http.post).mockResolvedValue(response)
|
||||
|
||||
const res = await testPush('jiancai', { showErrorMessage: false })
|
||||
|
||||
expect(res).toEqual(response)
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
const [url, body, config] = vi.mocked(http.post).mock.calls[0]
|
||||
expect(url).toBe('/api/platform/tenants/jiancai/push/test')
|
||||
expect(body).toBeUndefined() // 端点不读 body——探测参数全来自已保存配置
|
||||
expect(config).toEqual({ showErrorMessage: false })
|
||||
})
|
||||
|
||||
it('CONFIG_ENDPOINTS.emailTest / pushTest:端点路径与后端 config_routes 一致', () => {
|
||||
expect(CONFIG_ENDPOINTS.emailTest('jiancai')).toBe(
|
||||
'/api/platform/tenants/jiancai/email/test'
|
||||
)
|
||||
expect(CONFIG_ENDPOINTS.pushTest('jiancai')).toBe(
|
||||
'/api/platform/tenants/jiancai/push/test'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
|
||||
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
|
||||
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
|
||||
|
||||
+72
-1
@@ -25,6 +25,14 @@
|
||||
* 400 带字段路径;has_contact / source 可选(默认 false / web);
|
||||
* - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→
|
||||
* 零号码墙(replaced)→ 再索要墙(仅 has_contact,blocked 无替换)。
|
||||
*
|
||||
* ## Task 7c 凭据测试契约(config_routes.test_email / test_push)
|
||||
*
|
||||
* - POST email/test:body {to}——后端用**已保存**的当前邮件配置真发一封
|
||||
* 测试邮件;SMTP 未配置 → 400;成败如实返回(sent=false 时带 error);
|
||||
* - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索
|
||||
* 数据**(后端发空 messages 批量);不可达时 reason 分类
|
||||
* dns/timeout/connect,auth_ok 三态(true/false/null=不定)。
|
||||
*/
|
||||
|
||||
import { http, type RequestOptions } from './client'
|
||||
@@ -132,6 +140,32 @@ export interface AvatarUploadResponse {
|
||||
version: number
|
||||
}
|
||||
|
||||
/** SMTP 测试邮件请求(POST .../email/test;用**已保存**配置真发一封到 to) */
|
||||
export interface EmailTestRequest {
|
||||
to: string
|
||||
}
|
||||
|
||||
/** SMTP 测试邮件响应(发送失败不 5xx——sent=false 时 error 带原因) */
|
||||
export interface EmailTestResponse {
|
||||
slug: string
|
||||
sent: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** 推送探测不可达原因分类(前端据此给修复提示文案) */
|
||||
export type PushProbeReason = 'dns' | 'timeout' | 'connect'
|
||||
|
||||
/** 推送连通性探测响应(POST .../push/test;空批量预检,零线索数据) */
|
||||
export interface PushTestResponse {
|
||||
slug: string
|
||||
reachable: boolean
|
||||
/** true=2xx / false=401·403 / null=其他状态(到达但鉴权不定)或不可达 */
|
||||
auth_ok: boolean | null
|
||||
status: number | null
|
||||
reason: PushProbeReason | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** 配置中心端点 */
|
||||
export const CONFIG_ENDPOINTS = {
|
||||
current: (slug: string) => `/api/platform/tenants/${slug}/config`,
|
||||
@@ -144,7 +178,9 @@ export const CONFIG_ENDPOINTS = {
|
||||
`/api/platform/tenants/${slug}/config/versions/${version}`,
|
||||
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
|
||||
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`,
|
||||
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`
|
||||
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`,
|
||||
emailTest: (slug: string) => `/api/platform/tenants/${slug}/email/test`,
|
||||
pushTest: (slug: string) => `/api/platform/tenants/${slug}/push/test`
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -214,6 +250,41 @@ export function uploadBrandAvatar(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 测试邮件(POST .../email/test):用**已保存**的当前邮件配置真发一封
|
||||
* 到 to——表单里未保存的编辑不生效(界面需提示"先保存再测试")。SMTP 未
|
||||
* 配置 → 400(调用方 showErrorMessage: false 自行内联展示);发送失败
|
||||
* 不抛错——sent=false 时 error 带原因串。
|
||||
*/
|
||||
export function testEmail(
|
||||
slug: string,
|
||||
body: EmailTestRequest,
|
||||
config?: RequestOptions
|
||||
): Promise<EmailTestResponse> {
|
||||
return http.post<EmailTestResponse>(
|
||||
CONFIG_ENDPOINTS.emailTest(slug),
|
||||
body,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送连通性探测(POST .../push/test):只验 URL 可达 + 鉴权,后端发空
|
||||
* messages 批量(零线索数据)。URL 未配置 → 400;结果三态由调用方按
|
||||
* reachable/auth_ok 展示(不可达 reason 分类 dns/timeout/connect)。
|
||||
* 同样用**已保存**配置——改完 URL 先保存再探测。
|
||||
*/
|
||||
export function testPush(
|
||||
slug: string,
|
||||
config?: RequestOptions
|
||||
): Promise<PushTestResponse> {
|
||||
return http.post<PushTestResponse>(
|
||||
CONFIG_ENDPOINTS.pushTest(slug),
|
||||
undefined,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
|
||||
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂
|
||||
|
||||
+1
@@ -78,6 +78,7 @@ declare module 'vue' {
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
<!--
|
||||
凭据三组编辑(Phase 3 Task 7c;消费 config/center/model.ts 纯逻辑)
|
||||
- 邮件(email 组):SMTP 四字段 + 发件人名称 + 通知邮箱数组;数值/邮箱
|
||||
格式前端预检(端口 1-65535 / 项含 @),后端 400 二层兜底
|
||||
- 推送(push 组):开关 + URL + API Key + 前缀 + 三个数值(正数 / 1-10
|
||||
整数,留空 = null = 平台默认);prefix 跨租户冲突的 400 detail 内联展示
|
||||
- AI(ai 组):provider / API Key / base_url / model
|
||||
- 密钥哨兵交互(mask.ts):GET 密钥为 '__SET__' → 输入框留空 + 占位符
|
||||
「已设置,输入新值覆盖」;不动保存回传哨兵保持库值;「清除」按钮 =
|
||||
显式清空意图(提交空串),可再点「恢复保持」撤销
|
||||
- SMTP 测试邮件:收件人 + 按钮 → POST email/test——用**已保存**配置真发
|
||||
一封(先保存再测试);未配置 400 / 发送失败 sent=false 均内联展示
|
||||
- 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据)
|
||||
——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败
|
||||
(401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定
|
||||
-->
|
||||
<template>
|
||||
<div class="creds-pane">
|
||||
<!-- 邮件 -->
|
||||
<ElCard shadow="never" class="form-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">邮件(email 组 · SMTP 发信)</span>
|
||||
<span class="card-sub">空槽 = 未设置 → 回退平台环境默认;保存即写新版本并热生效</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElAlert
|
||||
v-if="emailError"
|
||||
type="error"
|
||||
:closable="true"
|
||||
:title="emailError"
|
||||
class="pane-error"
|
||||
@close="emailError = ''"
|
||||
/>
|
||||
|
||||
<ElForm label-width="130px" @submit.prevent>
|
||||
<ElFormItem label="SMTP 服务器">
|
||||
<ElInput
|
||||
v-model="emailForm.smtpHost"
|
||||
placeholder="如 smtp.exmail.qq.com(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="SMTP 端口">
|
||||
<ElInput
|
||||
v-model="emailForm.smtpPort"
|
||||
placeholder="如 465(1-65535 整数,留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="SMTP 账号">
|
||||
<ElInput
|
||||
v-model="emailForm.smtpUser"
|
||||
placeholder="发件邮箱账号(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="SMTP 密码">
|
||||
<div class="secret-row">
|
||||
<ElInput
|
||||
v-model="emailForm.smtpPassword"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="emailPasswordPlaceholder"
|
||||
class="secret-input"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="emailPasswordMasked === MASKED_SENTINEL"
|
||||
size="small"
|
||||
:type="emailPasswordCleared ? 'default' : 'danger'"
|
||||
plain
|
||||
@click="emailPasswordCleared = !emailPasswordCleared"
|
||||
>
|
||||
{{ emailPasswordCleared ? '恢复保持' : '清除' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<p v-if="emailPasswordCleared" class="field-warn">
|
||||
保存后将清除已存密码(空槽回退平台默认)
|
||||
</p>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="发件人名称">
|
||||
<ElInput
|
||||
v-model="emailForm.senderName"
|
||||
placeholder='邮件【发件人名称】展示(留空 = "HESUPIPE")'
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="通知邮箱">
|
||||
<ElSelect
|
||||
v-model="emailForm.notifyEmails"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
:reserve-keyword="false"
|
||||
placeholder="输入邮箱后回车添加(新询盘通知收件人,留空 = 平台默认)"
|
||||
class="wide-control"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="保存备注">
|
||||
<ElInput
|
||||
v-model="emailComment"
|
||||
maxlength="200"
|
||||
placeholder="可选;写入版本历史 comment"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="save-bar">
|
||||
<ElButton type="primary" :loading="emailSaving" @click="onSaveEmail">
|
||||
保存邮件组(整组提交,热生效)
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElDivider class="test-divider" align="left">连通性验证(用已保存配置)</ElDivider>
|
||||
<div class="test-row">
|
||||
<ElInput
|
||||
v-model="emailTestTo"
|
||||
placeholder="收件人邮箱(如 you@example.com)"
|
||||
class="test-input"
|
||||
/>
|
||||
<ElButton :loading="emailTesting" :disabled="!slug" @click="onTestEmail">
|
||||
发测试邮件
|
||||
</ElButton>
|
||||
</div>
|
||||
<p class="test-hint">
|
||||
后端用**已保存**的邮件配置真发一封(主题「[all-kefu] SMTP 测试邮件」)
|
||||
——改完先保存再测试;SMTP 未配置时返回 400。
|
||||
</p>
|
||||
<ElAlert
|
||||
v-if="emailTestResult"
|
||||
:type="emailTestResult.type"
|
||||
:closable="true"
|
||||
:title="emailTestResult.text"
|
||||
:description="emailTestResult.detail || undefined"
|
||||
class="test-result"
|
||||
@close="emailTestResult = null"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 推送 -->
|
||||
<ElCard shadow="never" class="form-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">推送(push 组 · 线索推送 UniService)</span>
|
||||
<span class="card-sub">数值留空 = 平台默认;前缀全局唯一(跨租户查重)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElAlert
|
||||
v-if="pushError"
|
||||
type="error"
|
||||
:closable="true"
|
||||
:title="pushError"
|
||||
class="pane-error"
|
||||
@close="pushError = ''"
|
||||
/>
|
||||
|
||||
<ElForm label-width="130px" @submit.prevent>
|
||||
<ElFormItem label="启用推送">
|
||||
<ElSwitch v-model="pushForm.enabled" />
|
||||
<span class="switch-hint">关闭后新线索不推送(只落库)</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="推送 URL">
|
||||
<ElInput
|
||||
v-model="pushForm.url"
|
||||
placeholder="https://push.example/open/v1/inquiry/push(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="API Key">
|
||||
<div class="secret-row">
|
||||
<ElInput
|
||||
v-model="pushForm.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="pushApiKeyPlaceholder"
|
||||
class="secret-input"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="pushApiKeyMasked === MASKED_SENTINEL"
|
||||
size="small"
|
||||
:type="pushApiKeyCleared ? 'default' : 'danger'"
|
||||
plain
|
||||
@click="pushApiKeyCleared = !pushApiKeyCleared"
|
||||
>
|
||||
{{ pushApiKeyCleared ? '恢复保持' : '清除' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<p v-if="pushApiKeyCleared" class="field-warn">
|
||||
保存后将清除已存 API Key(空槽回退平台默认)
|
||||
</p>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="线索前缀">
|
||||
<ElInput
|
||||
v-model="pushForm.prefix"
|
||||
placeholder="external_key 前缀(全局唯一;留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="超时(秒)">
|
||||
<ElInput
|
||||
v-model="pushForm.timeout"
|
||||
placeholder="正数,如 10(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="最大尝试次数">
|
||||
<ElInput
|
||||
v-model="pushForm.maxAttempts"
|
||||
placeholder="1-10 整数,如 3(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="退避基数(秒)">
|
||||
<ElInput
|
||||
v-model="pushForm.backoffBase"
|
||||
placeholder="正数,如 1(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="保存备注">
|
||||
<ElInput
|
||||
v-model="pushComment"
|
||||
maxlength="200"
|
||||
placeholder="可选;写入版本历史 comment"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="save-bar">
|
||||
<ElButton type="primary" :loading="pushSaving" @click="onSavePush">
|
||||
保存推送组(整组提交,热生效)
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElDivider class="test-divider" align="left">连通性探测(用已保存配置)</ElDivider>
|
||||
<div class="test-row">
|
||||
<ElButton :loading="pushProbing" :disabled="!slug" @click="onProbePush">
|
||||
探测推送端点
|
||||
</ElButton>
|
||||
<span class="test-hint-inline">
|
||||
只验可达 + 鉴权,<b>不发送任何线索数据</b>(后端发空批量)
|
||||
</span>
|
||||
</div>
|
||||
<p class="test-hint">改完 URL / API Key 先保存再探测——探测读的是已保存配置。</p>
|
||||
<ElAlert
|
||||
v-if="pushProbeError"
|
||||
type="error"
|
||||
:closable="true"
|
||||
:title="pushProbeError"
|
||||
class="test-result"
|
||||
@close="pushProbeError = ''"
|
||||
/>
|
||||
<ElAlert
|
||||
v-else-if="pushProbe && pushProbe.reachable && pushProbe.auth_ok === true"
|
||||
type="success"
|
||||
:closable="true"
|
||||
class="test-result"
|
||||
:title="`正常:端点可达且鉴权通过(HTTP ${pushProbe.status})`"
|
||||
@close="pushProbe = null"
|
||||
/>
|
||||
<ElAlert
|
||||
v-else-if="pushProbe && pushProbe.reachable && pushProbe.auth_ok === false"
|
||||
type="error"
|
||||
:closable="true"
|
||||
class="test-result"
|
||||
:title="`可达但鉴权失败(HTTP ${pushProbe.status})——检查 API Key`"
|
||||
@close="pushProbe = null"
|
||||
/>
|
||||
<ElAlert
|
||||
v-else-if="pushProbe && pushProbe.reachable"
|
||||
type="warning"
|
||||
:closable="true"
|
||||
class="test-result"
|
||||
:title="`可达但状态异常(HTTP ${pushProbe.status})——鉴权不定,请人工确认`"
|
||||
@close="pushProbe = null"
|
||||
/>
|
||||
<ElAlert
|
||||
v-else-if="pushProbe"
|
||||
type="error"
|
||||
:closable="true"
|
||||
class="test-result"
|
||||
:title="`不可达:${probeReasonLabel}`"
|
||||
:description="pushProbe.error || undefined"
|
||||
@close="pushProbe = null"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- AI -->
|
||||
<ElCard shadow="never" class="form-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">AI(ai 组 · 模型服务)</span>
|
||||
<span class="card-sub">密钥空槽 = 未设置 → 回退平台集团凭据(spec §5.5)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElAlert
|
||||
v-if="aiError"
|
||||
type="error"
|
||||
:closable="true"
|
||||
:title="aiError"
|
||||
class="pane-error"
|
||||
@close="aiError = ''"
|
||||
/>
|
||||
|
||||
<ElForm label-width="130px" @submit.prevent>
|
||||
<ElFormItem label="Provider">
|
||||
<ElInput
|
||||
v-model="aiForm.provider"
|
||||
placeholder="如 glm(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="API Key">
|
||||
<div class="secret-row">
|
||||
<ElInput
|
||||
v-model="aiForm.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="aiApiKeyPlaceholder"
|
||||
class="secret-input"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="aiApiKeyMasked === MASKED_SENTINEL"
|
||||
size="small"
|
||||
:type="aiApiKeyCleared ? 'default' : 'danger'"
|
||||
plain
|
||||
@click="aiApiKeyCleared = !aiApiKeyCleared"
|
||||
>
|
||||
{{ aiApiKeyCleared ? '恢复保持' : '清除' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<p v-if="aiApiKeyCleared" class="field-warn">
|
||||
保存后将清除已存 API Key(空槽回退平台集团凭据)
|
||||
</p>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="Base URL">
|
||||
<ElInput
|
||||
v-model="aiForm.baseUrl"
|
||||
placeholder="如 https://open.bigmodel.cn/api/paas/v4(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="模型">
|
||||
<ElInput
|
||||
v-model="aiForm.model"
|
||||
placeholder="如 glm-4-flash(留空 = 平台默认)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="保存备注">
|
||||
<ElInput
|
||||
v-model="aiComment"
|
||||
maxlength="200"
|
||||
placeholder="可选;写入版本历史 comment"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="save-bar">
|
||||
<ElButton type="primary" :loading="aiSaving" @click="onSaveAi">
|
||||
保存 AI 组(整组提交,热生效)
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PUSH_PROBE_REASON_LABELS,
|
||||
buildAiCredsPayload,
|
||||
buildEmailCredsPayload,
|
||||
buildPushCredsPayload,
|
||||
normalizeAiCredsGroup,
|
||||
normalizeEmailCredsGroup,
|
||||
normalizePushCredsGroup,
|
||||
validateEmailCredsForm,
|
||||
validatePushCredsForm,
|
||||
type AiCredsForm,
|
||||
type EmailCredsForm,
|
||||
type PushCredsForm
|
||||
} from './model'
|
||||
import { putConfigGroup, testEmail, testPush, type PushTestResponse } from '@/api/config'
|
||||
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
|
||||
|
||||
defineOptions({ name: 'CredsPane' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 租户 slug(保存/测试目标) */
|
||||
slug: string
|
||||
/** GET config 回显的 email 组(原样快照;null = 尚未加载) */
|
||||
emailGroup: Record<string, unknown> | null | undefined
|
||||
/** GET config 回显的 push 组 */
|
||||
pushGroup: Record<string, unknown> | null | undefined
|
||||
/** GET config 回显的 ai 组 */
|
||||
aiGroup: Record<string, unknown> | null | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 保存成功:父层更新版本号与快照组 */
|
||||
(
|
||||
e: 'saved',
|
||||
payload: {
|
||||
version: number
|
||||
group: 'email' | 'push' | 'ai'
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
// ---- 编辑态(组引用变化 = 保存回写/租户切换/重载 → 重置表单与密钥态) ----------------
|
||||
|
||||
const emailForm = reactive<EmailCredsForm>(normalizeEmailCredsGroup(undefined).form)
|
||||
const pushForm = reactive<PushCredsForm>(normalizePushCredsGroup(undefined).form)
|
||||
const aiForm = reactive<AiCredsForm>(normalizeAiCredsGroup(undefined).form)
|
||||
|
||||
/** 密钥掩码状态(GET 原样:哨兵或空)+ 显式清除意图(提交空串,可撤销) */
|
||||
const emailPasswordMasked = ref('')
|
||||
const emailPasswordCleared = ref(false)
|
||||
const pushApiKeyMasked = ref('')
|
||||
const pushApiKeyCleared = ref(false)
|
||||
const aiApiKeyMasked = ref('')
|
||||
const aiApiKeyCleared = ref(false)
|
||||
|
||||
const emailComment = ref('')
|
||||
const pushComment = ref('')
|
||||
const aiComment = ref('')
|
||||
const emailSaving = ref(false)
|
||||
const pushSaving = ref(false)
|
||||
const aiSaving = ref(false)
|
||||
const emailError = ref('')
|
||||
const pushError = ref('')
|
||||
const aiError = ref('')
|
||||
|
||||
// 测试态(声明先于下方 immediate watch 的 resetAll——避免 TDZ 引用)
|
||||
const emailTestTo = ref('')
|
||||
const emailTesting = ref(false)
|
||||
const emailTestResult = ref<{
|
||||
type: 'success' | 'error'
|
||||
text: string
|
||||
detail?: string
|
||||
} | null>(null)
|
||||
const pushProbing = ref(false)
|
||||
const pushProbe = ref<PushTestResponse | null>(null)
|
||||
const pushProbeError = ref('')
|
||||
|
||||
function resetAll(
|
||||
emailGroup: Record<string, unknown> | null | undefined,
|
||||
pushGroup: Record<string, unknown> | null | undefined,
|
||||
aiGroup: Record<string, unknown> | null | undefined
|
||||
): void {
|
||||
const es = normalizeEmailCredsGroup(emailGroup)
|
||||
Object.assign(emailForm, es.form)
|
||||
emailPasswordMasked.value = es.passwordMasked
|
||||
emailPasswordCleared.value = false
|
||||
const ps = normalizePushCredsGroup(pushGroup)
|
||||
Object.assign(pushForm, ps.form)
|
||||
pushApiKeyMasked.value = ps.apiKeyMasked
|
||||
pushApiKeyCleared.value = false
|
||||
const as = normalizeAiCredsGroup(aiGroup)
|
||||
Object.assign(aiForm, as.form)
|
||||
aiApiKeyMasked.value = as.apiKeyMasked
|
||||
aiApiKeyCleared.value = false
|
||||
emailComment.value = ''
|
||||
pushComment.value = ''
|
||||
aiComment.value = ''
|
||||
emailError.value = ''
|
||||
pushError.value = ''
|
||||
aiError.value = ''
|
||||
emailTestResult.value = null
|
||||
emailTesting.value = false
|
||||
pushProbe.value = null
|
||||
pushProbeError.value = ''
|
||||
pushProbing.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.emailGroup, props.pushGroup, props.aiGroup],
|
||||
([emailGroup, pushGroup, aiGroup]) => resetAll(emailGroup, pushGroup, aiGroup),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const emailPasswordPlaceholder = computed(() =>
|
||||
emailPasswordCleared.value
|
||||
? secretPlaceholder('')
|
||||
: secretPlaceholder(emailPasswordMasked.value)
|
||||
)
|
||||
const pushApiKeyPlaceholder = computed(() =>
|
||||
pushApiKeyCleared.value
|
||||
? secretPlaceholder('')
|
||||
: secretPlaceholder(pushApiKeyMasked.value)
|
||||
)
|
||||
const aiApiKeyPlaceholder = computed(() =>
|
||||
aiApiKeyCleared.value ? secretPlaceholder('') : secretPlaceholder(aiApiKeyMasked.value)
|
||||
)
|
||||
|
||||
// ---- 保存(前端预检 → 整组 PUT;密钥不动回哨兵保持库值) ------------------------------
|
||||
|
||||
async function onSaveEmail(): Promise<void> {
|
||||
if (emailSaving.value) return
|
||||
emailError.value = ''
|
||||
const errors = validateEmailCredsForm(emailForm)
|
||||
if (errors.length > 0) {
|
||||
emailError.value = errors.join(';')
|
||||
return
|
||||
}
|
||||
emailSaving.value = true
|
||||
try {
|
||||
const res = await putConfigGroup(
|
||||
props.slug,
|
||||
'email',
|
||||
buildEmailCredsPayload(
|
||||
emailForm,
|
||||
emailPasswordCleared.value ? '' : emailPasswordMasked.value
|
||||
),
|
||||
emailComment.value.trim() || undefined,
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
const state = normalizeEmailCredsGroup(res.config)
|
||||
Object.assign(emailForm, state.form)
|
||||
emailPasswordMasked.value = state.passwordMasked
|
||||
emailPasswordCleared.value = false
|
||||
emailComment.value = ''
|
||||
ElMessage.success(`邮件配置已保存(版本 v${res.version},热生效)`)
|
||||
emit('saved', { version: res.version, group: 'email', config: res.config })
|
||||
} catch (error) {
|
||||
// 400 校验 detail(带字段路径,如 "email.notify_emails[0]: …")内联展示
|
||||
emailError.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
emailSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSavePush(): Promise<void> {
|
||||
if (pushSaving.value) return
|
||||
pushError.value = ''
|
||||
const errors = validatePushCredsForm(pushForm)
|
||||
if (errors.length > 0) {
|
||||
pushError.value = errors.join(';')
|
||||
return
|
||||
}
|
||||
pushSaving.value = true
|
||||
try {
|
||||
const res = await putConfigGroup(
|
||||
props.slug,
|
||||
'push',
|
||||
buildPushCredsPayload(
|
||||
pushForm,
|
||||
pushApiKeyCleared.value ? '' : pushApiKeyMasked.value
|
||||
),
|
||||
pushComment.value.trim() || undefined,
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
const state = normalizePushCredsGroup(res.config)
|
||||
Object.assign(pushForm, state.form)
|
||||
pushApiKeyMasked.value = state.apiKeyMasked
|
||||
pushApiKeyCleared.value = false
|
||||
pushComment.value = ''
|
||||
ElMessage.success(`推送配置已保存(版本 v${res.version},热生效)`)
|
||||
emit('saved', { version: res.version, group: 'push', config: res.config })
|
||||
} catch (error) {
|
||||
// 含 prefix 跨租户占用:"push.prefix: 'xxx' 已被其他租户占用…"
|
||||
pushError.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
pushSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveAi(): Promise<void> {
|
||||
if (aiSaving.value) return
|
||||
aiError.value = ''
|
||||
aiSaving.value = true
|
||||
try {
|
||||
const res = await putConfigGroup(
|
||||
props.slug,
|
||||
'ai',
|
||||
buildAiCredsPayload(aiForm, aiApiKeyCleared.value ? '' : aiApiKeyMasked.value),
|
||||
aiComment.value.trim() || undefined,
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
const state = normalizeAiCredsGroup(res.config)
|
||||
Object.assign(aiForm, state.form)
|
||||
aiApiKeyMasked.value = state.apiKeyMasked
|
||||
aiApiKeyCleared.value = false
|
||||
aiComment.value = ''
|
||||
ElMessage.success(`AI 配置已保存(版本 v${res.version},热生效)`)
|
||||
emit('saved', { version: res.version, group: 'ai', config: res.config })
|
||||
} catch (error) {
|
||||
aiError.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
aiSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SMTP 测试邮件(email/test;用已保存配置真发一封) --------------------------------
|
||||
|
||||
async function onTestEmail(): Promise<void> {
|
||||
if (emailTesting.value) return
|
||||
const to = emailTestTo.value.trim()
|
||||
if (!to || !to.includes('@')) {
|
||||
emailTestResult.value = { type: 'error', text: '请先填写含 @ 的收件人邮箱' }
|
||||
return
|
||||
}
|
||||
emailTesting.value = true
|
||||
emailTestResult.value = null
|
||||
try {
|
||||
const res = await testEmail(props.slug, { to }, { showErrorMessage: false })
|
||||
if (res.sent) {
|
||||
emailTestResult.value = {
|
||||
type: 'success',
|
||||
text: `测试邮件已发出(${to}),请查收验证配置`
|
||||
}
|
||||
} else {
|
||||
emailTestResult.value = {
|
||||
type: 'error',
|
||||
text: '发送失败——SMTP 配置可能不正确',
|
||||
detail: res.error ?? ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 400「SMTP 未配置」等后端预检拒绝(ApiError.message = detail)
|
||||
emailTestResult.value = {
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : '测试发送失败'
|
||||
}
|
||||
} finally {
|
||||
emailTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 推送连通性探测(push/test;空批量,零线索数据) ----------------------------------
|
||||
|
||||
const probeReasonLabel = computed(
|
||||
() =>
|
||||
PUSH_PROBE_REASON_LABELS[pushProbe.value?.reason ?? ''] ??
|
||||
'连接失败(未分类原因)'
|
||||
)
|
||||
|
||||
async function onProbePush(): Promise<void> {
|
||||
if (pushProbing.value) return
|
||||
pushProbing.value = true
|
||||
pushProbe.value = null
|
||||
pushProbeError.value = ''
|
||||
try {
|
||||
pushProbe.value = await testPush(props.slug, { showErrorMessage: false })
|
||||
} catch (error) {
|
||||
// 400「推送 URL 未配置」等后端预检拒绝
|
||||
pushProbeError.value = error instanceof Error ? error.message : '探测失败'
|
||||
} finally {
|
||||
pushProbing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.creds-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
.pane-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.secret-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.secret-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.field-warn {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.switch-hint {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
|
||||
.wide-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-divider {
|
||||
margin: 18px 0 12px;
|
||||
}
|
||||
|
||||
.test-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
.test-input {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.test-hint-inline {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
|
||||
.test-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 配置中心提示词/词表编辑模型单元测试(Phase 3 Task 7b)
|
||||
* 配置中心提示词/词表/凭据编辑模型单元测试(Phase 3 Task 7b + 7c)
|
||||
*
|
||||
* 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源:
|
||||
* app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与
|
||||
@@ -9,7 +9,11 @@
|
||||
* - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的
|
||||
* 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的
|
||||
* 保留、buildWallPayload 的整组重组;
|
||||
* - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构。
|
||||
* - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构;
|
||||
* - 凭据三组(Task 7c):normalize(掩码哨兵态/数值转串/缺键防御)、
|
||||
* build(密钥哨兵往返——不动保存回 '__SET__' 保持库值;空串 = 清空;
|
||||
* 新值优先;数值空串 → null)与 validate 前端预检(端口 1-65535 /
|
||||
* 邮箱含 @ / push URL 协议与正数·1-10 整数边界,与后端校验同款)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
@@ -19,13 +23,21 @@ import {
|
||||
POOL_FIELDS,
|
||||
PROMPTS_CAPS,
|
||||
PROMPTS_KEYS,
|
||||
PUSH_PROBE_REASON_LABELS,
|
||||
REMINDER_VARIANTS,
|
||||
TRIGGER_KEYS,
|
||||
ZERO_NUMBER_KEYS,
|
||||
asSecretMasked,
|
||||
buildAiCredsPayload,
|
||||
buildEmailCredsPayload,
|
||||
buildPushCredsPayload,
|
||||
buildWallPayload,
|
||||
formatKB,
|
||||
initWallEdit,
|
||||
normalizeAiCredsGroup,
|
||||
normalizeEmailCredsGroup,
|
||||
normalizePromptsGroup,
|
||||
normalizePushCredsGroup,
|
||||
parsePatternEntry,
|
||||
promptByteInfo,
|
||||
promptsOverCap,
|
||||
@@ -34,7 +46,11 @@ import {
|
||||
rowToSingleEntry,
|
||||
touchstoneSentences,
|
||||
utf8Bytes,
|
||||
type PromptsDraft
|
||||
validateEmailCredsForm,
|
||||
validatePushCredsForm,
|
||||
type EmailCredsForm,
|
||||
type PromptsDraft,
|
||||
type PushCredsForm
|
||||
} from '../model'
|
||||
|
||||
// ---- 提示词四件套 -------------------------------------------------------------------
|
||||
@@ -357,3 +373,254 @@ describe('试金石句集', () => {
|
||||
expect(touchstoneSentences([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) -----------------------------------
|
||||
|
||||
describe('asSecretMasked(GET 密钥值 → 掩码哨兵态)', () => {
|
||||
it('哨兵原样 / 空串与 null·undefined → 空串(区分「配了」与「没配」)', () => {
|
||||
expect(asSecretMasked('__SET__')).toBe('__SET__')
|
||||
expect(asSecretMasked('')).toBe('')
|
||||
expect(asSecretMasked(null)).toBe('')
|
||||
expect(asSecretMasked(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('掩码失效漏出的任意非空值也归哨兵(防原始密钥回传库外)', () => {
|
||||
expect(asSecretMasked('sk-real-secret')).toBe('__SET__')
|
||||
expect(asSecretMasked(123)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalize 三组(GET 原样组 → 表单 + 密钥态)', () => {
|
||||
it('email:smtp 完整组 → 表单值 + 密码哨兵;密钥输入恒空', () => {
|
||||
const state = normalizeEmailCredsGroup({
|
||||
smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '__SET__' },
|
||||
sender_name: 'HESUPIPE',
|
||||
notify_emails: ['a@x.com', 'b@x.com']
|
||||
})
|
||||
expect(state.form).toEqual({
|
||||
smtpHost: 'smtp.x.com',
|
||||
smtpPort: '465',
|
||||
smtpUser: 'bot@x.com',
|
||||
smtpPassword: '',
|
||||
senderName: 'HESUPIPE',
|
||||
notifyEmails: ['a@x.com', 'b@x.com']
|
||||
})
|
||||
expect(state.passwordMasked).toBe('__SET__')
|
||||
})
|
||||
|
||||
it('email:jiancai 档案形态(无 smtp 键)→ 全空槽 + 未设置密钥态', () => {
|
||||
const state = normalizeEmailCredsGroup({
|
||||
sender_name: 'HESUPIPE',
|
||||
notify_emails: ['hesu@hesuu.com']
|
||||
})
|
||||
expect(state.form.smtpHost).toBe('')
|
||||
expect(state.form.smtpPort).toBe('')
|
||||
expect(state.form.smtpPassword).toBe('')
|
||||
expect(state.passwordMasked).toBe('')
|
||||
})
|
||||
|
||||
it('email:smtp null / 类型漂移防御', () => {
|
||||
const state = normalizeEmailCredsGroup({ smtp: null, notify_emails: [1, 'a@x.com'] })
|
||||
expect(state.form.smtpHost).toBe('')
|
||||
expect(state.form.notifyEmails).toEqual(['a@x.com'])
|
||||
expect(normalizeEmailCredsGroup(undefined).form.notifyEmails).toEqual([])
|
||||
})
|
||||
|
||||
it('push:完整组 → 表单(数值转串)+ api_key 哨兵', () => {
|
||||
const state = normalizePushCredsGroup({
|
||||
enabled: true,
|
||||
url: 'https://push.example/v1',
|
||||
api_key: '__SET__',
|
||||
prefix: 'ai-kefu-jc',
|
||||
timeout: 5.5,
|
||||
max_attempts: 3,
|
||||
retry_backoff_base: 1
|
||||
})
|
||||
expect(state.form).toEqual({
|
||||
enabled: true,
|
||||
url: 'https://push.example/v1',
|
||||
apiKey: '',
|
||||
prefix: 'ai-kefu-jc',
|
||||
timeout: '5.5',
|
||||
maxAttempts: '3',
|
||||
backoffBase: '1'
|
||||
})
|
||||
expect(state.apiKeyMasked).toBe('__SET__')
|
||||
})
|
||||
|
||||
it('push:缺省/未启用 → enabled false + 数值空串(null → 空槽)', () => {
|
||||
const state = normalizePushCredsGroup({ prefix: 'ai-kefu-jc' })
|
||||
expect(state.form.enabled).toBe(false)
|
||||
expect(state.form.timeout).toBe('')
|
||||
expect(state.form.maxAttempts).toBe('')
|
||||
expect(state.form.backoffBase).toBe('')
|
||||
expect(state.form.apiKey).toBe('')
|
||||
expect(state.apiKeyMasked).toBe('')
|
||||
})
|
||||
|
||||
it('ai:缺省组 → 空表单(出厂默认由后端深合并兜底)+ 未设置密钥态', () => {
|
||||
const state = normalizeAiCredsGroup(null)
|
||||
expect(state.form).toEqual({ provider: '', apiKey: '', baseUrl: '', model: '' })
|
||||
expect(state.apiKeyMasked).toBe('')
|
||||
const set = normalizeAiCredsGroup({
|
||||
provider: 'glm',
|
||||
api_key: '__SET__',
|
||||
model: 'glm-4-flash'
|
||||
})
|
||||
expect(set.form.provider).toBe('glm')
|
||||
expect(set.form.apiKey).toBe('')
|
||||
expect(set.apiKeyMasked).toBe('__SET__')
|
||||
})
|
||||
})
|
||||
|
||||
describe('build 三组载荷(表单 → PUT 整组)+ 密钥哨兵往返', () => {
|
||||
it('email:普通字段 trim、空行剔除、端口转整数', () => {
|
||||
const payload = buildEmailCredsPayload(
|
||||
{
|
||||
smtpHost: ' smtp.x.com ',
|
||||
smtpPort: ' 465 ',
|
||||
smtpUser: ' bot@x.com ',
|
||||
smtpPassword: '',
|
||||
senderName: ' HESUPIPE ',
|
||||
notifyEmails: [' a@x.com ', '', ' ']
|
||||
},
|
||||
''
|
||||
)
|
||||
expect(payload).toEqual({
|
||||
smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '' },
|
||||
sender_name: 'HESUPIPE',
|
||||
notify_emails: ['a@x.com']
|
||||
})
|
||||
})
|
||||
|
||||
it('email 密钥三态:未设置不动 → 空串;已设置不动 → 哨兵;输入新值 → 新值', () => {
|
||||
const form = (): EmailCredsForm => ({
|
||||
smtpHost: 'smtp.x.com',
|
||||
smtpPort: '465',
|
||||
smtpUser: 'bot@x.com',
|
||||
smtpPassword: '',
|
||||
senderName: '',
|
||||
notifyEmails: []
|
||||
})
|
||||
// 已设置 + 未动 → 哨兵(后端还原库值,库值不变)
|
||||
expect(buildEmailCredsPayload(form(), '__SET__').smtp).toMatchObject({
|
||||
password: '__SET__'
|
||||
})
|
||||
// 未设置 + 未动 → 空串(「清除」意图同为空串 = 显式清空)
|
||||
expect(buildEmailCredsPayload(form(), '').smtp).toMatchObject({ password: '' })
|
||||
// 输入新值 → 新值优先(无论原态)
|
||||
const typed = { ...form(), smtpPassword: 'new-pw' }
|
||||
expect(buildEmailCredsPayload(typed, '__SET__').smtp).toMatchObject({
|
||||
password: 'new-pw'
|
||||
})
|
||||
})
|
||||
|
||||
it('push:数值空串 → null(未设置哨兵)、enabled 布尔原样、密钥哨兵往返', () => {
|
||||
const payload = buildPushCredsPayload(
|
||||
{
|
||||
enabled: true,
|
||||
url: ' https://push.example/v1 ',
|
||||
apiKey: '',
|
||||
prefix: 'demo-p',
|
||||
timeout: '5.5',
|
||||
maxAttempts: '3',
|
||||
backoffBase: ''
|
||||
},
|
||||
'__SET__'
|
||||
)
|
||||
expect(payload).toEqual({
|
||||
enabled: true,
|
||||
url: 'https://push.example/v1',
|
||||
api_key: '__SET__',
|
||||
prefix: 'demo-p',
|
||||
timeout: 5.5,
|
||||
max_attempts: 3,
|
||||
retry_backoff_base: null
|
||||
})
|
||||
})
|
||||
|
||||
it('ai:api_key 哨兵/新值/清空三态 + 字段 trim', () => {
|
||||
const base = { provider: ' glm ', apiKey: '', baseUrl: ' https://x.com ', model: ' glm-4 ' }
|
||||
expect(buildAiCredsPayload({ ...base }, '__SET__')).toEqual({
|
||||
provider: 'glm',
|
||||
api_key: '__SET__',
|
||||
base_url: 'https://x.com',
|
||||
model: 'glm-4'
|
||||
})
|
||||
expect(buildAiCredsPayload({ ...base, apiKey: 'sk-new' }, '').api_key).toBe('sk-new')
|
||||
expect(buildAiCredsPayload({ ...base }, '').api_key).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate 前端预检(后端 400 的前置层)', () => {
|
||||
it('email:端口非整数/越界报错;空串合法(= 平台默认)', () => {
|
||||
const ok: EmailCredsForm = {
|
||||
smtpHost: '',
|
||||
smtpPort: '',
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
senderName: '',
|
||||
notifyEmails: []
|
||||
}
|
||||
expect(validateEmailCredsForm(ok)).toEqual([])
|
||||
for (const bad of ['0', '65536', 'abc', '1.5', '-1']) {
|
||||
expect(validateEmailCredsForm({ ...ok, smtpPort: bad })).toHaveLength(1)
|
||||
}
|
||||
expect(validateEmailCredsForm({ ...ok, smtpPort: '465' })).toEqual([])
|
||||
expect(validateEmailCredsForm({ ...ok, smtpPort: '65535' })).toEqual([])
|
||||
})
|
||||
|
||||
it('email:通知邮箱非空行须含 @(空行提交时剔除,不报错)', () => {
|
||||
const base: EmailCredsForm = {
|
||||
smtpHost: '',
|
||||
smtpPort: '',
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
senderName: '',
|
||||
notifyEmails: []
|
||||
}
|
||||
expect(validateEmailCredsForm({ ...base, notifyEmails: ['', ' '] })).toEqual([])
|
||||
expect(
|
||||
validateEmailCredsForm({ ...base, notifyEmails: ['a@x.com', 'not-an-email'] })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('push:URL 协议 / 正数 / 1-10 整数边界(与后端 _check_push 同款)', () => {
|
||||
const ok: PushCredsForm = {
|
||||
enabled: false,
|
||||
url: 'https://push.example/v1',
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
timeout: '10',
|
||||
maxAttempts: '3',
|
||||
backoffBase: '1'
|
||||
}
|
||||
expect(validatePushCredsForm(ok)).toEqual([])
|
||||
// URL 空串合法(= 平台默认);非 http(s) 协议报错
|
||||
expect(validatePushCredsForm({ ...ok, url: '' })).toEqual([])
|
||||
expect(validatePushCredsForm({ ...ok, url: 'ftp://x' })).toHaveLength(1)
|
||||
// timeout 正数
|
||||
expect(validatePushCredsForm({ ...ok, timeout: '0' })).toHaveLength(1)
|
||||
expect(validatePushCredsForm({ ...ok, timeout: '-1' })).toHaveLength(1)
|
||||
expect(validatePushCredsForm({ ...ok, timeout: 'abc' })).toHaveLength(1)
|
||||
expect(validatePushCredsForm({ ...ok, timeout: '0.5' })).toEqual([])
|
||||
// max_attempts 1-10 整数
|
||||
expect(validatePushCredsForm({ ...ok, maxAttempts: '0' })).toHaveLength(1)
|
||||
expect(validatePushCredsForm({ ...ok, maxAttempts: '11' })).toHaveLength(1)
|
||||
expect(validatePushCredsForm({ ...ok, maxAttempts: '2.5' })).toHaveLength(1)
|
||||
expect(validatePushCredsForm({ ...ok, maxAttempts: '10' })).toEqual([])
|
||||
// backoff 正数;全部空串 = 平台默认,合法
|
||||
expect(validatePushCredsForm({ ...ok, backoffBase: '0' })).toHaveLength(1)
|
||||
expect(
|
||||
validatePushCredsForm({ ...ok, url: '', timeout: '', maxAttempts: '', backoffBase: '' })
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () => {
|
||||
it('覆盖后端 reason 全部分类(dns/timeout/connect)', () => {
|
||||
expect(PUSH_PROBE_REASON_LABELS.dns).toContain('域名解析')
|
||||
expect(PUSH_PROBE_REASON_LABELS.timeout).toContain('超时')
|
||||
expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--
|
||||
配置中心(Phase 3 Task 7a:品牌与嵌入代码;Task 7b:提示词四件套与
|
||||
安全词表试金石;后续任务补凭据/版本)
|
||||
- 页骨架:左侧组导航(品牌/提示词/安全词表实装;凭据/版本占位)
|
||||
安全词表试金石;Task 7c:凭据表单与测试端点;后续任务补版本)
|
||||
- 页骨架:左侧组导航(品牌/提示词/安全词表/凭据实装;版本占位)
|
||||
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
|
||||
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
|
||||
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
|
||||
@@ -14,6 +14,10 @@
|
||||
试金石面板 POST wall/test(当前表单值预演,不落库);保存前自动跑
|
||||
试金石,有拦截/替换时二次确认——PUT config/wall 整组,400 字段路径
|
||||
内联展示
|
||||
- 凭据(Task 7c,CredsPane):邮件/推送/AI 三组凭据表单(密钥哨兵
|
||||
交互——不动保存回传 '__SET__' 保持库值,「清除」= 提交空串);数值
|
||||
前端预检 + 后端 400 双层;SMTP 测试邮件(email/test 真发一封)与
|
||||
推送连通性探测(push/test 空批量,零线索数据,三态展示)
|
||||
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
|
||||
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
|
||||
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
|
||||
@@ -79,7 +83,7 @@
|
||||
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
|
||||
</ElMenuItem>
|
||||
</ElMenu>
|
||||
<p class="nav-tip">凭据与版本在后续任务实装(当前为占位)。</p>
|
||||
<p class="nav-tip">版本历史在后续任务实装(当前为占位)。</p>
|
||||
</ElCard>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
@@ -397,6 +401,29 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 凭据 pane(Task 7c) -->
|
||||
<template v-else-if="activeGroup === 'creds'">
|
||||
<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>
|
||||
<CredsPane
|
||||
v-else-if="activeSlug"
|
||||
:slug="activeSlug"
|
||||
:email-group="snapshot?.email"
|
||||
:push-group="snapshot?.push"
|
||||
:ai-group="snapshot?.ai"
|
||||
@saved="onPaneSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其余组占位 -->
|
||||
<ElCard v-else shadow="never" class="placeholder-card">
|
||||
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
|
||||
@@ -418,6 +445,7 @@
|
||||
} from '@/api/config'
|
||||
import PromptsPane from './PromptsPane.vue'
|
||||
import WallPane from './WallPane.vue'
|
||||
import CredsPane from './CredsPane.vue'
|
||||
|
||||
defineOptions({ name: 'ConfigCenter' })
|
||||
|
||||
@@ -428,7 +456,7 @@
|
||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024
|
||||
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
|
||||
|
||||
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表实装;其余占位) --------------------
|
||||
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表 / Task 7c 凭据实装;版本占位) ------
|
||||
|
||||
const GROUP_NAV = [
|
||||
{ key: 'brand', label: '品牌' },
|
||||
@@ -439,7 +467,7 @@
|
||||
] as const
|
||||
|
||||
/** 已实装组(导航徽标) */
|
||||
const IMPLEMENTED_GROUPS = new Set<GroupKey>(['brand', 'prompts', 'wall'])
|
||||
const IMPLEMENTED_GROUPS = new Set<GroupKey>(['brand', 'prompts', 'wall', 'creds'])
|
||||
|
||||
type GroupKey = (typeof GROUP_NAV)[number]['key']
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 配置中心提示词/词表编辑模型(Phase 3 Task 7b)
|
||||
* 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c)
|
||||
*
|
||||
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue。纯逻辑无 DOM
|
||||
* 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
|
||||
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue。
|
||||
* 纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
|
||||
*
|
||||
* 事实源对齐:
|
||||
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
|
||||
@@ -14,8 +14,15 @@
|
||||
* comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的
|
||||
* 顶层键(含 "_" 前缀元信息键)原样透传;
|
||||
* - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 /
|
||||
* cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝)。
|
||||
* cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝);
|
||||
* - 凭据三组(Task 7c):DEFAULT_CONFIG 的 email/push/ai 组键 +
|
||||
* config_validation 数值边界(push.timeout/backoff 正数、max_attempts
|
||||
* 1-10 整数、notify_emails 项含 @)+ config_views 空槽回退语义
|
||||
* (空串/None = 未设置 → 回退 env,表单提示「留空 = 平台默认」)。
|
||||
* 密钥哨兵交互见 src/api/mask.ts(GET '__SET__' → 空输入 + 占位符;
|
||||
* 不动保存回传哨兵保持库值;「清除」= 提交空串)。
|
||||
*/
|
||||
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
|
||||
// ---- 提示词四件套 -----------------------------------------------------------------
|
||||
|
||||
/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
|
||||
@@ -423,3 +430,248 @@ export const DEFAULT_TOUCHSTONE_SENTENCES = [
|
||||
export function touchstoneSentences(sentences: string[]): string[] {
|
||||
return sentences.map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) --------------------------------
|
||||
|
||||
/** 邮件凭据表单(数值/密钥输入均为字符串形态——空串 = 未设置回退 env) */
|
||||
export interface EmailCredsForm {
|
||||
smtpHost: string
|
||||
smtpPort: string
|
||||
smtpUser: string
|
||||
/** 密钥输入:初始恒空,「已设置」态由占位符表达(见 mask.ts) */
|
||||
smtpPassword: string
|
||||
senderName: string
|
||||
notifyEmails: string[]
|
||||
}
|
||||
|
||||
/** 推送凭据表单(timeout/maxAttempts/backoffBase:'' = null = 平台默认) */
|
||||
export interface PushCredsForm {
|
||||
enabled: boolean
|
||||
url: string
|
||||
apiKey: string
|
||||
prefix: string
|
||||
timeout: string
|
||||
maxAttempts: string
|
||||
backoffBase: string
|
||||
}
|
||||
|
||||
/** AI 凭据表单 */
|
||||
export interface AiCredsForm {
|
||||
provider: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** GET 组 → 表单 + 密钥掩码状态(掩码值驱动占位符与 secretToSubmit) */
|
||||
export interface EmailCredsState {
|
||||
form: EmailCredsForm
|
||||
passwordMasked: string
|
||||
}
|
||||
|
||||
export interface PushCredsState {
|
||||
form: PushCredsForm
|
||||
apiKeyMasked: string
|
||||
}
|
||||
|
||||
export interface AiCredsState {
|
||||
apiKeyMasked: string
|
||||
form: AiCredsForm
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意非空字符串 → 掩码哨兵。比 isMaskedSentinel 更保守:即使后端掩码
|
||||
* 失效漏出原始密钥(理论不可达),也按「已设置」处理——不动保存回传
|
||||
* 哨兵由后端还原库值,绝不把原始值再提交回去;空串/null/undefined → ''
|
||||
* (未设置)。
|
||||
*/
|
||||
export function asSecretMasked(value: unknown): string {
|
||||
return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : ''
|
||||
}
|
||||
|
||||
/** 防御读取:数值 → 表单串(非有限数值/缺省 → '') */
|
||||
function numberToForm(value: unknown): string {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
|
||||
}
|
||||
|
||||
/** 防御读取:字符串数组(非字符串项剔除) */
|
||||
function strList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((v): v is string => typeof v === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
/** 表单串 → 整数 | null(空串 = 未设置;非整数由 validate 前置拦截) */
|
||||
function intOrNull(value: string): number | null {
|
||||
const n = Number(value.trim())
|
||||
return value.trim() !== '' && Number.isInteger(n) ? n : null
|
||||
}
|
||||
|
||||
/** 表单串 → 数值 | null(空串 = 未设置;非数值由 validate 前置拦截) */
|
||||
function numOrNull(value: string): number | null {
|
||||
const n = Number(value.trim())
|
||||
return value.trim() !== '' && Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** GET 的 email 组 → 邮件表单(smtp 缺省/null → 全空槽,回退 env) */
|
||||
export function normalizeEmailCredsGroup(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): EmailCredsState {
|
||||
const source = value ?? {}
|
||||
const smtp = asRecord(source.smtp) ?? {}
|
||||
return {
|
||||
form: {
|
||||
smtpHost: asStr(smtp.host),
|
||||
smtpPort: numberToForm(smtp.port),
|
||||
smtpUser: asStr(smtp.user),
|
||||
smtpPassword: '',
|
||||
senderName: asStr(source.sender_name),
|
||||
notifyEmails: strList(source.notify_emails)
|
||||
},
|
||||
passwordMasked: asSecretMasked(smtp.password)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET 的 push 组 → 推送表单 */
|
||||
export function normalizePushCredsGroup(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): PushCredsState {
|
||||
const source = value ?? {}
|
||||
return {
|
||||
form: {
|
||||
enabled: source.enabled === true,
|
||||
url: asStr(source.url),
|
||||
apiKey: '',
|
||||
prefix: asStr(source.prefix),
|
||||
timeout: numberToForm(source.timeout),
|
||||
maxAttempts: numberToForm(source.max_attempts),
|
||||
backoffBase: numberToForm(source.retry_backoff_base)
|
||||
},
|
||||
apiKeyMasked: asSecretMasked(source.api_key)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET 的 ai 组 → AI 表单 */
|
||||
export function normalizeAiCredsGroup(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): AiCredsState {
|
||||
const source = value ?? {}
|
||||
return {
|
||||
form: {
|
||||
provider: asStr(source.provider),
|
||||
apiKey: '',
|
||||
baseUrl: asStr(source.base_url),
|
||||
model: asStr(source.model)
|
||||
},
|
||||
apiKeyMasked: asSecretMasked(source.api_key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件表单 → PUT config/email 整组载荷。密钥走 secretToSubmit(未动 →
|
||||
* 哨兵保持库值;「清除」按钮已把 maskedState 置 '',提交空串 = 显式清空)。
|
||||
* 数值/邮箱格式由 validateEmailCredsForm 前置把关(后端 400 二层兜底)。
|
||||
*/
|
||||
export function buildEmailCredsPayload(
|
||||
form: EmailCredsForm,
|
||||
passwordMasked: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
smtp: {
|
||||
host: form.smtpHost.trim(),
|
||||
port: intOrNull(form.smtpPort),
|
||||
user: form.smtpUser.trim(),
|
||||
password: secretToSubmit(form.smtpPassword, passwordMasked)
|
||||
},
|
||||
sender_name: form.senderName.trim(),
|
||||
notify_emails: form.notifyEmails
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e !== '')
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送表单 → PUT config/push 整组载荷(prefix 冲突 400 由后端跨租户查重) */
|
||||
export function buildPushCredsPayload(
|
||||
form: PushCredsForm,
|
||||
apiKeyMasked: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
enabled: form.enabled,
|
||||
url: form.url.trim(),
|
||||
api_key: secretToSubmit(form.apiKey, apiKeyMasked),
|
||||
prefix: form.prefix.trim(),
|
||||
timeout: numOrNull(form.timeout),
|
||||
max_attempts: intOrNull(form.maxAttempts),
|
||||
retry_backoff_base: numOrNull(form.backoffBase)
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 表单 → PUT config/ai 整组载荷 */
|
||||
export function buildAiCredsPayload(
|
||||
form: AiCredsForm,
|
||||
apiKeyMasked: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
provider: form.provider.trim(),
|
||||
api_key: secretToSubmit(form.apiKey, apiKeyMasked),
|
||||
base_url: form.baseUrl.trim(),
|
||||
model: form.model.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/** 邮件表单预检(空槽合法——回退 env;只查非空行的格式) */
|
||||
export function validateEmailCredsForm(form: EmailCredsForm): string[] {
|
||||
const errors: string[] = []
|
||||
const port = form.smtpPort.trim()
|
||||
if (port !== '') {
|
||||
const n = Number(port)
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
errors.push('SMTP 端口:须为 1-65535 的整数(留空 = 平台默认)')
|
||||
}
|
||||
}
|
||||
form.notifyEmails.forEach((row, i) => {
|
||||
const t = row.trim()
|
||||
if (t !== '' && !t.includes('@')) {
|
||||
errors.push(`通知邮箱第 ${i + 1} 行:须为含 @ 的邮箱地址`)
|
||||
}
|
||||
})
|
||||
return errors
|
||||
}
|
||||
|
||||
/** 推送表单预检(与后端 _check_push 的数值边界同款:正数 / 1-10 整数) */
|
||||
export function validatePushCredsForm(form: PushCredsForm): string[] {
|
||||
const errors: string[] = []
|
||||
const url = form.url.trim()
|
||||
if (url !== '' && !/^https?:\/\//.test(url)) {
|
||||
errors.push('推送 URL:须以 http:// 或 https:// 开头')
|
||||
}
|
||||
const timeout = form.timeout.trim()
|
||||
if (timeout !== '') {
|
||||
const n = Number(timeout)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
errors.push('推送超时(秒):须为正数(留空 = 平台默认)')
|
||||
}
|
||||
}
|
||||
const attempts = form.maxAttempts.trim()
|
||||
if (attempts !== '') {
|
||||
const n = Number(attempts)
|
||||
if (!Number.isInteger(n) || n < 1 || n > 10) {
|
||||
errors.push('最大尝试次数:须为 1-10 的整数(留空 = 平台默认)')
|
||||
}
|
||||
}
|
||||
const backoff = form.backoffBase.trim()
|
||||
if (backoff !== '') {
|
||||
const n = Number(backoff)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
errors.push('重试退避基数(秒):须为正数(留空 = 平台默认)')
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/** 推送探测不可达原因 → 修复提示文案(后端 reason 分类的前端展示层) */
|
||||
export const PUSH_PROBE_REASON_LABELS: Record<string, string> = {
|
||||
dns: '域名解析失败(检查 URL 拼写)',
|
||||
timeout: '连接超时',
|
||||
connect: '连接被拒绝或网络不可达'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user