feat(portal): migrate V3 console to React 19 + Vite + TypeScript + Zustand + Tailwind v4

Browse Source
This commit is contained in:
Yin Kou committed 2026-11-07 00:07:22 +08:00
1 parent 518f1e757e
commit eae4596aa3
20 files changed
+4162 -3

No files matched your search

@@ -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>
);
};