feat(portal): migrate V3 console to React 19 + Vite + TypeScript + Zustand + Tailwind v4
This commit is contained in:
1 parent
518f1e757e
commit
eae4596aa3
20 files changed
+4162
-3
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HomeLab Console · 家庭云边中控台</title>
|
||||||
|
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2635
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "homelab-console",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"tailwindcss": "^4.0.6",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { usePortalStore } from './store/usePortalStore';
|
||||||
|
import { TopRibbon } from './components/TopRibbon';
|
||||||
|
import { ServiceCard } from './components/ServiceCard';
|
||||||
|
import { SshShelf } from './components/SshShelf';
|
||||||
|
import { CommandPalette } from './components/CommandPalette';
|
||||||
|
import { AgyTerminalModal } from './components/AgyTerminalModal';
|
||||||
|
import type { ServiceGroup } from './types/telemetry';
|
||||||
|
|
||||||
|
export const App: React.FC = () => {
|
||||||
|
const {
|
||||||
|
telemetry,
|
||||||
|
toast,
|
||||||
|
fetchTelemetry,
|
||||||
|
setShowCmdPalette,
|
||||||
|
getEffectiveMode,
|
||||||
|
routeMode,
|
||||||
|
} = usePortalStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTelemetry();
|
||||||
|
const timer = setInterval(fetchTelemetry, 3000);
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCmdPalette(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [fetchTelemetry, setShowCmdPalette]);
|
||||||
|
|
||||||
|
const activeModeLabel = useMemo(() => {
|
||||||
|
const m = getEffectiveMode();
|
||||||
|
if (m === 'lan') return 'LAN 局域网极速直连 (192.168.31.x)';
|
||||||
|
if (m === 'home10443') return '10443 家宽公网 HTTPS 直连';
|
||||||
|
return '443 免端口 HTTPS 直达 (*.961504.xyz)';
|
||||||
|
}, [getEffectiveMode, routeMode]);
|
||||||
|
|
||||||
|
const serviceGroups = useMemo<ServiceGroup[]>(() => {
|
||||||
|
const t = telemetry;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '核心工作台与私有云存储',
|
||||||
|
subtitle: 'Core Workbench & Storage',
|
||||||
|
showRouteHint: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'agy',
|
||||||
|
title: 'AGY 云端工作台',
|
||||||
|
subtitle: 'agy.961504.xyz · ttyd + tmux',
|
||||||
|
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
|
||||||
|
online: t.services.agy,
|
||||||
|
actionType: 'drawer',
|
||||||
|
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
|
||||||
|
edgeUrl: 'https://agy.961504.xyz',
|
||||||
|
homeUrl: 'https://agy.home.961504.xyz:10443',
|
||||||
|
lanUrl: 'http://192.168.31.164:7681',
|
||||||
|
stats: [
|
||||||
|
{ label: 'M6 CPU 负载', value: `${t.mini.cpu_pct}%` },
|
||||||
|
{ label: '已用内存', value: `${t.mini.mem_used_gb} / 24 GB` },
|
||||||
|
{
|
||||||
|
label: 'tmux 会话',
|
||||||
|
value: t.mini.agy_tmux ? '常驻运行中' : '待机',
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zspace',
|
||||||
|
title: '极空间 Z4 私有云',
|
||||||
|
subtitle: '192.168.31.185 · 20TB RAID 存储池',
|
||||||
|
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/synology-drive.png',
|
||||||
|
online: t.services.nas,
|
||||||
|
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',
|
||||||
|
stats: [
|
||||||
|
{ label: '剩余可用', value: `${t.nas.free_tb} TB`, highlight: true },
|
||||||
|
{ label: '已用容量', value: `${t.nas.used_tb} TB (${t.nas.pct}%)` },
|
||||||
|
{ label: 'SMB 挂载', value: t.nas.mounted ? '~/nas 已挂载' : '重连中' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vaultwarden',
|
||||||
|
title: 'Vaultwarden 密码保险箱',
|
||||||
|
subtitle: 'vw.961504.xyz · Bitwarden 兼容',
|
||||||
|
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png',
|
||||||
|
online: t.services.vaultwarden,
|
||||||
|
actionType: 'backup',
|
||||||
|
desc: '自托管全平台加密密码库,支持 iOS / Android / 浏览器插件自动填充,每天凌晨 03:30 自动打包灾备至极空间。',
|
||||||
|
edgeUrl: 'https://vw.961504.xyz',
|
||||||
|
homeUrl: 'https://vw.home.961504.xyz:10443',
|
||||||
|
lanUrl: 'http://192.168.31.164:8222',
|
||||||
|
stats: [
|
||||||
|
{ label: '容器状态', value: 'Healthy', highlight: true },
|
||||||
|
{ label: '最近极空间灾备', value: t.nas.latest_backup },
|
||||||
|
{ label: 'Mac SSD 占用', value: `${t.mini.disk_pct}%` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '网络网关、AI 专线与外部哨兵',
|
||||||
|
subtitle: 'Network Gateway & Cloud Edge',
|
||||||
|
showRouteHint: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'clash',
|
||||||
|
title: 'Mihomo 路由分流面板',
|
||||||
|
subtitle: '192.168.31.1:9999 · ShellCrash',
|
||||||
|
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/clash.png',
|
||||||
|
online: t.services.clash,
|
||||||
|
desc: '小米主路由器透明网关控制台,实时监控全屋设备流量、分流规则与狮城 AI 算力专线锁定状态。',
|
||||||
|
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||||
|
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||||
|
lanUrl: 'http://192.168.31.1:9999/ui/',
|
||||||
|
stats: [
|
||||||
|
{ label: 'AI 锁定策略', value: '狮城节点', highlight: true },
|
||||||
|
{
|
||||||
|
label: '新加坡延迟',
|
||||||
|
value: t.network.sg_delay_ms > 0 ? `${t.network.sg_delay_ms} ms` : '直连',
|
||||||
|
},
|
||||||
|
{ label: '固定出口 IP', value: t.network.sg_egress_ip },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'router',
|
||||||
|
title: '小米主路由器后台',
|
||||||
|
subtitle: '192.168.31.1 · PPPoE 公网网关',
|
||||||
|
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/openwrt.png',
|
||||||
|
online: t.services.router,
|
||||||
|
desc: '管理家庭联通公网宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口转发及全屋内网 Split-DNS。',
|
||||||
|
edgeUrl: 'https://router.home.961504.xyz:10443',
|
||||||
|
homeUrl: 'https://router.home.961504.xyz:10443',
|
||||||
|
lanUrl: 'http://192.168.31.1',
|
||||||
|
stats: [
|
||||||
|
{ label: '联通公网 IPv4', value: t.network.home_wan_ip, highlight: true },
|
||||||
|
{ label: '泛域名 DDNS', value: '*.home.961504.xyz' },
|
||||||
|
{ label: '内网网关', value: '192.168.31.1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uptime',
|
||||||
|
title: 'Uptime Kuma 外部哨兵',
|
||||||
|
subtitle: 'status.961504.xyz · SG Azure',
|
||||||
|
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png',
|
||||||
|
online: t.services.uptime,
|
||||||
|
desc: '独立部署在新加坡 Azure 公有云上的 24 小时外部监控哨兵,通过 Tailscale P2P 专线实时探测家庭服务。',
|
||||||
|
edgeUrl: 'https://status.961504.xyz',
|
||||||
|
homeUrl: 'https://status.961504.xyz',
|
||||||
|
lanUrl: 'https://status.961504.xyz',
|
||||||
|
stats: [
|
||||||
|
{ label: '云端公网 IP', value: '104.43.105.243' },
|
||||||
|
{ label: 'Tailscale 专线', value: '100.115.204.51' },
|
||||||
|
{ label: '外部探测状态', value: '100% 在线', highlight: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [telemetry]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed bottom-5 right-5 z-50 hl-card px-4 py-3 flex items-center gap-2.5 shadow-2xl">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
|
||||||
|
<span className="text-xs font-medium">{toast}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 1. Top Utility & System Resources Ribbon */}
|
||||||
|
<TopRibbon />
|
||||||
|
|
||||||
|
{/* 2. Main Categorized 3-Column Rows */}
|
||||||
|
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-7">
|
||||||
|
{serviceGroups.map((group) => (
|
||||||
|
<section key={group.title} className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-mono-num"
|
||||||
|
style={{ color: 'var(--text-subtle)' }}
|
||||||
|
>
|
||||||
|
· {group.subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.showRouteHint && (
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-mono-num hidden sm:inline"
|
||||||
|
style={{ color: 'var(--text-subtle)' }}
|
||||||
|
>
|
||||||
|
当前路由模式: {activeModeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{group.items.map((card) => (
|
||||||
|
<ServiceCard key={card.id} card={card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 3. Bottom SSH Quick Copy & Maintenance Shelf */}
|
||||||
|
<SshShelf />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 4. Global Cmd+K Command Palette */}
|
||||||
|
<CommandPalette />
|
||||||
|
|
||||||
|
{/* 5. Embedded AGY Cloud Terminal Modal */}
|
||||||
|
<AgyTerminalModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePortalStore } from '../store/usePortalStore';
|
||||||
|
|
||||||
|
export const AgyTerminalModal: React.FC = () => {
|
||||||
|
const { showAgyModal, setShowAgyModal, getEffectiveMode } = usePortalStore();
|
||||||
|
|
||||||
|
if (!showAgyModal) return null;
|
||||||
|
|
||||||
|
const mode = getEffectiveMode();
|
||||||
|
const url =
|
||||||
|
mode === 'lan'
|
||||||
|
? 'http://192.168.31.164:7681'
|
||||||
|
: mode === 'home10443'
|
||||||
|
? 'https://agy.home.961504.xyz:10443'
|
||||||
|
: 'https://agy.961504.xyz';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-3 sm:p-6">
|
||||||
|
<div className="w-full h-full max-w-6xl hl-card flex flex-col overflow-hidden shadow-2xl">
|
||||||
|
<div className="px-4 py-2.5 border-b hl-border flex items-center justify-between hl-muted">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<img
|
||||||
|
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png"
|
||||||
|
alt="AGY"
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono-num font-medium">
|
||||||
|
AGY 云端工作台 · tmux 持久会话 (关窗不断连)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hl-btn-outline px-2.5 py-1 text-xs font-mono-num"
|
||||||
|
>
|
||||||
|
新标签页全屏 ↗
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAgyModal(false)}
|
||||||
|
className="hl-btn-primary px-3 py-1 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
收起终端
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe src={url} title="AGY Cloud Workbench" className="w-full flex-1 bg-black border-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { usePortalStore } from '../store/usePortalStore';
|
||||||
|
|
||||||
|
interface CmdItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
type: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandPalette: React.FC = () => {
|
||||||
|
const {
|
||||||
|
showCmdPalette,
|
||||||
|
setShowCmdPalette,
|
||||||
|
setShowAgyModal,
|
||||||
|
setTheme,
|
||||||
|
triggerAction,
|
||||||
|
copyCmd,
|
||||||
|
getEffectiveMode,
|
||||||
|
} = usePortalStore();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showCmdPalette) {
|
||||||
|
setQuery('');
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 20);
|
||||||
|
}
|
||||||
|
}, [showCmdPalette]);
|
||||||
|
|
||||||
|
const commands = useMemo<CmdItem[]>(() => {
|
||||||
|
const mode = getEffectiveMode();
|
||||||
|
const resolve = (edge: string, home: string, lan: string) =>
|
||||||
|
mode === 'lan' ? lan : mode === 'home10443' ? home : edge;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'open-agy-drawer',
|
||||||
|
title: '在页内唤起 AGY 云端终端',
|
||||||
|
hint: 'tmux attach -t agy',
|
||||||
|
type: '终端',
|
||||||
|
run: () => setShowAgyModal(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-agy',
|
||||||
|
title: '新窗口打开 AGY 云端工作台',
|
||||||
|
hint: 'https://agy.961504.xyz',
|
||||||
|
type: '跳转',
|
||||||
|
run: () =>
|
||||||
|
window.open(
|
||||||
|
resolve(
|
||||||
|
'https://agy.961504.xyz',
|
||||||
|
'https://agy.home.961504.xyz:10443',
|
||||||
|
'http://192.168.31.164:7681'
|
||||||
|
),
|
||||||
|
'_blank'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-z4',
|
||||||
|
title: '打开 极空间 Z4 私有云 (20TB)',
|
||||||
|
hint: '192.168.31.185:5055',
|
||||||
|
type: '跳转',
|
||||||
|
run: () =>
|
||||||
|
window.open(
|
||||||
|
resolve(
|
||||||
|
'https://z4.home.961504.xyz:10443',
|
||||||
|
'https://z4.home.961504.xyz:10443',
|
||||||
|
'http://192.168.31.185:5055'
|
||||||
|
),
|
||||||
|
'_blank'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-vw',
|
||||||
|
title: '打开 Vaultwarden 密码保险箱',
|
||||||
|
hint: 'https://vw.961504.xyz',
|
||||||
|
type: '跳转',
|
||||||
|
run: () =>
|
||||||
|
window.open(
|
||||||
|
resolve(
|
||||||
|
'https://vw.961504.xyz',
|
||||||
|
'https://vw.home.961504.xyz:10443',
|
||||||
|
'http://192.168.31.164:8222'
|
||||||
|
),
|
||||||
|
'_blank'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-clash',
|
||||||
|
title: '打开 Mihomo 路由分流面板',
|
||||||
|
hint: '192.168.31.1:9999',
|
||||||
|
type: '跳转',
|
||||||
|
run: () =>
|
||||||
|
window.open(
|
||||||
|
resolve(
|
||||||
|
'https://clash.home.961504.xyz:10443/ui/',
|
||||||
|
'https://clash.home.961504.xyz:10443/ui/',
|
||||||
|
'http://192.168.31.1:9999/ui/'
|
||||||
|
),
|
||||||
|
'_blank'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'act-backup',
|
||||||
|
title: '立即打包备份核心数据至极空间 Z4',
|
||||||
|
hint: 'backup-to-nas.sh',
|
||||||
|
type: '运维',
|
||||||
|
run: () => triggerAction('backup_nas', '正在打包备份至极空间 Z4...'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'act-ddns',
|
||||||
|
title: '立即刷新 Cloudflare DDNS 解析',
|
||||||
|
hint: 'cloudflare-ddns.sh',
|
||||||
|
type: '运维',
|
||||||
|
run: () => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh-mini',
|
||||||
|
title: '复制 SSH 连接 Mac mini 指令',
|
||||||
|
hint: 'ssh mini',
|
||||||
|
type: 'SSH',
|
||||||
|
run: () => copyCmd('ssh mini', 'SSH Mac mini'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh-agy',
|
||||||
|
title: '复制 终端接力 AGY 会话指令',
|
||||||
|
hint: 'ssh mini -t "tmux attach -t agy"',
|
||||||
|
type: 'SSH',
|
||||||
|
run: () => copyCmd('ssh mini -t "tmux attach -t agy"', '接力 AGY'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-light',
|
||||||
|
title: '切换主题: 浅色纯净 (Shadcn Light)',
|
||||||
|
hint: 'zinc-light',
|
||||||
|
type: '主题',
|
||||||
|
run: () => setTheme('zinc-light'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-dark',
|
||||||
|
title: '切换主题: 深色极客 (Shadcn Dark)',
|
||||||
|
hint: 'zinc-dark',
|
||||||
|
type: '主题',
|
||||||
|
run: () => setTheme('zinc-dark'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-cat',
|
||||||
|
title: '切换主题: Catppuccin Mocha',
|
||||||
|
hint: 'catppuccin',
|
||||||
|
type: '主题',
|
||||||
|
run: () => setTheme('catppuccin'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-nord',
|
||||||
|
title: '切换主题: Nord 极地霜蓝',
|
||||||
|
hint: 'nord',
|
||||||
|
type: '主题',
|
||||||
|
run: () => setTheme('nord'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [getEffectiveMode, setShowAgyModal, setTheme, triggerAction, copyCmd]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return commands;
|
||||||
|
return commands.filter(
|
||||||
|
(c) => c.title.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [query, commands]);
|
||||||
|
|
||||||
|
if (!showCmdPalette) return null;
|
||||||
|
|
||||||
|
const execute = (cmd: CmdItem) => {
|
||||||
|
setShowCmdPalette(false);
|
||||||
|
cmd.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setShowCmdPalette(false);
|
||||||
|
}}
|
||||||
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-20 px-4"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-xl hl-card overflow-hidden shadow-2xl">
|
||||||
|
<div className="px-4 py-3 border-b hl-border flex items-center gap-3">
|
||||||
|
<Search className="w-4 h-4" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
type="text"
|
||||||
|
placeholder="输入服务名称、SSH 指令、路由模式或主题..."
|
||||||
|
className="w-full bg-transparent text-sm outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setShowCmdPalette(false);
|
||||||
|
if (e.key === 'Enter' && filtered.length > 0) execute(filtered[0]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto p-2 text-xs">
|
||||||
|
<div
|
||||||
|
className="px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text-subtle)' }}
|
||||||
|
>
|
||||||
|
快速操作与服务直达
|
||||||
|
</div>
|
||||||
|
{filtered.map((cmd) => (
|
||||||
|
<button
|
||||||
|
key={cmd.id}
|
||||||
|
onClick={() => execute(cmd)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg text-left flex items-center justify-between transition-colors cursor-pointer"
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-muted)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="font-medium">{cmd.title}</span>
|
||||||
|
<span className="font-mono-num text-[11px]" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{cmd.hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{cmd.type}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePortalStore } from '../store/usePortalStore';
|
||||||
|
import type { ServiceCardItem } from '../types/telemetry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
card: ServiceCardItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServiceCard: React.FC<Props> = ({ card }) => {
|
||||||
|
const { getEffectiveMode, setShowAgyModal, triggerAction } = usePortalStore();
|
||||||
|
const mode = getEffectiveMode();
|
||||||
|
|
||||||
|
const resolvedUrl =
|
||||||
|
mode === 'lan' ? card.lanUrl : mode === 'home10443' ? card.homeUrl : card.edgeUrl;
|
||||||
|
|
||||||
|
const badgeLabel =
|
||||||
|
mode === 'lan' ? 'LAN 直连' : mode === 'home10443' ? '10443' : '443 免端口';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={resolvedUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hl-card hl-card-interactive flex flex-col justify-between overflow-hidden group cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Card Top: Authentic Brand Icon + Title + Status Dot + Description */}
|
||||||
|
<div className="p-4 sm:p-5 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-xl hl-muted border hl-border p-1.5 flex items-center justify-center shrink-0">
|
||||||
|
<img src={card.iconUrl} alt={card.title} className="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold truncate group-hover:underline">
|
||||||
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: card.online !== false ? 'var(--success)' : '#ef4444',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[11px] font-mono-num truncate mt-0.5"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{card.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Pill or Route Badge */}
|
||||||
|
{card.actionType === 'drawer' ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowAgyModal(true);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--accent-soft)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
页内终端
|
||||||
|
</button>
|
||||||
|
) : card.actionType === 'backup' ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerAction('backup_nas', '正在打包备份至极空间 Z4...');
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--success-soft)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即备份
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono-num px-2 py-0.5 rounded hl-muted shrink-0"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs leading-relaxed line-clamp-2"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{card.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Footer: gethomepage.dev Signature 3-Cell Inline Telemetry Widget */}
|
||||||
|
<div className="hl-widget-footer grid grid-cols-3 divide-x hl-border text-center">
|
||||||
|
{card.stats.map((stat, idx) => (
|
||||||
|
<div key={idx} className="py-2.5 px-2 flex flex-col items-center justify-center">
|
||||||
|
<span
|
||||||
|
className="text-[10px] uppercase tracking-wider truncate max-w-full"
|
||||||
|
style={{ color: 'var(--text-subtle)' }}
|
||||||
|
>
|
||||||
|
{stat.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-semibold font-mono-num mt-0.5 truncate max-w-full"
|
||||||
|
style={{
|
||||||
|
color: stat.highlight ? 'var(--success)' : 'var(--text-main)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePortalStore } from '../store/usePortalStore';
|
||||||
|
|
||||||
|
const SSH_LIST = [
|
||||||
|
{ label: 'SSH 连接 Mac mini', cmd: 'ssh mini' },
|
||||||
|
{ label: '终端接力 AGY 会话', cmd: 'ssh mini -t "tmux attach -t agy"' },
|
||||||
|
{ label: 'SSH 连接新加坡 Azure', cmd: 'ssh azure' },
|
||||||
|
{ label: 'SSH 连接小米主路由', cmd: 'ssh root@192.168.31.1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SshShelf: React.FC = () => {
|
||||||
|
const { copyCmd, triggerAction } = usePortalStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3 pt-1">
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
终端快捷指令与备用直连书签
|
||||||
|
</h2>
|
||||||
|
<span className="text-[11px] font-mono-num" style={{ color: 'var(--text-subtle)' }}>
|
||||||
|
· 点击指令即复制
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')}
|
||||||
|
className="hover:underline font-medium cursor-pointer"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
刷新 DDNS 解析
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerAction('mount_nas', '正在巡检极空间 20TB SMB 挂载...')}
|
||||||
|
className="hover:underline font-medium cursor-pointer"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
巡检极空间挂载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{SSH_LIST.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.cmd}
|
||||||
|
onClick={() => copyCmd(item.cmd, item.label)}
|
||||||
|
className="hl-card hl-card-interactive px-4 py-3 text-left flex items-center justify-between gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold">{item.label}</div>
|
||||||
|
<div
|
||||||
|
className="text-[11px] font-mono-num truncate mt-0.5"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{item.cmd}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted shrink-0"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
COPY
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Search, Terminal } from 'lucide-react';
|
||||||
|
import { usePortalStore } from '../store/usePortalStore';
|
||||||
|
import type { RouteModeId, ThemeId } from '../types/telemetry';
|
||||||
|
|
||||||
|
const THEMES: Array<{ id: ThemeId; label: string; dot: string }> = [
|
||||||
|
{ id: 'zinc-dark', label: '深色极客', dot: '#18181b' },
|
||||||
|
{ id: 'zinc-light', label: '浅色纯净', dot: '#f8fafc' },
|
||||||
|
{ id: 'catppuccin', label: 'Catppuccin', dot: '#cba6f7' },
|
||||||
|
{ id: 'nord', label: 'Nord', dot: '#88c0d0' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODES: Array<{ id: RouteModeId; label: string }> = [
|
||||||
|
{ id: 'auto', label: '智能路由' },
|
||||||
|
{ id: 'edge443', label: '443 免端口' },
|
||||||
|
{ id: 'home10443', label: '10443 家宽' },
|
||||||
|
{ id: 'lan', label: 'LAN 局域网' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TopRibbon: React.FC = () => {
|
||||||
|
const {
|
||||||
|
theme,
|
||||||
|
routeMode,
|
||||||
|
telemetry,
|
||||||
|
setTheme,
|
||||||
|
setRouteMode,
|
||||||
|
setShowAgyModal,
|
||||||
|
setShowCmdPalette,
|
||||||
|
} = usePortalStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="border-b hl-border sticky top-0 z-30 backdrop-blur-md"
|
||||||
|
style={{ backgroundColor: 'color-mix(in srgb, var(--bg-page) 86%, transparent)' }}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
{/* Left: Brand + Live System Micro-Gauges Ribbon */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2.5 pr-1">
|
||||||
|
<img
|
||||||
|
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png"
|
||||||
|
alt="Mac mini"
|
||||||
|
className="w-6 h-6 rounded"
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-sm tracking-tight">HomeLab</span>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: 'var(--success)' }}
|
||||||
|
title="全屋 4 节点在线"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden xl:flex items-center gap-2 text-xs font-mono-num">
|
||||||
|
{/* CPU Gauge */}
|
||||||
|
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="Apple M6 12核处理器负载">
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>M6 CPU</span>
|
||||||
|
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full hl-bar-fill"
|
||||||
|
style={{ width: `${Math.max(5, telemetry.mini.cpu_pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{telemetry.mini.cpu_pct}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RAM Gauge */}
|
||||||
|
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="Mac mini 24GB 统一内存">
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>RAM</span>
|
||||||
|
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full hl-bar-fill"
|
||||||
|
style={{ width: `${Math.max(5, telemetry.mini.mem_pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{telemetry.mini.mem_used_gb}G</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ZSpace 20TB Gauge */}
|
||||||
|
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="极空间 Z4 20TB 存储阵列">
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>NAS 余量</span>
|
||||||
|
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full hl-bar-fill-green"
|
||||||
|
style={{ width: `${Math.max(5, telemetry.nas.pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium" style={{ color: 'var(--success)' }}>
|
||||||
|
{telemetry.nas.free_tb} TB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SG AI Line Gauge */}
|
||||||
|
<div className="hl-pill px-2.5 py-1 flex items-center gap-1.5" title="AGY 锁定新加坡 Azure 专线出口">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>SG 专线</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{telemetry.network.sg_delay_ms > 0 ? `${telemetry.network.sg_delay_ms}ms` : '直连'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Search (Cmd+K), Route Switcher, Theme Switcher, Terminal Button */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCmdPalette(true)}
|
||||||
|
className="hl-btn-outline px-2.5 py-1 text-xs flex items-center gap-3 cursor-pointer"
|
||||||
|
title="按 Cmd+K 唤起全局命令面板"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">搜索或执行指令...</span>
|
||||||
|
</span>
|
||||||
|
<kbd
|
||||||
|
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setRouteMode(m.id)}
|
||||||
|
className="px-2 py-1 rounded-md font-medium transition-all cursor-pointer"
|
||||||
|
style={
|
||||||
|
routeMode === m.id
|
||||||
|
? {
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
}
|
||||||
|
: { color: 'var(--text-muted)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
||||||
|
{THEMES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTheme(t.id)}
|
||||||
|
className="px-2 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer"
|
||||||
|
style={
|
||||||
|
theme === t.id
|
||||||
|
? {
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
}
|
||||||
|
: { color: 'var(--text-muted)' }
|
||||||
|
}
|
||||||
|
title={t.label}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full border border-black/10"
|
||||||
|
style={{ backgroundColor: t.dot }}
|
||||||
|
/>
|
||||||
|
<span className="hidden lg:inline">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAgyModal(true)}
|
||||||
|
className="hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Terminal className="w-3.5 h-3.5" />
|
||||||
|
<span>AGY 终端</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
HomeLab Console V3 (React + Vite + TypeScript + Zustand + Tailwind v4)
|
||||||
|
Architecture: gethomepage.dev (Top Ribbon + 3-Col Rows + Inline Widgets)
|
||||||
|
Design System: shadcn/ui + Raycast Command Palette + 4 Official Themes
|
||||||
|
========================================================================= */
|
||||||
|
:root,
|
||||||
|
[data-theme="zinc-dark"] {
|
||||||
|
--bg-page: #09090b;
|
||||||
|
--bg-card: #121215;
|
||||||
|
--bg-widget: #16161a;
|
||||||
|
--bg-muted: #18181b;
|
||||||
|
--bg-hover: #1c1c21;
|
||||||
|
--text-main: #f4f4f5;
|
||||||
|
--text-muted: #a1a1aa;
|
||||||
|
--text-subtle: #71717a;
|
||||||
|
--border-color: #27272a;
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-hover: #3f3f46;
|
||||||
|
--primary: #fafafa;
|
||||||
|
--primary-fg: #09090b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-soft: rgba(59, 130, 246, 0.14);
|
||||||
|
--success: #10b981;
|
||||||
|
--success-soft: rgba(16, 185, 129, 0.14);
|
||||||
|
--card-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="zinc-light"] {
|
||||||
|
--bg-page: #f8fafc;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-widget: #f8fafc;
|
||||||
|
--bg-muted: #f1f5f9;
|
||||||
|
--bg-hover: #ffffff;
|
||||||
|
--text-main: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-subtle: #94a3b8;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--border-subtle: #f1f5f9;
|
||||||
|
--border-hover: #cbd5e1;
|
||||||
|
--primary: #0f172a;
|
||||||
|
--primary-fg: #ffffff;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-soft: rgba(37, 99, 235, 0.08);
|
||||||
|
--success: #10b981;
|
||||||
|
--success-soft: rgba(16, 185, 129, 0.1);
|
||||||
|
--card-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.04), 0 1px 2px -1px rgba(15, 23, 42, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="catppuccin"] {
|
||||||
|
--bg-page: #11111b;
|
||||||
|
--bg-card: #1e1e2e;
|
||||||
|
--bg-widget: #181825;
|
||||||
|
--bg-muted: #313244;
|
||||||
|
--bg-hover: #242438;
|
||||||
|
--text-main: #cdd6f4;
|
||||||
|
--text-muted: #a6adc8;
|
||||||
|
--text-subtle: #6c7086;
|
||||||
|
--border-color: #313244;
|
||||||
|
--border-subtle: rgba(205, 214, 244, 0.06);
|
||||||
|
--border-hover: #45475a;
|
||||||
|
--primary: #cba6f7;
|
||||||
|
--primary-fg: #11111b;
|
||||||
|
--accent: #89b4fa;
|
||||||
|
--accent-soft: rgba(137, 180, 250, 0.15);
|
||||||
|
--success: #a6e3a1;
|
||||||
|
--success-soft: rgba(166, 227, 161, 0.15);
|
||||||
|
--card-shadow: 0 4px 16px -4px rgba(17, 17, 27, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="nord"] {
|
||||||
|
--bg-page: #242933;
|
||||||
|
--bg-card: #2e3440;
|
||||||
|
--bg-widget: #292e39;
|
||||||
|
--bg-muted: #3b4252;
|
||||||
|
--bg-hover: #353c4a;
|
||||||
|
--text-main: #eceff4;
|
||||||
|
--text-muted: #d8dee9;
|
||||||
|
--text-subtle: #81a1c1;
|
||||||
|
--border-color: #3b4252;
|
||||||
|
--border-subtle: rgba(236, 239, 244, 0.06);
|
||||||
|
--border-hover: #4c566a;
|
||||||
|
--primary: #88c0d0;
|
||||||
|
--primary-fg: #2e3440;
|
||||||
|
--accent: #88c0d0;
|
||||||
|
--accent-soft: rgba(136, 192, 208, 0.15);
|
||||||
|
--success: #a3be8c;
|
||||||
|
--success-soft: rgba(163, 190, 140, 0.15);
|
||||||
|
--card-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background-color: var(--bg-page);
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono-num {
|
||||||
|
font-family: "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-card-interactive:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-widget-footer {
|
||||||
|
background-color: var(--bg-widget);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-pill {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-muted {
|
||||||
|
background-color: var(--bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-border {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-btn-primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.hl-btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-btn-outline {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.hl-btn-outline:hover {
|
||||||
|
background-color: var(--bg-muted);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-bar-fill {
|
||||||
|
background-color: var(--accent);
|
||||||
|
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-bar-fill-green {
|
||||||
|
background-color: var(--success);
|
||||||
|
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry';
|
||||||
|
|
||||||
|
interface PortalState {
|
||||||
|
theme: ThemeId;
|
||||||
|
routeMode: RouteModeId;
|
||||||
|
showAgyModal: boolean;
|
||||||
|
showCmdPalette: boolean;
|
||||||
|
toast: string;
|
||||||
|
actionLoading: boolean;
|
||||||
|
telemetry: TelemetryData;
|
||||||
|
setTheme: (theme: ThemeId) => void;
|
||||||
|
setRouteMode: (mode: RouteModeId) => void;
|
||||||
|
setShowAgyModal: (open: boolean) => void;
|
||||||
|
setShowCmdPalette: (open: boolean) => void;
|
||||||
|
showToast: (msg: string) => void;
|
||||||
|
fetchTelemetry: () => Promise<void>;
|
||||||
|
triggerAction: (action: string, startMsg: string) => Promise<void>;
|
||||||
|
copyCmd: (cmd: string, label: string) => Promise<void>;
|
||||||
|
getEffectiveMode: () => Exclude<RouteModeId, 'auto'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTheme = (localStorage.getItem('hl_theme') as ThemeId) || 'catppuccin';
|
||||||
|
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
|
||||||
|
|
||||||
|
// Apply theme immediately on load
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePortalStore = create<PortalState>((set, get) => ({
|
||||||
|
theme: initialTheme,
|
||||||
|
routeMode: initialRouteMode,
|
||||||
|
showAgyModal: false,
|
||||||
|
showCmdPalette: false,
|
||||||
|
toast: '',
|
||||||
|
actionLoading: false,
|
||||||
|
telemetry: {
|
||||||
|
mini: {
|
||||||
|
chip: 'Apple M6 (12核)',
|
||||||
|
ip: '192.168.31.164',
|
||||||
|
tailscale_ip: '100.108.205.40',
|
||||||
|
cpu_pct: 6.5,
|
||||||
|
load_1m: 0.85,
|
||||||
|
mem_used_gb: 11.8,
|
||||||
|
mem_total_gb: 24.0,
|
||||||
|
mem_pct: 49.2,
|
||||||
|
disk_used_gb: 59.9,
|
||||||
|
disk_total_gb: 460.4,
|
||||||
|
disk_pct: 13.0,
|
||||||
|
uptime: '运行中',
|
||||||
|
agy_tmux: true,
|
||||||
|
},
|
||||||
|
nas: {
|
||||||
|
ip: '192.168.31.185',
|
||||||
|
online: true,
|
||||||
|
mounted: true,
|
||||||
|
total_tb: 19.94,
|
||||||
|
used_tb: 3.36,
|
||||||
|
free_tb: 16.58,
|
||||||
|
pct: 16.8,
|
||||||
|
latest_backup: '11-06 23:17',
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
home_wan_ip: '124.90.142.21',
|
||||||
|
ai_policy: 'Ai平台 → 狮城节点',
|
||||||
|
sg_node: '233boy-sea',
|
||||||
|
sg_egress_ip: '104.43.105.243',
|
||||||
|
sg_delay_ms: 68,
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
agy: true,
|
||||||
|
vaultwarden: true,
|
||||||
|
nas: true,
|
||||||
|
clash: true,
|
||||||
|
router: true,
|
||||||
|
uptime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme: (theme) => {
|
||||||
|
localStorage.setItem('hl_theme', theme);
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
set({ theme });
|
||||||
|
},
|
||||||
|
|
||||||
|
setRouteMode: (routeMode) => {
|
||||||
|
localStorage.setItem('hl_route_mode', routeMode);
|
||||||
|
set({ routeMode });
|
||||||
|
},
|
||||||
|
|
||||||
|
setShowAgyModal: (showAgyModal) => set({ showAgyModal }),
|
||||||
|
setShowCmdPalette: (showCmdPalette) => set({ showCmdPalette }),
|
||||||
|
|
||||||
|
showToast: (msg) => {
|
||||||
|
set({ toast: msg });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (get().toast === msg) {
|
||||||
|
set({ toast: '' });
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchTelemetry: async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/telemetry');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
set({ telemetry: data });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore transient network errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerAction: async (action, startMsg) => {
|
||||||
|
const { showToast, fetchTelemetry } = get();
|
||||||
|
set({ actionLoading: true });
|
||||||
|
showToast(startMsg);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message || '操作已完成');
|
||||||
|
await fetchTelemetry();
|
||||||
|
} catch {
|
||||||
|
showToast('请求失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
set({ actionLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copyCmd: async (cmd, label) => {
|
||||||
|
const { showToast } = get();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(cmd);
|
||||||
|
showToast(`已复制「${label}」: ${cmd}`);
|
||||||
|
} catch {
|
||||||
|
showToast(cmd);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getEffectiveMode: () => {
|
||||||
|
const { routeMode } = get();
|
||||||
|
if (routeMode !== 'auto') return routeMode;
|
||||||
|
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';
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type ThemeId = 'zinc-dark' | 'zinc-light' | 'catppuccin' | 'nord';
|
||||||
|
export type RouteModeId = 'auto' | 'edge443' | 'home10443' | 'lan';
|
||||||
|
|
||||||
|
export interface TelemetryData {
|
||||||
|
timestamp?: string;
|
||||||
|
mini: {
|
||||||
|
chip: string;
|
||||||
|
ip: string;
|
||||||
|
tailscale_ip: string;
|
||||||
|
cpu_pct: number;
|
||||||
|
load_1m: number;
|
||||||
|
cores?: number;
|
||||||
|
mem_used_gb: number;
|
||||||
|
mem_total_gb: number;
|
||||||
|
mem_pct: number;
|
||||||
|
disk_used_gb: number;
|
||||||
|
disk_total_gb: number;
|
||||||
|
disk_pct: number;
|
||||||
|
uptime: string;
|
||||||
|
containers?: Array<{ name: string; status: string; image: string }>;
|
||||||
|
agy_tmux: boolean;
|
||||||
|
};
|
||||||
|
nas: {
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
online: boolean;
|
||||||
|
latency_ms?: number;
|
||||||
|
mounted: boolean;
|
||||||
|
total_tb: number;
|
||||||
|
used_tb: number;
|
||||||
|
free_tb: number;
|
||||||
|
pct: number;
|
||||||
|
latest_backup: string;
|
||||||
|
backup_count?: number;
|
||||||
|
};
|
||||||
|
network: {
|
||||||
|
home_wan_ip: string;
|
||||||
|
router_ip?: string;
|
||||||
|
router_online?: boolean;
|
||||||
|
router_ms?: number;
|
||||||
|
ai_policy: string;
|
||||||
|
sg_node: string;
|
||||||
|
sg_egress_ip: string;
|
||||||
|
sg_delay_ms: number;
|
||||||
|
azure_tailscale_ip?: string;
|
||||||
|
azure_online?: boolean;
|
||||||
|
azure_ms?: number;
|
||||||
|
};
|
||||||
|
services: {
|
||||||
|
agy: boolean;
|
||||||
|
vaultwarden: boolean;
|
||||||
|
nas: boolean;
|
||||||
|
clash: boolean;
|
||||||
|
router: boolean;
|
||||||
|
uptime: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatCell {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceCardItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
iconUrl: string;
|
||||||
|
online: boolean;
|
||||||
|
actionType?: 'drawer' | 'backup';
|
||||||
|
desc: string;
|
||||||
|
edgeUrl: string;
|
||||||
|
homeUrl: string;
|
||||||
|
lanUrl: string;
|
||||||
|
stats: StatCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceGroup {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
showRouteHint?: boolean;
|
||||||
|
items: ServiceCardItem[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://192.168.31.164:3002',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user