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.
 
 
 
 
 
 

354 lines
14 KiB

<template>
<div class="p-4 space-y-4">
<!-- 顶部:标题 + 应用选择 -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">会议记录服务配置</h1>
<p class="text-xs text-base-content/60 mt-1">
识别 / 翻译 / 总结 三类服务统一在此管理:按优先级自动选择,识别叠加「支持语言」过滤,总结为客户端可选的具体模型条目。改配置后 home 需重启生效。
</p>
</div>
<label class="flex items-center gap-2 text-sm">
<span class="text-base-content/60">部署应用</span>
<select v-model.number="appId" class="select select-bordered select-sm" @change="loadList">
<option v-for="a in apps" :key="a.id" :value="a.id">{{ a.app_name || a.name }}({{ a.region }})</option>
</select>
</label>
</div>
<!-- 类别 tab -->
<div class="tabs tabs-boxed w-fit">
<a
v-for="c in categories"
:key="c.value"
class="tab"
:class="{ 'tab-active': curCat === c.value }"
@click="curCat = c.value"
>{{ c.label }}<span class="ml-1 opacity-60">{{ (grouped[c.value] || []).length }}</span></a>
</div>
<div v-if="dbError" class="alert alert-error text-sm">{{ dbError }}</div>
<!-- 列表 -->
<div class="bg-base-100 rounded-lg border border-base-200 overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th class="w-16">优先级</th>
<th>名称 / 条目id</th>
<th>服务商</th>
<th v-if="curCatHasLangs">支持语言</th>
<th class="w-20">启用</th>
<th class="w-28">凭据</th>
<th class="w-40 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(it, idx) in curList" :key="it.id">
<td>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-xs" :disabled="idx === 0" @click="move(idx, -1)">↑</button>
<button class="btn btn-ghost btn-xs" :disabled="idx === curList.length - 1" @click="move(idx, 1)">↓</button>
</div>
</td>
<td>
<div class="font-medium">{{ it.name || it.id }}</div>
<div class="text-xs text-base-content/50 font-mono">{{ it.id }}</div>
</td>
<td><span class="badge badge-ghost badge-sm">{{ it.provider }}</span></td>
<td v-if="curCatHasLangs">
<div v-if="it.support_langs" class="flex flex-wrap gap-1">
<span v-for="l in it.support_langs.split(',').slice(0, 6)" :key="l" class="badge badge-outline badge-xs font-mono">{{ l }}</span>
<span v-if="it.support_langs.split(',').length > 6" class="text-xs opacity-50">…</span>
</div>
<span v-else class="text-xs opacity-40">全部(默认)</span>
</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="it.enabled" @change="toggleEnabled(it)" />
</td>
<td>
<span v-if="secretCount(it) > 0" class="text-xs">🔒 {{ secretCount(it) }}</span>
<span v-else class="text-xs opacity-40">—</span>
</td>
<td class="text-right">
<button class="btn btn-ghost btn-xs" @click="openEdit(it)">编辑</button>
<button class="btn btn-ghost btn-xs text-error" @click="del(it)">删除</button>
</td>
</tr>
<tr v-if="!curList.length">
<td :colspan="curCatHasLangs ? 7 : 6" class="text-center text-base-content/40 py-6">暂无服务,点击下方「添加」</td>
</tr>
</tbody>
</table>
</div>
<button class="btn btn-primary btn-sm" @click="openAdd">+ 添加{{ curCatLabel }}服务</button>
<!-- 编辑/新增 弹窗 -->
<dialog ref="dlg" class="modal">
<div class="modal-box max-w-xl">
<h3 class="font-semibold mb-3">{{ editing.__isNew ? '添加' : '编辑' }}{{ curCatLabel }}服务</h3>
<div class="space-y-3">
<!-- 服务商(新增时可选,编辑时锁定) -->
<label class="form-control">
<span class="label-text text-xs">服务商</span>
<select
v-model="editing.provider"
class="select select-bordered select-sm"
:disabled="!editing.__isNew"
@change="onProviderChange"
>
<option v-for="p in providerOptions" :key="p.provider" :value="p.provider">{{ p.name }}({{ p.provider }})</option>
</select>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="form-control">
<span class="label-text text-xs">条目 id(唯一)</span>
<input v-model="editing.id" class="input input-bordered input-sm font-mono" :disabled="!editing.__isNew" />
</label>
<label class="form-control">
<span class="label-text text-xs">展示名{{ curCat === 3 ? '(客户端可见)' : '' }}</span>
<input v-model="editing.name" class="input input-bordered input-sm" />
</label>
</div>
<!-- 目录字段 -->
<div v-for="f in curFields" :key="f.key" class="form-control">
<span class="label-text text-xs">{{ f.label }}<span v-if="f.required" class="text-error">*</span></span>
<textarea
v-if="f.type === 'textarea'"
v-model="editing.values[f.key]"
class="textarea textarea-bordered textarea-sm font-mono"
rows="3"
:placeholder="f.placeholder"
></textarea>
<input
v-else
v-model="editing.values[f.key]"
:type="f.secret ? 'password' : 'text'"
class="input input-bordered input-sm"
:placeholder="f.placeholder"
autocomplete="off"
/>
<span v-if="f.desc" class="text-xs text-base-content/50 mt-0.5">{{ f.desc }}</span>
</div>
<!-- 识别:支持语言 -->
<div v-if="curCatHasLangs" class="form-control">
<span class="label-text text-xs">支持语言(留空=不限,按引擎默认)</span>
<LangSelect v-model="editing.support_langs" :multiple="true" />
</div>
<div class="grid grid-cols-2 gap-3 items-end">
<label class="form-control">
<span class="label-text text-xs">优先级(小=优先)</span>
<input v-model.number="editing.priority" type="number" class="input input-bordered input-sm" />
</label>
<label class="flex items-center gap-2 pb-2">
<input v-model="editing.enabled" 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="closeEdit">取消</button>
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存' }}</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'
definePageMeta({ title: '会议记录服务配置' })
const { consoleApi } = useApi()
const { success, error } = useToast()
const EncMask = '[encrypted]'
interface Field { key: string; label: string; type: string; secret: boolean; required: boolean; placeholder?: string; desc?: string }
interface CatalogItem { category: number; provider: string; name: string; has_langs: boolean; multi: boolean; fields: Field[] }
interface Category { value: number; label: string }
interface SvcConfig {
id: string; category: number; provider: string; name: string
priority: number; enabled: boolean; support_langs: string
values: Record<string, string>
__isNew?: boolean
}
interface AppItem { id: number; app_name: string; name: string; region: string }
const apps = ref<AppItem[]>([])
const appId = ref(0)
const catalog = ref<CatalogItem[]>([])
const categories = ref<Category[]>([])
const curCat = ref(1)
const items = ref<SvcConfig[]>([])
const dbError = ref('')
const saving = ref(false)
const dlg = ref<HTMLDialogElement | null>(null)
const editing = reactive<SvcConfig>(blank(1, ''))
function blank(cat: number, provider: string): SvcConfig {
return { id: '', category: cat, provider, name: '', priority: 100, enabled: true, support_langs: '', values: {}, __isNew: true }
}
// 当前类别的目录(服务商列表)
const providerOptions = computed(() => catalog.value.filter((c) => c.category === curCat.value))
const curCatLabel = computed(() => categories.value.find((c) => c.value === curCat.value)?.label || '')
const curCatHasLangs = computed(() => providerOptions.value.some((c) => c.has_langs))
const curDef = computed(() => catalog.value.find((c) => c.category === editing.category && c.provider === editing.provider) || null)
const curFields = computed(() => curDef.value?.fields || [])
// 按类别归集 + 优先级排序
const grouped = computed<Record<number, SvcConfig[]>>(() => {
const m: Record<number, SvcConfig[]> = {}
for (const it of items.value) (m[it.category] ||= []).push(it)
for (const k in m) m[k].sort((a, b) => a.priority - b.priority)
return m
})
const curList = computed(() => grouped.value[curCat.value] || [])
function secretCount(it: SvcConfig): number {
const def = catalog.value.find((c) => c.category === it.category && c.provider === it.provider)
if (!def) return 0
return def.fields.filter((f) => f.secret && (it.values?.[f.key] ?? '') !== '').length
}
async function loadApps() {
try {
const list = await consoleApi<AppItem[]>('apps/list', {})
apps.value = list ?? []
if (apps.value.length && !appId.value) appId.value = apps.value[0].id
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
}
}
async function loadCatalog() {
try {
const d = await consoleApi<{ catalog: CatalogItem[]; categories: Category[] }>('echomeetsvc/catalog', {})
catalog.value = d?.catalog ?? []
categories.value = d?.categories ?? []
if (categories.value.length) curCat.value = categories.value[0].value
} catch (e: any) {
error(e?.message ?? '获取配置目录失败')
}
}
async function loadList() {
if (!appId.value) return
dbError.value = ''
try {
const d = await consoleApi<{ items: SvcConfig[] }>('echomeetsvc/list', { app_id: appId.value })
items.value = (d?.items ?? []).map((r) => ({ ...r, values: r.values || {} }))
} catch (e: any) {
dbError.value = e?.message ?? '获取配置失败'
}
}
function openAdd() {
const first = providerOptions.value[0]
Object.assign(editing, blank(curCat.value, first?.provider || ''))
onProviderChange()
dlg.value?.showModal()
}
function openEdit(it: SvcConfig) {
Object.assign(editing, JSON.parse(JSON.stringify(it)), { __isNew: false })
if (!editing.values) editing.values = {}
dlg.value?.showModal()
}
function closeEdit() { dlg.value?.close() }
// 新增时切换服务商:重置字段容器 + 生成默认 id/名称
function onProviderChange() {
const def = curDef.value
editing.values = {}
if (def) {
for (const f of def.fields) editing.values[f.key] = ''
const prefix = curCat.value === 1 ? 'asr' : curCat.value === 2 ? 'mt' : 'llm'
let base = `${prefix}_${editing.provider}`
// 总结允许一商多模型,冲突则追加序号
if (items.value.some((x) => x.id === base)) {
let n = 2
while (items.value.some((x) => x.id === `${base}_${n}`)) n++
base = `${base}_${n}`
}
editing.id = base
if (!editing.name) editing.name = def.name
if (curCat.value === 1) editing.support_langs = ''
}
}
async function save() {
if (!editing.id.trim()) { error('条目 id 必填'); return }
if (editing.__isNew && items.value.some((x) => x.id === editing.id)) { error('条目 id 已存在'); return }
saving.value = true
try {
const { __isNew, ...cfg } = editing
await consoleApi('echomeetsvc/save', { app_id: appId.value, config: cfg })
success('已保存')
closeEdit()
await loadList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function del(it: SvcConfig) {
if (!confirm(`确定删除「${it.name || it.id}」?`)) return
try {
await consoleApi('echomeetsvc/del', { app_id: appId.value, id: it.id })
success('已删除')
await loadList()
} catch (e: any) {
error(e?.message ?? '删除失败')
}
}
async function toggleEnabled(it: SvcConfig) {
try {
// 保存时密钥字段是脱敏值 [encrypted],后端会保留原密文,安全。
await consoleApi('echomeetsvc/save', { app_id: appId.value, config: { ...it, enabled: !it.enabled } })
await loadList()
} catch (e: any) {
error(e?.message ?? '操作失败')
await loadList()
}
}
// 同类内上下移:与相邻项交换优先级并提交
async function move(idx: number, dir: number) {
const list = curList.value
const a = list[idx]
const b = list[idx + dir]
if (!a || !b) return
const pa = a.priority, pb = b.priority
try {
await consoleApi('echomeetsvc/reorder', {
app_id: appId.value,
items: [{ id: a.id, priority: pb }, { id: b.id, priority: pa }],
})
await loadList()
} catch (e: any) {
error(e?.message ?? '排序失败')
}
}
onMounted(async () => {
await Promise.all([loadApps(), loadCatalog()])
await loadList()
})
</script>