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
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>
|
|
|