XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
import { useEffect, useState, type ReactNode } from 'react';
import {
  Activity,
  Ban,
  Blocks,
  CalendarClock,
  ChevronRight,
  ClipboardList,
  Coins,
  Command,
  Languages,
  Menu,
  PanelsTopLeft,
  Workflow,
  RotateCcw,
  ScrollText,
  Settings,
  ShieldCheck,
  Users,
  X,
} from 'lucide-react';
import { useI18n } from '../lib/i18n';
import { canAccessRoute } from '../lib/permissions';
import { useServer } from '../context/server-context';
import { Badge, Button } from './ui';

export type RouteId = 'overview' | 'players' | 'economy' | 'triggers' | 'menus' | 'policies' | 'audit' | 'console' | 'settings' | 'moderation' | 'maintenance' | 'claims' | 'rollback';

const routeSet = new Set<RouteId>(['overview', 'players', 'economy', 'triggers', 'menus', 'policies', 'audit', 'console', 'settings', 'moderation', 'maintenance', 'claims', 'rollback']);

export function currentRoute(): RouteId {
  const value = typeof location === 'undefined' ? '' : location.hash.replace(/^#\/?/, '').split('/')[0];
  if (value === 'messages') return 'triggers'; // Preserve bookmarks from the retired message-settings page.
  return routeSet.has(value as RouteId) ? value as RouteId : 'overview';
}

export function useRoute(): [RouteId, (route: RouteId) => void] {
  const [route, setRoute] = useState<RouteId>(currentRoute);
  useEffect(() => {
    const change = () => setRoute(currentRoute());
    window.addEventListener('hashchange', change);
    return () => window.removeEventListener('hashchange', change);
  }, []);
  return [route, (next) => {
    if (next === currentRoute()) setRoute(next);
    else location.hash = `/${next}`;
  }];
}

const coreNavigation = [
  { id: 'overview', label: 'nav.overview', icon: Activity },
  { id: 'players', label: 'nav.players', icon: Users },
  { id: 'economy', label: 'nav.economy', icon: Coins },
  { id: 'triggers', label: 'nav.messages', icon: Workflow },
  { id: 'menus', label: 'nav.menus', icon: PanelsTopLeft },
  { id: 'policies', label: 'nav.policies', icon: ShieldCheck },
  { id: 'audit', label: 'nav.audit', icon: ScrollText },
  { id: 'console', label: 'nav.console', icon: Command },
  { id: 'settings', label: 'nav.settings', icon: Settings },
] as const;

const governanceNavigation = [
  { id: 'moderation', label: 'nav.moderation', icon: Ban },
  { id: 'maintenance', label: 'nav.maintenance', icon: CalendarClock },
  { id: 'claims', label: 'nav.claims', icon: Blocks },
  { id: 'rollback', label: 'nav.rollback', icon: RotateCcw },
] as const;

function ConnectionPill() {
  const { connection, lastEventAt } = useServer();
  const { t, locale } = useI18n();
  const labels = {
    online: t('connection.online'),
    connecting: t('connection.connecting'),
    offline: t('connection.offline'),
    degraded: t('connection.degraded'),
  };
  return (
    <div className={`connection-pill connection-pill--${connection}`} title={lastEventAt?.toLocaleString(locale)}>
      <span className="connection-pill__dot" />
      <span>{labels[connection]}</span>
    </div>
  );
}

function Navigation({ route, navigate, onNavigate }: {
  route: RouteId;
  navigate: (route: RouteId) => void;
  onNavigate?: () => void;
}) {
  const { t } = useI18n();
  const { session } = useServer();
  const section = (label: 'nav.core' | 'nav.governance', entries: typeof coreNavigation | typeof governanceNavigation) => {
    const permitted = entries.filter((entry) => canAccessRoute(session?.actor, entry.id));
    if (permitted.length === 0) return null;
    return (
    <div className="nav-section">
      <span className="nav-section__label">{t(label)}</span>
      <nav aria-label={t(label)}>
        {permitted.map((entry) => {
          const Icon = entry.icon;
          const active = route === entry.id;
          return (
            <button
              className={`nav-link ${active ? 'nav-link--active' : ''}`}
              key={entry.id}
              onClick={() => { navigate(entry.id); onNavigate?.(); }}
              aria-current={active ? 'page' : undefined}
            >
              <Icon size={18} />
              <span>{t(entry.label)}</span>
              {active && <ChevronRight className="nav-link__arrow" size={15} />}
            </button>
          );
        })}
      </nav>
    </div>
    );
  };
  return <>{section('nav.core', coreNavigation)}{section('nav.governance', governanceNavigation)}</>;
}

export function AppShell({ route, navigate, children }: {
  route: RouteId;
  navigate: (route: RouteId) => void;
  children: ReactNode;
}) {
  const [mobileOpen, setMobileOpen] = useState(false);
  const { locale, setLocale, t } = useI18n();
  const { session, connection, lastError, refresh, logout, operations } = useServer();

  return (
    <div className="app-shell">
      <aside className={`sidebar ${mobileOpen ? 'sidebar--open' : ''}`}>
        <div className="brand">
          <div className="brand__mark" aria-hidden="true"><span /><span /><span /></div>
          <div><strong>{t('app.name')}</strong><small>{t('app.subtitle')}</small></div>
          <button className="sidebar__close" onClick={() => setMobileOpen(false)} aria-label={t('common.close')}><X /></button>
        </div>
        <ConnectionPill />
        <div className="sidebar__navigation"><Navigation route={route} navigate={navigate} onNavigate={() => setMobileOpen(false)} /></div>
        <div className="sidebar__footer">
          {session?.actor && (
            <div className="actor">
              <div className="actor__avatar">{session.actor.displayName.slice(0, 2).toUpperCase()}</div>
              <div><strong>{session.actor.displayName}</strong><small>{session.actor.roles.join(' · ')}</small></div>
            </div>
          )}
          <button className="language-button" onClick={() => setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')}>
            <Languages size={16} /> {locale === 'zh-CN' ? 'English' : '简体中文'}
          </button>
        </div>
      </aside>
      {mobileOpen && <button className="sidebar-backdrop" aria-label={t('common.close')} onClick={() => setMobileOpen(false)} />}
      <div className="workspace">
        <header className="topbar">
          <button className="menu-button" onClick={() => setMobileOpen(true)} aria-label="Menu"><Menu /></button>
          <div className="topbar__server">
            <span className="topbar__pulse" />
            <span>{connection === 'online' ? t('connection.online') : t(`connection.${connection}`)}</span>
          </div>
          <div className="topbar__actions">
            {operations.some((item) => item.state === 'QUEUED' || item.state === 'RUNNING') && (
              <Badge tone="info"><ClipboardList size={13} />{operations.filter((item) => item.state === 'QUEUED' || item.state === 'RUNNING').length}</Badge>
            )}
            <button className="language-button language-button--top" onClick={() => setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')}>
              <Languages size={16} /><span>{locale === 'zh-CN' ? 'EN' : '中文'}</span>
            </button>
            {session?.authenticated && <Button variant="ghost" onClick={() => void logout()}>{t('common.signOut')}</Button>}
          </div>
        </header>
        {connection === 'offline' && (
          <div className="offline-banner" role="alert">
            <div><strong>{t('connection.offline')}</strong><span>{lastError ?? t('connection.offlineDetail')}</span></div>
            <Button variant="secondary" onClick={() => void refresh()}>{t('connection.retry')}</Button>
          </div>
        )}
        <main className="main-content">{children}</main>
      </div>
    </div>
  );
}