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.
547 lines
28 KiB
547 lines
28 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 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 '备用:前面都跳过时接手'
|
|
}
|
|
|
|
// 总结
|
|
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; }
|
|
|
|
.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>
|
|
|