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

<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>