first commit
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Bot,
|
||||
Heart,
|
||||
LayoutDashboard,
|
||||
PackageSearch,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
UserRoundPen,
|
||||
WandSparkles,
|
||||
Wrench
|
||||
} from "lucide-react";
|
||||
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import type { PageId, PageParams, PageRoute } from "../contracts/page";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import { MagicalParticleLayer } from "./MagicalParticleLayer";
|
||||
import {
|
||||
applyBackgroundImage,
|
||||
applyThemeBackgroundPreset,
|
||||
applyThemePalette,
|
||||
loadThemeState,
|
||||
themePaletteChangeEvent,
|
||||
themePalettes,
|
||||
themeTokens,
|
||||
type ThemePaletteChangeDetail,
|
||||
type WorkspaceThemeState
|
||||
} from "../theme/tokens";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
interface AppShellProps {
|
||||
routes: PageRoute[];
|
||||
currentPage: PageId;
|
||||
session: CurrentUserView;
|
||||
onNavigate: (pageId: PageId, params?: PageParams) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
routeIds: PageId[];
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
}
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{ id: "overview", label: "平台概览", routeIds: ["home"], icon: LayoutDashboard },
|
||||
{ id: "servers", label: "服务器管理", routeIds: ["servers"], icon: ServerCog },
|
||||
{ id: "plugins", label: "插件市场", routeIds: ["plugins"], icon: PackageSearch },
|
||||
{ id: "users", label: "用户管理", routeIds: ["users"], icon: ShieldCheck },
|
||||
{ id: "ai", label: "AI 提供商管理", routeIds: ["aiProviders"], icon: Bot },
|
||||
{ id: "tools", label: "系统工具", routeIds: ["maintenance"], icon: Wrench }
|
||||
];
|
||||
|
||||
const roleLabels: Record<CurrentUserView["roles"][number], string> = {
|
||||
platformAdmin: "平台管理员",
|
||||
serverOwner: "服主",
|
||||
serverAdmin: "服务器管理员"
|
||||
};
|
||||
|
||||
export function AppShell({ routes, currentPage, session, onNavigate, children }: AppShellProps) {
|
||||
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleThemePaletteChange(event: Event) {
|
||||
const paletteId = (event as CustomEvent<ThemePaletteChangeDetail>).detail?.paletteId;
|
||||
if (!paletteId) {
|
||||
return;
|
||||
}
|
||||
setThemeState((current) => (current.paletteId === paletteId ? current : { ...current, paletteId }));
|
||||
}
|
||||
|
||||
window.addEventListener(themePaletteChangeEvent, handleThemePaletteChange);
|
||||
const stored = loadThemeState();
|
||||
applyThemePalette(stored.paletteId);
|
||||
applyThemeBackgroundPreset(stored.backgroundPresetId);
|
||||
applyBackgroundImage(stored.backgroundImage);
|
||||
setThemeState(stored);
|
||||
return () => window.removeEventListener(themePaletteChangeEvent, handleThemePaletteChange);
|
||||
}, []);
|
||||
|
||||
const activePalette = themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0];
|
||||
const routesById = new Map(routes.map((route) => [route.id, route]));
|
||||
const visibleGroups = menuGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
routes: group.routeIds.map((routeId) => routesById.get(routeId)).filter((route): route is PageRoute => Boolean(route))
|
||||
}))
|
||||
.filter((group) => group.routes.length > 0);
|
||||
|
||||
function activateGroup(group: (typeof visibleGroups)[number]) {
|
||||
if (group.routes[0].id !== currentPage) {
|
||||
onNavigate(group.routes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed")}>
|
||||
<MagicalParticleLayer />
|
||||
<aside className="app-sidebar">
|
||||
<div className="app-brand" aria-label={themeTokens.appName}>
|
||||
<span className="app-brand-mark">
|
||||
<WandSparkles size={17} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{themeTokens.appName}</strong>
|
||||
<span className="app-brand-subtitle">{activePalette.label} / OPS</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-menu-toolbar" aria-label="sidebar mode">
|
||||
<span>导航</span>
|
||||
<button type="button" className="app-menu-toggle" aria-label={isSidebarCollapsed ? "展开完整菜单栏" : "收起为图标栏"} aria-pressed={isSidebarCollapsed} onClick={() => setIsSidebarCollapsed((current) => !current)}>
|
||||
{isSidebarCollapsed ? <PanelRight size={18} /> : <PanelLeft size={18} />}
|
||||
<span>{isSidebarCollapsed ? "展开" : "收起"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="app-nav" aria-label="primary">
|
||||
{visibleGroups.map((group) => {
|
||||
const Icon = group.icon;
|
||||
const isActive = group.routes.some((route) => route.id === currentPage);
|
||||
return (
|
||||
<section key={group.id} className={cx("app-nav-group", isActive && "app-nav-group-active")}>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("app-nav-group-button", isActive && "app-nav-item-active")}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
title={group.label}
|
||||
onClick={() => activateGroup(group)}
|
||||
>
|
||||
<span className="app-nav-icon" aria-hidden="true">
|
||||
<Icon size={22} />
|
||||
</span>
|
||||
<span className="app-nav-copy">
|
||||
<strong>{group.label}</strong>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="app-session">
|
||||
<button type="button" className="app-account-button" aria-current={currentPage === "profileSettings" ? "page" : undefined} onClick={() => onNavigate("profileSettings")}>
|
||||
<span className="account-avatar" aria-hidden="true">
|
||||
{session.profile.avatarUrl ? <img src={session.profile.avatarUrl} alt="" /> : <Heart size={17} />}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{session.displayName}</strong>
|
||||
<span className="provider-id">{session.roles.map((role) => roleLabels[role]).join(" / ")}</span>
|
||||
</span>
|
||||
<UserRoundPen className="account-chevron" size={16} />
|
||||
</button>
|
||||
<div className="palette-mini-strip" aria-label={`当前配色 ${activePalette.label}`}>
|
||||
{activePalette.swatches.map((swatch) => (
|
||||
<span key={swatch} style={{ background: swatch }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { HeartHandshake, Loader2, MoonStar, Sparkles, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import type { SessionState } from "../stores/session";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
interface AuthViewProps {
|
||||
session: SessionState;
|
||||
}
|
||||
|
||||
export function AuthView({ session }: AuthViewProps) {
|
||||
const isRegister = session.auth.mode === "register";
|
||||
const [draft, setDraft] = useState({
|
||||
account: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
phone: "",
|
||||
qq: ""
|
||||
});
|
||||
|
||||
function updateDraft(key: keyof typeof draft, value: string) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isRegister) {
|
||||
await session.register({
|
||||
displayName: draft.displayName.trim() || draft.account.trim() || "待审核玩家",
|
||||
email: draft.email.trim(),
|
||||
password: draft.password,
|
||||
phone: draft.phone.trim(),
|
||||
qq: draft.qq.trim()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await session.login({ account: draft.account.trim(), password: draft.password });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-card" aria-label={isRegister ? "注册账号" : "登录账号"}>
|
||||
<div className="auth-brand">
|
||||
<span className="app-brand-mark">
|
||||
<WandSparkles size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="page-kicker">Mecha Ops Console</p>
|
||||
<h1>{isRegister ? "申请进入服务器工作台" : "登录机甲运维工作台"}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className="auth-copy">
|
||||
{isRegister
|
||||
? "新账号默认进入待审核或服务器范围,不会获得平台管理员权限。"
|
||||
: "登录后会按你的角色进入默认工作区:平台管理员看概览,服务器用户看服务器列表。"}
|
||||
</p>
|
||||
|
||||
<div className="auth-mode-tabs" role="tablist" aria-label="认证方式">
|
||||
<button type="button" className={cx(!isRegister && "auth-mode-active")} onClick={() => session.switchAuthMode("login")}>
|
||||
<MoonStar size={14} />
|
||||
<span>登录</span>
|
||||
</button>
|
||||
<button type="button" className={cx(isRegister && "auth-mode-active")} onClick={() => session.switchAuthMode("register")}>
|
||||
<HeartHandshake size={14} />
|
||||
<span>注册</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
{isRegister ? (
|
||||
<>
|
||||
<label>
|
||||
<span>昵称</span>
|
||||
<input value={draft.displayName} required onChange={(event) => updateDraft("displayName", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>邮箱</span>
|
||||
<input value={draft.email} type="email" required onChange={(event) => updateDraft("email", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>手机号</span>
|
||||
<input value={draft.phone} inputMode="tel" onChange={(event) => updateDraft("phone", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>QQ</span>
|
||||
<input value={draft.qq} inputMode="numeric" onChange={(event) => updateDraft("qq", event.target.value)} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label>
|
||||
<span>账号 / 邮箱</span>
|
||||
<input value={draft.account} required onChange={(event) => updateDraft("account", event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input value={draft.password} type="password" required minLength={6} onChange={(event) => updateDraft("password", event.target.value)} />
|
||||
</label>
|
||||
{session.auth.error && (
|
||||
<div className="auth-result auth-result-error" role="alert">
|
||||
<strong>操作失败</strong>
|
||||
<span>{session.auth.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.auth.success && (
|
||||
<div className="auth-result auth-result-success" role="status">
|
||||
<strong>已记录</strong>
|
||||
<span>{session.auth.success}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.authUnavailable && session.localFallbackAvailable && (
|
||||
<div className="auth-result" role="status">
|
||||
<strong>本地回退可用</strong>
|
||||
<span>当前身份 API 不可用,可以进入服务器范围的本地演示工作台进行 UI 验收。</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="profile-save-button auth-submit" disabled={session.auth.pending}>
|
||||
{session.auth.pending ? <Loader2 size={14} className="state-spinner" /> : <Sparkles size={14} />}
|
||||
<span>{isRegister ? "提交注册" : "登录"}</span>
|
||||
</button>
|
||||
{session.authUnavailable && session.localFallbackAvailable && (
|
||||
<button type="button" className="state-action auth-local-action" onClick={session.continueWithLocalFallback}>
|
||||
<MoonStar size={14} />
|
||||
<span>进入本地回退工作台</span>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
const particleSlots = [
|
||||
{ x: "12%", y: "16%" },
|
||||
{ x: "31%", y: "24%" },
|
||||
{ x: "72%", y: "12%" },
|
||||
{ x: "88%", y: "36%" },
|
||||
{ x: "18%", y: "58%" },
|
||||
{ x: "46%", y: "66%" },
|
||||
{ x: "78%", y: "74%" },
|
||||
{ x: "92%", y: "86%" }
|
||||
];
|
||||
|
||||
type ParticleStyle = CSSProperties & {
|
||||
"--particle-index": string;
|
||||
"--particle-x": string;
|
||||
"--particle-y": string;
|
||||
};
|
||||
|
||||
export function MagicalParticleLayer() {
|
||||
return (
|
||||
<>
|
||||
<div className="workspace-background-layer" aria-hidden="true" />
|
||||
<div className="global-particle-layer" aria-hidden="true">
|
||||
<span className="global-particle-orbit" />
|
||||
<span className="global-particle-orbit global-particle-orbit-secondary" />
|
||||
<span className="global-particle-sweep" />
|
||||
<span className="global-particle-ribbon" />
|
||||
<span className="global-particle-frame global-particle-frame-tl" />
|
||||
<span className="global-particle-frame global-particle-frame-br" />
|
||||
{particleSlots.map((slot, index) => (
|
||||
<span
|
||||
key={`${slot.x}-${slot.y}`}
|
||||
className="global-particle-glint"
|
||||
style={{ "--particle-index": String(index), "--particle-x": slot.x, "--particle-y": slot.y } as ParticleStyle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, onConfirm, onCancel, children }: ConfirmDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="confirm-backdrop" role="presentation" onClick={onCancel}>
|
||||
<div className="confirm-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={onCancel} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className={danger ? "confirm-danger" : "confirm-primary"} onClick={onConfirm} disabled={busy}>
|
||||
{busy ? "提交中…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsageMeterProps {
|
||||
label: string;
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
export function UsageMeter({ label, percent }: UsageMeterProps) {
|
||||
const known = typeof percent === "number" && Number.isFinite(percent);
|
||||
const clamped = known ? Math.max(0, Math.min(100, percent)) : 0;
|
||||
return (
|
||||
<div className="usage-meter">
|
||||
<span>{label}</span>
|
||||
<span className="usage-meter-track" role="img" aria-label={known ? `${label} ${Math.round(clamped)}%` : `${label} 暂无数据`}>
|
||||
{known && <span className={`usage-meter-fill${clamped >= 85 ? " usage-high" : ""}`} style={{ width: `${clamped}%` }} />}
|
||||
</span>
|
||||
<span className="usage-meter-value">{known ? `${Math.round(clamped)}%` : "--"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffViewProps {
|
||||
lines: Array<{ kind: "same" | "added" | "removed"; text: string }>;
|
||||
}
|
||||
|
||||
export function DiffView({ lines }: DiffViewProps) {
|
||||
return (
|
||||
<div className="diff-view" role="figure" aria-label="配置变更对比">
|
||||
{lines.map((line, index) => (
|
||||
<span key={`${index}-${line.text}`} className={`diff-line diff-line-${line.kind}`}>
|
||||
<span aria-hidden="true">{line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "}</span>
|
||||
<span>{line.text || " "}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MoonStar } from "lucide-react";
|
||||
|
||||
import type { PageMetric } from "../contracts/page";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
interface PageFrameProps {
|
||||
kicker: string;
|
||||
title: string;
|
||||
status: string;
|
||||
metrics: PageMetric[];
|
||||
}
|
||||
|
||||
export function PageFrame({ kicker, title, status, metrics }: PageFrameProps) {
|
||||
return (
|
||||
<section className="page-frame" aria-labelledby="page-title">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<p className="page-kicker">{kicker}</p>
|
||||
<h1 id="page-title" className="page-title">
|
||||
<MoonStar size={22} aria-hidden="true" />
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<span className="page-status">{status}</span>
|
||||
</header>
|
||||
<div className="metric-grid">
|
||||
{metrics.map((metric) => (
|
||||
<article key={metric.label} className={cx("metric-card", `metric-tone-${metric.tone}`)}>
|
||||
<span className="metric-label">{metric.label}</span>
|
||||
<strong className="metric-value">{metric.value}</strong>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { AlertTriangle, CheckCircle2, Copy, Loader2, MoonStar, Sparkles, XCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, actionLabel, onAction, icon }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="state-view state-empty" role="status">
|
||||
<span className="state-icon" aria-hidden="true">
|
||||
{icon ?? <MoonStar size={26} />}
|
||||
</span>
|
||||
<strong>{title}</strong>
|
||||
<p>{description}</p>
|
||||
{actionLabel && onAction && (
|
||||
<button type="button" className="state-action" onClick={onAction}>
|
||||
<Sparkles size={14} />
|
||||
<span>{actionLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingStateProps {
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingState({ label, compact }: LoadingStateProps) {
|
||||
return (
|
||||
<div className={cx("state-view state-loading", compact && "state-compact")} role="status" aria-live="polite">
|
||||
<Loader2 size={compact ? 16 : 22} className="state-spinner" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorStateProps {
|
||||
title: string;
|
||||
reason?: string;
|
||||
diagnosticId?: string;
|
||||
onRetry?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ErrorState({ title, reason, diagnosticId, onRetry, compact }: ErrorStateProps) {
|
||||
return (
|
||||
<div className={cx("state-view state-error", compact && "state-compact")} role="alert">
|
||||
<span className="state-icon" aria-hidden="true">
|
||||
<AlertTriangle size={compact ? 16 : 24} />
|
||||
</span>
|
||||
<strong>{title}</strong>
|
||||
{reason && <p>{reason}</p>}
|
||||
{diagnosticId && <DiagnosticSummary diagnosticId={diagnosticId} />}
|
||||
{onRetry && (
|
||||
<button type="button" className="state-action" onClick={onRetry}>
|
||||
<Sparkles size={14} />
|
||||
<span>重试</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResultBadgeProps {
|
||||
status: "pending" | "succeeded" | "failed";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ResultBadge({ status, label }: ResultBadgeProps) {
|
||||
const icon =
|
||||
status === "pending" ? <Loader2 size={13} className="state-spinner" /> : status === "succeeded" ? <CheckCircle2 size={13} /> : <XCircle size={13} />;
|
||||
return (
|
||||
<span className={cx("result-badge", `result-badge-${status}`)}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiagnosticSummaryProps {
|
||||
diagnosticId: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function DiagnosticSummary({ diagnosticId, detail }: DiagnosticSummaryProps) {
|
||||
const summary = detail ? `${diagnosticId} ${detail}` : diagnosticId;
|
||||
return (
|
||||
<span className="diagnostic-summary">
|
||||
<code>{diagnosticId}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="diagnostic-copy"
|
||||
title="复制诊断信息"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(summary);
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<span>复制</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user