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.
615 lines
26 KiB
615 lines
26 KiB
<template>
|
|
<div>
|
|
<!-- 顶部 -->
|
|
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">通话翻译规则</h2>
|
|
<p class="text-xs text-slate-400 mt-1">
|
|
一条规则覆盖「多源语言 × 多目标语言」,命中后走它指定的 Agent。多条规则按优先级从高到低匹配,第一条命中的生效。
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<span class="text-sm text-slate-500">作用域</span>
|
|
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll">
|
|
<option value="">全局默认</option>
|
|
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
|
|
</select>
|
|
<span class="text-sm text-slate-500">区域</span>
|
|
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="loadRules">
|
|
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
|
|
</select>
|
|
<button class="btn btn-ghost btn-sm" @click="openTest">🔍 选路试算</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新增规则</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 行内编辑器 -->
|
|
<div v-show="editorOpen" ref="editorRef" class="editor-panel">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="font-semibold text-slate-800 text-sm">{{ editing ? '编辑规则' : '新增规则' }}</h3>
|
|
<span class="region-chip">{{ scopeApp || '全局默认' }}</span>
|
|
</div>
|
|
<button class="btn btn-ghost btn-xs" @click="closeEditor">✕ 关闭</button>
|
|
</div>
|
|
|
|
<!-- 1) 匹配条件 -->
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">匹配条件</div>
|
|
<div class="lang-row">
|
|
<div class="lang-field">
|
|
<div class="flex items-center justify-between">
|
|
<span class="ed-label">源语言</span>
|
|
<label class="any-toggle">
|
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="ed.srcAny" @change="toggleAny('src')" />
|
|
任意语言
|
|
</label>
|
|
</div>
|
|
<div v-if="ed.srcAny" class="any-box">任意源语言</div>
|
|
<LangSelect v-else v-model="ed.srcLangs" multiple />
|
|
</div>
|
|
<div class="dir-col">
|
|
<button
|
|
type="button"
|
|
class="dir-btn"
|
|
:class="{ on: ed.bidir }"
|
|
:title="ed.bidir ? '点击改为单向' : '点击改为双向(反向也走这条规则)'"
|
|
@click="ed.bidir = !ed.bidir"
|
|
>{{ ed.bidir ? '⇄' : '→' }}</button>
|
|
<span class="dir-tip">{{ ed.bidir ? '双向互译' : '单向翻译' }}</span>
|
|
</div>
|
|
<div class="lang-field">
|
|
<div class="flex items-center justify-between">
|
|
<span class="ed-label">目标语言</span>
|
|
<label class="any-toggle">
|
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="ed.dstAny" @change="toggleAny('dst')" />
|
|
任意语言
|
|
</label>
|
|
</div>
|
|
<div v-if="ed.dstAny" class="any-box">任意目标语言</div>
|
|
<LangSelect v-else v-model="ed.dstLangs" multiple />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
<div>
|
|
<span class="ed-label">生效区域</span>
|
|
<select v-model.number="ed.region" class="select select-bordered select-sm w-full">
|
|
<option :value="0">全部区域(兜底)</option>
|
|
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">优先级(越大越先匹配)</span>
|
|
<input v-model.number="ed.priority" type="number" class="input input-bordered input-sm w-full" />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">规则名(便于识别)</span>
|
|
<input v-model="ed.name" class="input input-bordered input-sm w-full" placeholder="如 中日韩端到端" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 2) 命中后走哪个 Agent -->
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">命中后使用的 Agent</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span class="ed-label">翻译 Agent <i class="req">*</i></span>
|
|
<select v-model="ed.agentId" class="select select-bordered select-sm w-full">
|
|
<option value="" disabled>{{ transAgents.length ? '请选择 Agent' : '(无可用 Agent,请先去 Agent 配置里创建)' }}</option>
|
|
<optgroup v-for="g in agentGroups" :key="g.type" :label="g.label">
|
|
<option v-for="a in g.items" :key="a.id" :value="a.id">{{ a.name || a.id }}</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">备注</span>
|
|
<input v-model="ed.remark" class="input input-bordered input-sm w-full" placeholder="可留空" />
|
|
</div>
|
|
</div>
|
|
<div v-if="curAgent" class="agent-preview">
|
|
<span class="type-badge" :style="typeStyle(curAgent.type)">{{ typeLabel(curAgent.type) }}</span>
|
|
<span class="text-xs text-slate-500">{{ curAgent.description || '(无描述)' }}</span>
|
|
<span v-if="curAgent.support_langs" class="text-[11px] text-slate-400">支持:{{ curAgent.support_langs }}</span>
|
|
<span v-if="!curAgent.enable" class="text-[11px] text-amber-600">⚠ 该 Agent 当前为停用状态</span>
|
|
</div>
|
|
<p v-if="langWarn" class="ed-warn">⚠ {{ langWarn }}</p>
|
|
</section>
|
|
|
|
<div class="ed-footer">
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input v-model="ed.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span>启用</span>
|
|
<span class="text-xs text-slate-400">停用的规则不参与选路</span>
|
|
</label>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="closeEditor">取消</button>
|
|
<button class="btn btn-outline btn-sm" @click="openSyncModal">保存并同步…</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="saveOnly">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 规则列表:顺序即匹配顺序 -->
|
|
<div v-if="!rules.length" class="text-center py-20 text-slate-300">
|
|
<div class="text-5xl mb-3">🌐</div>
|
|
<p class="text-sm">当前区域还没有翻译规则</p>
|
|
<p class="text-xs mt-1">建议先加一条「任意 → 任意」的兜底规则,再往上叠精确规则</p>
|
|
</div>
|
|
<div v-else class="rule-list">
|
|
<div class="rule-head">
|
|
<span>优先级</span><span>匹配条件</span><span>Agent</span><span>区域</span><span class="text-right">操作</span>
|
|
</div>
|
|
<div v-for="(r, i) in rules" :key="r.id" class="rule-row" :class="{ off: !r.enable }" @click="startEdit(r)">
|
|
<div class="prio-cell">
|
|
<span class="prio">{{ r.priority }}</span>
|
|
<span class="order">#{{ i + 1 }}</span>
|
|
</div>
|
|
<div class="cond-cell">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="lang-badge" :class="{ any: isAny(r.src_langs) }">{{ langsLabel(r.src_langs) }}</span>
|
|
<span class="text-slate-400 font-bold">{{ r.bidir ? '⇄' : '→' }}</span>
|
|
<span class="lang-badge" :class="{ any: isAny(r.dst_langs) }">{{ langsLabel(r.dst_langs) }}</span>
|
|
</div>
|
|
<div v-if="r.name || r.remark" class="text-[11px] text-slate-400 mt-1">{{ r.name }}<span v-if="r.remark"> · {{ r.remark }}</span></div>
|
|
</div>
|
|
<div class="agent-cell">
|
|
<span class="type-badge" :style="typeStyle(agentType(r.agent_id))">{{ typeLabel(agentType(r.agent_id)) }}</span>
|
|
<span class="text-xs text-slate-700">{{ agentName(r.agent_id) }}</span>
|
|
</div>
|
|
<div class="text-xs text-slate-500">{{ r.region === 0 ? '全部区域' : regionLabel(r.region) }}</div>
|
|
<div class="text-right">
|
|
<span v-if="r.enable" class="badge badge-success badge-xs mr-2">启用</span>
|
|
<span v-else class="badge badge-ghost badge-xs mr-2">停用</span>
|
|
<button class="btn btn-ghost btn-xs text-error" @click.stop="delRule(r)">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 选路试算 -->
|
|
<div v-show="testOpen" class="ct-overlay" @click.self="testOpen = false">
|
|
<div class="ct-box">
|
|
<h3 class="text-base font-semibold text-slate-800 mb-1">选路试算</h3>
|
|
<p class="text-xs text-slate-400 mb-4">用后端运行时同一套匹配逻辑,算一遍这个方向实际会走哪条规则。</p>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span class="ed-label">源语言</span>
|
|
<LangSelect v-model="testSrc" />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">目标语言</span>
|
|
<LangSelect v-model="testDst" />
|
|
</div>
|
|
</div>
|
|
<div v-if="testResult" class="test-result" :class="{ miss: !testResult.matched }">
|
|
<template v-if="testResult.matched">
|
|
<div class="text-sm font-semibold text-slate-800 mb-1">
|
|
命中规则:{{ testResult.rule?.name || '#' + testResult.rule?.id }}(优先级 {{ testResult.rule?.priority }})
|
|
</div>
|
|
<div class="text-xs text-slate-600">
|
|
{{ langsLabel(testResult.rule?.src_langs) }} {{ testResult.rule?.bidir ? '⇄' : '→' }} {{ langsLabel(testResult.rule?.dst_langs) }}
|
|
· Agent:{{ testResult.agent?.name || testResult.rule?.agent_id }}
|
|
<span v-if="testResult.agent">({{ typeLabel(testResult.agent.type) }})</span>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="text-sm text-amber-700">没有任何规则命中 —— 这个方向的通话翻译会不可用,建议补一条兜底规则。</div>
|
|
</template>
|
|
</div>
|
|
<div class="flex gap-3 justify-end mt-5">
|
|
<button class="btn btn-ghost" @click="testOpen = false">关闭</button>
|
|
<button class="btn btn-primary text-white" @click="runTest">试算</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 同步弹窗 -->
|
|
<div v-show="syncOpen" class="ct-overlay">
|
|
<div class="ct-box">
|
|
<h3 class="text-base font-semibold text-slate-800 mb-1">同步到其他区域</h3>
|
|
<p class="text-xs text-slate-400 mb-3">{{ syncDesc }}</p>
|
|
<div class="text-xs px-3 py-2 rounded-lg mb-3" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d">
|
|
✦ 同步内容:语言条件、Agent、优先级、启停状态一并复制到目标区域。
|
|
</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">⚠ 目标区域已有「同 Agent + 同语言条件」的规则则覆盖,否则新建。</p>
|
|
<div class="flex gap-3 justify-end">
|
|
<button class="btn btn-ghost" @click="syncOpen = false">取消</button>
|
|
<button class="btn btn-primary text-white" :disabled="!syncTargets.length" @click="doSaveAndSync">
|
|
保存并同步{{ syncTargets.length ? '(' + syncTargets.length + ' 个区域)' : '' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { langName } from '~/composables/useLangOptions'
|
|
import LangSelect from '~/components/LangSelect.vue'
|
|
|
|
definePageMeta({ title: '通话翻译规则' })
|
|
|
|
// ===== 类型 =====
|
|
interface Agent {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
description: string
|
|
enable: boolean
|
|
support_langs: string
|
|
}
|
|
interface Rule {
|
|
id: number
|
|
app_name: string
|
|
region: number
|
|
name: string
|
|
src_langs: string
|
|
dst_langs: string
|
|
bidir: boolean
|
|
agent_id: string
|
|
priority: number
|
|
enable: boolean
|
|
remark: string
|
|
}
|
|
|
|
// 语言通配符,与后端 comm.LangAny 一致
|
|
const LANG_ANY = '*'
|
|
|
|
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 {
|
|
return REGIONS.find((x) => x.value === v)?.label ?? '区域' + v
|
|
}
|
|
|
|
// 通话翻译只能引用这两类 agent(与后端 callTranslateAgentTypes 一致)
|
|
const TRANS_TYPES: Record<string, { label: string; color: string; bg: string }> = {
|
|
translate: { label: '🧩 三段式翻译', color: '#059669', bg: '#dcfce7' },
|
|
'ast-translate': { label: '🚀 端到端翻译', color: '#c2410c', bg: '#fff7ed' },
|
|
}
|
|
function typeLabel(t?: string): string {
|
|
return t && TRANS_TYPES[t] ? TRANS_TYPES[t].label : (t || '未知')
|
|
}
|
|
function typeStyle(t?: string) {
|
|
const s = t ? TRANS_TYPES[t] : undefined
|
|
return s ? { background: s.bg, color: s.color } : { background: '#f1f5f9', color: '#64748b' }
|
|
}
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
// ===== 状态 =====
|
|
const scopeApp = ref('')
|
|
const scopeApps = ref<string[]>([])
|
|
const curRegion = ref<number>(REGIONS[0]!.value)
|
|
const rules = ref<Rule[]>([])
|
|
const agents = ref<Agent[]>([])
|
|
|
|
const editorOpen = ref(false)
|
|
const editing = ref<Rule | null>(null)
|
|
const editorRef = ref<HTMLElement | null>(null)
|
|
const pendingId = ref(0)
|
|
|
|
const ed = reactive({
|
|
name: '',
|
|
srcLangs: '',
|
|
dstLangs: '',
|
|
srcAny: false,
|
|
dstAny: false,
|
|
bidir: true,
|
|
region: 0,
|
|
priority: 100,
|
|
agentId: '',
|
|
remark: '',
|
|
enable: true,
|
|
})
|
|
|
|
// 只列可用于通话翻译的 agent,按类型分组
|
|
const transAgents = computed(() => agents.value.filter((a) => a.type in TRANS_TYPES))
|
|
const agentGroups = computed(() =>
|
|
Object.keys(TRANS_TYPES).map((t) => ({
|
|
type: t,
|
|
label: TRANS_TYPES[t]!.label,
|
|
items: transAgents.value.filter((a) => a.type === t),
|
|
})).filter((g) => g.items.length),
|
|
)
|
|
const curAgent = computed(() => agents.value.find((a) => a.id === ed.agentId) || null)
|
|
|
|
// 规则选的语言若不在 agent 声明的支持语言里,运行时多半会失败,配置期就提示出来
|
|
const langWarn = computed(() => {
|
|
const a = curAgent.value
|
|
if (!a || !a.support_langs) return ''
|
|
const sup = a.support_langs.split(',').map((x) => x.trim()).filter(Boolean)
|
|
if (!sup.length) return ''
|
|
const picked = [
|
|
...(ed.srcAny ? [] : ed.srcLangs.split(',')),
|
|
...(ed.dstAny ? [] : ed.dstLangs.split(',')),
|
|
].map((x) => x.trim()).filter(Boolean)
|
|
const miss = picked.filter((c) => !sup.includes(c))
|
|
if (!miss.length) return ''
|
|
return `Agent「${a.name || a.id}」未声明支持:${miss.join('、')}`
|
|
})
|
|
|
|
function isAny(s?: string): boolean {
|
|
return (s || '').trim() === LANG_ANY
|
|
}
|
|
function langsLabel(s?: string): string {
|
|
if (isAny(s)) return '任意语言'
|
|
const arr = (s || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
if (!arr.length) return '—'
|
|
if (arr.length <= 3) return arr.map((c) => langName(c)).join('、')
|
|
return arr.slice(0, 3).map((c) => langName(c)).join('、') + ` 等 ${arr.length} 种`
|
|
}
|
|
function agentName(id: string): string {
|
|
return agents.value.find((a) => a.id === id)?.name || id || '未配置'
|
|
}
|
|
function agentType(id: string): string {
|
|
return agents.value.find((a) => a.id === id)?.type || ''
|
|
}
|
|
|
|
// ===== 加载 =====
|
|
async function loadScopeApps() {
|
|
try {
|
|
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
|
|
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
|
|
} catch {
|
|
scopeApps.value = []
|
|
}
|
|
}
|
|
async function loadAgents() {
|
|
try {
|
|
const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value })
|
|
agents.value = d?.items ?? []
|
|
} catch {
|
|
agents.value = []
|
|
}
|
|
}
|
|
async function loadRules() {
|
|
try {
|
|
const d = await webApi<{ items: Rule[] }>('getcalltranslaterules', { app_name: scopeApp.value, region: curRegion.value })
|
|
rules.value = d?.items ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载失败')
|
|
rules.value = []
|
|
}
|
|
}
|
|
async function loadAll() {
|
|
await Promise.all([loadAgents(), loadRules()])
|
|
}
|
|
|
|
// ===== 编辑 =====
|
|
function revealEditor() {
|
|
nextTick(() => editorRef.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }))
|
|
}
|
|
function fillEditor(r: Rule | null) {
|
|
ed.name = r?.name ?? ''
|
|
ed.srcAny = isAny(r?.src_langs)
|
|
ed.dstAny = isAny(r?.dst_langs)
|
|
ed.srcLangs = ed.srcAny ? '' : (r?.src_langs ?? '')
|
|
ed.dstLangs = ed.dstAny ? '' : (r?.dst_langs ?? '')
|
|
ed.bidir = r ? !!r.bidir : true
|
|
ed.region = r ? r.region : curRegion.value
|
|
ed.priority = r?.priority ?? 100
|
|
ed.agentId = r?.agent_id ?? ''
|
|
ed.remark = r?.remark ?? ''
|
|
ed.enable = r ? r.enable !== false : true
|
|
}
|
|
function startAdd() {
|
|
editing.value = null
|
|
fillEditor(null)
|
|
editorOpen.value = true
|
|
revealEditor()
|
|
}
|
|
function startEdit(r: Rule) {
|
|
editing.value = r
|
|
fillEditor(r)
|
|
editorOpen.value = true
|
|
revealEditor()
|
|
}
|
|
function closeEditor() {
|
|
editorOpen.value = false
|
|
editing.value = null
|
|
}
|
|
function toggleAny(which: 'src' | 'dst') {
|
|
if (which === 'src') {
|
|
ed.srcAny = !ed.srcAny
|
|
if (ed.srcAny) ed.srcLangs = ''
|
|
} else {
|
|
ed.dstAny = !ed.dstAny
|
|
if (ed.dstAny) ed.dstLangs = ''
|
|
}
|
|
}
|
|
|
|
function collect(): Rule {
|
|
return {
|
|
id: editing.value ? editing.value.id : 0,
|
|
app_name: scopeApp.value,
|
|
region: ed.region,
|
|
name: ed.name.trim(),
|
|
src_langs: ed.srcAny ? LANG_ANY : ed.srcLangs,
|
|
dst_langs: ed.dstAny ? LANG_ANY : ed.dstLangs,
|
|
bidir: ed.bidir,
|
|
agent_id: ed.agentId,
|
|
priority: ed.priority || 0,
|
|
enable: ed.enable,
|
|
remark: ed.remark.trim(),
|
|
}
|
|
}
|
|
|
|
async function performSave(): Promise<boolean> {
|
|
const data = collect()
|
|
if (!data.src_langs) { error('源语言必选(或勾选「任意语言」)'); return false }
|
|
if (!data.dst_langs) { error('目标语言必选(或勾选「任意语言」)'); return false }
|
|
if (!data.agent_id) { error('请选择翻译 Agent'); return false }
|
|
try {
|
|
const res = await webApi<Rule>('savecalltranslaterule', data)
|
|
pendingId.value = res?.id ?? 0
|
|
await loadRules()
|
|
return true
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function saveOnly() {
|
|
if (await performSave()) { closeEditor(); success('保存成功') }
|
|
}
|
|
|
|
function delRule(r: Rule) {
|
|
if (!window.confirm(`确定删除规则「${r.name || '#' + r.id}」吗?`)) return
|
|
webApi('delcalltranslaterules', { ids: [r.id] })
|
|
.then(() => { success('已删除'); loadRules() })
|
|
.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 !== ed.region))
|
|
|
|
function openSyncModal() {
|
|
if (!ed.agentId) { error('请先选择翻译 Agent'); return }
|
|
syncDesc.value = `将「${ed.srcAny ? '任意语言' : langsLabel(ed.srcLangs)} ${ed.bidir ? '⇄' : '→'} ${ed.dstAny ? '任意语言' : langsLabel(ed.dstLangs)}」这条规则复制到其他区域。`
|
|
syncTargets.value = []
|
|
syncOpen.value = true
|
|
}
|
|
function toggleSyncR(v: number) {
|
|
const i = syncTargets.value.indexOf(v)
|
|
if (i >= 0) syncTargets.value.splice(i, 1)
|
|
else syncTargets.value.push(v)
|
|
}
|
|
function syncSelAll(val: boolean) {
|
|
syncTargets.value = val ? syncRegions.value.map((r) => r.value) : []
|
|
}
|
|
async function doSaveAndSync() {
|
|
const targets = [...syncTargets.value]
|
|
syncOpen.value = false
|
|
if (!(await performSave())) return
|
|
closeEditor()
|
|
try {
|
|
const res = await webApi<{ synced: number }>('synccalltranslaterules', { rule_ids: [pendingId.value], target_regions: targets })
|
|
success(`已保存并同步到:${targets.map(regionLabel).join('、')}(${res?.synced ?? 0} 条)`)
|
|
} catch (e: any) {
|
|
error(e?.message ?? '同步失败')
|
|
}
|
|
}
|
|
|
|
// ===== 选路试算 =====
|
|
const testOpen = ref(false)
|
|
const testSrc = ref('zh-CN')
|
|
const testDst = ref('en-US')
|
|
const testResult = ref<{ matched: boolean; rule?: Rule; agent?: Agent } | null>(null)
|
|
|
|
function openTest() {
|
|
testResult.value = null
|
|
testOpen.value = true
|
|
}
|
|
async function runTest() {
|
|
if (!testSrc.value || !testDst.value) { error('请选择源语言与目标语言'); return }
|
|
try {
|
|
testResult.value = await webApi('testcalltranslaterule', {
|
|
app_name: scopeApp.value,
|
|
region: curRegion.value,
|
|
src_lang: testSrc.value,
|
|
dst_lang: testDst.value,
|
|
})
|
|
} catch (e: any) {
|
|
error(e?.message ?? '试算失败')
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadScopeApps()
|
|
await loadAll()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 规则列表 */
|
|
.rule-list { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; overflow: hidden; }
|
|
.rule-head, .rule-row { display: grid; grid-template-columns: 92px 1fr 220px 92px 160px; gap: 12px; align-items: center; padding: 11px 16px; }
|
|
.rule-head { font-size: 11px; font-weight: 700; color: #94a3b8; background: #f8fafc; border-bottom: 1px solid #eef2f7; }
|
|
.rule-row { border-bottom: 1px solid #f6f8fb; cursor: pointer; transition: background .15s; }
|
|
.rule-row:last-child { border-bottom: none; }
|
|
.rule-row:hover { background: #f8fafc; }
|
|
.rule-row.off { opacity: .55; }
|
|
.prio-cell { display: flex; align-items: baseline; gap: 6px; }
|
|
.prio { font-size: 15px; font-weight: 800; color: #4338ca; }
|
|
.order { font-size: 10px; color: #cbd5e1; }
|
|
.cond-cell { min-width: 0; }
|
|
.agent-cell { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
.lang-badge { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; background: #eef2ff; color: #4338ca; }
|
|
.lang-badge.any { background: #f1f5f9; color: #64748b; font-style: italic; }
|
|
.type-badge { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
|
|
|
|
/* 行内编辑器 */
|
|
.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; }
|
|
.region-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
|
|
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
|
|
.ed-section:first-of-type { padding-top: 0; border-top: none; }
|
|
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
|
|
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
|
|
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; }
|
|
.req { color: #dc2626; font-style: normal; }
|
|
.any-toggle { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #64748b; cursor: pointer; }
|
|
.any-box { display: flex; align-items: center; height: 34px; padding: 0 12px; border-radius: 8px; border: 1.5px dashed #cbd5e1; background: #f8fafc; color: #64748b; font-size: 12px; font-style: italic; }
|
|
|
|
.lang-row { display: grid; grid-template-columns: 1fr 92px 1fr; align-items: start; gap: 12px; }
|
|
.lang-field { min-width: 0; }
|
|
.dir-col { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-top: 22px; }
|
|
.dir-btn { width: 46px; height: 32px; border-radius: 9px; border: 1.5px solid #e2e8f0; background: #f8fafc; color: #64748b; font-size: 17px; line-height: 1; cursor: pointer; transition: all .15s; }
|
|
.dir-btn:hover { border-color: #c7d2fe; background: #f5f3ff; color: #4338ca; }
|
|
.dir-btn.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
|
|
.dir-tip { font-size: 10px; color: #94a3b8; white-space: nowrap; }
|
|
|
|
.agent-preview { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 8px 12px; border-radius: 9px; background: #f8fafc; border: 1px solid #eef2f7; }
|
|
|
|
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding-top: 14px; border-top: 1px solid #eef2f7; }
|
|
|
|
/* 弹窗 */
|
|
.ct-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); padding: 24px; }
|
|
.ct-box { background: #fff; border-radius: 20px; padding: 28px; width: 560px; 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; }
|
|
.test-result { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #eef2ff; border: 1px solid #c7d2fe; }
|
|
.test-result.miss { background: #fffbeb; border-color: #fde68a; }
|
|
|
|
@media (max-width: 1000px) {
|
|
.rule-head, .rule-row { grid-template-columns: 70px 1fr 150px; }
|
|
.rule-head > :nth-child(4), .rule-row > :nth-child(4) { display: none; }
|
|
}
|
|
@media (max-width: 860px) {
|
|
.lang-row { grid-template-columns: 1fr; }
|
|
.dir-col { flex-direction: row; padding-top: 0; }
|
|
}
|
|
</style>
|
|
|