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