优化: 设置域体验完善

- 校验失败红框定位字段,保存反馈改持久化成功后
- 数值越界钳制,双提示队列化不再重叠
- 搜索防抖跳分类,托盘气泡文案进多语言
This commit is contained in:
lxy
2026-10-03 17:57:43 +08:00
parent f67e65633b
commit 8396849d04
19 changed files with 329 additions and 93 deletions
+58 -11
View File
@@ -67,7 +67,10 @@
<div class="form-grid">
<div class="form-field">
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
<!-- 走查 P3-10:校验失败定位字段——红框 + @input 清错(示范级,仅本表单) -->
<input ref="inputName" v-model="providerForm.name" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.name }"
:placeholder="$t('settings.phProviderName')" @input="fieldErrors.name = false" />
</div>
<!-- 项 D:厂商预设下拉 — 选中自动填充 providerType/baseUrl/defaultModel(用户可改覆盖)。
新建态独立"快捷选择";编辑态回填后切预设仍可覆盖(等价快捷填充),不阻塞手改。
@@ -88,12 +91,16 @@
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelBaseUrl') }}</label>
<input v-model="providerForm.baseUrl" class="setting-input" :placeholder="$t('settings.phBaseUrl')" />
<input ref="inputBaseUrl" v-model="providerForm.baseUrl" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.baseUrl }"
:placeholder="$t('settings.phBaseUrl')" @input="fieldErrors.baseUrl = false" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<!-- 走查 P3-9:type 恒 password 无法核对已粘贴密钥,加显隐切换 -->
<input v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'" :placeholder="$t('settings.phApiKey')" />
<input ref="inputApiKey" v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'"
:class="{ 'setting-input--invalid': fieldErrors.apiKey }"
:placeholder="$t('settings.phApiKey')" @input="fieldErrors.apiKey = false" />
<button type="button" class="btn-link" :title="$t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow')"
@click="showApiKey = !showApiKey">
{{ $t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow') }}
@@ -104,7 +111,9 @@
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
<input ref="inputDefaultModel" v-model="providerForm.defaultModel" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.defaultModel }"
:placeholder="$t('settings.phDefaultModel')" @input="fieldErrors.defaultModel = false" />
<!-- 方向 A(轻量):defaultModel 仅作"建议起点"——预设 model id 易过时(DeepSeek chat 停用 /
Gemini 2.5→3.5 等),提示用户点「测试连接」拉真实模型列表覆盖,降低踩过时默认值的困惑。 -->
<div class="form-field-hint">{{ $t('settings.defaultModelHint') }}</div>
@@ -112,6 +121,7 @@
<!-- 供应商级采样温度覆盖:部分受限模型(如 Kimi 系)仅接受 temperature=1,留空走默认 0.7 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelSamplingTemp') }}</label>
<!-- 走查 P3-14:原生 min/max 只约束步进按钮,手输越界由 saveProvider clamp + toast 提示 -->
<input type="number" v-model.number="providerForm.temperature" class="setting-input"
:placeholder="$t('settings.phSamplingTemp')" step="0.1" min="0" max="2" />
</div>
@@ -192,7 +202,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
@@ -228,6 +238,25 @@ const aiProviders = ref<AiProviderConfig[]>([])
// 密钥显隐切换(走查 P3-9):默认隐藏;重开表单时复位,防上一家 provider 的显钥状态延续
const showApiKey = ref(false)
// 走查 P3-10:校验失败只 toast 不定位字段——补字段级红框 + 滚动聚焦首个缺失字段。
// 仅保存必填四项(name/baseUrl/apiKey/defaultModel)做示范级实现;红框在对应输入框
// @input 时即清(用户开始修正就不该继续红着)。
const fieldErrors = reactive({ name: false, baseUrl: false, apiKey: false, defaultModel: false })
const inputName = useTemplateRef<HTMLInputElement>('inputName')
const inputBaseUrl = useTemplateRef<HTMLInputElement>('inputBaseUrl')
const inputApiKey = useTemplateRef<HTMLInputElement>('inputApiKey')
const inputDefaultModel = useTemplateRef<HTMLInputElement>('inputDefaultModel')
/** 聚焦首个红框字段(focus 默认行为即滚动到该元素,无需额外 scrollIntoView) */
function focusFirstError() {
const el = fieldErrors.name ? inputName.value
: fieldErrors.baseUrl ? inputBaseUrl.value
: fieldErrors.apiKey ? inputApiKey.value
: fieldErrors.defaultModel ? inputDefaultModel.value
: null
el?.focus()
}
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
const providerForm = reactive({
@@ -433,6 +462,7 @@ function openProviderForm(p?: AiProviderConfig) {
}
providerForm.fetching = false
providerForm.saveError = null // 重开表单清旧错误横幅
fieldErrors.name = fieldErrors.baseUrl = fieldErrors.apiKey = fieldErrors.defaultModel = false // 重开表单清字段红框
showApiKey.value = false // 密钥默认隐藏,不延续上次表单的显钥状态(防窥)
providerForm.visible = true
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
@@ -547,10 +577,28 @@ function classifySaveError(raw: string): { title: string; detail: string } {
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
if (!providerForm.name || !providerForm.baseUrl || (needKey && !providerForm.apiKey) || !providerForm.defaultModel) {
// 走查 P3-10:逐字段判定缺失,红框标出并滚动聚焦首个缺失字段(toast 保留,双通道反馈)
fieldErrors.name = !providerForm.name
fieldErrors.baseUrl = !providerForm.baseUrl
fieldErrors.apiKey = needKey && !providerForm.apiKey
fieldErrors.defaultModel = !providerForm.defaultModel
if (fieldErrors.name || fieldErrors.baseUrl || fieldErrors.apiKey || fieldErrors.defaultModel) {
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
focusFirstError()
return
}
// 走查 P3-14:采样温度钳制 [0,2](原生 min/max 只约束步进按钮,手输 99 照存)。
// 越界有限值 → clamp + toast 告知修正值;NaN/清空残留 → 归一 null(走默认 0.7,同步清显示)
let temperatureOverride: number | undefined
if (typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)) {
if (providerForm.temperature < 0 || providerForm.temperature > 2) {
providerForm.temperature = Math.min(2, Math.max(0, providerForm.temperature))
emit('toast', t('settings.toastTempClamped', { v: providerForm.temperature }), 'warning')
}
temperatureOverride = providerForm.temperature
} else {
providerForm.temperature = null
}
providerForm.saving = true
try {
await aiApi.saveProvider({
@@ -561,11 +609,7 @@ async function saveProvider() {
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
// 采样温度非空且为有效数字才带(清空输入 v-model.number 可能残留 ''/NaN,不过滤会污染后端 config)
temperatureOverride:
typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)
? providerForm.temperature
: undefined,
temperatureOverride,
})
// 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
providerForm.saveError = null
@@ -788,6 +832,9 @@ onMounted(loadProviders)
/* 输入/下拉在 form-field 内 flex 填满(基础样式走全局 settings.css .setting-input/.setting-select) */
.form-field .setting-input,
.form-field .setting-select { flex: 1; min-width: 0; }
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */