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

+1
View File
@@ -2,3 +2,4 @@
stacks/gateway/certs/*.key stacks/gateway/certs/*.key
stacks/gateway/certs/*.pem stacks/gateway/certs/*.pem
stacks/lucky/goodluck/ stacks/lucky/goodluck/
**/node_modules/
+79
View File
@@ -0,0 +1,79 @@
(function(){const A=document.createElement("link").relList;if(A&&A.supports&&A.supports("modulepreload"))return;for(const p of document.querySelectorAll('link[rel="modulepreload"]'))m(p);new MutationObserver(p=>{for(const q of p)if(q.type==="childList")for(const nl of q.addedNodes)nl.tagName==="LINK"&&nl.rel==="modulepreload"&&m(nl)}).observe(document,{childList:!0,subtree:!0});function _(p){const q={};return p.integrity&&(q.integrity=p.integrity),p.referrerPolicy&&(q.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?q.credentials="include":p.crossOrigin==="anonymous"?q.credentials="omit":q.credentials="same-origin",q}function m(p){if(p.ep)return;p.ep=!0;const q=_(p);fetch(p.href,q)}})();function o0(b){return b&&b.__esModule&&Object.prototype.hasOwnProperty.call(b,"default")?b.default:b}var Ts={exports:{}},an={};/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Ir;function dh(){if(Ir)return an;Ir=1;var b=Symbol.for("react.transitional.element"),A=Symbol.for("react.fragment");function _(m,p,q){var nl=null;if(q!==void 0&&(nl=""+q),p.key!==void 0&&(nl=""+p.key),"key"in p){q={};for(var w in p)w!=="key"&&(q[w]=p[w])}else q=p;return p=q.ref,{$$typeof:b,type:m,key:nl,ref:p!==void 0?p:null,props:q}}return an.Fragment=A,an.jsx=_,an.jsxs=_,an}var Pr;function mh(){return Pr||(Pr=1,Ts.exports=dh()),Ts.exports}var z=mh(),Es={exports:{}},L={};/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var l0;function rh(){if(l0)return L;l0=1;var b=Symbol.for("react.transitional.element"),A=Symbol.for("react.portal"),_=Symbol.for("react.fragment"),m=Symbol.for("react.strict_mode"),p=Symbol.for("react.profiler"),q=Symbol.for("react.consumer"),nl=Symbol.for("react.context"),w=Symbol.for("react.forward_ref"),C=Symbol.for("react.suspense"),ll=Symbol.for("react.memo"),U=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),B=Symbol.for("react.view_transition"),Z=Symbol.iterator;function El(o){return o===null||typeof o!="object"?null:(o=Z&&o[Z]||o["@@iterator"],typeof o=="function"?o:null)}var wl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},vl=Object.assign,kl={};function nt(o,N,j){this.props=o,this.context=N,this.refs=kl,this.updater=j||wl}nt.prototype.isReactComponent={},nt.prototype.setState=function(o,N){if(typeof o!="object"&&typeof o!="function"&&o!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,o,N,"setState")},nt.prototype.forceUpdate=function(o){this.updater.enqueueForceUpdate(this,o,"forceUpdate")};function Ke(){}Ke.prototype=nt.prototype;function Bt(o,N,j){this.props=o,this.context=N,this.refs=kl,this.updater=j||wl}var qt=Bt.prototype=new Ke;qt.constructor=Bt,vl(qt,nt.prototype),qt.isPureReactComponent=!0;var it=Array.isArray;function W(){}var il={H:null,A:null,T:null,S:null},Gt=Object.prototype.hasOwnProperty;function bt(o,N,j){var Y=j.ref;return{$$typeof:b,type:o,key:N,ref:Y!==void 0?Y:null,props:j}}function Tt(o,N){return bt(o.type,N,o.props)}function ct(o){return typeof o=="object"&&o!==null&&o.$$typeof===b}function he(o){var N={"=":"=0",":":"=2"};return"$"+o.replace(/[=:]/g,function(j){return N[j]})}var Je=/\/+/g;function Cl(o,N){return typeof o=="object"&&o!==null&&o.key!=null?he(""+o.key):N.toString(36)}function O(o){switch(o.status){case"fulfilled":return o.value;case"rejected":throw o.reason;default:switch(typeof o.status=="string"?o.then(W,W):(o.status="pending",o.then(function(N){o.status==="pending"&&(o.status="fulfilled",o.value=N)},function(N){o.status==="pending"&&(o.status="rejected",o.reason=N)})),o.status){case"fulfilled":return o.value;case"rejected":throw o.reason}}throw o}function Q(o,N,j,Y,el){var ul=typeof o;(ul==="undefined"||ul==="boolean")&&(o=null);var cl=!1;if(o===null)cl=!0;else switch(ul){case"bigint":case"string":case"number":cl=!0;break;case"object":switch(o.$$typeof){case b:case A:cl=!0;break;case U:return cl=o._init,Q(cl(o._payload),N,j,Y,el)}}if(cl)return el=el(o),cl=Y===""?"."+Cl(o,0):Y,it(el)?(j="",cl!=null&&(j=cl.replace(Je,"$&/")+"/"),Q(el,N,j,"",function(le){return le})):el!=null&&(ct(el)&&(el=Tt(el,j+(el.key==null||o&&o.key===el.key?"":(""+el.key).replace(Je,"$&/")+"/")+cl)),N.push(el)),1;cl=0;var R=Y===""?".":Y+":";if(it(o))for(var V=0;V<o.length;V++)Y=o[V],ul=R+Cl(Y,V),cl+=Q(Y,N,j,ul,el);else if(V=El(o),typeof V=="function")for(o=V.call(o),V=0;!(Y=o.next()).done;)Y=Y.value,ul=R+Cl(Y,V++),cl+=Q(Y,N,j,ul,el);else if(ul==="object"){if(typeof o.then=="function")return Q(O(o),N,j,Y,el);throw N=String(o),Error("Objects are not valid as a React child (found: "+(N==="[object Object]"?"object with keys {"+Object.keys(o).join(", ")+"}":N)+"). If you meant to render a collection of children, use an array instead.")}return cl}function X(o,N,j){if(o==null)return o;var Y=[],el=0;return Q(o,Y,"","",function(ul){return N.call(j,ul,el++)}),Y}function hl(o){if(o._status===-1){var N=o._result,j=N();j.then(function(Y){(o._status===0||o._status===-1)&&(o._status=1,o._result=Y,j.status===void 0&&(j.status="fulfilled",j.value=Y))},function(Y){(o._status===0||o._status===-1)&&(o._status=2,o._result=Y,j.status===void 0&&(j.status="rejected",j.reason=Y))}),o._status===-1&&(o._status=0,o._result=j)}if(o._status===1)return o._result.default;throw o._result}var sl=typeof reportError=="function"?reportError:function(o){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var N=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof o=="object"&&o!==null&&typeof o.message=="string"?String(o.message):String(o),error:o});if(!window.dispatchEvent(N))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",o);return}console.error(o)};function Dt(o){var N=il.T,j={};j.types=N!==null?N.types:null,il.T=j;try{var Y=o(),el=il.S;el!==null&&el(j,Y),typeof Y=="object"&&Y!==null&&typeof Y.then=="function"&&Y.then(W,sl)}catch(ul){sl(ul)}finally{N!==null&&j.types!==null&&(N.types=j.types),il.T=N}}function Pt(o){var N=il.T;if(N!==null){var j=N.types;j===null?N.types=[o]:j.indexOf(o)===-1&&j.push(o)}else Dt(Pt.bind(null,o))}var $e={map:X,forEach:function(o,N,j){X(o,function(){N.apply(this,arguments)},j)},count:function(o){var N=0;return X(o,function(){N++}),N},toArray:function(o){return X(o,function(N){return N})||[]},only:function(o){Line truncated
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var e0;function vh(){return e0||(e0=1,(function(b){function A(O,Q){var X=O.length;O.push(Q);l:for(;0<X;){var hl=X-1>>>1,sl=O[hl];if(0<p(sl,Q))O[hl]=Q,O[X]=sl,X=hl;else break l}}function _(O){return O.length===0?null:O[0]}function m(O){if(O.length===0)return null;var Q=O[0],X=O.pop();if(X!==Q){O[0]=X;l:for(var hl=0,sl=O.length,Dt=sl>>>1;hl<Dt;){var Pt=2*(hl+1)-1,$e=O[Pt],o=Pt+1,N=O[o];if(0>p($e,X))o<sl&&0>p(N,$e)?(O[hl]=N,O[o]=X,hl=o):(O[hl]=$e,O[Pt]=X,hl=Pt);else if(o<sl&&0>p(N,X))O[hl]=N,O[o]=X,hl=o;else break l}}return Q}function p(O,Q){var X=O.sortIndex-Q.sortIndex;return X!==0?X:O.id-Q.id}if(b.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var q=performance;b.unstable_now=function(){return q.now()}}else{var nl=Date,w=nl.now();b.unstable_now=function(){return nl.now()-w}}var C=[],ll=[],U=1,S=null,B=3,Z=!1,El=!1,wl=!1,vl=!1,kl=typeof setTimeout=="function"?setTimeout:null,nt=typeof clearTimeout=="function"?clearTimeout:null,Ke=typeof setImmediate<"u"?setImmediate:null;function Bt(O){for(var Q=_(ll);Q!==null;){if(Q.callback===null)m(ll);else if(Q.startTime<=O)m(ll),Q.sortIndex=Q.expirationTime,A(C,Q);else break;Q=_(ll)}}function qt(O){if(wl=!1,Bt(O),!El)if(_(C)!==null)El=!0,it||(it=!0,ct());else{var Q=_(ll);Q!==null&&Cl(qt,Q.startTime-O)}}var it=!1,W=-1,il=5,Gt=-1;function bt(){return vl?!0:!(b.unstable_now()-Gt<il)}function Tt(){if(vl=!1,it){var O=b.unstable_now();Gt=O;var Q=!0;try{l:{El=!1,wl&&(wl=!1,nt(W),W=-1),Z=!0;var X=B;try{t:{for(Bt(O),S=_(C);S!==null&&!(S.expirationTime>O&&bt());){var hl=S.callback;if(typeof hl=="function"){S.callback=null,B=S.priorityLevel;var sl=hl(S.expirationTime<=O);if(O=b.unstable_now(),typeof sl=="function"){S.callback=sl,Bt(O),Q=!0;break t}S===_(C)&&m(C),Bt(O)}else m(C);S=_(C)}if(S!==null)Q=!0;else{var Dt=_(ll);Dt!==null&&Cl(qt,Dt.startTime-O),Q=!1}}break l}finally{S=null,B=X,Z=!1}Q=void 0}}finally{Q?ct():it=!1}}}var ct;if(typeof Ke=="function")ct=function(){Ke(Tt)};else if(typeof MessageChannel<"u"){var he=new MessageChannel,Je=he.port2;he.port1.onmessage=Tt,ct=function(){Je.postMessage(null)}}else ct=function(){kl(Tt,0)};function Cl(O,Q){W=kl(function(){O(b.unstable_now())},Q)}b.unstable_IdlePriority=5,b.unstable_ImmediatePriority=1,b.unstable_LowPriority=4,b.unstable_NormalPriority=3,b.unstable_Profiling=null,b.unstable_UserBlockingPriority=2,b.unstable_cancelCallback=function(O){O.callback=null},b.unstable_forceFrameRate=function(O){0>O||125<O?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):il=0<O?Math.floor(1e3/O):5},b.unstable_getCurrentPriorityLevel=function(){return B},b.unstable_next=function(O){switch(B){case 1:case 2:case 3:var Q=3;break;default:Q=B}var X=B;B=Q;try{return O()}finally{B=X}},b.unstable_requestPaint=function(){vl=!0},b.unstable_runWithPriority=function(O,Q){switch(O){case 1:case 2:case 3:case 4:case 5:break;default:O=3}var X=B;B=O;try{return Q()}finally{B=X}},b.unstable_scheduleCallback=function(O,Q,X){var hl=b.unstable_now();switch(typeof X=="object"&&X!==null?(X=X.delay,X=typeof X=="number"&&0<X?hl+X:hl):X=hl,O){case 1:var sl=-1;break;case 2:sl=250;break;case 5:sl=1073741823;break;case 4:sl=1e4;break;default:sl=5e3}return sl=X+sl,O={id:U++,callback:Q,priorityLevel:O,startTime:X,expirationTime:sl,sortIndex:-1},X>hl?(O.sortIndex=X,A(ll,O),_(C)===null&&O===_(ll)&&(wl?(nt(W),W=-1):wl=!0,Cl(qt,X-hl))):(O.sortIndex=sl,A(C,O),El||Z||(El=!0,it||(it=!0,ct()))),O},b.unstable_shouldYield=bt,b.unstable_wrapCallback=function(O){var Q=B;return function(){var X=B;B=Q;try{return O.apply(this,arguments)}finally{B=X}}}})(Ns)),Ns}var u0;function yh(){return u0||(u0=1,_s.exports=vh()),_s.exports}var xs={exports:{}},Ll={};/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var a0;function hh(){if(a0)return Ll;a0=1;var b=Os();function A(U){var S="https://react.dev/errors/"+U;if(1<arguments.length){S+="?args[]="+encodeURIComponent(arguments[1]);for(var B=2;B<arguments.length;B++)S+="&args[]="+encodeURIComponent(arguments[B])}return"Minified React error #"+U+"; visit "+S+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function _(){}var m={d:{f:_,r:function(){throw Error(A(522))},D:_,C:_,L:_,m:_,X:_,S:_,M:_},p:0,findDOMNode:null},p=Symbol.for("react.portal"),q=Symbol.for("react.recoverable"),nl=Symbol.for("react.optimistic_key");function w(U,S,B){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:p,key:Z==null?null:Z===nl?nl:""+Z,children:U,containerInfo:S,implementation:B}}var C=b.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function ll(U,S){if(U==="font")return"";if(typeof S=="string")return S==="use-credentials"?S:""}return Ll.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=m,Ll.browser=function(U){return{$$typeof:q,_reason:U}},Ll.createPortal=function(U,S){var B=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!S||S.nodeType!==1&&S.nodeType!==9&&S.nodeType!==11)throw Error(A(299));return w(U,S,null,B)},Ll.flushSync=function(U){var S=C.T,B=m.p;try{if(C.T=null,m.p=2,U)return U()}finally{C.T=S,m.p=B,m.d.f()}},Ll.preconnect=function(U,S){typeof U=="string"&&(S?(S=S.crossOrigin,S=typeof S=="string"?S==="use-credentials"?S:"":void 0):S=null,m.d.C(U,S))},Ll.prefetchDNS=function(U){typeof U=="string"&&m.d.D(U)},Ll.preinit=function(U,S){if(typeof U=="string"&&S&&typeof S.as=="string"){var B=S.as,Z=ll(B,S.crossOrigin),El=typeof S.integrity=="string"?S.integrity:void 0,wl=typeof S.fetchPriority=="string"?S.fetchPriority:void 0;B==="style"?m.d.S(U,typeof S.precedence=="string"?S.precedence:void 0,{crossOrigin:Z,integrity:El,fetchPriority:wl}):B==="script"&&m.d.X(U,{crossOrigin:Z,integrity:El,fetchPriority:wl,nonce:typeof S.nonce=="string"?S.nonce:void 0})}},Ll.preinitModule=function(U,S){if(typeof U=="string")if(typeof S=="object"&&S!==null){if(S.as==null||S.as==="script"){var B=ll(S.as,S.crossOrigin);m.d.M(U,{crossOrigin:B,integrity:typeof S.integrity=="string"?S.integrity:void 0,nonce:typeof S.nonce=="string"?S.nonce:void 0,fetchPriority:typeof S.fetchPriority=="string"?S.fetchPriority:void 0})}}else S==null&&m.d.M(U)},Ll.preload=function(U,S){if(typeof U=="string"&&typeof S=="object"&&S!==null&&typeof S.as=="string"){var B=S.as,Z=ll(B,S.crossOrigin);m.d.L(U,B,{crossOrigin:Z,integrity:typeof S.integrity=="string"?S.integrity:void 0,nonce:typeof S.nonce=="string"?S.nonce:void 0,type:typeof S.type=="string"?S.type:void 0,fetchPriority:typeof S.fetchPriority=="string"?S.fetchPriority:void 0,referrerPolicy:typeof S.referrerPolicy=="string"?S.referrerPolicy:void 0,imageSrcSet:typeof S.imageSrcSet=="string"?S.imageSrcSet:void 0,imageSizes:typeof S.imageSizes=="string"?S.imageSizes:void 0,media:typeof S.media=="string"?S.media:void 0})}},Ll.preloadModule=function(U,S){if(typeof U=="string")if(S){var B=ll(S.as,S.crossOrigin);m.d.m(U,{as:typeof S.as=="string"&&S.as!=="script"?S.as:void 0,crossOrigin:B,integrity:typeof S.integrity=="string"?S.integrity:void 0,nonce:typeof S.nonce=="string"?S.nonce:void 0,fetchPriority:typeof S.fetchPriority=="string"?S.fetchPriority:void 0})}else m.d.m(U)},Ll.requestFormReset=function(U){m.d.r(U)},Ll.unstable_batchedUpdates=function(U,S){return U(S)},Ll.useFormState=function(U,S,B){return C.H.useFormState(U,S,B)},Ll.useFormStatus=function(){return C.H.useHostTransitionStatus()},Ll.version="19.3.1",Ll}var n0;function gh(){if(n0)return xs.exports;n0=1;function b(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(b)}catch(A){console.error(A)}}return b(),xs.exports=hh(),xs.exports}/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var i0;function Sh(){if(i0)return nn;i0=1;var b=yh(),A=Os(),_=gh();function m(l){var t="https://react.dev/errors/"+l;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var e=2;e<arguments.length;e++)t+="&args[]="+encodeURIComponent(arguments[e])}return"Minified React error #"+l+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function p(l){return!(!l||l.nodeType!==1&&l.nodeType!==9&&l.nodeType!==11)}function q(l){for(var t=l,e=t;e&&!e.alternate;)t=e,(t.flags&4098)!==0&&(l=t.return),e=t.return;for(;t.return;)t=t.return;return t.tag===3?l:null}function nl(l){if(l.tag===13){var t=l.memoizedState;if(t===null&&(l=l.alternate,l!==null&&(t=l.memoizedState)),t!==null)return t.dehydrated}return null}function w(l){if(l.tag===31){var t=l.memoizedState;if(t===null&&(l=l.alternate,l!==null&&(t=l.memoizedState)),t!==null)return t.dehydrated}return null}function C(l){if(q(l)!==l)throw Error(m(188))}function ll(l){var t=l.alternate;if(!t){if(t=q(l),t===null)throw Error(m(188));return t!==l?null:l}for(var e=l,u=t;;){var a=e.return;if(a===null)break;var n=a.alternate;if(n===null){if(u=a.return,u!==null){e=u;continue}break}if(a.child===n.child){for(n=a.child;n;){if(n===e)return C(a),l;if(n===u)return C(a),t;n=n.sibling}throw Error(m(188))}if(e.return!==u.return)e=a,u=n;else{for(var i=!1,c=a.child;c;){if(c===e){i=!0,e=a,u=n;break}if(c===u){i=!0,u=a,e=n;break}c=c.sibling}if(!i){for(c=n.child;c;){if(c===e){i=!0,e=n,u=a;break}if(c===u){i=!0,u=n,e=a;break}c=c.sibling}if(!i)throw Error(m(189))}}if(e.alternate!==u)throw Error(m(190))}if(e.tag!==3)throw Error(m(188));return e.stateNode.current===e?l:t}function U(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l;for(l=l.child;l!==null;){if(t=U(l),t!==null)return t;l=l.sibling}return null}function S(l,t,e,u,a,n){for(;l!==null;){if((l.tag===5||l.tag===27||l.tag===6)&&e(l,u,a,n)||(l.tag!==22||l.memoizedState===null)&&(t||l.tag!==5&&l.tag!==27)&&S(l.child,t,e,u,a,n))return!0;l=l.sibling}return!1}function B(l){for(l=l.return;l!==null;){if(l.tag===3||l.tag===5||l.tag===27)return l;l=l.return}return null}function Z(l){var t=!1;for(l=l.return;l!==null&&(l.tag===4&&(t=!0),!(l.tag===3||l.tag===5||l.tag===27));)l=l.return;return t}function El(l){var t=[null,null],e=B(l);return e===null||wl(t,l,e.child,{foundSelf:!1}),t}function wl(l,t,e,u){for(;e!==null;){if(e===t)u.foundSelf=!0;else if(e.tag===5||e.tag===27||e.tag===6){if(u.foundSelf)return l[1]=e,!0;l[0]=e}else if((e.tag!==22||e.memoizedState===null)&&wl(l,t,e.child,u))return!0;e=e.sibling}return!1}function vl(l){switch(l.tag){case 5:case 27:case 6:return l.stateNode;case 3:return l.stateNode.containerInfo;default:throw Error(m(559))}}var kl=null,nt=null;function Ke(l,t,e){return l===e?!0:l===t?(kl=l,!0):!1}function Bt(l,t,e){return l===e?(nt=l,!1):l===t?(nt!==null&&(kl=l),!0):!1}function qt(l){if(l===null)return null;do l=l===null?null:l.return;while(l&&l.tag!==5&&l.tag!==27&&l.tag!==3);return l||null}function it(l,t,e){for(var u=0,a=l;a;a=e(a))u++;a=0;for(var n=t;n;n=e(n))a++;for(;0<u-a;)l=e(l),u--;for(;0<a-u;)t=e(t),a--;for(;u--;){if(l===t||t!==null&&l===t.alternate)return l;l=e(l),t=e(t)}return null}var W=Object.assign,il=Symbol.for("react.element"),Gt=Symbol.for("react.transitional.element"),bt=Symbol.for("react.portal"),Tt=Symbol.for("react.fragment"),ct=Symbol.for("react.strict_mode"),he=Symbol.for("react.profiler"),Je=Symbol.for("react.consumer"),Cl=Symbol.for("react.context"),O=Symbol.for("react.forward_ref"),Q=Symbol.for("react.suspense"),X=Symbol.for("react.suspense_list"),hl=Symbol.for("react.memo"),sl=Symbol.for("react.lazy"),Dt=Symbol.for("react.activity"),Pt=Symbol.for("react.legacy_hidden"),$e=Symbol.for("react.memo_cache_sentinel"),o=Symbol.for("react.view_transition"),N=Symbol.for("react.recoverable"),j=Symbol.iterator;function Y(l){return l===null||typeof l!="object"?null:(l=j&&l[j]||l["@@iterator"],typeof l=="function"?l:null)}var el=Symbol.for("react.client.reference");function ul(l){if(l==null)return null;if(typeof l=="function")return l.$$typeof===el?null:l.displayName||l.name||null;if(typeof l=="string")return l;switch(l){case Tt:return"Fragment";case he:return"Profiler";case ct:return"StrictMode";case Q:return"Suspense";case X:return"SuspenseList";case Dt:return"Activity";case o:return"ViewTransition"}if(typeof l=="object")switch(l.$$typeof){case bt:return"Portal";case Cl:return l.displayName||"Context";case Je:return(l._context.displayName||"Context")+".Consumer";case O:var t=l.render;return l=l.displayName,l||(l=t.displayName||t.name||"",l=l!==""?"ForwardRef("+l+")":"ForwardRef"),l;case hl:return t=l.displayName||null,t!==null?t:ul(l.type)||"Memo";case sl:t=l._payload,l=l._init;try{return ul(l(t))}catch{}}return null}var cl=Array.isArray,R=A.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,V=_.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,le={pending:!1,data:null,method:null,actiLine truncated
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Qi+l+As}var Xi=!1;function Zi(l,t){if(!l||Xi)return"";Xi=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(t){var E=function(){throw Error()};if(Object.defineProperty(E.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(E,[])}catch(x){var d=x}Reflect.construct(l,[],E)}else{try{E.call()}catch(x){d=x}E=!1;try{var h=Object.getOwnPropertyDescriptor(l.prototype,"props");Object.defineProperty(l.prototype,"props",{configurable:!0,set:function(){throw Error()}}),E=!0,new l}finally{E&&(h!==void 0?Object.defineProperty(l.prototype,"props",h):delete l.prototype.props)}}}else{try{throw Error()}catch(x){d=x}(E=l())&&typeof E.catch=="function"&&E.catch(function(){})}}catch(x){if(x&&d&&typeof x.stack=="string")return[x.stack,d.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=u.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
`),v=c.split(`
`);for(a=u=0;u<f.length&&!f[u].includes("DetermineComponentFrameRoot");)u++;for(;a<v.length&&!v[a].includes("DetermineComponentFrameRoot");)a++;if(u===f.length||a===v.length)for(u=f.length-1,a=v.length-1;1<=u&&0<=a&&f[u]!==v[a];)a--;for(;1<=u&&0<=a;u--,a--)if(f[u]!==v[a]){if(u!==1||a!==1)do if(u--,a--,0>a||f[u]!==v[a]){var g=`
`+f[u].replace(" at new "," at ");return l.displayName&&g.includes("<anonymous>")&&(g=g.replace("<anonymous>",l.displayName)),g}while(1<=u&&0<=a);break}}}finally{Xi=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?Se(e):""}function y0(l,t){switch(l.tag){case 26:case 27:case 5:return Se(l.type);case 16:return Se("Lazy");case 13:return l.child!==t&&t!==null?Se("Suspense Fallback"):Se("Suspense");case 19:return Se("SuspenseList");case 0:case 15:return Zi(l.type,!1);case 11:return Zi(l.type.render,!1);case 1:return Zi(l.type,!0);case 31:return Se("Activity");case 30:return Se("ViewTransition");default:return""}}function ps(l){try{var t="",e=null;do t+=y0(l,e),e=l,l=l.return;while(l);return t}catch(u){return`
Error generating stack: `+u.message+`
`+u.stack}}var Vi=Object.prototype.hasOwnProperty,Li=b.unstable_scheduleCallback,wi=b.unstable_cancelCallback,h0=b.unstable_shouldYield,g0=b.unstable_requestPaint,ft=b.unstable_now,S0=b.unstable_getCurrentPriorityLevel,Ms=b.unstable_ImmediatePriority,Ds=b.unstable_UserBlockingPriority,on=b.unstable_NormalPriority,b0=b.unstable_LowPriority,Us=b.unstable_IdlePriority,T0=b.log,E0=b.unstable_setDisableYieldValue,da=null,st=null;function be(l){if(typeof T0=="function"&&E0(l),st&&typeof st.setStrictMode=="function")try{st.setStrictMode(da,l)}catch{}}var ot=Math.clz32?Math.clz32:N0,z0=Math.log,_0=Math.LN2;function N0(l){return l>>>=0,l===0?32:31-(z0(l)/_0|0)|0}var dn=256,mn=262144,rn=4194304;function We(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&-l;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function vn(l,t,e){var u=l.pendingLanes;if(u===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=u&134217727;return c!==0?(u=c&~n,u!==0?a=We(u):(i&=c,i!==0?a=We(i):e||(e=c&~l,e!==0&&(a=We(e))))):(c=u&~n,c!==0?a=We(c):i!==0?a=We(i):e||(e=u&~l,e!==0&&(a=We(e)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,e=t&-t,n>=e||n===32&&(e&4194048)!==0)?t:a}function ma(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Cs(l,t){(t&8)!==0&&(t|=t&32);var e=l.entangledLanes;if(e!==0)for(l=l.entanglements,e&=t;0<e;){var u=31-ot(e),a=1<<u;t|=l[u],e&=~a}return t}function x0(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Rs(){var l=rn;return rn<<=1,(rn&62914560)===0&&(rn=4194304),l}function Ki(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function ra(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function O0(l,t,e,u,a,n){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,v=l.hiddenUpdates;for(e=i&~e;0<e;){var g=31-ot(e),E=1<<g;c[g]=0,f[g]=-1;var d=v[g];if(d!==null)for(v[g]=null,g=0;g<d.length;g++){var h=d[g];h!==null&&(h.lane&=-536870913)}e&=~E}u!==0&&Hs(l,u,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function Hs(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var u=31-ot(t);l.entangledLanes|=t,l.entanglements[u]=l.entanglements[u]|1073741824|e&261930}function js(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var u=31-ot(e),a=1<<u;a&t|l[u]&t&&(l[u]|=t),e&=~a}}function Ys(l,t){var e=t&-t;return e=(e&42)!==0?1:Ji(e),(e&(l.suspendedLanes|t))!==0?0:e}function Ji(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function $i(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Bs(){var l=V.p;return l!==0?l:(l=window.event,l===void 0?32:wr(l.type))}function qs(l,t){var e=V.p;try{return V.p=l,t()}finally{V.p=e}}var te=Math.random().toString(36).slice(2),ql="__reactFiber$"+te,Il="__reactProps$"+te,Tu="__reactContainer$"+te,Gs="__reactEvents$"+te,A0="__reactListeners$"+te,p0="__reactHandles$"+te,Qs="__reactResources$"+te,va="__reactMarker$"+te,yn="__reactLoad$"+te;function hn(l){delete l[ql],delete l[Il],delete l[A0],delete l[p0]}function Fe(l){var t;if(t=l[ql])return t;for(var e=l.parentNode;e;){if(t=e[Tu]||e[ql]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=xr(l);l!==null;){if(e=l[ql])return e;l=xr(l)}return t}l=e,e=l.parentNode}return null}function Eu(l){if(l=l[ql]||l[Tu]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function ya(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(m(33))}function zu(l){var t=l[Qs];return t||(t=l[Qs]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Rl(l){l[va]=!0}function Xs(l){l[yn]=void 0}var Zs=new Set,Vs={};function ke(l,t){_u(l,t),_u(l+"Capture",t)}function Line truncated
`).replace(yy,"")}function nr(l,t){return t=ar(t),ar(l)===t}function rl(l,t,e,u,a,n){switch(e){case"children":if(typeof u=="string")t==="body"||t==="textarea"&&u===""||xu(l,u);else if(typeof u=="number"||typeof u=="bigint")t!=="body"&&xu(l,""+u);else return;break;case"className":Sn(l,"class",u);break;case"tabIndex":Sn(l,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":Sn(l,e,u);break;case"style":Ps(l,u,n);return;case"data":if(t!=="object"){Sn(l,"data",u);break}case"src":case"href":if(u===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=bn(u),l.setAttribute(e,u);break;case"action":case"formAction":if(typeof u=="function"){l.setAttribute(e,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof n=="function"&&(e==="formAction"?(t!=="input"&&rl(l,t,"name",a.name,a,null),rl(l,t,"formEncType",a.formEncType,a,null),rl(l,t,"formMethod",a.formMethod,a,null),rl(l,t,"formTarget",a.formTarget,a,null)):(rl(l,t,"encType",a.encType,a,null),rl(l,t,"method",a.method,a,null),rl(l,t,"target",a.target,a,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=bn(u),l.setAttribute(e,u);break;case"onClick":u!=null&&(l.onclick=Zt);return;case"onScroll":u!=null&&k("scroll",l);return;case"onScrollEnd":u!=null&&k("scrollend",l);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(m(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(m(60));(n!=null?n.__html:void 0)!==e&&(l.innerHTML=e)}}break;case"multiple":l.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":l.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){l.removeAttribute("xlink:href");break}e=bn(u),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":u&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":u===!0?l.setAttribute(e,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?l.setAttribute(e,u):l.removeAttribute(e);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?l.removeAttribute(e):l.setAttribute(e,u);break;case"popover":k("beforetoggle",l),k("toggle",l),gn(l,"popover",u);break;case"xlinkActuate":ee(l,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":ee(l,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":ee(l,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":ee(l,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":ee(l,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":ee(l,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":ee(l,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":ee(l,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":ee(l,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":gn(l,"is",u);break;case"innerText":case"textContent":return;default:if(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")e=H0.get(e)||e,gn(l,e,u);else return}al=!0}function kf(l,t,e,u,a,n){switch(e){case"style":Ps(l,u,n);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(m(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(m(60));(n!=null?n.__html:void 0)!==e&&(l.innerHTML=e)}}break;case"children":if(typeof u=="string")xu(l,u);else if(typeof u=="number"||typeof u=="bigint")xu(l,""+u);else return;break;case"onScroll":u!=null&&k("scroll",l);return;case"onScrollEnd":u!=null&&k("scrollend" Line truncated
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Ah=b=>b.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),m0=(...b)=>b.filter((A,_,m)=>!!A&&A.trim()!==""&&m.indexOf(A)===_).join(" ").trim();/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/var ph={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Mh=at.forwardRef(({color:b="currentColor",size:A=24,strokeWidth:_=2,absoluteStrokeWidth:m,className:p="",children:q,iconNode:nl,...w},C)=>at.createElement("svg",{ref:C,...ph,width:A,height:A,stroke:b,strokeWidth:m?Number(_)*24/Number(A):_,className:m0("lucide",p),...w},[...nl.map(([ll,U])=>at.createElement(ll,U)),...Array.isArray(q)?q:[q]]));/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r0=(b,A)=>{const _=at.forwardRef(({className:m,...p},q)=>at.createElement(Mh,{ref:q,iconNode:A,className:m0(`lucide-${Ah(b)}`,m),...p}));return _.displayName=`${b}`,_};/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Dh=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]],v0=r0("Search",Dh);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Uh=[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]],Ch=r0("Terminal",Uh),Rh=[{id:"zinc-dark",label:"深色极客",dot:"#18181b"},{id:"zinc-light",label:"浅色纯净",dot:"#f8fafc"},{id:"catppuccin",label:"Catppuccin",dot:"#cba6f7"},{id:"nord",label:"Nord",dot:"#88c0d0"}],Hh=[{id:"auto",label:"智能路由"},{id:"edge443",label:"443 免端口"},{id:"home10443",label:"10443 家宽"},{id:"lan",label:"LAN 局域网"}],jh=()=>{const{theme:b,routeMode:A,telemetry:_,setTheme:m,setRouteMode:p,setShowAgyModal:q,setShowCmdPalette:nl}=sa();return z.jsx("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)"},children:z.jsxs("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",children:[z.jsxs("div",{className:"flex flex-wrap items-center gap-2.5 sm:gap-3",children:[z.jsxs("div",{className:"flex items-center gap-2.5 pr-1",children:[z.jsx("img",{src:"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png",alt:"Mac mini",className:"w-6 h-6 rounded"}),z.jsx("span",{className:"font-semibold text-sm tracking-tight",children:"HomeLab"}),z.jsx("span",{className:"w-2 h-2 rounded-full",style:{backgroundColor:"var(--success)"},title:"全屋 4 节点在线"})]}),z.jsxs("div",{className:"hidden xl:flex items-center gap-2 text-xs font-mono-num",children:[z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-2",title:"Apple M6 12核处理器负载",children:[z.jsx("span",{style:{color:"var(--text-muted)"},children:"M6 CPU"}),z.jsx("div",{className:"w-12 h-1.5 rounded-full hl-muted overflow-hidden",children:z.jsx("div",{className:"h-full hl-bar-fill",style:{width:`${Math.max(5,_.mini.cpu_pct)}%`}})}),z.jsxs("span",{className:"font-medium",children:[_.mini.cpu_pct,"%"]})]}),z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-2",title:"Mac mini 24GB 统一内存",children:[z.jsx("span",{style:{color:"var(--text-muted)"},children:"RAM"}),z.jsx("div",{className:"w-12 h-1.5 rounded-full hl-muted overflow-hidden",children:z.jsx("div",{className:"h-full hl-bar-fill",style:{width:`${Math.max(5,_.mini.mem_pct)}%`}})}),z.jsxs("span",{className:"font-medium",children:[_.mini.mem_used_gb,"G"]})]}),z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-2",title:"极空间 Z4 20TB 存储阵列",children:[z.jsx("span",{style:{color:"var(--text-muted)"},children:"NAS 余量"}),z.jsx("div",{className:"w-12 h-1.5 rounded-full hl-muted overflow-hidden",children:z.jsx("div",{className:"h-full hl-bar-fill-green",style:{width:`${Math.max(5,_.nas.pct)}%`}})}),z.jsxs("span",{className:"font-medium",style:{color:"var(--success)"},children:[_.nas.free_tb," TB"]})]}),z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-1.5",title:"AGY 锁定新加坡 Azure 专线出口",children:[z.jsx("span",{className:"w-1.5 h-1.5 rounded-full",style:{backgroundColor:"var(--success)"}}),z.jsx("span",{style:{color:"var(--text-muted)"},children:"SG 专线"}),z.jsx("span",{className:"font-medium",children:_.network.sg_delay_ms>0?`${_.network.sg_delay_ms}ms`:"直连"})]})]})]}),z.jsxs("div",{className:"flex items-center gap-2",children:[z.jsxs("button",{onClick:()=>nl(!0),className:"hl-btn-outline px-2.5 py-1 text-xs flex items-center gap-3 cursor-pointer",title:"按 Cmd+K 唤起全局命令面板",children:[z.jsxs("span",{className:"flex items-center gap-1.5",style:{color:"var(--text-muted)"},children:[z.jsx(v0,{className:"w-3.5 h-3.5"}),z.jsx("span",{className:"hidden sm:inline",children:"搜索或执行指令..."})]}),z.jsx("kbd",{className:"px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted",style:{color:"var(--text-muted)"},children:"⌘K"})]}),z.jsx("div",{className:"hidden md:flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs",children:Hh.map(w=>z.jsx("button",{onClick:()=>p(w.id),className:"px-2 py-1 rounded-md font-medium transition-all cursor-pointer",style:A===w.id?{backgroundColor:"var(--bg-card)",color:"var(--text-main)",boxShadow:"0 1px 2px rgba(0,0,0,0.08)"}:{color:"var(--text-muted)"},children:w.label},w.id))}),z.jsx("div",{className:"flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs",children:Rh.map(w=>z.jsxs("button",{onClick:()=>m(w.id),className:"px-2 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer",style:b===w.id?{backgroundColor:"var(--bg-card)",color:"var(--text-main)",boxShadow:"0 1px 2px rgba(0,0,0,0.08)"}:{color:"var(--text-muted)"},title:w.label,children:[z.jsx("span",{className:"w-2 h-2 rounded-full border border-black/10",style:{backgroundColor:w.dot}}),z.jsx("span",{className:"hidden lg:inline",children:w.label})]},w.id))}),z.jsxs("button",{onClick:()=>q(!0),className:"hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer",children:[z.jsx(Ch,{className:"w-3.5 Line truncated
+1
View File
@@ -0,0 +1 @@
/*! tailwindcss v4.3.5 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:block))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--spacing:.25rem;--container-xl:36rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-fielLine truncated
+20
View File
@@ -0,0 +1,20 @@
<!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"
/>
<script type="module" crossorigin src="/assets/index-DCqVyTsd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DWXFEfty.css">
</head>
<body class="min-h-screen">
<div id="root"></div>
</body>
</html>
+21 -3
View File
@@ -11,12 +11,14 @@ import subprocess
import threading import threading
import time import time
import urllib.request import urllib.request
import mimetypes
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
PORT = int(os.environ.get("PORT", "3004")) PORT = int(os.environ.get("PORT", "3004"))
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "dist"
INDEX_FILE = BASE_DIR / "index.html" INDEX_FILE = BASE_DIR / "index.html"
_cache = {"data": None, "ts": 0.0} _cache = {"data": None, "ts": 0.0}
@@ -285,9 +287,25 @@ class PortalHandler(BaseHTTPRequestHandler):
self._send_json(collect_telemetry()) self._send_json(collect_telemetry())
return return
# Serve SPA index.html for root and any path # Serve static assets from Vite build output (dist/) if available
if INDEX_FILE.exists(): clean_path = self.path.split("?", 1)[0].lstrip("/")
body = INDEX_FILE.read_bytes() if clean_path and DIST_DIR.exists():
asset_file = (DIST_DIR / clean_path).resolve()
if str(asset_file).startswith(str(DIST_DIR.resolve())) and asset_file.is_file():
body = asset_file.read_bytes()
mime, _ = mimetypes.guess_type(str(asset_file))
self.send_response(200)
self.send_header("Content-Type", mime or "application/octet-stream")
self.send_header("Cache-Control", "public, max-age=86400")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
# Serve SPA index.html from dist/index.html (React+Vite build) or fallback index.html
spa_index = (DIST_DIR / "index.html") if (DIST_DIR / "index.html").exists() else INDEX_FILE
if spa_index.exists():
body = spa_index.read_bytes()
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
+19
View File
@@ -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>
+2635
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -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"
}
}
+230
View File
@@ -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>
);
};
+170
View File
@@ -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);
}
+10
View File
@@ -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';
},
}));
+84
View File
@@ -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[];
}
+17
View File
@@ -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"]
}
+16
View File
@@ -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',
},
},
});