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.
 
 
 
 
 
 

629 lines
33 KiB

<template>
<div>
<!-- 顶部:标题 + 作用域 -->
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">会议记录服务</h2>
<p class="text-xs text-slate-400 mt-1 max-w-3xl">
识别 / 翻译 / 总结三段,从<b>第三方服务配置</b>里选已配好的服务来编排(不再单独填凭据)。
<span class="text-slate-500">先配「全局默认」作兜底,再按应用 / 区域覆盖</span>——没配覆盖的应用自动继承全局默认。
</p>
</div>
</div>
<!-- 作用域选择器 -->
<div class="scopebar">
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 font-semibold">作用域</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadOrch">
<option value="">全局默认(兜底)</option>
<option v-for="a in apps" :key="a" :value="a">应用:{{ a }}</option>
</select>
<select v-model.number="scopeRegion" class="select select-bordered select-sm" @change="loadOrch">
<option :value="0">全区域</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<span class="text-[11px] text-slate-400">
{{ scopeApp ? `正在编辑「${scopeApp}」` : '正在编辑全局默认' }}{{ scopeRegion ? ` · ${regionLabel(scopeRegion)}` : ' · 全区域' }}
<template v-if="isOverride">(覆盖全局默认)</template>
</span>
</div>
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
<span class="loading loading-spinner loading-sm"></span>
</div>
<template v-else>
<!-- 支持语言汇总:同段并集、跨段交集 -->
<div class="langsum" :class="{ bad: !langSum.unlimited && !langSum.langs.length, warn: langSum.missing.length }">
<div class="langsum-head">
<span class="langsum-title">当前配置支持的语言</span>
<span v-if="langSum.unlimited" class="langsum-count" :class="{ off: langSum.missing.length }">未限定(各引擎自行判断)</span>
<span v-else class="langsum-count" :class="{ off: langSum.missing.length }">
{{ langSum.langs.length }} 种<template v-if="langSum.missing.length">(链路未配全,尚不可用)</template>
</span>
<span class="langsum-formula">{{ langSumFormula }}</span>
</div>
<div v-if="langSum.missing.length" class="langsum-msg bad">
⚠ {{ langSum.missing.map(kindLabel).join('、') }}段没有启用中的服务,整条链路走不通——先把这些段配上。
</div>
<div v-else-if="!langSum.unlimited && !langSum.langs.length" class="langsum-msg bad">
⚠ 各段语言无交集,任何语种都走不完整条链路。放宽某一段的支持语言,或补一个覆盖面更广的服务。
</div>
<div v-if="langSum.langs.length" class="langsum-langs">
<span v-for="l in langSum.langs" :key="l" class="lang on" :title="langName(l)">{{ l }}</span>
</div>
<div v-else-if="langSum.unlimited && !langSum.missing.length" class="langsum-msg">
各段都没限定支持语言(服务未填 languages 字段),实际能否处理由引擎判断。
</div>
</div>
<!-- 无可用服务提示 -->
<div v-if="!available.length" class="alert alert-warning py-2 text-sm mb-4">
<span>
该作用域没有可选服务。先去<b>第三方服务配置</b>添加「录音文件识别 / 机器翻译 / 文本或多媒体大模型」类的服务
(全局默认或该应用作用域),这里才能选。
</span>
</div>
<!-- ============ 识别 ============ -->
<div class="card">
<div class="card-head">
<div>
<div class="card-title">识别(录音文件转写)</div>
<div class="card-desc">按顺序找<b>第一个「已启用 + 支持源语言」</b>的服务,不支持则跳到下一个。把便宜的排前面即"优先用性价比高的"。</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs" :disabled="ofKind(1).length < 2 || busy" @click="sortByCost(1)">⇅ 按参考单价重排</button>
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(1).length" @click="openAdd(1)">+ 添加识别服务</button>
</div>
</div>
<div class="probe-bar">
<span class="text-xs text-slate-500 font-semibold">选路预览</span>
<select v-model="probeLang" class="select select-bordered select-xs w-44 text-xs">
<option v-for="o in LANG_OPTIONS" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
</select>
<span v-if="asrHit" class="probe-out">命中 {{ svcName(asrHit.svc_id) }}<template v-if="asrHit.cost_hint"> · ¥{{ asrHit.cost_hint }}/时</template></span>
<span v-else class="probe-out bad">无可用服务 —— {{ langName(probeLang) }} 会转写失败</span>
</div>
<div v-if="!ofKind(1).length" class="py-10 text-center text-slate-400 text-sm">暂无识别服务,点右上「+ 添加识别服务」</div>
<div v-for="(o, i) in ofKind(1)" :key="o.row_id" class="svc asr" :class="{ hit: asrHit && asrHit.row_id === o.row_id, off: !o.enabled }">
<div class="svc-rank" :class="{ hit: asrHit && asrHit.row_id === o.row_id }">{{ i + 1 }}</div>
<div class="min-w-0">
<div class="svc-name" :class="{ skip: asrSkipped(o) }">{{ svcName(o.svc_id) }}</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="flex flex-wrap gap-1">
<template v-if="svcLangs(o.svc_id)">
<span v-for="l in svcLangs(o.svc_id).split(',').slice(0, 5)" :key="l" class="lang" :class="{ on: l === probeLang }">{{ l }}</span>
<span v-if="svcLangs(o.svc_id).split(',').length > 5" class="lang">+{{ svcLangs(o.svc_id).split(',').length - 5 }}</span>
</template>
<span v-else class="text-[11px] text-slate-400">未限定</span>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /时</small></template><span v-else class="text-slate-300">—</span></div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(1, i, -1)">↑</button>
<button class="arrow" :disabled="i === ofKind(1).length - 1 || busy" aria-label="下移" @click="move(1, i, 1)">↓</button>
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
<div class="svc-why" :class="asrWhyClass(o)">{{ asrWhy(o) }}</div>
</div>
</div>
<!-- ============ 翻译 ============ -->
<div class="card mt-4">
<div class="card-head">
<div>
<div class="card-title">翻译</div>
<div class="card-desc">按顺序取<b>第一个已启用</b>的服务;调用失败自动回退下一个。</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs" :disabled="ofKind(2).length < 2 || busy" @click="sortByCost(2)">⇅ 按参考单价重排</button>
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(2).length" @click="openAdd(2)">+ 添加翻译服务</button>
</div>
</div>
<div v-if="!ofKind(2).length" class="py-10 text-center text-slate-400 text-sm">暂无翻译服务</div>
<div v-for="(o, i) in ofKind(2)" :key="o.row_id" class="svc mt" :class="{ hit: mtHit && mtHit.row_id === o.row_id, off: !o.enabled }">
<div class="svc-rank" :class="{ hit: mtHit && mtHit.row_id === o.row_id }">{{ i + 1 }}</div>
<div class="min-w-0">
<div class="svc-name">{{ svcName(o.svc_id) }}</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万字符</small></template><span v-else class="text-slate-300">—</span></div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(2, i, -1)">↑</button>
<button class="arrow" :disabled="i === ofKind(2).length - 1 || busy" aria-label="下移" @click="move(2, i, 1)">↓</button>
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
<div class="svc-why" :class="!o.enabled ? 'no' : (mtHit && mtHit.row_id === o.row_id ? 'ok' : '')">
<template v-if="!o.enabled">已停用,跳过</template>
<template v-else-if="mtHit && mtHit.row_id === o.row_id">✓ 默认由它承接,失败顺延</template>
<template v-else>备用:前面失败时接手</template>
</div>
</div>
</div>
<!-- ============ 总结 ============ -->
<div class="card mt-4">
<div class="card-head">
<div>
<div class="card-title">总结(大模型)</div>
<div class="card-desc">后台指定默认模型,客户端可在允许范围内自选。带 🖼 的是<b>多媒体大模型</b>,能识别会议里的图片。</div>
</div>
<button class="btn btn-primary btn-xs text-white shrink-0" :disabled="!addable(3).length" @click="openAdd(3)">+ 添加模型</button>
</div>
<div class="mode-bar">
<label class="flex items-center gap-2 cursor-pointer shrink-0">
<input type="checkbox" class="toggle toggle-primary toggle-sm" :checked="llmClientChoice" :disabled="busy" @change="toggleClientChoice" />
<span class="text-sm font-semibold text-slate-700">允许客户端指定模型</span>
</label>
<p class="mode-txt">
<template v-if="llmClientChoice">
前端能选 <b>{{ pickable.length }}</b> 个模型<template v-if="pickable.length">:{{ pickable.map((x) => svcName(x.svc_id)).join('、') }}</template>
<template v-else>(一个都没勾「前端可见」,等同强制默认)</template>。不传则用默认 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>。
</template>
<template v-else>
前端不下发模型列表,一律走默认模型 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>;客户端传了也忽略。
</template>
</p>
</div>
<div v-if="!ofKind(3).length" class="py-10 text-center text-slate-400 text-sm">暂无模型</div>
<div v-for="o in ofKind(3)" :key="o.row_id" class="svc llm" :class="{ isdefault: o.is_default, off: !o.enabled }">
<div class="grid place-items-center">
<input type="radio" name="llmdef" class="radio radio-primary radio-xs" :checked="o.is_default" :disabled="!o.enabled || busy" @change="patch(o, { is_default: true })" />
</div>
<div class="min-w-0">
<div class="svc-name">
{{ svcName(o.svc_id) }}
<span v-if="isVision(o.svc_id)" title="多媒体大模型,能识图">🖼</span>
<span v-if="o.is_default" class="chip chip-app ml-1">默认</span>
</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万token</small></template><span v-else class="text-slate-300">—</span></div>
<div>
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.client_visible" :disabled="!llmClientChoice || !o.enabled || busy"
:title="!llmClientChoice ? '先打开「允许客户端指定模型」' : ''" @change="patch(o, { client_visible: !o.client_visible })" />
</div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
</div>
<div class="col-h llm-h"><div></div><div></div><div>参考单价</div><div>前端可见</div><div>启用</div><div></div></div>
</div>
</template>
<!-- 添加服务弹窗 -->
<dialog ref="addDlg" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg">添加{{ kindLabel(addKind) }}服务</h3>
<p class="text-xs text-slate-400 mt-1">从第三方服务配置里选一个已配好的服务加入本段编排。</p>
<div class="mt-4 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">服务</span></label>
<select v-model="addForm.svc_id" class="select select-bordered select-sm">
<option value="">请选择服务…</option>
<option v-for="s in addable(addKind)" :key="s.id" :value="s.id">
{{ s.name }}{{ addKind === 3 && catsHas(s.categories, 9) ? ' 🖼' : '' }}({{ s.provider || s.id }})
</option>
</select>
<span v-if="!addable(addKind).length" class="text-[11px] text-amber-600 mt-1">没有可添加的服务了(都加过了,或服务池里没这类服务)。</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">参考单价({{ costUnit(addKind) }},可空)</span></label>
<input v-model.number="addForm.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm font-mono text-xs" placeholder="0" />
<span class="text-[11px] text-slate-400 mt-1">仅用于排序/展示,填你的实付价。</span>
</div>
<div v-if="addKind === 3" class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.is_default" type="checkbox" class="toggle toggle-primary toggle-sm" /><span class="text-sm">设为默认</span></label>
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.client_visible" type="checkbox" class="toggle toggle-success toggle-sm" /><span class="text-sm">前端可见</span></label>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="addDlg?.close()">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="!addForm.svc_id || saving" @click="doAdd">{{ saving ? '添加中…' : '添加' }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 改单价弹窗 -->
<dialog ref="costDlg" class="modal">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-lg">参考单价</h3>
<p class="text-xs text-slate-400 mt-1">{{ costEdit.name }}({{ costUnit(costEdit.kind) }})</p>
<input v-model.number="costEdit.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm w-full font-mono text-xs mt-3" placeholder="0" />
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="costDlg?.close()">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="saving" @click="doSaveCost">保存</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS } from '~/composables/useLangOptions'
definePageMeta({ title: '会议记录服务' })
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
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) { return REGIONS.find((r) => r.value === v)?.label ?? '区域' + v }
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string }
interface Orch {
row_id: number; app_name: string; region: number; kind: number; svc_id: string
priority: number; is_default: boolean; client_visible: boolean; cost_hint: number; enabled: boolean
}
const apps = ref<string[]>([])
const scopeApp = ref('')
const scopeRegion = ref(0)
const items = ref<Orch[]>([])
const available = ref<SvcView[]>([])
const kindCats = ref<Record<string, number[]>>({ '1': [8], '2': [3], '3': [5, 9] })
const llmClientChoice = ref(false)
const isOverride = ref(false)
const loading = ref(false)
const saving = ref(false)
const busy = ref(false)
const probeLang = ref('zh-CN')
const addDlg = ref<HTMLDialogElement | null>(null)
const costDlg = ref<HTMLDialogElement | null>(null)
const addKind = ref(1)
const addForm = reactive({ svc_id: '', cost_hint: 0, is_default: false, client_visible: false })
const costEdit = reactive({ row_id: 0, name: '', kind: 1, cost_hint: 0 })
function kindLabel(k: number) { return k === 1 ? '识别' : k === 2 ? '翻译' : '总结' }
function costUnit(k: number) { return k === 1 ? '元/小时' : k === 2 ? '元/百万字符' : '元/百万token' }
function langName(code: string) { return LANG_OPTIONS.find((o) => o.code === code)?.name ?? code }
function catsHas(cats: string, v: number) { return (cats || '').split(',').map((x) => Number(x.trim())).includes(v) }
const svcMap = computed(() => {
const m = new Map<string, SvcView>()
for (const s of available.value) m.set(s.id, s)
return m
})
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id }
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '?' }
function svcLangs(id: string) { return svcMap.value.get(id)?.languages ?? '' }
function isVision(id: string) { return catsHas(svcMap.value.get(id)?.categories ?? '', 9) }
// 某段的编排(按优先级)
function ofKind(k: number) {
return items.value.filter((o) => o.kind === k).sort((a, b) => a.priority - b.priority)
}
// 某段可添加的服务 = 可选服务里 categories 命中该段类别、且还没加进本段编排
function addable(k: number): SvcView[] {
const cats = kindCats.value[String(k)] ?? []
const used = new Set(items.value.filter((o) => o.kind === k).map((o) => o.svc_id))
return available.value.filter((s) => !used.has(s.id) && s.categories.split(',').some((c) => cats.includes(Number(c.trim()))))
}
// 识别选路预览
function svcSupportsLang(id: string, lang: string) {
const ls = svcLangs(id)
if (!ls) return true // 未限定 = 交给引擎判断,先当支持
return ls.split(',').map((x) => x.trim()).includes(lang)
}
const asrHit = computed(() => ofKind(1).find((o) => o.enabled && svcSupportsLang(o.svc_id, probeLang.value)) ?? null)
const mtHit = computed(() => ofKind(2).find((o) => o.enabled) ?? null)
function asrSkipped(o: Orch) { return asrHit.value?.row_id !== o.row_id && (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) }
function asrWhyClass(o: Orch) {
if (asrHit.value?.row_id === o.row_id) return 'ok'
if (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) return 'no'
return ''
}
function asrWhy(o: Orch) {
if (asrHit.value?.row_id === o.row_id) {
return svcLangs(o.svc_id) ? `✓ 当前源语言(${langName(probeLang.value)})由它承接` : `✓ 未限定语言,先落到它(实际支持由引擎判断)`
}
if (!o.enabled) return '已停用,跳过'
if (!svcSupportsLang(o.svc_id, probeLang.value)) return `不支持 ${langName(probeLang.value)}(${probeLang.value}),跳过 → 顺延下一个`
return '备用:前面都跳过时接手'
}
// ---- 支持语言汇总 ----
// 规则:同一段内多个服务取**并集**(谁支持都算这段支持,选路时会自动顺延到支持的那个);
// 段与段之间取**交集**(一条记录要依次过识别→翻译→总结,缺一段就走不完)。
// 某段只要有一个服务没填 languages,该段视为「全语种」不参与收缩——与 svcSupportsLang
// 的「未限定=先当支持」保持一致,否则会把实际能用的语言误判成不支持。
const langSum = computed(() => {
const missing: number[] = [] // 没有任何启用服务的段
const groups: { kind: number; langs: string[] }[] = [] // 有语言限定的段
let unlimitedKinds = 0 // 有服务但全未限定的段数
for (const k of [1, 2, 3]) {
const list = ofKind(k).filter((o) => o.enabled)
if (!list.length) { missing.push(k); continue }
const union = new Set<string>()
let unlimited = false
for (const o of list) {
const ls = svcLangs(o.svc_id).split(',').map((x) => x.trim()).filter(Boolean)
if (!ls.length) { unlimited = true; break } // 该服务未限定 → 整段放开
ls.forEach((l) => union.add(l))
}
if (unlimited) unlimitedKinds++
else groups.push({ kind: k, langs: [...union] })
}
if (!groups.length) return { missing, unlimited: true, langs: [] as string[], groups, unlimitedKinds }
const langs = groups
.reduce<string[]>((acc, g) => acc.filter((l) => g.langs.includes(l)), [...groups[0]!.langs])
.sort((a, b) => langOrder(a) - langOrder(b)) // 按 LANG_OPTIONS 顺序,跟其它页面一致
return { missing, unlimited: false, langs, groups, unlimitedKinds }
})
function langOrder(code: string) {
const i = LANG_OPTIONS.findIndex((o) => o.code === code)
return i < 0 ? Number.MAX_SAFE_INTEGER : i
}
// 顶部那行算式,说明这个结果是怎么来的:识别 11 ∩ 翻译 全语种 …
const langSumFormula = computed(() => {
const parts: string[] = []
for (const k of [1, 2, 3]) {
if (langSum.value.missing.includes(k)) { parts.push(`${kindLabel(k)} 未配置`); continue }
const g = langSum.value.groups.find((x) => x.kind === k)
parts.push(`${kindLabel(k)} ${g ? g.langs.length + ' 种' : '全语种'}`)
}
return parts.join(' ∩ ')
})
// 总结
const llmDefault = computed(() => ofKind(3).find((o) => o.is_default) ?? null)
const pickable = computed(() => ofKind(3).filter((o) => o.enabled && o.client_visible))
// ---- 加载 ----
async function loadApps() {
try {
const list = await consoleApi<{ name: string }[]>('products/list', {})
apps.value = (list ?? []).map((p) => p.name).filter(Boolean)
} catch { apps.value = [] }
}
async function loadOrch() {
loading.value = true
try {
const d = await webApi<{ items: Orch[]; setting: { llm_client_choice: boolean }; available: SvcView[]; kind_cats: Record<string, number[]> }>(
'getechoorch', { app_name: scopeApp.value, region: scopeRegion.value },
)
items.value = d?.items ?? []
available.value = d?.available ?? []
kindCats.value = d?.kind_cats ?? kindCats.value
llmClientChoice.value = !!d?.setting?.llm_client_choice
// 是否为覆盖:非全局作用域且本作用域有编排/开关
isOverride.value = (!!scopeApp.value || scopeRegion.value !== 0) && (items.value.length > 0 || llmClientChoice.value)
} catch (e: any) {
error(e?.message ?? '加载失败')
} finally {
loading.value = false
}
}
// ---- 编排增改删 ----
async function saveOrch(orch: Partial<Orch>) {
await webApi('saveechoorch', { app_name: scopeApp.value, region: scopeRegion.value, orch })
}
function openAdd(k: number) {
addKind.value = k
addForm.svc_id = ''
addForm.cost_hint = 0
addForm.is_default = false
addForm.client_visible = false
addDlg.value?.showModal()
}
async function doAdd() {
if (!addForm.svc_id) return
saving.value = true
try {
const list = ofKind(addKind.value)
const priority = list.length ? (list[list.length - 1]!.priority + 10) : 10
await saveOrch({
kind: addKind.value, svc_id: addForm.svc_id, priority,
cost_hint: Number(addForm.cost_hint) || 0,
is_default: addKind.value === 3 ? addForm.is_default : false,
client_visible: addKind.value === 3 ? addForm.client_visible : false,
enabled: true,
})
success('已添加')
addDlg.value?.close()
await loadOrch()
} catch (e: any) {
error(e?.message ?? '添加失败')
} finally {
saving.value = false
}
}
async function patch(o: Orch, changes: Partial<Orch>) {
busy.value = true
try {
await saveOrch({ ...o, ...changes })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '操作失败')
await loadOrch()
} finally {
busy.value = false
}
}
async function del(o: Orch) {
if (!confirm(`从本段移除「${svcName(o.svc_id)}」?(不影响第三方服务配置里的服务本身)`)) return
try {
await webApi('delechoorch', { row_id: o.row_id })
success('已移除')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '移除失败')
}
}
// 排序
async function persistOrder(ordered: Orch[]) {
busy.value = true
try {
await webApi('reorderechoorch', { items: ordered.map((o, i) => ({ row_id: o.row_id, priority: (i + 1) * 10 })) })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '排序失败')
await loadOrch()
} finally {
busy.value = false
}
}
function move(k: number, idx: number, dir: number) {
const list = [...ofKind(k)]
const j = idx + dir
if (j < 0 || j >= list.length) return
const tmp = list[idx]!
list[idx] = list[j]!
list[j] = tmp
persistOrder(list)
}
function sortByCost(k: number) {
const list = [...ofKind(k)].sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
return (a.cost_hint || Number.MAX_VALUE) - (b.cost_hint || Number.MAX_VALUE)
})
persistOrder(list)
}
// 改单价
function openEditCost(o: Orch) {
costEdit.row_id = o.row_id
costEdit.name = svcName(o.svc_id)
costEdit.kind = o.kind
costEdit.cost_hint = o.cost_hint
costDlg.value?.showModal()
}
async function doSaveCost() {
const o = items.value.find((x) => x.row_id === costEdit.row_id)
if (!o) return
saving.value = true
try {
await saveOrch({ ...o, cost_hint: Number(costEdit.cost_hint) || 0 })
success('已保存')
costDlg.value?.close()
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function toggleClientChoice() {
busy.value = true
try {
await webApi('saveechoorchsetting', { app_name: scopeApp.value, region: scopeRegion.value, llm_client_choice: !llmClientChoice.value })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
await loadOrch()
} finally {
busy.value = false
}
}
onMounted(async () => {
await loadApps()
await loadOrch()
})
</script>
<style scoped>
.scopebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px; }
.langsum { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); padding: 13px 16px; margin-bottom: 16px; }
.langsum.warn { border-color: #fed7aa; background: #fffbf5; }
.langsum.bad { border-color: #fecaca; background: #fff7f7; }
.langsum-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.langsum-title { font-size: 13px; font-weight: 700; color: #1e293b; }
.langsum-count { font-size: 12px; font-weight: 700; color: #15803d; background: #dcfce7; padding: 2px 8px; border-radius: 6px; }
.langsum.bad .langsum-count { color: #dc2626; background: #fee2e2; }
.langsum-count.off { color: #b45309; background: #fef3c7; } /* 有段没配全:语言算得出来,但链路还走不通 */
.langsum-formula { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; }
.langsum-msg { font-size: 11.5px; color: #64748b; margin-top: 7px; line-height: 1.6; }
.langsum-msg.bad { color: #b91c1c; font-weight: 600; }
.langsum-langs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 9px; }
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; }
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; }
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 76ch; line-height: 1.55; }
.probe-bar { display: flex; align-items: center; gap: 10px; padding: 11px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; }
.probe-out { font-size: 11.5px; font-weight: 600; color: #15803d; background: #dcfce7; padding: 3px 9px; border-radius: 6px; }
.probe-out.bad { color: #dc2626; background: #fee2e2; }
.mode-bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; }
.mode-txt { font-size: 12px; color: #64748b; flex: 1; min-width: 260px; line-height: 1.6; margin: 0; }
.mode-txt b { color: #1e293b; font-weight: 600; }
.svc { display: grid; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid #eef2f7; }
.svc:last-child { border-bottom: 0; }
.svc:hover { background: #fcfcfd; }
.svc.off { opacity: .55; }
.svc.hit { background: linear-gradient(90deg, #dcfce7, transparent 42%); }
.svc.isdefault { background: linear-gradient(90deg, #eef2ff, transparent 42%); }
.svc.asr { grid-template-columns: 34px 1fr 170px 110px 60px auto; }
.svc.mt { grid-template-columns: 34px 1fr 130px 60px auto; }
.svc.llm { grid-template-columns: 40px 1fr 130px 90px 60px auto; }
.col-h { padding: 8px 16px; background: #fcfcfd; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; color: #94a3b8; font-weight: 700; display: grid; gap: 12px; }
.col-h.llm-h { grid-template-columns: 40px 1fr 130px 90px 60px auto; border-top: 1px solid #eef2f7; }
.svc-rank { width: 26px; height: 26px; border-radius: 8px; display: grid; place-items: center; background: #f8fafc; border: 1px solid #e2e8f0; color: #64748b; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; font-weight: 700; }
.svc-rank.hit { background: #16a34a; border-color: #16a34a; color: #fff; }
.svc-name { font-weight: 600; font-size: 13.5px; color: #334155; }
.svc-name.skip { text-decoration: line-through; text-decoration-color: #cbd5e1; }
.svc-prov { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; margin-top: 2px; }
.svc-cost { font-family: 'SF Mono', Menlo, monospace; font-size: 12.5px; font-variant-numeric: tabular-nums; color: #334155; }
.svc-cost small { color: #94a3b8; font-size: 10px; }
.svc-acts { display: flex; gap: 3px; align-items: center; justify-content: flex-end; }
.svc-why { grid-column: 2 / -1; font-size: 11px; color: #64748b; margin-top: -2px; }
.svc-why.ok { color: #15803d; font-weight: 600; }
.svc-why.no { color: #b45309; }
.arrow { width: 24px; height: 24px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; color: #64748b; cursor: pointer; display: grid; place-items: center; font-size: 11px; padding: 0; }
.arrow:hover:not(:disabled) { border-color: #4f46e5; color: #4f46e5; }
.arrow:disabled { opacity: .25; cursor: not-allowed; }
.lang { font-family: 'SF Mono', Menlo, monospace; font-size: 9.5px; padding: 1px 5px; border-radius: 4px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; }
.lang.on { background: #dcfce7; border-color: transparent; color: #15803d; font-weight: 700; }
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
.chip-app { background: #eef2ff; color: #4338ca; }
@media (max-width: 1100px) {
.svc.asr, .svc.mt, .svc.llm { grid-template-columns: 34px 1fr; }
.col-h { display: none; }
.svc-acts { grid-column: 1 / -1; justify-content: flex-start; }
.svc-why { grid-column: 1 / -1; }
}
</style>