38 changed files with 3050 additions and 2578 deletions
@ -1 +1 @@ |
|||
{"id":"dev","timestamp":1782957264073} |
|||
{"id":"dev","timestamp":1783752803916} |
|||
@ -1 +1 @@ |
|||
{"id":"dev","timestamp":1782957264073,"prerendered":[]} |
|||
{"id":"dev","timestamp":1783752803916,"prerendered":[]} |
|||
@ -0,0 +1,721 @@ |
|||
<template> |
|||
<div> |
|||
<div class="card"> |
|||
<div class="card-head"> |
|||
<div> |
|||
<div class="card-title">区域部署</div> |
|||
<div class="card-desc"> |
|||
每个区域一份部署,各自连自己的业务库。「升级」会 SSH 到绑定的环境节点,在部署目录执行 |
|||
<span class="font-mono">docker compose pull & up -d</span>。 |
|||
</div> |
|||
</div> |
|||
<button class="btn btn-primary btn-xs text-white shrink-0" @click="openNew()">+ 部署新环境</button> |
|||
</div> |
|||
|
|||
<div v-if="!deployments.length" class="py-14 text-center text-slate-400 text-sm"> |
|||
该应用还没有部署,点右上「+ 部署新环境」添加。 |
|||
</div> |
|||
|
|||
<div v-for="d in deployments" :key="d.id" class="dep" :class="{ pending: d.status === 'pending' }"> |
|||
<div class="min-w-0"> |
|||
<div class="dep-name"> |
|||
<span class="chip chip-dep">部署</span> |
|||
<span class="truncate">{{ d.name || '部署#' + d.id }}</span> |
|||
<span v-if="d.status === 'pending'" class="chip chip-warn">待部署</span> |
|||
<span v-else class="chip chip-ok">已接入</span> |
|||
<span v-if="!d.enabled" class="chip">停用</span> |
|||
</div> |
|||
<div class="dep-sub">#{{ d.id }} · {{ d.region || '未指定区域' }} · 业务库 {{ dbName(d.service_dsn) }}</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<div class="field-l">环境节点</div> |
|||
<select |
|||
v-model.number="d.env_id" |
|||
class="select select-bordered select-xs w-full text-xs" |
|||
:disabled="envSavingId === d.id" |
|||
@change="bindEnv(d)" |
|||
> |
|||
<option :value="0">未绑定</option> |
|||
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div> |
|||
<div class="field-l">当前版本</div> |
|||
<div class="flex items-center gap-1.5"> |
|||
<span class="chip chip-mono" :class="isPinned(d.image_tag) ? 'chip-ok' : 'chip-warn'">{{ d.image_tag || 'latest' }}</span> |
|||
<span v-if="!isPinned(d.image_tag)" class="text-[10.5px] text-amber-700" title="latest 是可变 tag,升级后无法从列表看出实际版本">未固定</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="min-w-0"> |
|||
<div class="field-l">部署目录</div> |
|||
<div class="font-mono text-[11px] truncate" :class="d.deploy_dir ? 'text-slate-500' : 'text-red-500'"> |
|||
{{ d.deploy_dir || '未填' }} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="dep-acts"> |
|||
<button class="btn btn-xs btn-warning" @click="openUpgrade(d)">升级</button> |
|||
<button class="btn btn-xs" @click="emit('configure', d.id)">配置</button> |
|||
<button |
|||
class="btn btn-xs btn-ghost text-teal-600" |
|||
:disabled="!d.base_url" |
|||
:title="d.base_url ? '打开该部署的 Swagger 文档' : '该部署未配置对外地址(base_url)'" |
|||
@click="openSwagger(d)" |
|||
>API 测试</button> |
|||
<button class="btn btn-xs btn-ghost" @click="openForm(d)">编辑</button> |
|||
<button class="btn btn-xs btn-ghost text-emerald-600" :disabled="d.status === 'pending'" @click="openAnalyze(d)">分析</button> |
|||
<button class="btn btn-xs btn-ghost text-indigo-500" :disabled="d.status === 'pending'" @click="notifyApp(d.id)">发事件</button> |
|||
<button class="btn btn-xs btn-ghost text-red-500" @click="delDeploy(d)">删除</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ============ 部署表单 ============ --> |
|||
<dialog ref="formDlg" class="modal"> |
|||
<div class="modal-box max-w-3xl"> |
|||
<h3 class="font-bold text-lg">{{ dIsEditing ? '编辑部署' : '部署新环境' }}</h3> |
|||
|
|||
<div v-if="!dIsEditing" class="tabs tabs-boxed bg-slate-100 w-fit my-3"> |
|||
<a class="tab" :class="dMode === 'existing' ? 'tab-active' : ''" @click="setMode('existing')">添加现有</a> |
|||
<a class="tab" :class="dMode === 'deploy' ? 'tab-active' : ''" @click="setMode('deploy')">部署到目标环境</a> |
|||
</div> |
|||
|
|||
<p class="text-xs text-slate-400 mb-4" :class="dIsEditing ? 'mt-1' : ''"> |
|||
<template v-if="dIsEditing">改好后回列表点「升级」执行远程部署(拉新镜像并重启)。</template> |
|||
<template v-else-if="dMode === 'existing'">把「已经部署好的应用区域」加进来:填地址与业务库 DSN,测试通过后保存。</template> |
|||
<template v-else>选一个目标环境节点,先建关联并标记<b>待部署</b>;保存后回列表点「升级」执行首次部署。</template> |
|||
</p> |
|||
|
|||
<form class="space-y-3" @submit.prevent="saveDeploy"> |
|||
<div class="grid grid-cols-2 gap-3"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">所属应用</span></label> |
|||
<input :value="appName" type="text" class="input input-bordered input-sm bg-slate-100" readonly /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">部署名称 *</span></label> |
|||
<input v-model="dForm.name" type="text" class="input input-bordered input-sm" required :placeholder="`如 ${appName}(国内)`" /> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-2 gap-3"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">区域</span></label> |
|||
<select v-model="dForm.region" class="select select-bordered select-sm"> |
|||
<option value="">未指定</option> |
|||
<option v-for="r in availableRegions" :key="r" :value="r">{{ r }}</option> |
|||
</select> |
|||
<span class="text-[11px] text-slate-400 mt-1">已部署的区域不再出现(一个应用每区域一份部署)。</span> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">部署环境{{ dMode === 'deploy' ? ' *' : '' }}</span></label> |
|||
<select v-model.number="dForm.env_id" class="select select-bordered select-sm"> |
|||
<option :value="0">{{ dMode === 'deploy' ? '请选择目标环境' : '未关联' }}</option> |
|||
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}({{ e.code || '-' }})</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-3 gap-3"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">部署目录</span></label> |
|||
<input v-model="dForm.deploy_dir" type="text" class="input input-bordered input-sm font-mono text-xs" placeholder="/home/work/app" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">镜像名</span></label> |
|||
<input v-model="dForm.image_name" type="text" class="input input-bordered input-sm font-mono text-xs" :placeholder="defaultImage || '默认取应用'" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">镜像 Tag</span></label> |
|||
<input v-model="dForm.image_tag" type="text" class="input input-bordered input-sm font-mono text-xs" placeholder="0.1.1 / latest" /> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">业务库 DSN{{ dMode === 'existing' ? ' *' : '' }}</span></label> |
|||
<input |
|||
v-model="dForm.service_dsn" type="text" class="input input-bordered input-sm font-mono text-xs" |
|||
placeholder="root:pwd@tcp(host:3306)/earphone?charset=utf8mb4&parseTime=True&loc=Local" |
|||
@input="resetTest" |
|||
/> |
|||
<span class="text-[11px] text-slate-400 mt-1">业务功能配置、会议记录服务配置都写在这个库里。</span> |
|||
</div> |
|||
|
|||
<template v-if="dMode === 'existing'"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">应用地址 *</span></label> |
|||
<input v-model="dForm.base_url" type="text" class="input input-bordered input-sm" placeholder="https://api.xxx.com/" @input="resetTest" /> |
|||
<span class="text-[11px] text-slate-400 mt-1">用于通知应用业务服务事件(如配置更新)、打开 Swagger。</span> |
|||
</div> |
|||
<div v-if="testResult.show" class="text-sm space-y-0.5"> |
|||
<div :class="testResult.apiOk ? 'text-green-700' : 'text-red-600'"> |
|||
{{ testResult.apiOk ? '✓' : '✗' }} 应用地址:{{ testResult.apiMsg }} |
|||
</div> |
|||
<div :class="testResult.dbOk ? 'text-green-700' : 'text-red-600'"> |
|||
{{ testResult.dbOk ? '✓' : '✗' }} 业务库:{{ testResult.dbMsg }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label> |
|||
<input v-model="dForm.remark" type="text" class="input input-bordered input-sm" /> |
|||
</div> |
|||
<label class="flex items-center gap-2 cursor-pointer"> |
|||
<input v-model="dForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" /> |
|||
<span class="text-sm">启用</span> |
|||
</label> |
|||
|
|||
<div class="modal-action"> |
|||
<button type="button" class="btn btn-ghost btn-sm" @click="formDlg?.close()">取消</button> |
|||
<button v-if="dMode === 'existing'" type="button" class="btn btn-outline btn-sm" :disabled="testing" @click="testConn"> |
|||
{{ testing ? '测试中…' : '测试连接' }} |
|||
</button> |
|||
<button type="submit" class="btn btn-primary btn-sm text-white" :disabled="!dIsEditing && dMode === 'existing' && !testPassed">保存</button> |
|||
</div> |
|||
<p v-if="!dIsEditing && dMode === 'existing'" class="text-[11px] text-slate-400 text-right"> |
|||
需先「测试连接」通过(应用地址可达 + 业务库可连)才能保存。 |
|||
</p> |
|||
</form> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
|
|||
<!-- ============ 升级 ============ --> |
|||
<dialog ref="upgradeDlg" class="modal"> |
|||
<div class="modal-box max-w-2xl"> |
|||
<h3 class="font-bold text-lg">升级部署</h3> |
|||
<p class="text-sm text-slate-500 mt-1">{{ up.label }}</p> |
|||
|
|||
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed"> |
|||
将 <b>SSH 连到该部署绑定的环境节点</b>,进入部署目录执行 |
|||
<span class="font-mono">docker login → compose pull → up -d → 清理旧镜像</span>。<br /> |
|||
· 需服务器上已维护好 <span class="font-mono">docker-compose.yml / .env / confs</span>(本操作不上传真实配置)。<br /> |
|||
· 会<b>重启该部署的容器</b>,请确认无误后再执行。 |
|||
</div> |
|||
|
|||
<dl class="grid grid-cols-[76px_1fr] gap-x-3 gap-y-1 text-xs mt-4"> |
|||
<dt class="text-slate-400">环境节点</dt> |
|||
<dd :class="up.envName ? 'font-medium text-slate-700' : 'font-medium text-red-500'"> |
|||
{{ up.envName || '未绑定 —— 请先在「编辑」里选择环境节点' }} |
|||
</dd> |
|||
<dt class="text-slate-400">部署目录</dt> |
|||
<dd :class="up.deployDir ? 'font-mono text-slate-700' : 'font-medium text-red-500'"> |
|||
{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }} |
|||
</dd> |
|||
</dl> |
|||
|
|||
<div class="mt-3"> |
|||
<div class="flex items-center justify-between mb-1"> |
|||
<label class="text-xs text-slate-500">镜像版本 Tag</label> |
|||
<button type="button" class="text-xs text-indigo-500 hover:underline" :disabled="up.running" @click="up.tagMode = up.tagMode === 'list' ? 'custom' : 'list'"> |
|||
{{ up.tagMode === 'list' ? '手动输入…' : '从仓库选择' }} |
|||
</button> |
|||
</div> |
|||
<div v-if="up.tagMode === 'list'" class="flex gap-2"> |
|||
<select v-model="up.tag" class="select select-bordered select-sm flex-1 font-mono" :disabled="up.running || up.tagsLoading"> |
|||
<option value="">{{ up.tagsLoading ? '加载可用版本中…' : '请选择版本' }}</option> |
|||
<option v-if="up.tag && !up.tags.includes(up.tag)" :value="up.tag">{{ up.tag }}(当前记录)</option> |
|||
<option v-for="t in up.tags" :key="t" :value="t">{{ t }}{{ t === 'latest' ? ' · 不固定,不推荐' : '' }}</option> |
|||
</select> |
|||
<button type="button" class="btn btn-ghost btn-sm" :disabled="up.running || up.tagsLoading" @click="loadTags">刷新</button> |
|||
</div> |
|||
<input v-else v-model="up.tag" type="text" class="input input-bordered input-sm w-full font-mono" placeholder="0.1.1" :disabled="up.running" /> |
|||
<p v-if="up.tagsError" class="text-xs text-amber-600 mt-1"> |
|||
拉取仓库版本失败:{{ up.tagsError }}(可点上方「手动输入」直接填版本号,或点「刷新」重试) |
|||
</p> |
|||
<p v-else-if="!isPinned(up.tag)" class="text-xs text-amber-600 mt-1"> |
|||
⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。 |
|||
</p> |
|||
</div> |
|||
|
|||
<div v-if="up.show" class="mt-3"> |
|||
<div class="flex items-center gap-2 text-xs mb-1"> |
|||
<span v-if="up.running" class="loading loading-spinner loading-xs text-amber-500"></span> |
|||
<span v-else-if="up.ok" class="text-green-600 font-semibold">✓ 部署完成</span> |
|||
<span v-else class="text-red-600 font-semibold">✗ 部署失败</span> |
|||
<span class="text-slate-400">{{ up.running ? '执行中…' : '' }}</span> |
|||
</div> |
|||
<pre ref="upLogBox" class="bg-slate-900 text-slate-100 text-[11px] leading-relaxed rounded-lg p-3 h-64 overflow-auto font-mono whitespace-pre-wrap">{{ up.log || '(等待输出…)' }}</pre> |
|||
</div> |
|||
|
|||
<div class="modal-action"> |
|||
<button class="btn btn-ghost btn-sm" :disabled="up.running" @click="upgradeDlg?.close()">关闭</button> |
|||
<button class="btn btn-warning btn-sm" :disabled="up.running" @click="runUpgrade">{{ up.btnText }}</button> |
|||
</div> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button :disabled="up.running">close</button></form> |
|||
</dialog> |
|||
|
|||
<!-- ============ 历史数据分析 ============ --> |
|||
<dialog ref="analyzeDlg" class="modal"> |
|||
<div class="modal-box"> |
|||
<h3 class="font-bold text-lg">分析历史数据</h3> |
|||
<p class="text-sm text-slate-500 mt-1">{{ az.label }}</p> |
|||
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed"> |
|||
扫描该应用业务库,按 <b>天 + 产品 + 区域</b> 生成历史统计(新增用户 / 订单 / 资源发放·使用 / 激活绑定设备),汇入 console 主库。<br /> |
|||
· <b>分析前会先清理该应用+区域在所选区间内的旧统计行</b>,避免数据重叠。<br /> |
|||
· 登录、活跃用户、AI对话等无每日原始记录,历史无法还原(由实时管道往后采集)。<br /> |
|||
· 仅对实时管道上线前的区间运行。留空 = 全部历史。大库可能耗时。 |
|||
</div> |
|||
<div class="grid grid-cols-2 gap-3 mt-4"> |
|||
<div><label class="text-xs text-slate-500">起始日(含)</label><input v-model="az.start" type="date" class="input input-bordered input-sm w-full" /></div> |
|||
<div><label class="text-xs text-slate-500">结束日(含)</label><input v-model="az.end" type="date" class="input input-bordered input-sm w-full" /></div> |
|||
</div> |
|||
|
|||
<div v-if="az.mode" class="text-sm mt-3"> |
|||
<div v-if="az.mode === 'progress'" class="bg-slate-50 border border-slate-200 rounded-lg p-3"> |
|||
<div class="flex justify-between items-center text-xs mb-1.5"> |
|||
<span class="text-slate-600"> |
|||
<template v-if="az.curName"><span class="loading loading-spinner loading-xs"></span> 正在:{{ az.curName }} ({{ az.curIndex }}/{{ az.curTotal }})</template> |
|||
<template v-else>准备中...</template> |
|||
</span> |
|||
<span class="font-mono text-slate-400">{{ az.pct }}%</span> |
|||
</div> |
|||
<progress class="progress progress-primary w-full h-2" :value="az.pct" max="100"></progress> |
|||
<div class="mt-2 text-xs space-y-0.5"> |
|||
<div v-for="(nm, i) in az.steps" :key="i" :class="i === az.curIndex - 1 ? 'text-slate-600' : 'text-green-600'"> |
|||
<template v-if="i === az.curIndex - 1"><span class="loading loading-spinner loading-xs"></span> {{ nm }}</template> |
|||
<template v-else>✓ {{ nm }}</template> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div v-else-if="az.mode === 'error'" class="text-red-600">✗ {{ az.errMsg }}</div> |
|||
<div v-else-if="az.mode === 'done' && az.report" class="bg-green-50 border border-green-100 rounded-lg p-3"> |
|||
<div class="font-semibold text-green-700 mb-2">✓ 分析完成</div> |
|||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-600 mb-2"> |
|||
<div>应用:<b>{{ az.report.app_name || '' }}</b></div> |
|||
<div>区域:{{ az.report.region || '未指定' }}</div> |
|||
<div>区间:{{ (az.report.start_day || '不限') + ' ~ ' + (az.report.end_day || '不限') }}</div> |
|||
<div>清理旧行:{{ az.report.cleared_rows || 0 }}</div> |
|||
<div>写入天数:<b>{{ az.report.days_written || 0 }}</b></div> |
|||
<div>写入行数:{{ az.report.rows_written || 0 }}</div> |
|||
</div> |
|||
<div class="border-t border-green-100 pt-2 text-xs"> |
|||
<div v-for="(s, i) in (az.report.steps || [])" :key="i" class="flex justify-between py-0.5"> |
|||
<span class="text-slate-500">{{ s.name }}</span> |
|||
<span class="font-mono text-slate-700">{{ (s.groups || 0) + ' 组' }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="modal-action"> |
|||
<button class="btn btn-ghost btn-sm" :disabled="az.running" @click="analyzeDlg?.close()">关闭</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="az.running" @click="runAnalyze">{{ az.btnText }}</button> |
|||
</div> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button :disabled="az.running">close</button></form> |
|||
</dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, nextTick } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { useAuthStore } from '~/stores/auth' |
|||
import type { Deployment, EnvOption } from '~/types/app' |
|||
|
|||
const props = defineProps<{ |
|||
appName: string |
|||
defaultImage: string |
|||
deployments: Deployment[] |
|||
envs: EnvOption[] |
|||
}>() |
|||
const emit = defineEmits<{ refresh: []; configure: [id: number] }>() |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
const auth = useAuthStore() |
|||
const router = useRouter() |
|||
|
|||
const REGIONS = ['中国', '美国', '东亚', '东南亚', '南亚', '中东', '欧洲', '俄罗斯', '非洲', '南美', '北美', '大洋洲', '巴西', '印度'] |
|||
|
|||
function dbName(dsn?: string) { |
|||
const m = (dsn || '').match(/\/([^/?]+)\?/) |
|||
return m ? m[1] : (dsn ? '(自定义)' : '未配置') |
|||
} |
|||
function isPinned(tag?: string) { |
|||
const t = (tag || '').trim() |
|||
return !!t && t !== 'latest' |
|||
} |
|||
function envName(id?: number) { |
|||
if (!id) return '' |
|||
return props.envs.find((e) => e.id === id)?.name ?? '#' + id |
|||
} |
|||
|
|||
// ---- 内联绑定环境节点 ---- |
|||
const envSavingId = ref(0) |
|||
async function bindEnv(d: Deployment) { |
|||
envSavingId.value = d.id |
|||
try { |
|||
await consoleApi('apps/bindenv', { id: d.id, env_id: Number(d.env_id) || 0 }) |
|||
success('部署环境已更新') |
|||
} catch (e: any) { |
|||
error(e?.message ?? '切换环境失败') |
|||
emit('refresh') // 回滚为服务器状态 |
|||
} finally { |
|||
envSavingId.value = 0 |
|||
} |
|||
} |
|||
|
|||
function openSwagger(d: Deployment) { |
|||
let base = (d.base_url || '').trim().replace(/\/+$/, '') |
|||
if (!base) { error('该部署未配置对外地址(base_url)'); return } |
|||
if (!/^https?:\/\//i.test(base)) base = 'https://' + base |
|||
window.open(base + '/swagger/index.html', '_blank', 'noopener') |
|||
} |
|||
|
|||
function notifyApp(id: number) { |
|||
if (!window.confirm(`向部署 #${id} 发送一条配置 reload 事件?`)) return |
|||
consoleApi<{ subject?: string }>('config/notify', { app_id: id, type: 'all', action: 'reload' }) |
|||
.then((d) => success('已发送 → ' + (d && d.subject))) |
|||
.catch((e: any) => error(e?.message ?? '发送失败')) |
|||
} |
|||
|
|||
function delDeploy(d: Deployment) { |
|||
if (!window.confirm(`删除部署「${d.name || '#' + d.id}」?(不影响应用自身的库)`)) return |
|||
consoleApi('apps/del', { id: d.id }) |
|||
.then(() => { success('已删除'); emit('refresh') }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
// ---- 部署表单 ---- |
|||
const formDlg = ref<HTMLDialogElement | null>(null) |
|||
const dIsEditing = ref(false) |
|||
const dMode = ref<'existing' | 'deploy'>('existing') |
|||
const dForm = reactive({ |
|||
id: 0, name: '', region: '', env_id: 0, base_url: '', service_dsn: '', |
|||
deploy_dir: '', image_name: '', image_tag: '', remark: '', enabled: true, |
|||
}) |
|||
const testPassed = ref(false) |
|||
const testing = ref(false) |
|||
const testResult = reactive({ show: false, apiOk: false, apiMsg: '', dbOk: false, dbMsg: '' }) |
|||
|
|||
const availableRegions = computed(() => { |
|||
const used = new Set(props.deployments.filter((d) => d.id !== dForm.id).map((d) => d.region).filter(Boolean)) |
|||
return REGIONS.filter((r) => !used.has(r) || r === dForm.region) |
|||
}) |
|||
|
|||
function resetTest() { |
|||
testPassed.value = false |
|||
testResult.show = false |
|||
} |
|||
function setMode(m: 'existing' | 'deploy') { |
|||
dMode.value = m |
|||
resetTest() |
|||
} |
|||
|
|||
function openNew() { |
|||
dIsEditing.value = false |
|||
dMode.value = 'existing' |
|||
Object.assign(dForm, { |
|||
id: 0, name: '', region: '', env_id: 0, base_url: '', service_dsn: '', |
|||
deploy_dir: '', image_name: props.defaultImage, image_tag: '', remark: '', enabled: true, |
|||
}) |
|||
resetTest() |
|||
formDlg.value?.showModal() |
|||
} |
|||
|
|||
function openForm(d: Deployment) { |
|||
dIsEditing.value = true |
|||
dMode.value = d.status === 'pending' ? 'deploy' : 'existing' |
|||
Object.assign(dForm, { |
|||
id: d.id, name: d.name || '', region: d.region || '', env_id: d.env_id || 0, |
|||
base_url: d.base_url || '', service_dsn: d.service_dsn || '', deploy_dir: d.deploy_dir || '', |
|||
image_name: d.image_name || '', image_tag: d.image_tag || '', remark: d.remark || '', enabled: !!d.enabled, |
|||
}) |
|||
resetTest() |
|||
formDlg.value?.showModal() |
|||
} |
|||
|
|||
async function testConn() { |
|||
const baseUrl = dForm.base_url.trim() |
|||
const serviceDsn = dForm.service_dsn.trim() |
|||
if (!baseUrl || !serviceDsn) { error('请先填应用地址和业务库 DSN'); return } |
|||
testing.value = true |
|||
try { |
|||
const d = await consoleApi<{ ok: boolean; api_ok: boolean; api_msg: string; db_ok: boolean; db_msg: string }>( |
|||
'apps/test', { base_url: baseUrl, service_dsn: serviceDsn }, |
|||
) |
|||
testResult.apiOk = !!d.api_ok |
|||
testResult.apiMsg = d.api_msg |
|||
testResult.dbOk = !!d.db_ok |
|||
testResult.dbMsg = d.db_msg |
|||
testResult.show = true |
|||
testPassed.value = !!d.ok |
|||
if (testPassed.value) success('检测通过,可以保存') |
|||
else error('检测未通过') |
|||
} catch (e: any) { |
|||
error(e?.message ?? '测试失败') |
|||
} finally { |
|||
testing.value = false |
|||
} |
|||
} |
|||
|
|||
async function saveDeploy() { |
|||
if (!dForm.name.trim()) { error('请填部署名称'); return } |
|||
if (!dIsEditing.value && dMode.value === 'existing' && !testPassed.value) { error('请先「测试连接」通过再保存'); return } |
|||
if (dMode.value === 'deploy' && !dForm.env_id) { error('请选择部署目标环境'); return } |
|||
|
|||
const data: Record<string, unknown> = { |
|||
app_name: props.appName, |
|||
status: dMode.value === 'deploy' ? 'pending' : 'existing', |
|||
name: dForm.name, |
|||
region: dForm.region, |
|||
env_id: Number(dForm.env_id) || 0, |
|||
base_url: dMode.value === 'deploy' ? '' : dForm.base_url, |
|||
service_dsn: dForm.service_dsn, |
|||
deploy_dir: dForm.deploy_dir, |
|||
image_name: dForm.image_name, |
|||
image_tag: dForm.image_tag, |
|||
remark: dForm.remark, |
|||
enabled: dForm.enabled, |
|||
} |
|||
let path = 'apps/add' |
|||
if (dIsEditing.value && dForm.id) { data.id = Number(dForm.id); path = 'apps/update' } |
|||
try { |
|||
await consoleApi(path, data) |
|||
success('保存成功') |
|||
formDlg.value?.close() |
|||
emit('refresh') |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} |
|||
} |
|||
|
|||
// ---- 升级(NDJSON 流式回显)---- |
|||
const upgradeDlg = ref<HTMLDialogElement | null>(null) |
|||
const upLogBox = ref<HTMLElement | null>(null) |
|||
const up = reactive({ |
|||
id: 0, label: '', envName: '', deployDir: '', tag: '', |
|||
tags: [] as string[], tagMode: 'list' as 'list' | 'custom', tagsLoading: false, tagsError: '', |
|||
log: '', show: false, running: false, ok: false, canRun: false, btnText: '开始升级', |
|||
}) |
|||
|
|||
function openUpgrade(d: Deployment) { |
|||
up.id = d.id |
|||
up.label = '部署:' + (d.name || '部署#' + d.id) + (d.region ? ' · ' + d.region : '') |
|||
up.envName = envName(d.env_id) |
|||
up.deployDir = d.deploy_dir || '' |
|||
up.tag = d.image_tag || '' |
|||
up.tags = [] |
|||
up.tagMode = 'list' |
|||
up.tagsError = '' |
|||
up.log = '' |
|||
up.show = false |
|||
up.running = false |
|||
up.ok = false |
|||
up.canRun = !!(d.env_id && (d.deploy_dir || '').trim()) |
|||
up.btnText = '开始升级' |
|||
upgradeDlg.value?.showModal() |
|||
if (up.canRun) loadTags() |
|||
} |
|||
|
|||
async function loadTags() { |
|||
if (!up.id) return |
|||
up.tagsLoading = true |
|||
up.tagsError = '' |
|||
try { |
|||
const r = await consoleApi<{ repo: string; current: string; tags: string[] }>('apps/tags', { id: up.id }) |
|||
up.tags = r.tags || [] |
|||
up.tagMode = 'list' |
|||
if (!up.tag || !up.tags.includes(up.tag)) { |
|||
up.tag = up.tags.find((t) => t !== 'latest') || up.tag || '' |
|||
} |
|||
} catch (e: any) { |
|||
up.tagsError = e?.message ?? '拉取失败' |
|||
} finally { |
|||
up.tagsLoading = false |
|||
} |
|||
} |
|||
|
|||
async function runUpgrade() { |
|||
if (up.running) return |
|||
if (!up.canRun) { |
|||
const miss: string[] = [] |
|||
if (!up.envName) miss.push('绑定环境节点') |
|||
if (!up.deployDir) miss.push('填写部署目录') |
|||
window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`) |
|||
return |
|||
} |
|||
const tag = (up.tag || '').trim() |
|||
if (!isPinned(tag)) { |
|||
if (!window.confirm(`未选择明确版本号(当前 = ${tag || '空 → 后端回退 latest'})。\nlatest 是可变 tag,升级后列表无法体现具体版本号,确定继续?`)) return |
|||
} |
|||
if (!window.confirm(`确认升级【${up.label}】?\n目标版本:${tag || 'latest'}\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return |
|||
|
|||
up.running = true |
|||
up.show = true |
|||
up.ok = false |
|||
up.log = '' |
|||
up.btnText = '升级中…' |
|||
|
|||
let hadError: string | null = null |
|||
let needLogout = false |
|||
|
|||
function append(s: string) { |
|||
up.log += (up.log ? '\n' : '') + s |
|||
nextTick(() => { if (upLogBox.value) upLogBox.value.scrollTop = upLogBox.value.scrollHeight }) |
|||
} |
|||
function handleLine(line: string) { |
|||
line = (line || '').trim() |
|||
if (!line) return |
|||
let ev: any |
|||
try { ev = JSON.parse(line) } catch { return } |
|||
if (ev.code === 18) { needLogout = true; return } |
|||
if (ev.code !== undefined && ev.code !== 0 && ev.type === undefined) { hadError = ev.msg || '请求被拒绝'; return } |
|||
if (ev.type === 'log') append(ev.line || '') |
|||
else if (ev.type === 'error') { hadError = ev.msg; append('✗ ' + (ev.msg || '')) } |
|||
} |
|||
|
|||
try { |
|||
const resp = await fetch('/console/api/apps/upgrade', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token }, |
|||
body: JSON.stringify({ id: up.id, tag }), |
|||
}) |
|||
const reader = resp.body!.getReader() |
|||
const decoder = new TextDecoder() |
|||
let buf = '' |
|||
for (;;) { |
|||
const r = await reader.read() |
|||
if (r.done) break |
|||
buf += decoder.decode(r.value, { stream: true }) |
|||
const lines = buf.split('\n') |
|||
buf = lines.pop() as string |
|||
for (const l of lines) handleLine(l) |
|||
} |
|||
handleLine(buf) |
|||
} catch (ex: any) { |
|||
hadError = '网络异常: ' + ex.message |
|||
append('✗ ' + hadError) |
|||
} |
|||
|
|||
if (needLogout) { auth.logout(); router.push('/'); return } |
|||
up.running = false |
|||
up.btnText = '重新升级' |
|||
up.ok = !hadError |
|||
if (hadError) error(hadError) |
|||
else { success('升级完成'); emit('refresh') } |
|||
} |
|||
|
|||
// ---- 历史数据分析(NDJSON 流式进度)---- |
|||
interface ReportStep { name: string; groups: number } |
|||
interface AnalyzeReport { |
|||
app_name: string; region: string; start_day: number; end_day: number |
|||
cleared_rows: number; days_written: number; rows_written: number; steps: ReportStep[] |
|||
} |
|||
const analyzeDlg = ref<HTMLDialogElement | null>(null) |
|||
const az = reactive({ |
|||
appId: 0, label: '', start: '', end: '', |
|||
mode: '' as '' | 'progress' | 'error' | 'done', |
|||
steps: [] as string[], curIndex: 0, curTotal: 0, curName: '', pct: 0, |
|||
errMsg: '', report: null as AnalyzeReport | null, running: false, btnText: '开始分析', |
|||
}) |
|||
|
|||
function ymd(s: string) { return parseInt((s || '').replace(/-/g, '')) || 0 } |
|||
|
|||
function openAnalyze(d: Deployment) { |
|||
Object.assign(az, { |
|||
appId: d.id, label: '部署:' + (d.name || '部署#' + d.id) + ' (#' + d.id + ')', |
|||
start: '', end: '', mode: '', steps: [], curIndex: 0, curTotal: 0, curName: '', pct: 0, |
|||
errMsg: '', report: null, running: false, btnText: '开始分析', |
|||
}) |
|||
analyzeDlg.value?.showModal() |
|||
} |
|||
|
|||
async function runAnalyze() { |
|||
const s = az.start || '最早' |
|||
const e = az.end || '至今' |
|||
if (!window.confirm(`将先删除【${az.label}】在 ${s} ~ ${e} 区间的历史统计数据,然后重新分析写入。\n\n确认继续?`)) return |
|||
|
|||
Object.assign(az, { running: true, btnText: '分析中...', mode: 'progress', steps: [], curIndex: 0, curTotal: 0, curName: '', pct: 0, report: null, errMsg: '' }) |
|||
|
|||
let doneReport: AnalyzeReport | null = null |
|||
let errMsg: string | null = null |
|||
let needLogout = false |
|||
|
|||
function handleLine(line: string) { |
|||
line = (line || '').trim() |
|||
if (!line) return |
|||
let ev: any |
|||
try { ev = JSON.parse(line) } catch { return } |
|||
if (ev.code === 18) { needLogout = true; return } |
|||
if (ev.code !== undefined && ev.code !== 0) { errMsg = ev.msg || '请求被拒绝'; return } |
|||
if (ev.type === 'progress') { |
|||
az.steps[ev.index - 1] = ev.name |
|||
az.pct = Math.round((ev.index / (ev.total + 1)) * 100) |
|||
az.curIndex = ev.index |
|||
az.curTotal = ev.total |
|||
az.curName = ev.name |
|||
} else if (ev.type === 'done') doneReport = ev.report |
|||
else if (ev.type === 'error') errMsg = ev.msg |
|||
} |
|||
|
|||
try { |
|||
const resp = await fetch('/console/api/apps/analyze', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token }, |
|||
body: JSON.stringify({ app_id: az.appId, start_day: ymd(az.start), end_day: ymd(az.end) }), |
|||
}) |
|||
const reader = resp.body!.getReader() |
|||
const decoder = new TextDecoder() |
|||
let buf = '' |
|||
for (;;) { |
|||
const r = await reader.read() |
|||
if (r.done) break |
|||
buf += decoder.decode(r.value, { stream: true }) |
|||
const lines = buf.split('\n') |
|||
buf = lines.pop() as string |
|||
for (const l of lines) handleLine(l) |
|||
} |
|||
handleLine(buf) |
|||
} catch (ex: any) { |
|||
errMsg = '网络异常: ' + ex.message |
|||
} |
|||
|
|||
if (needLogout) { auth.logout(); router.push('/'); return } |
|||
az.running = false |
|||
az.btnText = '重新分析' |
|||
if (errMsg) { az.mode = 'error'; az.errMsg = errMsg; return } |
|||
if (!doneReport) { az.mode = 'error'; az.errMsg = '分析中断,未收到完成事件'; return } |
|||
az.mode = 'done' |
|||
az.report = doneReport |
|||
success('分析完成') |
|||
emit('refresh') |
|||
} |
|||
|
|||
defineExpose({ openNew }) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.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: 74ch; line-height: 1.55; } |
|||
|
|||
.dep { display: grid; grid-template-columns: 1.5fr 1fr 1fr .9fr auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #eef2f7; } |
|||
.dep:last-child { border-bottom: 0; } |
|||
.dep:hover { background: #fcfcfd; } |
|||
.dep.pending { background: linear-gradient(90deg, #fef3c7 0%, transparent 34%); } |
|||
.dep-name { font-weight: 600; font-size: 13.5px; color: #334155; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } |
|||
.dep-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; font-family: 'SF Mono', Menlo, monospace; } |
|||
.dep-acts { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; } |
|||
.field-l { font-size: 10px; color: #94a3b8; letter-spacing: .04em; text-transform: uppercase; font-weight: 600; margin-bottom: 3px; } |
|||
|
|||
.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-dep { background: #ecfeff; color: #0e7490; } |
|||
.chip-ok { background: #dcfce7; color: #15803d; } |
|||
.chip-warn { background: #fef3c7; color: #b45309; } |
|||
.chip-mono { font-family: 'SF Mono', Menlo, monospace; font-weight: 600; } |
|||
|
|||
@media (max-width: 1280px) { |
|||
.dep { grid-template-columns: 1fr 1fr; } |
|||
.dep-acts { grid-column: 1 / -1; justify-content: flex-start; } |
|||
} |
|||
</style> |
|||
@ -0,0 +1,317 @@ |
|||
<template> |
|||
<div class="card"> |
|||
<div class="card-head"> |
|||
<div> |
|||
<div class="card-title">业务功能配置</div> |
|||
<div class="card-desc"> |
|||
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>「{{ depName }}」这个部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。 |
|||
</div> |
|||
</div> |
|||
<button |
|||
class="btn btn-outline btn-error btn-xs shrink-0" |
|||
:disabled="saving || !!dbError" |
|||
title="把该部署业务库里所有业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。用于升级/迁移把库写脏后的整体恢复。" |
|||
@click="resetAll" |
|||
>全部重置为配置文件</button> |
|||
</div> |
|||
|
|||
<div v-if="dbError" class="m-4 alert alert-warning py-2 text-sm"> |
|||
<span> |
|||
无法连接应用业务库:{{ dbError }}<br /> |
|||
<span class="text-xs opacity-70">待部署的应用先完成部署、或检查业务库 DSN 连通后再配置。</span> |
|||
</span> |
|||
</div> |
|||
|
|||
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div> |
|||
|
|||
<div v-else class="cfg"> |
|||
<aside class="cfg-nav"> |
|||
<div v-for="grp in groups" :key="grp.name"> |
|||
<div class="cfg-group">{{ grp.name }}</div> |
|||
<button |
|||
v-for="m in grp.modules" |
|||
:key="m.module" |
|||
class="cfg-item" |
|||
:aria-current="curModule === m.module" |
|||
@click="curModule = m.module" |
|||
> |
|||
<span>{{ m.name }}</span> |
|||
<span v-if="dataModules.has(m.module)" class="dot" title="库中已有配置"></span> |
|||
</button> |
|||
</div> |
|||
</aside> |
|||
|
|||
<section class="cfg-form"> |
|||
<template v-if="curDef"> |
|||
<div class="flex items-start justify-between gap-3 mb-4 flex-wrap"> |
|||
<div> |
|||
<div class="text-sm font-semibold text-slate-800">{{ curDef.name }}</div> |
|||
<div v-if="curDef.desc" class="text-[11px] text-slate-400 mt-0.5">{{ curDef.desc }}</div> |
|||
</div> |
|||
<div class="flex items-center gap-2"> |
|||
<button |
|||
class="btn btn-ghost btn-xs text-amber-600" :disabled="saving" |
|||
title="丢弃库中该模块的全部配置,改用业务服务配置文件(yaml)里的初始值重新写入。" |
|||
@click="resetModule" |
|||
>重置</button> |
|||
<button v-if="dataModules.has(curModule)" class="btn btn-ghost btn-xs text-rose-500" :disabled="saving" @click="clearModule">清空</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !!dbError" @click="save"> |
|||
{{ saving ? '保存中…' : '保存' }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-1 gap-3 max-w-2xl"> |
|||
<div v-for="f in curDef.fields" :key="f.key" class="form-control"> |
|||
<label class="label py-0.5"> |
|||
<span class="label-text text-sm"> |
|||
{{ f.label }} |
|||
<span v-if="f.required" class="text-rose-400">*</span> |
|||
<span v-if="f.secret" class="lock-tag ml-1">🔒</span> |
|||
<span class="text-[10px] font-normal text-slate-300 ml-1 font-mono">{{ f.key }}</span> |
|||
</span> |
|||
</label> |
|||
|
|||
<label v-if="f.type === 'bool'" class="cursor-pointer flex items-center gap-2"> |
|||
<input type="checkbox" class="toggle toggle-sm toggle-primary" :checked="cur[f.key] === 'true'" @change="setBool(f.key, $event)" /> |
|||
<span class="text-xs text-slate-500">{{ cur[f.key] === 'true' ? '开启' : '关闭' }}</span> |
|||
</label> |
|||
|
|||
<select v-else-if="f.type === 'select'" v-model="cur[f.key]" class="select select-bordered select-sm w-full"> |
|||
<option value="">请选择</option> |
|||
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option> |
|||
</select> |
|||
|
|||
<textarea |
|||
v-else-if="f.type === 'textarea'" v-model="cur[f.key]" |
|||
class="textarea textarea-bordered w-full font-mono text-xs" rows="3" |
|||
:placeholder="f.secret ? '留空/保持 [encrypted] 则不变' : (f.placeholder || '')" |
|||
></textarea> |
|||
|
|||
<div v-else-if="f.type === 'password'" class="relative"> |
|||
<input |
|||
v-model="cur[f.key]" :type="show[f.key] ? 'text' : 'password'" |
|||
class="input input-bordered input-sm w-full font-mono text-xs pr-14" |
|||
placeholder="留空/保持 [encrypted] 则不变" |
|||
/> |
|||
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="show[f.key] = !show[f.key]"> |
|||
{{ show[f.key] ? '隐藏' : '显示' }} |
|||
</button> |
|||
</div> |
|||
|
|||
<input v-else v-model="cur[f.key]" type="text" class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="f.placeholder || ''" /> |
|||
|
|||
<span v-if="f.desc" class="text-[11px] text-slate-400 mt-0.5">{{ f.desc }}</span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<div v-else class="py-10 text-center text-slate-400 text-sm">暂无可配置的业务模块。</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
const props = defineProps<{ appId: number; depName: string }>() |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, info, error } = useToast() as any |
|||
|
|||
interface FieldOption { value: string; label: string } |
|||
interface ModuleField { |
|||
key: string; label: string; type: string; secret: boolean; required: boolean |
|||
placeholder?: string; desc?: string; options?: FieldOption[] |
|||
} |
|||
interface ModuleDef { module: string; name: string; group: string; sys_key: string; desc?: string; fields: ModuleField[] } |
|||
interface ModItem { module: string; key: string; value: string; encrypted: boolean } |
|||
interface ApplyResult { module: string; status: string; err?: string } |
|||
interface ConfigAck { app_name: string; instance: string; results: ApplyResult[] } |
|||
|
|||
const catalog = ref<ModuleDef[]>([]) |
|||
const curModule = ref('') |
|||
const values = reactive<Record<string, Record<string, string>>>({}) |
|||
const dataModules = reactive(new Set<string>()) |
|||
const show = reactive<Record<string, boolean>>({}) |
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
const dbError = ref('') |
|||
|
|||
const curDef = computed(() => catalog.value.find((m) => m.module === curModule.value) || null) |
|||
const cur = computed(() => values[curModule.value] || {}) |
|||
const groups = computed(() => { |
|||
const order: string[] = [] |
|||
const map = new Map<string, ModuleDef[]>() |
|||
for (const m of catalog.value) { |
|||
if (!map.has(m.group)) { map.set(m.group, []); order.push(m.group) } |
|||
map.get(m.group)!.push(m) |
|||
} |
|||
return order.map((name) => ({ name, modules: map.get(name)! })) |
|||
}) |
|||
|
|||
function setBool(key: string, e: Event) { |
|||
const bag = values[curModule.value] || (values[curModule.value] = {}) |
|||
bag[key] = (e.target as HTMLInputElement).checked ? 'true' : 'false' |
|||
} |
|||
|
|||
async function loadCatalog() { |
|||
try { |
|||
const d = await consoleApi<{ modules: ModuleDef[] }>('modulecfg/catalog', {}) |
|||
catalog.value = d?.modules ?? [] |
|||
for (const m of catalog.value) if (!values[m.module]) values[m.module] = {} |
|||
if (catalog.value.length && !curModule.value) curModule.value = catalog.value[0]!.module |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取配置目录失败') |
|||
} |
|||
} |
|||
|
|||
async function loadItems() { |
|||
if (!props.appId) return |
|||
loading.value = true |
|||
dbError.value = '' |
|||
try { |
|||
const d = await consoleApi<{ items: ModItem[] }>('modulecfg/list', { app_id: props.appId }) |
|||
for (const m of catalog.value) values[m.module] = {} |
|||
dataModules.clear() |
|||
for (const it of (d?.items ?? [])) { |
|||
if (!values[it.module]) values[it.module] = {} |
|||
values[it.module]![it.key] = it.value |
|||
dataModules.add(it.module) |
|||
} |
|||
} catch (e: any) { |
|||
dbError.value = e?.message ?? '连接失败' |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
// reportAcks 把各业务服务的热重载回执翻成一句人话(一条回执 = 一个服务,如 home / api)。 |
|||
// 三态如实呈现:把"需重启才生效"说成"已生效",比不给回执更糟——用户会以为新凭据已在用。 |
|||
function reportAcks(acks: ConfigAck[] | undefined, module: string | undefined, verb = '配置已保存') { |
|||
const list = acks ?? [] |
|||
if (!list.length) { |
|||
info(`${verb},但未收到任何服务的生效回执 —— 目标应用可能未运行、未填对外地址(base_url)、或两端 FIELD_ENCRYPT_KEY 不一致,需重启后生效`) |
|||
return |
|||
} |
|||
const statusOf = (a: ConfigAck): string | undefined => { |
|||
const rs = module ? a.results?.filter((r) => r.module === module) : a.results |
|||
if (!rs || !rs.length) return undefined |
|||
if (rs.some((r) => r.status === 'failed')) return 'failed' |
|||
if (rs.some((r) => r.status === 'restart_required')) return 'restart_required' |
|||
if (rs.some((r) => r.status === 'applied')) return 'applied' |
|||
return undefined |
|||
} |
|||
const errOf = (a: ConfigAck) => (module ? a.results?.find((r) => r.module === module) : a.results?.find((r) => r.status === 'failed'))?.err ?? '' |
|||
const failed = list.filter((a) => statusOf(a) === 'failed') |
|||
const restart = list.filter((a) => statusOf(a) === 'restart_required') |
|||
const applied = list.filter((a) => statusOf(a) === 'applied') |
|||
|
|||
if (failed.length) { |
|||
error(`${verb},但 ${failed.length}/${list.length} 个服务重载失败(仍在使用旧配置):${failed[0]!.instance}: ${errOf(failed[0]!)}`) |
|||
} else if (restart.length) { |
|||
info(`${verb}。${restart.length}/${list.length} 个服务需重启才生效(含支付等暂不支持热更新的模块)`) |
|||
} else if (applied.length) { |
|||
success(`${verb},${applied.length}/${list.length} 个服务已实时生效`) |
|||
} else { |
|||
info(`${verb},收到 ${list.length} 个服务的回执`) |
|||
} |
|||
} |
|||
|
|||
async function save() { |
|||
if (!curDef.value) return |
|||
saving.value = true |
|||
try { |
|||
const items = curDef.value.fields.map((f) => ({ key: f.key, value: values[curModule.value]?.[f.key] ?? '' })) |
|||
const d = await consoleApi<{ saved: number; acks?: ConfigAck[] }>('modulecfg/save', { app_id: props.appId, module: curModule.value, items }) |
|||
reportAcks(d?.acks, curModule.value) |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function resetModule() { |
|||
if (!curDef.value) return |
|||
if (!confirm(`确认把「${curDef.value.name}」还原为业务服务配置文件(yaml)里的初始值?\n\n库中该模块现有的配置将被全部覆盖,且无法撤销。`)) return |
|||
saving.value = true |
|||
try { |
|||
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', { app_id: props.appId, module: curModule.value }) |
|||
reportAcks(d?.acks, curModule.value, '已还原为配置文件初始值') |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '重置失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
// resetAll 整库还原为配置文件初始值(module="*")。破坏性大——会覆盖后台手工保存过的所有模块,故要求打字确认。 |
|||
async function resetAll() { |
|||
const ans = prompt( |
|||
`⚠️ 危险操作:把「${props.depName}」业务库里【所有】业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。\n\n` + |
|||
`这会覆盖你在后台手工保存过的全部配置(例如邮件的 Resend 设置会被打回 yaml 默认值),且无法撤销。\n\n` + |
|||
`确认请输入:RESET`, |
|||
) |
|||
if (ans !== 'RESET') { if (ans !== null) info('已取消(未输入 RESET)'); return } |
|||
saving.value = true |
|||
try { |
|||
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', { app_id: props.appId, module: '*' }) |
|||
reportAcks(d?.acks, undefined, '整库已还原为配置文件初始值') |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '全部重置失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function clearModule() { |
|||
if (!curModule.value) return |
|||
if (!confirm(`确认清空「${curDef.value?.name}」的全部配置?`)) return |
|||
saving.value = true |
|||
try { |
|||
await consoleApi('modulecfg/del', { app_id: props.appId, module: curModule.value }) |
|||
success('已清空') |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '清空失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await loadCatalog() |
|||
await loadItems() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.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: 78ch; line-height: 1.55; } |
|||
|
|||
.cfg { display: grid; grid-template-columns: 190px 1fr; } |
|||
.cfg-nav { border-right: 1px solid #eef2f7; padding: 12px 10px; background: #fcfcfd; } |
|||
.cfg-group { font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #94a3b8; padding: 10px 8px 4px; } |
|||
.cfg-item { |
|||
width: 100%; border: 0; background: none; font-size: 13px; color: #334155; text-align: left; |
|||
padding: 7px 10px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; |
|||
} |
|||
.cfg-item:hover { background: #f8fafc; } |
|||
.cfg-item[aria-current='true'] { background: #eef2ff; color: #4338ca; font-weight: 600; } |
|||
.cfg-item .dot { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; flex-shrink: 0; } |
|||
.cfg-form { padding: 16px 20px; min-width: 0; } |
|||
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 5px; border-radius: 5px; } |
|||
|
|||
@media (max-width: 900px) { |
|||
.cfg { grid-template-columns: 1fr; } |
|||
.cfg-nav { border-right: 0; border-bottom: 1px solid #eef2f7; } |
|||
} |
|||
</style> |
|||
@ -1,405 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 返回 + 标题 --> |
|||
<div class="flex items-center gap-2 mb-4"> |
|||
<button class="btn btn-ghost btn-sm" @click="goBack">← 返回应用列表</button> |
|||
<span class="text-slate-300">/</span> |
|||
<h2 class="text-lg font-semibold text-slate-800">应用服务配置</h2> |
|||
</div> |
|||
|
|||
<!-- ===== 应用基础信息(置顶)===== --> |
|||
<div class="rounded-xl px-5 py-4 mb-5" style="background:linear-gradient(135deg,#eef2ff,#faf5ff);border:1px solid #e0e7ff"> |
|||
<div class="flex items-center gap-4"> |
|||
<div class="app-avatar">{{ appInitial }}</div> |
|||
<div class="flex-1 min-w-0"> |
|||
<div class="flex items-center gap-2 flex-wrap"> |
|||
<span class="text-base font-bold text-slate-800">{{ dep.name || appName }}</span> |
|||
<span class="badge badge-sm" style="background:#e0e7ff;color:#4338ca;border:none">#{{ appId }}</span> |
|||
<span v-if="dep.app_name" class="badge badge-ghost badge-sm">应用:{{ dep.app_name }}</span> |
|||
<span v-if="dep.region" class="badge badge-ghost badge-sm">{{ dep.region }}</span> |
|||
<span v-if="dep.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span> |
|||
<span v-else class="badge badge-success badge-sm text-white">已接入</span> |
|||
</div> |
|||
<div class="text-[11px] text-slate-500 mt-1 flex flex-wrap gap-x-4 gap-y-0.5"> |
|||
<span v-if="dep.base_url">地址:<span class="font-mono">{{ dep.base_url }}</span></span> |
|||
<span>业务库:<span class="font-mono">{{ dbName(dep.service_dsn) }}</span></span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- 部署环境切换(老数据 env_id=0 未绑定,可在此绑定/切换)--> |
|||
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-indigo-100"> |
|||
<span class="text-xs text-slate-500 shrink-0">部署环境</span> |
|||
<select v-model.number="envId" class="select select-bordered select-sm w-64" :disabled="envSaving" @change="bindEnv"> |
|||
<option :value="0">未绑定</option> |
|||
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}({{ e.code || '-' }})</option> |
|||
</select> |
|||
<span v-if="envSaving" class="loading loading-spinner loading-xs text-indigo-400"></span> |
|||
<span class="text-[11px] text-slate-400">切换后即时保存。</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ===== 业务功能配置 ===== --> |
|||
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-5"> |
|||
<div class="mb-3 flex items-start justify-between gap-3"> |
|||
<div> |
|||
<h3 class="text-base font-semibold text-slate-800">业务功能配置</h3> |
|||
<p class="text-[11px] text-slate-400 mt-0.5"> |
|||
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>该部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。 |
|||
</p> |
|||
</div> |
|||
<button class="btn btn-outline btn-error btn-xs shrink-0" :disabled="saving || dbError !== ''" |
|||
title="把该部署业务库里所有业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。用于升级/迁移把库写脏后的整体恢复。" |
|||
@click="resetAll">全部重置为配置文件</button> |
|||
</div> |
|||
|
|||
<!-- 业务库不可达(如待部署/连接失败)提示 --> |
|||
<div v-if="dbError" class="alert alert-warning py-2 text-sm mb-3"> |
|||
<span>无法连接应用业务库:{{ dbError }}<br><span class="text-xs opacity-70">待部署的应用先完成部署、或检查业务库 DSN 连通后再配置。</span></span> |
|||
</div> |
|||
|
|||
<div v-if="loading" class="py-12 text-center"><span class="loading loading-spinner loading-sm"></span></div> |
|||
|
|||
<div v-else class="flex gap-5"> |
|||
<!-- 左侧:按分组的模块导航 --> |
|||
<aside class="w-48 shrink-0"> |
|||
<div v-for="grp in groups" :key="grp.name" class="mb-3"> |
|||
<div class="text-[11px] font-semibold text-slate-400 px-2 mb-1">{{ grp.name }}</div> |
|||
<button |
|||
v-for="m in grp.modules" |
|||
:key="m.module" |
|||
class="w-full flex items-center justify-between text-left px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors" |
|||
:class="curModule === m.module ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-slate-600 hover:bg-slate-50'" |
|||
@click="curModule = m.module" |
|||
> |
|||
<span>{{ m.name }}</span> |
|||
<span v-if="dataModules.has(m.module)" class="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block"></span> |
|||
</button> |
|||
</div> |
|||
</aside> |
|||
|
|||
<!-- 右侧:当前模块表单 --> |
|||
<section class="flex-1 min-w-0"> |
|||
<template v-if="curDef"> |
|||
<div class="flex items-center justify-between mb-3"> |
|||
<div> |
|||
<div class="text-sm font-semibold text-slate-800">{{ curDef.name }}</div> |
|||
<div v-if="curDef.desc" class="text-[11px] text-slate-400 mt-0.5">{{ curDef.desc }}</div> |
|||
</div> |
|||
<div class="flex items-center gap-2"> |
|||
<button class="btn btn-ghost btn-xs text-amber-600" :disabled="saving" |
|||
title="丢弃库中该模块的全部配置,改用业务服务配置文件(yaml)里的初始值重新写入。用于库被改脏后还原。" |
|||
@click="resetModule">重置</button> |
|||
<button v-if="dataModules.has(curModule)" class="btn btn-ghost btn-xs text-rose-500" :disabled="saving" @click="clearModule">清空</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="saving || dbError !== ''" @click="save"> |
|||
{{ saving ? '保存中…' : '保存' }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-1 gap-3 max-w-2xl"> |
|||
<div v-for="f in curDef.fields" :key="f.key" class="form-control"> |
|||
<label class="label py-0.5"> |
|||
<span class="label-text text-sm"> |
|||
{{ f.label }} |
|||
<span v-if="f.required" class="text-rose-400">*</span> |
|||
<span v-if="f.secret" class="lock-tag ml-1">🔒</span> |
|||
<span class="text-[10px] font-normal text-slate-300 ml-1 font-mono">{{ f.key }}</span> |
|||
</span> |
|||
</label> |
|||
|
|||
<!-- bool --> |
|||
<label v-if="f.type === 'bool'" class="cursor-pointer flex items-center gap-2"> |
|||
<input type="checkbox" class="toggle toggle-sm toggle-primary" |
|||
:checked="cur[f.key] === 'true'" |
|||
@change="setBool(f.key, $event)" /> |
|||
<span class="text-xs text-slate-500">{{ cur[f.key] === 'true' ? '开启' : '关闭' }}</span> |
|||
</label> |
|||
|
|||
<!-- select --> |
|||
<select v-else-if="f.type === 'select'" v-model="cur[f.key]" class="select select-bordered select-sm w-full"> |
|||
<option value="">请选择</option> |
|||
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option> |
|||
</select> |
|||
|
|||
<!-- textarea --> |
|||
<textarea v-else-if="f.type === 'textarea'" v-model="cur[f.key]" |
|||
class="textarea textarea-bordered w-full font-mono text-xs" rows="3" |
|||
:placeholder="f.secret ? '留空/保持 [encrypted] 则不变' : (f.placeholder || '')"></textarea> |
|||
|
|||
<!-- password --> |
|||
<div v-else-if="f.type === 'password'" class="relative"> |
|||
<input v-model="cur[f.key]" :type="show[f.key] ? 'text' : 'password'" |
|||
class="input input-bordered input-sm w-full font-mono text-xs pr-14" |
|||
:placeholder="'留空/保持 [encrypted] 则不变'" /> |
|||
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" |
|||
@click="show[f.key] = !show[f.key]">{{ show[f.key] ? '隐藏' : '显示' }}</button> |
|||
</div> |
|||
|
|||
<!-- text --> |
|||
<input v-else v-model="cur[f.key]" type="text" |
|||
class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="f.placeholder || ''" /> |
|||
|
|||
<span v-if="f.desc" class="text-[11px] text-slate-400 mt-0.5">{{ f.desc }}</span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<div v-else class="py-10 text-center text-slate-400 text-sm">暂无可配置的业务模块。</div> |
|||
</section> |
|||
</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' |
|||
|
|||
definePageMeta({ title: '应用服务配置' }) |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, info, error } = useToast() as any |
|||
const route = useRoute() |
|||
const router = useRouter() |
|||
|
|||
interface FieldOption { value: string; label: string } |
|||
interface ModuleField { |
|||
key: string; label: string; type: string; secret: boolean; required: boolean |
|||
placeholder?: string; desc?: string; options?: FieldOption[] |
|||
} |
|||
interface ModuleDef { module: string; name: string; group: string; sys_key: string; desc?: string; fields: ModuleField[] } |
|||
interface ModItem { module: string; key: string; value: string; encrypted: boolean } |
|||
interface Deployment { |
|||
id: number; app_name: string; status: string; name: string; region: string |
|||
env_id: number; base_url: string; service_dsn: string |
|||
} |
|||
interface EnvOption { id: number; name: string; code: string } |
|||
|
|||
const appId = computed(() => Number(route.query.id) || 0) |
|||
const appName = computed(() => (route.query.name as string) || '未指定应用') |
|||
const appInitial = computed(() => ((dep.app_name || appName.value || 'A').charAt(0).toUpperCase())) |
|||
|
|||
const dep = reactive<Deployment>({ id: 0, app_name: '', status: '', name: '', region: '', env_id: 0, base_url: '', service_dsn: '' }) |
|||
const envs = ref<EnvOption[]>([]) |
|||
const envId = ref(0) |
|||
const envSaving = ref(false) |
|||
|
|||
const catalog = ref<ModuleDef[]>([]) |
|||
const curModule = ref('') |
|||
const values = reactive<Record<string, Record<string, string>>>({}) |
|||
const dataModules = reactive(new Set<string>()) |
|||
const show = reactive<Record<string, boolean>>({}) |
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
const dbError = ref('') |
|||
|
|||
const curDef = computed(() => catalog.value.find((m) => m.module === curModule.value) || null) |
|||
const cur = computed(() => values[curModule.value] || {}) |
|||
const groups = computed(() => { |
|||
const order: string[] = [] |
|||
const map = new Map<string, ModuleDef[]>() |
|||
for (const m of catalog.value) { |
|||
if (!map.has(m.group)) { map.set(m.group, []); order.push(m.group) } |
|||
map.get(m.group)!.push(m) |
|||
} |
|||
return order.map((name) => ({ name, modules: map.get(name)! })) |
|||
}) |
|||
|
|||
function setBool(key: string, e: Event) { |
|||
const bag = values[curModule.value] || (values[curModule.value] = {}) |
|||
bag[key] = (e.target as HTMLInputElement).checked ? 'true' : 'false' |
|||
} |
|||
|
|||
function goBack() { router.push('/apps') } |
|||
function dbName(dsn?: string) { |
|||
const m = (dsn || '').match(/\/([^/?]+)\?/) |
|||
return m ? m[1] : (dsn ? '(自定义)' : '—') |
|||
} |
|||
|
|||
async function loadBase() { |
|||
try { |
|||
const [deps, es] = await Promise.all([ |
|||
consoleApi<Deployment[]>('apps/list', {}), |
|||
consoleApi<EnvOption[]>('envs/list', {}), |
|||
]) |
|||
const found = (deps ?? []).find((d) => d.id === appId.value) |
|||
if (found) Object.assign(dep, found) |
|||
envs.value = es ?? [] |
|||
envId.value = dep.env_id || 0 |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取应用信息失败') |
|||
} |
|||
} |
|||
|
|||
async function loadCatalog() { |
|||
try { |
|||
const d = await consoleApi<{ modules: ModuleDef[] }>('modulecfg/catalog', {}) |
|||
catalog.value = d?.modules ?? [] |
|||
// 初始化各模块的值容器,避免模板访问 undefined。 |
|||
for (const m of catalog.value) if (!values[m.module]) values[m.module] = {} |
|||
if (catalog.value.length && !curModule.value) curModule.value = catalog.value[0].module |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取配置目录失败') |
|||
} |
|||
} |
|||
|
|||
async function loadItems() { |
|||
if (!appId.value) return |
|||
loading.value = true |
|||
dbError.value = '' |
|||
try { |
|||
const d = await consoleApi<{ items: ModItem[] }>('modulecfg/list', { app_id: appId.value }) |
|||
// 重置为目录默认容器,再灌入库值。 |
|||
for (const m of catalog.value) values[m.module] = {} |
|||
dataModules.clear() |
|||
for (const it of (d?.items ?? [])) { |
|||
if (!values[it.module]) values[it.module] = {} |
|||
values[it.module][it.key] = it.value |
|||
dataModules.add(it.module) |
|||
} |
|||
} catch (e: any) { |
|||
dbError.value = e?.message ?? '连接失败' |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function bindEnv() { |
|||
if (!dep.id) return |
|||
envSaving.value = true |
|||
try { |
|||
await consoleApi('apps/bindenv', { id: dep.id, env_id: envId.value }) |
|||
dep.env_id = envId.value |
|||
success('部署环境已更新') |
|||
} catch (e: any) { |
|||
error(e?.message ?? '切换环境失败') |
|||
envId.value = dep.env_id || 0 // 回滚 |
|||
} finally { |
|||
envSaving.value = false |
|||
} |
|||
} |
|||
|
|||
interface ApplyResult { module: string; status: string; err?: string } |
|||
interface ConfigAck { app_name: string; instance: string; results: ApplyResult[] } |
|||
|
|||
// reportAcks 把各业务服务的热重载回执翻成一句人话(一条回执 = 一个服务,如 home / api)。 |
|||
// module 指定则只看该模块的结果;留空(全部重置)则聚合该服务所有模块的结果。 |
|||
// 三态如实呈现:把"需重启才生效"说成"已生效",比不给回执更糟——用户会以为新凭据已在用。 |
|||
function reportAcks(acks: ConfigAck[] | undefined, module: string | undefined, verb = '配置已保存') { |
|||
const list = acks ?? [] |
|||
if (!list.length) { |
|||
info(`${verb},但未收到任何服务的生效回执 —— 目标应用可能未运行、未填对外地址(base_url)、或两端 FIELD_ENCRYPT_KEY 不一致,需重启后生效`) |
|||
return |
|||
} |
|||
// 每个服务取一个代表状态:任一模块 failed→failed;否则任一 restart_required→restart;否则 applied。 |
|||
const statusOf = (a: ConfigAck): string | undefined => { |
|||
const rs = module ? a.results?.filter((r) => r.module === module) : a.results |
|||
if (!rs || !rs.length) return undefined |
|||
if (rs.some((r) => r.status === 'failed')) return 'failed' |
|||
if (rs.some((r) => r.status === 'restart_required')) return 'restart_required' |
|||
if (rs.some((r) => r.status === 'applied')) return 'applied' |
|||
return undefined |
|||
} |
|||
const errOf = (a: ConfigAck) => (module ? a.results?.find((r) => r.module === module) : a.results?.find((r) => r.status === 'failed'))?.err ?? '' |
|||
const failed = list.filter((a) => statusOf(a) === 'failed') |
|||
const restart = list.filter((a) => statusOf(a) === 'restart_required') |
|||
const applied = list.filter((a) => statusOf(a) === 'applied') |
|||
|
|||
if (failed.length) { |
|||
error(`${verb},但 ${failed.length}/${list.length} 个服务重载失败(仍在使用旧配置):${failed[0].instance}: ${errOf(failed[0])}`) |
|||
} else if (restart.length) { |
|||
info(`${verb}。${restart.length}/${list.length} 个服务需重启才生效(含支付等暂不支持热更新的模块)`) |
|||
} else if (applied.length) { |
|||
success(`${verb},${applied.length}/${list.length} 个服务已实时生效`) |
|||
} else { |
|||
info(`${verb},收到 ${list.length} 个服务的回执`) |
|||
} |
|||
} |
|||
|
|||
// resetModule 让业务服务用它自己配置文件(yaml)里的初始值,覆盖库中该模块的配置。 |
|||
// 数据来源在业务服务那边(明文密钥不出它的容器),console 只负责发起并转达回执。 |
|||
async function resetModule() { |
|||
if (!curDef.value) return |
|||
if (!confirm( |
|||
`确认把「${curDef.value.name}」还原为业务服务配置文件(yaml)里的初始值?\n\n` + |
|||
`库中该模块现有的配置将被全部覆盖,且无法撤销。`, |
|||
)) return |
|||
saving.value = true |
|||
try { |
|||
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', { |
|||
app_id: appId.value, module: curModule.value, |
|||
}) |
|||
reportAcks(d?.acks, curModule.value, '已还原为配置文件初始值') |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '重置失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
// resetAll 整库还原为配置文件初始值(module="*")。破坏性大——会覆盖后台手工保存过的所有模块 |
|||
// (如把邮件打回 yaml 默认发信配置),故要求打字确认。 |
|||
async function resetAll() { |
|||
const depName = dep.name || appName.value || '该部署' |
|||
const ans = prompt( |
|||
`⚠️ 危险操作:把「${depName}」业务库里【所有】业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。\n\n` + |
|||
`这会覆盖你在后台手工保存过的全部配置(例如邮件的 Resend 设置会被打回 yaml 默认值),且无法撤销。\n\n` + |
|||
`确认请输入:RESET`, |
|||
) |
|||
if (ans !== 'RESET') { if (ans !== null) info('已取消(未输入 RESET)'); return } |
|||
saving.value = true |
|||
try { |
|||
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', { |
|||
app_id: appId.value, module: '*', |
|||
}) |
|||
reportAcks(d?.acks, undefined, '整库已还原为配置文件初始值') |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '全部重置失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function save() { |
|||
if (!curDef.value) return |
|||
saving.value = true |
|||
try { |
|||
const items = curDef.value.fields.map((f) => ({ key: f.key, value: values[curModule.value]?.[f.key] ?? '' })) |
|||
const d = await consoleApi<{ saved: number; acks?: ConfigAck[] }>('modulecfg/save', { app_id: appId.value, module: curModule.value, items }) |
|||
reportAcks(d?.acks, curModule.value) |
|||
await loadItems() // 重新拉取(密文回脱敏) |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function clearModule() { |
|||
if (!curModule.value) return |
|||
if (!confirm(`确认清空「${curDef.value?.name}」的全部配置?`)) return |
|||
saving.value = true |
|||
try { |
|||
await consoleApi('modulecfg/del', { app_id: appId.value, module: curModule.value }) |
|||
success('已清空') |
|||
await loadItems() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '清空失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await loadBase() |
|||
await loadCatalog() |
|||
await loadItems() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.app-avatar { width: 48px; height: 48px; border-radius: 13px; background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 20px; flex-shrink: 0; box-shadow: 0 6px 18px rgba(79,70,229,.3); } |
|||
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 5px; border-radius: 5px; } |
|||
</style> |
|||
File diff suppressed because it is too large
@ -0,0 +1,292 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 面包屑 --> |
|||
<div class="flex items-center gap-2 mb-4 text-sm"> |
|||
<button class="btn btn-ghost btn-xs" @click="router.push('/apps')">← 应用中心</button> |
|||
<span class="text-slate-300">/</span> |
|||
<span class="font-semibold text-slate-700">{{ appName }}</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> |
|||
|
|||
<div v-else-if="!product" class="bg-white rounded-xl border border-slate-100 py-16 text-center"> |
|||
<p class="text-slate-500">找不到应用「{{ appName }}」</p> |
|||
<button class="btn btn-sm mt-4" @click="router.push('/apps')">返回应用中心</button> |
|||
</div> |
|||
|
|||
<template v-else> |
|||
<!-- 应用头卡 --> |
|||
<div class="hero"> |
|||
<div class="hero-logo">{{ (product.name || '?').charAt(0).toUpperCase() }}</div> |
|||
<div class="flex-1 min-w-[240px]"> |
|||
<div class="hero-name"> |
|||
{{ product.name }} |
|||
<span class="chip" :class="product.enabled ? 'chip-ok' : ''">{{ product.enabled ? '启用' : '停用' }}</span> |
|||
<span class="chip chip-mono">🐳 {{ product.image_name || '未设置镜像' }}</span> |
|||
</div> |
|||
<div class="text-xs text-slate-500 mt-1.5">{{ product.remark || '—' }}</div> |
|||
</div> |
|||
<div class="flex gap-2"> |
|||
<button class="btn btn-sm" @click="openAppForm()">编辑应用</button> |
|||
<button class="btn btn-primary btn-sm text-white" @click="deployTab?.openNew()">+ 部署新环境</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- KPI --> |
|||
<div class="kpis"> |
|||
<div class="kpi accent"> |
|||
<div class="kpi-l">部署</div> |
|||
<div class="kpi-v">{{ deployments.length }}<small>个区域</small></div> |
|||
</div> |
|||
<div class="kpi ok"> |
|||
<div class="kpi-l">已接入</div> |
|||
<div class="kpi-v">{{ okCount }}</div> |
|||
</div> |
|||
<div class="kpi" :class="pendingCount ? 'warn' : ''"> |
|||
<div class="kpi-l">待部署</div> |
|||
<div class="kpi-v">{{ pendingCount }}</div> |
|||
</div> |
|||
<div class="kpi"> |
|||
<div class="kpi-l">版本分布</div> |
|||
<div class="kpi-v" style="font-size:14px;padding-top:5px">{{ versionSpread }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Tabs --> |
|||
<div class="tablist" role="tablist"> |
|||
<button class="tab" role="tab" :aria-selected="tab === 'deps'" @click="tab = 'deps'"> |
|||
部署环境 <span class="tab-n">{{ deployments.length }}</span> |
|||
</button> |
|||
<button class="tab" role="tab" :aria-selected="tab === 'modules'" @click="tab = 'modules'">业务功能配置</button> |
|||
</div> |
|||
|
|||
<!-- 配置目标切换器:业务功能配置写「某个部署自己的业务库」 --> |
|||
<div v-if="tab === 'modules'" class="depbar"> |
|||
<span class="text-xs text-slate-500 font-semibold shrink-0">配置目标</span> |
|||
<div class="flex gap-1.5 flex-wrap"> |
|||
<button |
|||
v-for="d in deployments" |
|||
:key="d.id" |
|||
class="depchip" |
|||
:aria-pressed="curDep === d.id" |
|||
:title="d.status === 'pending' ? '待部署:业务库可能还未就绪' : ''" |
|||
@click="curDep = d.id" |
|||
> |
|||
<span class="dot" :class="{ pending: d.status === 'pending' }"></span> |
|||
{{ d.region || d.name || '部署#' + d.id }} |
|||
<span class="text-[10.5px] text-slate-400 font-mono">#{{ d.id }}</span> |
|||
</button> |
|||
<span v-if="!deployments.length" class="text-xs text-slate-400 py-1">该应用还没有部署,无处存放配置</span> |
|||
</div> |
|||
<span class="text-[11px] text-slate-400 ml-auto">配置存在该部署自己的业务库里,各区域互不影响</span> |
|||
</div> |
|||
|
|||
<!-- 面板 --> |
|||
<AppDeployTab |
|||
v-show="tab === 'deps'" |
|||
ref="deployTab" |
|||
:app-name="appName" |
|||
:default-image="product.image_name || ''" |
|||
:deployments="deployments" |
|||
:envs="envs" |
|||
@refresh="loadAll" |
|||
@configure="onConfigure" |
|||
/> |
|||
<AppModuleTab v-if="tab === 'modules' && curDep" :key="'m' + curDep" :app-id="curDep" :dep-name="curDepName" /> |
|||
<div v-if="tab === 'modules' && !curDep" class="bg-white rounded-xl border border-slate-100 py-16 text-center text-slate-400"> |
|||
先在「部署环境」里创建一个部署,才能配置。 |
|||
</div> |
|||
</template> |
|||
|
|||
<!-- 编辑应用 --> |
|||
<dialog ref="appDlg" class="modal"> |
|||
<div class="modal-box max-w-lg"> |
|||
<h3 class="font-bold text-lg">编辑应用</h3> |
|||
<p class="text-xs text-slate-400 mt-1"> |
|||
应用名是关联键:部署、账号作用域、第三方服务配置都按它挂载,改名会断开已有关联。 |
|||
</p> |
|||
<form class="mt-4 space-y-3" @submit.prevent="saveProduct"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">应用名 *</span></label> |
|||
<input v-model="pForm.name" type="text" class="input input-bordered input-sm w-full" required /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">默认镜像名</span></label> |
|||
<input v-model="pForm.image_name" type="text" class="input input-bordered input-sm w-full font-mono text-xs" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label> |
|||
<input v-model="pForm.remark" type="text" class="input input-bordered input-sm w-full" /> |
|||
</div> |
|||
<label class="flex items-center gap-2 cursor-pointer pt-1"> |
|||
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" /> |
|||
<span class="text-sm">启用</span> |
|||
</label> |
|||
<div class="modal-action"> |
|||
<button type="button" class="btn btn-ghost btn-sm" @click="appDlg?.close()">取消</button> |
|||
<button type="submit" class="btn btn-primary btn-sm text-white">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted, watch } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import type { Deployment, EnvOption, Product } from '~/types/app' |
|||
|
|||
definePageMeta({ title: '应用详情' }) |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
const route = useRoute() |
|||
const router = useRouter() |
|||
|
|||
// vue-router 已经把 path 段解码过了,这里不能再 decode 一次: |
|||
// 应用名里带 % 时 decodeURIComponent 会直接抛 URIError。 |
|||
const appName = computed(() => String(route.params.name || '')) |
|||
|
|||
const product = ref<Product | null>(null) |
|||
const deployments = ref<Deployment[]>([]) |
|||
const envs = ref<EnvOption[]>([]) |
|||
const loading = ref(false) |
|||
|
|||
const tab = ref<'deps' | 'modules'>('deps') |
|||
const curDep = ref(0) |
|||
const deployTab = ref<{ openNew: () => void } | null>(null) |
|||
|
|||
const okCount = computed(() => deployments.value.filter((d) => d.status !== 'pending').length) |
|||
const pendingCount = computed(() => deployments.value.filter((d) => d.status === 'pending').length) |
|||
const curDepName = computed(() => { |
|||
const d = deployments.value.find((x) => x.id === curDep.value) |
|||
return d ? (d.name || '部署#' + d.id) : '' |
|||
}) |
|||
const versionSpread = computed(() => { |
|||
const tags = [...new Set(deployments.value.filter((d) => d.status !== 'pending').map((d) => d.image_tag || 'latest'))] |
|||
return tags.length ? tags.join(' · ') : '—' |
|||
}) |
|||
|
|||
async function loadAll() { |
|||
loading.value = true |
|||
try { |
|||
const [ps, ds, es] = await Promise.all([ |
|||
consoleApi<Product[]>('products/list', {}), |
|||
consoleApi<Deployment[]>('apps/list', {}), |
|||
consoleApi<EnvOption[]>('envs/list', {}), |
|||
]) |
|||
product.value = (ps ?? []).find((p) => p.name === appName.value) ?? null |
|||
deployments.value = (ds ?? []).filter((d) => d.app_name === appName.value) |
|||
envs.value = es ?? [] |
|||
// 默认配置目标:优先已接入的部署(待部署的业务库通常还连不上) |
|||
if (!deployments.value.some((d) => d.id === curDep.value)) { |
|||
const first = deployments.value.find((d) => d.status !== 'pending') ?? deployments.value[0] |
|||
curDep.value = first?.id ?? 0 |
|||
} |
|||
} catch (e: any) { |
|||
error(e?.message ?? '加载应用详情失败') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
// 从部署列表点「配置」:带着那个部署跳到业务功能配置 |
|||
function onConfigure(id: number) { |
|||
curDep.value = id |
|||
tab.value = 'modules' |
|||
} |
|||
|
|||
// ---- 编辑应用 ---- |
|||
const appDlg = ref<HTMLDialogElement | null>(null) |
|||
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true }) |
|||
|
|||
function openAppForm() { |
|||
const p = product.value |
|||
if (!p) return |
|||
pForm.id = p.id |
|||
pForm.name = p.name |
|||
pForm.image_name = p.image_name ?? '' |
|||
pForm.remark = p.remark ?? '' |
|||
pForm.enabled = !!p.enabled |
|||
appDlg.value?.showModal() |
|||
} |
|||
|
|||
async function saveProduct() { |
|||
try { |
|||
const renamed = pForm.name !== appName.value |
|||
await consoleApi('products/update', { |
|||
id: pForm.id, name: pForm.name, image_name: pForm.image_name, remark: pForm.remark, enabled: pForm.enabled, |
|||
}) |
|||
success('保存成功') |
|||
appDlg.value?.close() |
|||
// 改了应用名,当前 URL 已失效,跳到新名字下 |
|||
if (renamed) router.replace(`/apps/${encodeURIComponent(pForm.name)}`) |
|||
else await loadAll() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} |
|||
} |
|||
|
|||
watch(appName, loadAll) |
|||
onMounted(loadAll) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.hero { |
|||
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 20px 22px; margin-bottom: 16px; |
|||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 4px 16px rgba(15, 23, 42, .03); |
|||
display: flex; gap: 18px; align-items: center; flex-wrap: wrap; |
|||
} |
|||
.hero-logo { |
|||
width: 58px; height: 58px; border-radius: 15px; flex-shrink: 0; color: #fff; |
|||
background: linear-gradient(135deg, #4f46e5, #7c3aed); |
|||
display: grid; place-items: center; font-weight: 800; font-size: 24px; |
|||
box-shadow: 0 8px 22px rgba(79, 70, 229, .3); |
|||
} |
|||
.hero-name { font-size: 20px; font-weight: 700; color: #1e293b; letter-spacing: -.015em; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; } |
|||
|
|||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; } |
|||
.kpi { |
|||
background: #fff; border: 1px solid #eef2f7; border-radius: 12px; padding: 13px 15px; |
|||
position: relative; overflow: hidden; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); |
|||
} |
|||
.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: #e2e8f0; } |
|||
.kpi.ok::before { background: #16a34a; } |
|||
.kpi.warn::before { background: #d97706; } |
|||
.kpi.accent::before { background: #4f46e5; } |
|||
.kpi-l { font-size: 10.5px; color: #94a3b8; letter-spacing: .04em; text-transform: uppercase; font-weight: 600; } |
|||
.kpi-v { font-size: 22px; font-weight: 700; color: #1e293b; margin-top: 3px; letter-spacing: -.02em; font-family: 'SF Mono', Menlo, monospace; font-variant-numeric: tabular-nums; } |
|||
.kpi-v small { font-size: 12px; color: #94a3b8; font-weight: 500; margin-left: 3px; } |
|||
|
|||
.tablist { display: flex; gap: 2px; border-bottom: 1px solid #e2e8f0; margin-bottom: 18px; overflow-x: auto; } |
|||
.tab { |
|||
border: 0; background: none; font-size: 13.5px; color: #64748b; padding: 10px 15px; cursor: pointer; |
|||
border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; display: flex; align-items: center; gap: 6px; |
|||
} |
|||
.tab:hover { color: #1e293b; } |
|||
.tab[aria-selected='true'] { color: #4338ca; border-bottom-color: #4f46e5; font-weight: 600; } |
|||
.tab-n { font-size: 10.5px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; border-radius: 20px; padding: 0 6px; font-family: 'SF Mono', Menlo, monospace; } |
|||
.tab[aria-selected='true'] .tab-n { background: #eef2ff; color: #4338ca; border-color: transparent; } |
|||
|
|||
.depbar { |
|||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; |
|||
background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px; |
|||
} |
|||
.depchip { |
|||
border: 1px solid #e2e8f0; background: #fff; border-radius: 20px; padding: 4px 12px; font-size: 12px; |
|||
cursor: pointer; color: #334155; display: flex; align-items: center; gap: 6px; |
|||
} |
|||
.depchip:hover { border-color: #0e7490; } |
|||
.depchip[aria-pressed='true'] { background: #ecfeff; border-color: #0e7490; color: #0e7490; font-weight: 600; } |
|||
.depchip .dot { width: 6px; height: 6px; border-radius: 50%; background: #16a34a; } |
|||
.depchip .dot.pending { background: #d97706; } |
|||
|
|||
.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-ok { background: #dcfce7; color: #15803d; } |
|||
.chip-mono { font-family: 'SF Mono', Menlo, monospace; font-weight: 600; } |
|||
</style> |
|||
@ -0,0 +1,309 @@ |
|||
<template> |
|||
<div> |
|||
<div class="flex items-start justify-between mb-5 flex-wrap gap-3"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">应用中心</h2> |
|||
<p class="text-sm text-slate-400 mt-1 max-w-3xl"> |
|||
一张卡片 = 一个 <span class="chip chip-app">应用</span>,卡片里是它在各区域的 <span class="chip chip-dep">部署</span> 概况。 |
|||
点卡片进入应用详情:编辑应用、部署新环境、升级服务、改各项配置。 |
|||
</p> |
|||
</div> |
|||
<button class="btn btn-primary btn-sm text-white" @click="openAppForm()">+ 添加应用</button> |
|||
</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> |
|||
<div v-else-if="!groups" class="bg-white rounded-xl border border-slate-100 py-16 text-center text-slate-400"> |
|||
暂无应用,先「添加应用」 |
|||
</div> |
|||
|
|||
<div v-else class="app-grid"> |
|||
<!-- 应用卡片 --> |
|||
<article |
|||
v-for="g in productGroups" |
|||
:key="g.product.name" |
|||
class="app-card" |
|||
:class="{ off: !g.product.enabled }" |
|||
tabindex="0" |
|||
role="button" |
|||
@click="enter(g.product.name)" |
|||
@keydown.enter.prevent="enter(g.product.name)" |
|||
@keydown.space.prevent="enter(g.product.name)" |
|||
> |
|||
<div class="ac-head"> |
|||
<div class="ac-logo">{{ initial(g.product.name) }}</div> |
|||
<div class="min-w-0 flex-1"> |
|||
<div class="ac-title">{{ g.product.name }}</div> |
|||
<div class="ac-meta">{{ g.product.image_name ? '🐳 ' + g.product.image_name : '未设置默认镜像' }}</div> |
|||
</div> |
|||
<span v-if="!g.product.enabled" class="chip">停用</span> |
|||
</div> |
|||
|
|||
<p class="ac-remark">{{ g.product.remark || '—' }}</p> |
|||
|
|||
<div class="ac-stats"> |
|||
<div class="ac-stat"> |
|||
<div class="ac-stat-n" :class="{ zero: !g.deployments.length }">{{ g.deployments.length }}</div> |
|||
<div class="ac-stat-l">部署</div> |
|||
</div> |
|||
<div class="ac-stat"> |
|||
<div class="ac-stat-n" :class="{ zero: !g.okCount }">{{ g.okCount }}</div> |
|||
<div class="ac-stat-l">已接入</div> |
|||
</div> |
|||
<div class="ac-stat"> |
|||
<div class="ac-stat-n" :class="g.pendingCount ? 'warn' : 'zero'">{{ g.pendingCount }}</div> |
|||
<div class="ac-stat-l">待部署</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="ac-regions"> |
|||
<template v-if="g.deployments.length"> |
|||
<span v-for="d in g.deployments" :key="d.id" class="chip chip-dep"> |
|||
{{ d.region || '未指定' }}<template v-if="d.status === 'pending'"> · 待部署</template> |
|||
</span> |
|||
</template> |
|||
<span v-else class="text-xs text-slate-300">还没有任何部署</span> |
|||
</div> |
|||
|
|||
<div class="ac-foot" @click.stop> |
|||
<button class="btn btn-ghost btn-xs" @click="openAppForm(g.product)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="delProduct(g.product)">删除</button> |
|||
<button class="btn btn-primary btn-xs text-white" @click="enter(g.product.name)">进入详情 →</button> |
|||
</div> |
|||
</article> |
|||
|
|||
<!-- 未分组部署:不是应用,不能进详情,只能逐条归位 --> |
|||
<article v-if="orphans.length" class="app-card orphan"> |
|||
<div class="ac-head"> |
|||
<div class="ac-logo">?</div> |
|||
<div class="min-w-0 flex-1"> |
|||
<div class="ac-title">未分组部署</div> |
|||
<div class="ac-meta">需要归位到某个应用</div> |
|||
</div> |
|||
<span class="chip chip-warn">待归位</span> |
|||
</div> |
|||
<p class="ac-remark">未填所属应用名、或应用已被删除的历史部署。选一个应用把它归位,之后就能在该应用详情里管理。</p> |
|||
<div class="orphan-list"> |
|||
<div v-for="d in orphans" :key="d.id" class="orphan-row"> |
|||
<div class="min-w-0"> |
|||
<div class="text-sm font-medium text-slate-700 truncate">{{ d.name || '部署#' + d.id }}</div> |
|||
<div class="text-[11px] text-slate-400 font-mono">#{{ d.id }} · {{ d.region || '未指定区域' }}</div> |
|||
</div> |
|||
<select v-model="assignTo[d.id]" class="select select-bordered select-xs w-32"> |
|||
<option value="">选择应用…</option> |
|||
<option v-for="p in products" :key="p.id" :value="p.name">{{ p.name }}</option> |
|||
</select> |
|||
<button class="btn btn-xs" :disabled="!assignTo[d.id]" @click="assign(d)">归位</button> |
|||
</div> |
|||
</div> |
|||
</article> |
|||
</div> |
|||
|
|||
<!-- 应用 新增/编辑 --> |
|||
<dialog ref="appDlg" class="modal"> |
|||
<div class="modal-box max-w-lg"> |
|||
<h3 class="font-bold text-lg">{{ pEditing ? '编辑应用' : '添加应用' }}</h3> |
|||
<p class="text-xs text-slate-400 mt-1"> |
|||
应用名是关联键:部署、账号作用域、第三方服务配置都按它挂载,改名会断开已有关联。 |
|||
</p> |
|||
<form class="mt-4 space-y-3" @submit.prevent="saveProduct"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">应用名 *</span></label> |
|||
<input v-model="pForm.name" type="text" class="input input-bordered input-sm w-full" required placeholder="如 Voitrans" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">默认镜像名</span></label> |
|||
<input v-model="pForm.image_name" type="text" class="input input-bordered input-sm w-full font-mono text-xs" placeholder="voitrans-a11(新建部署时预填)" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label> |
|||
<input v-model="pForm.remark" type="text" class="input input-bordered input-sm w-full" /> |
|||
</div> |
|||
<label class="flex items-center gap-2 cursor-pointer pt-1"> |
|||
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" /> |
|||
<span class="text-sm">启用</span> |
|||
</label> |
|||
<div class="modal-action"> |
|||
<button type="button" class="btn btn-ghost btn-sm" @click="appDlg?.close()">取消</button> |
|||
<button type="submit" class="btn btn-primary btn-sm text-white">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
definePageMeta({ title: '应用中心' }) |
|||
|
|||
interface Product { id: number; name: string; image_name?: string; remark: string; enabled: boolean } |
|||
interface Deployment { id: number; app_name: string; status: string; name: string; region: string } |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
const router = useRouter() |
|||
|
|||
const products = ref<Product[]>([]) |
|||
const deployments = ref<Deployment[]>([]) |
|||
const loading = ref(false) |
|||
const assignTo = reactive<Record<number, string>>({}) |
|||
|
|||
const byApp = computed(() => { |
|||
const m = new Map<string, Deployment[]>() |
|||
for (const d of deployments.value) { |
|||
const k = d.app_name || '' |
|||
if (!m.has(k)) m.set(k, []) |
|||
m.get(k)!.push(d) |
|||
} |
|||
return m |
|||
}) |
|||
|
|||
const productGroups = computed(() => |
|||
products.value.map((p) => { |
|||
const deps = byApp.value.get(p.name) || [] |
|||
return { |
|||
product: p, |
|||
deployments: deps, |
|||
okCount: deps.filter((d) => d.status !== 'pending').length, |
|||
pendingCount: deps.filter((d) => d.status === 'pending').length, |
|||
} |
|||
}), |
|||
) |
|||
|
|||
// 未分组:app_name 为空、或指向一个已不存在的应用 |
|||
const orphans = computed(() => { |
|||
const known = new Set(products.value.map((p) => p.name)) |
|||
return deployments.value.filter((d) => !d.app_name || !known.has(d.app_name)) |
|||
}) |
|||
|
|||
const groups = computed(() => productGroups.value.length + (orphans.value.length ? 1 : 0)) |
|||
|
|||
function initial(name: string) { return (name || '?').charAt(0).toUpperCase() } |
|||
function enter(name: string) { router.push(`/apps/${encodeURIComponent(name)}`) } |
|||
|
|||
async function loadAll() { |
|||
loading.value = true |
|||
try { |
|||
const [ps, ds] = await Promise.all([ |
|||
consoleApi<Product[]>('products/list', {}), |
|||
consoleApi<Deployment[]>('apps/list', {}), |
|||
]) |
|||
products.value = ps ?? [] |
|||
deployments.value = ds ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取应用列表失败') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
// ---- 应用表单 ---- |
|||
const appDlg = ref<HTMLDialogElement | null>(null) |
|||
const pEditing = ref(false) |
|||
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true }) |
|||
|
|||
function openAppForm(p?: Product) { |
|||
pEditing.value = !!p |
|||
pForm.id = p?.id ?? 0 |
|||
pForm.name = p?.name ?? '' |
|||
pForm.image_name = p?.image_name ?? '' |
|||
pForm.remark = p?.remark ?? '' |
|||
pForm.enabled = p ? !!p.enabled : true |
|||
appDlg.value?.showModal() |
|||
} |
|||
|
|||
async function saveProduct() { |
|||
const data: Record<string, unknown> = { |
|||
name: pForm.name, image_name: pForm.image_name, remark: pForm.remark, enabled: pForm.enabled, |
|||
} |
|||
let path = 'products/add' |
|||
if (pEditing.value && pForm.id) { data.id = Number(pForm.id); path = 'products/update' } |
|||
try { |
|||
await consoleApi(path, data) |
|||
success('保存成功') |
|||
appDlg.value?.close() |
|||
await loadAll() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} |
|||
} |
|||
|
|||
function delProduct(p: Product) { |
|||
if (!window.confirm(`删除应用「${p.name}」?(其下若有区域部署将被拒绝)`)) return |
|||
consoleApi('products/del', { id: p.id }) |
|||
.then(() => { success('已删除'); loadAll() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
async function assign(d: Deployment) { |
|||
const target = assignTo[d.id] |
|||
if (!target) return |
|||
try { |
|||
await consoleApi('apps/assign', { id: d.id, app_name: target }) |
|||
success(`已归位到「${target}」`) |
|||
delete assignTo[d.id] |
|||
await loadAll() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '归位失败') |
|||
} |
|||
} |
|||
|
|||
onMounted(loadAll) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.app-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; } |
|||
|
|||
.app-card { |
|||
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; overflow: hidden; |
|||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 4px 16px rgba(15, 23, 42, .03); |
|||
cursor: pointer; position: relative; display: flex; flex-direction: column; |
|||
transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease; |
|||
} |
|||
.app-card:hover { transform: translateY(-2px); box-shadow: 0 10px 40px rgba(15, 23, 42, .12); border-color: #e2e8f0; } |
|||
.app-card:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; } |
|||
.app-card::before { content: ''; position: absolute; inset: 0 0 auto; height: 3px; background: linear-gradient(90deg, #4f46e5, #7c3aed); } |
|||
.app-card.off::before { background: #94a3b8; } |
|||
.app-card.orphan { cursor: default; border-color: #fde68a; } |
|||
.app-card.orphan::before { background: #f59e0b; } |
|||
.app-card.orphan:hover { transform: none; } |
|||
|
|||
.ac-head { padding: 18px 18px 12px; display: flex; gap: 13px; align-items: flex-start; } |
|||
.ac-logo { |
|||
width: 44px; height: 44px; border-radius: 12px; flex-shrink: 0; color: #fff; |
|||
background: linear-gradient(135deg, #4f46e5, #7c3aed); |
|||
display: grid; place-items: center; font-weight: 800; font-size: 18px; |
|||
box-shadow: 0 5px 14px rgba(79, 70, 229, .28); |
|||
} |
|||
.app-card.off .ac-logo { background: #94a3b8; box-shadow: none; } |
|||
.app-card.orphan .ac-logo { background: #f59e0b; box-shadow: none; } |
|||
.ac-title { font-size: 15.5px; font-weight: 700; color: #1e293b; letter-spacing: -.01em; } |
|||
.ac-meta { font-size: 11.5px; color: #94a3b8; margin-top: 3px; } |
|||
.ac-remark { font-size: 12px; color: #64748b; padding: 0 18px 14px; margin: 0; min-height: 2.4em; } |
|||
|
|||
.ac-stats { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid #eef2f7; background: #fcfcfd; } |
|||
.ac-stat { padding: 11px 8px; text-align: center; border-right: 1px solid #eef2f7; } |
|||
.ac-stat:last-child { border-right: 0; } |
|||
.ac-stat-n { font-size: 17px; font-weight: 700; line-height: 1.2; color: #1e293b; font-family: 'SF Mono', Menlo, monospace; font-variant-numeric: tabular-nums; } |
|||
.ac-stat-n.warn { color: #b45309; } |
|||
.ac-stat-n.zero { color: #cbd5e1; } |
|||
.ac-stat-l { font-size: 10px; color: #94a3b8; margin-top: 2px; letter-spacing: .03em; } |
|||
|
|||
.ac-regions { padding: 11px 18px; display: flex; gap: 5px; flex-wrap: wrap; align-items: center; border-top: 1px solid #eef2f7; min-height: 42px; } |
|||
.ac-foot { padding: 10px 14px; border-top: 1px solid #eef2f7; display: flex; gap: 6px; justify-content: flex-end; margin-top: auto; } |
|||
|
|||
.orphan-list { border-top: 1px solid #eef2f7; } |
|||
.orphan-row { display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-bottom: 1px solid #f8fafc; } |
|||
.orphan-row:last-child { border-bottom: 0; } |
|||
|
|||
.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; } |
|||
.chip-dep { background: #ecfeff; color: #0e7490; } |
|||
.chip-warn { background: #fef3c7; color: #b45309; } |
|||
</style> |
|||
@ -0,0 +1,32 @@ |
|||
// 应用中心(/apps)共享类型:应用、区域部署、部署环境节点。
|
|||
// 字段与 console 后端 modules/console 的 Product / AppRegistry / Environment 对齐。
|
|||
|
|||
export interface Product { |
|||
id: number |
|||
name: string |
|||
image_name?: string |
|||
remark: string |
|||
enabled: boolean |
|||
} |
|||
|
|||
export interface Deployment { |
|||
id: number |
|||
app_name: string |
|||
status: string // 'pending' = 待部署;其余 = 已接入
|
|||
name: string |
|||
region: string |
|||
env_id: number |
|||
base_url: string |
|||
service_dsn: string |
|||
deploy_dir: string |
|||
image_name: string |
|||
image_tag: string |
|||
remark: string |
|||
enabled: boolean |
|||
} |
|||
|
|||
export interface EnvOption { |
|||
id: number |
|||
name: string |
|||
code: string |
|||
} |
|||
@ -1,219 +0,0 @@ |
|||
package comm |
|||
|
|||
import ( |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/lego/sys/mysql" |
|||
) |
|||
|
|||
// 会议记录(echomeet)服务配置托管:把「识别 / 翻译 / 总结」三类服务配置全部搬进管理后台。
|
|||
//
|
|||
// 与旧的「一会一服务商,三段同源」不同:识别/翻译/总结各自维护一份**按优先级排序的服务清单**,
|
|||
// - 识别:按优先级 + 各服务「支持语言」自动选择(跳过不支持源语言的服务);
|
|||
// - 翻译:按优先级自动选择;
|
|||
// - 总结:每条 = 一个具体文本大模型条目,客户端按 id 选择。
|
|||
//
|
|||
// 存储沿用 [[app_module_config]] 的路子:存**应用自己的业务库**(mysql/pg,随部署)、**全局一份**(无 app_name)。
|
|||
// console 后台经 getServiceDB 写、home 的 echomeet 启动时 LoadOrSeed 读(改配置需重启生效)。
|
|||
// 密钥字段以 AES-256-GCM 密文落库(comm.Encrypt,${FIELD_ENCRYPT_KEY},console 与业务侧同一把 key),
|
|||
// 返回前端脱敏为 EncMask。字段清单由 EchoSvcCatalog 数据驱动(哪些字段、类型、是否密钥)。
|
|||
|
|||
// 服务类别。
|
|||
const ( |
|||
EchoSvcCatASR = 1 // 识别(转写)
|
|||
EchoSvcCatMT = 2 // 翻译
|
|||
EchoSvcCatLLM = 3 // 总结(文本大模型)
|
|||
) |
|||
|
|||
// EchoSvcConfig 一行 = 某类别下一条服务/模型条目。存应用业务库,全局一份。
|
|||
type EchoSvcConfig struct { |
|||
Id string `gorm:"primaryKey;column:id;size:64" json:"id"` // 稳定键:asr_ali / mt_bytedance / llm_qwen_plus
|
|||
Category int32 `gorm:"column:category;index:idx_echosvc_cat" json:"category"` // 1 识别 / 2 翻译 / 3 总结
|
|||
Provider string `gorm:"column:provider;size:32" json:"provider"` // bytedance/ali/google/microsoft(识别/翻译);doubao/openai/gemini(总结)
|
|||
Name string `gorm:"column:name;size:128" json:"name"` // 展示名(总结类即模型友好名,客户端可见)
|
|||
Priority int32 `gorm:"column:priority" json:"priority"` // 同类内升序,小=优先
|
|||
Enabled bool `gorm:"column:enabled" json:"enabled"` // 是否启用
|
|||
SupportLangs string `gorm:"column:support_langs;type:text" json:"support_langs"` // 识别用:逗号分隔 BCP-47;空=按代码默认支持集
|
|||
Values map[string]string `gorm:"column:values;type:text;serializer:json" json:"values"` // 参数/凭据值;密钥字段存密文
|
|||
Createtime int64 `gorm:"column:createtime" json:"createtime"` |
|||
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` |
|||
} |
|||
|
|||
func (EchoSvcConfig) TableName() string { return TableEchomeetSvcConfig } |
|||
|
|||
// EchoSvcField 一个配置字段的元数据(驱动前端表单 + 后端密钥判定)。
|
|||
type EchoSvcField struct { |
|||
Key string `json:"key"` |
|||
Label string `json:"label"` |
|||
Type string `json:"type"` // text|password|textarea|url
|
|||
Secret bool `json:"secret"` |
|||
Required bool `json:"required"` |
|||
Placeholder string `json:"placeholder,omitempty"` |
|||
Desc string `json:"desc,omitempty"` |
|||
} |
|||
|
|||
// EchoSvcProviderDef 某类别下某服务商/模型实现的字段模板。
|
|||
type EchoSvcProviderDef struct { |
|||
Category int32 `json:"category"` // 所属类别
|
|||
Provider string `json:"provider"` // 存库 Provider 值
|
|||
Name string `json:"name"` // 展示名
|
|||
HasLangs bool `json:"has_langs"` // 是否展示「支持语言」(识别类为 true)
|
|||
Multi bool `json:"multi"` // 是否允许同 provider 多条(总结类为 true:一个服务商多个模型)
|
|||
Fields []EchoSvcField `json:"fields"` |
|||
} |
|||
|
|||
// 常用字段构造助手。
|
|||
func esfText(key, label string, required bool) EchoSvcField { |
|||
return EchoSvcField{Key: key, Label: label, Type: "text", Required: required} |
|||
} |
|||
func esfSecret(key, label string, required bool) EchoSvcField { |
|||
return EchoSvcField{Key: key, Label: label, Type: "password", Secret: true, Required: required} |
|||
} |
|||
|
|||
// EchoSvcCatalog 会议记录服务字段目录:识别/翻译/总结 × 各服务商,字段依据 home.yaml 的 echomeet 配置结构。
|
|||
var EchoSvcCatalog = []EchoSvcProviderDef{ |
|||
// ===== 识别(转写)=====
|
|||
{Category: EchoSvcCatASR, Provider: "bytedance", Name: "字节跳动", HasLangs: true, Fields: []EchoSvcField{ |
|||
esfText("AppID", "AppID", true), |
|||
esfSecret("Token", "Token", false), |
|||
esfText("Callback", "回调地址", false), |
|||
}}, |
|||
{Category: EchoSvcCatASR, Provider: "ali", Name: "阿里云 DashScope", HasLangs: true, Fields: []EchoSvcField{ |
|||
esfSecret("ApiKey", "ApiKey", true), |
|||
esfText("Callback", "回调地址", false), |
|||
}}, |
|||
{Category: EchoSvcCatASR, Provider: "google", Name: "Google Cloud", HasLangs: true, Fields: []EchoSvcField{ |
|||
{Key: "JsonPath", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true, |
|||
Desc: "服务账号 JSON 内容或服务器上的文件路径"}, |
|||
}}, |
|||
{Category: EchoSvcCatASR, Provider: "microsoft", Name: "Azure 语音", HasLangs: true, Fields: []EchoSvcField{ |
|||
esfSecret("Key", "Key", true), |
|||
esfText("Region", "Region", true), |
|||
}}, |
|||
|
|||
// ===== 翻译 =====
|
|||
{Category: EchoSvcCatMT, Provider: "bytedance", Name: "火山翻译", Fields: []EchoSvcField{ |
|||
esfText("AccessKey", "AccessKey", true), |
|||
esfSecret("SecretKey", "SecretKey", true), |
|||
esfText("Region", "Region", false), |
|||
}}, |
|||
{Category: EchoSvcCatMT, Provider: "ali", Name: "阿里机器翻译", Fields: []EchoSvcField{ |
|||
esfText("AccessKeyId", "AccessKeyId", true), |
|||
esfSecret("AccessKeySecret", "AccessKeySecret", true), |
|||
esfText("Region", "Region", false), |
|||
}}, |
|||
{Category: EchoSvcCatMT, Provider: "google", Name: "Google 翻译", Fields: []EchoSvcField{ |
|||
{Key: "JsonPath", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true, |
|||
Desc: "服务账号 JSON 内容或服务器上的文件路径"}, |
|||
}}, |
|||
{Category: EchoSvcCatMT, Provider: "microsoft", Name: "Azure 翻译", Fields: []EchoSvcField{ |
|||
esfSecret("Key", "Key", true), |
|||
esfText("Region", "Region", false), |
|||
esfText("Endpoint", "Endpoint", false), |
|||
}}, |
|||
|
|||
// ===== 总结(文本大模型;一个服务商可配多条不同模型)=====
|
|||
{Category: EchoSvcCatLLM, Provider: "doubao", Name: "豆包(字节)", Multi: true, Fields: []EchoSvcField{ |
|||
esfSecret("Apikey", "ApiKey", true), |
|||
esfText("Model", "模型 ID", true), |
|||
}}, |
|||
{Category: EchoSvcCatLLM, Provider: "openai", Name: "OpenAI 兼容端点(通义/Azure OpenAI 等)", Multi: true, Fields: []EchoSvcField{ |
|||
esfText("BaseURL", "BaseURL", true), |
|||
esfSecret("Token", "Token", true), |
|||
esfText("Model", "模型 ID", true), |
|||
}}, |
|||
{Category: EchoSvcCatLLM, Provider: "gemini", Name: "Gemini(Google)", Multi: true, Fields: []EchoSvcField{ |
|||
esfSecret("Apikey", "ApiKey", true), |
|||
esfText("Model", "模型 ID", true), |
|||
esfText("Endpoint", "自定义 Endpoint(可选)", false), |
|||
}}, |
|||
} |
|||
|
|||
// FindEchoSvcDef 按 (类别, provider) 取字段模板;找不到返回 nil。
|
|||
func FindEchoSvcDef(category int32, provider string) *EchoSvcProviderDef { |
|||
for i := range EchoSvcCatalog { |
|||
if EchoSvcCatalog[i].Category == category && EchoSvcCatalog[i].Provider == provider { |
|||
return &EchoSvcCatalog[i] |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// EchoSvcFieldIsSecret 判断某 (类别,provider,字段) 是否密钥字段。目录优先,回退通用命名规则。
|
|||
func EchoSvcFieldIsSecret(category int32, provider, key string) bool { |
|||
if def := FindEchoSvcDef(category, provider); def != nil { |
|||
for _, f := range def.Fields { |
|||
if f.Key == key { |
|||
return f.Secret |
|||
} |
|||
} |
|||
} |
|||
return IsSecretField(key) |
|||
} |
|||
|
|||
// EncryptEchoValues 返回 values 的副本,把密钥字段加密(空值/已是 EncMask 的不动,交由调用方决定)。
|
|||
func EncryptEchoValues(encKey string, category int32, provider string, values map[string]string) map[string]string { |
|||
out := make(map[string]string, len(values)) |
|||
for k, v := range values { |
|||
if EchoSvcFieldIsSecret(category, provider, k) && v != "" && v != EncMask { |
|||
if ct, err := Encrypt(encKey, v); err == nil { |
|||
out[k] = ct |
|||
continue |
|||
} |
|||
} |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// DecryptEchoValues 返回 values 的副本,把密钥字段解密(供运行时消费)。
|
|||
func DecryptEchoValues(encKey string, category int32, provider string, values map[string]string) map[string]string { |
|||
out := make(map[string]string, len(values)) |
|||
for k, v := range values { |
|||
if EchoSvcFieldIsSecret(category, provider, k) && v != "" { |
|||
plain, err := Decrypt(encKey, v) |
|||
if err == nil { |
|||
out[k] = plain |
|||
continue |
|||
} |
|||
// 仍沿用密文(保持既有行为),但必须留痕:否则密文会被当明文用作第三方 API key,
|
|||
// 表现为鉴权失败,而后台显示"已配置",无从排查。
|
|||
log.Errorf("[EchoSvcConfig] 密钥字段解密失败 category=%d provider=%s key=%s err=%v 本服务key指纹=%s %s", |
|||
category, provider, k, err, KeyFingerprint(encKey), DiagnoseDecryptFailure(encKey, v)) |
|||
} |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// MaskEchoValues 返回 values 的副本,把密钥字段脱敏为 EncMask(返回前端用)。
|
|||
func MaskEchoValues(category int32, provider string, values map[string]string) map[string]string { |
|||
out := make(map[string]string, len(values)) |
|||
for k, v := range values { |
|||
if EchoSvcFieldIsSecret(category, provider, k) && v != "" { |
|||
out[k] = EncMask |
|||
continue |
|||
} |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// EnsureEchoSvcTable 建表(幂等)。db 为应用业务库。
|
|||
func EnsureEchoSvcTable(db mysql.ISys) error { |
|||
if db == nil { |
|||
return nil |
|||
} |
|||
return db.CreateTable(TableEchomeetSvcConfig, &EchoSvcConfig{}) |
|||
} |
|||
|
|||
// LoadEchoSvcConfigs 读全部会议记录服务配置行(未解密)。
|
|||
func LoadEchoSvcConfigs(db mysql.ISys) ([]*EchoSvcConfig, error) { |
|||
if db == nil { |
|||
return nil, nil |
|||
} |
|||
rows := make([]*EchoSvcConfig, 0) |
|||
if err := db.Find(TableEchomeetSvcConfig, &rows, ""); err != nil { |
|||
return nil, err |
|||
} |
|||
return rows, nil |
|||
} |
|||
@ -0,0 +1,318 @@ |
|||
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 |
|||
} |
|||
} |
|||
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) |
|||
} |
|||
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++ |
|||
} |
|||
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 |
|||
} |
|||
} |
|||
writeOK(c, gin.H{"llm_client_choice": req.LLMClientChoice}) |
|||
} |
|||
@ -1,218 +0,0 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ 会议记录服务配置 (echomeetsvc) ============================
|
|||
//
|
|||
// admin「会议记录服务配置」页的后端:读写**应用业务库**里的 echomeet_svc_config 表
|
|||
// (识别/翻译/总结三类,各自按优先级;识别可配支持语言;总结为具体模型条目)。全局一份、无 app_name。
|
|||
//
|
|||
// 业务库连接按部署 appId 从 registry 动态取(与 modulecfg 同机制);
|
|||
// 密钥字段(是否密钥以 comm.EchoSvcCatalog 为准)AES-GCM 加密落库、返回前端脱敏为 comm.EncMask。
|
|||
// 改配置后广播 ConfigKindEchomeetSvc;home 侧 echomeet 改配置需重启生效。
|
|||
|
|||
// echoSvcCatalog 返回会议记录服务字段目录(前端据此渲染各服务商表单、识别语言选择器)。与选中应用无关。
|
|||
func (this *serverComp) echoSvcCatalog(c *gin.Context) { |
|||
writeOK(c, gin.H{ |
|||
"catalog": comm.EchoSvcCatalog, |
|||
"categories": []gin.H{ |
|||
{"value": comm.EchoSvcCatASR, "label": "识别"}, |
|||
{"value": comm.EchoSvcCatMT, "label": "翻译"}, |
|||
{"value": comm.EchoSvcCatLLM, "label": "总结"}, |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
// echoSvcList 列出某部署业务库里的会议记录服务配置;密钥脱敏。
|
|||
func (this *serverComp) echoSvcList(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
if req.AppId == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
if err := comm.EnsureEchoSvcTable(db); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
rows, err := comm.LoadEchoSvcConfigs(db) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
for _, r := range rows { |
|||
r.Values = comm.MaskEchoValues(r.Category, r.Provider, r.Values) |
|||
} |
|||
writeOK(c, gin.H{"items": rows}) |
|||
} |
|||
|
|||
// echoSvcSave 保存单条服务配置:新增/更新(按 id)。
|
|||
// 是否密钥以目录为准(不信前端);密钥字段回传占位/空则保留原密文,否则加密新明文;目录外的键忽略。
|
|||
func (this *serverComp) echoSvcSave(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
Config comm.EchoSvcConfig `json:"config"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
cfg := req.Config |
|||
cfg.Id = strings.TrimSpace(cfg.Id) |
|||
cfg.Provider = strings.TrimSpace(cfg.Provider) |
|||
if req.AppId == 0 || cfg.Id == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填") |
|||
return |
|||
} |
|||
def := comm.FindEchoSvcDef(cfg.Category, cfg.Provider) |
|||
if def == nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "未知的服务类别/服务商组合") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
if err := comm.EnsureEchoSvcTable(db); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
|
|||
// 取旧行以保留密钥密文。
|
|||
old := &comm.EchoSvcConfig{} |
|||
hasOld := db.FindOne(comm.TableEchomeetSvcConfig, old, "id=?", cfg.Id) == nil && old.Id != "" |
|||
|
|||
// 合法字段过滤 + 密钥处理。
|
|||
allowed := make(map[string]bool, len(def.Fields)) |
|||
for _, f := range def.Fields { |
|||
allowed[f.Key] = true |
|||
} |
|||
encKey := this.options.EncryptKey |
|||
stored := make(map[string]string, len(cfg.Values)) |
|||
for k, v := range cfg.Values { |
|||
if !allowed[k] { |
|||
continue |
|||
} |
|||
if comm.EchoSvcFieldIsSecret(cfg.Category, cfg.Provider, k) { |
|||
if v == "" || v == comm.EncMask { |
|||
if hasOld { |
|||
stored[k] = old.Values[k] // 保留原密文
|
|||
} |
|||
continue |
|||
} |
|||
ct, ee := comm.Encrypt(encKey, v) |
|||
if ee != nil { |
|||
writeErr(c, pb.ErrorCode_SystemError, "加密失败: "+ee.Error()) |
|||
return |
|||
} |
|||
stored[k] = ct |
|||
continue |
|||
} |
|||
stored[k] = v |
|||
} |
|||
cfg.Values = stored |
|||
|
|||
now := time.Now().Unix() |
|||
cfg.Updatetime = now |
|||
if hasOld { |
|||
cfg.Createtime = old.Createtime |
|||
if e := db.Save(comm.TableEchomeetSvcConfig, &cfg); e != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, e.Error()) |
|||
return |
|||
} |
|||
} else { |
|||
cfg.Createtime = now |
|||
if e := db.Insert(comm.TableEchomeetSvcConfig, &cfg); e != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, e.Error()) |
|||
return |
|||
} |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "update", 0) |
|||
writeOK(c, gin.H{"id": cfg.Id}) |
|||
} |
|||
|
|||
// echoSvcDel 删除一条服务配置。
|
|||
func (this *serverComp) echoSvcDel(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
Id string `json:"id"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
req.Id = strings.TrimSpace(req.Id) |
|||
if req.AppId == 0 || req.Id == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
if err := db.Delete(comm.TableEchomeetSvcConfig, "id=?", req.Id); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "delete", 0) |
|||
writeOK(c, gin.H{"deleted": req.Id}) |
|||
} |
|||
|
|||
// echoSvcReorder 批量调整同类内优先级(拖拽排序后提交)。
|
|||
func (this *serverComp) echoSvcReorder(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
Items []struct { |
|||
Id string `json:"id"` |
|||
Priority int32 `json:"priority"` |
|||
} `json:"items"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if req.AppId == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
now := time.Now().Unix() |
|||
updated := 0 |
|||
for _, it := range req.Items { |
|||
id := strings.TrimSpace(it.Id) |
|||
if id == "" { |
|||
continue |
|||
} |
|||
row := &comm.EchoSvcConfig{} |
|||
if e := db.FindOne(comm.TableEchomeetSvcConfig, row, "id=?", id); e != nil || row.Id == "" { |
|||
continue |
|||
} |
|||
row.Priority = it.Priority |
|||
row.Updatetime = now |
|||
if e := db.Save(comm.TableEchomeetSvcConfig, row); e != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, e.Error()) |
|||
return |
|||
} |
|||
updated++ |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "update", 0) |
|||
writeOK(c, gin.H{"updated": updated}) |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
package console |
|||
|
|||
// 会议记录服务编排 (echomeet orchestration)。
|
|||
//
|
|||
// 与旧的 echomeet_svc_config(每应用一份、在业务库、自己存凭据)不同:
|
|||
// 编排**不存凭据**,只引用「第三方服务配置」(svc_config) 里配好的服务 id,按
|
|||
// 作用域 = (app_name, region) 分层:app_name='' 且 region=0 是「全局默认」兜底,
|
|||
// 越具体越优先。凭据、区域覆盖都由 svc_config / svc_region_override 那一套负责。
|
|||
//
|
|||
// 三类(Kind)各自映射到服务池的服务类别,管理端选服务时按此过滤:
|
|||
// 识别 → svcCatASRFile(8);翻译 → svcCatMT(3);总结 → svcCatLLM(5) + svcCatLLMVision(9)。
|
|||
// 识别/翻译按 Priority 升序选路,总结按 IsDefault + ClientVisible + 应用级开关。
|
|||
//
|
|||
// 存 console 共享库(postgres),与 svc_config 同库,便于 join 服务名/解析下发。
|
|||
//
|
|||
// ⚠️ 回调地址契约(下发时必须遵守):录音文件识别服务的 callback_path 字段存的是**相对路径**
|
|||
// (如 /api/home/echomeet_backcall),不是完整 URL——同一个服务会被所有应用共用,而各应用/部署
|
|||
// 的对外地址不同,写死完整 URL 只对一个应用有效。
|
|||
// 完整回调 = 该部署的对外地址(AppRegistry.BaseUrl) + callback_path,**由 console 在下发时拼接**
|
|||
// (console 才有注册表里的 base_url;业务服务未必知道自己的对外地址,尤其多区域部署)。
|
|||
// 下发解析顺序:(app,region) → (app,0) → ('',region) → ('',0) 全局默认兜底,越具体越优先。
|
|||
|
|||
// 编排类别(会议记录三段)。
|
|||
const ( |
|||
echoKindASR int32 = 1 // 识别(录音文件转写)
|
|||
echoKindMT int32 = 2 // 翻译
|
|||
echoKindLLM int32 = 3 // 总结(大模型)
|
|||
) |
|||
|
|||
// echoKindSvcCats 各编排类别对应的服务池类别(前端选服务时按此过滤,后端校验 svc_id 归属用)。
|
|||
var echoKindSvcCats = map[int32][]int32{ |
|||
echoKindASR: {svcCatASRFile}, |
|||
echoKindMT: {svcCatMT}, |
|||
echoKindLLM: {svcCatLLM, svcCatLLMVision}, |
|||
} |
|||
|
|||
// EchoOrchestration 一行 = 某作用域(app_name,region) 下某类别选中的一个服务引用 + 排序/标记。
|
|||
// 唯一:(app_name, region, kind, svc_id)。svc_id 引用 ThirdSvcConfig.Id(同作用域解析凭据)。
|
|||
type EchoOrchestration struct { |
|||
RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"` |
|||
AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_echo_orch,priority:1" json:"app_name"` // ''=全局默认兜底
|
|||
Region int32 `gorm:"column:region;uniqueIndex:uidx_echo_orch,priority:2" json:"region"` // 0=全区域兜底,>0=特定区域
|
|||
Kind int32 `gorm:"column:kind;uniqueIndex:uidx_echo_orch,priority:3" json:"kind"` // 1识别/2翻译/3总结
|
|||
SvcId string `gorm:"column:svc_id;size:64;uniqueIndex:uidx_echo_orch,priority:4" json:"svc_id"` // 引用 svc_config 的服务 id
|
|||
Priority int32 `gorm:"column:priority" json:"priority"` // 同类内升序,小=优先
|
|||
IsDefault bool `gorm:"column:is_default" json:"is_default"` // 总结用:默认模型,同作用域内至多一条
|
|||
ClientVisible bool `gorm:"column:client_visible" json:"client_visible"` // 总结用:是否允许客户端选择
|
|||
CostHint float64 `gorm:"column:cost_hint" json:"cost_hint"` // 参考单价,仅排序/展示;识别元/时、翻译元/百万字符、总结元/百万token
|
|||
Enabled bool `gorm:"column:enabled" json:"enabled"` |
|||
Createtime int64 `gorm:"column:createtime" json:"createtime"` |
|||
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` |
|||
} |
|||
|
|||
// EchoOrchSetting 会议记录编排的作用域级开关。每 (app_name, region) 一行。
|
|||
type EchoOrchSetting struct { |
|||
RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"` |
|||
AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_echo_orch_set,priority:1" json:"app_name"` |
|||
Region int32 `gorm:"column:region;uniqueIndex:uidx_echo_orch_set,priority:2" json:"region"` |
|||
// LLMClientChoice 是否允许客户端指定总结模型。false=强制默认模型;true=客户端可在 ClientVisible 且 Enabled 里选。
|
|||
LLMClientChoice bool `gorm:"column:llm_client_choice" json:"llm_client_choice"` |
|||
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` |
|||
} |
|||
Loading…
Reference in new issue