You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

449 lines
18 KiB

<template>
<div>
<div v-if="!backendReady" class="mb-4 rounded-lg px-4 py-3 text-sm" style="background:#fffbeb;border:1px solid #fde68a;color:#92400e">
⚠ 「通话翻译配置」的后端接口尚未迁移到当前 console 服务,功能暂不可用。
</div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">通话翻译配置</h2>
<p class="text-xs text-slate-400 mt-1">按区域和语言对配置翻译服务。支持三段式(ASR→MT→TTS)和端到端 AST 两种模式。</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">区域</span>
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="loadPairs">
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新增语言对</button>
</div>
</div>
<!-- 行内编辑器 -->
<div v-show="editorOpen" class="editor-panel">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-slate-800 text-sm">{{ editingPair ? '编辑语言对' : '新增语言对' }}</h3>
<button class="btn btn-ghost btn-xs" @click="closeEditor">✕ 关闭</button>
</div>
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">源语言 *</span></label>
<select v-model="ed.src" class="select select-bordered select-sm">
<option v-for="l in LANGS" :key="l.c" :value="l.c">{{ l.n }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">目标语言 *</span></label>
<select v-model="ed.dst" class="select select-bordered select-sm">
<option v-for="l in LANGS" :key="l.c" :value="l.c">{{ l.n }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">双向</span></label>
<input v-model="ed.bidir" type="checkbox" class="toggle toggle-primary mt-1" />
</div>
</div>
<div class="flex gap-3 mb-4">
<div class="mtab" :class="{ on: ed.mode === 0 }" @click="ed.mode = 0">
<div>
<div class="font-bold text-sm">🧩 三段式拼接</div>
<div class="text-xs text-slate-400 mt-0.5">ASR → MT → TTS</div>
</div>
</div>
<div class="mtab" :class="{ on: ed.mode === 1 }" @click="ed.mode = 1">
<div>
<div class="font-bold text-sm">⚡ 端到端 AST</div>
<div class="text-xs text-slate-400 mt-0.5">单服务直接完成语音翻译</div>
</div>
</div>
</div>
<div>
<div v-if="ed.mode === 0" class="grid grid-cols-3 gap-4">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">语音识别(ASR)</span></label>
<select v-model="ed.asr" class="select select-bordered select-sm">
<option v-if="!svcsByCat(SCAT_STT).length" value="">(无可用服务)</option>
<option v-for="s in svcsByCat(SCAT_STT)" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">机器翻译(MT)</span></label>
<select v-model="ed.mt" class="select select-bordered select-sm">
<option v-if="!svcsByCat(SCAT_MT).length" value="">(无可用服务)</option>
<option v-for="s in svcsByCat(SCAT_MT)" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">语音合成(TTS)</span></label>
<select v-model="ed.tts" class="select select-bordered select-sm">
<option v-if="!svcsByCat(SCAT_TTS).length" value="">(无可用服务)</option>
<option v-for="s in svcsByCat(SCAT_TTS)" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
</div>
<div v-else class="form-control max-w-xs">
<label class="label py-1"><span class="label-text text-xs">端到端语音翻译服务(AST)</span></label>
<select v-model="ed.ast" class="select select-bordered select-sm">
<option v-if="!svcsByCat(SCAT_AST).length" value="">(无可用服务)</option>
<option v-for="s in svcsByCat(SCAT_AST)" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
</div>
<div class="flex items-center justify-between pt-4 border-t border-slate-100 mt-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="ed.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> 启用
</label>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" @click="closeEditor">取消</button>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal">保存</button>
</div>
</div>
</div>
<!-- 语言对卡片 -->
<div class="grid grid-cols-1 gap-4" style="grid-template-columns:repeat(auto-fill,minmax(360px,1fr))">
<div v-if="!pairs.length" class="col-span-full text-center py-20 text-slate-300">
<div class="text-5xl mb-3">🌐</div>
<p class="text-sm">当前区域暂无语言对配置</p>
</div>
<div v-for="p in pairs" :key="p.id" class="pair-card" @click="editPair(p.id)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="lang-badge">{{ langName(p.src_lang) }}</span>
<span class="text-slate-400 font-bold text-lg">{{ p.bidir ? '⇄' : '→' }}</span>
<span class="lang-badge">{{ langName(p.dst_lang) }}</span>
</div>
<div class="flex items-center gap-2">
<span v-if="p.mode === 0" class="mode-badge mode-pipeline">三段式拼接</span>
<span v-else class="mode-badge mode-e2e">端到端 AST</span>
<span v-if="p.enable" class="badge badge-success badge-xs">启用</span>
<span v-else class="badge badge-ghost badge-xs">停用</span>
<button class="btn btn-ghost btn-xs text-error" @click.stop="deletePair(p.id)">删除</button>
</div>
</div>
<div v-if="p.mode === 0" class="flex flex-wrap items-center gap-2 mt-3">
<span class="svc-pill" style="background:#1664ff">ASR: {{ svcName(p.asr_svc_id) }}</span>
<span class="text-slate-300 text-sm">→</span>
<span class="svc-pill" style="background:#059669">MT: {{ svcName(p.mt_svc_id) }}</span>
<span class="text-slate-300 text-sm">→</span>
<span class="svc-pill" style="background:#7c3aed">TTS: {{ svcName(p.tts_svc_id) }}</span>
</div>
<div v-else class="flex items-center gap-2 mt-3">
<span class="svc-pill" style="background:#f97316">AST: {{ svcName(p.ast_svc_id) }}</span>
</div>
</div>
</div>
<!-- 同步弹窗 -->
<div v-show="syncOpen" class="sync-overlay">
<div class="sync-box">
<div class="flex items-start gap-3 mb-4">
<div style="width:42px;height:42px;background:linear-gradient(135deg,#4f46e5,#7c3aed);border-radius:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:20px;height:20px;color:#fff" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</div>
<div>
<h3 class="text-base font-semibold text-slate-800">同步到其他区域</h3>
<p class="text-xs text-slate-400 mt-1">{{ syncDesc }}</p>
</div>
</div>
<div class="text-xs px-3 py-2 rounded-lg mb-3" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d">
✦ 同步内容:服务组合(模式 + 各段服务)将被复制到目标区域。
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500">选择目标区域</span>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs" @click="syncSelAll(true)">全选</button>
<button class="btn btn-ghost btn-xs" @click="syncSelAll(false)">清空</button>
</div>
</div>
<div class="sync-region-grid">
<label
v-for="r in syncRegions"
:key="r.value"
class="sync-region-item"
:class="{ checked: syncTargets.includes(r.value) }"
@click.prevent="toggleSyncR(r.value)"
>
<input
type="checkbox"
class="checkbox checkbox-xs checkbox-primary"
:checked="syncTargets.includes(r.value)"
@click.stop="toggleSyncR(r.value)"
/>
<span>{{ r.label }}</span>
</label>
</div>
<p class="text-[11px] text-slate-400 mb-5">⚠ 目标区域相同语言对已存在则覆盖,否则新建。</p>
<div class="flex gap-3 justify-end">
<button class="btn btn-ghost" @click="doSaveOnly">仅保存当前区域</button>
<button class="btn btn-primary text-white" @click="doSaveAndSync">保存并同步</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '通话翻译配置' })
// ===== 类型 =====
interface SvcConfig {
id: string
name: string
enable: boolean
categories: string
}
interface Pair {
id: number
region: number
src_lang: string
dst_lang: string
bidir: boolean
mode: number
asr_svc_id: string
mt_svc_id: string
tts_svc_id: string
ast_svc_id: string
enable: boolean
}
// ===== 常量 =====
const LANGS = [
{ c: 'zh', n: '中文' }, { c: 'en', n: '英文' }, { c: 'ja', n: '日语' }, { c: 'ko', n: '韩语' },
{ c: 'fr', n: '法语' }, { c: 'de', n: '德语' }, { c: 'es', n: '西班牙语' }, { c: 'pt', n: '葡萄牙语' },
{ c: 'ru', n: '俄语' }, { c: 'ar', n: '阿拉伯语' }, { c: 'th', n: '泰语' }, { c: 'vi', n: '越南语' },
{ c: 'id', n: '印尼语' }, { c: 'ms', n: '马来语' }, { c: 'hi', n: '印地语' },
]
function langName(c?: string): string {
const x = LANGS.find((l) => l.c === c)
return x ? x.n : (c || '')
}
// 区域枚举(与 pb.Region 一一对应)
const REGIONS = [
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
function regionLabel(v: number): string {
const r = REGIONS.find((x) => x.value === v)
return r ? r.label : '区域' + v
}
// 服务分类
const SCAT_STT = 1
const SCAT_TTS = 2
const SCAT_MT = 3
const SCAT_AST = 4
const { webApi } = useApi()
const { success, error } = useToast()
const backendReady = ref(true)
const allSvcs = ref<SvcConfig[]>([])
const pairs = ref<Pair[]>([])
const curRegion = ref<number>(REGIONS.length ? REGIONS[0].value : 1)
const editorOpen = ref(false)
const editingPair = ref<Pair | null>(null)
const pendingId = ref<number>(0)
const ed = reactive({
src: 'zh',
dst: 'en',
bidir: false,
mode: 0,
asr: '',
mt: '',
tts: '',
ast: '',
enable: true,
})
// ===== 加载 =====
async function loadSvcList() {
try {
const data = await webApi<{ items: SvcConfig[] }>('getsvcconfigs', {})
allSvcs.value = data?.items ?? []
} catch {
backendReady.value = false
allSvcs.value = []
}
}
async function loadPairs() {
try {
const data = await webApi<{ items: Pair[] }>('getcalltranslatepairs', { region: curRegion.value })
pairs.value = data?.items ?? []
} catch {
backendReady.value = false
pairs.value = []
}
}
// ===== 服务筛选 =====
function svcsByCat(cat: number): SvcConfig[] {
return allSvcs.value.filter(
(s) => s.enable && (s.categories || '').split(',').map(Number).indexOf(cat) >= 0,
)
}
function svcName(id?: string): string {
const s = allSvcs.value.find((x) => x.id === id)
return s ? s.name : (id || '未配置')
}
// ===== 编辑器 =====
function fillEditor(p: Pair | null) {
ed.src = p?.src_lang || 'zh'
ed.dst = p?.dst_lang || 'en'
ed.bidir = !!p?.bidir
ed.mode = p?.mode != null ? p.mode : 0
ed.asr = p?.asr_svc_id || ''
ed.mt = p?.mt_svc_id || ''
ed.tts = p?.tts_svc_id || ''
ed.ast = p?.ast_svc_id || ''
ed.enable = p ? p.enable !== false : true
}
function startAdd() {
editingPair.value = null
fillEditor(null)
editorOpen.value = true
}
function editPair(id: number) {
const p = pairs.value.find((x) => x.id === id) || null
editingPair.value = p
fillEditor(p)
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
editingPair.value = null
}
// ===== 收集编辑器数据 =====
function collectPairData(): Pair {
const mode = ed.mode
return {
id: editingPair.value ? editingPair.value.id : 0,
region: curRegion.value,
src_lang: ed.src,
dst_lang: ed.dst,
bidir: ed.bidir,
mode,
asr_svc_id: mode === 0 ? ed.asr || '' : '',
mt_svc_id: mode === 0 ? ed.mt || '' : '',
tts_svc_id: mode === 0 ? ed.tts || '' : '',
ast_svc_id: mode === 1 ? ed.ast || '' : '',
enable: ed.enable,
}
}
// ===== 保存 =====
async function performSave(): Promise<boolean> {
const data = collectPairData()
if (!data.src_lang || !data.dst_lang) {
error('源语言和目标语言必填')
return false
}
try {
const res = await webApi<{ id: number }>('savecalltranslatepair', data)
pendingId.value = res?.id ?? 0
await loadPairs()
return true
} catch (e: any) {
error(e?.message ?? '保存失败')
return false
}
}
function deletePair(id: number) {
if (!window.confirm('确定删除这条语言对配置?')) return
webApi('delcalltranslatepair', { ids: [id] })
.then(() => { success('已删除'); loadPairs() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
// ===== 同步弹窗 =====
const syncOpen = ref(false)
const syncDesc = ref('')
const syncTargets = ref<number[]>([])
const syncRegions = computed(() => REGIONS.filter((r) => r.value !== curRegion.value))
function openSyncModal() {
const mode = ed.mode === 0 ? '三段式拼接' : '端到端 AST'
syncDesc.value =
'将「' + langName(ed.src) + ' → ' + langName(ed.dst) + '」(' + mode + (ed.bidir ? ',双向' : '') + ' )的服务编排同步到其他区域。'
syncTargets.value = []
syncOpen.value = true
}
function toggleSyncR(v: number) {
const idx = syncTargets.value.indexOf(v)
if (idx >= 0) syncTargets.value.splice(idx, 1)
else syncTargets.value.push(v)
}
function syncSelAll(val: boolean) {
syncTargets.value = val ? syncRegions.value.map((r) => r.value) : []
}
function closeSyncModal() {
syncOpen.value = false
}
async function doSaveOnly() {
closeSyncModal()
const ok = await performSave()
if (ok) { closeEditor(); success('保存成功') }
}
async function doSaveAndSync() {
const targets = [...syncTargets.value]
closeSyncModal()
const ok = await performSave()
if (!ok) return
closeEditor()
if (!targets.length) { success('保存成功'); return }
try {
const res = await webApi<{ synced: number }>('synccalltranslatepairs', {
pair_ids: [pendingId.value],
target_regions: targets,
})
const synced = res?.synced ?? 0
const names = targets.map((v) => regionLabel(v))
success('已保存并同步到:' + names.join('、') + '(' + synced + ' 个)')
} catch (e: any) {
error(e?.message ?? '同步失败')
}
}
onMounted(async () => {
await Promise.all([loadSvcList(), loadPairs()])
})
</script>
<style scoped>
.pair-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 16px; cursor: pointer; transition: all .15s; }
.pair-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); }
.lang-badge { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; background: #eef2ff; color: #4338ca; }
.svc-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 8px; color: #fff; }
.mode-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
.mode-pipeline { background: #dbeafe; color: #1e40af; }
.mode-e2e { background: #fff7ed; color: #c2410c; }
/* 行内编辑器 */
.editor-panel { background: #fff; border-radius: 16px; border: 1px solid #c7d2fe; box-shadow: 0 8px 32px rgba(99, 102, 241, .1); padding: 22px; margin-bottom: 16px; }
.mtab { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 10px; border: 2px solid transparent; cursor: pointer; transition: all .15s; flex: 1; }
.mtab.on { border-color: #6366f1; background: linear-gradient(135deg, #eef2ff, #f5f3ff); }
/* 同步弹窗 */
.sync-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); }
.sync-box { background: #fff; border-radius: 20px; padding: 28px; width: 520px; max-width: 95vw; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
.sync-region-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 14px 0; }
.sync-region-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; transition: all .15s; font-size: 13px; font-weight: 500; color: #475569; }
.sync-region-item:hover { border-color: #c7d2fe; background: #f5f3ff; }
.sync-region-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
</style>