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