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>
|