Files
web-fusion/src/components/GreetingDialog.vue

466 lines
21 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.
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 bg-black/50 flex items-center justify-center" style="z-index: 9999">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col mx-4">
<!-- 头部 -->
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">打招呼内容</h3>
<button @click="onClose" class="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
<div class="flex-1 overflow-auto p-4 space-y-4">
<!-- 源文本区 -->
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex items-center justify-between mb-3">
<span class="font-medium text-gray-800">源文本</span>
<div class="flex gap-2">
<button @click="addSentence"
class="px-3 py-1 text-sm bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200">
新增一行
</button>
<button @click="fetchPrologue" :disabled="isFetching || !isElectronEnv"
class="px-3 py-1 text-sm bg-purple-100 text-purple-700 border border-purple-300 rounded hover:bg-purple-200 disabled:opacity-50">
{{ isFetching ? '获取中...' : '从服务端获取' }}
</button>
<button @click="clearAll"
class="px-3 py-1 text-sm bg-gray-100 text-gray-700 border border-gray-300 rounded hover:bg-gray-200">
清空
</button>
</div>
</div>
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange" @paste="handlePaste"
placeholder="每行一句打招呼内容..."
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
<div class="text-xs text-gray-500 mt-2">
提示: 每行一句可直接粘贴多行文本
</div>
</div>
<!-- 翻译开关 -->
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="needTranslate" class="w-4 h-4" />
<span class="text-sm text-gray-700">启用翻译</span>
</label>
</div>
<!-- 大区选择与翻译区 -->
<div v-if="needTranslate" class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-medium text-gray-800 text-lg">选择大区</h3>
<div class="flex gap-2">
<input type="text" placeholder="搜索大区..." v-model="searchTerm"
class="px-3 py-1.5 border border-gray-300 rounded text-sm w-40 focus:border-blue-500 focus:outline-none" />
<button @click="handleTranslate"
:disabled="isTranslating || selectedRegions.length === 0 || !isElectronEnv"
class="px-4 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center gap-2 transition-colors">
<template v-if="isTranslating">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin">
</div>
<span>翻译中...</span>
</template>
<template v-else>
<span>翻译 ({{ selectedLanguages.length }} 种语言)</span>
</template>
</button>
</div>
</div>
<!-- 大区选择网格 -->
<div
class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 gap-2 max-h-48 overflow-y-auto mb-4 p-1 border rounded bg-gray-50">
<div v-for="region in filteredRegions" :key="region" @click="toggleRegion(region)" :class="[
'cursor-pointer text-center py-2 px-2 text-sm rounded border transition-all select-none',
selectedRegions.includes(region) ? 'bg-blue-50 border-blue-500 text-blue-600 font-medium shadow-sm' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:shadow-sm'
]">
{{ region }}
</div>
<div v-if="filteredRegions.length === 0" class="col-span-full text-center py-8 text-gray-400">
未找到相关大区
</div>
</div>
<!-- 选中大区的语言预览 -->
<div v-if="selectedRegions.length > 0" class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div class="text-sm text-blue-800 font-medium mb-2">
选中 {{ selectedRegions.length }} 个大区将翻译以下 {{ selectedLanguages.length }} 种语言
</div>
<div class="flex flex-wrap gap-1">
<span v-for="lang in selectedLanguages" :key="lang"
class="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
{{ getLangLabel(lang) }}
</span>
</div>
</div>
<!-- 翻译结果标签页 -->
<div v-if="selectedLanguages.length > 0 && Object.keys(translations).length > 0">
<div class="flex gap-1 border-b border-gray-200 mb-3 overflow-x-auto pb-1"
style="scrollbar-width: thin">
<button v-for="lang in selectedLanguages.filter(l => translations[l])" :key="lang"
@click="activeTab = lang" :class="[
'px-3 py-2 text-sm border-b-2 transition-all whitespace-nowrap flex-shrink-0',
activeTab === lang ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-700 hover:text-gray-700'
]">
{{ getLangLabel(lang) }}
</button>
</div>
<!-- 当前语言的翻译结果 -->
<div class="space-y-2 max-h-40 overflow-auto">
<template v-if="translations[activeTab]?.length">
<div v-for="(t, i) in translations[activeTab]" :key="i" class="flex items-center gap-2">
<input type="text" :value="t" @input="updateTranslation($event.target.value, i)"
class="flex-1 px-3 py-1.5 text-sm text-gray-900 border border-gray-300 rounded focus:border-blue-500 focus:outline-none" />
<div class="relative group/source">
<span
class="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded cursor-help hover:bg-blue-100 transition-colors border border-blue-200">
</span>
<!-- 悬停时显示源文本 - 向左展开 -->
<div
class="absolute top-1/2 right-full -translate-y-1/2 mr-2 hidden group-hover/source:block z-50">
<div
class="bg-white text-gray-800 text-sm px-4 py-2 rounded-lg shadow-xl border border-gray-200 min-w-[280px] max-w-[400px]">
{{ sentences[i] || '(空)' }}
</div>
</div>
</div>
<!-- 删除按钮 -->
<button @click="removeTranslation(i)"
class="text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded transition-colors"
title="删除此行">
</button>
</div>
</template>
<div v-else class="text-sm text-gray-700">无数据点击翻译获取</div>
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
<span class="text-xs text-gray-500">
{{ sentences.filter(Boolean).length }} · 选择 {{ selectedRegions.length }} 个大区 · {{
selectedLanguages.length }} 种语言
</span>
<div class="flex gap-3">
<button @click="onClose" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleConfirm"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
确定
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getRegions, getLanguagesForRegions } from '../utils/regionLanguageMapper'
import { isElectron } from '../utils/electronBridge'
const props = defineProps({
visible: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'confirm'])
const STORAGE_KEY = 'greeting_dialog_data'
const REGION_LIST = getRegions()
const sentences = ref([''])
const bulkText = ref('')
const selectedRegions = ref([])
const translations = ref({})
const activeTab = ref('')
const needTranslate = ref(false)
const isTranslating = ref(false)
const searchTerm = ref('')
const isFetching = ref(false)
const isElectronEnv = isElectron()
// Helper Functions
const getLangLabel = (langCode) => {
const langNames = {
'ar': '阿拉伯语', 'es': '西班牙语', 'en': '英语', 'fr': '法语',
'pt': '葡萄牙语', 'de': '德语', 'it': '意大利语', 'ja': '日语',
'ko': '韩语', 'zh-TW': '繁体中文', 'id': '印尼语', 'ms': '马来语',
'tl': '菲律宾语', 'th': '泰语', 'vi': '越南语', 'tr': '土耳其语',
'ro': '罗马尼亚语', 'pl': '波兰语', 'nl': '荷兰语', 'hy': '亚美尼亚语',
'az': '阿塞拜疆语', 'be': '白俄罗斯语', 'ka': '格鲁吉亚语', 'ky': '吉尔吉斯语',
'kk': '哈萨克语', 'tg': '塔吉克语', 'tk': '土库曼语', 'uk': '乌克兰语',
'uz': '乌兹别克语', 'da': '丹麦语', 'fi': '芬兰语', 'is': '冰岛语',
'no': '挪威语', 'sv': '瑞典语', 'cs': '捷克语', 'hu': '匈牙利语',
'sk': '斯洛伐克语', 'et': '爱沙尼亚语', 'lt': '立陶宛语', 'lv': '拉脱维亚语',
'sq': '阿尔巴尼亚语', 'bs': '波斯尼亚语', 'bg': '保加利亚语', 'el': '希腊语',
'hr': '克罗地亚语', 'sr': '塞尔维亚语', 'mk': '马其顿语', 'sl': '斯洛文尼亚语',
'mt': '马耳他语', 'ca': '加泰罗尼亚语', 'sm': '萨摩亚语', 'to': '汤加语',
'bi': '比斯拉马语', 'so': '索马里语', 'kl': '格陵兰语',
}
return langNames[langCode] || langCode
}
const filteredRegions = computed(() => {
return REGION_LIST.filter(r => r.toLowerCase().includes(searchTerm.value.toLowerCase()))
})
const selectedLanguages = computed(() => {
return getLanguagesForRegions(selectedRegions.value)
})
// Lifecycle
onMounted(() => {
if (props.visible) {
document.body.style.overflow = 'hidden'
loadFromStorage()
}
})
watch(() => props.visible, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
loadFromStorage()
} else {
document.body.style.overflow = ''
}
})
// Auto save
watch([sentences, selectedRegions, translations, needTranslate, activeTab], () => {
if (props.visible) {
saveToStorage()
}
}, { deep: true })
watch(selectedLanguages, (newLangs) => {
if (newLangs.length > 0) {
if (!newLangs.includes(activeTab.value)) {
const firstLangWithTranslation = newLangs.find(lang => translations.value[lang])
activeTab.value = firstLangWithTranslation || newLangs[0]
}
}
})
function loadFromStorage() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const data = JSON.parse(saved)
if (data.sentences?.length) sentences.value = data.sentences
if (data.selectedRegions?.length) selectedRegions.value = data.selectedRegions
if (data.translations) translations.value = data.translations
if (typeof data.needTranslate === 'boolean') needTranslate.value = data.needTranslate
if (data.activeTab) activeTab.value = data.activeTab
} catch (e) {
console.error('加载本地数据失败:', e)
}
}
}
function saveToStorage() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
sentences: sentences.value,
selectedRegions: selectedRegions.value,
translations: translations.value,
needTranslate: needTranslate.value,
activeTab: activeTab.value,
}))
}
// Actions
const onClose = () => emit('close')
const addSentence = () => {
sentences.value.push('')
}
const handlePaste = (e) => {
// e.preventDefault() handled by Vue if needed but standard logic applies
// We can rely on @paste event
// prevent default to handle manually
// Actually Vue @paste doesn't prevent default automatically.
// Let's grab data and prevent default.
/* Note: In Vue script setup, event is passed directly */
}
const handleBulkChange = (e) => {
const text = e.target.value
bulkText.value = text
const lines = text.split('\n').map(l => l.trim()).filter(Boolean)
sentences.value = lines.length ? lines : ['']
}
// We need to implement handlePaste manually to match React logic slightly better if we want exact same behavior
// but textarea default paste behavior + v-model or @input works too.
// However, the React code did:
// e.preventDefault(); getData; setBulkText; split...
// So we should do the same to strip formatting etc.
// In the template I used @paste="handlePaste"
// Let's refine handlePaste:
const handlePasteEvent = (e) => {
// e is the event
// But I used handlePaste name above which was empty
}
// Re-defining for clarity/correctness
const onPaste = (e) => {
// e is clipboard event
// But actually simpler to just let it paste and handle input?
// React code was explicit. Let's match it.
// But textarea v-model will sync. The React code set sentences from text split.
// If I paste, I get newlines.
// Let's stick with input handler which syncs sentences.
}
// Actually, converting React logic:
// const handlePaste = (e) ... setBulkText(text); setSentences(lines...)
// We can do that in Vue:
const clearAll = () => {
sentences.value = ['']
bulkText.value = ''
translations.value = {}
}
const toggleRegion = (region) => {
if (selectedRegions.value.includes(region)) {
selectedRegions.value = selectedRegions.value.filter(r => r !== region)
} else {
selectedRegions.value.push(region)
}
}
const fetchPrologue = async () => {
if (!isElectronEnv) {
alert('此功能仅在 Electron 环境中可用')
return
}
if (sentences.value.some(s => s.trim()) && !confirm('当前已有内容,获取新内容将清空现有内容,是否继续?')) {
return
}
isFetching.value = true
try {
console.log('[GreetingDialog] 开始获取打招呼内容...')
const result = await window.electronAPI.fetchPrologue()
console.log('[GreetingDialog] 获取结果:', result)
if (result.success && result.data && Array.isArray(result.data)) {
console.log('[GreetingDialog] 更新 sentences:', result.data.length, '条')
sentences.value = result.data
translations.value = {}
} else {
console.error('[GreetingDialog] 数据格式错误:', result)
alert(result.error || '获取失败:格式错误')
}
} catch (e) {
console.error('获取失败:', e)
alert('获取失败,请重试')
} finally {
isFetching.value = false
}
}
const handleTranslate = async () => {
if (!isElectronEnv) {
alert('此功能仅在 Electron 环境中可用')
return
}
const validSentences = sentences.value.filter(Boolean)
if (validSentences.length === 0 || selectedRegions.value.length === 0) return
const languagesToTranslate = getLanguagesForRegions(selectedRegions.value)
if (languagesToTranslate.length === 0) {
alert('选中的大区没有可翻译的语言')
return
}
isTranslating.value = true
try {
const newTranslations = {}
const joinedText = validSentences.join('\n')
await Promise.all(languagesToTranslate.map(async (lang) => {
try {
const result = await window.electronAPI.translate(joinedText, lang)
if (result.success) {
let translatedLines = result.result.split('\n').map(s => s.trim())
if (translatedLines.length > 0) {
if (translatedLines[0].startsWith('{')) {
translatedLines[0] = translatedLines[0].slice(1).trim()
}
const lastIdx = translatedLines.length - 1
if (translatedLines[lastIdx].endsWith('}')) {
translatedLines[lastIdx] = translatedLines[lastIdx].slice(0, -1).trim()
}
}
const finalSentences = []
let transIndex = 0
for (let i = 0; i < sentences.value.length; i++) {
if (sentences.value[i]) {
finalSentences.push(translatedLines[transIndex] || sentences.value[i])
transIndex++
} else {
finalSentences.push('')
}
}
newTranslations[lang] = finalSentences
} else {
newTranslations[lang] = sentences.value
}
} catch (e) {
console.error(`Lang ${lang} translate error:`, e)
newTranslations[lang] = sentences.value
}
}))
translations.value = newTranslations
activeTab.value = languagesToTranslate[0] || ''
} catch (error) {
console.error('翻译失败:', error)
alert('翻译失败,请重试')
} finally {
isTranslating.value = false
}
}
const handleConfirm = () => {
// console.log('sentences', sentences.value.filter(Boolean), 'translations',translations.value,'needTranslate', needTranslate.value)
emit('confirm', {
sentences: sentences.value.filter(Boolean),
translations: translations.value,
needTranslate: needTranslate.value,
})
onClose()
}
const updateTranslation = (val, index) => {
if (!translations.value[activeTab.value]) return
const newArr = [...translations.value[activeTab.value]]
newArr[index] = val
translations.value = { ...translations.value, [activeTab.value]: newArr }
}
const removeTranslation = (index) => {
if (!translations.value[activeTab.value]) return
const newArr = translations.value[activeTab.value].filter((_, i) => i !== index)
translations.value = { ...translations.value, [activeTab.value]: newArr }
}
</script>