You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1128 lines
46 KiB

<template>
<div>
<!-- 列表视图:应用卡片(应用 = 卡片,部署 = 卡片内表格),两层清晰区分 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">应用管理</h2>
<p class="text-sm text-slate-400 mt-1">
<span class="chip-app">应用</span> 是一个产品(如 Voitrans);其下挂多条 <span class="chip-dep">部署</span>(各区域一份)。
卡片 = 应用;卡片内表格 = 该应用的区域部署。
</p>
</div>
<button class="btn btn-primary btn-sm text-white" @click="showProductForm()">+ 添加应用</button>
</div>
<!-- 加载 / 空态 -->
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<div v-else-if="!groups.length" class="bg-white rounded-xl border border-slate-100 py-14 text-center text-slate-400">暂无应用,先「添加应用」</div>
<!-- 应用卡片列表 -->
<div v-else class="space-y-4">
<div v-for="g in groups" :key="g.key" class="app-card" :class="{ orphan: !g.product }">
<!-- 应用级 header -->
<div class="app-head" @click="toggle(g.key)">
<button class="chev">{{ isExpanded(g.key) ? '▾' : '▸' }}</button>
<div class="app-logo" :class="{ 'orphan-logo': !g.product }">{{ g.product ? (g.product.name || '?').charAt(0).toUpperCase() : '?' }}</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="chip-app">应用</span>
<span class="app-name">{{ g.product ? g.product.name : '未分组部署' }}</span>
<span v-if="g.product" class="badge badge-xs" :class="g.product.enabled ? 'badge-success' : 'badge-ghost'">{{ g.product.enabled ? '启用' : '停用' }}</span>
<span v-if="g.product && g.product.image_name" class="chip-mono">🐳 {{ g.product.image_name }}</span>
<span class="chip-count">{{ g.deployments.length }} 个部署</span>
</div>
<p class="text-xs text-slate-400 mt-0.5 truncate">
{{ g.product ? (g.product.remark || '—') : '未填所属应用名、或应用已删除的历史部署;编辑部署可归位到某个应用' }}
</p>
</div>
<div v-if="g.product" class="flex gap-1 shrink-0" @click.stop>
<button class="btn btn-primary btn-xs text-white" @click="showDeployForm(g.product.name)">+ 部署</button>
<button class="btn btn-ghost btn-xs" @click="showProductForm(g.product)">编辑应用</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delProduct(g.product)">删除</button>
</div>
</div>
<!-- 部署列表(该应用下的各区域部署) -->
<div v-show="isExpanded(g.key)" class="app-body">
<div v-if="!g.deployments.length" class="text-center text-slate-300 py-5 text-xs">
该应用暂无部署<template v-if="g.product">,点右上「+ 部署」添加</template>
</div>
<div v-else class="overflow-x-auto">
<table class="table table-sm text-sm">
<thead>
<tr class="text-slate-400">
<th>部署</th><th>区域</th><th>环境</th><th>状态</th><th>当前版本</th><th>业务库</th><th>启用</th><th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="a in g.deployments" :key="a.id" class="dep-row hover">
<td>
<span class="chip-dep">部署</span>
<span class="font-medium text-slate-700 ml-1">{{ a.name || ('部署#' + a.id) }}</span>
<span class="text-xs text-slate-400 ml-1 font-mono">#{{ a.id }}</span>
</td>
<td>{{ a.region || '—' }}</td>
<td>
<select v-model.number="a.env_id" class="select select-bordered select-xs w-36 text-xs" :disabled="envSavingId === a.id" @change="bindEnvRow(a)">
<option :value="0">未绑定</option>
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}</option>
</select>
</td>
<td>
<span v-if="a.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span>
<span v-else class="badge badge-success badge-sm text-white">已接入</span>
</td>
<td>
<span v-if="isPinnedVer(a.image_tag)" class="chip-mono text-emerald-700">{{ a.image_tag }}</span>
<span v-else class="text-amber-600 text-xs font-mono" title="未固定版本,升级时建议选明确版本号(如 0.1.1)">{{ a.image_tag || 'latest' }} ⚠</span>
</td>
<td class="font-mono text-xs">{{ dbName(a.service_dsn) }}</td>
<td>
<span v-if="a.enabled" class="text-green-600">是</span>
<span v-else class="text-slate-400">否</span>
</td>
<td class="text-right">
<div class="flex gap-1 justify-end">
<template v-if="a.status !== 'pending'">
<button class="btn btn-ghost btn-xs text-indigo-600" @click="openAppConfig(a)">服务配置</button>
<button v-if="a.base_url" class="btn btn-ghost btn-xs text-teal-600" @click="openSwagger(a)" title="打开该部署的 Swagger API 文档(可在线 Try it out 直接调用该应用接口)">API测试</button>
<button v-else class="btn btn-ghost btn-xs text-slate-300 cursor-not-allowed" disabled title="该部署未配置对外地址(base_url),无法打开 Swagger;到「编辑」里补上对外地址">API测试</button>
<button class="btn btn-ghost btn-xs text-slate-500" @click="showDeployForm(a.app_name, a)">编辑</button>
<button class="btn btn-ghost btn-xs text-amber-600" @click="openUpgrade(a)">升级</button>
<button class="btn btn-ghost btn-xs text-emerald-600" @click="openAnalyze(a.id, a.name || ('部署#' + a.id))">分析</button>
<button class="btn btn-ghost btn-xs text-indigo-500" @click="notifyApp(a.id)">发事件</button>
</template>
<button v-else class="btn btn-ghost btn-xs text-amber-600" @click="showDeployForm(a.app_name, a)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delApp(a.id)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 应用(产品)表单 -->
<div v-show="view === 'productForm'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回</button>
<h2 class="text-lg font-semibold text-slate-800">{{ pIsEditing ? '编辑应用' : '添加应用' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-xl">
<form @submit.prevent="saveProduct">
<div class="form-control">
<label class="label"><span class="label-text font-medium">应用名 *</span></label>
<input v-model="pForm.name" type="text" class="input input-bordered w-full" required placeholder="如 Voitrans" />
<span class="text-xs text-slate-400 mt-1">账号作用域按应用名绑定,尽量与统计/账号里一致。</span>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">默认镜像名</span></label>
<input v-model="pForm.image_name" type="text" class="input input-bordered w-full" placeholder="voitrans-a11(部署时用,可留空)" />
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="pForm.remark" type="text" class="input input-bordered w-full" />
</div>
<div class="flex items-center gap-2 mt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">启用</span>
</label>
</div>
<div class="flex gap-3 mt-6">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white">保存</button>
</div>
</form>
</div>
</div>
<!-- 部署表单:现有 / 部署目标环境 双模式 -->
<div v-show="view === 'deployForm'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回</button>
<h2 class="text-lg font-semibold text-slate-800">{{ dIsEditing ? '编辑部署' : '添加部署' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
<form @submit.prevent="saveDeploy">
<!-- 模式切换(仅新增部署时可选;编辑/升级保持原状态)-->
<div v-if="!dIsEditing" class="tabs tabs-boxed bg-slate-100 w-fit mb-5">
<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-3'">
<template v-if="dIsEditing">编辑该区域部署的配置(镜像 Tag / 部署目录 / 环境节点 等);改好后回列表点「升级」执行远程部署(拉新镜像并重启)。</template>
<template v-else-if="dMode === 'existing'">把「已经部署好的应用区域」加进来:填地址与业务库 DSN,测试通过后保存。</template>
<template v-else>选一个「部署目标环境」,先建关联并标记<b>待部署</b>;保存后回列表点「升级」执行远程部署。</template>
</p>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">所属应用</span></label>
<input :value="dForm.app_name" type="text" class="input input-bordered w-full bg-slate-100" readonly />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">部署名称 *</span></label>
<input v-model="dForm.name" type="text" class="input input-bordered w-full" required placeholder="如 Voitrans(国内)" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">区域</span></label>
<select v-model="dForm.region" class="select select-bordered w-full">
<option value="">未指定</option>
<option v-for="r in availableRegions" :key="r" :value="r">{{ r }}</option>
</select>
<span class="text-xs text-slate-400 mt-1">已部署的区域不再出现(一个应用每区域一份部署)。</span>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">部署环境{{ dMode === 'deploy' ? ' *' : '' }}</span></label>
<select v-model.number="dForm.env_id" class="select select-bordered w-full">
<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>
<!-- 部署配置(按应用区分,存到部署记录;升级时改镜像 Tag)。公共项取自所选环境节点。 -->
<div class="grid grid-cols-3 gap-4 mt-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">部署目录</span></label>
<input v-model="dForm.deploy_dir" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="/home/work/voitrans-a11" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">镜像名</span></label>
<input v-model="dForm.image_name" type="text" class="input input-bordered w-full" placeholder="voitrans-a11(默认取应用)" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">镜像 Tag</span></label>
<input v-model="dForm.image_tag" type="text" class="input input-bordered w-full" placeholder="0.1.1 / latest" />
</div>
</div>
<!-- 业务库 DSN:现有模式必填并测试;部署模式可留空、部署后回填 -->
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">业务库 DSN{{ dMode === 'existing' ? ' *' : '' }}</span></label>
<input v-model="dForm.service_dsn" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="root:pwd@tcp(host:3306)/earphone?charset=utf8mb4&parseTime=True&loc=Local" @input="resetTest" />
<span class="text-xs text-slate-400 mt-1">用于读取业务数据(重置设备码时清 userdevice/product_stat)</span>
</div>
<!-- 现有模式:应用地址 + 测试 -->
<template v-if="dMode === 'existing'">
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">应用地址 *</span></label>
<input v-model="dForm.base_url" type="text" class="input input-bordered w-full" placeholder="https://api.xxx.com/" @input="resetTest" />
<span class="text-xs text-slate-400 mt-1">用于通知应用业务服务事件(如配置更新)</span>
</div>
<div v-show="testResult.show" class="mt-4 text-sm">
<div :class="testResult.apiOk ? 'text-green-700' : 'text-red-600'">
<span :class="testResult.apiOk ? 'text-green-600' : 'text-red-500'">{{ testResult.apiOk ? '✓' : '✗' }}</span>
应用地址:{{ testResult.apiMsg }}
</div>
<div :class="testResult.dbOk ? 'text-green-700' : 'text-red-600'">
<span :class="testResult.dbOk ? 'text-green-600' : 'text-red-500'">{{ testResult.dbOk ? '✓' : '✗' }}</span>
业务库:{{ testResult.dbMsg }}
</div>
</div>
</template>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="dForm.remark" type="text" class="input input-bordered w-full" />
</div>
<div class="flex items-center gap-2 mt-4">
<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>
<div class="flex gap-3 mt-6">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button v-if="dMode === 'existing'" type="button" class="btn btn-outline" :disabled="testing" @click="testConn">{{ testing ? '测试中...' : '测试连接' }}</button>
<button type="submit" class="btn btn-primary text-white" :disabled="!dIsEditing && dMode === 'existing' && !testPassed">保存</button>
</div>
<p v-if="!dIsEditing && dMode === 'existing'" class="text-xs text-slate-400 mt-2">需先「测试连接」通过(应用地址可达 + 业务库可连)才能保存。</p>
</form>
</div>
</div>
<!-- 历史数据分析弹窗 -->
<dialog ref="analyzeModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">分析历史数据</h3>
<p class="text-sm text-slate-500 mt-1">{{ az.appLabel }}</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-show="az.resultShow" class="text-sm mt-3" :class="az.resultClass">
<!-- 进度态 -->
<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" @click="closeAnalyze">关闭</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>close</button></form>
</dialog>
<!-- 升级(远程部署)弹窗:确认目标 + 可改 tag,确认后 SSH 执行并实时回显日志 -->
<dialog ref="upgradeModal" 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>
<div class="grid grid-cols-1 gap-2 mt-4 text-xs">
<div class="flex"><span class="w-20 text-slate-400 shrink-0">环境节点</span><span :class="up.envName ? 'font-medium text-slate-700' : 'font-medium text-red-500'">{{ up.envName || '未绑定 —— 请先在「编辑」里选择环境节点' }}</span></div>
<div class="flex"><span class="w-20 text-slate-400 shrink-0">部署目录</span><span :class="up.deployDir ? 'font-mono text-slate-700' : 'font-medium text-red-500'">{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}</span></div>
</div>
<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="loadUpgradeTags">刷新</button>
</div>
<!-- 手动输入兜底(仓库拉取失败或想指定任意 tag 时用) -->
<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="up.tag === 'latest' || up.tag === ''" class="text-xs text-amber-600 mt-1">⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。</p>
</div>
<!-- 日志流 -->
<div v-show="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="closeUpgrade">关闭</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>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
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
env_id: number
base_url: string
service_dsn: string
deploy_dir: string
image_name: string
image_tag: string
remark: string
enabled: boolean
}
interface EnvOption {
id: number
name: string
code: string
}
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 { consoleApi } = useApi()
const { success, error } = useToast()
const auth = useAuthStore()
const router = useRouter()
const view = ref<'list' | 'productForm' | 'deployForm'>('list')
const products = ref<Product[]>([])
const apps = ref<Deployment[]>([])
const envs = ref<EnvOption[]>([])
const loading = ref(false)
const envSavingId = ref(0)
const expanded = reactive<Record<string, boolean>>({})
const ORPHAN_KEY = '__orphan__'
const regionOptions = ['中国', '美国', '东亚', '东南亚', '南亚', '中东', '欧洲', '俄罗斯', '非洲', '南美', '北美', '大洋洲', '巴西', '印度']
// ---- 分组:按 app_name(应用名)把部署归到应用名下,未匹配的进「未分组」 ----
const groups = computed(() => {
const byName = new Map<string, Deployment[]>()
for (const d of apps.value) {
const name = d.app_name || ''
if (!byName.has(name)) byName.set(name, [])
byName.get(name)!.push(d)
}
const known = new Set(products.value.map((p) => p.name))
const res = products.value.map((p) => ({ key: p.name, product: p as Product | null, deployments: byName.get(p.name) || [] }))
const orphan: Deployment[] = []
for (const [name, ds] of byName) {
if (name === '' || !known.has(name)) orphan.push(...ds)
}
if (orphan.length) res.push({ key: ORPHAN_KEY, product: null, deployments: orphan })
return res
})
function isExpanded(key: string) {
return expanded[key] !== false // 默认展开:直接呈现「应用 → 部署」层级,点卡片头可折叠
}
function toggle(key: string) {
expanded[key] = expanded[key] === false ? true : false
}
function envName(id?: number) {
if (!id) return '-'
const e = envs.value.find((x) => x.id === id)
return e ? e.name : '#' + id
}
function dbName(dsn?: string) {
const m = (dsn || '').match(/\/([^/?]+)\?/)
return m ? m[1] : '-'
}
// 是否已固定到明确版本(非空、非 latest)→ 列表用绿色 chip 显示;否则标黄提示。
function isPinnedVer(tag?: string) {
const t = (tag || '').trim()
return !!t && t !== 'latest'
}
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', {}),
])
products.value = ps ?? []
apps.value = ds ?? []
envs.value = es ?? []
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
} finally {
loading.value = false
}
}
function showList() {
view.value = 'list'
loadAll()
}
// ---- 应用(产品)表单 ----
const pIsEditing = ref(false)
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true })
function showProductForm(p?: Product) {
if (p) {
pIsEditing.value = true
pForm.id = p.id
pForm.name = p.name || ''
pForm.image_name = p.image_name || ''
pForm.remark = p.remark || ''
pForm.enabled = !!p.enabled
} else {
pIsEditing.value = false
pForm.id = 0
pForm.name = ''
pForm.image_name = ''
pForm.remark = ''
pForm.enabled = true
}
view.value = 'productForm'
}
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 (pIsEditing.value && pForm.id) {
data.id = Number(pForm.id)
path = 'products/update'
}
try {
await consoleApi(path, data)
success('保存成功')
showList()
} 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 ?? '失败'))
}
// ---- 部署表单(现有 / 部署目标环境)----
const dIsEditing = ref(false)
const dMode = ref<'existing' | 'deploy'>('existing')
const dForm = reactive({ id: 0, app_name: '', name: '', region: '', env_id: 0, base_url: '', service_dsn: '', deploy_dir: '', image_name: '', image_tag: '', remark: '', enabled: true })
// productImage 取某应用的默认镜像名(新增部署时预填)。
function productImage(name?: string) {
if (!name) return ''
const p = products.value.find((x) => x.name === name)
return p?.image_name || ''
}
// availableRegions 该应用还没部署过的区域(已部署的区域不可再部署;编辑时保留当前区域)。
const availableRegions = computed(() => {
const used = new Set(
apps.value
.filter((d) => d.app_name === dForm.app_name && d.id !== dForm.id)
.map((d) => d.region)
.filter(Boolean),
)
return regionOptions.filter((r) => !used.has(r))
})
const testPassed = ref(false)
const testing = ref(false)
const testResult = reactive({ show: false, apiOk: false, apiMsg: '', dbOk: false, dbMsg: '' })
function resetTest() {
testPassed.value = false
testResult.show = false
testResult.apiMsg = ''
testResult.dbMsg = ''
}
function setMode(m: 'existing' | 'deploy') {
dMode.value = m
resetTest()
}
function showDeployForm(appName?: string, a?: Deployment) {
if (a) {
dIsEditing.value = true
dMode.value = a.status === 'pending' ? 'deploy' : 'existing'
dForm.id = a.id
dForm.app_name = a.app_name || appName || ''
dForm.name = a.name || ''
dForm.region = a.region || ''
dForm.env_id = a.env_id || 0
dForm.base_url = a.base_url || ''
dForm.service_dsn = a.service_dsn || ''
dForm.deploy_dir = a.deploy_dir || ''
dForm.image_name = a.image_name || ''
dForm.image_tag = a.image_tag || ''
dForm.remark = a.remark || ''
dForm.enabled = !!a.enabled
} else {
dIsEditing.value = false
dMode.value = 'existing'
dForm.id = 0
dForm.app_name = appName || ''
dForm.name = ''
dForm.region = ''
dForm.env_id = 0
dForm.base_url = ''
dForm.service_dsn = ''
dForm.deploy_dir = ''
dForm.image_name = productImage(appName)
dForm.image_tag = ''
dForm.remark = ''
dForm.enabled = true
}
resetTest()
view.value = 'deployForm'
}
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.app_name) {
error('请选择所属应用')
return
}
if (!dForm.name.trim()) {
error('请填部署名称')
return
}
if (dMode.value === 'existing' && !testPassed.value) {
error('请先「测试连接」通过再保存')
return
}
if (dMode.value === 'deploy' && !dForm.env_id) {
error('请选择部署目标环境')
return
}
const data: Record<string, unknown> = {
app_name: dForm.app_name,
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('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '失败')
}
}
function openAppConfig(a: Deployment) {
router.push({ path: '/appconfig', query: { id: a.id, name: a.name || ('部署#' + a.id), region: a.region || '', app_name: a.app_name || '' } })
}
// bindEnvRow 内联切换某部署的部署环境(含未绑定的历史数据);失败则回滚并重拉。
async function bindEnvRow(a: Deployment) {
envSavingId.value = a.id
try {
await consoleApi('apps/bindenv', { id: a.id, env_id: Number(a.env_id) || 0 })
success('部署环境已更新')
} catch (e: any) {
error(e?.message ?? '切换环境失败')
loadAll() // 回滚为服务器状态
} finally {
envSavingId.value = 0
}
}
function delApp(id: number) {
if (!window.confirm(`删除部署 #${id} ?(不影响应用自身的库)`)) return
consoleApi('apps/del', { id })
.then(() => { success('已删除'); loadAll() })
.catch((e: any) => error(e?.message ?? '失败'))
}
// ---- 历史数据分析(沿用原逻辑)----
const analyzeModal = ref<HTMLDialogElement | null>(null)
const az = reactive({
appId: 0,
appLabel: '',
start: '',
end: '',
resultShow: false,
resultClass: '',
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 azYmd(s: string) {
return parseInt((s || '').replace(/-/g, '')) || 0
}
function openAnalyze(id: number, name: string) {
az.appId = id
az.appLabel = '部署:' + name + ' (#' + id + ')'
az.start = ''
az.end = ''
az.resultShow = false
az.mode = ''
az.report = null
az.errMsg = ''
az.steps = []
az.curIndex = 0
az.curTotal = 0
az.curName = ''
az.pct = 0
az.running = false
az.btnText = '开始分析'
analyzeModal.value?.showModal()
}
function closeAnalyze() {
analyzeModal.value?.close()
}
function azUpdateProgress(ev: { index: number; total: number; name: string }) {
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
}
async function runAnalyze() {
const name = az.appLabel
const s = az.start || '最早'
const e = az.end || '至今'
if (!window.confirm(`将先删除【${name}】在 ${s} ~ ${e} 区间的历史统计数据,然后重新分析写入。\n\n确认继续?`)) return
az.running = true
az.btnText = '分析中...'
az.resultShow = true
az.resultClass = ''
az.mode = 'progress'
az.steps = []
az.curIndex = 0
az.curTotal = 0
az.curName = ''
az.pct = 0
az.report = null
az.errMsg = ''
const body = { app_id: az.appId, start_day: azYmd(az.start), end_day: azYmd(az.end) }
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') { azUpdateProgress(ev) }
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(body),
})
const reader = resp.body!.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
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 (let i = 0; i < lines.length; i++) handleLine(lines[i] as string)
}
handleLine(buf)
} catch (ex: any) {
errMsg = '网络异常: ' + ex.message
}
if (needLogout) { auth.logout(); router.push('/'); return }
az.running = false
az.btnText = '重新分析'
if (errMsg) {
az.resultClass = 'text-red-600'
az.mode = 'error'
az.errMsg = errMsg
return
}
if (!doneReport) {
az.resultClass = 'text-red-600'
az.mode = 'error'
az.errMsg = '分析中断,未收到完成事件'
return
}
az.resultClass = ''
az.mode = 'done'
az.report = doneReport
success('分析完成')
loadAll()
}
// 打开该部署的 Swagger API 文档:base_url + /swagger/index.html(新标签页)。
// swagger 已内嵌 gateway 二进制,SwaggerInfo.Host 留空 → UI 用当前页面 origin(应用域名) 作为调用地址,
// 故 Try it out 会直接打到该应用自己的真实网关接口(前提:该域名 /swagger、/api 均反代到 gateway 7100)。
function openSwagger(a: any) {
let base = (a?.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: { subject?: string }) => { success('已发送 → ' + (d && d.subject)) })
.catch((e: any) => error(e?.message ?? '失败'))
}
// ---- 升级(远程部署):SSH 到部署环境执行 docker compose 拉新镜像并重启,NDJSON 流式回显日志 ----
const upgradeModal = 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(a: Deployment) {
up.id = a.id
up.label = '部署:' + (a.name || ('部署#' + a.id)) + ' (#' + a.id + ')' + (a.region ? ' · ' + a.region : '')
up.envName = a.env_id ? envName(a.env_id) : ''
up.deployDir = a.deploy_dir || ''
up.tag = a.image_tag || ''
up.tags = []
up.tagMode = 'list'
up.tagsError = ''
up.log = ''
up.show = false
up.running = false
up.ok = false
up.canRun = !!(a.env_id && (a.deploy_dir || '').trim())
up.btnText = '开始升级'
upgradeModal.value?.showModal()
if (up.canRun) loadUpgradeTags() // 弹窗弹出后异步拉可用版本;缺环境/目录时无从查,跳过
}
// 从镜像仓库拉取该部署镜像的可用 tag,填充升级弹窗下拉。失败则切到手动输入。
async function loadUpgradeTags() {
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'
// 预选:优先当前记录的版本;否则第一个非 latest 的明确版本,引导选版本号而非 latest
if (!up.tag || !up.tags.includes(up.tag)) {
up.tag = (up.tag && up.tags.includes(up.tag)) ? up.tag : (up.tags.find((t) => t !== 'latest') || up.tag || '')
}
} catch (e: any) {
// 失败不清空 tags、不强制切模式:保留「当前记录版本」仍可选,只提示错误,避免看着像数据全没了
up.tagsError = e?.message ?? '拉取失败'
} finally {
up.tagsLoading = false
}
}
function closeUpgrade() {
if (up.running) return
upgradeModal.value?.close()
}
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 (!tag || tag === 'latest') {
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 || '')) }
// ev.type === 'done' 由末尾统一处理
}
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: (up.tag || '').trim() }),
})
const reader = resp.body!.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
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 (let i = 0; i < lines.length; i++) handleLine(lines[i] as string)
}
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('升级完成'); loadAll() }
}
onMounted(loadAll)
</script>
<style scoped>
/* 应用卡片:应用级(卡片头) 与 部署级(卡片内表格) 视觉分层 */
.app-card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.app-card.orphan {
border-color: #fde68a;
}
.app-head {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 16px;
cursor: pointer;
background: linear-gradient(90deg, #f5f7ff, #ffffff);
border-left: 4px solid #6366f1;
transition: background 0.15s;
}
.app-card.orphan .app-head {
background: linear-gradient(90deg, #fffbeb, #ffffff);
border-left-color: #f59e0b;
}
.app-head:hover {
background: linear-gradient(90deg, #eef2ff, #ffffff);
}
.chev {
color: #94a3b8;
font-size: 12px;
width: 16px;
flex-shrink: 0;
}
.app-logo {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
color: #fff;
font-weight: 700;
font-size: 17px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.app-logo.orphan-logo {
background: #f59e0b;
}
.app-name {
font-size: 15px;
font-weight: 700;
color: #1e293b;
}
.chip-app {
display: inline-flex;
font-size: 10px;
font-weight: 700;
padding: 1px 8px;
border-radius: 6px;
background: #eef2ff;
color: #4338ca;
}
.chip-dep {
display: inline-flex;
font-size: 9px;
font-weight: 700;
padding: 1px 6px;
border-radius: 5px;
background: #ecfeff;
color: #0e7490;
}
.chip-mono {
display: inline-flex;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
font-family: 'SF Mono', Menlo, monospace;
}
.chip-count {
display: inline-flex;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
}
.app-body {
padding: 4px 12px 10px 12px;
border-top: 1px solid #f1f5f9;
background: #fcfcfd;
}
.dep-row td {
vertical-align: middle;
}
</style>