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.
309 lines
13 KiB
309 lines
13 KiB
<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>
|
|
|