Files
ai-translate-client/src/components/SettingsView.vue
2026-03-27 17:50:06 -04:00

433 lines
26 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.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { Play, Settings, Type, Save, Check, Plus, Trash2, ChevronDown, Eye, EyeOff } from 'lucide-vue-next';
import { useSettingsStore, DEFAULT_TEMPLATE, DEFAULT_EVALUATION_TEMPLATE, DEFAULT_REFINEMENT_TEMPLATE, type ApiProfile } from '../stores/settings';
import { cn } from '../lib/utils';
const settings = useSettingsStore();
const settingsCategory = ref<'api' | 'general' | 'prompts'>('api');
const showApiKey = ref(false);
const newProfileName = ref('');
const isSavingProfile = ref(false);
const saveCurrentAsProfile = () => {
if (!newProfileName.value.trim()) return;
const newProfile: ApiProfile = {
id: crypto.randomUUID(),
name: newProfileName.value.trim(),
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
modelName: settings.modelName
};
settings.profiles.push(newProfile);
newProfileName.value = '';
isSavingProfile.value = false;
};
const applyProfile = (p: ApiProfile) => {
settings.apiBaseUrl = p.apiBaseUrl;
settings.apiKey = p.apiKey;
settings.modelName = p.modelName;
};
const deleteProfile = (id: string) => {
settings.profiles = settings.profiles.filter(p => p.id !== id);
};
const evaluationProfileDropdownOpen = ref(false);
const toggleDropdown = (type: string) => {
if (type === 'evaluationProfile') evaluationProfileDropdownOpen.value = !evaluationProfileDropdownOpen.value;
};
const currentEvaluationProfileLabel = computed(() => {
if (!settings.evaluationProfileId) return '使用主翻译配置(默认)';
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
return profile ? `${profile.name}${profile.modelName}` : '使用主翻译配置(默认)';
});
const handleGlobalClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.lang-dropdown')) {
evaluationProfileDropdownOpen.value = false;
}
};
onMounted(() => window.addEventListener('click', handleGlobalClick));
onUnmounted(() => window.removeEventListener('click', handleGlobalClick));
</script>
<template>
<!-- Settings View -->
<div class="flex-1 flex overflow-hidden bg-slate-100/50 dark:bg-slate-950">
<!-- Settings Sidebar (Master) -->
<div class="w-64 border-r dark:border-slate-800 flex flex-col bg-white/60 dark:bg-slate-900/40">
<div class="p-4 border-b dark:border-slate-800 shrink-0">
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">系统设置</h2>
</div>
<nav class="flex-1 overflow-y-auto p-3 space-y-1">
<button
@click="settingsCategory = 'api'"
:class="cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
settingsCategory === 'api' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800/50'
)"
>
<div :class="cn('p-1.5 rounded-md', settingsCategory === 'api' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-800')">
<Play class="w-4 h-4" />
</div>
API 与模型
</button>
<button
@click="settingsCategory = 'general'"
:class="cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
settingsCategory === 'general' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800/50'
)"
>
<div :class="cn('p-1.5 rounded-md', settingsCategory === 'general' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-800')">
<Settings class="w-4 h-4" />
</div>
常规设置
</button>
<button
@click="settingsCategory = 'prompts'"
:class="cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
settingsCategory === 'prompts' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800/50'
)"
>
<div :class="cn('p-1.5 rounded-md', settingsCategory === 'prompts' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-800')">
<Type class="w-4 h-4" />
</div>
提示词工程
</button>
</nav>
</div>
<!-- Settings Content (Detail) -->
<div class="flex-1 overflow-y-auto p-6 md:p-10 custom-scrollbar bg-slate-50/30 dark:bg-transparent">
<div class="max-w-3xl mx-auto space-y-8 pb-20">
<!-- API & Models -->
<template v-if="settingsCategory === 'api'">
<div class="mb-6 border-b dark:border-slate-800 pb-4">
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">API 与模型</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">配置与大语言模型通信的接口地址和凭证</p>
</div>
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">当前接口配置</h2>
<div v-if="!isSavingProfile" class="flex items-center gap-2">
<button
@click="isSavingProfile = true"
class="text-xs flex items-center gap-1.5 text-blue-600 dark:text-blue-400 hover:underline px-2 py-1 rounded"
>
<Save class="w-3.5 h-3.5" />
保存为预设
</button>
</div>
<div v-else class="flex items-center gap-2 bg-white dark:bg-slate-800 p-1 rounded-lg border dark:border-slate-700 shadow-sm animate-in fade-in zoom-in duration-200">
<input
v-model="newProfileName"
type="text"
placeholder="输入预设名称..."
class="text-xs px-2 py-1 bg-transparent outline-none w-32 dark:text-slate-200"
@keyup.enter="saveCurrentAsProfile"
/>
<button
@click="saveCurrentAsProfile"
:disabled="!newProfileName.trim()"
class="p-1 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded disabled:opacity-50"
>
<Check class="w-3.5 h-3.5" />
</button>
<button
@click="isSavingProfile = false; newProfileName = ''"
class="p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 rounded"
>
<Plus class="w-3.5 h-3.5 rotate-45" />
</button>
</div>
</div>
<div class="bg-white/80 dark:bg-slate-900 rounded-2xl shadow-sm border dark:border-slate-800 p-6 space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">API Base URL</label>
<input
v-model="settings.apiBaseUrl"
type="text"
class="w-full px-4 py-2.5 border dark:border-slate-700 rounded-xl bg-slate-50/50 dark:bg-slate-950 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-sm text-slate-900 dark:text-slate-100"
placeholder="https://api.openai.com/v1"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">API Key</label>
<div class="relative">
<input
v-model="settings.apiKey"
:type="showApiKey ? 'text' : 'password'"
class="w-full pl-4 pr-12 py-2.5 border dark:border-slate-700 rounded-xl bg-slate-50/50 dark:bg-slate-950 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-sm text-slate-900 dark:text-slate-100"
placeholder="sk-..."
/>
<button
@click="showApiKey = !showApiKey"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors focus:outline-none"
>
<Eye v-if="showApiKey" class="w-5 h-5" />
<EyeOff v-else class="w-5 h-5" />
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Model Name</label>
<input
v-model="settings.modelName"
type="text"
class="w-full px-4 py-2.5 border dark:border-slate-700 rounded-xl bg-slate-50/50 dark:bg-slate-950 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-sm text-slate-900 dark:text-slate-100"
placeholder="gpt-3.5-turbo"
/>
</div>
</div>
</section>
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">API 配置预设库</h2>
<div class="bg-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-2 space-y-1">
<div v-if="settings.profiles.length === 0" class="p-8 text-center text-sm text-slate-400 dark:text-slate-600 italic">
暂无预设配置修改上方配置并点击保存为预设
</div>
<div
v-for="profile in settings.profiles"
:key="profile.id"
class="p-3 flex items-center justify-between group hover:bg-white dark:hover:bg-slate-800 transition-colors rounded-lg border border-transparent hover:border-slate-200 dark:hover:border-slate-700"
>
<div class="flex flex-col gap-1 min-w-0">
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 truncate">{{ profile.name }}</span>
<div class="flex items-center gap-2 text-[10px] text-slate-500 dark:text-slate-400 font-mono bg-slate-100 dark:bg-slate-950 px-2 py-0.5 rounded w-fit">
<span class="truncate max-w-32">{{ profile.modelName }}</span>
<span class="opacity-30"></span>
<span class="truncate max-w-48">{{ profile.apiBaseUrl }}</span>
</div>
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click="applyProfile(profile)"
class="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50 rounded-lg transition-colors text-xs font-medium shadow-sm"
>
<Play class="w-3 h-3 fill-current" />
应用
</button>
<button
@click="deleteProfile(profile.id)"
class="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="删除"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</div>
</section>
</template>
<!-- General Settings -->
<template v-if="settingsCategory === 'general'">
<div class="mb-6 border-b dark:border-slate-800 pb-4">
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">常规设置</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">调整软件的翻译行为和质量审计规则</p>
</div>
<div class="bg-white/80 dark:bg-slate-900 rounded-2xl shadow-sm border dark:border-slate-800 p-6 space-y-6">
<!-- Streaming -->
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label class="text-sm font-semibold text-slate-800 dark:text-slate-200">流式输出</label>
<p class="text-xs text-slate-500 dark:text-slate-400">在模型生成文本时逐字渲染提升响应速度体验</p>
</div>
<button
@click="settings.enableStreaming = !settings.enableStreaming"
:class="cn(
'w-12 h-6 rounded-full transition-colors relative focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shrink-0',
settings.enableStreaming ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-700'
)"
>
<div :class="cn(
'absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm',
settings.enableStreaming ? 'translate-x-6' : 'translate-x-0'
)"></div>
</button>
</div>
<div class="h-px bg-slate-100 dark:bg-slate-800"></div>
<!-- Auto Evaluation -->
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label class="text-sm font-semibold text-slate-800 dark:text-slate-200">自动质量审计</label>
<p class="text-xs text-slate-500 dark:text-slate-400">翻译完成后自动启动一个次级请求来评估翻译的准确度和语气并提供改进建议</p>
</div>
<button
@click="settings.enableEvaluation = !settings.enableEvaluation"
:class="cn(
'w-12 h-6 rounded-full transition-colors relative focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shrink-0',
settings.enableEvaluation ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-700'
)"
>
<div :class="cn(
'absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm',
settings.enableEvaluation ? 'translate-x-6' : 'translate-x-0'
)"></div>
</button>
</div>
<div class="h-px bg-slate-100 dark:bg-slate-800"></div>
<!-- Audit Profile Selector -->
<div class="space-y-3">
<div class="space-y-0.5">
<label class="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-1.5">
审计模型
</label>
<p class="text-xs text-slate-500 dark:text-slate-400">强烈建议选择更强大的模型以获得精准反馈</p>
</div>
<!-- Custom Evaluation Profile Dropdown -->
<div class="relative lang-dropdown w-full max-w-md">
<button
@click.stop="toggleDropdown('evaluationProfile')"
class="flex items-center justify-between w-full px-4 py-2 border dark:border-slate-700 rounded-xl bg-slate-50 dark:bg-slate-950 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all text-sm text-slate-700 dark:text-slate-200 group shadow-sm"
>
<span class="truncate font-medium">{{ currentEvaluationProfileLabel }}</span>
<ChevronDown :class="cn('w-4 h-4 text-slate-400 transition-transform duration-200 group-hover:text-blue-500', evaluationProfileDropdownOpen && 'rotate-180')" />
</button>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="evaluationProfileDropdownOpen"
class="absolute left-0 mt-2 w-full max-h-60 overflow-y-auto bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 py-2 flex flex-col custom-scrollbar"
>
<button
@click="settings.evaluationProfileId = null; evaluationProfileDropdownOpen = false"
:class="cn(
'px-4 py-3 text-sm text-left transition-colors flex items-center justify-between',
settings.evaluationProfileId === null ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 font-bold' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700/50'
)"
>
<span class="font-semibold">使用当前主翻译配置不推荐</span>
<Check v-if="settings.evaluationProfileId === null" class="w-4 h-4" />
</button>
<div class="h-px bg-slate-100 dark:bg-slate-700 my-1 mx-2"></div>
<button
v-for="profile in settings.profiles"
:key="profile.id"
@click="settings.evaluationProfileId = profile.id; evaluationProfileDropdownOpen = false"
:class="cn(
'px-4 py-3 text-sm text-left transition-colors flex items-center justify-between',
settings.evaluationProfileId === profile.id ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 font-bold' : 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700/50'
)"
>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="truncate font-semibold">{{ profile.name }}</span>
<span class="text-[10px] opacity-60 font-mono">{{ profile.modelName }}</span>
</div>
<Check v-if="settings.evaluationProfileId === profile.id" class="w-4 h-4 shrink-0" />
</button>
</div>
</transition>
</div>
</div>
</div>
</template>
<!-- Prompt Engineering -->
<template v-if="settingsCategory === 'prompts'">
<div class="mb-6 border-b dark:border-slate-800 pb-4">
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">提示词工程</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">深度定制 AI 的系统指令修改变量占位符将改变其核心逻辑</p>
</div>
<div class="space-y-8">
<!-- Translation Prompt -->
<div class="bg-white/80 dark:bg-slate-900 rounded-2xl shadow-sm border dark:border-slate-800 overflow-hidden flex flex-col">
<div class="px-5 py-3 border-b dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<h3 class="text-sm font-bold text-slate-700 dark:text-slate-200">主翻译系统指令</h3>
</div>
<button @click="settings.systemPromptTemplate = DEFAULT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium">恢复默认</button>
</div>
<textarea
v-model="settings.systemPromptTemplate"
rows="8"
class="w-full p-5 bg-transparent outline-none font-mono text-xs leading-relaxed text-slate-800 dark:text-slate-300 resize-y"
spellcheck="false"
></textarea>
<div class="px-5 py-3 bg-slate-50 dark:bg-slate-950 border-t dark:border-slate-800">
<p class="text-[10px] font-semibold text-slate-400 uppercase mb-2">可用变量 (Variables)</p>
<div class="flex flex-wrap gap-1.5">
<span v-for="tag in ['{SOURCE_CODE}', '{TARGET_CODE}', '{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}']" :key="tag" class="px-2 py-0.5 bg-white dark:bg-slate-800 text-[10px] font-mono rounded-md border dark:border-slate-700 text-slate-500 shadow-sm">{{ tag }}</span>
</div>
</div>
</div>
<!-- Evaluation Prompt -->
<div class="bg-white/80 dark:bg-slate-900 rounded-2xl shadow-sm border dark:border-slate-800 overflow-hidden flex flex-col">
<div class="px-5 py-3 border-b dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-amber-500"></div>
<h3 class="text-sm font-bold text-slate-700 dark:text-slate-200">质量审计指令 (JSON 输出)</h3>
</div>
<button @click="settings.evaluationPromptTemplate = DEFAULT_EVALUATION_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium">恢复默认</button>
</div>
<textarea
v-model="settings.evaluationPromptTemplate"
rows="12"
class="w-full p-5 bg-transparent outline-none font-mono text-xs leading-relaxed text-slate-800 dark:text-slate-300 resize-y"
spellcheck="false"
></textarea>
<div class="px-5 py-3 bg-slate-50 dark:bg-slate-950 border-t dark:border-slate-800">
<p class="text-[10px] font-semibold text-slate-400 uppercase mb-2">可用变量 (Variables)</p>
<div class="flex flex-wrap gap-1.5">
<span v-for="tag in ['{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{CONTEXT}']" :key="tag" class="px-2 py-0.5 bg-white dark:bg-slate-800 text-[10px] font-mono rounded-md border dark:border-slate-700 text-slate-500 shadow-sm">{{ tag }}</span>
</div>
</div>
</div>
<!-- Refinement Prompt -->
<div class="bg-white/80 dark:bg-slate-900 rounded-2xl shadow-sm border dark:border-slate-800 overflow-hidden flex flex-col">
<div class="px-5 py-3 border-b dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<h3 class="text-sm font-bold text-slate-700 dark:text-slate-200">定向润色指令</h3>
</div>
<button @click="settings.refinementPromptTemplate = DEFAULT_REFINEMENT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium">恢复默认</button>
</div>
<textarea
v-model="settings.refinementPromptTemplate"
rows="8"
class="w-full p-5 bg-transparent outline-none font-mono text-xs leading-relaxed text-slate-800 dark:text-slate-300 resize-y"
spellcheck="false"
></textarea>
<div class="px-5 py-3 bg-slate-50 dark:bg-slate-950 border-t dark:border-slate-800">
<p class="text-[10px] font-semibold text-slate-400 uppercase mb-2">可用变量 (Variables)</p>
<div class="flex flex-wrap gap-1.5">
<span v-for="tag in ['{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{CONTEXT}']" :key="tag" class="px-2 py-0.5 bg-white dark:bg-slate-800 text-[10px] font-mono rounded-md border dark:border-slate-700 text-slate-500 shadow-sm">{{ tag }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>