add identity and tone
This commit is contained in:
137
src/App.vue
137
src/App.vue
@@ -12,11 +12,19 @@ import {
|
||||
FileText,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Moon
|
||||
Moon,
|
||||
User,
|
||||
Type
|
||||
} from 'lucide-vue-next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useSettingsStore, LANGUAGES, DEFAULT_TEMPLATE } from './stores/settings';
|
||||
import {
|
||||
useSettingsStore,
|
||||
LANGUAGES,
|
||||
DEFAULT_TEMPLATE,
|
||||
SPEAKER_IDENTITY_OPTIONS,
|
||||
TONE_REGISTER_OPTIONS
|
||||
} from './stores/settings';
|
||||
import pkg from '../package.json';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -45,20 +53,29 @@ const view = ref<'translate' | 'settings' | 'logs'>('translate');
|
||||
// Dropdown State
|
||||
const sourceDropdownOpen = ref(false);
|
||||
const targetDropdownOpen = ref(false);
|
||||
const speakerDropdownOpen = ref(false);
|
||||
const toneDropdownOpen = ref(false);
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
sourceDropdownOpen.value = false;
|
||||
targetDropdownOpen.value = false;
|
||||
speakerDropdownOpen.value = false;
|
||||
toneDropdownOpen.value = false;
|
||||
};
|
||||
|
||||
const toggleDropdown = (type: 'source' | 'target') => {
|
||||
if (type === 'source') {
|
||||
sourceDropdownOpen.value = !sourceDropdownOpen.value;
|
||||
targetDropdownOpen.value = false;
|
||||
} else {
|
||||
targetDropdownOpen.value = !targetDropdownOpen.value;
|
||||
sourceDropdownOpen.value = false;
|
||||
}
|
||||
const toggleDropdown = (type: 'source' | 'target' | 'speaker' | 'tone') => {
|
||||
const states = {
|
||||
source: sourceDropdownOpen,
|
||||
target: targetDropdownOpen,
|
||||
speaker: speakerDropdownOpen,
|
||||
tone: toneDropdownOpen
|
||||
};
|
||||
|
||||
const targetState = states[type];
|
||||
const currentValue = targetState.value;
|
||||
|
||||
closeAllDropdowns();
|
||||
targetState.value = !currentValue;
|
||||
};
|
||||
|
||||
const handleGlobalClick = (e: MouseEvent) => {
|
||||
@@ -116,6 +133,14 @@ const targetLangCode = computed({
|
||||
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 swapLanguages = () => {
|
||||
const temp = { ...settings.sourceLang };
|
||||
settings.sourceLang = { ...settings.targetLang };
|
||||
@@ -150,6 +175,8 @@ const translate = async () => {
|
||||
.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)
|
||||
.replace(/{TEXT}/g, sourceText.value);
|
||||
|
||||
const requestBody = {
|
||||
@@ -328,6 +355,94 @@ const translate = async () => {
|
||||
</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" />
|
||||
@@ -403,7 +518,7 @@ const translate = async () => {
|
||||
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}', '{SOURCE_CODE}', '{TARGET_LANG}', '{TARGET_CODE}', '{TEXT}']" :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>
|
||||
<span v-for="tag in ['{SOURCE_LANG}', '{SOURCE_CODE}', '{TARGET_LANG}', '{TARGET_CODE}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{TEXT}']" :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>
|
||||
|
||||
@@ -25,7 +25,29 @@ export const LANGUAGES: Language[] = [
|
||||
{ displayName: '阿拉伯语', englishName: 'Arabic', code: 'ar' },
|
||||
];
|
||||
|
||||
export const SPEAKER_IDENTITY_OPTIONS = [
|
||||
{ label: '男性', value: 'Male' },
|
||||
{ label: '女性', value: 'Female' },
|
||||
{ label: '中性', value: 'Gender-neutral' },
|
||||
];
|
||||
|
||||
export const TONE_REGISTER_OPTIONS = [
|
||||
{ label: '正式专业', value: 'Formal & Professional', description: '商务邮件、法律合同、官方报告' },
|
||||
{ label: '礼貌客气', value: 'Polite & Respectful', description: '与长辈、客户或初次见面的人交流' },
|
||||
{ label: '礼貌随和', value: 'Polite & Conversational', description: '得体但不刻板的日常对话' },
|
||||
{ label: '中性标准', value: 'Neutral & Standard', description: '维基百科、说明书、客观的新闻报道' },
|
||||
{ label: '非正式', value: 'Casual & Informal', description: '朋友聊天、社交媒体、非正式简讯' },
|
||||
{ label: '亲切友好', value: 'Warm & Friendly', description: '社区信函、给朋友的建议、温馨提示' },
|
||||
{ label: '严谨权威', value: 'Strict & Authoritative', description: '警示标志、强制规定、上级指令' },
|
||||
{ label: '热情生动', value: 'Enthusiastic & Vivid', description: '广告文案、旅游推荐、博主推文' },
|
||||
];
|
||||
|
||||
export const DEFAULT_TEMPLATE = `You are a professional {SOURCE_LANG} ({SOURCE_CODE}) to {TARGET_LANG} ({TARGET_CODE}) translator. Your goal is to accurately convey the meaning and nuances of the original {SOURCE_LANG} text while adhering to {TARGET_LANG} grammar, vocabulary, and cultural sensitivities.
|
||||
|
||||
Translation Context & Style Constraints:
|
||||
Speaker Identity: {SPEAKER_IDENTITY}. Ensure all grammatical agreements and self-referential terms in {TARGET_LANG} reflect this.
|
||||
Tone & Register: {TONE_REGISTER}.
|
||||
|
||||
Produce only the {TARGET_LANG} translation, without any additional explanations or commentary. Please translate the following {SOURCE_LANG} text into {TARGET_LANG}:
|
||||
|
||||
|
||||
@@ -42,6 +64,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const sourceLang = useLocalStorage<Language>('source-lang-v2', LANGUAGES[0]);
|
||||
const targetLang = useLocalStorage<Language>('target-lang-v2', LANGUAGES[4]);
|
||||
|
||||
const speakerIdentity = useLocalStorage('speaker-identity', SPEAKER_IDENTITY_OPTIONS[0].value);
|
||||
const toneRegister = useLocalStorage('tone-register', TONE_REGISTER_OPTIONS[0].value);
|
||||
|
||||
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
||||
|
||||
const addLog = (type: 'request' | 'response' | 'error', content: any) => {
|
||||
@@ -64,6 +89,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
systemPromptTemplate,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
speakerIdentity,
|
||||
toneRegister,
|
||||
logs,
|
||||
addLog
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user