feat(admin): 凭据页套视觉基线+SecretField 四件套

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 18:15:23 +08:00
parent 712c5f9027
commit 06e28e4435
2 changed files with 241 additions and 315 deletions
+236 -314
View File
@@ -7,38 +7,30 @@
- AI(ai 组):provider / API Key / base_url / model
- 密钥哨兵交互(mask.ts):GET 密钥为 '__SET__' → 输入框留空 + 占位符
「已设置,输入新值覆盖」;不动保存回传哨兵保持库值;「清除」按钮 =
显式清空意图(提交空串),可再点「恢复保持」撤销
显式清空意图(提交空串),可再点「恢复保持」撤销——交互由 SecretField
四件套组件承载(Task 5 起),提交值仍由父层 build*Payload 计算
- SMTP 测试邮件:收件人 + 按钮 → POST email/test——用**已保存**配置真发
一封(先保存再测试);未配置 400 / 发送失败 sent=false 均内联展示
- 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据)
——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败
(401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定
- 重置出厂(Task 7d):三卡头部危险色小按钮 → 确认框 → POST
- 重置出厂(Task 7d):卡底危险行小按钮 → 确认框 → POST
config/{group}/reset → 表单/密钥态回档案值 + emit saved;push 出厂
前缀被其他租户占用的 400 detail 内联展示
- Task 5 视觉基线:三卡套 ConfigCard(图标/副标题/信息条/footer/danger
行);footer-left「出问题?回滚 →」emit navigate('versions')(父层
接线在后续任务);表单区双列 grid
-->
<template>
<div class="creds-pane">
<!-- 邮件 -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">邮件(email 组 · SMTP 发信)</span>
<div class="header-side">
<span class="card-sub">空槽 = 未设置 → 回退平台环境默认;保存即写新版本并热生效</span>
<ElButton
size="small"
type="danger"
plain
:loading="resettingEmail"
@click="onResetGroup('email')"
>
重置出厂
</ElButton>
</div>
</div>
</template>
<ConfigCard
icon="solar:letter-linear"
title="邮件通知"
subtitle="客户留下线索后,系统用这组配置发通知邮件"
info="整组保存 · 立即生效 · 产生新版本可回滚;带 • 的字段留空 = 沿用平台默认"
danger-label="恢复出厂:把邮件配置重置为初始档案值"
>
<ElAlert
v-if="emailError"
type="error"
@@ -49,81 +41,54 @@
/>
<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">
<div class="form-grid">
<ElFormItem label="SMTP 服务器">
<ElInput
v-model="emailForm.smtpPassword"
type="password"
show-password
autocomplete="new-password"
:placeholder="emailPasswordPlaceholder"
class="secret-input"
v-model="emailForm.smtpHost"
placeholder="如 smtp.exmail.qq.com(留空 = 平台默认)"
/>
<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>
</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="发件人名称">
<ElInput
v-model="emailForm.senderName"
placeholder='邮件【发件人名称】展示(留空 = "HESUPIPE")'
/>
</ElFormItem>
<ElFormItem label="SMTP 密码" class="span-2">
<SecretField
v-model="emailForm.smtpPassword"
:masked="emailPasswordMasked"
:cleared="emailPasswordCleared"
@update:cleared="emailPasswordCleared = $event"
/>
</ElFormItem>
<ElFormItem label="通知邮箱" class="span-2">
<ElSelect
v-model="emailForm.notifyEmails"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="输入邮箱后回车添加(新询盘通知收件人,留空 = 平台默认)"
class="wide-control"
/>
</ElFormItem>
</div>
</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
@@ -132,11 +97,11 @@
class="test-input"
/>
<ElButton :loading="emailTesting" :disabled="!slug" @click="onTestEmail">
发测试邮件
发送测试邮件(真发一封)
</ElButton>
</div>
<p class="test-hint">
后端用**已保存**的邮件配置真发一封(主题「[all-kefu] SMTP 测试邮件」)
系统用**已保存**的邮件配置真发一封(主题「[all-kefu] SMTP 测试邮件」)
——改完先保存再测试;SMTP 未配置时返回 400。
</p>
<ElAlert
@@ -148,28 +113,42 @@
class="test-result"
@close="emailTestResult = null"
/>
</ElCard>
<template #footer-left>
<ElButton link type="primary" size="small" @click="$emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput
v-model="emailComment"
maxlength="200"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton type="primary" :loading="emailSaving" @click="onSaveEmail">保存</ElButton>
</template>
<template #danger>
<ElButton
size="small"
type="danger"
plain
:loading="resettingEmail"
@click="onResetGroup('email')"
>
重置出厂…
</ElButton>
</template>
</ConfigCard>
<!-- 推送 -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">推送(push 组 · 线索推送 UniService)</span>
<div class="header-side">
<span class="card-sub">数值留空 = 平台默认;前缀全局唯一(跨租户查重)</span>
<ElButton
size="small"
type="danger"
plain
:loading="resettingPush"
@click="onResetGroup('push')"
>
重置出厂
</ElButton>
</div>
</div>
</template>
<ConfigCard
icon="solar:upload-square-line-duotone"
title="线索推送"
subtitle="新线索实时推送到你的业务系统(UniService)"
info="数值留空 = 平台默认;前缀全局唯一(跨租户查重)——整组保存 · 立即生效 · 可回滚"
danger-label="恢复出厂:把推送配置重置为初始档案值"
>
<ElAlert
v-if="pushError"
type="error"
@@ -180,79 +159,52 @@
/>
<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">
<div class="form-grid">
<ElFormItem label="启用推送" class="span-2">
<ElSwitch v-model="pushForm.enabled" />
<span class="switch-hint">关闭后新线索不推送(只落库)</span>
</ElFormItem>
<ElFormItem label="推送 URL">
<ElInput
v-model="pushForm.apiKey"
type="password"
show-password
autocomplete="new-password"
:placeholder="pushApiKeyPlaceholder"
class="secret-input"
v-model="pushForm.url"
placeholder="https://push.example/open/v1/inquiry/push(留空 = 平台默认)"
/>
<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>
</ElFormItem>
<ElFormItem label="线索前缀">
<ElInput
v-model="pushForm.prefix"
placeholder="external_key 前缀(全局唯一;留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="API Key" class="span-2">
<SecretField
v-model="pushForm.apiKey"
:masked="pushApiKeyMasked"
:cleared="pushApiKeyCleared"
@update:cleared="pushApiKeyCleared = $event"
/>
</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>
</div>
</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">
@@ -304,28 +256,42 @@
:description="pushProbe.error || undefined"
@close="pushProbe = null"
/>
</ElCard>
<template #footer-left>
<ElButton link type="primary" size="small" @click="$emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput
v-model="pushComment"
maxlength="200"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton type="primary" :loading="pushSaving" @click="onSavePush">保存</ElButton>
</template>
<template #danger>
<ElButton
size="small"
type="danger"
plain
:loading="resettingPush"
@click="onResetGroup('push')"
>
重置出厂…
</ElButton>
</template>
</ConfigCard>
<!-- AI -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">AI(ai 组 · 模型服务)</span>
<div class="header-side">
<span class="card-sub">密钥空槽 = 未设置 → 回退平台集团凭据(spec §5.5)</span>
<ElButton
size="small"
type="danger"
plain
:loading="resettingAi"
@click="onResetGroup('ai')"
>
重置出厂
</ElButton>
</div>
</div>
</template>
<ConfigCard
icon="solar:cpu-bolt-line-duotone"
title="AI 服务"
subtitle="对话引擎调用的大模型服务凭据"
info="整组保存 · 立即生效 · 可回滚;留空 = 平台默认"
danger-label="恢复出厂:把 AI 配置重置为初始档案值"
>
<ElAlert
v-if="aiError"
type="error"
@@ -336,63 +302,62 @@
/>
<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">
<div class="form-grid">
<ElFormItem label="Provider">
<ElInput
v-model="aiForm.apiKey"
type="password"
show-password
autocomplete="new-password"
:placeholder="aiApiKeyPlaceholder"
class="secret-input"
v-model="aiForm.provider"
placeholder="如 glm(留空 = 平台默认)"
/>
<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>
</ElFormItem>
<ElFormItem label="模型">
<ElInput
v-model="aiForm.model"
placeholder="如 glm-4-flash(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="Base URL" class="span-2">
<ElInput
v-model="aiForm.baseUrl"
placeholder="如 https://open.bigmodel.cn/api/paas/v4(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="API Key" class="span-2">
<SecretField
v-model="aiForm.apiKey"
:masked="aiApiKeyMasked"
:cleared="aiApiKeyCleared"
@update:cleared="aiApiKeyCleared = $event"
/>
</ElFormItem>
</div>
</ElForm>
<div class="save-bar">
<ElButton type="primary" :loading="aiSaving" @click="onSaveAi">
保存 AI 组(整组提交,热生效)
<template #footer-left>
<ElButton link type="primary" size="small" @click="$emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</div>
</ElCard>
</template>
<template #footer-right>
<ElInput
v-model="aiComment"
maxlength="200"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton type="primary" :loading="aiSaving" @click="onSaveAi">保存</ElButton>
</template>
<template #danger>
<ElButton
size="small"
type="danger"
plain
:loading="resettingAi"
@click="onResetGroup('ai')"
>
重置出厂…
</ElButton>
</template>
</ConfigCard>
</div>
</template>
@@ -413,8 +378,9 @@
type PushCredsForm
} from './model'
import { putConfigGroup, testEmail, testPush, type PushTestResponse } from '@/api/config'
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
import { confirmAndResetGroup } from './reset'
import ConfigCard from './ConfigCard.vue'
import SecretField from './SecretField.vue'
defineOptions({ name: 'CredsPane' })
@@ -439,6 +405,8 @@
config: Record<string, unknown>
}
): void
/** footer 回滚入口:请求父层切到对应组('versions' = 版本历史) */
(e: 'navigate', group: string): void
}>()
// ---- 编辑态(组引用变化 = 保存回写/租户切换/重载 → 重置表单与密钥态) ----------------
@@ -513,20 +481,6 @@
{ 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> {
@@ -698,7 +652,8 @@
}
}
} catch (error) {
// 400「SMTP 未配置」等后端预检拒绝(ApiError.message = detail)
// 400「SMTP 未配置」/ 429「测试端点调用过于频繁」等后端预检拒绝
// (ApiError.message = detail,白话文案直接内联展示)
emailTestResult.value = {
type: 'error',
text: error instanceof Error ? error.message : '测试发送失败'
@@ -724,7 +679,7 @@
try {
pushProbe.value = await testPush(props.slug, { showErrorMessage: false })
} catch (error) {
// 400「推送 URL 未配置」等后端预检拒绝
// 400「推送 URL 未配置」/ 429「测试端点调用过于频繁」等后端预检拒绝
pushProbeError.value = error instanceof Error ? error.message : '探测失败'
} finally {
pushProbing.value = false
@@ -742,52 +697,21 @@
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);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
.pane-error {
margin-bottom: 12px;
}
.secret-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
// 表单区双列 grid(span-2 = 独占整行:密钥/长控件/开关)
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
.secret-input {
flex: 1;
min-width: 0;
.span-2 {
grid-column: span 2;
}
}
.field-warn {
margin: 4px 0 0;
font-size: 12px;
color: var(--el-color-danger);
}
.switch-hint {
margin-left: 10px;
font-size: 12px;
@@ -798,10 +722,8 @@
width: 100%;
}
.save-bar {
display: flex;
justify-content: flex-end;
margin-top: 8px;
.comment-input {
width: 220px;
}
.test-divider {
@@ -29,7 +29,7 @@
{{ cleared ? '恢复保持' : '清除' }}
</ElButton>
</div>
<p v-if="state.badge === 'pending-clear'" class="field-warn">
<p v-if="state.badge === 'pending-clear'" role="status" class="field-warn">
保存后将清除已存密钥(空槽回退平台默认)
</p>
</div>
@@ -67,6 +67,10 @@
align-items: center;
gap: 8px;
:deep(.el-button) {
flex-shrink: 0;
}
.secret-input {
flex: 1;
min-width: 0;