Compare commits
17 Commits
268ce26bc9
...
7ce56f693d
| Author | SHA1 | Date |
|---|---|---|
|
|
7ce56f693d | 1 month ago |
|
|
8baa082d9e | 1 month ago |
|
|
8b50e52572 | 1 month ago |
|
|
7aef22905b | 1 month ago |
|
|
b61c4c91d7 | 1 month ago |
|
|
90fe2d6c2a | 1 month ago |
|
|
76aa2032bf | 1 month ago |
|
|
f9b9c8c72d | 1 month ago |
|
|
811a008545 | 1 month ago |
|
|
5193b0e5e8 | 1 month ago |
|
|
4d97c99528 | 1 month ago |
|
|
41e3a023a3 | 1 month ago |
|
|
3709aa6e3b | 1 month ago |
|
|
c14580d7db | 1 month ago |
|
|
263c72ad31 | 1 month ago |
|
|
461289b780 | 1 month ago |
|
|
e00b617025 | 1 month ago |
@ -1,264 +0,0 @@ |
|||||
<template> |
|
||||
<div> |
|
||||
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#f8fafc;border:1px solid #e2e8f0;color:#475569"> |
|
||||
这里只放<b>应用自己的业务参数</b>(导航方式、客服 QQ、智能体标识…),直接读写所选部署的业务库 config 表。 |
|
||||
第三方服务的<b>凭据请到「第三方服务」标签页配</b>——2026-09-12 起凭据统一收在那里(带加密、区域分叉与巡检), |
|
||||
下发给客户端时按字段上的「下发键名」自动回填一份 env,老客户端无需升级。 |
|
||||
</div> |
|
||||
<div class="flex items-center justify-between mb-4 flex-wrap gap-3"> |
|
||||
<div class="flex items-center gap-3 flex-wrap"> |
|
||||
<span class="text-sm text-slate-500">应用</span> |
|
||||
<select v-model="curAppName" class="select select-bordered select-sm min-w-40" @change="onAppNameChange"> |
|
||||
<option value="" disabled>请选择应用</option> |
|
||||
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option> |
|
||||
</select> |
|
||||
<span class="text-sm text-slate-500">部署环境</span> |
|
||||
<select |
|
||||
v-model.number="curApp" |
|
||||
class="select select-bordered select-sm min-w-48" |
|
||||
:disabled="!curAppName" |
|
||||
@change="loadData" |
|
||||
> |
|
||||
<option :value="0" disabled>请选择环境</option> |
|
||||
<option v-for="d in appDeployments" :key="d.id" :value="d.id">{{ deployLabel(d) }}</option> |
|
||||
</select> |
|
||||
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" /> |
|
||||
</div> |
|
||||
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="openForm()">+ 新增参数</button> |
|
||||
</div> |
|
||||
|
|
||||
<div v-if="!curApp" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16"> |
|
||||
<div class="text-4xl mb-2">🗂️</div> |
|
||||
<p class="text-sm">{{ curAppName ? '该应用还没有部署环境,请先在「应用中心」为它部署一个环境' : '请先在上方选择应用和部署环境' }}</p> |
|
||||
</div> |
|
||||
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|
||||
<div class="overflow-x-auto"> |
|
||||
<table class="table"> |
|
||||
<thead> |
|
||||
<tr><th>键</th><th>值</th><th>描述</th><th class="text-right">操作</th></tr> |
|
||||
</thead> |
|
||||
<tbody> |
|
||||
<tr v-if="loading"><td colspan="4" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr> |
|
||||
<tr v-else-if="!filteredRows.length"><td colspan="4" class="text-center text-slate-400 py-8">暂无参数</td></tr> |
|
||||
<template v-for="grp in groupedRows" :key="grp.key"> |
|
||||
<tr class="bg-slate-100/80"> |
|
||||
<td colspan="4" class="py-2"> |
|
||||
<span class="font-semibold text-slate-600 text-sm">▸ {{ grp.label }}</span> |
|
||||
<span class="text-xs text-slate-400 ml-2">{{ grp.items.length }} 项</span> |
|
||||
</td> |
|
||||
</tr> |
|
||||
<tr v-for="c in grp.items" :key="c.id" class="hover"> |
|
||||
<td class="font-mono text-sm font-medium">{{ c.key || '' }}</td> |
|
||||
<td class="text-sm text-slate-500 max-w-xs truncate" :title="c.value || ''">{{ c.value || '' }}</td> |
|
||||
<td class="text-sm text-slate-400">{{ c.description || '' }}</td> |
|
||||
<td class="text-right"> |
|
||||
<div class="inline-flex gap-1"> |
|
||||
<button class="btn btn-ghost btn-xs" @click="openForm(c)">编辑</button> |
|
||||
<button class="btn btn-ghost btn-xs text-red-500" @click="delCfg(c)">删除</button> |
|
||||
</div> |
|
||||
</td> |
|
||||
</tr> |
|
||||
</template> |
|
||||
</tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<!-- 编辑弹窗:留在同一个标签页里改,不跳走第二屏 --> |
|
||||
<div v-if="formOpen" class="modal modal-open"> |
|
||||
<div class="modal-box max-w-xl"> |
|
||||
<h3 class="font-bold text-base mb-1">{{ form.id ? '编辑参数' : '新增参数' }}</h3> |
|
||||
<p class="text-xs text-slate-400 mb-4">配置目标:{{ curAppLabel }}</p> |
|
||||
<div class="space-y-3"> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">分组 (group)</label> |
|
||||
<input v-model="form.group" class="input input-bordered input-sm w-full" placeholder="如 APP / AI / other" /> |
|
||||
</div> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">键 (key) *</label> |
|
||||
<input v-model="form.key" class="input input-bordered input-sm w-full font-mono" placeholder="如 APP_NAVIGATION_MODE" /> |
|
||||
</div> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">值 (value)</label> |
|
||||
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea> |
|
||||
</div> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">描述</label> |
|
||||
<input v-model="form.description" class="input input-bordered input-sm w-full" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="modal-action"> |
|
||||
<button class="btn btn-ghost btn-sm" @click="formOpen = false">取消</button> |
|
||||
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="saveCfg">保存</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script setup lang="ts"> |
|
||||
import { ref, reactive, computed, onMounted } from 'vue' |
|
||||
import { useApi } from '~/composables/useApi' |
|
||||
import { useToast } from '~/composables/useToast' |
|
||||
import { regionLabel } from '~/utils/regions' |
|
||||
|
|
||||
// 「应用环境配置」原来是独立页面(pages/appconfig.vue),2026-09-12 并进「服务与环境配置」的第二个标签页。 |
|
||||
// 合并的理由:凭据已统一收进 svc_config,这张表剩下的只是应用自己的业务参数, |
|
||||
// 单独占一个一级菜单项与「第三方服务配置」并列,只会让人分不清凭据该填哪儿。 |
|
||||
|
|
||||
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)。 |
|
||||
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——参数按「应用 × 环境」独立。 |
|
||||
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number } |
|
||||
interface EnvOption { id: number; name: string; code: string; enabled: boolean } |
|
||||
interface AppConfig { id: number; group: string; key: string; value: string; description: string } |
|
||||
|
|
||||
const { consoleApi } = useApi() |
|
||||
const { success, error } = useToast() |
|
||||
|
|
||||
const apps = ref<Deployment[]>([]) |
|
||||
const envs = ref<EnvOption[]>([]) |
|
||||
const curAppName = ref('') |
|
||||
const curApp = ref(0) |
|
||||
const items = ref<AppConfig[]>([]) |
|
||||
const loading = ref(false) |
|
||||
const submitting = ref(false) |
|
||||
const keyword = ref('') |
|
||||
const formOpen = ref(false) |
|
||||
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' }) |
|
||||
|
|
||||
const appNames = computed(() => |
|
||||
[...new Set(apps.value.map((a) => a.app_name || a.name).filter(Boolean))].sort() |
|
||||
) |
|
||||
const appDeployments = computed(() => |
|
||||
apps.value.filter((a) => (a.app_name || a.name) === curAppName.value) |
|
||||
) |
|
||||
|
|
||||
// 部署对应的环境名;区域不同也一并标出,避免同环境多区域时分不清 |
|
||||
function deployLabel(d: Deployment) { |
|
||||
const e = envs.value.find((x) => x.id === d.env_id) |
|
||||
const envName = e ? (e.name || e.code) : d.env_id ? `环境#${d.env_id}` : '未关联环境' |
|
||||
const suffix = d.status === 'pending' ? '(待部署)' : '' |
|
||||
return d.region ? `${envName} · ${regionLabel(d.region)}${suffix}` : `${envName}${suffix}` |
|
||||
} |
|
||||
|
|
||||
const curAppLabel = computed(() => { |
|
||||
const a = apps.value.find((x) => x.id === curApp.value) |
|
||||
return a ? `${a.app_name || a.name} · ${deployLabel(a)}` : '' |
|
||||
}) |
|
||||
|
|
||||
const filteredRows = computed(() => { |
|
||||
const kw = keyword.value.trim().toLowerCase() |
|
||||
if (!kw) return items.value |
|
||||
return items.value.filter((c) => ((c.group || '') + (c.key || '')).toLowerCase().includes(kw)) |
|
||||
}) |
|
||||
|
|
||||
// 按 group 分组:相同组归在一起(未分组排最前) |
|
||||
const groupedRows = computed(() => { |
|
||||
const groups: Record<string, AppConfig[]> = {} |
|
||||
filteredRows.value.forEach((c) => { |
|
||||
const g = c.group || '' |
|
||||
;(groups[g] = groups[g] || []).push(c) |
|
||||
}) |
|
||||
const names = Object.keys(groups).sort((a, b) => { |
|
||||
if (a === '') return -1 |
|
||||
if (b === '') return 1 |
|
||||
return a < b ? -1 : 1 |
|
||||
}) |
|
||||
return names.map((g) => ({ key: g, label: g || '未分组', items: groups[g] })) |
|
||||
}) |
|
||||
|
|
||||
async function loadApps() { |
|
||||
try { |
|
||||
const [list, es] = await Promise.all([ |
|
||||
consoleApi<Deployment[]>('apps/list', {}), |
|
||||
consoleApi<EnvOption[]>('envs/list', {}), |
|
||||
]) |
|
||||
apps.value = list ?? [] |
|
||||
envs.value = es ?? [] |
|
||||
if (!curAppName.value && appNames.value.length) { |
|
||||
curAppName.value = appNames.value[0]! |
|
||||
pickDefaultDeployment() |
|
||||
await loadData() |
|
||||
} |
|
||||
} catch (e: any) { |
|
||||
error(e?.message ?? '获取应用列表失败') |
|
||||
apps.value = [] |
|
||||
envs.value = [] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 在当前应用下挑一个默认部署环境;优先已接入的(待部署的业务库通常还连不上) |
|
||||
function pickDefaultDeployment() { |
|
||||
const list = appDeployments.value |
|
||||
const first = list.find((d) => d.status !== 'pending') ?? list[0] |
|
||||
curApp.value = first?.id ?? 0 |
|
||||
} |
|
||||
|
|
||||
function onAppNameChange() { |
|
||||
pickDefaultDeployment() |
|
||||
loadData() |
|
||||
} |
|
||||
|
|
||||
async function loadData() { |
|
||||
if (!curApp.value) { |
|
||||
items.value = [] |
|
||||
return |
|
||||
} |
|
||||
loading.value = true |
|
||||
try { |
|
||||
const data = await consoleApi<{ items: AppConfig[] }>('appcfg/list', { app_id: curApp.value }) |
|
||||
items.value = data?.items ?? [] |
|
||||
} catch (e: any) { |
|
||||
error(e?.message ?? '获取参数失败') |
|
||||
items.value = [] |
|
||||
} finally { |
|
||||
loading.value = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function openForm(cfg?: AppConfig) { |
|
||||
form.id = cfg?.id ?? 0 |
|
||||
form.group = cfg?.group ?? '' |
|
||||
form.key = cfg?.key ?? '' |
|
||||
form.value = cfg?.value ?? '' |
|
||||
form.description = cfg?.description ?? '' |
|
||||
formOpen.value = true |
|
||||
} |
|
||||
|
|
||||
async function saveCfg() { |
|
||||
if (!curApp.value) { error('请先选择应用和部署环境'); return } |
|
||||
if (!form.key.trim()) { error('键(key) 必填'); return } |
|
||||
submitting.value = true |
|
||||
try { |
|
||||
const data: Record<string, unknown> = { |
|
||||
app_id: curApp.value, |
|
||||
group: form.group.trim(), |
|
||||
key: form.key.trim(), |
|
||||
value: form.value, |
|
||||
description: form.description.trim(), |
|
||||
} |
|
||||
if (form.id) { |
|
||||
data.id = Number(form.id) |
|
||||
await consoleApi('appcfg/update', data) |
|
||||
} else { |
|
||||
await consoleApi('appcfg/add', data) |
|
||||
} |
|
||||
success('保存成功') |
|
||||
formOpen.value = false |
|
||||
loadData() |
|
||||
} catch (e: any) { |
|
||||
error(e?.message ?? '保存失败') |
|
||||
} finally { |
|
||||
submitting.value = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function delCfg(c: AppConfig) { |
|
||||
if (!window.confirm(`确定删除参数 ${c.key} 吗?`)) return |
|
||||
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] }) |
|
||||
.then(() => { success('删除成功'); loadData() }) |
|
||||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|
||||
} |
|
||||
|
|
||||
onMounted(loadApps) |
|
||||
</script> |
|
||||
@ -0,0 +1,739 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ --> |
||||
|
<div v-if="view === 'list'"> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-5"> |
||||
|
<p class="text-xs text-slate-400 max-w-3xl"> |
||||
|
会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示 |
||||
|
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。 |
||||
|
</p> |
||||
|
<button class="btn btn-primary btn-sm text-white shrink-0" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 筛选条 --> |
||||
|
<div class="panel p-3 mb-5"> |
||||
|
<div class="flex flex-wrap items-center gap-2"> |
||||
|
<select v-model="catFilter" class="select select-bordered select-sm w-40"> |
||||
|
<option value="">全部分类</option> |
||||
|
<option v-for="c in catOptions" :key="c" :value="c">{{ c }}</option> |
||||
|
</select> |
||||
|
<select v-model="typeFilter" class="select select-bordered select-sm w-40"> |
||||
|
<option value="">全部类型</option> |
||||
|
<option v-for="t in typeOptions" :key="t" :value="t">{{ t }}</option> |
||||
|
</select> |
||||
|
<input v-model="kwFilter" class="input input-bordered input-sm w-56" placeholder="搜索标题 / tid / 描述" /> |
||||
|
<span class="ml-auto text-xs text-slate-400"> |
||||
|
{{ cards.length }} 个模板 · {{ rows.length }} 个语言版本 · 命中 {{ filteredCards.length }} |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="loading" class="py-16 text-center"> |
||||
|
<span class="loading loading-spinner loading-md"></span> |
||||
|
</div> |
||||
|
<div v-else-if="!filteredCards.length" class="panel py-16 text-center text-slate-400 text-sm"> |
||||
|
{{ rows.length ? '当前筛选下没有匹配的模板' : '暂无模板,点击右上角「新增模板」创建' }} |
||||
|
</div> |
||||
|
|
||||
|
<div v-else class="tpl-grid"> |
||||
|
<div v-for="c in filteredCards" :key="c.tid" class="tpl-card" @click="openDetail(c)"> |
||||
|
<div class="tc-top"> |
||||
|
<div class="tc-ico"> |
||||
|
<img v-if="isImg(c.primary.icon)" :src="c.primary.icon" alt="" /> |
||||
|
<span v-else>{{ initials(c) }}</span> |
||||
|
</div> |
||||
|
<div class="min-w-0 flex-1"> |
||||
|
<div class="tc-title" :title="c.primary.title">{{ c.primary.title || '(未命名)' }}</div> |
||||
|
<div class="tc-tid" :title="c.tid">{{ c.tid || '无 tid' }}</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="tc-desc">{{ c.primary.description || '(无描述)' }}</div> |
||||
|
|
||||
|
<div class="tc-tags"> |
||||
|
<span v-if="c.primary.ttype" class="chip chip-type">{{ c.primary.ttype }}</span> |
||||
|
<span v-for="(tag, i) in tagList(c.primary.tags)" :key="i" class="chip">{{ tag }}</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="tc-foot"> |
||||
|
<span class="chip chip-lang">{{ c.langs.length }} 种语言</span> |
||||
|
<span v-if="!c.hasPrimary" class="chip chip-warn">缺 {{ PRIMARY_LANG }},按 {{ c.primary.language }} 展示</span> |
||||
|
<span class="ml-auto tc-go">查看 →</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ══════════════ 详情视图:一个 tid 的全部语言版本 ══════════════ --> |
||||
|
<div v-else-if="view === 'detail' && current"> |
||||
|
<div class="flex flex-wrap items-center gap-3 mb-4"> |
||||
|
<button class="btn btn-ghost btn-sm" @click="backToList()">← 返回列表</button> |
||||
|
<div class="min-w-0"> |
||||
|
<h2 class="text-lg font-semibold text-slate-800 truncate">{{ form.title || current.tid }}</h2> |
||||
|
<div class="text-xs text-slate-400 font-mono">{{ current.tid }} · {{ current.langs.length }} 种语言</div> |
||||
|
</div> |
||||
|
<div class="ml-auto flex items-center gap-2"> |
||||
|
<button class="btn btn-sm" @click="openSync()">同步到其他语言</button> |
||||
|
<button class="btn btn-sm" @click="openAddLang()">+ 添加语言</button> |
||||
|
<button class="btn btn-ghost btn-sm text-red-500" @click="delWholeTid()">删除整组</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 语言切换条 --> |
||||
|
<div class="panel p-3 mb-4"> |
||||
|
<div class="flex items-center gap-2 flex-wrap"> |
||||
|
<span class="text-xs text-slate-400 shrink-0">语言版本</span> |
||||
|
<button |
||||
|
v-for="l in current.langs" |
||||
|
:key="l.language" |
||||
|
class="lang-pill" |
||||
|
:class="{ on: l.language === curLang, primary: isPrimary(l.language) }" |
||||
|
:title="langName(l.language)" |
||||
|
@click="switchLang(l.language)" |
||||
|
> |
||||
|
{{ l.language }} |
||||
|
<span class="lp-name">{{ langName(l.language) }}</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 当前语言内容 --> |
||||
|
<div class="panel p-6"> |
||||
|
<div v-if="detailLoading" class="py-16 text-center"> |
||||
|
<span class="loading loading-spinner loading-md"></span> |
||||
|
</div> |
||||
|
<form v-else @submit.prevent="saveCurrent()"> |
||||
|
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100"> |
||||
|
<span class="badge badge-outline">{{ curLang }}</span> |
||||
|
<span class="text-sm text-slate-500">{{ langName(curLang) }}</span> |
||||
|
<span v-if="isPrimary(curLang)" class="chip chip-primary">主语言</span> |
||||
|
<span class="text-xs text-slate-300 font-mono ml-2">id {{ form.id }}</span> |
||||
|
<div class="ml-auto flex gap-2"> |
||||
|
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="delCurrentLang()"> |
||||
|
删除此语言版本 |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
||||
|
<div class="form-control"> |
||||
|
<label class="label"><span class="label-text font-medium">标题 *</span></label> |
||||
|
<input v-model="form.title" type="text" class="input input-bordered w-full" required /> |
||||
|
</div> |
||||
|
<div class="form-control"> |
||||
|
<label class="label"><span class="label-text font-medium">类型 (ttype)</span></label> |
||||
|
<input v-model="form.ttype" type="text" class="input input-bordered w-full" list="ttypelist" /> |
||||
|
<datalist id="ttypelist"> |
||||
|
<option v-for="t in typeOptions" :key="t" :value="t" /> |
||||
|
</datalist> |
||||
|
</div> |
||||
|
<div class="form-control"> |
||||
|
<label class="label"><span class="label-text font-medium">标签 (tags)</span></label> |
||||
|
<input v-model="form.tags" type="text" class="input input-bordered w-full" placeholder="逗号分隔" /> |
||||
|
</div> |
||||
|
<div class="form-control"> |
||||
|
<label class="label"><span class="label-text font-medium">图标 (icon)</span></label> |
||||
|
<input v-model="form.icon" type="text" class="input input-bordered w-full" placeholder="图标 URL 或名称" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="form-control mt-4"> |
||||
|
<label class="label"><span class="label-text font-medium">描述</span></label> |
||||
|
<input v-model="form.description" type="text" class="input input-bordered w-full" /> |
||||
|
</div> |
||||
|
<div class="form-control mt-4"> |
||||
|
<label class="label"><span class="label-text font-medium">大纲 (outline)</span></label> |
||||
|
<textarea v-model="form.outline" class="textarea textarea-bordered w-full font-mono text-sm" rows="5"></textarea> |
||||
|
</div> |
||||
|
<div class="form-control mt-4"> |
||||
|
<label class="label"><span class="label-text font-medium">模板内容 (template)</span></label> |
||||
|
<textarea v-model="form.template" class="textarea textarea-bordered w-full font-mono text-sm" rows="16"></textarea> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex gap-3 mt-6"> |
||||
|
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存当前语言</button> |
||||
|
<button type="button" class="btn btn-ghost" :disabled="submitting" @click="reloadCurrent()">放弃修改</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ══════════════ 同步弹窗 ══════════════ --> |
||||
|
<div v-if="syncOpen" class="modal modal-open"> |
||||
|
<div class="modal-box max-w-2xl"> |
||||
|
<h3 class="font-bold text-base mb-1">同步到其他语言</h3> |
||||
|
<p class="text-xs text-slate-400 mb-4"> |
||||
|
以 <span class="font-mono">{{ curLang }}</span> 版本为源,把勾选字段覆盖写入所选语言版本。 |
||||
|
标题/描述/大纲/模板属译文,覆盖后目标语言的译文会丢失,请谨慎勾选。 |
||||
|
</p> |
||||
|
|
||||
|
<div class="mb-4"> |
||||
|
<div class="text-sm font-medium text-slate-600 mb-2">同步哪些字段</div> |
||||
|
<div class="flex flex-wrap gap-x-5 gap-y-2"> |
||||
|
<label v-for="f in SYNC_FIELDS" :key="f.key" class="flex items-center gap-2 text-sm cursor-pointer"> |
||||
|
<input v-model="syncFields" type="checkbox" :value="f.key" class="checkbox checkbox-sm checkbox-primary" /> |
||||
|
<span>{{ f.label }}</span> |
||||
|
<span v-if="f.translated" class="chip chip-warn">译文</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<div class="flex items-center gap-2 mb-2"> |
||||
|
<span class="text-sm font-medium text-slate-600">同步到哪些语言</span> |
||||
|
<span class="text-xs text-slate-400">已选 {{ syncLangs.length }} / {{ otherLangs.length }}</span> |
||||
|
<button type="button" class="btn btn-ghost btn-xs ml-auto" @click="syncLangs = otherLangs.slice()">全选</button> |
||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="syncLangs = []">清空</button> |
||||
|
</div> |
||||
|
<div class="lang-picker"> |
||||
|
<label v-for="l in otherLangs" :key="l" class="lang-opt"> |
||||
|
<input v-model="syncLangs" type="checkbox" :value="l" class="checkbox checkbox-xs checkbox-primary" /> |
||||
|
<span class="lo-code">{{ l }}</span> |
||||
|
<span class="lo-name">{{ langName(l) }}</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="modal-action"> |
||||
|
<button class="btn btn-ghost" @click="syncOpen = false">取消</button> |
||||
|
<button |
||||
|
class="btn btn-primary text-white" |
||||
|
:disabled="submitting || !syncFields.length || !syncLangs.length" |
||||
|
@click="doSync()" |
||||
|
> |
||||
|
同步到 {{ syncLangs.length }} 个语言 |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ══════════════ 添加语言弹窗 ══════════════ --> |
||||
|
<div v-if="addLangOpen" class="modal modal-open"> |
||||
|
<div class="modal-box max-w-lg"> |
||||
|
<h3 class="font-bold text-base mb-1">为「{{ current?.tid }}」添加语言版本</h3> |
||||
|
<p class="text-xs text-slate-400 mb-4"> |
||||
|
新版本以当前 <span class="font-mono">{{ curLang }}</span> 版本的内容为初稿,创建后再逐项译成目标语言。 |
||||
|
</p> |
||||
|
<div class="form-control"> |
||||
|
<label class="label"><span class="label-text font-medium">语言码 *</span></label> |
||||
|
<input |
||||
|
v-model="newLang" |
||||
|
class="input input-bordered w-full" |
||||
|
list="addlanglist" |
||||
|
placeholder="BCP-47,如 en-US" |
||||
|
/> |
||||
|
<datalist id="addlanglist"> |
||||
|
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.name }}</option> |
||||
|
</datalist> |
||||
|
<span v-if="newLangErr" class="text-xs text-rose-600 mt-1">{{ newLangErr }}</span> |
||||
|
</div> |
||||
|
<div class="modal-action"> |
||||
|
<button class="btn btn-ghost" @click="addLangOpen = false">取消</button> |
||||
|
<button class="btn btn-primary text-white" :disabled="submitting" @click="doAddLang()">创建</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ══════════════ 新增模板弹窗 ══════════════ --> |
||||
|
<div v-if="createOpen" class="modal modal-open"> |
||||
|
<div class="modal-box max-w-lg"> |
||||
|
<h3 class="font-bold text-base mb-1">新增模板</h3> |
||||
|
<p class="text-xs text-slate-400 mb-4"> |
||||
|
先建出主语言版本,创建后在详情页补齐内容,再用「添加语言」扩展其他语言。 |
||||
|
</p> |
||||
|
<div class="form-control"> |
||||
|
<label class="label"><span class="label-text font-medium">模板标识 tid *</span></label> |
||||
|
<input v-model="createTid" class="input input-bordered w-full" placeholder="如 Meeting-WeeklyMeeting" /> |
||||
|
</div> |
||||
|
<div class="form-control mt-3"> |
||||
|
<label class="label"><span class="label-text font-medium">标题 *</span></label> |
||||
|
<input v-model="createTitle" class="input input-bordered w-full" /> |
||||
|
</div> |
||||
|
<div class="form-control mt-3"> |
||||
|
<label class="label"><span class="label-text font-medium">语言</span></label> |
||||
|
<input v-model="createLang" class="input input-bordered w-full" list="addlanglist" /> |
||||
|
</div> |
||||
|
<span v-if="createErr" class="text-xs text-rose-600 mt-2 block">{{ createErr }}</span> |
||||
|
<div class="modal-action"> |
||||
|
<button class="btn btn-ghost" @click="createOpen = false">取消</button> |
||||
|
<button class="btn btn-primary text-white" :disabled="submitting" @click="doCreate()">创建</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive, computed, onMounted, watch } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import { useToast } from '~/composables/useToast' |
||||
|
import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions' |
||||
|
|
||||
|
interface MeetTemplate { |
||||
|
id: number |
||||
|
title: string |
||||
|
tid: string |
||||
|
language: string |
||||
|
ttype: string |
||||
|
tags: string |
||||
|
icon: string |
||||
|
description: string |
||||
|
outline: string |
||||
|
template: string |
||||
|
} |
||||
|
|
||||
|
// 一个 tid 的聚合视图:主语言版本用于卡面展示,langs 是该 tid 下的全部语言版本。 |
||||
|
interface TplCard { |
||||
|
tid: string |
||||
|
primary: MeetTemplate // 主语言(中文)版本;没有则回退到第一个可用语言 |
||||
|
hasPrimary: boolean |
||||
|
langs: MeetTemplate[] |
||||
|
} |
||||
|
|
||||
|
const PRIMARY_LANG = 'zh-CN' |
||||
|
|
||||
|
// 可同步字段。translated=true 的属各语言译文,同步等于用源语言覆盖,默认不勾。 |
||||
|
const SYNC_FIELDS = [ |
||||
|
{ key: 'ttype', label: '类型 ttype' }, |
||||
|
{ key: 'tags', label: '标签 tags' }, |
||||
|
{ key: 'icon', label: '图标 icon' }, |
||||
|
{ key: 'title', label: '标题 title', translated: true }, |
||||
|
{ key: 'description', label: '描述 description', translated: true }, |
||||
|
{ key: 'outline', label: '大纲 outline', translated: true }, |
||||
|
{ key: 'template', label: '模板内容 template', translated: true }, |
||||
|
] as const |
||||
|
|
||||
|
const { webApi } = useApi() |
||||
|
const { success, error } = useToast() |
||||
|
|
||||
|
// 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名由「会议配置」页统一下发, |
||||
|
// 这里不再自己拉一份应用列表——同一页上三块配置共用一个应用选择器,各拉各的会出现 |
||||
|
// 「转写配的是 A、模板改的是 B」这种对不上的状态。 |
||||
|
const props = defineProps<{ appName: string }>() |
||||
|
const scopeApp = computed(() => props.appName) |
||||
|
|
||||
|
const view = ref<'list' | 'detail'>('list') |
||||
|
const rows = ref<MeetTemplate[]>([]) |
||||
|
const loading = ref(false) |
||||
|
const detailLoading = ref(false) |
||||
|
const submitting = ref(false) |
||||
|
|
||||
|
const catFilter = ref('') |
||||
|
const typeFilter = ref('') |
||||
|
const kwFilter = ref('') |
||||
|
|
||||
|
const current = ref<TplCard | null>(null) |
||||
|
const curLang = ref('') |
||||
|
// 详情大字段(outline/template)列表接口不返回,按 id 单独拉;缓存避免来回切语言重复请求。 |
||||
|
const detailCache = new Map<number, MeetTemplate>() |
||||
|
|
||||
|
const form = reactive<MeetTemplate>({ |
||||
|
id: 0, title: '', tid: '', language: '', ttype: '', tags: '', |
||||
|
icon: '', description: '', outline: '', template: '', |
||||
|
}) |
||||
|
|
||||
|
const syncOpen = ref(false) |
||||
|
const syncFields = ref<string[]>([]) |
||||
|
const syncLangs = ref<string[]>([]) |
||||
|
|
||||
|
const addLangOpen = ref(false) |
||||
|
const newLang = ref('') |
||||
|
const newLangErr = ref('') |
||||
|
|
||||
|
const createOpen = ref(false) |
||||
|
const createTid = ref('') |
||||
|
const createTitle = ref('') |
||||
|
const createLang = ref(PRIMARY_LANG) |
||||
|
const createErr = ref('') |
||||
|
|
||||
|
function tagList(s?: string) { |
||||
|
return (s || '').split(',').map((x) => x.trim()).filter(Boolean) |
||||
|
} |
||||
|
function isImg(s?: string) { |
||||
|
return /^https?:\/\//i.test(s || '') |
||||
|
} |
||||
|
function isPrimary(lang: string) { |
||||
|
return lang === PRIMARY_LANG |
||||
|
} |
||||
|
// tid 惯例是 `分类-名称`(General-Meeting / Speech-Lecture),取前缀当分类。 |
||||
|
function catOf(tid: string) { |
||||
|
const i = (tid || '').indexOf('-') |
||||
|
return i > 0 ? tid.slice(0, i) : '其他' |
||||
|
} |
||||
|
function initials(c: TplCard) { |
||||
|
const t = (c.primary.title || c.tid || '?').trim() |
||||
|
return t.slice(0, 1).toUpperCase() |
||||
|
} |
||||
|
|
||||
|
// ── 按 tid 聚合成卡片 ── |
||||
|
const cards = computed<TplCard[]>(() => { |
||||
|
const byTid = new Map<string, MeetTemplate[]>() |
||||
|
rows.value.forEach((t) => { |
||||
|
const k = t.tid || '' |
||||
|
if (!byTid.has(k)) byTid.set(k, []) |
||||
|
byTid.get(k)!.push(t) |
||||
|
}) |
||||
|
const out: TplCard[] = [] |
||||
|
byTid.forEach((items, tid) => { |
||||
|
const langs = items.slice().sort((a, b) => { |
||||
|
// 主语言永远排最前,其余按语言码字典序 |
||||
|
if (isPrimary(a.language) !== isPrimary(b.language)) return isPrimary(a.language) ? -1 : 1 |
||||
|
return (a.language || '') < (b.language || '') ? -1 : 1 |
||||
|
}) |
||||
|
const zh = langs.find((t) => isPrimary(t.language)) |
||||
|
out.push({ tid, primary: zh || langs[0]!, hasPrimary: !!zh, langs }) |
||||
|
}) |
||||
|
return out.sort((a, b) => (a.tid < b.tid ? -1 : 1)) |
||||
|
}) |
||||
|
|
||||
|
const catOptions = computed(() => |
||||
|
Array.from(new Set(cards.value.map((c) => catOf(c.tid)))).sort(), |
||||
|
) |
||||
|
const typeOptions = computed(() => |
||||
|
Array.from(new Set(rows.value.map((t) => t.ttype).filter(Boolean))).sort(), |
||||
|
) |
||||
|
|
||||
|
const filteredCards = computed(() => { |
||||
|
const kw = kwFilter.value.trim().toLowerCase() |
||||
|
return cards.value.filter((c) => { |
||||
|
if (catFilter.value && catOf(c.tid) !== catFilter.value) return false |
||||
|
if (typeFilter.value && c.primary.ttype !== typeFilter.value) return false |
||||
|
if (!kw) return true |
||||
|
return `${c.tid} ${c.primary.title} ${c.primary.description}`.toLowerCase().includes(kw) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// 当前 tid 下除当前语言外的其它语言码(同步/添加语言用) |
||||
|
const otherLangs = computed(() => |
||||
|
(current.value?.langs ?? []).map((t) => t.language).filter((l) => l && l !== curLang.value), |
||||
|
) |
||||
|
const addableLangs = computed(() => { |
||||
|
const has = new Set((current.value?.langs ?? []).map((t) => t.language)) |
||||
|
return ALL_LANG_OPTIONS.filter((o) => !has.has(o.code)) |
||||
|
}) |
||||
|
|
||||
|
function fillForm(t: Partial<MeetTemplate>) { |
||||
|
form.id = t.id || 0 |
||||
|
form.title = t.title || '' |
||||
|
form.tid = t.tid || '' |
||||
|
form.language = t.language || '' |
||||
|
form.ttype = t.ttype || '' |
||||
|
form.tags = t.tags || '' |
||||
|
form.icon = t.icon || '' |
||||
|
form.description = t.description || '' |
||||
|
form.outline = t.outline || '' |
||||
|
form.template = t.template || '' |
||||
|
} |
||||
|
|
||||
|
async function loadData() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
if (!scopeApp.value) { rows.value = []; loading.value = false; return } |
||||
|
const d = await webApi<{ templates: MeetTemplate[] }>('getmeettemplates', { app_name: scopeApp.value }) |
||||
|
rows.value = d?.templates ?? [] |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '加载失败') |
||||
|
rows.value = [] |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function backToList() { |
||||
|
view.value = 'list' |
||||
|
current.value = null |
||||
|
curLang.value = '' |
||||
|
loadData() |
||||
|
} |
||||
|
|
||||
|
function openDetail(c: TplCard) { |
||||
|
current.value = c |
||||
|
view.value = 'detail' |
||||
|
switchLang(c.primary.language) |
||||
|
} |
||||
|
|
||||
|
// 切到某个语言版本:先用列表里的浅字段占位,再拉详情补 outline/template。 |
||||
|
async function switchLang(lang: string) { |
||||
|
const t = current.value?.langs.find((x) => x.language === lang) |
||||
|
if (!t) return |
||||
|
curLang.value = lang |
||||
|
fillForm(t) |
||||
|
const cached = detailCache.get(t.id) |
||||
|
if (cached) { |
||||
|
fillForm(cached) |
||||
|
return |
||||
|
} |
||||
|
detailLoading.value = true |
||||
|
try { |
||||
|
const detail = await webApi<MeetTemplate>('getmeettemplate', { id: t.id }) |
||||
|
if (detail) { |
||||
|
detailCache.set(t.id, detail) |
||||
|
// 只有仍停在这个语言上才回填,避免慢请求覆盖用户已切走的内容 |
||||
|
if (curLang.value === lang) fillForm(detail) |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '加载详情失败') |
||||
|
} finally { |
||||
|
detailLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function reloadCurrent() { |
||||
|
detailCache.delete(form.id) |
||||
|
switchLang(curLang.value) |
||||
|
} |
||||
|
|
||||
|
async function saveCurrent() { |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('updatemeettemplate', { |
||||
|
app_name: scopeApp.value, |
||||
|
id: Number(form.id), |
||||
|
title: form.title.trim(), |
||||
|
tid: form.tid.trim(), |
||||
|
language: form.language.trim(), |
||||
|
ttype: form.ttype.trim(), |
||||
|
tags: form.tags.trim(), |
||||
|
icon: form.icon.trim(), |
||||
|
description: form.description.trim(), |
||||
|
outline: form.outline, |
||||
|
template: form.template, |
||||
|
}) |
||||
|
detailCache.delete(form.id) |
||||
|
success('已保存') |
||||
|
await refreshCurrentCard() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '保存失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 列表数据变动后,重新拉列表并把 current 指向同一 tid 的新聚合结果。 |
||||
|
async function refreshCurrentCard(preferLang?: string) { |
||||
|
const tid = current.value?.tid |
||||
|
const lang = preferLang || curLang.value |
||||
|
await loadData() |
||||
|
if (!tid) return |
||||
|
const c = cards.value.find((x) => x.tid === tid) |
||||
|
if (!c) { |
||||
|
backToList() |
||||
|
return |
||||
|
} |
||||
|
current.value = c |
||||
|
const target = c.langs.find((x) => x.language === lang) ? lang : c.primary.language |
||||
|
await switchLang(target) |
||||
|
} |
||||
|
|
||||
|
// ── 同步 ── |
||||
|
function openSync() { |
||||
|
syncFields.value = SYNC_FIELDS.filter((f) => !('translated' in f && f.translated)).map((f) => f.key) |
||||
|
syncLangs.value = otherLangs.value.slice() |
||||
|
syncOpen.value = true |
||||
|
} |
||||
|
|
||||
|
async function doSync() { |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
const res = await webApi<{ updated: number }>('syncmeettemplate', { |
||||
|
app_name: scopeApp.value, |
||||
|
src_id: Number(form.id), |
||||
|
fields: syncFields.value, |
||||
|
languages: syncLangs.value, |
||||
|
}) |
||||
|
syncOpen.value = false |
||||
|
detailCache.clear() |
||||
|
success(`已同步 ${res?.updated ?? 0} 个语言版本`) |
||||
|
await refreshCurrentCard() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '同步失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 添加语言 ── |
||||
|
function openAddLang() { |
||||
|
newLang.value = '' |
||||
|
newLangErr.value = '' |
||||
|
addLangOpen.value = true |
||||
|
} |
||||
|
|
||||
|
async function doAddLang() { |
||||
|
const lang = newLang.value.trim() |
||||
|
newLangErr.value = '' |
||||
|
if (!lang) { |
||||
|
newLangErr.value = '请填写语言码' |
||||
|
return |
||||
|
} |
||||
|
if (current.value?.langs.some((t) => t.language === lang)) { |
||||
|
newLangErr.value = `该 tid 下已存在 ${lang} 版本` |
||||
|
return |
||||
|
} |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('addmeettemplate', { |
||||
|
app_name: scopeApp.value, |
||||
|
title: form.title.trim(), |
||||
|
tid: form.tid.trim(), |
||||
|
language: lang, |
||||
|
ttype: form.ttype.trim(), |
||||
|
tags: form.tags.trim(), |
||||
|
icon: form.icon.trim(), |
||||
|
description: form.description.trim(), |
||||
|
outline: form.outline, |
||||
|
template: form.template, |
||||
|
}) |
||||
|
addLangOpen.value = false |
||||
|
success(`已创建 ${lang} 版本`) |
||||
|
await refreshCurrentCard(lang) |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '创建失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 新增模板 ── |
||||
|
function openCreate() { |
||||
|
createTid.value = '' |
||||
|
createTitle.value = '' |
||||
|
createLang.value = PRIMARY_LANG |
||||
|
createErr.value = '' |
||||
|
createOpen.value = true |
||||
|
} |
||||
|
|
||||
|
async function doCreate() { |
||||
|
const tid = createTid.value.trim() |
||||
|
const title = createTitle.value.trim() |
||||
|
const lang = createLang.value.trim() |
||||
|
createErr.value = '' |
||||
|
if (!tid || !title) { |
||||
|
createErr.value = 'tid 与标题均必填' |
||||
|
return |
||||
|
} |
||||
|
if (cards.value.some((c) => c.tid === tid)) { |
||||
|
createErr.value = `tid「${tid}」已存在,请到列表中打开它再「添加语言」` |
||||
|
return |
||||
|
} |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('addmeettemplate', { app_name: scopeApp.value, tid, title, language: lang }) |
||||
|
createOpen.value = false |
||||
|
success('已创建') |
||||
|
await loadData() |
||||
|
const c = cards.value.find((x) => x.tid === tid) |
||||
|
if (c) openDetail(c) |
||||
|
} catch (e: any) { |
||||
|
createErr.value = e?.message ?? '创建失败' |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 删除 ── |
||||
|
async function delCurrentLang() { |
||||
|
if (!current.value) return |
||||
|
if (current.value.langs.length <= 1) { |
||||
|
error('这是最后一个语言版本,请用「删除整组」') |
||||
|
return |
||||
|
} |
||||
|
if (!window.confirm(`确定删除「${current.value.tid}」的 ${curLang.value} 版本吗?`)) return |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('delmeettemplate', { app_name: scopeApp.value, ids: [Number(form.id)] }) |
||||
|
detailCache.delete(form.id) |
||||
|
success('已删除') |
||||
|
await refreshCurrentCard(PRIMARY_LANG) |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '删除失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function delWholeTid() { |
||||
|
const c = current.value |
||||
|
if (!c) return |
||||
|
if (!window.confirm(`确定删除「${c.tid}」的全部 ${c.langs.length} 个语言版本吗?此操作不可恢复。`)) return |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('delmeettemplate', { app_name: scopeApp.value, ids: c.langs.map((t) => Number(t.id)) }) |
||||
|
detailCache.clear() |
||||
|
success('已删除整组') |
||||
|
backToList() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '删除失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 切换应用要退回列表:详情停在上一个应用的模板上,保存时会带着新应用名写过去。 |
||||
|
watch(scopeApp, () => { |
||||
|
view.value = 'list' |
||||
|
current.value = null |
||||
|
curLang.value = '' |
||||
|
detailCache.clear() |
||||
|
loadData() |
||||
|
}) |
||||
|
|
||||
|
onMounted(() => { loadData() }) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); } |
||||
|
|
||||
|
.tpl-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); } |
||||
|
|
||||
|
.tpl-card { |
||||
|
background:#fff; border:1px solid #eef2f7; border-radius:16px; padding:16px; |
||||
|
cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:10px; |
||||
|
box-shadow:0 1px 2px rgba(0,0,0,0.04); |
||||
|
} |
||||
|
.tpl-card:hover { border-color:#c7d2fe; box-shadow:0 6px 18px rgba(15,23,42,0.08); transform:translateY(-1px); } |
||||
|
|
||||
|
.tc-top { display:flex; align-items:flex-start; gap:11px; } |
||||
|
.tc-ico { |
||||
|
width:40px; height:40px; border-radius:11px; flex-shrink:0; overflow:hidden; |
||||
|
background:linear-gradient(135deg,#eef2ff,#e0e7ff); color:#4338ca; |
||||
|
display:flex; align-items:center; justify-content:center; font-weight:700; font-size:17px; |
||||
|
} |
||||
|
.tc-ico img { width:100%; height:100%; object-fit:cover; } |
||||
|
.tc-title { font-weight:600; font-size:14.5px; color:#1e293b; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
||||
|
.tc-tid { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
||||
|
|
||||
|
.tc-desc { |
||||
|
font-size:12.5px; color:#64748b; line-height:1.55; min-height:39px; |
||||
|
display:-webkit-box; -webkit-line-clamp:2; line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; |
||||
|
} |
||||
|
.tc-tags { display:flex; flex-wrap:wrap; gap:4px; min-height:20px; } |
||||
|
.tc-foot { display:flex; align-items:center; gap:6px; flex-wrap:wrap; padding-top:9px; border-top:1px solid #f8fafc; } |
||||
|
.tc-go { font-size:11.5px; color:#6366f1; font-weight:600; } |
||||
|
|
||||
|
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; } |
||||
|
.chip-type { background:#e0e7ff; color:#4338ca; } |
||||
|
.chip-lang { background:#cffafe; color:#0e7490; } |
||||
|
.chip-warn { background:#fef3c7; color:#b45309; } |
||||
|
.chip-primary { background:#d1fae5; color:#047857; } |
||||
|
|
||||
|
.lang-pill { |
||||
|
display:inline-flex; align-items:center; gap:5px; font-size:11.5px; padding:3px 9px; |
||||
|
border-radius:999px; border:1px solid #e2e8f0; background:#f8fafc; color:#64748b; |
||||
|
cursor:pointer; transition:all .12s; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; |
||||
|
} |
||||
|
.lang-pill:hover { border-color:#c7d2fe; color:#4338ca; } |
||||
|
.lang-pill.on { background:#4338ca; border-color:#4338ca; color:#fff; } |
||||
|
.lang-pill.primary:not(.on) { background:#ecfdf5; border-color:#a7f3d0; color:#047857; } |
||||
|
.lp-name { font-family:inherit; opacity:.75; font-size:11px; } |
||||
|
.lang-pill.on .lp-name { opacity:.9; } |
||||
|
|
||||
|
.lang-picker { |
||||
|
display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:3px; |
||||
|
max-height:260px; overflow-y:auto; border:1px solid #eef2f7; border-radius:10px; padding:8px; |
||||
|
} |
||||
|
.lang-opt { display:flex; align-items:center; gap:6px; padding:3px 5px; border-radius:6px; cursor:pointer; font-size:12px; } |
||||
|
.lang-opt:hover { background:#f8fafc; } |
||||
|
.lo-code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#475569; } |
||||
|
.lo-name { color:#94a3b8; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
||||
|
</style> |
||||
@ -0,0 +1,383 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<!-- 页头 + 应用选择:三块配置共用一个作用域 --> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-4"> |
||||
|
<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>用哪家录音文件识别、<b>会议总结</b>用哪个大模型、 |
||||
|
以及<b>会议模板</b>。都<b>按应用各一套</b>,互不干涉。 |
||||
|
</p> |
||||
|
</div> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<span class="text-sm text-slate-500">应用</span> |
||||
|
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40"> |
||||
|
<option value="" disabled>请选择应用</option> |
||||
|
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="!scopeApp" class="panel py-16 text-center text-slate-400 text-sm"> |
||||
|
请先在右上角选择应用。 |
||||
|
</div> |
||||
|
|
||||
|
<template v-else> |
||||
|
<!-- 上半区:左「会议转写」右「会议总结」,窄屏自动叠成一列 --> |
||||
|
<div class="topgrid"> |
||||
|
<!-- ══════════════ 会议转写 ══════════════ --> |
||||
|
<section class="panel p-5"> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-1"> |
||||
|
<div> |
||||
|
<div class="text-sm font-semibold text-slate-700">会议转写(录音文件识别)</div> |
||||
|
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
从<b>第三方服务配置</b>里已配好的「录音文件识别」类服务中选。 |
||||
|
<b>选一个就只用这一个;选两个及以上,排在后面的是兜底</b>—— |
||||
|
第一家提交失败(凭据失效、欠费、报错)时自动换下一家。 |
||||
|
另外,服务若填了支持语言,遇到它不支持的源语言也会直接顺延。 |
||||
|
</div> |
||||
|
</div> |
||||
|
<button class="btn btn-sm shrink-0" :disabled="!addableAsr.length || saving" @click="openAdd()"> |
||||
|
+ 添加转写服务 |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div> |
||||
|
|
||||
|
<template v-else> |
||||
|
<div v-if="!asrPick.length" class="empty mt-4"> |
||||
|
还没有选转写服务,语音纪要会直接转写失败。 |
||||
|
<span v-if="!svcOf('asr').length"> |
||||
|
当前应用下也没有可选的「录音文件识别」服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。 |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<ol v-else class="picklist mt-4"> |
||||
|
<li v-for="(id, i) in asrPick" :key="id" class="pick"> |
||||
|
<span class="rank" :class="{ main: i === 0 }">{{ i === 0 ? '主用' : '兜底 ' + i }}</span> |
||||
|
<div class="min-w-0 flex-1"> |
||||
|
<div class="pick-name"> |
||||
|
{{ svcName(id) }} |
||||
|
<span class="pick-id">{{ id }}</span> |
||||
|
<span v-if="!svcMap.get(id)" class="chip chip-warn">该服务已停用或不存在</span> |
||||
|
</div> |
||||
|
<div class="pick-sub"> |
||||
|
{{ svcProvider(id) }} · |
||||
|
<template v-if="svcLangs(id)">支持 {{ svcLangs(id).length }} 种语言</template> |
||||
|
<template v-else>未限定支持语言(由引擎判断)</template> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="flex items-center gap-1 shrink-0"> |
||||
|
<button class="btn btn-ghost btn-xs" :disabled="i === 0" title="上移" @click="moveAsr(i, -1)">↑</button> |
||||
|
<button class="btn btn-ghost btn-xs" :disabled="i === asrPick.length - 1" title="下移" @click="moveAsr(i, 1)">↓</button> |
||||
|
<button class="btn btn-ghost btn-xs text-red-500" @click="asrPick.splice(i, 1)">移除</button> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ol> |
||||
|
|
||||
|
<div class="savebar"> |
||||
|
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !asrDirty" @click="saveAsr()">保存会议转写</button> |
||||
|
<button class="btn btn-ghost btn-sm" :disabled="saving || !asrDirty" @click="resetAsr()">放弃修改</button> |
||||
|
<span v-if="asrDirty" class="text-xs text-amber-600">有未保存的改动</span> |
||||
|
</div> |
||||
|
</template> |
||||
|
</section> |
||||
|
|
||||
|
<!-- ══════════════ 会议总结 ══════════════ --> |
||||
|
<section class="panel p-5"> |
||||
|
<div class="text-sm font-semibold text-slate-700">会议总结(大模型)</div> |
||||
|
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
从<b>第三方服务配置</b>里已配好的「文本大模型 / 多媒体大模型」类服务中选一个,用于生成纪要总结。 |
||||
|
这一段<b>没有兜底</b>:选中的服务不可用时总结直接失败。 |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div> |
||||
|
|
||||
|
<template v-else> |
||||
|
<div class="form-control max-w-xl mt-4"> |
||||
|
<label class="label"><span class="label-text font-medium">总结模型</span></label> |
||||
|
<select v-model="llmPick" class="select select-bordered"> |
||||
|
<option value="">不配置(不生成总结)</option> |
||||
|
<option v-for="s in svcOf('llm')" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option> |
||||
|
<!-- 已选服务在服务池里找不到(被停用/删除)时仍要列出来,否则下拉会静默跳到「不配置」 --> |
||||
|
<option v-if="llmPick && !svcMap.get(llmPick)" :value="llmPick">{{ llmPick }}(已停用或不存在)</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="!svcOf('llm').length" class="empty mt-4"> |
||||
|
当前应用下没有可选的大模型服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。 |
||||
|
</div> |
||||
|
<div v-if="extraLlm.length" class="alert alert-warning py-2 text-xs mt-4"> |
||||
|
<span> |
||||
|
该应用还另有 {{ extraLlm.length }} 个已配置的总结服务({{ extraLlm.join('、') }})。 |
||||
|
保存后只保留上面选中的这一个,其余会从编排里移除(不影响第三方服务配置里的服务本身)。 |
||||
|
<template v-if="clientChoice"> |
||||
|
⚠️ 该应用开着「客户端可指定总结模型」,这些服务现在可能正被 App 端选用,移除后客户端只剩这一个可选。 |
||||
|
</template> |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="savebar"> |
||||
|
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !llmSavable" @click="saveLlm()">保存会议总结</button> |
||||
|
<button class="btn btn-ghost btn-sm" :disabled="saving || !llmDirty" @click="resetLlm()">放弃修改</button> |
||||
|
<span v-if="llmDirty" class="text-xs text-amber-600">有未保存的改动</span> |
||||
|
</div> |
||||
|
</template> |
||||
|
</section> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 区域分叉:本页只编辑「全区域」那一层,有分叉就说清楚,别让后台显示的和线上跑的不是一回事 --> |
||||
|
<div v-if="regionRows" class="alert alert-warning py-2 text-xs mt-4"> |
||||
|
<span> |
||||
|
该应用另有 {{ regionRows }} 条<b>按区域</b>的编排,本页只编辑「全区域」这一层。 |
||||
|
区域分叉优先于全区域,对应区域的部署实际跑的是那几条,如需调整请直接改库 |
||||
|
(表 <span class="font-mono">echomeet_orch</span>)。 |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ══════════════ 会议模板:占满下方,内容多了整页往下滚 ══════════════ --> |
||||
|
<section class="tplsec"> |
||||
|
<h3 class="tplsec-title">会议模板</h3> |
||||
|
<MeetTemplatePanel :app-name="scopeApp" /> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<!-- 添加转写服务 --> |
||||
|
<div v-if="addOpen" class="modal modal-open"> |
||||
|
<div class="modal-box max-w-lg"> |
||||
|
<h3 class="font-bold text-base mb-1">添加转写服务</h3> |
||||
|
<p class="text-xs text-slate-400 mb-4">加进列表末尾作为兜底,可再用上下箭头调整顺序。</p> |
||||
|
<div class="form-control"> |
||||
|
<select v-model="addSvc" class="select select-bordered"> |
||||
|
<option value="" disabled>选择一个服务</option> |
||||
|
<option v-for="s in addableAsr" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
<div class="modal-action"> |
||||
|
<button class="btn btn-ghost" @click="addOpen = false">取消</button> |
||||
|
<button class="btn btn-primary text-white" :disabled="!addSvc" @click="doAdd()">添加</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, onMounted, watch } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import { useToast } from '~/composables/useToast' |
||||
|
|
||||
|
definePageMeta({ title: '会议配置' }) |
||||
|
|
||||
|
const { webApi, consoleApi } = useApi() |
||||
|
const { success, error } = useToast() |
||||
|
|
||||
|
// 编排类别(与后端 comm.EchoKindASR/MT/LLM 同一套数字)。翻译段(2)本页不编辑, |
||||
|
// 后端 saveechoorch 也不会碰它——它同样是语音纪要流水线的一环,跟着一起重置会让译文整体消失。 |
||||
|
const KIND_ASR = 1 |
||||
|
const KIND_LLM = 3 |
||||
|
|
||||
|
interface Orch { |
||||
|
row_id: number |
||||
|
app_name: string |
||||
|
region: number |
||||
|
kind: number |
||||
|
svc_id: string |
||||
|
priority: number |
||||
|
is_default: boolean |
||||
|
enabled: boolean |
||||
|
} |
||||
|
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string } |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const saving = ref(false) |
||||
|
|
||||
|
// 应用名来源同 appversion.vue:products/list ∪ apps/list,无权读时走 getmyapps。 |
||||
|
// 用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。 |
||||
|
const scopeApp = ref('') |
||||
|
const scopeApps = ref<string[]>([]) |
||||
|
async function loadScopeApps() { |
||||
|
const names = new Set<string>() |
||||
|
const [products, deploys] = await Promise.all([ |
||||
|
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>), |
||||
|
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>), |
||||
|
]) |
||||
|
;(products ?? []).forEach((p) => p.name && names.add(p.name)) |
||||
|
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name)) |
||||
|
if (!names.size) { |
||||
|
try { |
||||
|
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {}) |
||||
|
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) }) |
||||
|
} catch { /* 空列表,页面提示先选应用 */ } |
||||
|
} |
||||
|
scopeApps.value = [...names].sort() |
||||
|
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]! |
||||
|
} |
||||
|
|
||||
|
const available = ref<SvcView[]>([]) |
||||
|
const kindCats = ref<Record<string, number[]>>({ '1': [8], '3': [5, 9] }) |
||||
|
const regionRows = ref(0) |
||||
|
// 作用域开关:允许客户端自己指定总结模型。本页不编辑它(没有后台入口,需要时改库), |
||||
|
// 但要读出来——开着的时候「只保留一个总结服务」这件事对 App 端是有感知的。 |
||||
|
const clientChoice = ref(false) |
||||
|
// 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。 |
||||
|
const asrSaved = ref<string[]>([]) |
||||
|
const llmSaved = ref('') |
||||
|
const extraLlm = ref<string[]>([]) |
||||
|
const asrPick = ref<string[]>([]) |
||||
|
const llmPick = ref('') |
||||
|
|
||||
|
const addOpen = ref(false) |
||||
|
const addSvc = ref('') |
||||
|
|
||||
|
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 ?? '').split(',').map((x) => x.trim()).filter(Boolean) |
||||
|
} |
||||
|
function catsOf(s: SvcView) { |
||||
|
return (s.categories || '').split(',').map((x) => Number(x.trim())).filter((n) => !Number.isNaN(n)) |
||||
|
} |
||||
|
// 某段可选的服务 = 服务池里 categories 命中该段类别的 |
||||
|
function svcOf(seg: 'asr' | 'llm') { |
||||
|
const want = kindCats.value[String(seg === 'asr' ? KIND_ASR : KIND_LLM)] ?? [] |
||||
|
return available.value.filter((s) => catsOf(s).some((c) => want.includes(c))) |
||||
|
} |
||||
|
const addableAsr = computed(() => svcOf('asr').filter((s) => !asrPick.value.includes(s.id))) |
||||
|
|
||||
|
const asrDirty = computed(() => asrPick.value.join(',') !== asrSaved.value.join(',')) |
||||
|
const llmDirty = computed(() => llmPick.value !== llmSaved.value) |
||||
|
// 下拉没动过、但库里还躺着多余的总结行时也允许保存——保存正是为了把那些清掉。 |
||||
|
// 这种情况不算「未保存的改动」(用户什么都没改),所以只放开按钮,不打那条提示。 |
||||
|
const llmSavable = computed(() => llmDirty.value || extraLlm.value.length > 0) |
||||
|
|
||||
|
function resetAsr() { asrPick.value = asrSaved.value.slice() } |
||||
|
function resetLlm() { llmPick.value = llmSaved.value } |
||||
|
|
||||
|
function openAdd() { |
||||
|
addSvc.value = '' |
||||
|
addOpen.value = true |
||||
|
} |
||||
|
function doAdd() { |
||||
|
if (addSvc.value && !asrPick.value.includes(addSvc.value)) asrPick.value.push(addSvc.value) |
||||
|
addOpen.value = false |
||||
|
} |
||||
|
function moveAsr(i: number, dir: number) { |
||||
|
const list = asrPick.value |
||||
|
const j = i + dir |
||||
|
if (j < 0 || j >= list.length) return |
||||
|
const tmp = list[i]! |
||||
|
list[i] = list[j]! |
||||
|
list[j] = tmp |
||||
|
} |
||||
|
|
||||
|
async function loadOrch() { |
||||
|
if (!scopeApp.value) { available.value = []; asrSaved.value = []; llmSaved.value = ''; return } |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const d = await webApi<{ |
||||
|
items: Orch[] |
||||
|
available: SvcView[] |
||||
|
kind_cats: Record<string, number[]> |
||||
|
other_regions: Record<string, number> |
||||
|
setting?: { llm_client_choice?: boolean } |
||||
|
}>('getechoorch', { app_name: scopeApp.value }) |
||||
|
available.value = d?.available ?? [] |
||||
|
clientChoice.value = !!d?.setting?.llm_client_choice |
||||
|
if (d?.kind_cats) kindCats.value = d.kind_cats |
||||
|
regionRows.value = Object.values(d?.other_regions ?? {}).reduce((a, b) => a + b, 0) |
||||
|
|
||||
|
const items = (d?.items ?? []).filter((o) => o.enabled) |
||||
|
asrSaved.value = items.filter((o) => o.kind === KIND_ASR).sort((a, b) => a.priority - b.priority).map((o) => o.svc_id) |
||||
|
// 总结取默认模型;没有标默认就按优先级第一个(与运行时 PickLLM 的口径一致)。 |
||||
|
const llms = items.filter((o) => o.kind === KIND_LLM).sort((a, b) => a.priority - b.priority) |
||||
|
const def = llms.find((o) => o.is_default) ?? llms[0] |
||||
|
llmSaved.value = def?.svc_id ?? '' |
||||
|
extraLlm.value = llms.filter((o) => o.svc_id !== llmSaved.value).map((o) => o.svc_id) |
||||
|
resetAsr() |
||||
|
resetLlm() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '加载失败') |
||||
|
available.value = [] |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function saveAsr() { |
||||
|
saving.value = true |
||||
|
try { |
||||
|
await webApi('saveechoorch', { app_name: scopeApp.value, asr: asrPick.value }) |
||||
|
success('会议转写已保存') |
||||
|
await loadOrch() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '保存失败') |
||||
|
} finally { |
||||
|
saving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function saveLlm() { |
||||
|
saving.value = true |
||||
|
try { |
||||
|
await webApi('saveechoorch', { app_name: scopeApp.value, llm: llmPick.value }) |
||||
|
success('会议总结已保存') |
||||
|
await loadOrch() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '保存失败') |
||||
|
} finally { |
||||
|
saving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
watch(scopeApp, () => { loadOrch() }) |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
await loadScopeApps() |
||||
|
await loadOrch() |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); } |
||||
|
|
||||
|
/* 上半区两栏:左转写、右总结。两块内容长短不一,用 stretch 让卡片等高,视觉上是一排。 |
||||
|
1100px 以下叠成一列——两栏挤在窄屏上会让服务名和按钮换行成一团。 */ |
||||
|
.topgrid { display:grid; grid-template-columns:1fr 1fr; gap:16px; align-items:stretch; } |
||||
|
@media (max-width: 1100px) { .topgrid { grid-template-columns:1fr; } } |
||||
|
/* 两块内容长短不一,保存条用 margin-top:auto 压到卡片底部,左右才对得齐 */ |
||||
|
.topgrid > .panel { display:flex; flex-direction:column; } |
||||
|
.topgrid .savebar { margin-top:auto; } |
||||
|
|
||||
|
/* 会议模板占满下方剩余空间,超出就整页往下滚(不给它自己开滚动条: |
||||
|
模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空) */ |
||||
|
.tplsec { margin-top:20px; } |
||||
|
.tplsec-title { font-size:15px; font-weight:600; color:#1e293b; margin-bottom:12px; } |
||||
|
|
||||
|
.picklist { display:flex; flex-direction:column; gap:8px; } |
||||
|
.pick { |
||||
|
display:flex; align-items:center; gap:12px; padding:11px 13px; |
||||
|
border:1px solid #eef2f7; border-radius:12px; background:#fff; |
||||
|
} |
||||
|
.rank { |
||||
|
flex-shrink:0; font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px; |
||||
|
background:#f1f5f9; color:#64748b; white-space:nowrap; |
||||
|
} |
||||
|
.rank.main { background:#e0e7ff; color:#4338ca; } |
||||
|
.pick-name { font-size:13.5px; font-weight:600; color:#1e293b; display:flex; align-items:center; gap:7px; flex-wrap:wrap; } |
||||
|
.pick-id { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-weight:400; } |
||||
|
.pick-sub { font-size:11.5px; color:#94a3b8; margin-top:2px; } |
||||
|
|
||||
|
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; } |
||||
|
.chip-warn { background:#fef3c7; color:#b45309; } |
||||
|
|
||||
|
.empty { font-size:12.5px; color:#94a3b8; background:#f8fafc; border:1px dashed #e2e8f0; border-radius:12px; padding:18px; } |
||||
|
.link { color:#4338ca; text-decoration:underline; } |
||||
|
|
||||
|
.savebar { display:flex; align-items:center; gap:10px; margin-top:20px; padding-top:16px; border-top:1px solid #f1f5f9; } |
||||
|
</style> |
||||
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 846 KiB After Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 32 KiB |
@ -0,0 +1,58 @@ |
|||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
/// 语言选择弹窗的「最近使用」排序。 |
||||
|
/// |
||||
|
/// 语言表本身有几十项(面对面/同传/音视频共用 LanguageManager 那份), |
||||
|
/// 每次都从头翻很慢,所以用过的往上提,最顶的是最近一次选的。 |
||||
|
/// |
||||
|
/// ⚠️ 源/目标**分开记**:同一个人的源语言常年是母语、目标语言才是变的那个, |
||||
|
/// 合成一份会让刚选的目标语言跑到源语言列表顶上,越用越乱。 |
||||
|
class RecentLanguages { |
||||
|
RecentLanguages._(); |
||||
|
|
||||
|
/// 每侧最多记几个。再多就没有「置顶」的意义了,整张表都成了最近使用。 |
||||
|
static const int maxKeep = 8; |
||||
|
|
||||
|
static const String _sourceKey = 'recent_languages_source'; |
||||
|
static const String _targetKey = 'recent_languages_target'; |
||||
|
|
||||
|
static String _keyOf(bool isSource) => isSource ? _sourceKey : _targetKey; |
||||
|
|
||||
|
static final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
/// 读最近使用列表(最新的在前)。存储里可能是任意类型,逐项转字符串兜底。 |
||||
|
static List<String> read(bool isSource) { |
||||
|
final raw = _storage.read(_keyOf(isSource)); |
||||
|
if (raw is! List) return <String>[]; |
||||
|
return raw |
||||
|
.map((e) => e?.toString() ?? '') |
||||
|
.where((e) => e.isNotEmpty) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
/// 记一次使用:已存在就提到最前,不重复占位。 |
||||
|
static void touch(String language, {required bool isSource}) { |
||||
|
if (language.isEmpty) return; |
||||
|
final list = read(isSource)..remove(language); |
||||
|
list.insert(0, language); |
||||
|
if (list.length > maxKeep) list.removeRange(maxKeep, list.length); |
||||
|
_storage.write(_keyOf(isSource), list); |
||||
|
} |
||||
|
|
||||
|
/// 按最近使用重排:用过的按使用顺序排在前面,其余保持原有顺序。 |
||||
|
/// |
||||
|
/// 只提「本次列表里确实有」的语言 —— 各模块传进来的语言表不一样 |
||||
|
/// (语音翻译走 LanguageManager、图片翻译另有一份), |
||||
|
/// 直接把记录拼上去会冒出当前模式根本不支持的语言。 |
||||
|
static List<String> sort(List<String> languages, {required bool isSource}) { |
||||
|
final recent = read(isSource); |
||||
|
if (recent.isEmpty) return languages; |
||||
|
|
||||
|
final remaining = List<String>.from(languages); |
||||
|
final ordered = <String>[]; |
||||
|
for (final name in recent) { |
||||
|
if (remaining.remove(name)) ordered.add(name); |
||||
|
} |
||||
|
return [...ordered, ...remaining]; |
||||
|
} |
||||
|
} |
||||
@ -1,193 +1,133 @@ |
|||||
import 'dart:convert'; |
import 'dart:io'; |
||||
|
|
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import 'package:get_storage/get_storage.dart'; |
|
||||
|
|
||||
import '../../data/services/db/database_helper.dart'; |
import '../../data/services/db/database_helper.dart'; |
||||
import '../../data/services/db/sqflite_api.dart'; |
|
||||
import '../../data/services/meeting/meeting_task_service.dart'; |
import '../../data/services/meeting/meeting_task_service.dart'; |
||||
|
import '../../data/services/meeting/meeting_upload_service.dart'; |
||||
import '../../data/services/network/api.dart'; |
import '../../data/services/network/api.dart'; |
||||
|
import 'logger.dart'; |
||||
|
|
||||
|
/// 语音纪要列表与服务端对齐。 |
||||
|
/// |
||||
|
/// 模型:**服务端是唯一真源,本地 sqlite 只是缓存。** |
||||
|
/// - `echomeet_getallrecords` 只回骨架(title/rtype/state/seconds/audiourl/creationtime), |
||||
|
/// 每条 ~250 字节,上千条也是一次拉完,所以没有分页、没有增量、没有软删除; |
||||
|
/// - 转写(`meetingspeaker`) 与总结(`summary/overview/personnel`) **不在这里写**, |
||||
|
/// 详情页进页面时 `echomeet_getrecord` 拉整条再落库(见 MeetingDetailsController); |
||||
|
/// - 本地有、服务端没有的记录 = 在别的设备上删了,这里跟着删。 |
||||
|
/// 本地行必然带服务端 id(归档是先 `addrecord` 拿到 id 才 `insertMeeting`),不会误删归档中的。 |
||||
|
/// |
||||
|
/// 原先这个类还带一套「离线操作队列 → echomeet_putrecords」的上行逻辑,服务端那个接口整个 |
||||
|
/// 注释掉了、客户端上传段也注释掉了,是死代码,2026-09-18 一并删除。 |
||||
class Synchrodata { |
class Synchrodata { |
||||
static final GetStorage _storage = GetStorage(); |
static const String _tag = 'Synchrodata'; |
||||
|
static bool _running = false; |
||||
|
|
||||
static List<Map<String, dynamic>> _records = |
/// 拉一次骨架并对齐本地库。并发调用直接返回;失败只记日志(列表照用本地缓存)。 |
||||
(_storage.read("operation_records") as List?) |
static Future<void> sync() async { |
||||
?.map((e) => Map<String, dynamic>.from(e as Map)) |
if (_running) return; |
||||
.toList() ?? |
_running = true; |
||||
[]; |
try { |
||||
static List _delids = _storage.read("operation_delids") ?? []; |
await _sync(); |
||||
static bool _isSynchro = false; |
} catch (e, st) { |
||||
|
Logger.w(_tag, '列表同步失败(沿用本地缓存): $e\n$st'); |
||||
// 保存操作记录 |
} finally { |
||||
static Future saveOperationRecord( |
_running = false; |
||||
String method, |
|
||||
dynamic params, |
|
||||
) async { |
|
||||
String userId = ''; |
|
||||
Map? userInfo = _storage.read("user_info"); |
|
||||
if (userInfo != null) { |
|
||||
userId = userInfo['user']['uid']; |
|
||||
} |
|
||||
String id = '${userId}_${params['id']}'; |
|
||||
if (params['type'] != null) { |
|
||||
params['rtype'] = params['type']; |
|
||||
} |
|
||||
|
|
||||
switch (method) { |
|
||||
case 'insert': |
|
||||
_records.add({ |
|
||||
...Map<String, dynamic>.from(params), |
|
||||
'id': id, |
|
||||
'number': params['id'], |
|
||||
'uid': userId, |
|
||||
}); |
|
||||
_storage.write("operation_records", _records); |
|
||||
break; |
|
||||
case 'update': |
|
||||
Map<String, dynamic>? foundElement = _records.firstWhere( |
|
||||
(element) => element['id'] == id, |
|
||||
orElse: () => {}, |
|
||||
); |
|
||||
if (foundElement.isNotEmpty) { |
|
||||
foundElement.addAll({ |
|
||||
...Map<String, dynamic>.from(params), |
|
||||
'id': id, |
|
||||
'number': params['id'], |
|
||||
}); |
|
||||
} else { |
|
||||
_records.add({ |
|
||||
...Map<String, dynamic>.from(params), |
|
||||
'id': id, |
|
||||
'number': params['id'], |
|
||||
'uid': userId, |
|
||||
}); |
|
||||
} |
|
||||
_storage.write("operation_records", _records); |
|
||||
break; |
|
||||
case 'delete': |
|
||||
_delids.add(id); |
|
||||
_storage.write("operation_delids", _delids); |
|
||||
break; |
|
||||
default: |
|
||||
break; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 上传操作记录 |
static Future<void> _sync() async { |
||||
static Future uploadOperationRecords({bool isSynchro = false}) async { |
final result = await Api.getOperationAllRecord(); |
||||
if (_isSynchro) return; |
// ⚠️ 业务错误时 AuthInterceptor 把 data 置 null 后 resolve(不抛),必须判空—— |
||||
_isSynchro = true; |
// 把 null 当「服务端没有记录」会把本地整个清空。 |
||||
// if (_records.isNotEmpty || _delids.isNotEmpty) { |
if (result == null) { |
||||
// // 获取需要修改的全部数据 |
Logger.w(_tag, '列表接口返回业务错误,本轮不对齐'); |
||||
// List putRecords = []; |
return; |
||||
// for (var element in _records) { |
|
||||
// Map result = {'details': {}}; |
|
||||
// List speakerList = []; |
|
||||
// if (!_delids.contains(element['id'])) { |
|
||||
// result = await SqfliteApi.getMeetingDetails(element['number']); |
|
||||
// speakerList = await SqfliteApi.getMeetingSpeaker(element['number']); |
|
||||
// } |
|
||||
// putRecords.add({ |
|
||||
// ...Map<String, dynamic>.from(result), |
|
||||
// ...Map<String, dynamic>.from(result['details']), |
|
||||
// 'rtype': result['type'], |
|
||||
// 'original': jsonEncode(speakerList), |
|
||||
// ...Map<String, dynamic>.from(element), |
|
||||
// }); |
|
||||
// } |
|
||||
// var result = await Api.putOperationRecord({ |
|
||||
// 'delids': _delids, |
|
||||
// 'records': putRecords, |
|
||||
// }); |
|
||||
// if (result != null) { |
|
||||
// _records = []; |
|
||||
// _delids = []; |
|
||||
// _storage.write("operation_records", _records); |
|
||||
// _storage.write("operation_delids", _delids); |
|
||||
// } |
|
||||
// } |
|
||||
if (isSynchro) { |
|
||||
await _synchrodata(); |
|
||||
} |
} |
||||
_isSynchro = false; |
final List records = result['records'] ?? []; |
||||
} |
final serverIds = <int>{}; |
||||
|
final taskList = <Map<String, Object?>>[]; |
||||
|
|
||||
// 同步数据 |
|
||||
static Future _synchrodata() async { |
|
||||
var result = await Api.getOperationAllRecord(); |
|
||||
List records = result['records'] ?? []; |
|
||||
if (records.isEmpty) return; |
|
||||
final meetingTaskService = Get.find<MeetingTaskService>(); |
|
||||
List taskList = []; |
|
||||
final db = await DatabaseHelper().database; |
final db = await DatabaseHelper().database; |
||||
await db.transaction((txn) async { |
await db.transaction((txn) async { |
||||
for (var element in records) { |
for (final element in records) { |
||||
if (element['state'] > 0 && element['state'] < 5) { |
final int id = element['id']; |
||||
taskList.add({ |
final int state = element['state'] ?? 0; |
||||
'id': element['id'], |
serverIds.add(id); |
||||
'tasktype': element['state'], |
if (state > 0 && state < 5) { |
||||
}); |
taskList.add({'id': id, 'tasktype': state}); |
||||
} |
} |
||||
final List existList = await txn.query( |
final meeting = <String, Object?>{ |
||||
'meeting', |
'id': id, |
||||
where: 'id = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
Map<String, Object?> meetingData = { |
|
||||
'id': element['id'], |
|
||||
'title': element['title'] ?? '', |
'title': element['title'] ?? '', |
||||
'type': element['rtype'] ?? '', |
'type': element['rtype'] ?? '', |
||||
'seconds': element['seconds'] ?? 0, |
'seconds': element['seconds'] ?? 0, |
||||
'audiourl': element['audiourl'] ?? '', |
'audiourl': element['audiourl'] ?? '', |
||||
'tasktype': element['state'] ?? 0, |
'tasktype': state, |
||||
|
// 服务端秒,本地毫秒 |
||||
'creationtime': (element['creationtime'] ?? 0) * 1000, |
'creationtime': (element['creationtime'] ?? 0) * 1000, |
||||
}; |
}; |
||||
Map<String, Object?> meetingDetailsData = { |
final exists = await txn.query('meeting', |
||||
'meetingid': element['id'], |
columns: ['id'], where: 'id = ?', whereArgs: [id], limit: 1); |
||||
'address': element['address'] ?? '', |
if (exists.isNotEmpty) { |
||||
'personnel': element['personnel'] ?? '', |
// 不碰 filepath(本机录音的路径只有本机知道) |
||||
'taskid': element['taskid'] ?? '', |
await txn.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); |
||||
'tasktype': element['state'] ?? 0, |
// 状态在两张表各存一份,以 meeting 表为准,details 跟着走(详情页读的是这份) |
||||
'overview': element['overview'] ?? '', |
await txn.update('meetingdetails', {'tasktype': state}, |
||||
'summary': element['summary'] ?? '', |
where: 'meetingid = ?', whereArgs: [id]); |
||||
'mindmap': '', |
|
||||
}; |
|
||||
if (existList.isNotEmpty) { |
|
||||
await txn.update( |
|
||||
'meeting', |
|
||||
meetingData, |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
await txn.update( |
|
||||
'meetingdetails', |
|
||||
meetingDetailsData, |
|
||||
where: 'meetingid = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
} else { |
} else { |
||||
await txn.insert('meeting', { |
await txn.insert('meeting', {...meeting, 'filepath': ''}); |
||||
...meetingData, |
await txn.insert('meetingdetails', { |
||||
'filepath': '', |
'meetingid': id, |
||||
|
'address': element['address'] ?? '', |
||||
|
'personnel': element['personnel'] ?? '', |
||||
|
'taskid': element['taskid'] ?? '', |
||||
|
'tasktype': state, |
||||
|
'overview': '', |
||||
|
'summary': '', |
||||
|
'mindmap': '', |
||||
}); |
}); |
||||
await txn.insert('meetingdetails', meetingDetailsData); |
|
||||
} |
|
||||
if (element['translate'].isNotEmpty) { |
|
||||
List speakerList = jsonDecode(element['translate']); |
|
||||
await txn.delete( |
|
||||
'meetingspeaker', |
|
||||
where: 'meetingid = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
final batch = txn.batch(); |
|
||||
for (var item in speakerList) { |
|
||||
batch.insert('meetingspeaker', item); |
|
||||
} |
|
||||
await batch.commit(); |
|
||||
} |
} |
||||
} |
} |
||||
}); |
}); |
||||
meetingTaskService.startTask(taskList); |
|
||||
|
await _removeLocalOrphans(serverIds); |
||||
|
|
||||
|
if (taskList.isNotEmpty && Get.isRegistered<MeetingTaskService>()) { |
||||
|
Get.find<MeetingTaskService>().startTask(taskList); |
||||
|
} |
||||
|
Logger.i(_tag, '列表已对齐:服务端 ${records.length} 条,进行中 ${taskList.length} 条'); |
||||
|
} |
||||
|
|
||||
|
/// 删掉「本地有、服务端没有」的记录:连本地音频、上传队列、轮询任务一起清。 |
||||
|
static Future<void> _removeLocalOrphans(Set<int> serverIds) async { |
||||
|
final db = await DatabaseHelper().database; |
||||
|
final local = await db.query('meeting', columns: ['id', 'filepath']); |
||||
|
final orphans = local.where((r) => !serverIds.contains(r['id'] as int)); |
||||
|
for (final row in orphans) { |
||||
|
final int id = row['id'] as int; |
||||
|
final String path = (row['filepath'] as String?) ?? ''; |
||||
|
await db.transaction((txn) async { |
||||
|
await txn.delete('meeting', where: 'id = ?', whereArgs: [id]); |
||||
|
await txn.delete('meetingdetails', where: 'meetingid = ?', whereArgs: [id]); |
||||
|
await txn.delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]); |
||||
|
}); |
||||
|
if (path.isNotEmpty) { |
||||
|
try { |
||||
|
final f = File(path); |
||||
|
if (await f.exists()) await f.delete(); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '删除孤儿录音文件失败 $path: $e'); |
||||
|
} |
||||
|
} |
||||
|
if (Get.isRegistered<MeetingUploadService>()) { |
||||
|
Get.find<MeetingUploadService>().removeUpload(id, isDeleteTask: true); |
||||
|
} |
||||
|
if (Get.isRegistered<MeetingTaskService>()) { |
||||
|
Get.find<MeetingTaskService>().removeLocalTask(id); |
||||
|
} |
||||
|
Logger.i(_tag, '服务端已不存在,删除本地记录 id=$id'); |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,324 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:convert'; |
||||
|
import 'dart:io' show Platform; |
||||
|
|
||||
|
import 'package:flutter/foundation.dart' show kIsWeb; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
|
||||
|
import '../../core/utils/logger.dart'; |
||||
|
import '../models/assistant_directive.dart'; |
||||
|
import 'bailian_multimodal_service.dart'; |
||||
|
|
||||
|
/// 耳机唤醒那条 EMAI 链路的**原生**百炼客户端句柄。 |
||||
|
/// |
||||
|
/// 真正的 WebSocket 与音频收发在 `azure_speech` 插件里 |
||||
|
/// (`BailianDialogClient.swift` / `.kt`);上行 PCM 由 `bluetooth_manager` 直接 |
||||
|
/// 交过去,合成音频原路回耳机,**一个字节都不经过 Dart**。这里只做三件事: |
||||
|
/// 起停、把原生事件翻译成 [BailianEvent]、把门控开关推下去。 |
||||
|
/// |
||||
|
/// ## 为什么保留 Dart 版 |
||||
|
/// [BailianMultimodalService] 仍然服务两种情况: |
||||
|
/// 1. **手机麦那条链路**(EMAI 页按住说话)——音频本来就在 Dart 侧采集,没有下沉的意义; |
||||
|
/// 2. **退路**——插件是老版本(`MissingPluginException`)、或设备不是恒玄(AI PCM 桥 |
||||
|
/// 只存在于 bluetooth_manager)时,整条链路退回 Dart,功能不受影响。 |
||||
|
/// |
||||
|
/// ## ⚠️ 这不修「锁屏断连」 |
||||
|
/// iOS 挂起的是整个进程,原生和 Dart 一起停。锁屏能不能活着取决于 |
||||
|
/// `UIBackgroundModes=audio` + 确实有音频在播(`BackgroundKeepalive` 干的事), |
||||
|
/// 与代码在哪一层无关。本类解决的是 event loop 抖动与跨界开销。 |
||||
|
class BailianNativeDialog { |
||||
|
BailianNativeDialog({String? appId, String? workspaceId}) |
||||
|
: _appIdOverride = appId, |
||||
|
_workspaceIdOverride = workspaceId; |
||||
|
|
||||
|
static const String _tag = 'BailianNative'; |
||||
|
|
||||
|
static const MethodChannel _channel = |
||||
|
MethodChannel('azure_speech/bailian_dialog'); |
||||
|
static const EventChannel _events = |
||||
|
EventChannel('azure_speech/bailian_dialog_events'); |
||||
|
|
||||
|
final String? _appIdOverride; |
||||
|
final String? _workspaceIdOverride; |
||||
|
|
||||
|
final _eventCtl = StreamController<BailianEvent>.broadcast(); |
||||
|
final _downBytesCtl = StreamController<int>.broadcast(); |
||||
|
final _micActivityCtl = StreamController<void>.broadcast(); |
||||
|
|
||||
|
StreamSubscription? _sub; |
||||
|
bool _running = false; |
||||
|
|
||||
|
/// 本次会话解出来的指令记到哪个来源名下(与 Dart 版同义) |
||||
|
String directiveSource = 'earphone'; |
||||
|
|
||||
|
/// 业务事件流。与 [BailianMultimodalService.events] 同一套 [BailianEvent], |
||||
|
/// 所以 `DeviceAiSessionService` 的事件处理一行都不用改。 |
||||
|
Stream<BailianEvent> get events => _eventCtl.stream; |
||||
|
|
||||
|
/// 下行已写入的**累计**字节数(约 100ms 一条)。 |
||||
|
/// |
||||
|
/// 原生直连之后 Dart 拿不到下行 PCM,但它要靠「首包到达」开门控、靠累计字节数 |
||||
|
/// 估算播完时刻。这两件事都只依赖长度,所以只报数字。 |
||||
|
Stream<int> get downstreamBytes => _downBytesCtl.stream; |
||||
|
|
||||
|
/// 设备上行有声(原生按 500ms 节流上报)。 |
||||
|
/// |
||||
|
/// ⚠️ **不能把它当成 `speechStarted`**:那个事件在会话服务里会丢弃待播队列、 |
||||
|
/// 取消播完计时(「真人插话」的语义)。这里只是给空闲计时续命, |
||||
|
/// 混在一起会让 AI 每说一句就被自己的回声打断一次。 |
||||
|
Stream<void> get micActivity => _micActivityCtl.stream; |
||||
|
|
||||
|
bool get isRunning => _running; |
||||
|
|
||||
|
bool get isConfigured => |
||||
|
BailianMultimodalService.resolveApiKey().trim().isNotEmpty; |
||||
|
|
||||
|
String get configHint => isConfigured ? '' : '未配置 ALIBABA_OPENSPEECH_APP_KEY'; |
||||
|
|
||||
|
/// 这台机器/这个包能不能用原生链路。 |
||||
|
/// |
||||
|
/// 老包没有这个方法通道 → `MissingPluginException` → 返回 false,调用方退回 Dart。 |
||||
|
/// Web 直接排除(压根没有原生插件)。 |
||||
|
static Future<bool> isAvailable() async { |
||||
|
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return false; |
||||
|
try { |
||||
|
await _channel.invokeMethod<Map>('stats'); |
||||
|
return true; |
||||
|
} on MissingPluginException { |
||||
|
return false; |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '原生百炼客户端探测失败(退回 Dart): $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 建立会话。参数与 [BailianMultimodalService.start] 一一对应。 |
||||
|
Future<bool> start({ |
||||
|
String mode = 'duplex', |
||||
|
String? voice, |
||||
|
String? userId, |
||||
|
bool enableWebSearch = true, |
||||
|
int downstreamRate = 16000, |
||||
|
String source = 'earphone', |
||||
|
String? mcpToken, |
||||
|
}) async { |
||||
|
if (!isConfigured) { |
||||
|
Logger.e(_tag, '配置不全,无法启动会话:$configHint'); |
||||
|
_eventCtl.add(BailianEvent(BailianEventType.error, |
||||
|
text: configHint, code: 'NOT_CONFIGURED', fatal: true)); |
||||
|
return false; |
||||
|
} |
||||
|
await stop(); |
||||
|
directiveSource = source; |
||||
|
_sub = _events.receiveBroadcastStream().listen( |
||||
|
_onNativeEvent, |
||||
|
onError: (e) { |
||||
|
Logger.e(_tag, '原生事件流错误: $e'); |
||||
|
_eventCtl.add( |
||||
|
BailianEvent(BailianEventType.error, text: '$e', fatal: true)); |
||||
|
}, |
||||
|
); |
||||
|
try { |
||||
|
final ok = await _channel.invokeMethod<bool>('start', { |
||||
|
'apiKey': BailianMultimodalService.resolveApiKey(), |
||||
|
'appId': BailianMultimodalService.resolveAppId(_appIdOverride), |
||||
|
'workspaceId': |
||||
|
BailianMultimodalService.resolveWorkspaceId(_workspaceIdOverride), |
||||
|
'mode': mode, |
||||
|
if (voice != null && voice.trim().isNotEmpty) 'voice': voice, |
||||
|
'userId': userId ?? '', |
||||
|
'enableWebSearch': enableWebSearch, |
||||
|
'downstreamRate': downstreamRate, |
||||
|
if (mcpToken != null && mcpToken.trim().isNotEmpty) 'mcpToken': mcpToken, |
||||
|
// 工具名只在 Dart 维护一份:服务端加工具时改那一处就够了 |
||||
|
'mcpAuthTools': BailianMultimodalService.mcpAuthTools, |
||||
|
}); |
||||
|
_running = ok == true; |
||||
|
if (!_running) await _cancelSub(); |
||||
|
return _running; |
||||
|
} on MissingPluginException { |
||||
|
Logger.w(_tag, '原生不支持,退回 Dart 链路'); |
||||
|
await _cancelSub(); |
||||
|
return false; |
||||
|
} catch (e) { |
||||
|
Logger.e(_tag, '启动原生会话失败: $e'); |
||||
|
await _cancelSub(); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> stop() async { |
||||
|
if (!_running) { |
||||
|
await _cancelSub(); |
||||
|
return; |
||||
|
} |
||||
|
_running = false; |
||||
|
try { |
||||
|
await _channel.invokeMethod<void>('stop'); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '停止原生会话失败: $e'); |
||||
|
} |
||||
|
await _cancelSub(); |
||||
|
} |
||||
|
|
||||
|
/// AI 是否正在耳机里播报。播报期间原生把上行换成等长静音。 |
||||
|
/// |
||||
|
/// **什么时候开关门由 Dart 决定**(`DeviceAiSessionService` 里那套带兜底计时、 |
||||
|
/// 非致命错误也要关门的逻辑),这里只是把结论推下去。 |
||||
|
Future<void> setGated(bool gated) => _invoke('setGated', {'gated': gated}); |
||||
|
|
||||
|
Future<void> notifyLocalRespondingStarted() => |
||||
|
_invoke('localRespondingStarted'); |
||||
|
|
||||
|
Future<void> notifyLocalRespondingEnded() => _invoke('localRespondingEnded'); |
||||
|
|
||||
|
Future<void> interrupt() => _invoke('interrupt'); |
||||
|
|
||||
|
Future<void> beginSpeech() => _invoke('beginSpeech'); |
||||
|
|
||||
|
Future<void> endSpeech() => _invoke('endSpeech'); |
||||
|
|
||||
|
Future<void> cancelSpeech() => _invoke('cancelSpeech'); |
||||
|
|
||||
|
Future<void> sendText(String text, {bool speakDirectly = false}) => |
||||
|
_invoke('sendText', {'text': text, 'speakDirectly': speakDirectly}); |
||||
|
|
||||
|
/// 诊断:上/下行字节、出包、丢包、门控状态。桥开着却没声音时先问它。 |
||||
|
Future<Map<String, dynamic>> stats() async { |
||||
|
try { |
||||
|
final raw = await _channel.invokeMethod<Map>('stats'); |
||||
|
return raw == null ? const {} : Map<String, dynamic>.from(raw); |
||||
|
} catch (e) { |
||||
|
return {'error': '$e'}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _invoke(String method, [Map<String, dynamic>? args]) async { |
||||
|
if (!_running) return; |
||||
|
try { |
||||
|
await _channel.invokeMethod<void>(method, args); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '$method 失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _cancelSub() async { |
||||
|
await _sub?.cancel(); |
||||
|
_sub = null; |
||||
|
} |
||||
|
|
||||
|
// ---------- 事件翻译 ---------- |
||||
|
|
||||
|
void _onNativeEvent(dynamic raw) { |
||||
|
if (raw is! Map) return; |
||||
|
final name = raw['event']?.toString() ?? ''; |
||||
|
final text = raw['text']?.toString() ?? ''; |
||||
|
final isFinal = raw['isFinal'] == true; |
||||
|
|
||||
|
switch (name) { |
||||
|
case 'started': |
||||
|
_emit(const BailianEvent(BailianEventType.started)); |
||||
|
break; |
||||
|
case 'stateChanged': |
||||
|
final s = raw['state']?.toString() ?? ''; |
||||
|
_emit(BailianEvent(BailianEventType.stateChanged, state: _mapState(s))); |
||||
|
break; |
||||
|
case 'speechStarted': |
||||
|
_emit(const BailianEvent(BailianEventType.speechStarted)); |
||||
|
break; |
||||
|
case 'speechContent': |
||||
|
_emit(BailianEvent(BailianEventType.speechContent, |
||||
|
text: text, isFinal: isFinal)); |
||||
|
break; |
||||
|
case 'speechEnded': |
||||
|
_emit(BailianEvent(BailianEventType.speechEnded, |
||||
|
text: text, isFinal: true)); |
||||
|
break; |
||||
|
case 'requestAccepted': |
||||
|
_emit(const BailianEvent(BailianEventType.requestAccepted)); |
||||
|
break; |
||||
|
case 'respondingStarted': |
||||
|
_emit(const BailianEvent(BailianEventType.respondingStarted)); |
||||
|
break; |
||||
|
case 'respondingContent': |
||||
|
_emit(BailianEvent( |
||||
|
BailianEventType.respondingContent, |
||||
|
text: text, |
||||
|
isFinal: isFinal, |
||||
|
finishReason: raw['finishReason']?.toString() ?? '', |
||||
|
directives: _parseDirectives(raw['extraInfo']?.toString(), text), |
||||
|
)); |
||||
|
break; |
||||
|
case 'respondingEnded': |
||||
|
_emit(BailianEvent(BailianEventType.respondingEnded, |
||||
|
text: text, isFinal: true)); |
||||
|
break; |
||||
|
case 'error': |
||||
|
_emit(BailianEvent(BailianEventType.error, |
||||
|
text: text, |
||||
|
code: raw['code']?.toString(), |
||||
|
fatal: raw['fatal'] == true)); |
||||
|
break; |
||||
|
case 'closed': |
||||
|
_running = false; |
||||
|
_emit(const BailianEvent(BailianEventType.closed)); |
||||
|
break; |
||||
|
case 'downlink': |
||||
|
final n = (raw['bytes'] as num?)?.toInt() ?? 0; |
||||
|
if (!_downBytesCtl.isClosed) _downBytesCtl.add(n); |
||||
|
break; |
||||
|
case 'micActivity': |
||||
|
// 只走自己的流,不冒充任何协议事件(见 micActivity 的说明) |
||||
|
if (!_micActivityCtl.isClosed) _micActivityCtl.add(null); |
||||
|
break; |
||||
|
case 'overflow': |
||||
|
// 上行拥塞丢包。warn 级:这是「说了没反应」时唯一的证据 |
||||
|
Logger.w(_tag, '[AI-BRIDGE] 上行拥塞,原生累计丢包=${raw['dropped']}'); |
||||
|
break; |
||||
|
default: |
||||
|
Logger.d(_tag, '未处理的原生事件: $name'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 指令解析**仍然走 Dart 这一份**:`extra_info.tool_calls` 的那些坑 |
||||
|
/// (参数是字符串套 JSON、值恒为中文、下午时间要校正)有一批真实 payload 的 |
||||
|
/// 金测试守着,原生再写一遍只会让那批测试失去意义。原生把 `extra_info` |
||||
|
/// 原样转成字符串带上来,这里拼回 output 的形状交给同一个解析器。 |
||||
|
List<AssistantDirective> _parseDirectives(String? extraInfoJson, String text) { |
||||
|
if (extraInfoJson == null || extraInfoJson.isEmpty) return const []; |
||||
|
try { |
||||
|
final extra = json.decode(extraInfoJson); |
||||
|
final parsed = AssistantDirective.parseFromOutput( |
||||
|
{'extra_info': extra, 'text': text}, |
||||
|
source: directiveSource, |
||||
|
); |
||||
|
if (parsed.isNotEmpty) { |
||||
|
// warn 级、且只在真的带了指令时打——「闹钟没定上」只有两种可能 |
||||
|
// (百炼没下发 / 端侧没落地),这一行是把两者分开的证据 |
||||
|
Logger.w(_tag, '[指令帧] 原生链路解析出 ${parsed.length} 条'); |
||||
|
} |
||||
|
return parsed; |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, 'extra_info 解析失败: $e'); |
||||
|
return const []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
static BailianDialogState _mapState(String s) => switch (s) { |
||||
|
'listening' => BailianDialogState.listening, |
||||
|
'thinking' => BailianDialogState.thinking, |
||||
|
'responding' => BailianDialogState.responding, |
||||
|
_ => BailianDialogState.idle, |
||||
|
}; |
||||
|
|
||||
|
void _emit(BailianEvent e) { |
||||
|
if (!_eventCtl.isClosed) _eventCtl.add(e); |
||||
|
} |
||||
|
|
||||
|
Future<void> dispose() async { |
||||
|
await stop(); |
||||
|
await _eventCtl.close(); |
||||
|
await _downBytesCtl.close(); |
||||
|
await _micActivityCtl.close(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,86 @@ |
|||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:dio/dio.dart'; |
||||
|
import 'package:encrypt/encrypt.dart' as enc; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
|
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 网关「加密接口」的响应解密。 |
||||
|
/// |
||||
|
/// 服务端(modules/gateway/wservice_comp.go `writeReply`)对声明在 `EncryptMsgs` 里的接口 |
||||
|
/// (`user_getappconfig_v2/v3`、`user_getthirdsvcs_v2`、`user_getagents_v2/v3`)把**整个响应体** |
||||
|
/// 做 AES-CBC 加密:key = 网关的 `GATEWAY_ENCRYPT_KEY`(16/24/32 字节),IV 固定 16 个 `'0'`, |
||||
|
/// PKCS5/7 填充,密文 base64;`Content-Type` 改成 `text/plain`,并加响应头 `X-Encrypted: 1`。 |
||||
|
/// 这些响应带着第三方服务的**明文凭据**,所以整体加密后才出网关。 |
||||
|
/// |
||||
|
/// 密钥来自客户端 `.env` 的 `GATEWAY_ENCRYPT_KEY`,须与服务器一致。没配时 [enabled] 为 false, |
||||
|
/// `Api.getappconfig` 会回退到明文 v1——凭据只能走 env 兼容层,`thirdsvcs` 拿不到。 |
||||
|
class ApiCrypto { |
||||
|
ApiCrypto._(); |
||||
|
|
||||
|
static const String _iv = '0000000000000000'; |
||||
|
|
||||
|
static String get _key => (dotenv.env['GATEWAY_ENCRYPT_KEY'] ?? '').trim(); |
||||
|
|
||||
|
/// 密钥是否配置且长度合法(AES 只接受 16/24/32 字节)。 |
||||
|
static bool get enabled { |
||||
|
final k = _key; |
||||
|
return k.length == 16 || k.length == 24 || k.length == 32; |
||||
|
} |
||||
|
|
||||
|
/// 解 base64(AES-CBC-PKCS7) 密文为明文字符串。密钥不合法或密文损坏时抛异常,由调用方决定退路。 |
||||
|
static String decrypt(String cipherBase64) { |
||||
|
final key = enc.Key.fromUtf8(_key); |
||||
|
final iv = enc.IV.fromUtf8(_iv); |
||||
|
final aes = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc, padding: 'PKCS7')); |
||||
|
return aes.decrypt64(cipherBase64.trim(), iv: iv); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Dio 拦截器:把 `X-Encrypted: 1` 的响应体解密并 JSON 解析,再交给后面的 [AuthInterceptor] |
||||
|
/// 按业务 JSON 处理。**必须排在 AuthInterceptor 之前**——它一看到不是 JSON 的 body 就当解析失败了。 |
||||
|
/// |
||||
|
/// 密文的 Content-Type 是 text/plain,Dio 的 `ResponseType.json` 不会去解析它,这里拿到的就是 String。 |
||||
|
class ApiDecryptInterceptor extends Interceptor { |
||||
|
static const String _tag = 'ApiCrypto'; |
||||
|
|
||||
|
@override |
||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) { |
||||
|
final flag = response.headers.value('x-encrypted'); |
||||
|
if (flag != '1') { |
||||
|
handler.next(response); |
||||
|
return; |
||||
|
} |
||||
|
final body = response.data; |
||||
|
if (body is! String || body.isEmpty) { |
||||
|
handler.next(response); |
||||
|
return; |
||||
|
} |
||||
|
if (!ApiCrypto.enabled) { |
||||
|
// 服务端加密了、客户端却没配密钥:这不是网络问题,得让人一眼看到。 |
||||
|
Logger.e(_tag, '响应已加密但 .env 未配置 GATEWAY_ENCRYPT_KEY:${response.requestOptions.path}'); |
||||
|
handler.reject(DioException( |
||||
|
requestOptions: response.requestOptions, |
||||
|
response: response, |
||||
|
type: DioExceptionType.badResponse, |
||||
|
error: '响应已加密但客户端未配置 GATEWAY_ENCRYPT_KEY', |
||||
|
)); |
||||
|
return; |
||||
|
} |
||||
|
try { |
||||
|
final plain = ApiCrypto.decrypt(body); |
||||
|
response.data = jsonDecode(plain); |
||||
|
handler.next(response); |
||||
|
} catch (e) { |
||||
|
// 密钥不一致时 AES 解出来是乱码、PKCS7 去填充就会失败——报错信息要指向密钥,别让人去查网络。 |
||||
|
Logger.e(_tag, '解密失败(多半是 GATEWAY_ENCRYPT_KEY 与服务器不一致):${response.requestOptions.path} $e'); |
||||
|
handler.reject(DioException( |
||||
|
requestOptions: response.requestOptions, |
||||
|
response: response, |
||||
|
type: DioExceptionType.badResponse, |
||||
|
error: '响应解密失败,请检查 GATEWAY_ENCRYPT_KEY 是否与服务器一致', |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||