Files
ai-translate-client/src/App.vue
Julian Freeman 69a8047743 rebuild logs ui
2026-03-25 18:21:07 -04:00

1421 lines
68 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, watch } from 'vue';
import {
Settings,
Languages,
Send,
Copy,
Trash2,
ArrowRightLeft,
Loader2,
Check,
FileText,
ChevronDown,
Sun,
Moon,
User,
Type,
Plus,
Save,
Play,
Clock,
Search
} from 'lucide-vue-next';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
useSettingsStore,
LANGUAGES,
DEFAULT_TEMPLATE,
DEFAULT_EVALUATION_TEMPLATE,
DEFAULT_REFINEMENT_TEMPLATE,
SPEAKER_IDENTITY_OPTIONS,
TONE_REGISTER_OPTIONS,
type ApiProfile
} from './stores/settings';
import pkg from '../package.json';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const settings = useSettingsStore();
// Theme management
watch(() => settings.isDark, (val) => {
if (val) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, { immediate: true });
const toggleTheme = () => {
settings.isDark = !settings.isDark;
};
const view = ref<'translate' | 'settings' | 'logs' | 'history'>('translate');
// History Management
const searchQuery = ref('');
const selectedHistoryId = ref<string | null>(null);
const filteredHistory = computed(() => {
if (!searchQuery.value.trim()) return settings.history;
const q = searchQuery.value.toLowerCase();
return settings.history.filter(h =>
h.sourceText.toLowerCase().includes(q) ||
h.targetText.toLowerCase().includes(q)
);
});
const selectedHistoryItem = computed(() =>
settings.history.find(h => h.id === selectedHistoryId.value) || null
);
watch(filteredHistory, (newVal) => {
if (newVal.length > 0 && !selectedHistoryId.value) {
selectedHistoryId.value = newVal[0].id;
}
}, { immediate: true });
const deleteHistoryItem = (id: string) => {
settings.history = settings.history.filter(h => h.id !== id);
if (selectedHistoryId.value === id) {
selectedHistoryId.value = filteredHistory.value[0]?.id || null;
}
};
const copyHistoryText = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy history text: ', err);
}
};
// Logs Management
const selectedLogId = ref<string | null>(null);
const selectedLogItem = computed(() =>
settings.logs.find(l => l.id === selectedLogId.value) || null
);
watch(() => settings.logs, (newVal) => {
if (newVal.length > 0 && !selectedLogId.value) {
selectedLogId.value = newVal[0].id;
}
}, { immediate: true });
const getLogSummary = (log: any) => {
if (log.type === 'error') return String(log.content);
if (typeof log.content === 'string') return log.content;
if (log.content && log.content.model) return `Model: ${log.content.model}`;
if (log.content && log.content.score) return `Score: ${log.content.score}`;
return 'JSON Data';
};
// Profile Management
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);
};
// Dropdown State
const sourceDropdownOpen = ref(false);
const targetDropdownOpen = ref(false);
const speakerDropdownOpen = ref(false);
const toneDropdownOpen = ref(false);
const evaluationProfileDropdownOpen = ref(false);
const closeAllDropdowns = () => {
sourceDropdownOpen.value = false;
targetDropdownOpen.value = false;
speakerDropdownOpen.value = false;
toneDropdownOpen.value = false;
evaluationProfileDropdownOpen.value = false;
};
const toggleDropdown = (type: 'source' | 'target' | 'speaker' | 'tone' | 'evaluationProfile') => {
const states = {
source: sourceDropdownOpen,
target: targetDropdownOpen,
speaker: speakerDropdownOpen,
tone: toneDropdownOpen,
evaluationProfile: evaluationProfileDropdownOpen
};
const targetState = states[type];
const currentValue = targetState.value;
closeAllDropdowns();
targetState.value = !currentValue;
};
const handleGlobalClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.lang-dropdown')) {
closeAllDropdowns();
}
};
onMounted(() => {
window.addEventListener('click', handleGlobalClick);
});
onUnmounted(() => {
window.removeEventListener('click', handleGlobalClick);
});
// Translation State
const sourceText = ref('');
const context = ref('');
const targetText = ref('');
const isTranslating = ref(false);
const showCopyFeedback = ref(false);
interface Suggestion {
id: number;
text: string;
importance: number;
}
interface EvaluationResult {
score: number;
analysis: string;
suggestions?: Suggestion[];
}
const evaluationResult = ref<EvaluationResult | null>(null);
const isEvaluating = ref(false);
const isRefining = ref(false);
const selectedSuggestionIds = ref<number[]>([]);
const appliedSuggestionIds = ref<number[]>([]);
const toggleSuggestion = (id: number) => {
if (!selectedSuggestionIds.value) selectedSuggestionIds.value = [];
const index = selectedSuggestionIds.value.indexOf(id);
if (index > -1) {
selectedSuggestionIds.value.splice(index, 1);
} else {
selectedSuggestionIds.value.push(id);
}
};
let unlisten: (() => void) | null = null;
onMounted(async () => {
unlisten = await listen<string>('translation-chunk', (event) => {
if (isTranslating.value || isRefining.value) {
targetText.value += event.payload;
}
});
});
onUnmounted(() => {
if (unlisten) unlisten();
});
// Language Selection
const sourceLangCode = computed({
get: () => settings.sourceLang.code,
set: (code) => {
const lang = LANGUAGES.find(l => l.code === code);
if (lang) settings.sourceLang = lang;
}
});
const targetLangCode = computed({
get: () => settings.targetLang.code,
set: (code) => {
const lang = LANGUAGES.find(l => l.code === code);
if (lang) settings.targetLang = lang;
}
});
const sourceLang = computed(() => settings.sourceLang);
const targetLang = computed(() => settings.targetLang);
const currentSpeakerLabel = computed(() => {
return SPEAKER_IDENTITY_OPTIONS.find(opt => opt.value === settings.speakerIdentity)?.label || '男性';
});
const currentToneLabel = computed(() => {
return TONE_REGISTER_OPTIONS.find(opt => opt.value === settings.toneRegister)?.label || '正式专业';
});
const currentEvaluationProfileLabel = computed(() => {
if (!settings.evaluationProfileId) return '使用主翻译配置(默认)';
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
return profile ? `${profile.name}${profile.modelName}` : '使用主翻译配置(默认)';
});
const swapLanguages = () => {
const temp = { ...settings.sourceLang };
settings.sourceLang = { ...settings.targetLang };
settings.targetLang = temp;
};
const clearSource = () => {
sourceText.value = '';
targetText.value = '';
evaluationResult.value = null;
};
const copyTarget = async () => {
try {
await navigator.clipboard.writeText(targetText.value);
showCopyFeedback.value = true;
setTimeout(() => {
showCopyFeedback.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
};
const evaluateTranslation = async () => {
if (!targetText.value) return;
isEvaluating.value = true;
evaluationResult.value = null;
selectedSuggestionIds.value = [];
appliedSuggestionIds.value = [];
// Determine which API config to use for evaluation
let apiBaseUrl = settings.apiBaseUrl;
let apiKey = settings.apiKey;
let modelName = settings.modelName;
if (settings.evaluationProfileId) {
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
if (profile) {
apiBaseUrl = profile.apiBaseUrl;
apiKey = profile.apiKey;
modelName = profile.modelName;
}
}
const evaluationSystemPrompt = settings.evaluationPromptTemplate
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
.replace(/{TONE_REGISTER}/g, settings.toneRegister)
.replace(/{CONTEXT}/g, context.value || 'None');
const evaluationUserPrompt = `[Source Text]\n${sourceText.value}\n\n[Translated Text]\n${targetText.value}`;
const requestBody = {
model: modelName,
messages: [
{ role: "system", content: evaluationSystemPrompt },
{ role: "user", content: evaluationUserPrompt }
],
stream: false // Non-streaming for evaluation to parse JSON
};
settings.addLog('request', { type: 'evaluation', ...requestBody });
try {
const response = await invoke<string>('translate', {
apiAddress: apiBaseUrl,
apiKey: apiKey,
payload: requestBody
});
try {
// Try to extract JSON if the model wrapped it in code blocks
const jsonStr = response.replace(/```json\s?|\s?```/g, '').trim();
evaluationResult.value = JSON.parse(jsonStr);
settings.addLog('response', { type: 'evaluation', content: evaluationResult.value });
} catch (parseErr) {
console.error('Failed to parse evaluation result:', response);
settings.addLog('error', `Evaluation parsing error: ${response}`);
}
} catch (err: any) {
settings.addLog('error', `Evaluation error: ${String(err)}`);
} finally {
isEvaluating.value = false;
}
};
const refineTranslation = async () => {
if (!targetText.value || isRefining.value) return;
const selectedTexts = evaluationResult.value?.suggestions
?.filter(s => selectedSuggestionIds.value?.includes(s.id))
.map(s => s.text);
if (!selectedTexts || selectedTexts.length === 0) return;
isRefining.value = true;
const originalTranslation = targetText.value;
targetText.value = '';
// Keep evaluationResult and selectedSuggestionIds to allow the user to see what was changed
// Determine which API config to use (same as audit)
let apiBaseUrl = settings.apiBaseUrl;
let apiKey = settings.apiKey;
let modelName = settings.modelName;
if (settings.evaluationProfileId) {
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
if (profile) {
apiBaseUrl = profile.apiBaseUrl;
apiKey = profile.apiKey;
modelName = profile.modelName;
}
}
const refinementSystemPrompt = settings.refinementPromptTemplate
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
.replace(/{TONE_REGISTER}/g, settings.toneRegister)
.replace(/{CONTEXT}/g, context.value || 'None');
const formattedSuggestions = selectedTexts.map((text, i) => `${i + 1}. ${text}`).join('\n');
const refinementUserPrompt = `[Source Text]\n${sourceText.value}\n\n[Current Translation]\n${originalTranslation}\n\n[User Feedback]\n${formattedSuggestions}`;
const requestBody = {
model: modelName,
messages: [
{ role: "system", content: refinementSystemPrompt },
{ role: "user", content: refinementUserPrompt }
],
stream: settings.enableStreaming
};
settings.addLog('request', { type: 'refinement', ...requestBody });
try {
const response = await invoke<string>('translate', {
apiAddress: apiBaseUrl,
apiKey: apiKey,
payload: requestBody
});
if (!settings.enableStreaming) {
targetText.value = response;
}
// Track applied suggestions and clear selection
if (evaluationResult.value?.suggestions) {
appliedSuggestionIds.value.push(...selectedSuggestionIds.value);
selectedSuggestionIds.value = [];
}
settings.addLog('response', 'Refinement completed');
} catch (err: any) {
const errorMsg = String(err);
settings.addLog('error', errorMsg);
targetText.value = `Error: ${errorMsg}`;
} finally {
isRefining.value = false;
}
};
const translate = async () => {
if (!sourceText.value.trim() || isTranslating.value) return;
isTranslating.value = true;
targetText.value = '';
evaluationResult.value = null;
const systemMessage = settings.systemPromptTemplate
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
.replace(/{SOURCE_CODE}/g, sourceLang.value.code)
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
.replace(/{TARGET_CODE}/g, targetLang.value.code)
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
.replace(/{TONE_REGISTER}/g, settings.toneRegister);
const userMessage = context.value
? `[Context]\n${context.value}\n\n[Text to Translate]\n${sourceText.value}`
: `[Text to Translate]\n${sourceText.value}`;
const requestBody = {
model: settings.modelName,
messages: [
{ role: "system", content: systemMessage },
{ role: "user", content: userMessage }
],
stream: settings.enableStreaming
};
settings.addLog('request', requestBody);
try {
const response = await invoke<string>('translate', {
apiAddress: settings.apiBaseUrl,
apiKey: settings.apiKey,
payload: requestBody
});
// For non-streaming, response is returned as string
if (!settings.enableStreaming) {
targetText.value = response;
}
settings.addLog('response', 'Translation completed');
// Save to history
settings.addHistory({
sourceLang: { ...sourceLang.value },
targetLang: { ...targetLang.value },
sourceText: sourceText.value,
targetText: settings.enableStreaming ? targetText.value : response,
context: context.value,
speakerIdentity: settings.speakerIdentity,
toneRegister: settings.toneRegister,
modelName: settings.modelName
});
} catch (err: any) {
const errorMsg = String(err);
settings.addLog('error', errorMsg);
targetText.value = `Error: ${errorMsg}`;
} finally {
isTranslating.value = false;
}
// Trigger evaluation if enabled
if (settings.enableEvaluation) {
await evaluateTranslation();
}
};
</script>
<template>
<div class="h-screen bg-slate-100/50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 font-sans selection:bg-blue-100 dark:selection:bg-blue-900 flex flex-col overflow-hidden">
<!-- Header -->
<header class="h-14 border-b dark:border-slate-800 bg-slate-50 dark:bg-slate-900 flex items-center justify-between px-6 shrink-0 sticky top-0 z-10 shadow-sm/5">
<div class="flex items-center gap-2 cursor-pointer group" @click="view = 'translate'">
<Languages class="w-6 h-6 text-blue-600 group-hover:scale-110 transition-transform" />
<h1 class="font-semibold text-lg tracking-tight">AI 翻译</h1>
</div>
<div class="flex items-center gap-2">
<button
@click="toggleTheme"
class="p-2 rounded-full transition-colors hover:bg-slate-200/50 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300"
:title="settings.isDark ? '切换亮色主题' : '切换暗色主题'"
>
<Sun v-if="settings.isDark" class="w-5 h-5" />
<Moon v-else class="w-5 h-5" />
</button>
<button
@click="view = 'settings'"
:class="cn('p-2 rounded-full transition-colors', view === 'settings' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'hover:bg-slate-200/50 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300')"
title="设置"
>
<Settings class="w-5 h-5" />
</button>
<button
@click="view = 'logs'"
:class="cn('p-2 rounded-full transition-colors', view === 'logs' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'hover:bg-slate-200/50 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300')"
title="日志"
>
<FileText class="w-5 h-5" />
</button>
<button
@click="view = 'history'"
:class="cn('p-2 rounded-full transition-colors', view === 'history' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'hover:bg-slate-200/50 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300')"
title="历史记录"
>
<Clock class="w-5 h-5" />
</button>
</div>
</header>
<main class="flex-1 flex overflow-hidden min-h-0 relative">
<!-- Translation View -->
<div v-if="view === 'translate'" class="flex-1 flex flex-col md:flex-row divide-y md:divide-y-0 md:divide-x dark:divide-slate-800 bg-white/50 dark:bg-slate-900 overflow-hidden h-full">
<!-- Source Pane -->
<div class="flex-1 flex flex-col min-h-0 relative h-full">
<div class="flex items-center gap-3 px-6 py-3 border-b dark:border-slate-800 bg-slate-100/40 dark:bg-slate-800/30 relative z-40 shrink-0">
<!-- Custom Source Dropdown -->
<div class="relative lang-dropdown min-w-30">
<button
@click.stop="toggleDropdown('source')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors text-sm font-semibold text-slate-700 dark:text-slate-200 w-full justify-between group"
>
<span class="truncate">{{ sourceLang.displayName }}</span>
<ChevronDown :class="cn('w-4 h-4 text-slate-400 transition-transform duration-200', sourceDropdownOpen && 'rotate-180')" />
</button>
<!-- Dropdown Menu -->
<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="sourceDropdownOpen"
class="absolute left-0 mt-2 w-56 max-h-80 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
v-for="lang in LANGUAGES"
:key="lang.code"
@click="sourceLangCode = lang.code; sourceDropdownOpen = false"
:class="cn(
'px-4 py-2.5 text-sm text-left transition-colors flex items-center justify-between',
sourceLangCode === lang.code ? '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'
)"
>
{{ lang.displayName }}
<Check v-if="sourceLangCode === lang.code" class="w-3.5 h-3.5" />
</button>
</div>
</transition>
</div>
<button @click="swapLanguages" class="p-1.5 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-md transition-colors" title="交换语言">
<ArrowRightLeft class="w-4 h-4 text-slate-500 dark:text-slate-400" />
</button>
<div class="ml-auto flex items-center gap-2">
<button @click="clearSource" class="p-1.5 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-md transition-colors" title="清空内容">
<Trash2 class="w-4 h-4 text-slate-500 dark:text-slate-400" />
</button>
</div>
</div>
<textarea
v-model="sourceText"
placeholder="请输入待翻译内容..."
class="flex-1 p-6 resize-none outline-none text-lg leading-relaxed placeholder:text-slate-300 dark:placeholder:text-slate-600 bg-transparent min-h-0"
></textarea>
<!-- Context Input Area -->
<div class="px-6 py-3 bg-slate-200/20 dark:bg-slate-800/20 border-t border-dashed dark:border-slate-800 group/context relative">
<div class="flex items-center justify-between mb-1.5 h-5">
<div class="flex items-center gap-1.5">
<FileText class="w-4 h-4 text-slate-400" />
<span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">情景背景 (可选)</span>
</div>
<button
v-if="context"
@click="context = ''"
class="p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded opacity-0 group-hover/context:opacity-100 transition-opacity"
title="清空背景"
>
<Plus class="w-3 h-3 rotate-45 text-slate-400" />
</button>
</div>
<textarea
v-model="context"
placeholder="在此输入背景信息,有助于提升翻译准确度..."
class="w-full bg-transparent border-none outline-none text-sm text-slate-500 dark:text-slate-400 resize-none h-14 leading-normal placeholder:italic placeholder:text-slate-300 dark:placeholder:text-slate-600"
></textarea>
</div>
<div class="p-4 border-t dark:border-slate-800 bg-slate-50/30 dark:bg-transparent flex justify-end shrink-0"> <button
@click="translate"
:disabled="isTranslating || isEvaluating || isRefining || !sourceText.trim()"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
>
<Loader2 v-if="isTranslating" class="w-4 h-4 animate-spin" />
<Send v-else class="w-4 h-4" />
{{ isTranslating ? '正在翻译...' : '翻译' }}
</button>
</div>
</div>
<!-- Target Pane -->
<div class="flex-1 flex flex-col min-h-0 bg-slate-100/20 dark:bg-slate-900/50 relative h-full">
<div class="flex items-center gap-3 px-6 py-3 border-b dark:border-slate-800 bg-slate-100/40 dark:bg-slate-800/30 relative z-40 shrink-0">
<!-- Custom Target Dropdown -->
<div class="relative lang-dropdown min-w-30">
<button
@click.stop="toggleDropdown('target')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors text-sm font-semibold text-slate-700 dark:text-slate-200 w-full justify-between"
>
<span class="truncate">{{ targetLang.displayName }}</span>
<ChevronDown :class="cn('w-4 h-4 text-slate-400 transition-transform duration-200', targetDropdownOpen && 'rotate-180')" />
</button>
<!-- Dropdown Menu -->
<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="targetDropdownOpen"
class="absolute left-0 mt-2 w-56 max-h-80 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
v-for="lang in LANGUAGES"
:key="lang.code"
@click="targetLangCode = lang.code; targetDropdownOpen = false"
:class="cn(
'px-4 py-2.5 text-sm text-left transition-colors flex items-center justify-between',
targetLangCode === lang.code ? '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'
)"
>
{{ lang.displayName }}
<Check v-if="targetLangCode === lang.code" class="w-3.5 h-3.5" />
</button>
</div>
</transition>
</div>
<!-- Speaker Identity Dropdown -->
<div class="relative lang-dropdown min-w-24">
<button
@click.stop="toggleDropdown('speaker')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors text-sm font-medium text-slate-600 dark:text-slate-300 w-full justify-between group"
title="说话人身份"
>
<div class="flex items-center gap-1.5 truncate">
<User class="w-3.5 h-3.5 text-slate-400" />
<span class="truncate">{{ currentSpeakerLabel }}</span>
</div>
<ChevronDown :class="cn('w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0', speakerDropdownOpen && '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="speakerDropdownOpen"
class="absolute left-0 mt-2 w-40 max-h-80 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
v-for="opt in SPEAKER_IDENTITY_OPTIONS"
:key="opt.value"
@click="settings.speakerIdentity = opt.value; speakerDropdownOpen = false"
:class="cn(
'px-4 py-2 text-sm text-left transition-colors flex items-center justify-between',
settings.speakerIdentity === opt.value ? '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'
)"
>
{{ opt.label }}
<Check v-if="settings.speakerIdentity === opt.value" class="w-3.5 h-3.5" />
</button>
</div>
</transition>
</div>
<!-- Tone & Register Dropdown -->
<div class="relative lang-dropdown min-w-32">
<button
@click.stop="toggleDropdown('tone')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors text-sm font-medium text-slate-600 dark:text-slate-300 w-full justify-between group"
title="语气风格"
>
<div class="flex items-center gap-1.5 truncate">
<Type class="w-3.5 h-3.5 text-slate-400" />
<span class="truncate">{{ currentToneLabel }}</span>
</div>
<ChevronDown :class="cn('w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0', toneDropdownOpen && '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="toneDropdownOpen"
class="absolute left-0 mt-2 w-56 max-h-80 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
v-for="opt in TONE_REGISTER_OPTIONS"
:key="opt.value"
@click="settings.toneRegister = opt.value; toneDropdownOpen = false"
:class="cn(
'px-4 py-2.5 text-sm text-left transition-colors flex flex-col gap-0.5',
settings.toneRegister === opt.value ? '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 items-center justify-between w-full">
{{ opt.label }}
<Check v-if="settings.toneRegister === opt.value" class="w-3.5 h-3.5" />
</div>
<span class="text-[10px] opacity-60 font-normal truncate">{{ opt.description }}</span>
</button>
</div>
</transition>
</div>
<div class="ml-auto flex items-center gap-2">
<button @click="copyTarget" class="p-1.5 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-md transition-colors relative" title="复制结果">
<Check v-if="showCopyFeedback" class="w-4 h-4 text-green-600" />
<Copy v-else class="w-4 h-4 text-slate-500 dark:text-slate-400" />
</button>
</div>
</div>
<div class="flex-1 p-6 overflow-y-auto text-lg leading-relaxed whitespace-pre-wrap min-h-0">
<template v-if="targetText">
{{ targetText }}
</template>
<span v-else class="text-slate-300 dark:text-slate-600 italic">翻译结果将在此显示...</span>
</div>
<!-- Evaluation Results -->
<div v-if="isEvaluating || evaluationResult" class="px-6 py-4 bg-slate-200/20 dark:bg-slate-800/20 border-t border-dashed dark:border-slate-800 space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500 overflow-y-auto max-h-80 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div :class="cn(
'w-2 h-2 rounded-full',
isEvaluating ? 'bg-blue-400 animate-pulse' : (evaluationResult?.score && evaluationResult.score >= 80 ? 'bg-green-500' : evaluationResult?.score && evaluationResult.score >= 60 ? 'bg-amber-500' : 'bg-red-500')
)"></div>
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">质量审计</h3>
</div>
<div v-if="evaluationResult" :class="cn(
'text-lg font-black font-mono',
evaluationResult.score >= 80 ? 'text-green-600' : evaluationResult.score >= 60 ? 'text-amber-600' : 'text-red-600'
)">
{{ evaluationResult.score }} <span class="text-[10px] font-normal opacity-50">/ 100</span>
</div>
<div v-else-if="isEvaluating" class="flex items-center gap-1.5 text-xs text-blue-500 font-medium">
<Loader2 class="w-3 h-3 animate-spin" />
正在审计...
</div>
</div>
<div v-if="evaluationResult" class="space-y-3">
<div class="bg-slate-50 dark:bg-slate-800/40 p-3 rounded-lg border border-slate-100 dark:border-slate-800/60">
<p class="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">
{{ evaluationResult.analysis }}
</p>
</div>
<div v-if="evaluationResult.suggestions && evaluationResult.suggestions.length > 0" class="space-y-4 pt-2">
<!-- Pending Suggestions -->
<div v-if="evaluationResult.suggestions.some(s => !appliedSuggestionIds.includes(s.id))" class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">修改建议</h3>
</div>
<div class="space-y-2">
<div
v-for="sug in evaluationResult.suggestions.filter(s => !appliedSuggestionIds.includes(s.id))"
:key="sug.id"
@click="toggleSuggestion(sug.id)"
:class="cn(
'flex items-start gap-3 p-3 rounded-xl border transition-all cursor-pointer group',
selectedSuggestionIds?.includes(sug.id)
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'bg-white dark:bg-slate-800/40 border-slate-100 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700'
)"
>
<div :class="cn(
'w-4 h-4 rounded border mt-0.5 shrink-0 flex items-center justify-center transition-colors',
selectedSuggestionIds?.includes(sug.id) ? 'bg-blue-600 border-blue-600' : 'border-slate-300 dark:border-slate-600'
)">
<Check v-if="selectedSuggestionIds?.includes(sug.id)" class="w-3.5 h-3.5 text-white" stroke-width="4" />
</div>
<div class="flex-1 space-y-1.5 min-w-0">
<p class="text-xs text-slate-700 dark:text-slate-200 leading-normal">{{ sug.text }}</p>
<div class="flex items-center gap-2">
<div class="flex-1 h-1 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-1000"
:class="sug.importance >= 80 ? 'bg-red-500' : sug.importance >= 40 ? 'bg-amber-500' : 'bg-blue-500'"
:style="{ width: `${sug.importance}%` }"
></div>
</div>
<span class="text-[9px] font-bold opacity-40 uppercase tracking-tighter w-8 shrink-0">{{ sug.importance }}%</span>
</div>
</div>
</div>
</div>
</div>
<!-- Applied Suggestions -->
<div v-if="appliedSuggestionIds.length > 0" class="space-y-2 border-t dark:border-slate-800 pt-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-slate-400"></div>
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">已润色</h3>
</div>
<div class="space-y-2">
<div
v-for="sug in evaluationResult.suggestions.filter(s => appliedSuggestionIds.includes(s.id))"
:key="'applied-' + sug.id"
class="p-3 rounded-xl border border-slate-100 dark:border-slate-800/60 bg-white/30 dark:bg-slate-800/20 opacity-70"
>
<p class="text-xs text-slate-500 dark:text-slate-400 leading-normal">{{ sug.text }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 border-t dark:border-slate-800 bg-slate-50/30 dark:bg-transparent flex justify-end gap-2 shrink-0">
<button
@click="refineTranslation"
v-if="evaluationResult && evaluationResult.suggestions && evaluationResult.suggestions.length > 0"
:disabled="isRefining || isEvaluating || isTranslating || selectedSuggestionIds.length === 0"
class="bg-blue-600 enabled:hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
>
<Loader2 v-if="isRefining" class="w-4 h-4 animate-spin" />
<Save v-else class="w-4 h-4" />
{{ isRefining ? '正在润色...' : '润色' }}
</button>
<button
@click="evaluateTranslation"
:disabled="isEvaluating || isTranslating || isRefining || !targetText.trim()"
class="bg-blue-600 enabled:hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
>
<Loader2 v-if="isEvaluating" class="w-4 h-4 animate-spin" />
<Check v-else class="w-4 h-4" />
{{ isEvaluating ? '正在审计...' : '审计' }}
</button>
</div>
</div>
</div>
<!-- History View -->
<div v-else-if="view === 'history'" class="flex-1 flex overflow-hidden bg-slate-100/50 dark:bg-slate-950">
<!-- History List (Master) -->
<div class="w-80 md:w-96 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 space-y-3">
<div class="relative group">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
<input
v-model="searchQuery"
type="text"
placeholder="搜索历史记录..."
class="w-full pl-9 pr-4 py-2 bg-slate-100 dark:bg-slate-800 border-none rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-500/20 transition-all dark:text-slate-200"
/>
</div>
<div class="flex items-center justify-between px-1">
<span class="text-[11px] font-bold text-slate-400 uppercase tracking-wider"> {{ filteredHistory.length }} 条记录</span>
<button
@click="settings.history = []"
class="text-[11px] text-red-500 hover:underline font-medium"
>清空全部</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
<div v-if="filteredHistory.length === 0" class="py-20 text-center space-y-2">
<Clock class="w-10 h-10 text-slate-200 dark:text-slate-800 mx-auto" />
<p class="text-sm text-slate-400 italic">暂无相关历史</p>
</div>
<div
v-for="item in filteredHistory"
:key="item.id"
@click="selectedHistoryId = item.id"
:class="cn(
'w-full p-4 rounded-xl text-left transition-all border group relative cursor-pointer',
selectedHistoryId === item.id
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-900/50 shadow-sm'
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50 border-transparent'
)"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 uppercase">{{ item.sourceLang.code }}</span>
<ArrowRightLeft class="w-3 h-3 text-slate-300" />
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400 uppercase">{{ item.targetLang.code }}</span>
</div>
<span class="text-[10px] text-slate-400 font-mono">{{ item.timestamp.split(' ')[1].substring(0, 5) }}</span>
</div>
<p class="text-xs text-slate-700 dark:text-slate-200 font-medium line-clamp-2 leading-relaxed mb-1">{{ item.sourceText }}</p>
<p class="text-[11px] text-slate-400 dark:text-slate-500 line-clamp-1 italic">{{ item.targetText }}</p>
<button
@click.stop="deleteHistoryItem(item.id)"
class="absolute right-2 bottom-2 p-1.5 rounded-md text-red-400 opacity-0 group-hover:opacity-100 hover:bg-red-50 dark:hover:bg-red-900/30 transition-all"
title="删除此记录"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<!-- History Detail (Detail) -->
<div class="flex-1 flex flex-col min-w-0 bg-white dark:bg-slate-900">
<template v-if="selectedHistoryItem">
<div class="p-6 border-b dark:border-slate-800 flex items-center justify-between shrink-0 bg-slate-50/50 dark:bg-slate-800/20">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100">翻译详情</h2>
<span class="text-[11px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">{{ selectedHistoryItem.timestamp }}</span>
</div>
<div class="flex items-center gap-3 text-xs text-slate-500">
<div class="flex items-center gap-1">
<User class="w-3 h-3" />
{{ SPEAKER_IDENTITY_OPTIONS.find(o => o.value === (selectedHistoryItem?.speakerIdentity))?.label || selectedHistoryItem?.speakerIdentity }}
</div>
<span></span>
<div class="flex items-center gap-1">
<Type class="w-3 h-3" />
{{ TONE_REGISTER_OPTIONS.find(o => o.value === (selectedHistoryItem?.toneRegister))?.label || selectedHistoryItem?.toneRegister }}
</div>
<span></span>
<div class="font-mono text-[10px]">{{ selectedHistoryItem.modelName }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="deleteHistoryItem(selectedHistoryItem.id)"
class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
>
<Trash2 class="w-4 h-4" />
删除记录
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-8 space-y-10 custom-scrollbar">
<!-- Source -->
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-1.5 h-4 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">{{ selectedHistoryItem.sourceLang.displayName }} (原文)</h3>
</div>
<button
@click="copyHistoryText(selectedHistoryItem.sourceText)"
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg text-slate-400 transition-colors"
>
<Copy class="w-4 h-4" />
</button>
</div>
<div class="bg-slate-50 dark:bg-slate-800/30 p-6 rounded-2xl border border-slate-100 dark:border-slate-800 text-lg leading-relaxed text-slate-700 dark:text-slate-200 whitespace-pre-wrap">
{{ selectedHistoryItem.sourceText }}
</div>
</section>
<!-- Context if exists -->
<section v-if="selectedHistoryItem.context" class="space-y-4">
<div class="flex items-center gap-2">
<FileText class="w-4 h-4 text-slate-300" />
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">情景背景</h3>
</div>
<div class="bg-amber-50/30 dark:bg-amber-900/10 p-4 rounded-xl border border-amber-100/50 dark:border-amber-900/20 text-sm italic text-slate-500 dark:text-slate-400 leading-relaxed">
{{ selectedHistoryItem.context }}
</div>
</section>
<!-- Target -->
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-1.5 h-4 bg-blue-500 rounded-full"></div>
<h3 class="text-xs font-black text-blue-500/60 uppercase tracking-widest">{{ selectedHistoryItem.targetLang.displayName }} (译文)</h3>
</div>
<button
@click="copyHistoryText(selectedHistoryItem.targetText)"
class="p-2 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded-lg text-blue-600 dark:text-blue-400 transition-colors"
>
<Copy class="w-4 h-4" />
</button>
</div>
<div class="bg-blue-50/20 dark:bg-blue-900/10 p-6 rounded-2xl border border-blue-100/50 dark:border-blue-900/20 text-xl leading-relaxed font-medium text-slate-800 dark:text-slate-100 whitespace-pre-wrap shadow-sm">
{{ selectedHistoryItem.targetText }}
</div>
</section>
</div>
</template>
<div v-else class="flex-1 flex flex-col items-center justify-center text-slate-300 dark:text-slate-800 p-10 text-center space-y-4">
<div class="w-20 h-20 rounded-full bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center">
<Clock class="w-10 h-10 opacity-20" />
</div>
<p class="text-sm font-medium">请从左侧选择一条历史记录查看详情</p>
</div>
</div>
</div>
<!-- Settings View -->
<div v-else-if="view === 'settings'" class="flex-1 overflow-y-auto bg-slate-100/50 dark:bg-slate-950 p-6 md:p-10 min-h-0">
<div class="max-w-2xl mx-auto space-y-8">
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">API 配置预设</h2>
<div class="bg-white/60 dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-1.5 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-slate-100/50 dark:hover:bg-slate-800/50 transition-colors rounded-lg"
>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200 truncate">{{ profile.name }}</span>
<div class="flex items-center gap-2 text-[10px] text-slate-400 dark:text-slate-500 font-mono">
<span class="truncate max-w-30">{{ profile.modelName }}</span>
<span></span>
<span class="truncate">{{ profile.apiBaseUrl }}</span>
</div>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click="applyProfile(profile)"
class="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors"
title="应用此配置"
>
<Play class="w-4 h-4 fill-current" />
</button>
<button
@click="deleteProfile(profile.id)"
class="p-2 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>
<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-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm/5 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 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono 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>
<input
v-model="settings.apiKey"
type="password"
class="w-full px-4 py-2 border dark:border-slate-700 rounded-lg bg-transparent 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-..."
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Model</label>
<input
v-model="settings.modelName"
type="text"
class="w-full px-4 py-2 border dark:border-slate-700 rounded-lg bg-transparent 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 class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">流式输出</label>
<p class="text-xs text-slate-500 dark:text-slate-500">在生成时即时渲染文本</p>
</div>
<button
@click="settings.enableStreaming = !settings.enableStreaming"
:class="cn(
'w-12 h-6 rounded-full transition-colors relative',
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',
settings.enableStreaming ? 'translate-x-6' : 'translate-x-0'
)"></div>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">自动质量审计</label>
<p class="text-xs text-slate-500 dark:text-slate-500">翻译完成后自动评估准确度</p>
</div>
<button
@click="settings.enableEvaluation = !settings.enableEvaluation"
:class="cn(
'w-12 h-6 rounded-full transition-colors relative',
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',
settings.enableEvaluation ? 'translate-x-6' : 'translate-x-0'
)"></div>
</button>
</div>
<div class="space-y-3 pt-4 border-t border-dashed dark:border-slate-800 animate-in fade-in slide-in-from-top-2 duration-300">
<div class="flex items-center gap-2 mb-1">
<Settings class="w-3.5 h-3.5 text-blue-500" />
<label class="text-sm font-semibold text-slate-700 dark:text-slate-300">审计模型</label>
</div>
<!-- Custom Evaluation Profile Dropdown -->
<div class="relative lang-dropdown">
<button
@click.stop="toggleDropdown('evaluationProfile')"
class="flex items-center justify-between w-full px-4 py-2.5 border dark:border-slate-700 rounded-xl bg-slate-50/50 dark:bg-slate-800/30 hover:bg-slate-100/50 dark:hover:bg-slate-800/50 transition-all text-sm text-slate-700 dark:text-slate-200 group"
>
<span class="truncate">{{ 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-2.5 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-500 hover:bg-slate-50 dark:hover:bg-slate-700/50'
)"
>
使用主翻译配置默认
<Check v-if="settings.evaluationProfileId === null" class="w-3.5 h-3.5" />
</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-2.5 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 min-w-0">
<span class="truncate">{{ profile.name }}</span>
<span class="text-[10px] opacity-60 font-mono">{{ profile.modelName }}</span>
</div>
<Check v-if="settings.evaluationProfileId === profile.id" class="w-3.5 h-3.5 shrink-0" />
</button>
</div>
</transition>
</div>
<p class="text-[11px] text-slate-500 dark:text-slate-500 pl-1">
提示建议为审计选择更强大的模型以获得更精准的反馈
</p>
</div>
</div>
</section>
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">提示词工程</h2>
<div class="bg-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm/5 border dark:border-slate-800 p-6 space-y-6">
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">翻译系统提示词模板</label>
<button @click="settings.systemPromptTemplate = DEFAULT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
</div>
<textarea
v-model="settings.systemPromptTemplate"
rows="6"
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
></textarea>
<div class="flex flex-wrap gap-2 mt-2">
<span v-for="tag in ['{SOURCE_CODE}', '{TARGET_CODE}', '{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
</div>
</div>
<div class="space-y-2 border-t dark:border-slate-800 pt-6">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">审计系统提示词模板</label>
<button @click="settings.evaluationPromptTemplate = DEFAULT_EVALUATION_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
</div>
<textarea
v-model="settings.evaluationPromptTemplate"
rows="8"
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
></textarea>
<div class="flex flex-wrap gap-2 mt-2">
<span v-for="tag in ['{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{CONTEXT}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
</div>
</div>
<div class="space-y-2 border-t dark:border-slate-800 pt-6">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">润色系统提示词模板</label>
<button @click="settings.refinementPromptTemplate = DEFAULT_REFINEMENT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
</div>
<textarea
v-model="settings.refinementPromptTemplate"
rows="8"
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
></textarea>
<div class="flex flex-wrap gap-2 mt-2">
<span v-for="tag in ['{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{CONTEXT}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Logs View -->
<div v-else-if="view === 'logs'" class="flex-1 flex overflow-hidden bg-slate-100/50 dark:bg-slate-950">
<!-- Logs List (Master) -->
<div class="w-80 md:w-96 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 space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">系统日志</h2>
<button
@click="settings.logs = []"
class="text-[11px] text-red-500 hover:underline font-medium flex items-center gap-1"
>
清空全部
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
<div v-if="settings.logs.length === 0" class="py-20 text-center space-y-2">
<FileText class="w-10 h-10 text-slate-200 dark:text-slate-800 mx-auto" />
<p class="text-sm text-slate-400 italic">暂无日志记录</p>
</div>
<div
v-for="log in settings.logs"
:key="log.id"
@click="selectedLogId = log.id"
:class="cn(
'w-full p-4 rounded-xl text-left transition-all border group relative cursor-pointer',
selectedLogId === log.id
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-900/50 shadow-sm'
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50 border-transparent'
)"
>
<div class="flex items-center justify-between mb-2">
<span
:class="cn(
'px-2 py-0.5 rounded uppercase font-bold text-[10px]',
log.type === 'request' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' :
log.type === 'response' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' :
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
)"
>{{ log.type === 'request' ? '请求' : log.type === 'response' ? '响应' : '错误' }}</span>
<span class="text-[10px] text-slate-400 font-mono">{{ log.timestamp.split(' ')[1] }}</span>
</div>
<p class="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-2 font-mono leading-relaxed">{{ getLogSummary(log) }}</p>
</div>
</div>
</div>
<!-- Log Detail (Detail) -->
<div class="flex-1 flex flex-col min-w-0 bg-white dark:bg-slate-900">
<template v-if="selectedLogItem">
<div class="p-6 border-b dark:border-slate-800 flex items-center justify-between shrink-0 bg-slate-50/50 dark:bg-slate-800/20">
<div class="flex items-center gap-3">
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100">日志详情</h2>
<span class="text-[11px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">{{ selectedLogItem.timestamp }}</span>
<span
:class="cn(
'px-2 py-0.5 rounded uppercase font-bold text-[10px]',
selectedLogItem.type === 'request' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' :
selectedLogItem.type === 'response' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' :
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
)"
>{{ selectedLogItem.type === 'request' ? 'Request' : selectedLogItem.type === 'response' ? 'Response' : 'Error' }}</span>
</div>
<button
@click="copyHistoryText(typeof selectedLogItem.content === 'object' ? JSON.stringify(selectedLogItem.content, null, 2) : String(selectedLogItem.content))"
class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 rounded-lg transition-colors border border-slate-200 dark:border-slate-700"
>
<Copy class="w-4 h-4" />
复制内容
</button>
</div>
<div class="flex-1 overflow-auto p-6 bg-slate-50/30 dark:bg-slate-900/50 custom-scrollbar">
<pre class="font-mono text-xs leading-relaxed text-slate-700 dark:text-slate-300 whitespace-pre-wrap break-all">{{ typeof selectedLogItem.content === 'object' ? JSON.stringify(selectedLogItem.content, null, 2) : selectedLogItem.content }}</pre>
</div>
</template>
<div v-else class="flex-1 flex flex-col items-center justify-center text-slate-300 dark:text-slate-800 p-10 text-center space-y-4">
<div class="w-20 h-20 rounded-full bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center">
<FileText class="w-10 h-10 opacity-20" />
</div>
<p class="text-sm font-medium">请从左侧选择一条日志查看详情</p>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="h-8 bg-slate-200/40 dark:bg-slate-900 border-t dark:border-slate-800 flex items-center px-4 justify-between shrink-0">
<div class="text-[10px] text-slate-400 dark:text-slate-500">
{{ settings.modelName }}
</div>
<div class="text-[10px] text-slate-400 dark:text-slate-500">
Client v{{ pkg.version }}
</div>
</footer>
</div>
</template>