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.
 
 
 
 
 
 

707 lines
34 KiB

<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 &amp; 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 }} · {{ regionLabel(d.region) }} · 业务库 {{ dbName(d.service_dsn) }}</div>
</div>
<div>
<div class="field-l">环境节点</div>
<div class="text-xs truncate" :class="d.env_id ? 'text-slate-600' : 'text-red-500'" :title="envName(d.env_id) || '未绑定'">
{{ envName(d.env_id) || '未绑定' }}
</div>
</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" :value="r.value">{{ r.label }}</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>区域:{{ regionLabel(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'
import { REGION_OPTIONS, normalizeRegion, regionLabel } from '~/utils/regions'
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()
// 区域只有「国内(cn) / 海外(hw)」两档——一个应用就两套环境。
// 历史部署里存的是中文名/细分码,编辑时 normalizeRegion 归一后再回填下拉。
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
}
// 环境节点改为只读展示:切换须走「编辑」弹窗,避免列表行误操作改动部署绑定。
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) => normalizeRegion(d.region)).filter(Boolean),
)
return REGION_OPTIONS.filter((r) => !used.has(r.value) || r.value === 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, {
// 历史行的 region 可能是中文名/细分码,归一后才能对上下拉的 cn/hw
id: d.id, name: d.name || '', region: normalizeRegion(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 ? ' · ' + regionLabel(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>