Files
web-fusion/src/components/GreetingDialog.vue
2026-03-24 14:17:55 +08:00

567 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.
<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">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 cursor-pointer">
<input type="radio" v-model="inputMode" value="bulk" class="w-4 h-4" />
<span class="text-sm text-gray-700">批量导入</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="radio" v-model="inputMode" value="individual" class="w-4 h-4" />
<span class="text-sm text-gray-700">单个编辑</span>
</label>
</div>
<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>
<!-- 批量导入模式 -->
<div v-if="inputMode === 'bulk'">
<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 v-else class="space-y-3">
<div v-for="(sentence, index) in sentences" :key="index"
class="bg-white rounded border border-gray-200 p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">话术 {{ index + 1 }}</span>
<button @click="removeSentence(index)"
class="text-red-500 hover:text-red-700 text-sm">
删除
</button>
</div>
<textarea :value="sentence" @input="updateSentence($event.target.value, index)"
placeholder="输入打招呼内容(可包含多行)..."
class="w-full h-20 p-2 border border-gray-200 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
</div>
</div>
</div>
<!-- 翻译开关 -->
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"
:class="{ 'opacity-50': inputMode === 'individual' }">
<input type="checkbox" v-model="needTranslate" class="w-4 h-4"
:disabled="inputMode === 'individual'" />
<span class="text-sm text-gray-700">{{ inputMode === 'individual' ? '单个编辑模式下翻译不可用' : '启用翻译'
}}</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 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>
</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 inputMode = ref('bulk') // 'bulk' 或 'individual'
// 为两个模式分别存储内容
const modeData = ref({
bulk: {
sentences: [''],
bulkText: ''
},
individual: {
sentences: ['']
}
})
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]
}
}
})
// 当模式切换时,保存当前模式的内容并加载新模式的内容
watch(inputMode, (newMode, oldMode) => {
// 保存旧模式的内容
if (oldMode) {
if (oldMode === 'bulk') {
modeData.value.bulk.sentences = [...sentences.value]
modeData.value.bulk.bulkText = bulkText.value
} else if (oldMode === 'individual') {
modeData.value.individual.sentences = [...sentences.value]
}
}
// 加载新模式的内容
if (newMode === 'bulk') {
sentences.value = [...modeData.value.bulk.sentences]
bulkText.value = modeData.value.bulk.bulkText
} else if (newMode === 'individual') {
sentences.value = [...modeData.value.individual.sentences]
needTranslate.value = false
}
})
function loadFromStorage() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const data = JSON.parse(saved)
if (data.modeData) modeData.value = data.modeData
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
if (data.inputMode) inputMode.value = data.inputMode
// 加载当前模式的内容
if (inputMode.value === 'bulk') {
sentences.value = [...modeData.value.bulk.sentences]
bulkText.value = modeData.value.bulk.bulkText
} else if (inputMode.value === 'individual') {
sentences.value = [...modeData.value.individual.sentences]
}
} catch (e) {
console.error('加载本地数据失败:', e)
}
}
}
function saveToStorage() {
// 保存当前模式的内容到 modeData
if (inputMode.value === 'bulk') {
modeData.value.bulk.sentences = [...sentences.value]
modeData.value.bulk.bulkText = bulkText.value
} else if (inputMode.value === 'individual') {
modeData.value.individual.sentences = [...sentences.value]
}
localStorage.setItem(STORAGE_KEY, JSON.stringify({
modeData: modeData.value,
selectedRegions: selectedRegions.value,
translations: translations.value,
needTranslate: needTranslate.value,
activeTab: activeTab.value,
inputMode: inputMode.value,
}))
}
// Actions
const onClose = () => emit('close')
const addSentence = () => {
sentences.value.push('')
}
const updateSentence = (value, index) => {
sentences.value[index] = value
}
const removeSentence = (index) => {
sentences.value.splice(index, 1)
if (sentences.value.length === 0) {
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) {
console.log(`翻译结果完整 ${lang} 成功:`, result)
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>