Files
homelab/stacks/portal/index.html
T

697 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>