Browse Source
这一提交里有两条相对独立的线,落在同一批文件上(server.go / core.go / model.go /
CLAUDE.md 等被两边同时改到),按文件拆不开,故合并提交。
── 一、第三方凭据归一到 svc_config,后台「服务配置」从 11 项收敛到 6 项 ──
起因:凭据同时躺在两张扁平 key/value 表里——应用业务库的 config 与 console 库的
global_config,而合并下发时**全局那张赢**。同一个 AZURE_SPEECH_REGION /
OPENAI_API_KEY / VOLC_OPENSPEECH_APP_ID / XUNFEI 密钥在两边值不一样,运营在
「应用环境配置」里改半天没有任何效果,且不报错。global_config 还按区域整份复制
(14 区 × 31 键 = 473 行),实测其中 27 个键是各区同值。
- comm.SvcField 新增 EnvKey(后台字段表里的「下发键名」列):非空时该字段的明文值
同时以这个名字出现在 user_getappconfig 的 env 里。已发布的客户端只读 env,
靠它继续供货、无需发新包;新客户端直接读结构化的 thirdsvcs。
- env 覆盖层与 thirdsvcs 走同一份 VIP 过滤,否则 VIP 过期的用户虽拿不到 AST 条目,
却仍能从 env 里读出同一套通话翻译凭据,那道闸门就形同虚设。
- 同作用域内 env_key 不允许重名(checkSvcEnvKeys 在保存时拦):重名时下发结果取决于
服务 id 排序,表现为「凭据时对时不对」。
- 一次性迁移 migrate_envtosvc.go(console 每次启动跑、幂等):基础值取中国区现值,
只有与基础值不同的区域才写 svc_region_override,473 行收敛到几十行。
顺带修了两行填错列的历史数据(group 里是键名、key 里是值),正是它让
ALIBABA_BAILIAN_APP_ID/WORKSPACE_ID 恒为 null、客户端一直用代码里写死的兜底值。
不删 global_config 的行(不可逆的线上凭据,留作回退依据),确认跑稳后由人手工 DROP。
- 后台:「第三方服务配置」改名「服务与环境配置」,「应用环境配置」并为其第二个标签页;
「全局环境配置」下线只留重定向;Agent 配置、通话翻译配置、会议记录服务三页删除
(前两者对应的 agent_config、call_translate_rule 两表在测试机与正式机上都是 0 行)。
⚠️ 会议记录服务删的只是后台入口,echomeet_orch 的数据与运行时都还在(两台机各 4 行,
是语音纪要识别/翻译/总结的服务选型来源),要换服务商现在只能直接改库。
阿龙测试环境实测:迁移前后 env 逐键比对,48/54 键值完全一致,丢失的 8 个全部是预期
丢弃(ALIBABA_OSS_* 与 COMMENT_* 无人读取、两个填错列的垃圾键名),无一个值发生变化。
验证中发现并修掉四个都不报错的坑:空值不能下发成 env 键(Dart 的 ?? 只兜 null,
空串会把默认值顶掉)、区域覆盖里的空串会把基础值抹成空、服务 enable=false 等于凭据
一个都不下发、启动时的模板补字段会给实例加空的加密字段导致误报「缺凭据」且保存被拒。
── 二、品牌商(identity=3)与公码下架 ──
brand 表、公码 authcode 及其全部后台页面与接口移除;账号作用域不再有 brandId,
设备/结算的归属改由渠道商推导。这部分不是本次会话中由 Claude 编写的。
验证:go build ./... + go vet ./... 通过,comm/console/user/echomeet/api 各包测试全绿;
admin npm run build 通过。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
71 changed files with 2355 additions and 7001 deletions
@ -0,0 +1,264 @@ |
|||
<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> |
|||
File diff suppressed because it is too large
@ -1,310 +1,16 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 列表视图 --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">应用环境配置</h2> |
|||
<p class="text-xs text-slate-400 mt-1">应用自有的 key/value 配置(音乐、OSS 等),直接读写所选应用的业务库。结构同「全局环境配置」但不分区域。</p> |
|||
</div> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="showForm()">+ 新增配置</button> |
|||
</div> |
|||
<div class="flex items-center gap-3 mb-4 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="onAppChange" |
|||
> |
|||
<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> |
|||
<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="showForm(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> |
|||
|
|||
<!-- 表单视图 --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑配置' : '新增配置' }}</h2> |
|||
<span class="text-xs text-slate-400">配置目标:{{ curAppLabel }}</span> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> |
|||
<form @submit.prevent="saveCfg"> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">分组 (group)</span></label> |
|||
<input v-model="form.group" type="text" class="input input-bordered w-full" placeholder="如 music / oss / other" /> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">键 (key) *</span></label> |
|||
<input v-model="form.key" type="text" class="input input-bordered w-full" required placeholder="如 oss_access_key" /> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">值 (value)</span></label> |
|||
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea> |
|||
</div> |
|||
<div class="form-control mb-6"> |
|||
<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="flex gap-3"> |
|||
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> |
|||
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
<div class="py-16 text-center text-slate-400"> |
|||
<span class="loading loading-spinner loading-sm"></span> |
|||
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p> |
|||
</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' |
|||
|
|||
definePageMeta({ title: '应用环境配置' }) |
|||
|
|||
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库) |
|||
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——配置按「应用 × 环境」独立。 |
|||
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number } |
|||
|
|||
// 部署环境(envs/list 同源,这里只用来给部署显示环境名) |
|||
interface EnvOption { id: number; name: string; code: string; enabled: boolean } |
|||
|
|||
// 应用 config 项(结构同 DBGlobalConfigItem 去掉 region) |
|||
interface AppConfig { |
|||
id: number |
|||
group: string |
|||
key: string |
|||
value: string |
|||
description: string |
|||
} |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const apps = ref<Deployment[]>([]) // 全部部署(跨应用、跨环境) |
|||
const envs = ref<EnvOption[]>([]) // 全部环境 |
|||
const curAppName = ref('') // 一级:选中的应用 |
|||
const curApp = ref(0) // 二级:选中的部署 app_id(= 应用 × 环境) |
|||
const items = ref<AppConfig[]>([]) |
|||
const loading = ref(false) |
|||
const submitting = ref(false) |
|||
const isEditing = ref(false) |
|||
const keyword = ref('') |
|||
|
|||
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' }) |
|||
|
|||
// 应用名列表(一级选项):部署记录按 app_name 去重 |
|||
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)}` : '' |
|||
}) |
|||
|
|||
// 关键字过滤(按 group + key) |
|||
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 |
|||
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置), |
|||
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。 |
|||
// 保留本路由做重定向,兼容旧书签/直链。 |
|||
definePageMeta({ title: '服务与环境配置' }) |
|||
onMounted(() => { |
|||
navigateTo('/serviceconfig') |
|||
}) |
|||
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 onAppChange() { |
|||
loadData() |
|||
} |
|||
|
|||
function showList() { |
|||
view.value = 'list' |
|||
loadData() |
|||
} |
|||
|
|||
function showForm(cfg?: AppConfig) { |
|||
if (cfg) { |
|||
isEditing.value = true |
|||
form.id = cfg.id || 0 |
|||
form.group = cfg.group || '' |
|||
form.key = cfg.key || '' |
|||
form.value = cfg.value || '' |
|||
form.description = cfg.description || '' |
|||
} else { |
|||
isEditing.value = false |
|||
form.id = 0 |
|||
form.group = '' |
|||
form.key = '' |
|||
form.value = '' |
|||
form.description = '' |
|||
} |
|||
view.value = 'form' |
|||
} |
|||
|
|||
async function saveCfg() { |
|||
if (!curApp.value) { |
|||
error("请先选择应用和部署环境") |
|||
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 (isEditing.value) { |
|||
data.id = Number(form.id) |
|||
await consoleApi('appcfg/update', data) |
|||
} else { |
|||
await consoleApi('appcfg/add', data) |
|||
} |
|||
success('保存成功') |
|||
showList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
function delCfg(c: AppConfig) { |
|||
if (!window.confirm('确定删除该配置项吗?')) return |
|||
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] }) |
|||
.then(() => { success('删除成功'); loadData() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
onMounted(loadApps) |
|||
</script> |
|||
|
|||
@ -1,622 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 顶部 --> |
|||
<div class="flex items-center justify-between mb-5 flex-wrap gap-3"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">通话翻译规则</h2> |
|||
<p class="text-xs text-slate-400 mt-1"> |
|||
一条规则覆盖「多源语言 × 多目标语言」,命中后走它指定的 Agent。多条规则按优先级从高到低匹配,第一条命中的生效。 |
|||
</p> |
|||
</div> |
|||
<div class="flex items-center gap-3 flex-wrap"> |
|||
<span class="text-sm text-slate-500">作用域</span> |
|||
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll"> |
|||
<option value="">全局默认</option> |
|||
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option> |
|||
</select> |
|||
<span class="text-sm text-slate-500">区域</span> |
|||
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="loadRules"> |
|||
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> |
|||
</select> |
|||
<button class="btn btn-ghost btn-sm" @click="openTest">🔍 选路试算</button> |
|||
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新增规则</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 行内编辑器 --> |
|||
<div v-show="editorOpen" ref="editorRef" class="editor-panel"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<div class="flex items-center gap-3"> |
|||
<h3 class="font-semibold text-slate-800 text-sm">{{ editing ? '编辑规则' : '新增规则' }}</h3> |
|||
<span class="region-chip">{{ scopeApp || '全局默认' }}</span> |
|||
</div> |
|||
<button class="btn btn-ghost btn-xs" @click="closeEditor">✕ 关闭</button> |
|||
</div> |
|||
|
|||
<!-- 1) 匹配条件 --> |
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">匹配条件</div> |
|||
<div class="lang-row"> |
|||
<div class="lang-field"> |
|||
<div class="flex items-center justify-between"> |
|||
<span class="ed-label">源语言</span> |
|||
<label class="any-toggle"> |
|||
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="ed.srcAny" @change="toggleAny('src')" /> |
|||
任意语言 |
|||
</label> |
|||
</div> |
|||
<div v-if="ed.srcAny" class="any-box">任意源语言</div> |
|||
<LangSelect v-else v-model="ed.srcLangs" multiple /> |
|||
</div> |
|||
<div class="dir-col"> |
|||
<button |
|||
type="button" |
|||
class="dir-btn" |
|||
:class="{ on: ed.bidir }" |
|||
:title="ed.bidir ? '点击改为单向' : '点击改为双向(反向也走这条规则)'" |
|||
@click="ed.bidir = !ed.bidir" |
|||
>{{ ed.bidir ? '⇄' : '→' }}</button> |
|||
<span class="dir-tip">{{ ed.bidir ? '双向互译' : '单向翻译' }}</span> |
|||
</div> |
|||
<div class="lang-field"> |
|||
<div class="flex items-center justify-between"> |
|||
<span class="ed-label">目标语言</span> |
|||
<label class="any-toggle"> |
|||
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="ed.dstAny" @change="toggleAny('dst')" /> |
|||
任意语言 |
|||
</label> |
|||
</div> |
|||
<div v-if="ed.dstAny" class="any-box">任意目标语言</div> |
|||
<LangSelect v-else v-model="ed.dstLangs" multiple /> |
|||
</div> |
|||
</div> |
|||
<div class="grid grid-cols-3 gap-3 mt-3"> |
|||
<div> |
|||
<span class="ed-label">生效区域</span> |
|||
<select v-model.number="ed.region" class="select select-bordered select-sm w-full"> |
|||
<option :value="0">全部区域(兜底)</option> |
|||
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> |
|||
</select> |
|||
</div> |
|||
<div> |
|||
<span class="ed-label">优先级(越大越先匹配)</span> |
|||
<input v-model.number="ed.priority" type="number" class="input input-bordered input-sm w-full" /> |
|||
</div> |
|||
<div> |
|||
<span class="ed-label">规则名(便于识别)</span> |
|||
<input v-model="ed.name" class="input input-bordered input-sm w-full" placeholder="如 中日韩端到端" /> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- 2) 命中后走哪个 Agent --> |
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">命中后使用的 Agent</div> |
|||
<div class="grid grid-cols-2 gap-3"> |
|||
<div> |
|||
<span class="ed-label">翻译 Agent <i class="req">*</i></span> |
|||
<select v-model="ed.agentId" class="select select-bordered select-sm w-full"> |
|||
<option value="" disabled>{{ transAgents.length ? '请选择 Agent' : '(无可用 Agent,请先去 Agent 配置里创建)' }}</option> |
|||
<optgroup v-for="g in agentGroups" :key="g.type" :label="g.label"> |
|||
<option v-for="a in g.items" :key="a.id" :value="a.id"> |
|||
{{ a.name || a.id }}{{ a.global ? '(全局兜底)' : '' }}{{ a.enable ? '' : '(已停用)' }} |
|||
</option> |
|||
</optgroup> |
|||
</select> |
|||
</div> |
|||
<div> |
|||
<span class="ed-label">备注</span> |
|||
<input v-model="ed.remark" class="input input-bordered input-sm w-full" placeholder="可留空" /> |
|||
</div> |
|||
</div> |
|||
<div v-if="curAgent" class="agent-preview"> |
|||
<span class="type-badge" :style="typeStyle(curAgent.type)">{{ typeLabel(curAgent.type) }}</span> |
|||
<span class="text-xs text-slate-500">{{ curAgent.description || '(无描述)' }}</span> |
|||
<span v-if="curAgent.support_langs" class="text-[11px] text-slate-400">支持:{{ curAgent.support_langs }}</span> |
|||
<span v-if="!curAgent.enable" class="text-[11px] text-amber-600">⚠ 该 Agent 当前为停用状态</span> |
|||
</div> |
|||
<p v-if="langWarn" class="ed-warn">⚠ {{ langWarn }}</p> |
|||
</section> |
|||
|
|||
<div class="ed-footer"> |
|||
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
|||
<input v-model="ed.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
|||
<span>启用</span> |
|||
<span class="text-xs text-slate-400">停用的规则不参与选路</span> |
|||
</label> |
|||
<div class="flex gap-2"> |
|||
<button class="btn btn-ghost btn-sm" @click="closeEditor">取消</button> |
|||
<button class="btn btn-outline btn-sm" @click="openSyncModal">保存并同步…</button> |
|||
<button class="btn btn-primary btn-sm text-white" @click="saveOnly">保存</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 规则列表:顺序即匹配顺序 --> |
|||
<div v-if="!rules.length" class="text-center py-20 text-slate-300"> |
|||
<div class="text-5xl mb-3">🌐</div> |
|||
<p class="text-sm">当前区域还没有翻译规则</p> |
|||
<p class="text-xs mt-1">建议先加一条「任意 → 任意」的兜底规则,再往上叠精确规则</p> |
|||
</div> |
|||
<div v-else class="rule-list"> |
|||
<div class="rule-head"> |
|||
<span>优先级</span><span>匹配条件</span><span>Agent</span><span>区域</span><span class="text-right">操作</span> |
|||
</div> |
|||
<div v-for="(r, i) in rules" :key="r.id" class="rule-row" :class="{ off: !r.enable }" @click="startEdit(r)"> |
|||
<div class="prio-cell"> |
|||
<span class="prio">{{ r.priority }}</span> |
|||
<span class="order">#{{ i + 1 }}</span> |
|||
</div> |
|||
<div class="cond-cell"> |
|||
<div class="flex items-center gap-2 flex-wrap"> |
|||
<span class="lang-badge" :class="{ any: isAny(r.src_langs) }">{{ langsLabel(r.src_langs) }}</span> |
|||
<span class="text-slate-400 font-bold">{{ r.bidir ? '⇄' : '→' }}</span> |
|||
<span class="lang-badge" :class="{ any: isAny(r.dst_langs) }">{{ langsLabel(r.dst_langs) }}</span> |
|||
</div> |
|||
<div v-if="r.name || r.remark" class="text-[11px] text-slate-400 mt-1">{{ r.name }}<span v-if="r.remark"> · {{ r.remark }}</span></div> |
|||
</div> |
|||
<div class="agent-cell"> |
|||
<span class="type-badge" :style="typeStyle(agentType(r.agent_id))">{{ typeLabel(agentType(r.agent_id)) }}</span> |
|||
<span class="text-xs text-slate-700">{{ agentName(r.agent_id) }}</span> |
|||
</div> |
|||
<div class="text-xs text-slate-500">{{ r.region === 0 ? '全部区域' : regionLabel(r.region) }}</div> |
|||
<div class="text-right"> |
|||
<span v-if="r.enable" class="badge badge-success badge-xs mr-2">启用</span> |
|||
<span v-else class="badge badge-ghost badge-xs mr-2">停用</span> |
|||
<button class="btn btn-ghost btn-xs text-error" @click.stop="delRule(r)">删除</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 选路试算 --> |
|||
<div v-show="testOpen" class="ct-overlay"> |
|||
<div class="ct-box"> |
|||
<h3 class="text-base font-semibold text-slate-800 mb-1">选路试算</h3> |
|||
<p class="text-xs text-slate-400 mb-4">用后端运行时同一套匹配逻辑,算一遍这个方向实际会走哪条规则。</p> |
|||
<div class="grid grid-cols-2 gap-3"> |
|||
<div> |
|||
<span class="ed-label">源语言</span> |
|||
<LangSelect v-model="testSrc" /> |
|||
</div> |
|||
<div> |
|||
<span class="ed-label">目标语言</span> |
|||
<LangSelect v-model="testDst" /> |
|||
</div> |
|||
</div> |
|||
<div v-if="testResult" class="test-result" :class="{ miss: !testResult.matched }"> |
|||
<template v-if="testResult.matched"> |
|||
<div class="text-sm font-semibold text-slate-800 mb-1"> |
|||
命中规则:{{ testResult.rule?.name || '#' + testResult.rule?.id }}(优先级 {{ testResult.rule?.priority }}) |
|||
</div> |
|||
<div class="text-xs text-slate-600"> |
|||
{{ langsLabel(testResult.rule?.src_langs) }} {{ testResult.rule?.bidir ? '⇄' : '→' }} {{ langsLabel(testResult.rule?.dst_langs) }} |
|||
· Agent:{{ testResult.agent?.name || testResult.rule?.agent_id }} |
|||
<span v-if="testResult.agent">({{ typeLabel(testResult.agent.type) }})</span> |
|||
</div> |
|||
</template> |
|||
<template v-else> |
|||
<div class="text-sm text-amber-700">没有任何规则命中 —— 这个方向的通话翻译会不可用,建议补一条兜底规则。</div> |
|||
</template> |
|||
</div> |
|||
<div class="flex gap-3 justify-end mt-5"> |
|||
<button class="btn btn-ghost" @click="testOpen = false">关闭</button> |
|||
<button class="btn btn-primary text-white" @click="runTest">试算</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 同步弹窗 --> |
|||
<div v-show="syncOpen" class="ct-overlay"> |
|||
<div class="ct-box"> |
|||
<h3 class="text-base font-semibold text-slate-800 mb-1">同步到其他区域</h3> |
|||
<p class="text-xs text-slate-400 mb-3">{{ syncDesc }}</p> |
|||
<div class="text-xs px-3 py-2 rounded-lg mb-3" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d"> |
|||
✦ 同步内容:语言条件、Agent、优先级、启停状态一并复制到目标区域。 |
|||
</div> |
|||
<div class="flex items-center justify-between mb-1"> |
|||
<span class="text-xs font-semibold text-slate-500">选择目标区域</span> |
|||
<div class="flex gap-2"> |
|||
<button class="btn btn-ghost btn-xs" @click="syncSelAll(true)">全选</button> |
|||
<button class="btn btn-ghost btn-xs" @click="syncSelAll(false)">清空</button> |
|||
</div> |
|||
</div> |
|||
<div class="sync-region-grid"> |
|||
<label |
|||
v-for="r in syncRegions" |
|||
:key="r.value" |
|||
class="sync-region-item" |
|||
:class="{ checked: syncTargets.includes(r.value) }" |
|||
@click.prevent="toggleSyncR(r.value)" |
|||
> |
|||
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="syncTargets.includes(r.value)" @click.stop="toggleSyncR(r.value)" /> |
|||
<span>{{ r.label }}</span> |
|||
</label> |
|||
</div> |
|||
<p class="text-[11px] text-slate-400 mb-5">⚠ 目标区域已有「同 Agent + 同语言条件」的规则则覆盖,否则新建。</p> |
|||
<div class="flex gap-3 justify-end"> |
|||
<button class="btn btn-ghost" @click="syncOpen = false">取消</button> |
|||
<button class="btn btn-primary text-white" :disabled="!syncTargets.length" @click="doSaveAndSync"> |
|||
保存并同步{{ syncTargets.length ? '(' + syncTargets.length + ' 个区域)' : '' }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted, nextTick } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { langName } from '~/composables/useLangOptions' |
|||
import LangSelect from '~/components/LangSelect.vue' |
|||
|
|||
definePageMeta({ title: '通话翻译规则' }) |
|||
|
|||
// ===== 类型 ===== |
|||
interface Agent { |
|||
id: string |
|||
name: string |
|||
type: string |
|||
description: string |
|||
enable: boolean |
|||
support_langs: string |
|||
global?: boolean // 来自「全局默认」作用域的兜底 agent(当前应用没配同 id 时可引用) |
|||
} |
|||
interface Rule { |
|||
id: number |
|||
app_name: string |
|||
region: number |
|||
name: string |
|||
src_langs: string |
|||
dst_langs: string |
|||
bidir: boolean |
|||
agent_id: string |
|||
priority: number |
|||
enable: boolean |
|||
remark: string |
|||
} |
|||
|
|||
// 语言通配符,与后端 comm.LangAny 一致 |
|||
const LANG_ANY = '*' |
|||
|
|||
const REGIONS = [ |
|||
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' }, |
|||
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' }, |
|||
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' }, |
|||
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' }, |
|||
{ value: 13, label: '巴西' }, { value: 14, label: '印度' }, |
|||
] |
|||
function regionLabel(v: number): string { |
|||
return REGIONS.find((x) => x.value === v)?.label ?? '区域' + v |
|||
} |
|||
|
|||
// 通话翻译只能引用这两类 agent(与后端 callTranslateAgentTypes 一致) |
|||
const TRANS_TYPES: Record<string, { label: string; color: string; bg: string }> = { |
|||
translate: { label: '🧩 三段式翻译', color: '#059669', bg: '#dcfce7' }, |
|||
'ast-translate': { label: '🚀 端到端翻译', color: '#c2410c', bg: '#fff7ed' }, |
|||
} |
|||
function typeLabel(t?: string): string { |
|||
return t && TRANS_TYPES[t] ? TRANS_TYPES[t].label : (t || '未知') |
|||
} |
|||
function typeStyle(t?: string) { |
|||
const s = t ? TRANS_TYPES[t] : undefined |
|||
return s ? { background: s.bg, color: s.color } : { background: '#f1f5f9', color: '#64748b' } |
|||
} |
|||
|
|||
const { webApi, consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
// ===== 状态 ===== |
|||
const scopeApp = ref('') |
|||
const scopeApps = ref<string[]>([]) |
|||
const curRegion = ref<number>(REGIONS[0]!.value) |
|||
const rules = ref<Rule[]>([]) |
|||
const agents = ref<Agent[]>([]) |
|||
|
|||
const editorOpen = ref(false) |
|||
const editing = ref<Rule | null>(null) |
|||
const editorRef = ref<HTMLElement | null>(null) |
|||
const pendingId = ref(0) |
|||
|
|||
const ed = reactive({ |
|||
name: '', |
|||
srcLangs: '', |
|||
dstLangs: '', |
|||
srcAny: false, |
|||
dstAny: false, |
|||
bidir: true, |
|||
region: 0, |
|||
priority: 100, |
|||
agentId: '', |
|||
remark: '', |
|||
enable: true, |
|||
}) |
|||
|
|||
// 只列可用于通话翻译的 agent,按类型分组 |
|||
const transAgents = computed(() => agents.value.filter((a) => a.type in TRANS_TYPES)) |
|||
const agentGroups = computed(() => |
|||
Object.keys(TRANS_TYPES).map((t) => ({ |
|||
type: t, |
|||
label: TRANS_TYPES[t]!.label, |
|||
items: transAgents.value.filter((a) => a.type === t), |
|||
})).filter((g) => g.items.length), |
|||
) |
|||
const curAgent = computed(() => agents.value.find((a) => a.id === ed.agentId) || null) |
|||
|
|||
// 规则选的语言若不在 agent 声明的支持语言里,运行时多半会失败,配置期就提示出来 |
|||
const langWarn = computed(() => { |
|||
const a = curAgent.value |
|||
if (!a || !a.support_langs) return '' |
|||
const sup = a.support_langs.split(',').map((x) => x.trim()).filter(Boolean) |
|||
if (!sup.length) return '' |
|||
const picked = [ |
|||
...(ed.srcAny ? [] : ed.srcLangs.split(',')), |
|||
...(ed.dstAny ? [] : ed.dstLangs.split(',')), |
|||
].map((x) => x.trim()).filter(Boolean) |
|||
const miss = picked.filter((c) => !sup.includes(c)) |
|||
if (!miss.length) return '' |
|||
return `Agent「${a.name || a.id}」未声明支持:${miss.join('、')}` |
|||
}) |
|||
|
|||
function isAny(s?: string): boolean { |
|||
return (s || '').trim() === LANG_ANY |
|||
} |
|||
function langsLabel(s?: string): string { |
|||
if (isAny(s)) return '任意语言' |
|||
const arr = (s || '').split(',').map((x) => x.trim()).filter(Boolean) |
|||
if (!arr.length) return '—' |
|||
if (arr.length <= 3) return arr.map((c) => langName(c)).join('、') |
|||
return arr.slice(0, 3).map((c) => langName(c)).join('、') + ` 等 ${arr.length} 种` |
|||
} |
|||
function agentName(id: string): string { |
|||
return agents.value.find((a) => a.id === id)?.name || id || '未配置' |
|||
} |
|||
function agentType(id: string): string { |
|||
return agents.value.find((a) => a.id === id)?.type || '' |
|||
} |
|||
|
|||
// ===== 加载 ===== |
|||
async function loadScopeApps() { |
|||
try { |
|||
const list = await consoleApi<Array<{ name: string }>>('products/list', {}) |
|||
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean) |
|||
} catch { |
|||
scopeApps.value = [] |
|||
} |
|||
} |
|||
// 选了应用时要连全局默认的 agent 一起拉:规则可以引用全局兜底的翻译 Agent |
|||
// (后端 findAgentForRule 就是「应用作用域优先、未命中回退全局」),同 id 以本应用的为准。 |
|||
async function loadAgents() { |
|||
const app = scopeApp.value |
|||
const [own, global] = await Promise.all([ |
|||
webApi<{ items: Agent[] }>('getagents', { app_name: app }).catch(() => null), |
|||
app ? webApi<{ items: Agent[] }>('getagents', { app_name: '' }).catch(() => null) : Promise.resolve(null), |
|||
]) |
|||
const list = own?.items ?? [] |
|||
const ids = new Set(list.map((a) => a.id)) |
|||
agents.value = [...list, ...(global?.items ?? []).filter((a) => !ids.has(a.id)).map((a) => ({ ...a, global: true }))] |
|||
} |
|||
async function loadRules() { |
|||
try { |
|||
const d = await webApi<{ items: Rule[] }>('getcalltranslaterules', { app_name: scopeApp.value, region: curRegion.value }) |
|||
rules.value = d?.items ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '加载失败') |
|||
rules.value = [] |
|||
} |
|||
} |
|||
async function loadAll() { |
|||
await Promise.all([loadAgents(), loadRules()]) |
|||
} |
|||
|
|||
// ===== 编辑 ===== |
|||
function revealEditor() { |
|||
nextTick(() => editorRef.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })) |
|||
} |
|||
function fillEditor(r: Rule | null) { |
|||
ed.name = r?.name ?? '' |
|||
ed.srcAny = isAny(r?.src_langs) |
|||
ed.dstAny = isAny(r?.dst_langs) |
|||
ed.srcLangs = ed.srcAny ? '' : (r?.src_langs ?? '') |
|||
ed.dstLangs = ed.dstAny ? '' : (r?.dst_langs ?? '') |
|||
ed.bidir = r ? !!r.bidir : true |
|||
ed.region = r ? r.region : curRegion.value |
|||
ed.priority = r?.priority ?? 100 |
|||
ed.agentId = r?.agent_id ?? '' |
|||
ed.remark = r?.remark ?? '' |
|||
ed.enable = r ? r.enable !== false : true |
|||
} |
|||
function startAdd() { |
|||
editing.value = null |
|||
fillEditor(null) |
|||
editorOpen.value = true |
|||
revealEditor() |
|||
} |
|||
function startEdit(r: Rule) { |
|||
editing.value = r |
|||
fillEditor(r) |
|||
editorOpen.value = true |
|||
revealEditor() |
|||
} |
|||
function closeEditor() { |
|||
editorOpen.value = false |
|||
editing.value = null |
|||
} |
|||
function toggleAny(which: 'src' | 'dst') { |
|||
if (which === 'src') { |
|||
ed.srcAny = !ed.srcAny |
|||
if (ed.srcAny) ed.srcLangs = '' |
|||
} else { |
|||
ed.dstAny = !ed.dstAny |
|||
if (ed.dstAny) ed.dstLangs = '' |
|||
} |
|||
} |
|||
|
|||
function collect(): Rule { |
|||
return { |
|||
id: editing.value ? editing.value.id : 0, |
|||
app_name: scopeApp.value, |
|||
region: ed.region, |
|||
name: ed.name.trim(), |
|||
src_langs: ed.srcAny ? LANG_ANY : ed.srcLangs, |
|||
dst_langs: ed.dstAny ? LANG_ANY : ed.dstLangs, |
|||
bidir: ed.bidir, |
|||
agent_id: ed.agentId, |
|||
priority: ed.priority || 0, |
|||
enable: ed.enable, |
|||
remark: ed.remark.trim(), |
|||
} |
|||
} |
|||
|
|||
async function performSave(): Promise<boolean> { |
|||
const data = collect() |
|||
if (!data.src_langs) { error('源语言必选(或勾选「任意语言」)'); return false } |
|||
if (!data.dst_langs) { error('目标语言必选(或勾选「任意语言」)'); return false } |
|||
if (!data.agent_id) { error('请选择翻译 Agent'); return false } |
|||
try { |
|||
const res = await webApi<Rule>('savecalltranslaterule', data) |
|||
pendingId.value = res?.id ?? 0 |
|||
await loadRules() |
|||
return true |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
return false |
|||
} |
|||
} |
|||
|
|||
async function saveOnly() { |
|||
if (await performSave()) { closeEditor(); success('保存成功') } |
|||
} |
|||
|
|||
function delRule(r: Rule) { |
|||
if (!window.confirm(`确定删除规则「${r.name || '#' + r.id}」吗?`)) return |
|||
webApi('delcalltranslaterules', { ids: [r.id] }) |
|||
.then(() => { success('已删除'); loadRules() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
// ===== 同步 ===== |
|||
const syncOpen = ref(false) |
|||
const syncDesc = ref('') |
|||
const syncTargets = ref<number[]>([]) |
|||
const syncRegions = computed(() => REGIONS.filter((r) => r.value !== ed.region)) |
|||
|
|||
function openSyncModal() { |
|||
if (!ed.agentId) { error('请先选择翻译 Agent'); return } |
|||
syncDesc.value = `将「${ed.srcAny ? '任意语言' : langsLabel(ed.srcLangs)} ${ed.bidir ? '⇄' : '→'} ${ed.dstAny ? '任意语言' : langsLabel(ed.dstLangs)}」这条规则复制到其他区域。` |
|||
syncTargets.value = [] |
|||
syncOpen.value = true |
|||
} |
|||
function toggleSyncR(v: number) { |
|||
const i = syncTargets.value.indexOf(v) |
|||
if (i >= 0) syncTargets.value.splice(i, 1) |
|||
else syncTargets.value.push(v) |
|||
} |
|||
function syncSelAll(val: boolean) { |
|||
syncTargets.value = val ? syncRegions.value.map((r) => r.value) : [] |
|||
} |
|||
async function doSaveAndSync() { |
|||
const targets = [...syncTargets.value] |
|||
syncOpen.value = false |
|||
if (!(await performSave())) return |
|||
closeEditor() |
|||
try { |
|||
const res = await webApi<{ synced: number }>('synccalltranslaterules', { rule_ids: [pendingId.value], target_regions: targets }) |
|||
success(`已保存并同步到:${targets.map(regionLabel).join('、')}(${res?.synced ?? 0} 条)`) |
|||
} catch (e: any) { |
|||
error(e?.message ?? '同步失败') |
|||
} |
|||
} |
|||
|
|||
// ===== 选路试算 ===== |
|||
const testOpen = ref(false) |
|||
const testSrc = ref('zh-CN') |
|||
const testDst = ref('en-US') |
|||
const testResult = ref<{ matched: boolean; rule?: Rule; agent?: Agent } | null>(null) |
|||
|
|||
function openTest() { |
|||
testResult.value = null |
|||
testOpen.value = true |
|||
} |
|||
async function runTest() { |
|||
if (!testSrc.value || !testDst.value) { error('请选择源语言与目标语言'); return } |
|||
try { |
|||
testResult.value = await webApi('testcalltranslaterule', { |
|||
app_name: scopeApp.value, |
|||
region: curRegion.value, |
|||
src_lang: testSrc.value, |
|||
dst_lang: testDst.value, |
|||
}) |
|||
} catch (e: any) { |
|||
error(e?.message ?? '试算失败') |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await loadScopeApps() |
|||
await loadAll() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
/* 规则列表 */ |
|||
.rule-list { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; overflow: hidden; } |
|||
.rule-head, .rule-row { display: grid; grid-template-columns: 92px 1fr 220px 92px 160px; gap: 12px; align-items: center; padding: 11px 16px; } |
|||
.rule-head { font-size: 11px; font-weight: 700; color: #94a3b8; background: #f8fafc; border-bottom: 1px solid #eef2f7; } |
|||
.rule-row { border-bottom: 1px solid #f6f8fb; cursor: pointer; transition: background .15s; } |
|||
.rule-row:last-child { border-bottom: none; } |
|||
.rule-row:hover { background: #f8fafc; } |
|||
.rule-row.off { opacity: .55; } |
|||
.prio-cell { display: flex; align-items: baseline; gap: 6px; } |
|||
.prio { font-size: 15px; font-weight: 800; color: #4338ca; } |
|||
.order { font-size: 10px; color: #cbd5e1; } |
|||
.cond-cell { min-width: 0; } |
|||
.agent-cell { display: flex; align-items: center; gap: 6px; min-width: 0; } |
|||
.lang-badge { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; background: #eef2ff; color: #4338ca; } |
|||
.lang-badge.any { background: #f1f5f9; color: #64748b; font-style: italic; } |
|||
.type-badge { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 5px; white-space: nowrap; } |
|||
|
|||
/* 行内编辑器 */ |
|||
.editor-panel { background: #fff; border-radius: 16px; border: 1px solid #c7d2fe; box-shadow: 0 8px 32px rgba(99, 102, 241, .1); padding: 22px; margin-bottom: 16px; } |
|||
.region-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; } |
|||
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; } |
|||
.ed-section:first-of-type { padding-top: 0; border-top: none; } |
|||
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; } |
|||
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; } |
|||
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; } |
|||
.req { color: #dc2626; font-style: normal; } |
|||
.any-toggle { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #64748b; cursor: pointer; } |
|||
.any-box { display: flex; align-items: center; height: 34px; padding: 0 12px; border-radius: 8px; border: 1.5px dashed #cbd5e1; background: #f8fafc; color: #64748b; font-size: 12px; font-style: italic; } |
|||
|
|||
.lang-row { display: grid; grid-template-columns: 1fr 92px 1fr; align-items: start; gap: 12px; } |
|||
.lang-field { min-width: 0; } |
|||
.dir-col { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-top: 22px; } |
|||
.dir-btn { width: 46px; height: 32px; border-radius: 9px; border: 1.5px solid #e2e8f0; background: #f8fafc; color: #64748b; font-size: 17px; line-height: 1; cursor: pointer; transition: all .15s; } |
|||
.dir-btn:hover { border-color: #c7d2fe; background: #f5f3ff; color: #4338ca; } |
|||
.dir-btn.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; } |
|||
.dir-tip { font-size: 10px; color: #94a3b8; white-space: nowrap; } |
|||
|
|||
.agent-preview { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 8px 12px; border-radius: 9px; background: #f8fafc; border: 1px solid #eef2f7; } |
|||
|
|||
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding-top: 14px; border-top: 1px solid #eef2f7; } |
|||
|
|||
/* 弹窗 */ |
|||
.ct-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; } |
|||
.ct-box { background: #fff; border-radius: 20px; padding: 28px; width: 560px; max-width: 95vw; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); } |
|||
.sync-region-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 14px 0; } |
|||
.sync-region-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; transition: all .15s; font-size: 13px; font-weight: 500; color: #475569; } |
|||
.sync-region-item:hover { border-color: #c7d2fe; background: #f5f3ff; } |
|||
.sync-region-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; } |
|||
.test-result { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #eef2ff; border: 1px solid #c7d2fe; } |
|||
.test-result.miss { background: #fffbeb; border-color: #fde68a; } |
|||
|
|||
@media (max-width: 1000px) { |
|||
.rule-head, .rule-row { grid-template-columns: 70px 1fr 150px; } |
|||
.rule-head > :nth-child(4), .rule-row > :nth-child(4) { display: none; } |
|||
} |
|||
@media (max-width: 860px) { |
|||
.lang-row { grid-template-columns: 1fr; } |
|||
.dir-col { flex-direction: row; padding-top: 0; } |
|||
} |
|||
</style> |
|||
@ -1,445 +0,0 @@ |
|||
<template> |
|||
<div class="space-y-6"> |
|||
<!-- ========== 顶部筛选:选品牌商 ========== --> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4"> |
|||
<div class="flex items-end gap-3 flex-wrap"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-xs">品牌商</span></label> |
|||
<select v-model.number="currentBrandId" class="select select-bordered select-sm" style="min-width:200px;" @change="onBrandChange"> |
|||
<option :value="0">全部品牌商</option> |
|||
<option v-for="b in brands" :key="b.id" :value="b.id">{{ brandMap[b.id] }}</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-xs">状态</span></label> |
|||
<select v-model.number="filterStatus" class="select select-bordered select-sm" @change="loadCodes"> |
|||
<option :value="-1">全部</option> |
|||
<option :value="0">启用</option> |
|||
<option :value="1">停用</option> |
|||
</select> |
|||
</div> |
|||
<button class="btn btn-primary btn-sm text-white" title="新增公码需先选择具体品牌商" :disabled="currentBrandId === 0" @click="showCodeForm()">+ 新增公码</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ========== 公码列表 ========== --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|||
<div class="overflow-x-auto"> |
|||
<table class="table table-zebra"> |
|||
<thead> |
|||
<tr> |
|||
<th>公码</th><th>品牌商</th><th>关联产品</th><th>备注</th> |
|||
<th>VIP天/翻译分/会议分</th> |
|||
<th>核销/上限</th><th>失效时间</th><th>状态</th><th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"> |
|||
<td colspan="9" class="text-center text-slate-400 py-8">加载中…</td> |
|||
</tr> |
|||
<tr v-else-if="!codes.length"> |
|||
<td colspan="9" class="text-center text-slate-400 py-8">{{ currentBrandId ? '暂无公码,点击右上角“新增公码”创建' : '暂无公码' }}</td> |
|||
</tr> |
|||
<tr v-for="c in codes" :key="c.code"> |
|||
<td class="font-mono text-base font-bold text-indigo-700">{{ c.code || '' }}</td> |
|||
<td class="text-sm">{{ facName(c) }}</td> |
|||
<td class="text-sm">{{ prodName(c) }}</td> |
|||
<td class="text-sm text-slate-500">{{ c.remark || '-' }}</td> |
|||
<td class="text-sm">{{ (c.bindrewardvpitime || 0) }} / {{ (c.bindrewardtranslate || 0) }} / {{ (c.bindrewardmeeting || 0) }}</td> |
|||
<td class="text-sm">{{ (c.useduses || 0) }} / {{ c.maxuses ? c.maxuses : '∞' }}</td> |
|||
<td class="text-sm">{{ formatTs(c.expiretime) }}</td> |
|||
<td> |
|||
<span v-if="c.status === 0" class="badge badge-success badge-sm text-white">启用</span> |
|||
<span v-else class="badge badge-ghost badge-sm">停用</span> |
|||
</td> |
|||
<td> |
|||
<div class="flex gap-1"> |
|||
<button class="btn btn-ghost btn-xs text-indigo-600" @click="showQrCode(c.code)">二维码</button> |
|||
<button class="btn btn-ghost btn-xs" @click="showCodeForm(c)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-amber-600" @click="toggleCodeStatus(c)">{{ c.status === 0 ? '停用' : '启用' }}</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="delCode(c.code)">删除</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ========== 公码表单 ========== --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="backToCodes">← 返回公码列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? `编辑公码 ${form.code}` : '新增公码' }}</h2> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> |
|||
<form @submit.prevent="saveCode"> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">关联产品(选填,仅作分类)</span></label> |
|||
<select v-model.number="form.productid" class="select select-bordered w-full"> |
|||
<option :value="0">不关联</option> |
|||
<option v-for="p in allProducts" :key="p.id" :value="p.id">{{ p.devicename || ('#' + p.id) }}</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">备注</span></label> |
|||
<input v-model="form.remark" type="text" class="input input-bordered w-full" placeholder="例如:包装海报-2026春季" /> |
|||
</div> |
|||
<div class="grid grid-cols-3 gap-3 mb-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">奖励VIP天数</span></label> |
|||
<input v-model.number="form.bindrewardvpitime" type="number" min="0" class="input input-bordered" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">奖励翻译分钟</span></label> |
|||
<input v-model.number="form.bindrewardtranslate" type="number" min="0" class="input input-bordered" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">奖励会议分钟</span></label> |
|||
<input v-model.number="form.bindrewardmeeting" type="number" min="0" class="input input-bordered" /> |
|||
</div> |
|||
</div> |
|||
<div class="grid grid-cols-2 gap-3 mb-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">最大核销人数(0=不限)</span></label> |
|||
<input v-model.number="form.maxuses" type="number" min="0" class="input input-bordered" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">失效时间(留空=永久)</span></label> |
|||
<input v-model="form.expire" type="datetime-local" class="input input-bordered" /> |
|||
</div> |
|||
</div> |
|||
<div v-show="isEditing" class="form-control mb-6"> |
|||
<label class="label cursor-pointer justify-start gap-3"> |
|||
<input v-model="form.disabled" type="checkbox" class="checkbox" /> |
|||
<span class="label-text font-medium">停用</span> |
|||
</label> |
|||
</div> |
|||
<div class="flex gap-3"> |
|||
<button type="button" class="btn btn-ghost" @click="backToCodes">取消</button> |
|||
<button type="submit" class="btn btn-primary text-white">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 二维码弹窗 --> |
|||
<dialog ref="qrModal" class="modal"> |
|||
<div class="modal-box max-w-md"> |
|||
<h3 class="font-bold text-lg mb-4 text-slate-800">公码二维码</h3> |
|||
<div class="flex flex-col items-center gap-4"> |
|||
<div ref="qrCanvas" class="p-3 bg-white border border-slate-200 rounded-lg"></div> |
|||
<div class="text-center"> |
|||
<div class="text-xs text-slate-500 mb-1">公码</div> |
|||
<div class="font-mono text-2xl font-bold text-indigo-700 tracking-widest">{{ currentQrCode }}</div> |
|||
</div> |
|||
<div class="text-xs text-slate-400 text-center"> |
|||
新用户使用 App 扫描即可领取奖励,每人仅可领取 1 次 |
|||
</div> |
|||
</div> |
|||
<div class="modal-action"> |
|||
<button type="button" class="btn btn-outline btn-sm" @click="downloadQr">下载图片</button> |
|||
<button type="button" class="btn btn-outline btn-sm" @click="printQr">打印</button> |
|||
<form method="dialog"><button class="btn btn-sm">关闭</button></form> |
|||
</div> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
definePageMeta({ title: '公码管理' }) |
|||
|
|||
interface PublicCode { |
|||
code: string |
|||
brandid: number |
|||
productid: number |
|||
remark: string |
|||
status: number |
|||
maxuses: number |
|||
useduses: number |
|||
expiretime: number |
|||
bindrewardvpitime: number |
|||
bindrewardtranslate: number |
|||
bindrewardmeeting: number |
|||
[key: string]: unknown |
|||
} |
|||
interface Brand { id: number; brand: string } |
|||
interface Product { id: number; devicename: string } |
|||
|
|||
const { webApi } = useApi() |
|||
const { success, error, warn } = useToast() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const loading = ref(false) |
|||
const isEditing = ref(false) |
|||
|
|||
const currentBrandId = ref(0) // 0 = 全部品牌商 |
|||
const filterStatus = ref(-1) |
|||
|
|||
const codes = ref<PublicCode[]>([]) |
|||
const brands = ref<Brand[]>([]) |
|||
const allProducts = ref<Product[]>([]) // 缓存产品列表给 select 用 + 表格产品名展示 |
|||
const brandMap = reactive<Record<number, string>>({}) // brandid -> 品牌商名 |
|||
|
|||
const form = reactive({ |
|||
code: '', |
|||
productid: 0, |
|||
remark: '', |
|||
bindrewardvpitime: 0, |
|||
bindrewardtranslate: 0, |
|||
bindrewardmeeting: 0, |
|||
maxuses: 0, |
|||
expire: '', |
|||
disabled: false, |
|||
}) |
|||
|
|||
// ---- 时间工具 ---- |
|||
function formatTs(ts?: number) { |
|||
if (!ts) return '永久' |
|||
return new Date(ts * 1000).toLocaleString() |
|||
} |
|||
function tsToLocalInput(ts?: number) { |
|||
if (!ts) return '' |
|||
const d = new Date(ts * 1000) |
|||
const pad = (n: number) => (n < 10 ? '0' + n : '' + n) |
|||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + |
|||
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) |
|||
} |
|||
function localInputToTs(val?: string) { |
|||
if (!val) return 0 |
|||
return Math.floor(new Date(val).getTime() / 1000) |
|||
} |
|||
|
|||
// ---- 表格展示 ---- |
|||
function facName(c: PublicCode) { |
|||
return brandMap[c.brandid] || (c.brandid ? ('品牌商#' + c.brandid) : '-') |
|||
} |
|||
function prodName(c: PublicCode) { |
|||
const prod = allProducts.value.find((p) => p.id === c.productid) |
|||
return prod ? (prod.devicename || ('#' + c.productid)) : (c.productid ? ('#' + c.productid) : '-') |
|||
} |
|||
|
|||
// ---- 视图切换 ---- |
|||
function backToCodes() { |
|||
view.value = 'list' |
|||
loadCodes() |
|||
} |
|||
|
|||
// ---- 品牌商切换 ---- |
|||
function onBrandChange() { |
|||
backToCodes() |
|||
} |
|||
|
|||
// ---- 公码列表 ---- |
|||
async function loadCodes() { |
|||
loading.value = true |
|||
try { |
|||
// brandid=0 → 后端不过滤品牌商,返回全部公码 |
|||
const d = await webApi<{ codes: PublicCode[] }>('getfactorypubliccodes', { |
|||
brandid: currentBrandId.value, |
|||
status: filterStatus.value, |
|||
}) |
|||
codes.value = d?.codes ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取公码列表失败') |
|||
codes.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function showCodeForm(code?: PublicCode) { |
|||
if (!currentBrandId.value) { warn('请先选择品牌商'); return } |
|||
if (code) { |
|||
isEditing.value = true |
|||
form.code = code.code |
|||
form.productid = code.productid || 0 |
|||
form.remark = code.remark || '' |
|||
form.bindrewardvpitime = code.bindrewardvpitime || 0 |
|||
form.bindrewardtranslate = code.bindrewardtranslate || 0 |
|||
form.bindrewardmeeting = code.bindrewardmeeting || 0 |
|||
form.maxuses = code.maxuses || 0 |
|||
form.expire = tsToLocalInput(code.expiretime) |
|||
form.disabled = code.status === 1 |
|||
} else { |
|||
isEditing.value = false |
|||
form.code = '' |
|||
form.productid = 0 |
|||
form.remark = '' |
|||
form.bindrewardvpitime = 0 |
|||
form.bindrewardtranslate = 0 |
|||
form.bindrewardmeeting = 0 |
|||
form.maxuses = 0 |
|||
form.expire = '' |
|||
form.disabled = false |
|||
} |
|||
view.value = 'form' |
|||
} |
|||
|
|||
async function saveCode() { |
|||
const common: Record<string, unknown> = { |
|||
productid: Number(form.productid) || 0, |
|||
remark: form.remark, |
|||
maxuses: Number(form.maxuses) || 0, |
|||
expiretime: localInputToTs(form.expire), |
|||
bindrewardvpitime: Number(form.bindrewardvpitime) || 0, |
|||
bindrewardtranslate: Number(form.bindrewardtranslate) || 0, |
|||
bindrewardmeeting: Number(form.bindrewardmeeting) || 0, |
|||
} |
|||
try { |
|||
if (isEditing.value) { |
|||
common.code = form.code |
|||
common.status = form.disabled ? 1 : 0 |
|||
await webApi('updatefactorypubliccode', common) |
|||
success('保存成功') |
|||
} else { |
|||
common.brandid = currentBrandId.value |
|||
const d = await webApi<{ code?: { code?: string } }>('createfactorypubliccode', common) |
|||
success('新公码已生成:' + (d?.code?.code || '')) |
|||
} |
|||
backToCodes() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} |
|||
} |
|||
|
|||
async function toggleCodeStatus(code: PublicCode) { |
|||
const newStatus = code.status === 0 ? 1 : 0 |
|||
try { |
|||
await webApi('updatefactorypubliccode', { |
|||
code: code.code, |
|||
productid: code.productid || 0, |
|||
remark: code.remark || '', |
|||
status: newStatus, |
|||
maxuses: code.maxuses || 0, |
|||
expiretime: code.expiretime || 0, |
|||
bindrewardvpitime: code.bindrewardvpitime || 0, |
|||
bindrewardtranslate: code.bindrewardtranslate || 0, |
|||
bindrewardmeeting: code.bindrewardmeeting || 0, |
|||
}) |
|||
success(newStatus === 1 ? '已停用' : '已启用') |
|||
loadCodes() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '操作失败') |
|||
} |
|||
} |
|||
|
|||
function delCode(code: string) { |
|||
if (!window.confirm(`确定删除公码 "${code}" 吗?删除后该码无法再被领取(已领取记录保留)`)) return |
|||
webApi('delfactorypubliccode', { code }) |
|||
.then(() => { success('删除成功'); loadCodes() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
// =================== 公码二维码 =================== |
|||
const qrModal = ref<HTMLDialogElement | null>(null) |
|||
const qrCanvas = ref<HTMLElement | null>(null) |
|||
const currentQrCode = ref('') |
|||
|
|||
// 动态加载 qrcodejs(保持原版 CDN 方案,不引入新依赖) |
|||
const QR_CDN = 'https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js' |
|||
function loadQrLib(): Promise<any> { |
|||
return new Promise((resolve, reject) => { |
|||
const w = window as any |
|||
if (w.QRCode) { resolve(w.QRCode); return } |
|||
const s = document.createElement('script') |
|||
s.src = QR_CDN |
|||
s.onload = () => resolve(w.QRCode) |
|||
s.onerror = () => reject(new Error('二维码库加载失败')) |
|||
document.head.appendChild(s) |
|||
}) |
|||
} |
|||
|
|||
async function showQrCode(codeStr: string) { |
|||
currentQrCode.value = codeStr |
|||
qrModal.value?.showModal() |
|||
let QRCode: any |
|||
try { |
|||
QRCode = await loadQrLib() |
|||
} catch { |
|||
error('二维码库加载失败') |
|||
return |
|||
} |
|||
const box = qrCanvas.value |
|||
if (!box || !QRCode) return |
|||
box.innerHTML = '' |
|||
// eslint-disable-next-line no-new |
|||
new QRCode(box, { |
|||
text: codeStr, |
|||
width: 240, |
|||
height: 240, |
|||
colorDark: '#1E293B', |
|||
colorLight: '#FFFFFF', |
|||
correctLevel: QRCode.CorrectLevel.H, |
|||
}) |
|||
} |
|||
|
|||
function getQrDataUrl(): string { |
|||
const box = qrCanvas.value |
|||
if (!box) return '' |
|||
const canvas = box.querySelector('canvas') |
|||
if (canvas) return canvas.toDataURL('image/png') |
|||
const img = box.querySelector('img') |
|||
return img ? img.src : '' |
|||
} |
|||
|
|||
function downloadQr() { |
|||
const url = getQrDataUrl() |
|||
if (!url) { warn('二维码尚未生成'); return } |
|||
const a = document.createElement('a') |
|||
a.href = url |
|||
a.download = 'publiccode_' + currentQrCode.value + '.png' |
|||
document.body.appendChild(a); a.click(); document.body.removeChild(a) |
|||
} |
|||
|
|||
function printQr() { |
|||
const url = getQrDataUrl() |
|||
if (!url) { warn('二维码尚未生成'); return } |
|||
const w = window.open('', '_blank') |
|||
if (!w) { warn('浏览器拦截了打印窗口'); return } |
|||
w.document.write( |
|||
'<html><head><title>' + currentQrCode.value + '</title>' + |
|||
'<style>body{font-family:sans-serif;text-align:center;padding:48px;}' + |
|||
'img{width:320px;height:320px;}h2{font-size:36px;letter-spacing:6px;margin:24px 0 8px;}' + |
|||
'p{color:#666;font-size:14px;}</style></head><body>' + |
|||
'<img src="' + url + '" />' + |
|||
'<h2>' + currentQrCode.value + '</h2>' + |
|||
'<p>新用户扫码领取奖励</p>' + |
|||
'<scr' + 'ipt>window.onload=function(){window.print();}</scr' + 'ipt>' + |
|||
'</body></html>' |
|||
) |
|||
w.document.close() |
|||
} |
|||
|
|||
// =================== 初始化 =================== |
|||
async function initData() { |
|||
// 加载品牌商列表 |
|||
try { |
|||
const fr = await webApi<{ brands: Brand[] }>('getbrands', {}) |
|||
const facs = fr?.brands ?? [] |
|||
brands.value = facs |
|||
facs.forEach((b) => { brandMap[b.id] = b.brand || ('品牌商#' + b.id) }) |
|||
} catch { |
|||
brands.value = [] |
|||
} |
|||
// 预加载产品列表(用于公码表单下拉 + 表格产品名展示) |
|||
try { |
|||
const pr = await webApi<{ products: Product[] }>('getproducts', {}) |
|||
allProducts.value = pr?.products ?? [] |
|||
} catch { |
|||
allProducts.value = [] |
|||
} |
|||
// 默认厂家=全部,直接展示所有公码 |
|||
loadCodes() |
|||
} |
|||
|
|||
onMounted(initData) |
|||
</script> |
|||
@ -1,354 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 列表视图 --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<h2 class="text-lg font-semibold text-slate-800">品牌商列表</h2> |
|||
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增品牌商</button> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|||
<div class="overflow-x-auto"> |
|||
<table class="table table-zebra"> |
|||
<thead> |
|||
<tr> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌名称{{ sortMark('brand') }}</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('sharerate')">分成比例{{ sortMark('sharerate') }}</th> |
|||
<th>联系邮箱</th><th>产品</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"> |
|||
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td> |
|||
</tr> |
|||
<tr v-else-if="!viewRows.length"> |
|||
<td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td> |
|||
</tr> |
|||
<tr v-for="b in viewRows" :key="b.id"> |
|||
<td class="font-mono text-sm">{{ hexId(b.id) }}</td> |
|||
<td class="font-medium">{{ b.brand }}</td> |
|||
<td class="text-sm">{{ ratePct(b.sharerate) }}</td> |
|||
<td class="text-sm"> |
|||
<template v-if="mailList(b.contactmails).length"> |
|||
<span v-for="(e, i) in mailList(b.contactmails)" :key="i" class="badge badge-ghost badge-sm mr-1">{{ e }}</span> |
|||
</template> |
|||
<span v-else class="text-slate-300">-</span> |
|||
</td> |
|||
<td class="text-sm text-slate-500">{{ b.productlists || '' }}</td> |
|||
<td class="text-xs text-slate-500">{{ formatTs(b.createtime) }}</td> |
|||
<td> |
|||
<div class="flex gap-1"> |
|||
<button class="btn btn-ghost btn-xs" @click="showForm(b)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="delBrand(b)">删除</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 表单视图 --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑品牌商' : '新增品牌商' }}</h2> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> |
|||
<form @submit.prevent="saveBrand"> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">品牌名称 *</span></label> |
|||
<input v-model="form.brand" type="text" class="input input-bordered w-full" required /> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">分成比例(%)</span></label> |
|||
<input v-model.number="form.sharepct" type="number" step="0.01" min="0" max="100" class="input input-bordered w-full" /> |
|||
<span class="label-text-alt text-slate-400 mt-1">平台从订单实付里分给该品牌商的总提成;其名下渠道商再从这份里切自己的比例。</span> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">描述</span></label> |
|||
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3"></textarea> |
|||
</div> |
|||
|
|||
<!-- 多邮箱管理 --> |
|||
<div class="form-control mb-6"> |
|||
<label class="label"><span class="label-text font-medium">联系邮箱</span></label> |
|||
<div class="space-y-2"> |
|||
<p v-if="!emails.length" class="text-xs text-slate-400">暂无邮箱</p> |
|||
<div v-for="(e, i) in emails" :key="i" class="flex items-center gap-2"> |
|||
<span class="badge badge-lg gap-1 bg-slate-100 border-slate-200"> |
|||
<svg class="h-3 w-3 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> |
|||
</svg> |
|||
{{ e }} |
|||
</span> |
|||
<button type="button" class="btn btn-ghost btn-xs text-red-400 hover:text-red-600" @click="removeEmail(i)">×</button> |
|||
</div> |
|||
</div> |
|||
<div class="flex gap-2 mt-2"> |
|||
<input v-model="newEmail" type="email" class="input input-bordered input-sm flex-1" placeholder="输入邮箱地址" @keydown.enter.prevent="addEmail" /> |
|||
<button type="button" class="btn btn-outline btn-sm btn-primary" @click="addEmail">添加</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 官方渠道:新增时必建,代表品牌自营的销售线,不配渠道账号 --> |
|||
<div v-if="!isEditing" class="form-control mb-6 border-t border-slate-100 pt-4"> |
|||
<label class="label"><span class="label-text font-medium">品牌官方渠道</span></label> |
|||
<input v-model="form.officialChannelName" type="text" class="input input-bordered w-full" :placeholder="defaultOfficialName" /> |
|||
<span class="label-text-alt text-slate-400 mt-1"> |
|||
新建品牌商时自动创建一条该品牌自己的渠道(直接启用、分成 0%、不创建渠道账号),用于品牌直销的授权码归属;留空则叫「{{ defaultOfficialName }}」。 |
|||
</span> |
|||
</div> |
|||
|
|||
<!-- 同时创建品牌账号(超管/管理员) --> |
|||
<div v-if="!isEditing && auth.isAdmin" class="form-control mb-6 border-t border-slate-100 pt-4"> |
|||
<label class="flex items-center gap-2 cursor-pointer"> |
|||
<input v-model="form.createAccount" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
|||
<span class="label-text font-medium">同时创建品牌账号</span> |
|||
</label> |
|||
<span class="label-text-alt text-slate-400 mt-1"> |
|||
建一个绑定本品牌商的「品牌商」角色登录账号,可见本品牌及名下全部渠道商的数据。 |
|||
用户名/密码留空则自动生成,创建后凭据会邮件发给上面填的品牌商联系邮箱。 |
|||
</span> |
|||
<div v-if="form.createAccount" class="grid grid-cols-2 gap-3 mt-3"> |
|||
<div> |
|||
<label class="text-xs text-slate-500">登录用户名</label> |
|||
<input |
|||
v-model="form.accountUsername" type="text" class="input input-bordered input-sm w-full" |
|||
placeholder="留空自动生成,如 Brand-A001" autocomplete="off" /> |
|||
</div> |
|||
<div> |
|||
<label class="text-xs text-slate-500">登录密码</label> |
|||
<input |
|||
v-model="form.accountPassword" type="text" class="input input-bordered input-sm w-full font-mono" |
|||
placeholder="留空自动生成 12 位随机密码" autocomplete="off" /> |
|||
</div> |
|||
<div class="col-span-2"> |
|||
<label class="text-xs text-slate-500">备注</label> |
|||
<input v-model="form.accountRemark" type="text" class="input input-bordered input-sm w-full" placeholder="可选" /> |
|||
</div> |
|||
<p v-if="!credentialMails.length" class="col-span-2 text-xs text-amber-600"> |
|||
当前没有填品牌商自己的联系邮箱(平台默认邮箱不算),凭据将无法邮件送达,只会在创建后弹窗显示一次。 |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="flex gap-3"> |
|||
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> |
|||
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 新开品牌账号的凭据回执 --> |
|||
<AccountCredentialDialog :info="cred" @close="cred = null" /> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { useAuthStore } from '~/stores/auth' |
|||
import { useTableSort } from '~/composables/useTableSort' |
|||
import type { AccountCredential } from '~/components/AccountCredentialDialog.vue' |
|||
|
|||
definePageMeta({ title: '品牌商管理' }) |
|||
|
|||
interface Brand { |
|||
id: number |
|||
brand: string |
|||
description: string |
|||
contactmails: string |
|||
productlists: string |
|||
sharerate: number // 万分比 |
|||
createtime?: number |
|||
} |
|||
// addbrand 的返回:品牌商本体 + 自动建的官方渠道 + (可选)品牌账号凭据 |
|||
interface AddBrandResp { |
|||
channel?: { id: string; name: string } |
|||
account?: string |
|||
account_password?: string |
|||
mail_to?: string |
|||
mail_error?: string |
|||
} |
|||
|
|||
const { webApi } = useApi() |
|||
const { success, error, warn } = useToast() |
|||
const auth = useAuthStore() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const rows = ref<Brand[]>([]) |
|||
const loading = ref(false) |
|||
const submitting = ref(false) |
|||
const isEditing = ref(false) |
|||
const emails = ref<string[]>([]) |
|||
const newEmail = ref('') |
|||
|
|||
// 比例在表单里用百分比(更直观),提交时换算成后端的万分比整数 |
|||
const form = reactive({ |
|||
id: 0, brand: '', description: '', sharepct: 0, |
|||
// 仅新增时用:官方渠道名称 + 顺手开一个品牌商账号 |
|||
officialChannelName: '', |
|||
createAccount: false, |
|||
accountUsername: '', |
|||
accountPassword: '', |
|||
accountRemark: '', |
|||
}) |
|||
|
|||
// 官方渠道默认名(与后端 addOfficialChannel 的兜底一致) |
|||
const defaultOfficialName = computed(() => (form.brand.trim() || '品牌') + '官方渠道') |
|||
|
|||
// 能收凭据邮件的邮箱:平台默认邮箱不算(它是为收授权码清单强行塞进列表的,不是品牌商的人)。 |
|||
// 与后端 credentialMails 同口径。 |
|||
const PLATFORM_MAIL = '358030594@qq.com' |
|||
const credentialMails = computed(() => |
|||
emails.value.filter((e) => e.toLowerCase() !== PLATFORM_MAIL), |
|||
) |
|||
|
|||
// 新开账号的凭据回执(密码只有这一次能看到) |
|||
const cred = ref<AccountCredential | null>(null) |
|||
|
|||
function formatTs(ts?: number) { |
|||
if (!ts) return '-' |
|||
const d = new Date(ts * 1000) |
|||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + |
|||
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') |
|||
} |
|||
|
|||
// 列表排序:默认按品牌商 ID 升序,表头可切 |
|||
const { toggleSort, sortMark, applySort } = useTableSort<Brand>({ |
|||
id: (a, b) => Number(a.id) - Number(b.id), |
|||
brand: (a, b) => String(a.brand || '').localeCompare(String(b.brand || ''), 'zh') || Number(a.id) - Number(b.id), |
|||
sharerate: (a, b) => Number(a.sharerate || 0) - Number(b.sharerate || 0) || Number(a.id) - Number(b.id), |
|||
// 历史品牌商没有创建时间(0),相等时退回按 ID |
|||
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id), |
|||
}, 'id') |
|||
const viewRows = computed(() => applySort(rows.value)) |
|||
|
|||
function ratePct(rate?: number) { |
|||
return ((Number(rate) || 0) / 100).toFixed(2) + '%' |
|||
} |
|||
|
|||
function hexId(id: number) { |
|||
return id ? Number(id).toString(16).toUpperCase() : '' |
|||
} |
|||
function mailList(s?: string) { |
|||
return (s || '').split(',').map((e) => e.trim()).filter(Boolean) |
|||
} |
|||
async function loadData() { |
|||
loading.value = true |
|||
try { |
|||
const data = await webApi<{ brands: Brand[] }>('getbrands', {}) |
|||
rows.value = data?.brands ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取品牌商列表失败') |
|||
rows.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function showList() { |
|||
view.value = 'list' |
|||
loadData() |
|||
} |
|||
|
|||
function showForm(b?: Brand) { |
|||
if (b) { |
|||
isEditing.value = true |
|||
form.id = b.id |
|||
form.brand = b.brand || '' |
|||
form.description = b.description || '' |
|||
form.sharepct = (Number(b.sharerate) || 0) / 100 |
|||
emails.value = mailList(b.contactmails) |
|||
} else { |
|||
isEditing.value = false |
|||
form.id = 0 |
|||
form.brand = '' |
|||
form.description = '' |
|||
form.sharepct = 0 |
|||
emails.value = [] |
|||
} |
|||
form.officialChannelName = '' |
|||
form.createAccount = false |
|||
form.accountUsername = '' |
|||
form.accountPassword = '' |
|||
form.accountRemark = '' |
|||
newEmail.value = '' |
|||
view.value = 'form' |
|||
} |
|||
|
|||
function addEmail() { |
|||
const val = newEmail.value.trim() |
|||
if (!val) return |
|||
if (emails.value.includes(val)) { warn('邮箱已存在'); return } |
|||
emails.value.push(val) |
|||
newEmail.value = '' |
|||
} |
|||
function removeEmail(idx: number) { |
|||
emails.value.splice(idx, 1) |
|||
} |
|||
|
|||
async function saveBrand() { |
|||
submitting.value = true |
|||
try { |
|||
const payload: Record<string, unknown> = { |
|||
brand: { |
|||
brand: form.brand, |
|||
description: form.description, |
|||
sharerate: Math.round((Number(form.sharepct) || 0) * 100), |
|||
contactmails: emails.value.join(','), |
|||
} as Record<string, unknown>, |
|||
} |
|||
if (isEditing.value) { |
|||
;(payload.brand as Record<string, unknown>).id = Number(form.id) |
|||
} else { |
|||
// 新增才带:官方渠道名 + 是否顺手开品牌账号 |
|||
payload.official_channel_name = form.officialChannelName.trim() |
|||
payload.create_account = form.createAccount |
|||
if (form.createAccount) { |
|||
// 用户名/密码留空 = 交给服务端生成(用户名按品牌 id 推导,密码随机) |
|||
payload.account_username = form.accountUsername.trim() |
|||
payload.account_password = form.accountPassword |
|||
payload.account_remark = form.accountRemark.trim() |
|||
} |
|||
} |
|||
const d = await webApi<AddBrandResp>(isEditing.value ? 'updatebrand' : 'addbrand', payload) |
|||
if (isEditing.value) { |
|||
success('保存成功') |
|||
} else { |
|||
const parts = ['品牌商已创建'] |
|||
if (d?.channel?.id) parts.push(`官方渠道「${d.channel.name}」(${d.channel.id})`) |
|||
success(parts.join(',')) |
|||
if (d?.account) { |
|||
cred.value = { |
|||
title: `品牌账号「${d.account}」已创建`, |
|||
username: d.account, |
|||
password: d.account_password ?? '', |
|||
mailTo: d.mail_to ?? '', |
|||
mailError: d.mail_error ?? '', |
|||
} |
|||
} |
|||
} |
|||
showList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
function delBrand(b: Brand) { |
|||
if (!window.confirm(`确定删除品牌商 "${b.brand}" 吗?`)) return |
|||
webApi('delbrand', { id: b.id }) |
|||
.then(() => { success('删除成功'); loadData() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
onMounted(loadData) |
|||
</script> |
|||
@ -1,234 +1,16 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 列表视图 --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">全局环境配置</h2> |
|||
<p class="text-xs text-slate-400 mt-1">全局第三方服务配置(AI / 翻译等),按区域分组,所有应用共享。修改后各服务约 10 分钟内自动同步。</p> |
|||
</div> |
|||
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增配置</button> |
|||
</div> |
|||
<div class="flex items-center gap-3 mb-4"> |
|||
<span class="text-sm text-slate-500">区域</span> |
|||
<select v-model.number="filterRegion" class="select select-bordered select-sm" @change="loadData"> |
|||
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> |
|||
</select> |
|||
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" /> |
|||
</div> |
|||
<div 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="showForm(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> |
|||
|
|||
<!-- 表单视图 --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑配置' : '新增配置' }}</h2> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> |
|||
<form @submit.prevent="saveCfg"> |
|||
<div class="grid grid-cols-2 gap-4"> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">区域 *</span></label> |
|||
<select v-model.number="form.region" class="select select-bordered w-full"> |
|||
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">分组 (group)</span></label> |
|||
<input v-model="form.group" type="text" class="input input-bordered w-full" placeholder="如 ai / translate / asr" /> |
|||
</div> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">键 (key) *</span></label> |
|||
<input v-model="form.key" type="text" class="input input-bordered w-full" required placeholder="如 openai_api_key" /> |
|||
</div> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">值 (value)</span></label> |
|||
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea> |
|||
</div> |
|||
<div class="form-control mb-6"> |
|||
<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="flex gap-3"> |
|||
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> |
|||
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
<div class="py-16 text-center text-slate-400"> |
|||
<span class="loading loading-spinner loading-sm"></span> |
|||
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p> |
|||
</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: '全局环境配置' }) |
|||
|
|||
// 区域枚举(与 pb.Region 一一对应),按区域分组管理,始终选定一个区域,不带「全部区域」 |
|||
const REGIONS = [ |
|||
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' }, |
|||
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' }, |
|||
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' }, |
|||
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' }, |
|||
{ value: 13, label: '巴西' }, { value: 14, label: '印度' }, |
|||
] |
|||
|
|||
interface GlobalConfig { |
|||
id: number |
|||
region: number |
|||
group: string |
|||
key: string |
|||
value: string |
|||
description: string |
|||
} |
|||
|
|||
const { webApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const items = ref<GlobalConfig[]>([]) |
|||
const loading = ref(false) |
|||
const submitting = ref(false) |
|||
const isEditing = ref(false) |
|||
const filterRegion = ref(1) |
|||
const keyword = ref('') |
|||
|
|||
const form = reactive({ id: 0, region: 1, group: '', key: '', value: '', description: '' }) |
|||
|
|||
// 关键字过滤(按 group + key) |
|||
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, GlobalConfig[]> = {} |
|||
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] })) |
|||
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置), |
|||
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。 |
|||
// 保留本路由做重定向,兼容旧书签/直链。 |
|||
definePageMeta({ title: '服务与环境配置' }) |
|||
onMounted(() => { |
|||
navigateTo('/serviceconfig') |
|||
}) |
|||
|
|||
async function loadData() { |
|||
loading.value = true |
|||
try { |
|||
const data = await webApi<{ items: GlobalConfig[] }>('getglobalconfigs', { region: filterRegion.value }) |
|||
items.value = data?.items ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取配置失败') |
|||
items.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function showList() { |
|||
view.value = 'list' |
|||
loadData() |
|||
} |
|||
|
|||
function showForm(cfg?: GlobalConfig) { |
|||
if (cfg) { |
|||
isEditing.value = true |
|||
form.id = cfg.id || 0 |
|||
form.region = cfg.region || 1 |
|||
form.group = cfg.group || '' |
|||
form.key = cfg.key || '' |
|||
form.value = cfg.value || '' |
|||
form.description = cfg.description || '' |
|||
} else { |
|||
isEditing.value = false |
|||
form.id = 0 |
|||
// 新增时默认沿用当前筛选区域(回退到中国) |
|||
form.region = filterRegion.value > 0 ? filterRegion.value : 1 |
|||
form.group = '' |
|||
form.key = '' |
|||
form.value = '' |
|||
form.description = '' |
|||
} |
|||
view.value = 'form' |
|||
} |
|||
|
|||
async function saveCfg() { |
|||
submitting.value = true |
|||
try { |
|||
const data: Record<string, unknown> = { |
|||
region: Number(form.region) || 0, |
|||
group: form.group.trim(), |
|||
key: form.key.trim(), |
|||
value: form.value, |
|||
description: form.description.trim(), |
|||
} |
|||
if (isEditing.value) { |
|||
data.id = Number(form.id) |
|||
await webApi('updateglobalconfig', data) |
|||
} else { |
|||
await webApi('addglobalconfig', data) |
|||
} |
|||
success('保存成功') |
|||
showList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
function delCfg(c: GlobalConfig) { |
|||
if (!window.confirm('确定删除该配置项吗?')) return |
|||
webApi('delglobalconfig', { ids: [c.id] }) |
|||
.then(() => { success('删除成功'); loadData() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
onMounted(loadData) |
|||
</script> |
|||
|
|||
@ -1,629 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 顶部:标题 + 作用域 --> |
|||
<div class="flex items-start justify-between mb-4 flex-wrap gap-3"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">会议记录服务</h2> |
|||
<p class="text-xs text-slate-400 mt-1 max-w-3xl"> |
|||
识别 / 翻译 / 总结三段,从<b>第三方服务配置</b>里选已配好的服务来编排(不再单独填凭据)。 |
|||
<span class="text-slate-500">先配「全局默认」作兜底,再按应用 / 区域覆盖</span>——没配覆盖的应用自动继承全局默认。 |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 作用域选择器 --> |
|||
<div class="scopebar"> |
|||
<div class="flex items-center gap-2"> |
|||
<span class="text-xs text-slate-500 font-semibold">作用域</span> |
|||
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadOrch"> |
|||
<option value="">全局默认(兜底)</option> |
|||
<option v-for="a in apps" :key="a" :value="a">应用:{{ a }}</option> |
|||
</select> |
|||
<select v-model.number="scopeRegion" class="select select-bordered select-sm" @change="loadOrch"> |
|||
<option :value="0">全区域</option> |
|||
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> |
|||
</select> |
|||
</div> |
|||
<span class="text-[11px] text-slate-400"> |
|||
{{ scopeApp ? `正在编辑「${scopeApp}」` : '正在编辑全局默认' }}{{ scopeRegion ? ` · ${regionLabel(scopeRegion)}` : ' · 全区域' }} |
|||
<template v-if="isOverride">(覆盖全局默认)</template> |
|||
</span> |
|||
</div> |
|||
|
|||
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center"> |
|||
<span class="loading loading-spinner loading-sm"></span> |
|||
</div> |
|||
|
|||
<template v-else> |
|||
<!-- 支持语言汇总:同段并集、跨段交集 --> |
|||
<div class="langsum" :class="{ bad: !langSum.unlimited && !langSum.langs.length, warn: langSum.missing.length }"> |
|||
<div class="langsum-head"> |
|||
<span class="langsum-title">当前配置支持的语言</span> |
|||
<span v-if="langSum.unlimited" class="langsum-count" :class="{ off: langSum.missing.length }">未限定(各引擎自行判断)</span> |
|||
<span v-else class="langsum-count" :class="{ off: langSum.missing.length }"> |
|||
{{ langSum.langs.length }} 种<template v-if="langSum.missing.length">(链路未配全,尚不可用)</template> |
|||
</span> |
|||
<span class="langsum-formula">{{ langSumFormula }}</span> |
|||
</div> |
|||
|
|||
<div v-if="langSum.missing.length" class="langsum-msg bad"> |
|||
⚠ {{ langSum.missing.map(kindLabel).join('、') }}段没有启用中的服务,整条链路走不通——先把这些段配上。 |
|||
</div> |
|||
<div v-else-if="!langSum.unlimited && !langSum.langs.length" class="langsum-msg bad"> |
|||
⚠ 各段语言无交集,任何语种都走不完整条链路。放宽某一段的支持语言,或补一个覆盖面更广的服务。 |
|||
</div> |
|||
|
|||
<div v-if="langSum.langs.length" class="langsum-langs"> |
|||
<span v-for="l in langSum.langs" :key="l" class="lang on" :title="langName(l)">{{ l }}</span> |
|||
</div> |
|||
<div v-else-if="langSum.unlimited && !langSum.missing.length" class="langsum-msg"> |
|||
各段都没限定支持语言(服务未填 languages 字段),实际能否处理由引擎判断。 |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 无可用服务提示 --> |
|||
<div v-if="!available.length" class="alert alert-warning py-2 text-sm mb-4"> |
|||
<span> |
|||
该作用域没有可选服务。先去<b>第三方服务配置</b>添加「录音文件识别 / 机器翻译 / 文本或多媒体大模型」类的服务 |
|||
(全局默认或该应用作用域),这里才能选。 |
|||
</span> |
|||
</div> |
|||
|
|||
<!-- ============ 识别 ============ --> |
|||
<div class="card"> |
|||
<div class="card-head"> |
|||
<div> |
|||
<div class="card-title">识别(录音文件转写)</div> |
|||
<div class="card-desc">按顺序找<b>第一个「已启用 + 支持源语言」</b>的服务,不支持则跳到下一个。把便宜的排前面即"优先用性价比高的"。</div> |
|||
</div> |
|||
<div class="flex gap-2 shrink-0"> |
|||
<button class="btn btn-xs" :disabled="ofKind(1).length < 2 || busy" @click="sortByCost(1)">⇅ 按参考单价重排</button> |
|||
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(1).length" @click="openAdd(1)">+ 添加识别服务</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="probe-bar"> |
|||
<span class="text-xs text-slate-500 font-semibold">选路预览</span> |
|||
<select v-model="probeLang" class="select select-bordered select-xs w-44 text-xs"> |
|||
<option v-for="o in LANG_OPTIONS" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option> |
|||
</select> |
|||
<span v-if="asrHit" class="probe-out">命中 {{ svcName(asrHit.svc_id) }}<template v-if="asrHit.cost_hint"> · ¥{{ asrHit.cost_hint }}/时</template></span> |
|||
<span v-else class="probe-out bad">无可用服务 —— {{ langName(probeLang) }} 会转写失败</span> |
|||
</div> |
|||
|
|||
<div v-if="!ofKind(1).length" class="py-10 text-center text-slate-400 text-sm">暂无识别服务,点右上「+ 添加识别服务」</div> |
|||
<div v-for="(o, i) in ofKind(1)" :key="o.row_id" class="svc asr" :class="{ hit: asrHit && asrHit.row_id === o.row_id, off: !o.enabled }"> |
|||
<div class="svc-rank" :class="{ hit: asrHit && asrHit.row_id === o.row_id }">{{ i + 1 }}</div> |
|||
<div class="min-w-0"> |
|||
<div class="svc-name" :class="{ skip: asrSkipped(o) }">{{ svcName(o.svc_id) }}</div> |
|||
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div> |
|||
</div> |
|||
<div class="flex flex-wrap gap-1"> |
|||
<template v-if="svcLangs(o.svc_id)"> |
|||
<span v-for="l in svcLangs(o.svc_id).split(',').slice(0, 5)" :key="l" class="lang" :class="{ on: l === probeLang }">{{ l }}</span> |
|||
<span v-if="svcLangs(o.svc_id).split(',').length > 5" class="lang">+{{ svcLangs(o.svc_id).split(',').length - 5 }}</span> |
|||
</template> |
|||
<span v-else class="text-[11px] text-slate-400">未限定</span> |
|||
</div> |
|||
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /时</small></template><span v-else class="text-slate-300">—</span></div> |
|||
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div> |
|||
<div class="svc-acts"> |
|||
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(1, i, -1)">↑</button> |
|||
<button class="arrow" :disabled="i === ofKind(1).length - 1 || busy" aria-label="下移" @click="move(1, i, 1)">↓</button> |
|||
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button> |
|||
</div> |
|||
<div class="svc-why" :class="asrWhyClass(o)">{{ asrWhy(o) }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ============ 翻译 ============ --> |
|||
<div class="card mt-4"> |
|||
<div class="card-head"> |
|||
<div> |
|||
<div class="card-title">翻译</div> |
|||
<div class="card-desc">按顺序取<b>第一个已启用</b>的服务;调用失败自动回退下一个。</div> |
|||
</div> |
|||
<div class="flex gap-2 shrink-0"> |
|||
<button class="btn btn-xs" :disabled="ofKind(2).length < 2 || busy" @click="sortByCost(2)">⇅ 按参考单价重排</button> |
|||
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(2).length" @click="openAdd(2)">+ 添加翻译服务</button> |
|||
</div> |
|||
</div> |
|||
<div v-if="!ofKind(2).length" class="py-10 text-center text-slate-400 text-sm">暂无翻译服务</div> |
|||
<div v-for="(o, i) in ofKind(2)" :key="o.row_id" class="svc mt" :class="{ hit: mtHit && mtHit.row_id === o.row_id, off: !o.enabled }"> |
|||
<div class="svc-rank" :class="{ hit: mtHit && mtHit.row_id === o.row_id }">{{ i + 1 }}</div> |
|||
<div class="min-w-0"> |
|||
<div class="svc-name">{{ svcName(o.svc_id) }}</div> |
|||
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div> |
|||
</div> |
|||
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万字符</small></template><span v-else class="text-slate-300">—</span></div> |
|||
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div> |
|||
<div class="svc-acts"> |
|||
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(2, i, -1)">↑</button> |
|||
<button class="arrow" :disabled="i === ofKind(2).length - 1 || busy" aria-label="下移" @click="move(2, i, 1)">↓</button> |
|||
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button> |
|||
</div> |
|||
<div class="svc-why" :class="!o.enabled ? 'no' : (mtHit && mtHit.row_id === o.row_id ? 'ok' : '')"> |
|||
<template v-if="!o.enabled">已停用,跳过</template> |
|||
<template v-else-if="mtHit && mtHit.row_id === o.row_id">✓ 默认由它承接,失败顺延</template> |
|||
<template v-else>备用:前面失败时接手</template> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ============ 总结 ============ --> |
|||
<div class="card mt-4"> |
|||
<div class="card-head"> |
|||
<div> |
|||
<div class="card-title">总结(大模型)</div> |
|||
<div class="card-desc">后台指定默认模型,客户端可在允许范围内自选。带 🖼 的是<b>多媒体大模型</b>,能识别会议里的图片。</div> |
|||
</div> |
|||
<button class="btn btn-primary btn-xs text-white shrink-0" :disabled="!addable(3).length" @click="openAdd(3)">+ 添加模型</button> |
|||
</div> |
|||
|
|||
<div class="mode-bar"> |
|||
<label class="flex items-center gap-2 cursor-pointer shrink-0"> |
|||
<input type="checkbox" class="toggle toggle-primary toggle-sm" :checked="llmClientChoice" :disabled="busy" @change="toggleClientChoice" /> |
|||
<span class="text-sm font-semibold text-slate-700">允许客户端指定模型</span> |
|||
</label> |
|||
<p class="mode-txt"> |
|||
<template v-if="llmClientChoice"> |
|||
前端能选 <b>{{ pickable.length }}</b> 个模型<template v-if="pickable.length">:{{ pickable.map((x) => svcName(x.svc_id)).join('、') }}</template> |
|||
<template v-else>(一个都没勾「前端可见」,等同强制默认)</template>。不传则用默认 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>。 |
|||
</template> |
|||
<template v-else> |
|||
前端不下发模型列表,一律走默认模型 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>;客户端传了也忽略。 |
|||
</template> |
|||
</p> |
|||
</div> |
|||
|
|||
<div v-if="!ofKind(3).length" class="py-10 text-center text-slate-400 text-sm">暂无模型</div> |
|||
<div v-for="o in ofKind(3)" :key="o.row_id" class="svc llm" :class="{ isdefault: o.is_default, off: !o.enabled }"> |
|||
<div class="grid place-items-center"> |
|||
<input type="radio" name="llmdef" class="radio radio-primary radio-xs" :checked="o.is_default" :disabled="!o.enabled || busy" @change="patch(o, { is_default: true })" /> |
|||
</div> |
|||
<div class="min-w-0"> |
|||
<div class="svc-name"> |
|||
{{ svcName(o.svc_id) }} |
|||
<span v-if="isVision(o.svc_id)" title="多媒体大模型,能识图">🖼</span> |
|||
<span v-if="o.is_default" class="chip chip-app ml-1">默认</span> |
|||
</div> |
|||
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div> |
|||
</div> |
|||
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万token</small></template><span v-else class="text-slate-300">—</span></div> |
|||
<div> |
|||
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.client_visible" :disabled="!llmClientChoice || !o.enabled || busy" |
|||
:title="!llmClientChoice ? '先打开「允许客户端指定模型」' : ''" @change="patch(o, { client_visible: !o.client_visible })" /> |
|||
</div> |
|||
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div> |
|||
<div class="svc-acts"> |
|||
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button> |
|||
</div> |
|||
</div> |
|||
<div class="col-h llm-h"><div></div><div></div><div>参考单价</div><div>前端可见</div><div>启用</div><div></div></div> |
|||
</div> |
|||
</template> |
|||
|
|||
<!-- 添加服务弹窗 --> |
|||
<dialog ref="addDlg" class="modal"> |
|||
<div class="modal-box max-w-md"> |
|||
<h3 class="font-bold text-lg">添加{{ kindLabel(addKind) }}服务</h3> |
|||
<p class="text-xs text-slate-400 mt-1">从第三方服务配置里选一个已配好的服务加入本段编排。</p> |
|||
<div class="mt-4 space-y-3"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">服务</span></label> |
|||
<select v-model="addForm.svc_id" class="select select-bordered select-sm"> |
|||
<option value="">请选择服务…</option> |
|||
<option v-for="s in addable(addKind)" :key="s.id" :value="s.id"> |
|||
{{ s.name }}{{ addKind === 3 && catsHas(s.categories, 9) ? ' 🖼' : '' }}({{ s.provider || s.id }}) |
|||
</option> |
|||
</select> |
|||
<span v-if="!addable(addKind).length" class="text-[11px] text-amber-600 mt-1">没有可添加的服务了(都加过了,或服务池里没这类服务)。</span> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">参考单价({{ costUnit(addKind) }},可空)</span></label> |
|||
<input v-model.number="addForm.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm font-mono text-xs" placeholder="0" /> |
|||
<span class="text-[11px] text-slate-400 mt-1">仅用于排序/展示,填你的实付价。</span> |
|||
</div> |
|||
<div v-if="addKind === 3" class="flex gap-4"> |
|||
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.is_default" type="checkbox" class="toggle toggle-primary toggle-sm" /><span class="text-sm">设为默认</span></label> |
|||
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.client_visible" type="checkbox" class="toggle toggle-success toggle-sm" /><span class="text-sm">前端可见</span></label> |
|||
</div> |
|||
</div> |
|||
<div class="modal-action"> |
|||
<button class="btn btn-ghost btn-sm" @click="addDlg?.close()">取消</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="!addForm.svc_id || saving" @click="doAdd">{{ saving ? '添加中…' : '添加' }}</button> |
|||
</div> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
|
|||
<!-- 改单价弹窗 --> |
|||
<dialog ref="costDlg" class="modal"> |
|||
<div class="modal-box max-w-sm"> |
|||
<h3 class="font-bold text-lg">参考单价</h3> |
|||
<p class="text-xs text-slate-400 mt-1">{{ costEdit.name }}({{ costUnit(costEdit.kind) }})</p> |
|||
<input v-model.number="costEdit.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm w-full font-mono text-xs mt-3" placeholder="0" /> |
|||
<div class="modal-action"> |
|||
<button class="btn btn-ghost btn-sm" @click="costDlg?.close()">取消</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="saving" @click="doSaveCost">保存</button> |
|||
</div> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { LANG_OPTIONS } from '~/composables/useLangOptions' |
|||
|
|||
definePageMeta({ title: '会议记录服务' }) |
|||
|
|||
const { webApi, consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const REGIONS = [ |
|||
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' }, |
|||
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' }, |
|||
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' }, |
|||
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' }, |
|||
{ value: 13, label: '巴西' }, { value: 14, label: '印度' }, |
|||
] |
|||
function regionLabel(v: number) { return REGIONS.find((r) => r.value === v)?.label ?? '区域' + v } |
|||
|
|||
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string } |
|||
interface Orch { |
|||
row_id: number; app_name: string; region: number; kind: number; svc_id: string |
|||
priority: number; is_default: boolean; client_visible: boolean; cost_hint: number; enabled: boolean |
|||
} |
|||
|
|||
const apps = ref<string[]>([]) |
|||
const scopeApp = ref('') |
|||
const scopeRegion = ref(0) |
|||
const items = ref<Orch[]>([]) |
|||
const available = ref<SvcView[]>([]) |
|||
const kindCats = ref<Record<string, number[]>>({ '1': [8], '2': [3], '3': [5, 9] }) |
|||
const llmClientChoice = ref(false) |
|||
const isOverride = ref(false) |
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
const busy = ref(false) |
|||
const probeLang = ref('zh-CN') |
|||
|
|||
const addDlg = ref<HTMLDialogElement | null>(null) |
|||
const costDlg = ref<HTMLDialogElement | null>(null) |
|||
const addKind = ref(1) |
|||
const addForm = reactive({ svc_id: '', cost_hint: 0, is_default: false, client_visible: false }) |
|||
const costEdit = reactive({ row_id: 0, name: '', kind: 1, cost_hint: 0 }) |
|||
|
|||
function kindLabel(k: number) { return k === 1 ? '识别' : k === 2 ? '翻译' : '总结' } |
|||
function costUnit(k: number) { return k === 1 ? '元/小时' : k === 2 ? '元/百万字符' : '元/百万token' } |
|||
function langName(code: string) { return LANG_OPTIONS.find((o) => o.code === code)?.name ?? code } |
|||
function catsHas(cats: string, v: number) { return (cats || '').split(',').map((x) => Number(x.trim())).includes(v) } |
|||
|
|||
const svcMap = computed(() => { |
|||
const m = new Map<string, SvcView>() |
|||
for (const s of available.value) m.set(s.id, s) |
|||
return m |
|||
}) |
|||
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id } |
|||
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '?' } |
|||
function svcLangs(id: string) { return svcMap.value.get(id)?.languages ?? '' } |
|||
function isVision(id: string) { return catsHas(svcMap.value.get(id)?.categories ?? '', 9) } |
|||
|
|||
// 某段的编排(按优先级) |
|||
function ofKind(k: number) { |
|||
return items.value.filter((o) => o.kind === k).sort((a, b) => a.priority - b.priority) |
|||
} |
|||
// 某段可添加的服务 = 可选服务里 categories 命中该段类别、且还没加进本段编排 |
|||
function addable(k: number): SvcView[] { |
|||
const cats = kindCats.value[String(k)] ?? [] |
|||
const used = new Set(items.value.filter((o) => o.kind === k).map((o) => o.svc_id)) |
|||
return available.value.filter((s) => !used.has(s.id) && s.categories.split(',').some((c) => cats.includes(Number(c.trim())))) |
|||
} |
|||
|
|||
// 识别选路预览 |
|||
function svcSupportsLang(id: string, lang: string) { |
|||
const ls = svcLangs(id) |
|||
if (!ls) return true // 未限定 = 交给引擎判断,先当支持 |
|||
return ls.split(',').map((x) => x.trim()).includes(lang) |
|||
} |
|||
const asrHit = computed(() => ofKind(1).find((o) => o.enabled && svcSupportsLang(o.svc_id, probeLang.value)) ?? null) |
|||
const mtHit = computed(() => ofKind(2).find((o) => o.enabled) ?? null) |
|||
function asrSkipped(o: Orch) { return asrHit.value?.row_id !== o.row_id && (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) } |
|||
function asrWhyClass(o: Orch) { |
|||
if (asrHit.value?.row_id === o.row_id) return 'ok' |
|||
if (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) return 'no' |
|||
return '' |
|||
} |
|||
function asrWhy(o: Orch) { |
|||
if (asrHit.value?.row_id === o.row_id) { |
|||
return svcLangs(o.svc_id) ? `✓ 当前源语言(${langName(probeLang.value)})由它承接` : `✓ 未限定语言,先落到它(实际支持由引擎判断)` |
|||
} |
|||
if (!o.enabled) return '已停用,跳过' |
|||
if (!svcSupportsLang(o.svc_id, probeLang.value)) return `不支持 ${langName(probeLang.value)}(${probeLang.value}),跳过 → 顺延下一个` |
|||
return '备用:前面都跳过时接手' |
|||
} |
|||
|
|||
// ---- 支持语言汇总 ---- |
|||
// 规则:同一段内多个服务取**并集**(谁支持都算这段支持,选路时会自动顺延到支持的那个); |
|||
// 段与段之间取**交集**(一条记录要依次过识别→翻译→总结,缺一段就走不完)。 |
|||
// 某段只要有一个服务没填 languages,该段视为「全语种」不参与收缩——与 svcSupportsLang |
|||
// 的「未限定=先当支持」保持一致,否则会把实际能用的语言误判成不支持。 |
|||
const langSum = computed(() => { |
|||
const missing: number[] = [] // 没有任何启用服务的段 |
|||
const groups: { kind: number; langs: string[] }[] = [] // 有语言限定的段 |
|||
let unlimitedKinds = 0 // 有服务但全未限定的段数 |
|||
for (const k of [1, 2, 3]) { |
|||
const list = ofKind(k).filter((o) => o.enabled) |
|||
if (!list.length) { missing.push(k); continue } |
|||
const union = new Set<string>() |
|||
let unlimited = false |
|||
for (const o of list) { |
|||
const ls = svcLangs(o.svc_id).split(',').map((x) => x.trim()).filter(Boolean) |
|||
if (!ls.length) { unlimited = true; break } // 该服务未限定 → 整段放开 |
|||
ls.forEach((l) => union.add(l)) |
|||
} |
|||
if (unlimited) unlimitedKinds++ |
|||
else groups.push({ kind: k, langs: [...union] }) |
|||
} |
|||
if (!groups.length) return { missing, unlimited: true, langs: [] as string[], groups, unlimitedKinds } |
|||
const langs = groups |
|||
.reduce<string[]>((acc, g) => acc.filter((l) => g.langs.includes(l)), [...groups[0]!.langs]) |
|||
.sort((a, b) => langOrder(a) - langOrder(b)) // 按 LANG_OPTIONS 顺序,跟其它页面一致 |
|||
return { missing, unlimited: false, langs, groups, unlimitedKinds } |
|||
}) |
|||
function langOrder(code: string) { |
|||
const i = LANG_OPTIONS.findIndex((o) => o.code === code) |
|||
return i < 0 ? Number.MAX_SAFE_INTEGER : i |
|||
} |
|||
// 顶部那行算式,说明这个结果是怎么来的:识别 11 ∩ 翻译 全语种 … |
|||
const langSumFormula = computed(() => { |
|||
const parts: string[] = [] |
|||
for (const k of [1, 2, 3]) { |
|||
if (langSum.value.missing.includes(k)) { parts.push(`${kindLabel(k)} 未配置`); continue } |
|||
const g = langSum.value.groups.find((x) => x.kind === k) |
|||
parts.push(`${kindLabel(k)} ${g ? g.langs.length + ' 种' : '全语种'}`) |
|||
} |
|||
return parts.join(' ∩ ') |
|||
}) |
|||
|
|||
// 总结 |
|||
const llmDefault = computed(() => ofKind(3).find((o) => o.is_default) ?? null) |
|||
const pickable = computed(() => ofKind(3).filter((o) => o.enabled && o.client_visible)) |
|||
|
|||
// ---- 加载 ---- |
|||
async function loadApps() { |
|||
try { |
|||
const list = await consoleApi<{ name: string }[]>('products/list', {}) |
|||
apps.value = (list ?? []).map((p) => p.name).filter(Boolean) |
|||
} catch { apps.value = [] } |
|||
} |
|||
|
|||
async function loadOrch() { |
|||
loading.value = true |
|||
try { |
|||
const d = await webApi<{ items: Orch[]; setting: { llm_client_choice: boolean }; available: SvcView[]; kind_cats: Record<string, number[]> }>( |
|||
'getechoorch', { app_name: scopeApp.value, region: scopeRegion.value }, |
|||
) |
|||
items.value = d?.items ?? [] |
|||
available.value = d?.available ?? [] |
|||
kindCats.value = d?.kind_cats ?? kindCats.value |
|||
llmClientChoice.value = !!d?.setting?.llm_client_choice |
|||
// 是否为覆盖:非全局作用域且本作用域有编排/开关 |
|||
isOverride.value = (!!scopeApp.value || scopeRegion.value !== 0) && (items.value.length > 0 || llmClientChoice.value) |
|||
} catch (e: any) { |
|||
error(e?.message ?? '加载失败') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
// ---- 编排增改删 ---- |
|||
async function saveOrch(orch: Partial<Orch>) { |
|||
await webApi('saveechoorch', { app_name: scopeApp.value, region: scopeRegion.value, orch }) |
|||
} |
|||
|
|||
function openAdd(k: number) { |
|||
addKind.value = k |
|||
addForm.svc_id = '' |
|||
addForm.cost_hint = 0 |
|||
addForm.is_default = false |
|||
addForm.client_visible = false |
|||
addDlg.value?.showModal() |
|||
} |
|||
|
|||
async function doAdd() { |
|||
if (!addForm.svc_id) return |
|||
saving.value = true |
|||
try { |
|||
const list = ofKind(addKind.value) |
|||
const priority = list.length ? (list[list.length - 1]!.priority + 10) : 10 |
|||
await saveOrch({ |
|||
kind: addKind.value, svc_id: addForm.svc_id, priority, |
|||
cost_hint: Number(addForm.cost_hint) || 0, |
|||
is_default: addKind.value === 3 ? addForm.is_default : false, |
|||
client_visible: addKind.value === 3 ? addForm.client_visible : false, |
|||
enabled: true, |
|||
}) |
|||
success('已添加') |
|||
addDlg.value?.close() |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '添加失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function patch(o: Orch, changes: Partial<Orch>) { |
|||
busy.value = true |
|||
try { |
|||
await saveOrch({ ...o, ...changes }) |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '操作失败') |
|||
await loadOrch() |
|||
} finally { |
|||
busy.value = false |
|||
} |
|||
} |
|||
|
|||
async function del(o: Orch) { |
|||
if (!confirm(`从本段移除「${svcName(o.svc_id)}」?(不影响第三方服务配置里的服务本身)`)) return |
|||
try { |
|||
await webApi('delechoorch', { row_id: o.row_id }) |
|||
success('已移除') |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '移除失败') |
|||
} |
|||
} |
|||
|
|||
// 排序 |
|||
async function persistOrder(ordered: Orch[]) { |
|||
busy.value = true |
|||
try { |
|||
await webApi('reorderechoorch', { items: ordered.map((o, i) => ({ row_id: o.row_id, priority: (i + 1) * 10 })) }) |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '排序失败') |
|||
await loadOrch() |
|||
} finally { |
|||
busy.value = false |
|||
} |
|||
} |
|||
function move(k: number, idx: number, dir: number) { |
|||
const list = [...ofKind(k)] |
|||
const j = idx + dir |
|||
if (j < 0 || j >= list.length) return |
|||
const tmp = list[idx]! |
|||
list[idx] = list[j]! |
|||
list[j] = tmp |
|||
persistOrder(list) |
|||
} |
|||
function sortByCost(k: number) { |
|||
const list = [...ofKind(k)].sort((a, b) => { |
|||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1 |
|||
return (a.cost_hint || Number.MAX_VALUE) - (b.cost_hint || Number.MAX_VALUE) |
|||
}) |
|||
persistOrder(list) |
|||
} |
|||
|
|||
// 改单价 |
|||
function openEditCost(o: Orch) { |
|||
costEdit.row_id = o.row_id |
|||
costEdit.name = svcName(o.svc_id) |
|||
costEdit.kind = o.kind |
|||
costEdit.cost_hint = o.cost_hint |
|||
costDlg.value?.showModal() |
|||
} |
|||
async function doSaveCost() { |
|||
const o = items.value.find((x) => x.row_id === costEdit.row_id) |
|||
if (!o) return |
|||
saving.value = true |
|||
try { |
|||
await saveOrch({ ...o, cost_hint: Number(costEdit.cost_hint) || 0 }) |
|||
success('已保存') |
|||
costDlg.value?.close() |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function toggleClientChoice() { |
|||
busy.value = true |
|||
try { |
|||
await webApi('saveechoorchsetting', { app_name: scopeApp.value, region: scopeRegion.value, llm_client_choice: !llmClientChoice.value }) |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
await loadOrch() |
|||
} finally { |
|||
busy.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await loadApps() |
|||
await loadOrch() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.scopebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px; } |
|||
|
|||
.langsum { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); padding: 13px 16px; margin-bottom: 16px; } |
|||
.langsum.warn { border-color: #fed7aa; background: #fffbf5; } |
|||
.langsum.bad { border-color: #fecaca; background: #fff7f7; } |
|||
.langsum-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } |
|||
.langsum-title { font-size: 13px; font-weight: 700; color: #1e293b; } |
|||
.langsum-count { font-size: 12px; font-weight: 700; color: #15803d; background: #dcfce7; padding: 2px 8px; border-radius: 6px; } |
|||
.langsum.bad .langsum-count { color: #dc2626; background: #fee2e2; } |
|||
.langsum-count.off { color: #b45309; background: #fef3c7; } /* 有段没配全:语言算得出来,但链路还走不通 */ |
|||
.langsum-formula { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; } |
|||
.langsum-msg { font-size: 11.5px; color: #64748b; margin-top: 7px; line-height: 1.6; } |
|||
.langsum-msg.bad { color: #b91c1c; font-weight: 600; } |
|||
.langsum-langs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 9px; } |
|||
|
|||
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; } |
|||
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; } |
|||
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; } |
|||
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 76ch; line-height: 1.55; } |
|||
|
|||
.probe-bar { display: flex; align-items: center; gap: 10px; padding: 11px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; } |
|||
.probe-out { font-size: 11.5px; font-weight: 600; color: #15803d; background: #dcfce7; padding: 3px 9px; border-radius: 6px; } |
|||
.probe-out.bad { color: #dc2626; background: #fee2e2; } |
|||
|
|||
.mode-bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; } |
|||
.mode-txt { font-size: 12px; color: #64748b; flex: 1; min-width: 260px; line-height: 1.6; margin: 0; } |
|||
.mode-txt b { color: #1e293b; font-weight: 600; } |
|||
|
|||
.svc { display: grid; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid #eef2f7; } |
|||
.svc:last-child { border-bottom: 0; } |
|||
.svc:hover { background: #fcfcfd; } |
|||
.svc.off { opacity: .55; } |
|||
.svc.hit { background: linear-gradient(90deg, #dcfce7, transparent 42%); } |
|||
.svc.isdefault { background: linear-gradient(90deg, #eef2ff, transparent 42%); } |
|||
.svc.asr { grid-template-columns: 34px 1fr 170px 110px 60px auto; } |
|||
.svc.mt { grid-template-columns: 34px 1fr 130px 60px auto; } |
|||
.svc.llm { grid-template-columns: 40px 1fr 130px 90px 60px auto; } |
|||
.col-h { padding: 8px 16px; background: #fcfcfd; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; color: #94a3b8; font-weight: 700; display: grid; gap: 12px; } |
|||
.col-h.llm-h { grid-template-columns: 40px 1fr 130px 90px 60px auto; border-top: 1px solid #eef2f7; } |
|||
|
|||
.svc-rank { width: 26px; height: 26px; border-radius: 8px; display: grid; place-items: center; background: #f8fafc; border: 1px solid #e2e8f0; color: #64748b; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; font-weight: 700; } |
|||
.svc-rank.hit { background: #16a34a; border-color: #16a34a; color: #fff; } |
|||
.svc-name { font-weight: 600; font-size: 13.5px; color: #334155; } |
|||
.svc-name.skip { text-decoration: line-through; text-decoration-color: #cbd5e1; } |
|||
.svc-prov { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; margin-top: 2px; } |
|||
.svc-cost { font-family: 'SF Mono', Menlo, monospace; font-size: 12.5px; font-variant-numeric: tabular-nums; color: #334155; } |
|||
.svc-cost small { color: #94a3b8; font-size: 10px; } |
|||
.svc-acts { display: flex; gap: 3px; align-items: center; justify-content: flex-end; } |
|||
.svc-why { grid-column: 2 / -1; font-size: 11px; color: #64748b; margin-top: -2px; } |
|||
.svc-why.ok { color: #15803d; font-weight: 600; } |
|||
.svc-why.no { color: #b45309; } |
|||
|
|||
.arrow { width: 24px; height: 24px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; color: #64748b; cursor: pointer; display: grid; place-items: center; font-size: 11px; padding: 0; } |
|||
.arrow:hover:not(:disabled) { border-color: #4f46e5; color: #4f46e5; } |
|||
.arrow:disabled { opacity: .25; cursor: not-allowed; } |
|||
|
|||
.lang { font-family: 'SF Mono', Menlo, monospace; font-size: 9.5px; padding: 1px 5px; border-radius: 4px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; } |
|||
.lang.on { background: #dcfce7; border-color: transparent; color: #15803d; font-weight: 700; } |
|||
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; } |
|||
.chip-app { background: #eef2ff; color: #4338ca; } |
|||
|
|||
@media (max-width: 1100px) { |
|||
.svc.asr, .svc.mt, .svc.llm { grid-template-columns: 34px 1fr; } |
|||
.col-h { display: none; } |
|||
.svc-acts { grid-column: 1 / -1; justify-content: flex-start; } |
|||
.svc-why { grid-column: 1 / -1; } |
|||
} |
|||
</style> |
|||
@ -1,57 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
const defaultFactoryContactMail = "358030594@qq.com" |
|||
|
|||
// 添加品牌商
|
|||
func (this *apiComp) AddBrand(session comm.IUserSession, req *pb.ApiAddBrandReq) (resp *pb.ApiAddBrandResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
err error |
|||
) |
|||
if req.Brand != nil { |
|||
req.Brand.Contactmails = ensureDefaultContactMail(req.Brand.Contactmails) |
|||
req.Brand.Createtime = time.Now().Unix() |
|||
} |
|||
if err = this.module.model.addfactory(req.Brand); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("AddBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
resp = &pb.ApiAddBrandResp{ |
|||
Brand: req.Brand, |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 确保品牌商联系邮箱中包含默认邮箱
|
|||
func ensureDefaultContactMail(mails string) string { |
|||
parts := strings.Split(mails, ",") |
|||
cleaned := make([]string, 0, len(parts)+1) |
|||
has := false |
|||
for _, p := range parts { |
|||
m := strings.TrimSpace(p) |
|||
if m == "" { |
|||
continue |
|||
} |
|||
if strings.EqualFold(m, defaultFactoryContactMail) { |
|||
has = true |
|||
} |
|||
cleaned = append(cleaned, m) |
|||
} |
|||
if !has { |
|||
cleaned = append([]string{defaultFactoryContactMail}, cleaned...) |
|||
} |
|||
return strings.Join(cleaned, ",") |
|||
} |
|||
@ -1,28 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 添加全局第三方服务配置
|
|||
func (this *apiComp) AddGlobalConfigs(session comm.IUserSession, req *pb.ApiAddGlobalConfigsReq) (resp *pb.ApiAddGlobalConfigsResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
err error |
|||
) |
|||
if err = this.module.model.addglobalconfig(req.Configs...); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
|
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("AddGlobalConfig Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil { |
|||
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()}) |
|||
} |
|||
resp = &pb.ApiAddGlobalConfigsResp{} |
|||
return |
|||
} |
|||
@ -1,87 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
"time" |
|||
) |
|||
|
|||
// 创建厂家公码(一厂家可多码,每次调用生成一条新码)
|
|||
// 公码字符串自动随机生成 6 位短码,遇到极端碰撞时重试若干次
|
|||
func (this *apiComp) CreateFactoryPublicCode(session comm.IUserSession, req *pb.ApiCreateFactoryPublicCodeReq) (resp *pb.ApiCreateFactoryPublicCodeResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
code string |
|||
err error |
|||
now = time.Now().Unix() |
|||
model *pb.DBFactoryPublicCode |
|||
) |
|||
|
|||
if req.Brandid == 0 { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_ReqParameterError, |
|||
Message: "brandid 必填", |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 校验品牌商存在
|
|||
if _, err = this.module.model.factory(req.Brandid); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: "品牌商不存在: " + err.Error(), |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 防碰撞:32^6 ≈ 10亿,正常一次成功;最多重试 5 次
|
|||
for i := 0; i < 5; i++ { |
|||
if code, err = comm.GeneratePublicCode(); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_SystemError, |
|||
Message: err.Error(), |
|||
} |
|||
return |
|||
} |
|||
if _, e := this.module.model.factoryPublicCode(code); e != nil { |
|||
// 不存在 → 可以用
|
|||
break |
|||
} |
|||
code = "" |
|||
} |
|||
if code == "" { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_SystemError, |
|||
Message: "公码生成多次碰撞,请重试", |
|||
} |
|||
return |
|||
} |
|||
|
|||
model = &pb.DBFactoryPublicCode{ |
|||
Code: code, |
|||
Brandid: req.Brandid, |
|||
Productid: req.Productid, |
|||
Remark: req.Remark, |
|||
Status: 0, |
|||
Maxuses: req.Maxuses, |
|||
Useduses: 0, |
|||
Expiretime: req.Expiretime, |
|||
Bindrewardvpitime: req.Bindrewardvpitime, |
|||
Bindrewardtranslate: req.Bindrewardtranslate, |
|||
Bindrewardmeeting: req.Bindrewardmeeting, |
|||
Createtime: now, |
|||
Updatetime: now, |
|||
} |
|||
|
|||
if err = this.module.model.addFactoryPublicCode(model); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("CreateFactoryPublicCode Fail!", log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
resp = &pb.ApiCreateFactoryPublicCodeResp{Code: model} |
|||
return |
|||
} |
|||
@ -1,24 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 删除品牌商
|
|||
func (this *apiComp) DelBrand(session comm.IUserSession, req *pb.ApiDelBrandReq) (resp *pb.ApiDelBrandResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
err error |
|||
) |
|||
if err = this.module.model.delfactory(req.Id); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("DelBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.ApiDelBrandResp{} |
|||
return |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 删除厂家公码
|
|||
// 仅删除公码本身;已领取记录(DBUserPublicCodeRedeem)保留以维持防重复领的全局唯一性约束
|
|||
func (this *apiComp) DelFactoryPublicCode(session comm.IUserSession, req *pb.ApiDelFactoryPublicCodeReq) (resp *pb.ApiDelFactoryPublicCodeResp, errdata *pb.ErrorData) { |
|||
if req.Code == "" { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_ReqParameterError, |
|||
Message: "code 必填", |
|||
} |
|||
return |
|||
} |
|||
if err := this.module.model.delFactoryPublicCode(req.Code); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("DelFactoryPublicCode Fail!", log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.ApiDelFactoryPublicCodeResp{} |
|||
return |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 删除全局第三方服务配置
|
|||
func (this *apiComp) DelGlobalConfigs(session comm.IUserSession, req *pb.ApiDelGlobalConfigsReq) (resp *pb.ApiDelGlobalConfigsResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
err error |
|||
) |
|||
if err = this.module.model.delglobalconfig(req.Ids); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("DelGlobalConfig Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil { |
|||
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()}) |
|||
} |
|||
resp = &pb.ApiDelGlobalConfigsResp{} |
|||
return |
|||
} |
|||
@ -1,28 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 获取品牌商
|
|||
func (this *apiComp) GetBrand(session comm.IUserSession, req *pb.ApiGetBrandReq) (resp *pb.ApiGetBrandResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
model *pb.DBBrand |
|||
err error |
|||
) |
|||
|
|||
if model, err = this.module.model.factory(req.Id); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("GetBrand Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.ApiGetBrandResp{ |
|||
Brand: model, |
|||
} |
|||
return |
|||
} |
|||
@ -1,47 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 获取品牌商列表
|
|||
func (this *apiComp) GetBrands(session comm.IUserSession, req *pb.ApiGetBrandsReq) (resp *pb.ApiGetBrandsResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
models []*pb.DBBrand |
|||
err error |
|||
) |
|||
|
|||
if models, err = this.module.model.factorys(); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("GetBrands Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
// 运营/代理:按账号的 品牌商 + 产品白名单过滤
|
|||
scope := this.getSessionScope(session) |
|||
if !scope.Unlimited { |
|||
allProducts, _ := this.module.model.products() |
|||
allowed := visibleFactoryIDs(scope, allProducts) |
|||
allowSet := make(map[uint32]struct{}, len(allowed)) |
|||
for _, id := range allowed { |
|||
allowSet[id] = struct{}{} |
|||
} |
|||
filtered := make([]*pb.DBBrand, 0, len(models)) |
|||
for _, f := range models { |
|||
if _, ok := allowSet[f.Id]; ok { |
|||
filtered = append(filtered, f) |
|||
} |
|||
} |
|||
models = filtered |
|||
} |
|||
|
|||
resp = &pb.ApiGetBrandsResp{ |
|||
Brands: models, |
|||
} |
|||
return |
|||
} |
|||
@ -1,23 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 查询厂家公码列表
|
|||
// factoryid=0 返回全部;status<0 不过滤状态
|
|||
func (this *apiComp) GetFactoryPublicCodes(session comm.IUserSession, req *pb.ApiGetFactoryPublicCodesReq) (resp *pb.ApiGetFactoryPublicCodesResp, errdata *pb.ErrorData) { |
|||
models, err := this.module.model.factoryPublicCodes(req.Brandid, req.Status) |
|||
if err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("GetFactoryPublicCodes Fail!", log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.ApiGetFactoryPublicCodesResp{Codes: models} |
|||
return |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 获取全局第三方服务配置
|
|||
func (this *apiComp) GetGlobalConfig(session comm.IUserSession, req *pb.ApiGetGlobalConfigReq) (resp *pb.ApiGetGlobalConfigResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
config []*pb.DBGlobalConfigItem |
|||
err error |
|||
) |
|||
if config, err = this.module.model.globalconfigbyregion(req.Region); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("GetGlobalConfig Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.ApiGetGlobalConfigResp{ |
|||
Config: config, |
|||
} |
|||
return |
|||
} |
|||
@ -1,53 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 更新品牌商信息
|
|||
func (this *apiComp) UpdateBrand(session comm.IUserSession, req *pb.ApiUpdateBrandReq) (resp *pb.ApiUpdateBrandResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
old *pb.DBBrand |
|||
err error |
|||
) |
|||
if req.Brand == nil || req.Brand.Id == 0 { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_ReqParameterError, |
|||
Message: "brand id is required", |
|||
} |
|||
return |
|||
} |
|||
if old, err = this.module.model.factory(req.Brand.Id); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("UpdateBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
// 仅允许客户端覆盖可编辑字段,productlists 等由服务端维护的字段不接受前端写入,
|
|||
// 防止前端表单缺字段或携带陈旧值时把产品列表覆盖回旧值。
|
|||
old.Brand = req.Brand.Brand |
|||
// providerid 不再由品牌商维护:方案商归属挂在产品上(DBProduct.providerid),这里刻意不覆盖旧值。
|
|||
old.Description = req.Brand.Description |
|||
old.Contactmails = req.Brand.Contactmails |
|||
// 分成比例只有超管/管理员能改(品牌商自己改比例等于自定分成)。
|
|||
if canAuditChannel(session) { |
|||
old.Sharerate = req.Brand.Sharerate |
|||
} |
|||
if err = this.module.model.savefactory(old); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("UpdateBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
resp = &pb.ApiUpdateBrandResp{ |
|||
Brand: old, |
|||
} |
|||
return |
|||
} |
|||
@ -1,51 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
"time" |
|||
) |
|||
|
|||
// 更新厂家公码(按 code 主键定位;保留 createtime/useduses/factoryid 不变)
|
|||
func (this *apiComp) UpdateFactoryPublicCode(session comm.IUserSession, req *pb.ApiUpdateFactoryPublicCodeReq) (resp *pb.ApiUpdateFactoryPublicCodeResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
model *pb.DBFactoryPublicCode |
|||
err error |
|||
) |
|||
if req.Code == "" { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_ReqParameterError, |
|||
Message: "code 必填", |
|||
} |
|||
return |
|||
} |
|||
if model, err = this.module.model.factoryPublicCode(req.Code); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: "公码不存在: " + err.Error(), |
|||
} |
|||
return |
|||
} |
|||
|
|||
model.Productid = req.Productid |
|||
model.Remark = req.Remark |
|||
model.Status = req.Status |
|||
model.Maxuses = req.Maxuses |
|||
model.Expiretime = req.Expiretime |
|||
model.Bindrewardvpitime = req.Bindrewardvpitime |
|||
model.Bindrewardtranslate = req.Bindrewardtranslate |
|||
model.Bindrewardmeeting = req.Bindrewardmeeting |
|||
model.Updatetime = time.Now().Unix() |
|||
|
|||
if err = this.module.model.saveFactoryPublicCode(model); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("UpdateFactoryPublicCode Fail!", log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.ApiUpdateFactoryPublicCodeResp{Code: model} |
|||
return |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 更新全局第三方服务配置
|
|||
func (this *apiComp) UpdateGlobalConfig(session comm.IUserSession, req *pb.ApiUpdateGlobalConfigReq) (resp *pb.ApiUpdateGlobalConfigResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
err error |
|||
) |
|||
if err = this.module.model.updateglobalconfig(req.Config); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("UpdateGlobalConfig Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil { |
|||
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()}) |
|||
} |
|||
resp = &pb.ApiUpdateGlobalConfigResp{} |
|||
return |
|||
} |
|||
@ -1,357 +0,0 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"errors" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/postgres" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ Agent 配置域 (agent) ============================
|
|||
//
|
|||
// yunyan 自管 agents 的管理端 CRUD,存 console 主库(postgres.GetSys())。
|
|||
// agent 编排引用第三方服务(ThirdSvcConfig)的 id;保存时按 Type 校验必需的服务引用「存在且类别匹配」。
|
|||
// 配置「下发客户端」由后续阶段的下发接口 + modules/agents 改造承接。
|
|||
|
|||
// agentSvcRef 一个待校验的服务引用(哪个位置、引用的 svcId、允许的服务类别集合)。
|
|||
// cats 是「任一命中即可」:LLM 槽位既收文本大模型也收多媒体大模型——
|
|||
// 多媒体模型(GPT-4o / 千问 VL / Gemini / 豆包视觉)本身就能处理纯文本对话,
|
|||
// 是文本模型能力的超集,没有理由把它挡在对话型 agent 之外。
|
|||
type agentSvcRef struct { |
|||
label string |
|||
svcId string |
|||
cats []int32 |
|||
} |
|||
|
|||
// agentLLMCats 对话型 agent 的 LLM 槽位可选类别(与 admin agents.vue 的 CAT_LLM/CAT_LLM_VISION 对齐)。
|
|||
var agentLLMCats = []int32{svcCatLLM, svcCatLLMVision} |
|||
|
|||
// collectAgentSvcRefs 按 agent 类型收集其应校验的服务引用集合。
|
|||
func collectAgentSvcRefs(a *AgentConfig) []agentSvcRef { |
|||
switch a.Type { |
|||
case AgentTypeChat: |
|||
return []agentSvcRef{ |
|||
{"STT 语音识别", a.SttSvcId, []int32{svcCatSTT}}, |
|||
{"LLM 大语言", a.LlmSvcId, agentLLMCats}, |
|||
{"TTS 语音合成", a.TtsSvcId, []int32{svcCatTTS}}, |
|||
} |
|||
case AgentTypeTranslate: |
|||
return []agentSvcRef{ |
|||
{"STT 语音识别", a.SttSvcId, []int32{svcCatSTT}}, |
|||
{"MT 机器翻译", a.MtSvcId, []int32{svcCatMT}}, |
|||
{"TTS 语音合成", a.TtsSvcId, []int32{svcCatTTS}}, |
|||
} |
|||
case AgentTypeStsChat: |
|||
return []agentSvcRef{{"STS 端到端对话", a.StsSvcId, []int32{svcCatSTS}}} |
|||
case AgentTypeAstTranslate: |
|||
return []agentSvcRef{{"AST 端到端翻译", a.AstSvcId, []int32{svcCatAST}}} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// svcHasAnyCategory 判断 ThirdSvcConfig.Categories("1,2,3") 是否命中 cats 中任意一个类别。
|
|||
func svcHasAnyCategory(categories string, cats []int32) bool { |
|||
have := make(map[string]bool, 4) |
|||
for _, s := range strings.Split(categories, ",") { |
|||
if s = strings.TrimSpace(s); s != "" { |
|||
have[s] = true |
|||
} |
|||
} |
|||
for _, c := range cats { |
|||
if have[strconv.Itoa(int(c))] { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// validateAgentSvcRefs 校验 agent 引用的服务:必填、存在、类别匹配。返回错误说明(空串=通过)。
|
|||
// 引用在 agent 自己的应用作用域内解析,未命中回退全局(app_name=”)——与运行时「应用优先、无则全局」一致。
|
|||
func validateAgentSvcRefs(a *AgentConfig) string { |
|||
refs := collectAgentSvcRefs(a) |
|||
if len(refs) == 0 { |
|||
return "未知的智能体类型: " + a.Type |
|||
} |
|||
for _, r := range refs { |
|||
if strings.TrimSpace(r.svcId) == "" { |
|||
return r.label + " 必选" |
|||
} |
|||
cfg := &ThirdSvcConfig{} |
|||
err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", a.AppName, r.svcId) |
|||
if err != nil && a.AppName != "" { // 应用作用域未命中 → 回退全局
|
|||
err = postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", "", r.svcId) |
|||
} |
|||
if err != nil { |
|||
return r.label + " 引用的服务不存在: " + r.svcId |
|||
} |
|||
if !svcHasAnyCategory(cfg.Categories, r.cats) { |
|||
return r.label + " 引用的服务类别不匹配: " + r.svcId |
|||
} |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// validateAgentType 校验类型可用。四类都可建:对话两类下发成客户端 agent 列表,
|
|||
// 翻译两类供通话翻译规则按语言选路引用。
|
|||
func validateAgentType(a *AgentConfig) string { |
|||
if comm.AgentTypeSelectable(a.Type) { |
|||
return "" |
|||
} |
|||
return "智能体类型不支持: " + a.Type + |
|||
"(支持 对话(chat) / 端到端对话(sts-chat) / 三段式翻译(translate) / 端到端翻译(ast-translate))" |
|||
} |
|||
|
|||
// validateAgentMcpRefs 校验绑定的 MCP 服务:存在且类别含 MCP。空列表直接通过。
|
|||
// 解析口径同服务引用:应用作用域优先、未命中回退全局。
|
|||
func validateAgentMcpRefs(a *AgentConfig) string { |
|||
for _, id := range a.McpSvcIds { |
|||
id = strings.TrimSpace(id) |
|||
if id == "" { |
|||
continue |
|||
} |
|||
cfg := &ThirdSvcConfig{} |
|||
err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", a.AppName, id) |
|||
if err != nil && a.AppName != "" { |
|||
err = postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", "", id) |
|||
} |
|||
if err != nil { |
|||
return "绑定的 MCP 服务不存在: " + id |
|||
} |
|||
if !svcHasAnyCategory(cfg.Categories, []int32{svcCatMCP}) { |
|||
return "绑定的服务不是 MCP 类型: " + id |
|||
} |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// normalizeAgent 落库前清洗:去掉空的 MCP 引用与无 key 的变量项,避免脏数据下发到客户端。
|
|||
func normalizeAgent(a *AgentConfig) { |
|||
ids := make([]string, 0, len(a.McpSvcIds)) |
|||
seen := map[string]bool{} |
|||
for _, id := range a.McpSvcIds { |
|||
if id = strings.TrimSpace(id); id != "" && !seen[id] { |
|||
seen[id] = true |
|||
ids = append(ids, id) |
|||
} |
|||
} |
|||
a.McpSvcIds = ids |
|||
// 工具白名单只保留仍绑着的服务:取消绑定后再重新勾上,不该冒出上次的残留选择。
|
|||
// 某服务的列表为空就整条删掉——空列表与「没这个 key」同义(全放开),留着只会让下发数据变脏。
|
|||
tools := make(map[string][]string, len(a.McpTools)) |
|||
for _, id := range ids { |
|||
names := make([]string, 0, len(a.McpTools[id])) |
|||
dup := map[string]bool{} |
|||
for _, n := range a.McpTools[id] { |
|||
if n = strings.TrimSpace(n); n != "" && !dup[n] { |
|||
dup[n] = true |
|||
names = append(names, n) |
|||
} |
|||
} |
|||
if len(names) > 0 { |
|||
tools[id] = names |
|||
} |
|||
} |
|||
a.McpTools = tools |
|||
vars := make([]comm.AgentVariable, 0, len(a.Variables)) |
|||
for _, v := range a.Variables { |
|||
if v.Key = strings.TrimSpace(v.Key); v.Key != "" { |
|||
vars = append(vars, v) |
|||
} |
|||
} |
|||
a.Variables = vars |
|||
} |
|||
|
|||
// ============================== Agent ID 生成 ==============================
|
|||
|
|||
// agentIdPrefix agent 业务 id 的类型前缀:id 由后台生成,但仍希望肉眼能看出是哪类 agent。
|
|||
func agentIdPrefix(t string) string { |
|||
switch t { |
|||
case AgentTypeChat: |
|||
return "chat" |
|||
case AgentTypeStsChat: |
|||
return "sts" |
|||
case AgentTypeTranslate: |
|||
return "trans" |
|||
case AgentTypeAstTranslate: |
|||
return "ast" |
|||
} |
|||
return "agent" |
|||
} |
|||
|
|||
// genAgentId 生成作用域内唯一的 agent 业务 id(形如 chat-a3k7m2qp)。
|
|||
// 运营只填名称/类型,id 一律由后台生成——避免人工起名撞车,也免得下发给客户端的标识随手改。
|
|||
// 唯一性最终由 (app_name,id) 唯一索引兜底,这里做几次探测降低冲突概率。
|
|||
func genAgentId(appName, agentType string) (string, error) { |
|||
prefix := agentIdPrefix(agentType) |
|||
for i := 0; i < 8; i++ { |
|||
code, err := comm.GenerateShortCode(8) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
id := prefix + "-" + strings.ToLower(code) |
|||
exist := &AgentConfig{} |
|||
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", appName, id); err != nil { |
|||
return id, nil // 查不到 = 没被占用
|
|||
} |
|||
} |
|||
return "", errors.New("生成智能体ID失败,请重试") |
|||
} |
|||
|
|||
// ============================== Agent CRUD ==============================
|
|||
|
|||
// getAgents 列出某作用域(app_name)下全部 agent 配置。app_name=” 为全局默认。
|
|||
func (this *serverComp) getAgents(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
items := make([]*AgentConfig, 0) |
|||
if err := postgres.Find(comm.TableAgentConfig, &items, "app_name=?", req.AppName); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
writeOK(c, gin.H{"items": items}) |
|||
} |
|||
|
|||
// getAgent 取单个 agent 配置(按 app_name+id)。
|
|||
func (this *serverComp) getAgent(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
Id string `json:"id"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") |
|||
return |
|||
} |
|||
m := &AgentConfig{} |
|||
if err := postgres.FindOne(comm.TableAgentConfig, m, "app_name=? AND id=?", req.AppName, req.Id); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+req.Id) |
|||
return |
|||
} |
|||
writeOK(c, gin.H{"agent": m}) |
|||
} |
|||
|
|||
// addAgent 新增 agent 配置(作用域内 app_name+id 唯一)。
|
|||
func (this *serverComp) addAgent(c *gin.Context) { |
|||
var m AgentConfig |
|||
if err := c.ShouldBindJSON(&m); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if !this.requireAppNameScope(c, m.AppName) { // 应用级账号只能在自己名下的应用里建
|
|||
return |
|||
} |
|||
m.Name = strings.TrimSpace(m.Name) |
|||
if m.Name == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体名称 必填") |
|||
return |
|||
} |
|||
if msg := validateAgentType(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
// 业务 id 由后台生成:前端不再填。仍兼容显式传入(导入/迁移场景),此时校验作用域内不重复。
|
|||
if m.Id = strings.TrimSpace(m.Id); m.Id == "" { |
|||
id, err := genAgentId(m.AppName, m.Type) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_SystemError, err.Error()) |
|||
return |
|||
} |
|||
m.Id = id |
|||
} else { |
|||
exist := &AgentConfig{} |
|||
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 已存在: "+m.Id) |
|||
return |
|||
} |
|||
} |
|||
if msg := validateAgentSvcRefs(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
if msg := validateAgentMcpRefs(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
normalizeAgent(&m) |
|||
m.RowId = 0 // 代理主键由库自增回填
|
|||
now := time.Now().UnixMilli() |
|||
m.Createtime = now |
|||
m.Updatetime = now |
|||
if err := postgres.Insert(comm.TableAgentConfig, &m); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindAgent, "add", 0) |
|||
writeOK(c, &m) |
|||
} |
|||
|
|||
// updateAgent 更新 agent 配置(按 app_name+id)。
|
|||
func (this *serverComp) updateAgent(c *gin.Context) { |
|||
var m AgentConfig |
|||
if err := c.ShouldBindJSON(&m); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if strings.TrimSpace(m.Id) == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 必填") |
|||
return |
|||
} |
|||
if !this.requireAppNameScope(c, m.AppName) { |
|||
return |
|||
} |
|||
old := &AgentConfig{} |
|||
if err := postgres.FindOne(comm.TableAgentConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+m.Id) |
|||
return |
|||
} |
|||
if msg := validateAgentType(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
if msg := validateAgentSvcRefs(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
if msg := validateAgentMcpRefs(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
normalizeAgent(&m) |
|||
m.RowId = old.RowId // 按代理主键更新本行
|
|||
m.Createtime = old.Createtime |
|||
m.Updatetime = time.Now().UnixMilli() |
|||
if err := postgres.Save(comm.TableAgentConfig, &m); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindAgent, "update", 0) |
|||
writeOK(c, &m) |
|||
} |
|||
|
|||
// delAgent 删除 agent 配置(按 app_name+id)。
|
|||
func (this *serverComp) delAgent(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
Id string `json:"id"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") |
|||
return |
|||
} |
|||
if !this.requireAppNameScope(c, req.AppName) { |
|||
return |
|||
} |
|||
if err := postgres.Delete(comm.TableAgentConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindAgent, "delete", 0) |
|||
writeOK(c, gin.H{"id": req.Id}) |
|||
} |
|||
@ -1,58 +0,0 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"yunyan/comm" |
|||
) |
|||
|
|||
// normalizeAgent 是落库前最后一道清洗:解绑过的 MCP 服务不能把工具白名单留在库里,
|
|||
// 否则重新勾上该服务时会冒出上一轮的选择,运营看不见、客户端却会照着执行。
|
|||
func TestNormalizeAgentMcpTools(t *testing.T) { |
|||
a := &AgentConfig{ |
|||
McpSvcIds: []string{" mcp_map ", "mcp_music", "mcp_map", ""}, |
|||
McpTools: map[string][]string{ |
|||
"mcp_map": {"geocode", " geocode ", "", "route"}, |
|||
"mcp_music": {}, // 空列表 = 全放开,不该留在库里
|
|||
"mcp_gone": {"search"}, // 已解绑的服务,残留必须清掉
|
|||
}, |
|||
} |
|||
normalizeAgent(a) |
|||
|
|||
if len(a.McpSvcIds) != 2 || a.McpSvcIds[0] != "mcp_map" || a.McpSvcIds[1] != "mcp_music" { |
|||
t.Fatalf("MCP 引用清洗错:%v", a.McpSvcIds) |
|||
} |
|||
if _, ok := a.McpTools["mcp_gone"]; ok { |
|||
t.Fatalf("解绑服务的工具白名单没清掉:%v", a.McpTools) |
|||
} |
|||
if _, ok := a.McpTools["mcp_music"]; ok { |
|||
t.Fatalf("空白名单应整条删掉(空=全放开):%v", a.McpTools) |
|||
} |
|||
got := a.McpTools["mcp_map"] |
|||
if len(got) != 2 || got[0] != "geocode" || got[1] != "route" { |
|||
t.Fatalf("工具名去空白/去重错:%v", got) |
|||
} |
|||
} |
|||
|
|||
// 没配过工具白名单的老 agent(mcp_tools 为 nil)不能因为清洗炸掉,也不该凭空长出条目。
|
|||
func TestNormalizeAgentMcpToolsNil(t *testing.T) { |
|||
a := &AgentConfig{McpSvcIds: []string{"mcp_map"}} |
|||
normalizeAgent(a) |
|||
if a.McpTools == nil || len(a.McpTools) != 0 { |
|||
t.Fatalf("nil 白名单应清洗成空 map:%v", a.McpTools) |
|||
} |
|||
} |
|||
|
|||
// id 前缀按类型走,肉眼能看出这是哪类 agent;未知类型兜底成 agent。
|
|||
func TestAgentIdPrefix(t *testing.T) { |
|||
for typ, want := range map[string]string{ |
|||
comm.AgentTypeChat: "chat", |
|||
comm.AgentTypeStsChat: "sts", |
|||
"": "agent", |
|||
"whatever": "agent", |
|||
} { |
|||
if got := agentIdPrefix(typ); got != want { |
|||
t.Fatalf("type=%q 前缀应为 %s,实际 %s", typ, want, got) |
|||
} |
|||
} |
|||
} |
|||
@ -1,249 +0,0 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/postgres" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ 通话翻译配置域 (calltranslate) ============================
|
|||
//
|
|||
// 数据存 console 主库(postgres.GetSys()),模型见 comm/calltranslate.go。
|
|||
//
|
|||
// 规则化模型(取代旧的 call_translate_pair 语言对表):
|
|||
// 一条规则 = 某应用作用域 + 某区域下的「多源语言 × 多目标语言 → 一个 agent」映射,带优先级。
|
|||
// 运行时按 comm.MatchCallTranslateRule 选路:优先级大者先,同优先级精确规则压过 * 通配规则。
|
|||
// 规则不再直接拼 asr/mt/tts —— 服务编排收敛在 agent 里,规则只决定「什么语言走哪个 agent」。
|
|||
|
|||
// 规则可引用的 agent 类型:三段式翻译 与 端到端翻译。chat 类不能用于通话翻译选路。
|
|||
var callTranslateAgentTypes = []string{comm.AgentTypeTranslate, comm.AgentTypeAstTranslate} |
|||
|
|||
// findAgentForRule 在规则的应用作用域内找 agent,未命中回退全局(与 validateAgentSvcRefs 的解析口径一致)。
|
|||
func findAgentForRule(appName, agentId string) (*AgentConfig, error) { |
|||
a := &AgentConfig{} |
|||
err := postgres.FindOne(comm.TableAgentConfig, a, "app_name=? AND id=?", appName, agentId) |
|||
if err != nil && appName != "" { |
|||
err = postgres.FindOne(comm.TableAgentConfig, a, "app_name=? AND id=?", "", agentId) |
|||
} |
|||
return a, err |
|||
} |
|||
|
|||
// validateRule 校验规则自身与其引用的 agent,返回错误说明(空串=通过)。
|
|||
func validateRule(m *comm.CallTranslateRule) string { |
|||
if len(comm.SplitLangs(m.SrcLangs)) == 0 { |
|||
return "源语言 必选(或选「任意语言」)" |
|||
} |
|||
if len(comm.SplitLangs(m.DstLangs)) == 0 { |
|||
return "目标语言 必选(或选「任意语言」)" |
|||
} |
|||
if strings.TrimSpace(m.AgentId) == "" { |
|||
return "翻译 Agent 必选" |
|||
} |
|||
a, err := findAgentForRule(m.AppName, m.AgentId) |
|||
if err != nil { |
|||
return "引用的 Agent 不存在: " + m.AgentId |
|||
} |
|||
ok := false |
|||
for _, t := range callTranslateAgentTypes { |
|||
if a.Type == t { |
|||
ok = true |
|||
break |
|||
} |
|||
} |
|||
if !ok { |
|||
return "Agent「" + a.Name + "」类型为 " + a.Type + ",通话翻译只能引用 translate(三段式)或 ast-translate(端到端)" |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// getCallTranslateRules 列出某作用域下的规则;region>0 只取该区域 + 不限区域(0)的规则,region<=0 取全量。
|
|||
// 返回已按生效顺序排好序(优先级降序 → id 升序),前端直接按序展示即可。
|
|||
func (this *serverComp) getCallTranslateRules(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
Region int32 `json:"region"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
items := make([]*comm.CallTranslateRule, 0) |
|||
var err error |
|||
if req.Region > 0 { |
|||
err = postgres.Find(comm.TableCallTranslateRule, &items, "app_name=? AND (region=? OR region=?)", |
|||
req.AppName, req.Region, comm.RegionAny) |
|||
} else { |
|||
err = postgres.Find(comm.TableCallTranslateRule, &items, "app_name=?", req.AppName) |
|||
} |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
sortRulesByEffect(items) |
|||
writeOK(c, gin.H{"items": items}) |
|||
} |
|||
|
|||
// sortRulesByEffect 按运行时生效顺序排序:优先级降序 → id 升序(插入排序,规则量级很小)。
|
|||
func sortRulesByEffect(items []*comm.CallTranslateRule) { |
|||
for i := 1; i < len(items); i++ { |
|||
cur := items[i] |
|||
j := i - 1 |
|||
for j >= 0 && (items[j].Priority < cur.Priority || (items[j].Priority == cur.Priority && items[j].Id > cur.Id)) { |
|||
items[j+1] = items[j] |
|||
j-- |
|||
} |
|||
items[j+1] = cur |
|||
} |
|||
} |
|||
|
|||
// saveCallTranslateRule 新增或更新一条规则(id=0 新增,否则按 id 更新)。
|
|||
func (this *serverComp) saveCallTranslateRule(c *gin.Context) { |
|||
var m comm.CallTranslateRule |
|||
if err := c.ShouldBindJSON(&m); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if msg := validateRule(&m); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
now := time.Now().UnixMilli() |
|||
m.Updatetime = now |
|||
var err error |
|||
if m.Id == 0 { |
|||
m.Createtime = now |
|||
err = postgres.Insert(comm.TableCallTranslateRule, &m) |
|||
} else { |
|||
old := &comm.CallTranslateRule{} |
|||
if e := postgres.FindOne(comm.TableCallTranslateRule, old, "id=?", m.Id); e != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "规则不存在") |
|||
return |
|||
} |
|||
m.Createtime = old.Createtime |
|||
err = postgres.Save(comm.TableCallTranslateRule, &m) |
|||
} |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindCallTranslate, "update", m.Region) |
|||
writeOK(c, &m) |
|||
} |
|||
|
|||
// delCallTranslateRules 按 id 批量删除规则。
|
|||
func (this *serverComp) delCallTranslateRules(c *gin.Context) { |
|||
var req struct { |
|||
Ids []uint64 `json:"ids"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
if len(req.Ids) == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填") |
|||
return |
|||
} |
|||
if err := postgres.Delete(comm.TableCallTranslateRule, "id IN ?", req.Ids); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindCallTranslate, "delete", 0) |
|||
writeOK(c, gin.H{"deleted": len(req.Ids)}) |
|||
} |
|||
|
|||
// syncCallTranslateRules 把指定规则复制到多个目标区域。
|
|||
// 目标区域已有「同 agent + 同源语言集 + 同目标语言集」的规则则覆盖,否则新建;源区域自身跳过。
|
|||
func (this *serverComp) syncCallTranslateRules(c *gin.Context) { |
|||
var req struct { |
|||
RuleIds []uint64 `json:"rule_ids"` |
|||
TargetRegions []int32 `json:"target_regions"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil || len(req.RuleIds) == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "rule_ids 必填") |
|||
return |
|||
} |
|||
if len(req.TargetRegions) == 0 { |
|||
writeOK(c, gin.H{"synced": 0}) |
|||
return |
|||
} |
|||
srcRules := make([]*comm.CallTranslateRule, 0, len(req.RuleIds)) |
|||
if err := postgres.Find(comm.TableCallTranslateRule, &srcRules, "id IN ?", req.RuleIds); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
now := time.Now().UnixMilli() |
|||
synced := 0 |
|||
for _, r := range srcRules { |
|||
for _, tgt := range req.TargetRegions { |
|||
if r.Region == tgt { |
|||
continue |
|||
} |
|||
old := &comm.CallTranslateRule{} |
|||
_ = postgres.FindOne(comm.TableCallTranslateRule, old, |
|||
"app_name=? AND region=? AND agent_id=? AND src_langs=? AND dst_langs=?", |
|||
r.AppName, tgt, r.AgentId, r.SrcLangs, r.DstLangs) |
|||
m := &comm.CallTranslateRule{ |
|||
Id: old.Id, |
|||
AppName: r.AppName, |
|||
Region: tgt, |
|||
Name: r.Name, |
|||
SrcLangs: r.SrcLangs, |
|||
DstLangs: r.DstLangs, |
|||
Bidir: r.Bidir, |
|||
AgentId: r.AgentId, |
|||
Priority: r.Priority, |
|||
Enable: r.Enable, |
|||
Remark: r.Remark, |
|||
Updatetime: now, |
|||
} |
|||
if old.Id == 0 { |
|||
m.Createtime = now |
|||
if err := postgres.Insert(comm.TableCallTranslateRule, m); err == nil { |
|||
synced++ |
|||
} |
|||
} else { |
|||
m.Createtime = old.Createtime |
|||
if err := postgres.Save(comm.TableCallTranslateRule, m); err == nil { |
|||
synced++ |
|||
} |
|||
} |
|||
} |
|||
} |
|||
if synced > 0 { |
|||
for _, tgt := range req.TargetRegions { |
|||
broadcastConfigChanged(comm.ConfigKindCallTranslate, "update", tgt) |
|||
} |
|||
} |
|||
writeOK(c, gin.H{"synced": synced}) |
|||
} |
|||
|
|||
// testCallTranslateRule 选路试算:给定区域与源/目标语言,返回实际会命中的规则及其 agent。
|
|||
// 规则一多就很难靠肉眼确认「zh-CN→ja-JP 到底走哪条」,故给后台一个所见即所得的验证入口,
|
|||
// 走的是与运行时完全相同的 comm.MatchCallTranslateRule。
|
|||
func (this *serverComp) testCallTranslateRule(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
Region int32 `json:"region"` |
|||
SrcLang string `json:"src_lang"` |
|||
DstLang string `json:"dst_lang"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
if strings.TrimSpace(req.SrcLang) == "" || strings.TrimSpace(req.DstLang) == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "src_lang / dst_lang 必填") |
|||
return |
|||
} |
|||
items := make([]*comm.CallTranslateRule, 0) |
|||
if err := postgres.Find(comm.TableCallTranslateRule, &items, "app_name=?", req.AppName); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
hit := comm.MatchCallTranslateRule(items, req.Region, req.SrcLang, req.DstLang) |
|||
if hit == nil { |
|||
writeOK(c, gin.H{"matched": false}) |
|||
return |
|||
} |
|||
resp := gin.H{"matched": true, "rule": hit} |
|||
if a, err := findAgentForRule(hit.AppName, hit.AgentId); err == nil { |
|||
resp["agent"] = a |
|||
} |
|||
writeOK(c, resp) |
|||
} |
|||
@ -1,324 +0,0 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"sort" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/postgres" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ 会议记录服务编排 (echomeet orch) ============================
|
|||
//
|
|||
// admin「会议记录服务」页的后端。编排**不存凭据**,只引用第三方服务配置(svc_config)里配好的
|
|||
// 服务 id,按作用域 (app_name, region) 分层:app_name='' 且 region=0 = 全局默认兜底。
|
|||
// 数据存 console 共享库(postgres),与 svc_config 同库。
|
|||
//
|
|||
// 前端流程与「通话翻译配置」一致:先 getsvcconfigs 拿服务池、再 getechoorch 拿编排,前端组装。
|
|||
// web api(handleWeb 分发),前端统一用 webApi。
|
|||
|
|||
// echoOrchScope 解析请求里的作用域参数。region<0 归一为 0(全区域)。
|
|||
type echoOrchScope struct { |
|||
AppName string `json:"app_name"` |
|||
Region int32 `json:"region"` |
|||
} |
|||
|
|||
func normScope(s *echoOrchScope) { |
|||
s.AppName = strings.TrimSpace(s.AppName) |
|||
if s.Region < 0 { |
|||
s.Region = 0 |
|||
} |
|||
} |
|||
|
|||
// getEchoOrch 列出某作用域下的编排 + 作用域开关。app_name='' 且 region=0 为全局默认。
|
|||
func (this *serverComp) getEchoOrch(c *gin.Context) { |
|||
var req echoOrchScope |
|||
_ = c.ShouldBindJSON(&req) |
|||
normScope(&req) |
|||
|
|||
items := make([]*EchoOrchestration, 0) |
|||
if err := postgres.Find(comm.TableEchomeetOrch, &items, "app_name=? AND region=?", req.AppName, req.Region); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
sort.SliceStable(items, func(i, j int) bool { |
|||
if items[i].Kind != items[j].Kind { |
|||
return items[i].Kind < items[j].Kind |
|||
} |
|||
return items[i].Priority < items[j].Priority |
|||
}) |
|||
|
|||
setting := &EchoOrchSetting{} |
|||
if err := postgres.FindOne(comm.TableEchomeetOrchSetting, setting, "app_name=? AND region=?", req.AppName, req.Region); err != nil { |
|||
setting = &EchoOrchSetting{AppName: req.AppName, Region: req.Region} // 未设置 → 零值
|
|||
} |
|||
|
|||
writeOK(c, gin.H{ |
|||
"items": items, |
|||
"setting": setting, |
|||
"available": this.echoOrchAvailableSvcs(req.AppName), |
|||
// 各编排类别对应的服务池类别,前端据此从「可选服务」里筛出每段能选的服务。
|
|||
"kind_cats": gin.H{ |
|||
"1": echoKindSvcCats[echoKindASR], |
|||
"2": echoKindSvcCats[echoKindMT], |
|||
"3": echoKindSvcCats[echoKindLLM], |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
// echoOrchSvcView 供前端选服务/显示名/识别语言的精简服务视图(不含凭据)。
|
|||
type echoOrchSvcView struct { |
|||
Id string `json:"id"` |
|||
Name string `json:"name"` |
|||
Provider string `json:"provider"` |
|||
Categories string `json:"categories"` // 逗号分隔类别,前端按 kind_cats 筛
|
|||
Languages string `json:"languages"` // 识别服务的支持语言(逗号分隔 BCP-47),用于选路预览
|
|||
} |
|||
|
|||
// echoOrchAvailableSvcs 返回该作用域可见的启用服务(本作用域 + 全局兜底,同 id 应用覆盖全局)。
|
|||
func (this *serverComp) echoOrchAvailableSvcs(appName string) []echoOrchSvcView { |
|||
rows := make([]*ThirdSvcConfig, 0) |
|||
if err := postgres.Find(comm.TableSvcConfig, &rows, "(app_name=? OR app_name='') AND enable=true", appName); err != nil { |
|||
return nil |
|||
} |
|||
// 去重:同业务 id,应用作用域优先于全局兜底。
|
|||
pick := make(map[string]*ThirdSvcConfig, len(rows)) |
|||
for _, r := range rows { |
|||
if cur, ok := pick[r.Id]; !ok || (r.AppName != "" && cur.AppName == "") { |
|||
pick[r.Id] = r |
|||
} |
|||
} |
|||
out := make([]echoOrchSvcView, 0, len(pick)) |
|||
for _, r := range pick { |
|||
langs := "" |
|||
for _, f := range r.Fields { |
|||
if f.Key == "languages" { |
|||
langs = f.DefValue |
|||
break |
|||
} |
|||
} |
|||
out = append(out, echoOrchSvcView{ |
|||
Id: r.Id, Name: r.Name, Provider: r.Provider, Categories: r.Categories, Languages: langs, |
|||
}) |
|||
} |
|||
sort.SliceStable(out, func(i, j int) bool { return out[i].Id < out[j].Id }) |
|||
return out |
|||
} |
|||
|
|||
// echoOrchSvcAllowed 校验 svc_id 在该作用域可见(本作用域或全局兜底)且类别与 kind 匹配。
|
|||
// 返回 (ok, 提示)。服务池按 app_name 分作用域,应用作用域看不到别的应用、但能继承全局。
|
|||
func (this *serverComp) echoOrchSvcAllowed(appName, svcId string, kind int32) (bool, string) { |
|||
wantCats := echoKindSvcCats[kind] |
|||
if len(wantCats) == 0 { |
|||
return false, "未知的编排类别" |
|||
} |
|||
// 该作用域可见的服务 = app_name 自己的 + 全局默认的。
|
|||
rows := make([]*ThirdSvcConfig, 0) |
|||
if err := postgres.Find(comm.TableSvcConfig, &rows, "(app_name=? OR app_name='') AND id=?", appName, svcId); err != nil { |
|||
return false, err.Error() |
|||
} |
|||
for _, r := range rows { |
|||
if !r.Enable { |
|||
continue |
|||
} |
|||
for _, cat := range strings.Split(r.Categories, ",") { |
|||
for _, want := range wantCats { |
|||
if strings.TrimSpace(cat) == strconv.Itoa(int(want)) { |
|||
return true, "" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return false, "所选服务不存在、已停用,或类别与该段不匹配" |
|||
} |
|||
|
|||
// saveEchoOrch 新增/更新一条编排(按 row_id;0 为新增)。
|
|||
// 总结类默认模型同作用域唯一;非总结类强制清零 is_default/client_visible。
|
|||
func (this *serverComp) saveEchoOrch(c *gin.Context) { |
|||
var req struct { |
|||
echoOrchScope |
|||
Orch EchoOrchestration `json:"orch"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
normScope(&req.echoOrchScope) |
|||
o := req.Orch |
|||
o.AppName = req.AppName |
|||
o.Region = req.Region |
|||
o.SvcId = strings.TrimSpace(o.SvcId) |
|||
if o.SvcId == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 必填") |
|||
return |
|||
} |
|||
if _, ok := echoKindSvcCats[o.Kind]; !ok { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "未知的编排类别 kind") |
|||
return |
|||
} |
|||
if ok, msg := this.echoOrchSvcAllowed(req.AppName, o.SvcId, o.Kind); !ok { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
// 只有总结类有默认/客户端可见;其余强制清零。停用的不能当默认。
|
|||
if o.Kind != echoKindLLM { |
|||
o.IsDefault = false |
|||
o.ClientVisible = false |
|||
} |
|||
if !o.Enabled { |
|||
o.IsDefault = false |
|||
} |
|||
|
|||
now := time.Now().Unix() |
|||
o.Updatetime = now |
|||
if o.RowId != 0 { |
|||
old := &EchoOrchestration{} |
|||
if err := postgres.FindOne(comm.TableEchomeetOrch, old, "row_id=?", o.RowId); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "编排不存在: "+err.Error()) |
|||
return |
|||
} |
|||
o.AppName, o.Region, o.Createtime = old.AppName, old.Region, old.Createtime |
|||
if err := postgres.Save(comm.TableEchomeetOrch, &o); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
} else { |
|||
// 同作用域同类同服务不可重复。
|
|||
dup := &EchoOrchestration{} |
|||
if postgres.FindOne(comm.TableEchomeetOrch, dup, "app_name=? AND region=? AND kind=? AND svc_id=?", |
|||
o.AppName, o.Region, o.Kind, o.SvcId) == nil && dup.RowId != 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "该服务已在此段添加") |
|||
return |
|||
} |
|||
o.Createtime = now |
|||
if err := postgres.Insert(comm.TableEchomeetOrch, &o); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
} |
|||
|
|||
// 默认模型同作用域唯一:本行置默认后,抹掉同作用域同类其余行。先写后清,避免中途失败留空默认。
|
|||
if o.IsDefault { |
|||
if res := postgres.Exec( |
|||
"UPDATE "+comm.TableEchomeetOrch+" SET is_default=false WHERE app_name=? AND region=? AND kind=? AND row_id<>?", |
|||
o.AppName, o.Region, o.Kind, o.RowId); res.Error != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "清理旧默认模型失败: "+res.Error.Error()) |
|||
return |
|||
} |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", o.Region) // 通知 echomeet 运行时重载编排
|
|||
writeOK(c, gin.H{"row_id": o.RowId}) |
|||
} |
|||
|
|||
// delEchoOrch 删除一条编排。删掉的若是默认模型,顺延同作用域同类一个可用的。
|
|||
func (this *serverComp) delEchoOrch(c *gin.Context) { |
|||
var req struct { |
|||
RowId uint64 `json:"row_id"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil || req.RowId == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "row_id 必填") |
|||
return |
|||
} |
|||
old := &EchoOrchestration{} |
|||
had := postgres.FindOne(comm.TableEchomeetOrch, old, "row_id=?", req.RowId) == nil && old.RowId != 0 |
|||
if err := postgres.Delete(comm.TableEchomeetOrch, "row_id=?", req.RowId); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
if had && old.IsDefault && old.Kind == echoKindLLM { |
|||
this.promoteEchoOrchDefault(old.AppName, old.Region) |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "delete", old.Region) // 通知 echomeet 运行时重载编排
|
|||
writeOK(c, gin.H{"deleted": req.RowId}) |
|||
} |
|||
|
|||
// promoteEchoOrchDefault 该作用域总结类无默认时,把优先级最高的启用条目提为默认。best-effort。
|
|||
func (this *serverComp) promoteEchoOrchDefault(appName string, region int32) { |
|||
rows := make([]*EchoOrchestration, 0) |
|||
if err := postgres.Find(comm.TableEchomeetOrch, &rows, "app_name=? AND region=? AND kind=?", appName, region, echoKindLLM); err != nil { |
|||
return |
|||
} |
|||
var best *EchoOrchestration |
|||
for _, r := range rows { |
|||
if r.IsDefault { |
|||
return // 还有默认
|
|||
} |
|||
if !r.Enabled { |
|||
continue |
|||
} |
|||
if best == nil || r.Priority < best.Priority { |
|||
best = r |
|||
} |
|||
} |
|||
if best == nil { |
|||
return |
|||
} |
|||
postgres.Exec("UPDATE "+comm.TableEchomeetOrch+" SET is_default=true WHERE row_id=?", best.RowId) |
|||
} |
|||
|
|||
// reorderEchoOrch 批量调整同作用域内的优先级(拖拽/上下移后提交)。
|
|||
func (this *serverComp) reorderEchoOrch(c *gin.Context) { |
|||
var req struct { |
|||
Items []struct { |
|||
RowId uint64 `json:"row_id"` |
|||
Priority int32 `json:"priority"` |
|||
} `json:"items"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
now := time.Now().Unix() |
|||
updated := 0 |
|||
for _, it := range req.Items { |
|||
if it.RowId == 0 { |
|||
continue |
|||
} |
|||
if res := postgres.Exec("UPDATE "+comm.TableEchomeetOrch+" SET priority=?, updatetime=? WHERE row_id=?", |
|||
it.Priority, now, it.RowId); res.Error != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, res.Error.Error()) |
|||
return |
|||
} |
|||
updated++ |
|||
} |
|||
if updated > 0 { |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", 0) // 通知 echomeet 运行时重载编排
|
|||
} |
|||
writeOK(c, gin.H{"updated": updated}) |
|||
} |
|||
|
|||
// saveEchoOrchSetting 保存作用域级开关(客户端可否指定总结模型),upsert 单行。
|
|||
func (this *serverComp) saveEchoOrchSetting(c *gin.Context) { |
|||
var req struct { |
|||
echoOrchScope |
|||
LLMClientChoice bool `json:"llm_client_choice"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
normScope(&req.echoOrchScope) |
|||
now := time.Now().Unix() |
|||
old := &EchoOrchSetting{} |
|||
if postgres.FindOne(comm.TableEchomeetOrchSetting, old, "app_name=? AND region=?", req.AppName, req.Region) == nil && old.RowId != 0 { |
|||
old.LLMClientChoice = req.LLMClientChoice |
|||
old.Updatetime = now |
|||
if err := postgres.Save(comm.TableEchomeetOrchSetting, old); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
} else { |
|||
row := &EchoOrchSetting{AppName: req.AppName, Region: req.Region, LLMClientChoice: req.LLMClientChoice, Updatetime: now} |
|||
if err := postgres.Insert(comm.TableEchomeetOrchSetting, row); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", req.Region) // 通知 echomeet 运行时重载编排
|
|||
writeOK(c, gin.H{"llm_client_choice": req.LLMClientChoice}) |
|||
} |
|||
@ -0,0 +1,629 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"fmt" |
|||
"sort" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/lego/sys/mysql" |
|||
"yunyan/lego/sys/postgres" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// ============================ 凭据归一:两张扁平 env 表 → svc_config ============================
|
|||
//
|
|||
// 2026-09-12 之前,第三方凭据同时躺在两张扁平 key/value 表里,客户端从 user_getappconfig 的
|
|||
// env[KEY] 读:
|
|||
//
|
|||
// config 应用业务库,一应用一份,不分区域 ← 后台「应用环境配置」
|
|||
// global_config console 主库,按区域各存一份 ← 后台「全局环境配置」,**覆盖上面那张**
|
|||
//
|
|||
// 这套结构有三个实打实的坑:
|
|||
// 1. 同一个 AZURE_SPEECH_REGION / OPENAI_API_KEY / VOLC_OPENSPEECH_APP_ID / XUNFEI 密钥在两张表
|
|||
// 里值**不一样**,全局那张赢。运营在「应用环境配置」里改半天没有任何效果,且不报错。
|
|||
// 2. global_config 按区域整份复制(14 个区域 × 31 个键 = 473 行),而其中只有 4 组键真的因区域
|
|||
// 而异——剩下的全是同值复制,改一次要改 14 处。
|
|||
// 3. 凭据明文落库、无加密、无类别、无巡检,跟 svc_config 那套(加密字段 + 区域覆盖 + 服务巡检)
|
|||
// 完全是两个世界。
|
|||
//
|
|||
// 本迁移把凭据收进 svc_config,一个服务一行:
|
|||
// - 基础值(DefValue) 取**中国区**的现值(没有就退回 app_config 的值);
|
|||
// - 与基础值不同的区域,写一条 svc_region_override;同值的区域不写,473 行因此收敛到几十行;
|
|||
// - 字段带 EnvKey,下发时回填一份 env(见 modules/user 的 appEnvForUser)——**存量客户端零改动**。
|
|||
//
|
|||
// 幂等:只新建不存在的服务、只填空着的字段值;跑过一遍后即空转,运营后来改的值不会被刷回去。
|
|||
//
|
|||
// ⚠️ **不删 global_config 的行**。这是不可逆的线上凭据,留着是出问题时唯一的回退依据
|
|||
// (与 migrateLicenseToDeviceMac 同一套取舍)。代码已不再读它,确认新链路跑稳后由人手工
|
|||
// `DROP TABLE global_config;`。业务库 config 表里那些**已经被迁走**的键则会删除——
|
|||
// 它们在迁移前就已经被 global_config 盖住、一直是死值,留着只会继续误导运营。
|
|||
|
|||
// envFieldSpec 一个 env 键在 svc_config 里的落点。
|
|||
type envFieldSpec struct { |
|||
EnvKey string // 老的下发键名,迁移后由 SvcField.EnvKey 继续下发
|
|||
FieldKey string // 在服务里的字段名
|
|||
Desc string |
|||
Encrypted bool |
|||
} |
|||
|
|||
// envSvcSpec 一个服务的落点定义。Id 已存在时只补字段、不改服务本身(名称/类别/启用状态都不动)。
|
|||
type envSvcSpec struct { |
|||
Id string |
|||
Name string |
|||
Provider string |
|||
Categories string // 逗号分隔的 comm.SvcCatXxx
|
|||
Desc string |
|||
Fields []envFieldSpec |
|||
} |
|||
|
|||
// envToSvcPlan 迁移落点表。
|
|||
//
|
|||
// ⚠️ 改这张表前先想清楚 EnvKey:它是**已发布客户端**读的键名,改一个字母老包就拿不到凭据,
|
|||
// 而且只表现为「某个能力鉴权失败」,不报配置错误。客户端读取点见
|
|||
// apps/client/lib/data/services/**(搜 AppConfig.env)。
|
|||
var envToSvcPlan = []envSvcSpec{ |
|||
{ |
|||
// 已存在(默认停用、字段名就是这几个),本迁移只给它的字段补 env_key 与值。
|
|||
Id: "stt_azure", Name: "微软 Azure 语音", Provider: "azure", |
|||
Categories: "1", Desc: "语音识别/合成(客户端直连)", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "AZURE_SPEECH_KEY", FieldKey: "subscription_key", Desc: "订阅密钥", Encrypted: true}, |
|||
{EnvKey: "AZURE_SPEECH_REGION", FieldKey: "region", Desc: "服务区域,如 eastasia"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "mt_azure", Name: "微软 Azure 翻译", Provider: "azure", |
|||
Categories: "3", Desc: "文本翻译(客户端直连,面对面/同传的翻译段)", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "AZURE_TRANSLATION_KEY", FieldKey: "subscription_key", Desc: "订阅密钥", Encrypted: true}, |
|||
{EnvKey: "AZURE_TRANSLATION_REGION", FieldKey: "region", Desc: "服务区域"}, |
|||
{EnvKey: "AZURE_TRANSLATION_ENDPOINT", FieldKey: "endpoint", Desc: "接口地址,留空用客户端默认"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "ast_alibaba", Name: "阿里云 通义实时翻译", Provider: "alibaba", |
|||
Categories: "4", Desc: "通话翻译端到端(qwen3.5-livetranslate),VIP 过期即停发凭据", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "ALIBABA_OPENSPEECH_APP_KEY", FieldKey: "app_key", Desc: "百炼 API Key", Encrypted: true}, |
|||
{EnvKey: "ALIBABA_OPENSPEECH_APP_ID", FieldKey: "app_id", Desc: "旧版模型名/AppId(仅第 2 档用)"}, |
|||
{EnvKey: "ALIBABA_OPENSPEECH_APP_URL", FieldKey: "ws_url", Desc: "WebSocket 地址(国内/国际站不同)"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "sts_bailian", Name: "阿里云 百炼多模态", Provider: "alibaba", |
|||
Categories: "7", Desc: "EMAI 助手(百炼多模态应用)。API Key 复用 ast_alibaba 下发的 ALIBABA_OPENSPEECH_APP_KEY,这里只配应用与业务空间", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "ALIBABA_BAILIAN_APP_ID", FieldKey: "app_id", Desc: "百炼应用 ID"}, |
|||
{EnvKey: "ALIBABA_BAILIAN_WORKSPACE_ID", FieldKey: "workspace_id", Desc: "百炼业务空间 ID"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "stt_iflytek", Name: "讯飞 语音识别", Provider: "iflytek", |
|||
Categories: "1", Desc: "语音识别兜底档(客户端直连)", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "XUNFEI_ASR_APP_ID", FieldKey: "app_id", Desc: "应用 ID"}, |
|||
{EnvKey: "XUNFEI_ASR_ACCESS_KEY_ID", FieldKey: "access_key_id", Desc: "AccessKeyId", Encrypted: true}, |
|||
{EnvKey: "XUNFEI_ASR_ACCESS_KEY_SECRET", FieldKey: "access_key_secret", Desc: "AccessKeySecret", Encrypted: true}, |
|||
{EnvKey: "IFLYTEK_ASR_HOST", FieldKey: "host", Desc: "接口地址,留空用客户端默认"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "ast_bytedance", Name: "火山引擎 语音大模型", Provider: "bytedance", |
|||
Categories: "4", Desc: "字节端到端语音翻译(当前客户端已不作首选,凭据仍下发)", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "VOLC_OPENSPEECH_APP_ID", FieldKey: "app_id", Desc: "应用 ID"}, |
|||
{EnvKey: "VOLC_OPENSPEECH_ACCESS_TOKEN", FieldKey: "access_token", Desc: "访问令牌", Encrypted: true}, |
|||
{EnvKey: "VOLC_OPENSPEECH_TRANSLATION_BIGMODEL", FieldKey: "translation_service_type", Desc: "翻译服务类型标识"}, |
|||
{EnvKey: "VOLC_OPENSPEECH_BASE_URL_BIGMODEL", FieldKey: "base_url", Desc: "大模型接口地址"}, |
|||
{EnvKey: "VOLC_OPENSPEECH_CLUSTER_BIGMODEL", FieldKey: "cluster", Desc: "集群标识"}, |
|||
{EnvKey: "VOLC_OPENSPEECH_MODEL_NAME", FieldKey: "model_name", Desc: "模型名"}, |
|||
{EnvKey: "VOLC_OPENSPEECH_MODEL_VERSION", FieldKey: "model_version", Desc: "模型版本"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "llm_doubao", Name: "火山方舟 豆包", Provider: "volcano", |
|||
Categories: "5", Desc: "客户端直连的文本/视觉大模型(键名沿用 OPENAI_*,是 OpenAI 兼容接口不是 OpenAI)", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "OPENAI_API_KEY", FieldKey: "api_key", Desc: "API Key", Encrypted: true}, |
|||
{EnvKey: "OPENAI_BASE_URL", FieldKey: "base_url", Desc: "接口地址"}, |
|||
{EnvKey: "OPENAI_MODEL", FieldKey: "model", Desc: "文本模型"}, |
|||
{EnvKey: "OPENAI_VISION_MODEL", FieldKey: "vision_model", Desc: "视觉模型"}, |
|||
{EnvKey: "OPENAI_AGENT_ID", FieldKey: "agent_id", Desc: "默认智能体标识"}, |
|||
}, |
|||
}, |
|||
{ |
|||
Id: "mt_volcano", Name: "火山引擎 翻译", Provider: "volcano", |
|||
Categories: "3", Desc: "火山机器翻译(当前客户端已不作首选,凭据仍下发)", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "VOLCANO_TRANSLATION_ACCESS_KEY", FieldKey: "access_key", Desc: "AccessKey", Encrypted: true}, |
|||
{EnvKey: "VOLCANO_TRANSLATION_SECRET_KEY", FieldKey: "secret_key", Desc: "SecretKey", Encrypted: true}, |
|||
{EnvKey: "VOLCANO_AI_API_KEY", FieldKey: "ark_api_key", Desc: "方舟 API Key", Encrypted: true}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
// envKeysDropped 确认没有任何代码读、迁移时直接丢弃的键。
|
|||
//
|
|||
// ALIBABA_OSS_* 对象存储早已改由 svc_config 的 storage_aliyun_oss / sys_service_config 提供,
|
|||
// 客户端与服务端都不读这几个 env 键(服务端唯一读取点是 comm.LoadAliyunOSSConf)。
|
|||
// COMMENT_* comment.voitrans.net 随 voitrans.net 一起下线,客户端无任何引用。
|
|||
var envKeysDropped = map[string]string{ |
|||
"ALIBABA_OSS_ACCESS_KEY_ID": "对象存储改由 storage_aliyun_oss 提供", |
|||
"ALIBABA_OSS_ACCESS_KEY_SECRET": "对象存储改由 storage_aliyun_oss 提供", |
|||
"ALIBABA_OSS_BUCKET_NAME": "对象存储改由 storage_aliyun_oss 提供", |
|||
"ALIBABA_OSS_ENDPOINT": "对象存储改由 storage_aliyun_oss 提供", |
|||
"COMMENT_SERVER_URL": "comment.voitrans.net 已下线", |
|||
"COMMENT_SIGN_SECRET": "comment.voitrans.net 已下线", |
|||
} |
|||
|
|||
// globalCovered 该键在 global_config 里有没有任何区域配了非空值。
|
|||
// 这是「业务库 config 里那一行是不是死值」的判据:老的合并口径是 global 覆盖 app,
|
|||
// 有全局值时应用自己那份从来就没生效过,删掉不改变任何行为。
|
|||
func globalCovered(byKey map[string]map[int32]string, key string) bool { |
|||
for _, v := range byKey[key] { |
|||
if strings.TrimSpace(v) != "" { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// planEnvKeys 落点表里出现过的全部 env 键名,用于识别「填错列」的历史行(见 normalizeAppCfgRow)。
|
|||
func planEnvKeys() map[string]bool { |
|||
m := make(map[string]bool, 32) |
|||
for _, spec := range envToSvcPlan { |
|||
for _, f := range spec.Fields { |
|||
m[f.EnvKey] = true |
|||
} |
|||
} |
|||
return m |
|||
} |
|||
|
|||
// normalizeAppCfgRow 读一行业务库 config,返回它**真正表达**的 (键, 值)。
|
|||
//
|
|||
// 绝大多数行直接就是 (key, value)。但线上存在「填错列」的历史行:运营把键名填进了 group、
|
|||
// 把值填进了 key,value 留空——
|
|||
//
|
|||
// group=ALIBABA_BAILIAN_APP_ID key=mm_982f2157… value=''
|
|||
//
|
|||
// 后果是 AppConfig.env('ALIBABA_BAILIAN_APP_ID') 恒为 null,客户端退回代码里写死的兜底值,
|
|||
// 后台改这两行毫无效果且不报任何错。识别条件卡得很死——**group 必须正好是落点表里的一个
|
|||
// env 键名**、且 value 为空、key 非空——不满足就原样返回,不做任何猜测。
|
|||
//
|
|||
// 保持纯函数(不打日志):日志由调用方按 key != it.Key 判断后补,便于单测直接调
|
|||
// (lego 的 log 子系统在单测里没初始化,函数内部打日志会直接 panic)。
|
|||
func normalizeAppCfgRow(it *pb.DBAppConfigItem) (key, value string) { |
|||
if strings.TrimSpace(it.Value) == "" && strings.TrimSpace(it.Key) != "" && planEnvKeys()[it.Group] { |
|||
return it.Group, it.Key |
|||
} |
|||
return it.Key, it.Value |
|||
} |
|||
|
|||
// migrateEnvToSvcConfig 主入口。encKey = options.EncryptKey。
|
|||
// 任何一步失败都只记日志不中断启动:迁移是幂等的,下次启动会重来;
|
|||
// 而 console 起不来的后果(整个后台不可用)比晚一轮迁移严重得多。
|
|||
func migrateEnvToSvcConfig(encKey string, apps []*AppRegistry) { |
|||
// ① 把 global_config 读成 key → region → value。
|
|||
type gcRow struct { |
|||
Region int32 `gorm:"column:region"` |
|||
Key string `gorm:"column:key"` |
|||
Value string `gorm:"column:value"` |
|||
} |
|||
rows := make([]*gcRow, 0) |
|||
if err := postgres.GetSys().Raw( |
|||
`SELECT region, "key", value FROM ` + comm.TableGlobalConfig).Scan(&rows).Error; err != nil { |
|||
// 表不存在(全新部署)是正常情况,不是错误。
|
|||
log.Infof("console.migrate: global_config 读取跳过(%v),仅按应用业务库迁移", err) |
|||
rows = nil |
|||
} |
|||
byKey := map[string]map[int32]string{} |
|||
for _, r := range rows { |
|||
k := strings.TrimSpace(r.Key) |
|||
if k == "" { |
|||
continue |
|||
} |
|||
if byKey[k] == nil { |
|||
byKey[k] = map[int32]string{} |
|||
} |
|||
// 同 key 同 region 出现多行(历史重复录入)时后写的赢;只有空值不覆盖非空值。
|
|||
if v, ok := byKey[k][r.Region]; ok && strings.TrimSpace(r.Value) == "" && v != "" { |
|||
continue |
|||
} |
|||
byKey[k][r.Region] = r.Value |
|||
} |
|||
|
|||
// ② app_config 的值作为「global_config 没有该 key 时」的兜底(与迁移前的合并口径一致)。
|
|||
appFallback := map[string]string{} |
|||
fallbackOwner := map[string]string{} // env 键 → 提供兜底值的那个应用(多应用时用于判定能不能删)
|
|||
appConns := map[string]mysql.ISys{} // appName → 业务库连接,第 ③ 步删键时复用
|
|||
for _, app := range apps { |
|||
if !app.Enabled || app.ServiceDsn == "" { |
|||
continue |
|||
} |
|||
appName := app.Name |
|||
db, err := openDriver(app.ServiceDsn) |
|||
if err != nil { |
|||
log.Warnf("console.migrate: 应用 %s 业务库连接失败,跳过: %v", app.Name, err) |
|||
continue |
|||
} |
|||
items := make([]*pb.DBAppConfigItem, 0) |
|||
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { |
|||
log.Warnf("console.migrate: 应用 %s 读 config 表失败,跳过: %v", app.Name, err) |
|||
continue |
|||
} |
|||
appConns[app.Name] = db |
|||
for _, it := range items { |
|||
k, v := normalizeAppCfgRow(it) |
|||
if k == "" { |
|||
continue |
|||
} |
|||
if k != it.Key { |
|||
log.Infof("console.migrate: 应用 %s 的 config 行填错了列(group=%s 才是键名,key 里装的是值),迁移时已按正确含义读取", |
|||
appName, it.Group) |
|||
} |
|||
if _, ok := appFallback[k]; !ok && strings.TrimSpace(v) != "" { |
|||
appFallback[k] = v |
|||
fallbackOwner[k] = appName |
|||
} else if ok && appFallback[k] != v && strings.TrimSpace(v) != "" && !globalCovered(byKey, k) { |
|||
// 两个应用对同一个「全局表里没有的」键给了不同的值:归一成一条全局服务行会让
|
|||
// 其中一个应用改用别人的凭据。这里只告警不擅自选边,下面 cleanAppConfig 会保留它的行。
|
|||
log.Warnf("console.migrate: 键 %s 在应用 %s 与 %s 里值不同,且 global_config 没有它——"+ |
|||
"已按 %s 的值建全局服务行,%s 的配置原样保留,请到「第三方服务配置」为它建应用级覆盖", |
|||
k, fallbackOwner[k], appName, fallbackOwner[k], appName) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ③ 按落点表建/补服务。
|
|||
migrated := map[string]bool{} // 已经真正落到 svc_config 里的 env 键
|
|||
for _, spec := range envToSvcPlan { |
|||
if err := upsertEnvSvc(spec, byKey, appFallback, encKey, migrated); err != nil { |
|||
log.Warnf("console.migrate: 服务 %s 迁移失败: %v", spec.Id, err) |
|||
} |
|||
} |
|||
|
|||
// ④ 业务库 config 表清理:只删**确认已迁走**的键。没迁成功的一个都不碰。
|
|||
for name, db := range appConns { |
|||
cleanAppConfig(name, db, migrated, byKey, fallbackOwner) |
|||
} |
|||
|
|||
if len(migrated) > 0 { |
|||
keys := make([]string, 0, len(migrated)) |
|||
for k := range migrated { |
|||
keys = append(keys, k) |
|||
} |
|||
sort.Strings(keys) |
|||
log.Infof("console.migrate: 凭据已归一到 svc_config(%d 个键):%s", len(keys), strings.Join(keys, ", ")) |
|||
log.Infof("console.migrate: global_config 已无代码读取,确认新链路无误后可手工 DROP TABLE %s", comm.TableGlobalConfig) |
|||
} |
|||
} |
|||
|
|||
// planEnvSvcValues 纯函数:给定一个服务的落点定义与两张老表的内容,算出
|
|||
//
|
|||
// base 该服务每个字段的基础值(fieldKey → 值)
|
|||
// regions 需要写成区域分叉的值(region → fieldKey → 值)
|
|||
//
|
|||
// 口径(与迁移前的下发结果对齐,这是本函数存在的全部意义):
|
|||
// - 基础值取**中国区**的现值 → 区域 0 → 业务库 config 表兜底。选中国区是因为当前只有一个
|
|||
// 应用且 app_registry.region=cn;其余区域一律用分叉表达差异。
|
|||
// - 只有**与基础值不同**的区域才写分叉。global_config 里 31 个键里有 27 个是 14 个区域同值复制
|
|||
// (实测),不做这一步就会把 473 行的重复原样搬进新表,等于换个地方继续乱。
|
|||
// - 空值不写:区域那行是空串时按「该区域没配」处理,退回基础值,与老的
|
|||
// 「env[k] 只在 global_config 有该区域行时才覆盖」一致。
|
|||
func planEnvSvcValues(spec envSvcSpec, byKey map[string]map[int32]string, appFallback map[string]string, |
|||
) (base map[string]string, regions map[int32]map[string]string) { |
|||
|
|||
cn := int32(pb.Region_RegionChina) |
|||
base = make(map[string]string, len(spec.Fields)) |
|||
regions = map[int32]map[string]string{} |
|||
|
|||
for _, f := range spec.Fields { |
|||
m := byKey[f.EnvKey] |
|||
b := "" |
|||
if m != nil { |
|||
if v, ok := m[cn]; ok && strings.TrimSpace(v) != "" { |
|||
b = v |
|||
} else if v, ok := m[0]; ok && strings.TrimSpace(v) != "" { |
|||
b = v |
|||
} |
|||
} |
|||
if b == "" { |
|||
b = appFallback[f.EnvKey] |
|||
} |
|||
base[f.FieldKey] = b |
|||
|
|||
for region, v := range m { |
|||
if region == 0 || region == cn || strings.TrimSpace(v) == "" || v == b { |
|||
continue |
|||
} |
|||
if regions[region] == nil { |
|||
regions[region] = map[string]string{} |
|||
} |
|||
regions[region][f.FieldKey] = v |
|||
} |
|||
} |
|||
return |
|||
} |
|||
|
|||
// hasAnyValue 整份落点一个值都取不到时不建服务——免得后台平白多出一堆空壳卡片。
|
|||
func hasAnyValue(base map[string]string) bool { |
|||
for _, v := range base { |
|||
if strings.TrimSpace(v) != "" { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// upsertEnvSvc 建或补一个服务。幂等:
|
|||
// - 服务已存在 → 保留它的名称/类别/启用状态,只补「缺失的字段」和「字段上缺失的 env_key」;
|
|||
// - 字段已有非空 DefValue → 不覆盖(运营后来改过的值优先于历史 env 值)。
|
|||
//
|
|||
// ⚠️ migrated 只在**落库成功之后**才写。调用方拿它去删业务库 config 里的对应行——
|
|||
// 提前标记会让「保存失败」变成「两张表都没有这个凭据」,那是不可逆的丢数据。
|
|||
func upsertEnvSvc(spec envSvcSpec, byKey map[string]map[int32]string, appFallback map[string]string, |
|||
encKey string, migrated map[string]bool) error { |
|||
|
|||
base, regionVals := planEnvSvcValues(spec, byKey, appFallback) |
|||
if !hasAnyValue(base) { |
|||
return nil |
|||
} |
|||
|
|||
now := time.Now().Unix() |
|||
svc := &comm.ThirdSvcConfig{} |
|||
exists := true |
|||
if err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", spec.Id); err != nil { |
|||
if err != postgres.ErrNoDocuments { |
|||
return err |
|||
} |
|||
exists = false |
|||
svc = &comm.ThirdSvcConfig{ |
|||
AppName: "", Id: spec.Id, Name: spec.Name, Provider: spec.Provider, |
|||
Categories: spec.Categories, Description: spec.Desc, Enable: true, |
|||
Fields: []comm.SvcField{}, Createtime: now, |
|||
} |
|||
} |
|||
|
|||
have := map[string]int{} |
|||
for i, f := range svc.Fields { |
|||
have[f.Key] = i |
|||
} |
|||
done := map[string]bool{} // 本次确实带上了值的 env 键,落库成功后才并进 migrated
|
|||
changed := !exists |
|||
for i, spf := range spec.Fields { |
|||
val := base[spf.FieldKey] |
|||
idx, ok := have[spf.FieldKey] |
|||
if !ok { |
|||
// 新字段:带上值(加密字段先加密)。
|
|||
stored, err := storeVal(val, spf.Encrypted, encKey) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
svc.Fields = append(svc.Fields, comm.SvcField{ |
|||
Key: spf.FieldKey, Description: spf.Desc, Encrypted: spf.Encrypted, |
|||
DefValue: stored, Sort: int32(i), EnvKey: spf.EnvKey, |
|||
}) |
|||
have[spf.FieldKey] = len(svc.Fields) - 1 |
|||
changed = true |
|||
if strings.TrimSpace(val) != "" { |
|||
done[spf.EnvKey] = true |
|||
} |
|||
continue |
|||
} |
|||
// 已有字段:只补 env_key 与空值,不动运营填过的内容。
|
|||
if svc.Fields[idx].EnvKey == "" { |
|||
svc.Fields[idx].EnvKey = spf.EnvKey |
|||
changed = true |
|||
} |
|||
if strings.TrimSpace(svc.Fields[idx].DefValue) == "" && strings.TrimSpace(val) != "" { |
|||
stored, err := storeVal(val, svc.Fields[idx].Encrypted, encKey) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
svc.Fields[idx].DefValue = stored |
|||
changed = true |
|||
} |
|||
if strings.TrimSpace(svc.Fields[idx].DefValue) != "" && svc.Fields[idx].EnvKey == spf.EnvKey { |
|||
done[spf.EnvKey] = true |
|||
} |
|||
} |
|||
|
|||
// ⚠️ 带着迁移来的凭据就必须是启用状态。老的 global_config 是**无条件**下发这些键的,
|
|||
// 而 svc_config 的 enable=false 会让 resolveThirdSvcs 整条跳过——真机上 stt_azure
|
|||
// 恰好是停用的,照搬这个状态就等于把 AZURE_SPEECH_KEY/REGION 从 env 里抹掉,
|
|||
// 表现为 Azure 语音识别/合成突然鉴权失败,而后台看上去一切正常。
|
|||
// 运营要停用某个服务,迁移之后再停——那时停用的语义才是真的「不下发」。
|
|||
if len(done) > 0 && !svc.Enable { |
|||
svc.Enable = true |
|||
changed = true |
|||
log.Infof("console.migrate: 服务 %s 原为停用,因承接了老 env 键(%d 个)已置为启用——"+ |
|||
"老链路是无条件下发这些凭据的,保持停用会让客户端拿不到", spec.Id, len(done)) |
|||
} |
|||
|
|||
if changed { |
|||
svc.Updatetime = now |
|||
if exists { |
|||
if err := postgres.Save(comm.TableSvcConfig, svc); err != nil { |
|||
return err |
|||
} |
|||
} else if err := postgres.Insert(comm.TableSvcConfig, svc); err != nil { |
|||
return err |
|||
} |
|||
log.Infof("console.migrate: 服务 %s 已%s(%d 字段)", spec.Id, map[bool]string{true: "补齐", false: "建立"}[exists], len(svc.Fields)) |
|||
} |
|||
// 走到这里说明基础值已经在库里了(changed=false 时是上一轮就写好的),可以放心标记。
|
|||
for k := range done { |
|||
migrated[k] = true |
|||
} |
|||
|
|||
// 区域分叉:只为「与基础值不同」的区域写覆盖,同值区域一行都不写。
|
|||
return upsertEnvSvcRegions(spec, svc, regionVals, encKey) |
|||
} |
|||
|
|||
// upsertEnvSvcRegions 把 planEnvSvcValues 算出的区域差异落成 svc_region_override,
|
|||
// 并**修掉会把基础值抹成空的历史空覆盖**。
|
|||
//
|
|||
// ⚠️ 后面这半件事是真机踩出来的:svc_config 里早就存在的 stt_azure 带着 14 行区域覆盖,
|
|||
// 内容是 {"region":"", "subscription_key":""}——当初建服务时留下的空壳。而
|
|||
// ResolveSvcPlainFields 判的是「这个 key 在 Overrides 里**存不存在**」,存在就用,
|
|||
// 哪怕值是空串。于是迁移把凭据填进基础值之后,任何一个区域的用户拿到的仍然是空字符串,
|
|||
// 表现为 Azure 语音识别/合成鉴权失败,而后台看上去字段明明填好了。
|
|||
//
|
|||
// 所以对**本迁移拥有的字段**(落点表里带 env_key 的那些)额外做一次清理:
|
|||
// - 该区域有真值 → 写进去(空覆盖视同没填,可以覆盖);
|
|||
// - 该区域没真值 → 把空覆盖**删掉**,让基础值生效。
|
|||
//
|
|||
// 运营真正填过的非空覆盖一律不动。
|
|||
func upsertEnvSvcRegions(spec envSvcSpec, svc *comm.ThirdSvcConfig, regionVals map[int32]map[string]string, encKey string) error { |
|||
owned := make(map[string]bool, len(spec.Fields)) |
|||
for _, f := range spec.Fields { |
|||
owned[f.FieldKey] = true |
|||
} |
|||
encOf := map[string]bool{} |
|||
for _, f := range svc.Fields { |
|||
encOf[f.Key] = f.Encrypted |
|||
} |
|||
|
|||
// 已存在的覆盖行也要过一遍(它们可能带着空壳覆盖),所以取「已有区域 ∪ 有差异的区域」。
|
|||
existing := make([]*comm.SvcRegionOverride, 0) |
|||
if err := postgres.Find(comm.TableSvcRegionOverride, &existing, |
|||
"app_name=? AND svc_id=?", "", spec.Id); err != nil && err != postgres.ErrNoDocuments { |
|||
return err |
|||
} |
|||
byRegion := make(map[int32]*comm.SvcRegionOverride, len(existing)) |
|||
regionSet := map[int32]bool{} |
|||
for _, o := range existing { |
|||
byRegion[o.Region] = o |
|||
regionSet[o.Region] = true |
|||
} |
|||
for r := range regionVals { |
|||
regionSet[r] = true |
|||
} |
|||
if len(regionSet) == 0 { |
|||
return nil |
|||
} |
|||
regions := make([]int, 0, len(regionSet)) |
|||
for r := range regionSet { |
|||
regions = append(regions, int(r)) |
|||
} |
|||
sort.Ints(regions) // map 迭代无序,排一下让写入顺序与日志可复现
|
|||
|
|||
written, cleaned := 0, 0 |
|||
for _, ri := range regions { |
|||
region := int32(ri) |
|||
ovr, exists := byRegion[region] |
|||
if !exists { |
|||
ovr = &comm.SvcRegionOverride{AppName: "", SvcId: spec.Id, Region: region, |
|||
Overrides: map[string]string{}, ExtraFields: []comm.SvcField{}, DisabledKeys: []string{}} |
|||
} |
|||
if ovr.Overrides == nil { |
|||
ovr.Overrides = map[string]string{} |
|||
} |
|||
changed := !exists |
|||
for fk := range owned { |
|||
cur, has := ovr.Overrides[fk] |
|||
blank := has && strings.TrimSpace(cur) == "" |
|||
want := regionVals[region][fk] |
|||
if strings.TrimSpace(want) != "" { |
|||
if has && !blank { |
|||
continue // 运营填过真值,不动
|
|||
} |
|||
stored, err := storeVal(want, encOf[fk], encKey) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
ovr.Overrides[fk] = stored |
|||
changed = true |
|||
written++ |
|||
continue |
|||
} |
|||
if blank { |
|||
delete(ovr.Overrides, fk) // 空壳覆盖会把基础值抹成空,删掉让基础值生效
|
|||
changed = true |
|||
cleaned++ |
|||
} |
|||
} |
|||
if !changed { |
|||
continue |
|||
} |
|||
ovr.Updatetime = time.Now().Unix() |
|||
if exists { |
|||
if err := postgres.Save(comm.TableSvcRegionOverride, ovr); err != nil { |
|||
return err |
|||
} |
|||
} else if err := postgres.Insert(comm.TableSvcRegionOverride, ovr); err != nil { |
|||
return err |
|||
} |
|||
} |
|||
if written > 0 || cleaned > 0 { |
|||
log.Infof("console.migrate: 服务 %s 区域覆盖:写入 %d 项差异,清掉 %d 项会抹空基础值的空覆盖", |
|||
spec.Id, written, cleaned) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// storeVal 按字段是否加密决定入库形态。空值不加密(加密空串会得到一段无意义密文,
|
|||
// 而 ResolveSvcPlainFields 对空值是直接放行的,两边口径要一致)。
|
|||
func storeVal(val string, encrypted bool, encKey string) (string, error) { |
|||
if strings.TrimSpace(val) == "" { |
|||
return "", nil |
|||
} |
|||
if !encrypted { |
|||
return val, nil |
|||
} |
|||
return comm.Encrypt(encKey, val) |
|||
} |
|||
|
|||
// cleanAppConfig 删掉业务库 config 表里那些已经迁进 svc_config 的凭据键,以及确认无人读取的死键。
|
|||
//
|
|||
// ⚠️ 只删「本次确认迁走了」的键。迁移没成功的键留在原地——宁可后台多显示几行,
|
|||
// 也不能出现「两张表都没有值」的窗口。
|
|||
// ⚠️ 非凭据的应用参数(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ / COS_* / 算力与运营参数…)
|
|||
// 一律不碰:它们本来就该住在「应用环境配置」里。
|
|||
func cleanAppConfig(appName string, db mysql.ISys, migrated map[string]bool, |
|||
byKey map[string]map[int32]string, fallbackOwner map[string]string) { |
|||
items := make([]*pb.DBAppConfigItem, 0) |
|||
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { |
|||
return |
|||
} |
|||
dropIds := make([]uint64, 0) |
|||
dropKeys := make([]string, 0) |
|||
for _, it := range items { |
|||
// 填错列的历史行按它真正表达的键名判定,否则永远清不掉(key 里装的是值)。
|
|||
k, _ := normalizeAppCfgRow(it) |
|||
reason := "" |
|||
switch { |
|||
case envKeysDropped[k] != "": |
|||
reason = envKeysDropped[k] |
|||
case migrated[k] && globalCovered(byKey, k): |
|||
// 全局表里有这个键 → 应用自己这行在迁移前就被盖住、从来没生效过,删掉不改变行为。
|
|||
reason = "已迁入 svc_config(原本就被 global_config 盖住)" |
|||
case migrated[k] && fallbackOwner[k] == appName: |
|||
// 全局表没有,值就是从这个应用取的,已原样搬进全局服务行。
|
|||
reason = "已迁入 svc_config" |
|||
case migrated[k]: |
|||
// 全局表没有、值也不是从本应用取的:删了就等于让本应用改用别的应用的凭据。保留。
|
|||
log.Warnf("console.migrate: 应用 %s 的 %s 未清理——全局服务行里的值来自应用 %s,"+ |
|||
"两者可能不同;如需按应用区分,请在「第三方服务配置」里为本应用建覆盖行", |
|||
appName, k, fallbackOwner[k]) |
|||
continue |
|||
} |
|||
if reason == "" { |
|||
continue |
|||
} |
|||
dropIds = append(dropIds, it.Id) |
|||
dropKeys = append(dropKeys, fmt.Sprintf("%s(%s)", k, reason)) |
|||
} |
|||
if len(dropIds) == 0 { |
|||
return |
|||
} |
|||
if err := db.Delete(comm.TableAppConfig, "id IN ?", dropIds); err != nil { |
|||
log.Warnf("console.migrate: 应用 %s 清理 config 表失败: %v", appName, err) |
|||
return |
|||
} |
|||
log.Infof("console.migrate: 应用 %s 的 config 表清掉 %d 个已归一的键:%s", |
|||
appName, len(dropKeys), strings.Join(dropKeys, ", ")) |
|||
} |
|||
@ -0,0 +1,191 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 迁移口径必须与迁移**前**的下发结果逐键一致——错一个键,已发布的客户端就拿不到凭据,
|
|||
// 而且只表现为「某个能力鉴权失败」,不报配置错误。下面的数据取自 2026-09-12 线上真实布局:
|
|||
// global_config 里 31 个键中只有 4 组因区域而异,其余 27 个是 14 个区域同值复制。
|
|||
func TestPlanEnvSvcValues(t *testing.T) { |
|||
const ( |
|||
cn = int32(pb.Region_RegionChina) |
|||
us = int32(pb.Region_RegionUSA) |
|||
ea = int32(pb.Region_RegionEastAsia) |
|||
) |
|||
spec := envSvcSpec{ |
|||
Id: "stt_azure", |
|||
Fields: []envFieldSpec{ |
|||
{EnvKey: "AZURE_SPEECH_KEY", FieldKey: "subscription_key", Encrypted: true}, |
|||
{EnvKey: "AZURE_SPEECH_REGION", FieldKey: "region"}, |
|||
{EnvKey: "AZURE_SPEECH_ENDPOINT", FieldKey: "endpoint"}, |
|||
}, |
|||
} |
|||
|
|||
t.Run("基础值取中国区,同值区域不写分叉", func(t *testing.T) { |
|||
byKey := map[string]map[int32]string{ |
|||
// 真实布局:key 每区不同,region 每区不同,但很多区其实同值
|
|||
"AZURE_SPEECH_KEY": {cn: "k-cn", us: "k-us", ea: "k-cn"}, |
|||
"AZURE_SPEECH_REGION": {cn: "southeastasia", us: "southeastasia", ea: "eastasia"}, |
|||
} |
|||
base, regions := planEnvSvcValues(spec, byKey, nil) |
|||
if base["subscription_key"] != "k-cn" { |
|||
t.Fatalf("基础值应取中国区,实际 %q", base["subscription_key"]) |
|||
} |
|||
if base["region"] != "southeastasia" { |
|||
t.Fatalf("基础值应取中国区,实际 %q", base["region"]) |
|||
} |
|||
// 美区 key 不同 → 要分叉;美区 region 与中国区同值 → 不该出现在分叉里
|
|||
if regions[us]["subscription_key"] != "k-us" { |
|||
t.Fatalf("美区 key 不同,应写分叉,实际 %+v", regions[us]) |
|||
} |
|||
if _, ok := regions[us]["region"]; ok { |
|||
t.Fatal("与基础值同值的区域字段不能写分叉——那正是老表 473 行重复的来源") |
|||
} |
|||
// 东亚 key 与中国区同值、region 不同 → 只分叉 region
|
|||
if _, ok := regions[ea]["subscription_key"]; ok { |
|||
t.Fatal("东亚 key 与中国区同值,不该写分叉") |
|||
} |
|||
if regions[ea]["region"] != "eastasia" { |
|||
t.Fatalf("东亚 region 不同,应写分叉,实际 %+v", regions[ea]) |
|||
} |
|||
}) |
|||
|
|||
t.Run("global_config 没有该键时回落业务库 config", func(t *testing.T) { |
|||
byKey := map[string]map[int32]string{} |
|||
base, regions := planEnvSvcValues(spec, byKey, map[string]string{ |
|||
"AZURE_SPEECH_KEY": "from-app-config", |
|||
}) |
|||
if base["subscription_key"] != "from-app-config" { |
|||
t.Fatalf("应回落业务库 config 的值,实际 %q", base["subscription_key"]) |
|||
} |
|||
if len(regions) != 0 { |
|||
t.Fatalf("没有区域数据就不该有分叉,实际 %+v", regions) |
|||
} |
|||
}) |
|||
|
|||
t.Run("中国区缺行时退回区域0,再退回业务库", func(t *testing.T) { |
|||
byKey := map[string]map[int32]string{ |
|||
"AZURE_SPEECH_KEY": {0: "k-default", us: "k-us"}, |
|||
} |
|||
base, regions := planEnvSvcValues(spec, byKey, map[string]string{"AZURE_SPEECH_KEY": "k-app"}) |
|||
if base["subscription_key"] != "k-default" { |
|||
t.Fatalf("中国区缺行应退回区域0,实际 %q", base["subscription_key"]) |
|||
} |
|||
if regions[us]["subscription_key"] != "k-us" { |
|||
t.Fatal("与基础值不同的区域仍要分叉") |
|||
} |
|||
}) |
|||
|
|||
t.Run("区域行是空串按没配处理", func(t *testing.T) { |
|||
byKey := map[string]map[int32]string{ |
|||
"AZURE_SPEECH_KEY": {cn: "k-cn", us: " "}, |
|||
} |
|||
_, regions := planEnvSvcValues(spec, byKey, nil) |
|||
if _, ok := regions[us]; ok { |
|||
t.Fatal("空值区域不该写分叉——老逻辑下它会下发空串覆盖,那本身就是配置事故") |
|||
} |
|||
}) |
|||
|
|||
t.Run("整份取不到值的服务不建", func(t *testing.T) { |
|||
base, _ := planEnvSvcValues(spec, nil, nil) |
|||
if hasAnyValue(base) { |
|||
t.Fatalf("一个值都没有时不该判定为可建,实际 %+v", base) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 落点表本身的自检:EnvKey 是发给已发布客户端的键名,全局不能重名——重名时
|
|||
// 下发结果取决于服务 id 排序,表现为「凭据时对时不对」。FieldKey 在同一个服务内也不能重名。
|
|||
func TestEnvToSvcPlanNoDuplicateKeys(t *testing.T) { |
|||
seenEnv := map[string]string{} |
|||
seenSvc := map[string]bool{} |
|||
for _, spec := range envToSvcPlan { |
|||
if seenSvc[spec.Id] { |
|||
t.Fatalf("服务 id 重复: %s", spec.Id) |
|||
} |
|||
seenSvc[spec.Id] = true |
|||
fields := map[string]bool{} |
|||
for _, f := range spec.Fields { |
|||
if f.EnvKey == "" { |
|||
t.Fatalf("服务 %s 的字段 %s 没有 env_key——那它就不会下发给老客户端", spec.Id, f.FieldKey) |
|||
} |
|||
if owner, ok := seenEnv[f.EnvKey]; ok { |
|||
t.Fatalf("下发键名 %s 被 %s 和 %s 同时占用", f.EnvKey, owner, spec.Id) |
|||
} |
|||
seenEnv[f.EnvKey] = spec.Id |
|||
if fields[f.FieldKey] { |
|||
t.Fatalf("服务 %s 的字段名 %s 重复", spec.Id, f.FieldKey) |
|||
} |
|||
fields[f.FieldKey] = true |
|||
} |
|||
if spec.Categories == "" { |
|||
t.Fatalf("服务 %s 没有类别——第三方服务配置页按类别分组,没类别就只会落进「未分类」", spec.Id) |
|||
} |
|||
} |
|||
// 被明确丢弃的键不能同时又出现在落点表里
|
|||
for k := range envKeysDropped { |
|||
if owner, ok := seenEnv[k]; ok { |
|||
t.Fatalf("键 %s 既在丢弃清单里、又被服务 %s 迁移,口径矛盾", k, owner) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 线上存在「填错列」的 config 行(group 里是键名、key 里是值、value 空),
|
|||
// 后果是客户端读不到这个键、后台怎么改都没效果。迁移必须按它真正表达的含义读,
|
|||
// 否则 ALIBABA_BAILIAN_* 会在归一之后继续丢失。
|
|||
func TestNormalizeAppCfgRow(t *testing.T) { |
|||
t.Run("填错列的行按 group 当键名读", func(t *testing.T) { |
|||
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{ |
|||
Group: "ALIBABA_BAILIAN_APP_ID", Key: "mm_982f215733384f79aa5072c3c2bf", Value: "", |
|||
}) |
|||
if k != "ALIBABA_BAILIAN_APP_ID" || v != "mm_982f215733384f79aa5072c3c2bf" { |
|||
t.Fatalf("应识别为 (键=group, 值=key),实际 (%q, %q)", k, v) |
|||
} |
|||
}) |
|||
t.Run("正常行原样返回", func(t *testing.T) { |
|||
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{Group: "AI", Key: "AGENT_TYPE", Value: "default"}) |
|||
if k != "AGENT_TYPE" || v != "default" { |
|||
t.Fatalf("正常行不该被改写,实际 (%q, %q)", k, v) |
|||
} |
|||
}) |
|||
t.Run("group 不是落点表里的键名就不猜", func(t *testing.T) { |
|||
// 值确实为空的普通配置行很常见,绝不能把它的 group 当成键名
|
|||
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{Group: "APP", Key: "APP_AUTHCODE_OPEN", Value: ""}) |
|||
if k != "APP_AUTHCODE_OPEN" || v != "" { |
|||
t.Fatalf("不满足识别条件的行必须原样返回,实际 (%q, %q)", k, v) |
|||
} |
|||
}) |
|||
t.Run("value 非空时一律原样", func(t *testing.T) { |
|||
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{ |
|||
Group: "ALIBABA_BAILIAN_APP_ID", Key: "x", Value: "real", |
|||
}) |
|||
if k != "x" || v != "real" { |
|||
t.Fatalf("value 有值说明这行是正常的,实际 (%q, %q)", k, v) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 多应用部署下的清理判定:这是个 SaaS 平台,一台 console 管多个应用的业务库。
|
|||
// 删错一行就等于让某个应用改用别人的凭据,且不报任何错。
|
|||
func TestGlobalCovered(t *testing.T) { |
|||
byKey := map[string]map[int32]string{ |
|||
"OPENAI_API_KEY": {1: "k-cn", 2: "k-us"}, |
|||
"ALIBABA_BAILIAN_APP_ID": {}, // 全局表里没有
|
|||
"EMPTY_EVERYWHERE": {1: "", 2: " "}, // 有行但全是空
|
|||
} |
|||
if !globalCovered(byKey, "OPENAI_API_KEY") { |
|||
t.Fatal("全局表有非空值就算覆盖——应用自己那行在迁移前就没生效过") |
|||
} |
|||
if globalCovered(byKey, "ALIBABA_BAILIAN_APP_ID") { |
|||
t.Fatal("全局表没有该键,应用自己那行才是生效值,不能当死值删") |
|||
} |
|||
if globalCovered(byKey, "EMPTY_EVERYWHERE") { |
|||
t.Fatal("全是空值不算覆盖") |
|||
} |
|||
if globalCovered(byKey, "NOT_PRESENT") { |
|||
t.Fatal("键根本不存在时不算覆盖") |
|||
} |
|||
} |
|||
@ -1,185 +0,0 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
"strings" |
|||
"time" |
|||
) |
|||
|
|||
// @Summary 扫激活码领取奖励
|
|||
// @Description 新设计:激活码 = 厂家公码。每个用户一生仅可领取 1 次(DBUser.Isactivatecode 判重)
|
|||
// @Tags User
|
|||
// @Accept json
|
|||
// @Produce json
|
|||
// @Security BearerAuth
|
|||
// @Param user body pb.UserBindAuthCodeReq true "扫激活码请求"
|
|||
// @Success 200 {object} comm.HttpResult{data=pb.UserBindAuthCodeResp} "成功返回"
|
|||
// @Router /api/home/user_bindauthcode [post]
|
|||
func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAuthCodeReq) (resp *pb.UserBindAuthCodeResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
user *pb.DBUser |
|||
code *pb.DBFactoryPublicCode |
|||
err error |
|||
now = time.Now().Unix() |
|||
codeStr = strings.ToUpper(strings.TrimSpace(req.Code)) |
|||
affected int64 |
|||
) |
|||
|
|||
if codeStr == "" { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_ReqParameterError, |
|||
Message: "激活码不能为空", |
|||
} |
|||
return |
|||
} |
|||
|
|||
if user, err = this.module.model.findforid(session.GetUserId()); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_UserSessionNobeing, |
|||
Message: err.Error(), |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 1) 快速判重:user.Isactivatecode 为 true 表示已经领过激活码奖励
|
|||
if user.Isactivatecode { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeNoCanUse, |
|||
Message: "您已领取过激活码奖励,不可重复领取", |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 2) 校验公码
|
|||
if code, err = this.module.model.getFactoryPublicCode(codeStr); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeIllegal, |
|||
Message: "激活码无效", |
|||
} |
|||
return |
|||
} |
|||
if code.Status != 0 { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeNoCanUse, |
|||
Message: "激活码已停用", |
|||
} |
|||
return |
|||
} |
|||
if code.Expiretime > 0 && code.Expiretime < now { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeNoCanUse, |
|||
Message: "激活码已过期", |
|||
} |
|||
return |
|||
} |
|||
if code.Maxuses > 0 && code.Useduses >= code.Maxuses { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeNoCanUse, |
|||
Message: "激活码已达核销上限", |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 3) 原子置位 user.isactivatecode → 防同账号并发扫码兜底,输的请求 affected=0
|
|||
if affected, err = this.module.model.tryMarkActivateCode(user.Uid); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
return |
|||
} |
|||
if affected == 0 { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeNoCanUse, |
|||
Message: "您已领取过激活码奖励,不可重复领取", |
|||
} |
|||
return |
|||
} |
|||
user.Isactivatecode = true |
|||
|
|||
// 4) 原子扣减公码名额(CAS 防超发);失败需回滚 user.isactivatecode 以便用户换码重试
|
|||
if affected, err = this.module.model.incrFactoryPublicCodeUsed(codeStr); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
return |
|||
} |
|||
if affected == 0 { |
|||
// 公码已达上限:回滚 user.isactivatecode,让用户换码重试
|
|||
if rbAff, rbErr := this.module.model.rollbackActivateCode(user.Uid); rbErr != nil || rbAff == 0 { |
|||
this.module.Error("BindAuthCode rollback isactivatecode fail", |
|||
log.Field{Key: "uid", Value: user.Uid}, |
|||
log.Field{Key: "rbAff", Value: rbAff}) |
|||
} |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_AuthorizeNoCanUse, |
|||
Message: "激活码已达核销上限", |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 5) 计算奖励:VIP 按天累加到自购到期时间(公码是发给**账号**的,归属用户,与设备无关);
|
|||
// 公码上配的翻译/会议分钟数(旧字段)按当前换算系数折成算力发到用户余额。
|
|||
addVipDay := int64(code.Bindrewardvpitime) |
|||
addTradeSec := int64(code.Bindrewardtranslate) * 60 |
|||
addMeetSec := int64(code.Bindrewardmeeting) * 60 |
|||
addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0) |
|||
if addVipDay > 0 { |
|||
addVipSec := addVipDay * 24 * 60 * 60 |
|||
if user.Vipexptime == 0 || user.Vipexptime < now { |
|||
user.Vipexptime = now + addVipSec |
|||
} else { |
|||
user.Vipexptime += addVipSec |
|||
} |
|||
} |
|||
user.Computebalance += addCompute |
|||
|
|||
// 6) 保存用户余额(isactivatecode 已经在步骤 3 提前落库,这里 Save 会覆盖一并写回,是幂等的)
|
|||
if err = this.module.model.save(user); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: err.Error(), |
|||
} |
|||
return |
|||
} |
|||
|
|||
if addVipDay > 0 || addCompute > 0 { |
|||
_ = this.module.model.addIntegralLog(&pb.DBUserUseLog{ |
|||
Uid: user.Uid, |
|||
Logtype: pb.UserLogType_ActivityReward, |
|||
Ts: now, |
|||
Addvipday: addVipDay, |
|||
Compute: addCompute, |
|||
Extra: code.Code, |
|||
}) |
|||
} |
|||
|
|||
// 埋点:二维码(公码)激活成功。只统计到 应用×区域(ProductId 留 0,不落地到产品),与用户口径一致。
|
|||
if this.module.analyze != nil { |
|||
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventQRCodeActive, Uid: user.Uid}) |
|||
} |
|||
|
|||
this.module.Debug("BindAuthCode", |
|||
log.Field{Key: "uid", Value: user.Uid}, |
|||
log.Field{Key: "code", Value: code.Code}, |
|||
log.Field{Key: "factoryid", Value: code.Brandid}) |
|||
|
|||
resp = &pb.UserBindAuthCodeResp{ |
|||
Isgiveaway: addVipDay > 0 || addTradeSec > 0 || addMeetSec > 0, |
|||
Viplv: user.Viplv, |
|||
Vipexptime: user.Vipexptime, |
|||
Tradeintegral: user.Tradeintegral, |
|||
Tradetotalintegral: user.Tradetotalintegral, |
|||
Meetintegral: user.Meetintegral, |
|||
Meettotalintegral: user.Meettotalintegral, |
|||
Addvipday: addVipDay, |
|||
Addtradesecond: addTradeSec, |
|||
Addmeetsecond: addMeetSec, |
|||
Factoryid: code.Brandid, |
|||
Productid: code.Productid, |
|||
} |
|||
return |
|||
} |
|||
Loading…
Reference in new issue