大哥 主播 即时消息 三合一
This commit is contained in:
465
src/components/GreetingDialog.vue
Normal file
465
src/components/GreetingDialog.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user