bf923d9cf7
- 后端 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>
744 lines
24 KiB
Vue
744 lines
24 KiB
Vue
<!--
|
||
凭据三组编辑(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>
|