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