feat(portal): replace Sun-Panel with custom Apple Dark Glass HomeLab OS Portal + live telemetry API

Browse Source
This commit is contained in:
Yin Kou committed 2026-11-06 23:16:18 +08:00
1 parent 5dafc94d5e
commit a072a62141
3 files changed
+1025 -1

No files matched your search

+696
View File
@@ -0,0 +1,696 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>HomeLab OS · M6 All-in-One 中控台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', 'SF Pro Display', 'Inter', 'PingFang SC', 'sans-serif'],
mono: ['SF Mono', 'JetBrains Mono', 'Menlo', 'monospace'],
},
colors: {
border: 'rgba(255, 255, 255, 0.08)',
surface: 'rgba(24, 24, 27, 0.65)',
surfaceHover: 'rgba(39, 39, 42, 0.75)',
}
}
}
}
</script>
<style>
body {
background-color: #09090b;
background-image:
radial-gradient(at 15% 0%, rgba(56, 189, 248, 0.10) 0px, transparent 45%),
radial-gradient(at 85% 5%, rgba(139, 92, 246, 0.10) 0px, transparent 45%),
radial-gradient(at 50% 100%, rgba(16, 185, 129, 0.06) 0px, transparent 50%);
background-attachment: fixed;
color: #f4f4f5;
-webkit-font-smoothing: antialiased;
}
.glass-card {
background: rgba(24, 24, 27, 0.62);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 0 0 rgba(255, 255, 255, 0.06);
}
.glass-card-interactive {
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.glass-card-interactive:hover {
background: rgba(39, 39, 42, 0.72);
border-color: rgba(255, 255, 255, 0.16);
transform: translateY(-2px);
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.7), inset 0 1px 0 0 rgba(255, 255, 255, 0.12);
}
.progress-bar {
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
[v-cloak] { display: none; }
</style>
</head>
<body class="min-h-screen selection:bg-sky-500/30 selection:text-sky-200">
<div id="app" v-cloak class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 space-y-6">
<!-- Top Toast Notification -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform -translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform -translate-y-4 opacity-0"
>
<div v-if="toast" class="fixed top-5 right-5 z-50 glass-card px-4 py-3 rounded-xl border border-emerald-500/30 flex items-center gap-3 shadow-2xl">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-ping"></span>
<span class="text-sm font-medium text-zinc-100">{{ toast }}</span>
</div>
</transition>
<!-- Header / Navbar -->
<header class="glass-card rounded-2xl p-4 sm:px-6 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3.5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-sky-500 via-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20 border border-white/20">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
</svg>
</div>
<div>
<div class="flex items-center gap-2.5">
<h1 class="text-lg sm:text-xl font-semibold tracking-tight text-white">HomeLab OS</h1>
<span class="px-2 py-0.5 text-[11px] font-mono font-medium rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/25 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
4/4 节点在线
</span>
</div>
<p class="text-xs text-zinc-400 mt-0.5 font-mono">
Apple M6 Mac mini (192.168.31.164) ⇄ 极空间 Z4 (20TB) ⇄ SG Azure (104.43.105.243)
</p>
</div>
</div>
</div>
<!-- Right Controls: Mode Switcher & Embedded AGY Drawer Trigger -->
<div class="flex flex-wrap items-center gap-2.5">
<div class="bg-zinc-950/80 p-1 rounded-xl border border-white/10 flex items-center text-xs">
<button
v-for="m in modes"
:key="m.id"
@click="routeMode = m.id"
:class="[
'px-2.5 py-1.5 rounded-lg font-medium transition-all flex items-center gap-1.5',
routeMode === m.id
? 'bg-zinc-800 text-white shadow-sm border border-white/10'
: 'text-zinc-400 hover:text-zinc-200'
]"
>
<span>{{ m.icon }}</span>
<span>{{ m.label }}</span>
</button>
</div>
<button
@click="showAgyDrawer = true"
class="px-3.5 py-2 rounded-xl text-xs font-medium bg-gradient-to-r from-sky-500/20 to-indigo-500/20 hover:from-sky-500/30 hover:to-indigo-500/30 text-sky-300 border border-sky-500/30 transition-all flex items-center gap-2 shadow-sm"
>
<span class="w-2 h-2 rounded-full bg-sky-400 animate-ping"></span>
<span>页内唤起 AGY 终端</span>
</button>
</div>
</header>
<!-- Bento Grid: 3 Real-Time Telemetry Cards -->
<section class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<!-- 1. M6 Mac mini Telemetry -->
<div class="glass-card rounded-2xl p-5 flex flex-col justify-between space-y-4">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-xs font-mono uppercase tracking-wider text-sky-400 font-semibold">Core Compute Server</span>
<span class="px-2 py-0.5 text-[10px] font-mono rounded-md bg-zinc-800/90 text-zinc-300 border border-white/5">
UP {{ telemetry.mini.uptime }}
</span>
</div>
<h2 class="text-base font-semibold text-white mt-1 flex items-center gap-2">
<span>🖥️ Mac mini · {{ telemetry.mini.chip }}</span>
</h2>
</div>
<span class="text-xs font-mono text-zinc-400 bg-zinc-900/90 px-2.5 py-1 rounded-lg border border-white/5">
{{ telemetry.mini.ip }}
</span>
</div>
<!-- Resource Bars -->
<div class="space-y-3 pt-1">
<!-- CPU -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-zinc-400">CPU 负载 (12核 M6 · Load {{ telemetry.mini.load_1m }})</span>
<span class="font-mono text-zinc-200 font-medium">{{ telemetry.mini.cpu_pct }}%</span>
</div>
<div class="w-full h-2 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-gradient-to-r from-sky-500 to-indigo-500 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.mini.cpu_pct) + '%' }"></div>
</div>
</div>
<!-- RAM -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-zinc-400">统一内存 ({{ telemetry.mini.mem_used_gb }} / {{ telemetry.mini.mem_total_gb }} GB)</span>
<span class="font-mono text-zinc-200 font-medium">{{ telemetry.mini.mem_pct }}%</span>
</div>
<div class="w-full h-2 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.mini.mem_pct) + '%' }"></div>
</div>
</div>
<!-- SSD -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-zinc-400">系统闪存 ({{ telemetry.mini.disk_used_gb }} / {{ telemetry.mini.disk_total_gb }} GB)</span>
<span class="font-mono text-zinc-200 font-medium">{{ telemetry.mini.disk_pct }}%</span>
</div>
<div class="w-full h-2 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.mini.disk_pct) + '%' }"></div>
</div>
</div>
</div>
<!-- Footer Pills -->
<div class="pt-2 border-t border-white/5 flex flex-wrap items-center justify-between gap-2 text-[11px] font-mono text-zinc-400">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
<span>AGY tmux: {{ telemetry.mini.agy_tmux ? '常驻运行中' : '待机' }}</span>
</div>
<div>
OrbStack 容器: <span class="text-zinc-200 font-semibold">{{ telemetry.mini.containers.length }} 运行中</span>
</div>
</div>
</div>
<!-- 2. ZSpace Z4 20TB Storage Telemetry -->
<div class="glass-card rounded-2xl p-5 flex flex-col justify-between space-y-4">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-xs font-mono uppercase tracking-wider text-emerald-400 font-semibold">Storage Pool & Backup</span>
<span
:class="[
'px-2 py-0.5 text-[10px] font-mono rounded-md border',
telemetry.nas.mounted
? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/25'
: 'bg-amber-500/15 text-amber-300 border-amber-500/25'
]"
>
{{ telemetry.nas.mounted ? 'SMB 已挂载 ~/nas' : '未挂载' }}
</span>
</div>
<h2 class="text-base font-semibold text-white mt-1">
💾 极空间 Z4 · 20TB 存储中心
</h2>
</div>
<span class="text-xs font-mono text-zinc-400 bg-zinc-900/90 px-2.5 py-1 rounded-lg border border-white/5">
{{ telemetry.nas.ip }}
</span>
</div>
<!-- Big Storage Meter -->
<div class="bg-zinc-950/60 rounded-xl p-3.5 border border-white/5 space-y-2.5">
<div class="flex items-baseline justify-between">
<div>
<span class="text-2xl font-bold tracking-tight text-white font-mono">{{ telemetry.nas.free_tb }} TB</span>
<span class="text-xs text-zinc-400 ml-1.5">剩余可用</span>
</div>
<span class="text-xs font-mono text-zinc-400">
已用 {{ telemetry.nas.used_tb }} TB / 总计 {{ telemetry.nas.total_tb }} TB ({{ telemetry.nas.pct }}%)
</span>
</div>
<div class="w-full h-2.5 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.nas.pct) + '%' }"></div>
</div>
<div class="flex items-center justify-between text-[11px] text-zinc-400 font-mono pt-0.5">
<span>挂载点: ~/nas/{共享文件,个人空间}</span>
<span>Time Machine: 手动按需</span>
</div>
</div>
<!-- Backup Action Row -->
<div class="pt-2 border-t border-white/5 flex items-center justify-between gap-2">
<div class="text-[11px] text-zinc-400 truncate">
最近自动备份: <span class="font-mono text-zinc-200">{{ telemetry.nas.latest_backup }}</span>
</div>
<button
@click="triggerAction('backup_nas', '正在打包备份至极空间 Z4...')"
:disabled="actionLoading"
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-100 border border-white/10 transition-all shrink-0 flex items-center gap-1.5 disabled:opacity-50"
>
<span>⚡ 立即备份</span>
</button>
</div>
</div>
<!-- 3. AI Routing & SG Cloud Edge Telemetry -->
<div class="glass-card rounded-2xl p-5 flex flex-col justify-between space-y-4">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-xs font-mono uppercase tracking-wider text-purple-400 font-semibold">AI Egress & Cloud Edge</span>
<span class="px-2 py-0.5 text-[10px] font-mono rounded-md bg-purple-500/15 text-purple-300 border border-purple-500/25">
SG 出口死锁保护
</span>
</div>
<h2 class="text-base font-semibold text-white mt-1">
🛡️ 小米主路由 ⇄ 新加坡 Azure
</h2>
</div>
<span class="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
{{ telemetry.network.sg_delay_ms > 0 ? telemetry.network.sg_delay_ms + ' ms' : '专线直连' }}
</span>
</div>
<!-- Network Rows -->
<div class="space-y-2 text-xs font-mono">
<div class="flex items-center justify-between bg-zinc-950/60 px-3 py-2 rounded-xl border border-white/5">
<span class="text-zinc-400">AGY / AI 分流策略</span>
<span class="text-purple-300 font-medium">{{ telemetry.network.ai_policy }}</span>
</div>
<div class="flex items-center justify-between bg-zinc-950/60 px-3 py-2 rounded-xl border border-white/5">
<span class="text-zinc-400">AI 锁定公网出口 (SG)</span>
<span class="text-zinc-100">{{ telemetry.network.sg_egress_ip }} ({{ telemetry.network.sg_node }})</span>
</div>
<div class="flex items-center justify-between bg-zinc-950/60 px-3 py-2 rounded-xl border border-white/5">
<span class="text-zinc-400">家庭联通公网 IPv4</span>
<span class="text-sky-300">{{ telemetry.network.home_wan_ip }} (*.home.961504.xyz)</span>
</div>
</div>
<!-- Footer -->
<div class="pt-2 border-t border-white/5 flex items-center justify-between text-[11px] text-zinc-400">
<span class="font-mono">Tailscale P2P: 100.108.205.40 ⇄ 100.115.204.51</span>
<button
@click="triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')"
:disabled="actionLoading"
class="px-2.5 py-1 rounded-lg text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-white/10 transition-all"
>
刷新 DDNS
</button>
</div>
</div>
</section>
<!-- Main Services Launchpad Grid -->
<section class="space-y-3">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2.5">
<h2 class="text-sm font-semibold uppercase tracking-wider text-zinc-400">全屋核心服务矩阵 (Service Launchpad)</h2>
<span class="text-xs text-zinc-500 font-mono">
当前点击跳转模式: {{ activeModeLabel }}
</span>
</div>
<span class="text-xs text-zinc-500 hidden sm:inline">所有受保护服务统一账号: <code class="text-zinc-300 font-mono">ixvii0iv</code></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
v-for="svc in services"
:key="svc.id"
:href="resolveUrl(svc)"
target="_blank"
rel="noopener noreferrer"
class="glass-card glass-card-interactive rounded-2xl p-5 flex flex-col justify-between group cursor-pointer relative overflow-hidden"
>
<!-- Subtle Top Gradient Glow -->
<div :class="['absolute -right-10 -top-10 w-28 h-28 rounded-full blur-2xl opacity-20 group-hover:opacity-40 transition-opacity', svc.glow]"></div>
<div class="space-y-3 relative z-10">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div :class="['w-11 h-11 rounded-xl flex items-center justify-center text-xl border border-white/15 shadow-inner', svc.iconBg]">
{{ svc.emoji }}
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors">
{{ svc.title }}
</h3>
<span
:class="[
'w-2 h-2 rounded-full',
telemetry.services[svc.healthKey] !== false ? 'bg-emerald-400 shadow-[0_0_8px_#34d399]' : 'bg-rose-500'
]"
></span>
</div>
<span class="text-[11px] font-mono text-zinc-400">{{ svc.badge }}</span>
</div>
</div>
<span class="text-zinc-500 group-hover:text-zinc-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17L17 7M17 7H7M17 7V17"/>
</svg>
</span>
</div>
<p class="text-xs text-zinc-400 leading-relaxed">
{{ svc.desc }}
</p>
</div>
<div class="pt-3.5 mt-4 border-t border-white/5 flex items-center justify-between text-[11px] font-mono relative z-10">
<span class="text-zinc-400 truncate max-w-[72%]">{{ resolveUrl(svc) }}</span>
<div class="flex items-center gap-1.5">
<button
v-if="svc.id === 'agy'"
@click.prevent="showAgyDrawer = true"
class="px-2 py-0.5 rounded bg-sky-500/20 text-sky-300 border border-sky-500/30 hover:bg-sky-500/30"
>
页内打开
</button>
<span v-else class="px-2 py-0.5 rounded bg-zinc-800/80 text-zinc-300 border border-white/5">
{{ resolveTag(svc) }}
</span>
</div>
</div>
</a>
</div>
</section>
<!-- SSH & Terminal Quick Commands Bar -->
<section class="glass-card rounded-2xl p-4 sm:px-6 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span class="text-zinc-200 font-semibold">⌨️ 终端快捷指令 (点击即复制):</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
v-for="cmd in sshCommands"
:key="cmd.cmd"
@click="copyText(cmd.cmd, cmd.label)"
class="px-3 py-1.5 rounded-xl bg-zinc-950/80 hover:bg-zinc-800/90 border border-white/10 text-xs font-mono text-zinc-300 hover:text-white transition-all flex items-center gap-2"
>
<span class="text-zinc-500">{{ cmd.label }}:</span>
<span class="text-sky-300">{{ cmd.cmd }}</span>
</button>
</div>
</section>
<!-- Slide-over AGY Cloud Terminal Drawer / Modal -->
<div v-if="showAgyDrawer" class="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-2 sm:p-6">
<div class="w-full h-full max-w-6xl bg-zinc-950 rounded-2xl border border-white/15 shadow-2xl flex flex-col overflow-hidden">
<div class="px-4 py-3 bg-zinc-900/90 border-b border-white/10 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<button @click="showAgyDrawer = false" class="w-3 h-3 rounded-full bg-rose-500 hover:opacity-80"></button>
<span class="w-3 h-3 rounded-full bg-amber-500"></span>
<a :href="agyIframeUrl" target="_blank" class="w-3 h-3 rounded-full bg-emerald-500 hover:opacity-80"></a>
</div>
<span class="text-xs font-mono text-zinc-200 font-medium">
🤖 AGY 云端工作台 (tmux attach -t agy · 关窗不断连)
</span>
</div>
<div class="flex items-center gap-2">
<a
:href="agyIframeUrl"
target="_blank"
class="px-2.5 py-1 rounded-lg text-xs font-mono bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-white/10"
>
↗ 新标签页全屏打开
</a>
<button
@click="showAgyDrawer = false"
class="px-2.5 py-1 rounded-lg text-xs bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/30"
>
关闭窗口 (后台继续运行)
</button>
</div>
</div>
<iframe :src="agyIframeUrl" class="w-full flex-1 bg-black border-0"></iframe>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
createApp({
setup() {
const routeMode = ref('auto');
const showAgyDrawer = ref(false);
const toast = ref('');
const actionLoading = ref(false);
const modes = [
{ id: 'auto', icon: '⚡', label: '智能自动' },
{ id: 'edge443', icon: '🌐', label: '443 免端口' },
{ id: 'home10443', icon: '🏠', label: '10443 家宽' },
{ id: 'lan', icon: '🔌', label: '局域网 LAN' },
];
const telemetry = ref({
mini: {
chip: 'Apple M6 (12核)',
ip: '192.168.31.164',
cpu_pct: 5.2,
load_1m: 0.65,
mem_used_gb: 6.4,
mem_total_gb: 24.0,
mem_pct: 26.7,
disk_used_gb: 28.5,
disk_total_gb: 460.4,
disk_pct: 6.2,
uptime: '运行中',
containers: [{ name: 'vaultwarden' }],
agy_tmux: true,
},
nas: {
name: '极空间 Z4',
ip: '192.168.31.185',
online: true,
mounted: true,
total_tb: 20.0,
used_tb: 2.9,
free_tb: 17.1,
pct: 14.5,
latest_backup: '已就绪',
},
network: {
home_wan_ip: '124.90.142.21',
ai_policy: 'Ai平台 → 狮城节点',
sg_node: 'coco-sea1-reality-sony',
sg_egress_ip: '104.43.105.243',
sg_delay_ms: 48,
},
services: {
agy: true,
vaultwarden: true,
nas: true,
clash: true,
router: true,
uptime: true,
}
});
const services = [
{
id: 'agy',
healthKey: 'agy',
emoji: '🤖',
title: 'AGY 云端工作台',
badge: 'ttyd + tmux 24h 常驻终端',
desc: 'Mac mini 上的 Antigravity AI 编程终端,已同步 Google 登录态并锁定新加坡固定出口,关掉网页任务继续在后台跑。',
edgeUrl: 'https://agy.961504.xyz',
homeUrl: 'https://agy.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:7681',
iconBg: 'bg-sky-500/20 text-sky-300',
glow: 'bg-sky-500',
},
{
id: 'zspace',
healthKey: 'nas',
emoji: '💾',
title: '极空间 Z4 私有云',
badge: '20TB RAID 存储池 · 极影视',
desc: '家庭 20TB 核心大容量存储、极影视蓝光家庭影院、手机相册备份中心,已自动 SMB 挂载至 Mac mini ~/nas。',
edgeUrl: 'https://z4.home.961504.xyz:10443',
homeUrl: 'https://z4.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.185:5055',
iconBg: 'bg-emerald-500/20 text-emerald-300',
glow: 'bg-emerald-500',
},
{
id: 'vaultwarden',
healthKey: 'vaultwarden',
emoji: '🔐',
title: 'Vaultwarden 密码保险箱',
badge: 'Bitwarden 官方客户端兼容',
desc: '自托管全平台加密密码库,支持 iOS / Android / Chrome 自动填充,每天凌晨 03:30 自动打包灾备至极空间 Z4。',
edgeUrl: 'https://vw.961504.xyz',
homeUrl: 'https://vw.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:8222',
iconBg: 'bg-indigo-500/20 text-indigo-300',
glow: 'bg-indigo-500',
},
{
id: 'clash',
healthKey: 'clash',
emoji: '🚀',
title: 'Mihomo 路由分流面板',
badge: 'ShellCrash 透明网关控制台',
desc: '小米主路由器实时流量监控、策略组切换与规则明细(Ai平台已死锁狮城节点 104.43.105.243)。',
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
lanUrl: 'http://192.168.31.1:9999/ui/',
iconBg: 'bg-purple-500/20 text-purple-300',
glow: 'bg-purple-500',
},
{
id: 'router',
healthKey: 'router',
emoji: '📡',
title: '小米主路由器后台',
badge: '192.168.31.1 · PPPoE & DNAT',
desc: '管理家庭联通宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口映射及全屋 Wi-Fi 设备状态。',
edgeUrl: 'https://router.home.961504.xyz:10443',
homeUrl: 'https://router.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.1',
iconBg: 'bg-amber-500/20 text-amber-300',
glow: 'bg-amber-500',
},
{
id: 'uptime',
healthKey: 'uptime',
emoji: '📈',
title: 'Uptime Kuma 外部哨兵',
badge: 'SG Azure 104.43.105.243',
desc: '独立部署在新加坡公有云上的 24 小时外部监控哨兵,从家庭外部实时探测家里宽带与各项服务在线率。',
edgeUrl: 'https://status.961504.xyz',
homeUrl: 'https://status.961504.xyz',
lanUrl: 'https://status.961504.xyz',
iconBg: 'bg-teal-500/20 text-teal-300',
glow: 'bg-teal-500',
},
];
const sshCommands = [
{ label: '连 Mac mini', cmd: 'ssh mini' },
{ label: '接管 AGY 会话', cmd: 'ssh mini -t "tmux attach -t agy"' },
{ label: '连新加坡 Azure', cmd: 'ssh azure' },
{ label: '连小米路由器', cmd: 'ssh root@192.168.31.1' },
];
const effectiveMode = computed(() => {
if (routeMode.value !== 'auto') return routeMode.value;
const host = window.location.hostname;
if (host.startsWith('192.168.') || host.startsWith('100.') || host === 'localhost') {
return 'lan';
}
if (host.includes('.home.961504.xyz') || window.location.port === '10443') {
return 'home10443';
}
return 'edge443';
});
const activeModeLabel = computed(() => {
const m = effectiveMode.value;
if (m === 'lan') return '局域网超低延迟直连 (192.168.31.x)';
if (m === 'home10443') return '家宽公网 HTTPS 直连 (*.home.961504.xyz:10443)';
return '443 免端口 HTTPS 直达 (*.961504.xyz)';
});
const resolveUrl = (svc) => {
const m = effectiveMode.value;
if (m === 'lan') return svc.lanUrl;
if (m === 'home10443') return svc.homeUrl;
return svc.edgeUrl;
};
const resolveTag = (svc) => {
const m = effectiveMode.value;
if (m === 'lan') return 'LAN 极速';
if (m === 'home10443') return '10443 直连';
return '443 免端口';
};
const agyIframeUrl = computed(() => {
return resolveUrl(services[0]);
});
const showToast = (msg) => {
toast.value = msg;
setTimeout(() => {
if (toast.value === msg) toast.value = '';
}, 3000);
};
const copyText = async (text, label) => {
try {
await navigator.clipboard.writeText(text);
showToast(`已复制「${label}」指令: ${text}`);
} catch {
showToast(`指令: ${text}`);
}
};
const fetchTelemetry = async () => {
try {
const res = await fetch('/api/telemetry');
if (res.ok) {
telemetry.value = await res.json();
}
} catch (e) {}
};
const triggerAction = async (action, startMsg) => {
actionLoading.value = true;
showToast(startMsg);
try {
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
const data = await res.json();
showToast(data.message || '操作成功');
await fetchTelemetry();
} catch (e) {
showToast('执行失败,请稍后重试');
} finally {
actionLoading.value = false;
}
};
onMounted(() => {
fetchTelemetry();
setInterval(fetchTelemetry, 3000);
});
return {
modes,
routeMode,
effectiveMode,
activeModeLabel,
telemetry,
services,
sshCommands,
showAgyDrawer,
agyIframeUrl,
toast,
actionLoading,
resolveUrl,
resolveTag,
copyText,
triggerAction,
};
}
}).mount('#app');
</script>
</body>
</html>