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