feat(frontend): 实现中英文切换与拼写检查

This commit is contained in:
2026-09-05 09:38:23 +08:00
parent a35b577d66
commit ef961d322b
44 changed files with 722 additions and 519 deletions
+24 -23
View File
@@ -5,6 +5,7 @@ import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
@@ -37,7 +38,7 @@ async function previewRequest() {
const generation = draftGeneration
error.value = ''
try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
@@ -50,8 +51,8 @@ async function probeRequest() {
error.value = ''; probeResult.value = ''; probing.value = true
const generation = draftGeneration
try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
if (apiKey.value.trim()) throw new Error('请先保存新的 API Key,再进行推理验证。')
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.'))
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || '推理验证',base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
@@ -75,7 +76,7 @@ async function loadPresets() {
try {
presets.value = await service.listProviderPresets()
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' }
} catch { presetsError.value = t('预设加载失败,请重试,或填写自定义服务。', 'Preset loading failed. Retry or enter a custom service.') }
finally { presetsLoading.value = false }
}
@@ -88,7 +89,7 @@ onMounted(async () => {
const result = await service.getCredentialStatus(credentialId.value)
if (active && generation === credentialGeneration) configured.value = result
} catch {
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。'
if (active && generation === credentialGeneration) credentialError.value = t('无法检查已保存的凭据。可输入新密钥,或关闭后重试。', 'Could not check the saved credential. Enter a new key or close and retry.')
} finally {
if (generation === credentialGeneration) credentialLoading.value = false
}
@@ -148,9 +149,9 @@ async function save() {
error.value = ''
saving.value = true
try {
if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。')
if (!requestJsonValid.value) throw new Error('请先修正自定义请求 JSON。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
if (!form.name.trim() || !form.base_url.trim()) throw new Error(t('请填写名称和 Base URL。', 'Enter a name and Base URL.'))
if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value }
if (apiKey.value.trim()) {
@@ -171,7 +172,7 @@ async function save() {
: await service.createProvider({ ...data, credential_id: reference })
if (active) { emit('saved', saved); close() }
} catch (reason) {
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。'
if (active) error.value = reason instanceof Error ? reason.message : t('Provider 保存失败,请重试。', 'Provider save failed. Please retry.')
} finally { apiKey.value = ''; saving.value = false }
}
</script>
@@ -179,32 +180,32 @@ async function save() {
<template>
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? t('编辑 Provider', 'Edit Provider') : t('新增 Provider', 'Add Provider') }}</h2><button type="button" class="button-secondary" :aria-label="t('关闭提供商表单', 'Close provider form')" @click="close">{{ t('关闭', 'Close') }}</button></div>
<p v-if="presetsLoading" class="subtle" role="status">{{ t('正在加载提供商预设', 'Loading provider presets') }}</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">{{ t('重试', 'Retry') }}</button></div>
<form @submit.prevent="save" @input="requestPreview = ''" @change="requestPreview = ''">
<fieldset :disabled="saving">
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
<div class="form-grid">
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field"><span>{{ t('接入协议', 'Protocol') }}</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
<label class="field"><span>{{ t('名称', 'Name') }}</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" required @change="changeConnection" /></label>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? t('已配置,留空表示不修改', 'Configured; leave blank to keep it') : t('请输入 API Key(无鉴权服务可留空)', 'Enter an API key (optional for unauthenticated services)')" /><small class="subtle">{{ t('密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用', 'The local AI Core encrypts the key; provider settings store only its credential reference.') }}</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">{{ t('正在检查凭据状态', 'Checking credential status') }}</p>
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
<label class="field wide"><span>{{ t('默认聊天模型', 'Default chat model') }}</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" :placeholder="t('输入模型 ID,或保存后获取模型列表', 'Enter a model ID, or save to fetch the model list')" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
</div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> {{ t('启用', 'Enabled') }}</label>
<RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" />
<div class="inline-actions"><label>预览能力<select v-model="previewCapability" class="select"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">转写</option><option value="speaker_matching">声纹</option></select></label><label><input v-model="previewStream" type="checkbox" />流式聊天</label></div>
<button type="button" class="button-secondary" @click="previewRequest">预览最终请求隐藏正文</button>
<button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? '推理验证中' : '发送测试推理请求' }}</button>
<p class="subtle">推理验证会向当前模型发送固定短消息并计入实际用量媒体参数请通过真实转写或声纹操作验证</p><p v-if="probeResult" role="status">{{ probeResult }}</p>
<div class="inline-actions"><label>{{ t('预览能力', 'Preview capability') }}<select v-model="previewCapability" class="select"><option value="chat">{{ t('聊天', 'Chat') }}</option><option value="embedding">Embedding</option><option value="transcription">{{ t('转写', 'Transcription') }}</option><option value="speaker_matching">{{ t('声纹', 'Speaker') }}</option></select></label><label><input v-model="previewStream" type="checkbox" />{{ t('流式聊天', 'Streaming chat') }}</label></div>
<button type="button" class="button-secondary" @click="previewRequest">{{ t('预览最终请求隐藏正文', 'Preview final request (content hidden)') }}</button>
<button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? t('推理验证中', 'Testing inference') : t('发送测试推理请求', 'Send test inference request') }}</button>
<p class="subtle">{{ t('推理验证会向当前模型发送固定短消息并计入实际用量媒体参数请通过真实转写或声纹操作验证。', 'The inference test sends a fixed short message to the current model and counts toward usage. Validate media parameters through an actual transcription or speaker operation.') }}</p><p v-if="probeResult" role="status">{{ probeResult }}</p>
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
</fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? t('保存中…', 'Saving…') : t('保存提供商', 'Save provider') }}</button><button type="button" class="button-secondary" @click="close">{{ t('取消', 'Cancel') }}</button></div>
</form>
</div>
</div>