Files
ai-kefu-admin/admin/src/views/config/center/CredsPane.vue
T
浅念 bf923d9cf7 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>
2026-10-13 20:55:40 +08:00

744 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
凭据三组编辑(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>