Files
aivideo/frontend-web/src/components/site-shell.tsx

223 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import {
Activity,
Coins,
FolderKanban,
ImagePlus,
LogOut,
Sparkles,
Ticket,
UserRound,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { api } from "@/lib/api";
import type { UserProfile } from "@/lib/types";
const navigation = [
{
href: "/workspace/create",
label: "工作台",
description: "生成参数、提示词与素材联动",
icon: Sparkles,
},
{
href: "/workspace/tasks",
label: "任务记录",
description: "查看队列、状态轮询与结果视频",
icon: FolderKanban,
},
{
href: "/workspace/assets",
label: "素材管理",
description: "上传图片、视频和音频素材",
icon: ImagePlus,
},
{
href: "/wallet",
label: "钱包概览",
description: "查看可用积分和冻结明细",
icon: Coins,
},
{
href: "/wallet/recharge",
label: "充值中心",
description: "采购积分,补足生产额度",
icon: Coins,
},
{
href: "/wallet/redeem",
label: "兑换密钥",
description: "使用兑换码快速充值",
icon: Ticket,
},
{
href: "/invite",
label: "邀请中心",
description: "管理关系链与推广奖励",
icon: Users,
},
{
href: "/profile",
label: "个人资料",
description: "维护账户信息与展示名",
icon: UserRound,
},
];
function matchesPath(pathname: string, href: string) {
return pathname === href || pathname.startsWith(`${href}/`);
}
export function SiteShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { data, error, isLoading } = useQuery({
queryKey: ["web-me"],
queryFn: () => api.get<UserProfile>("/api/v1/auth/me"),
});
useEffect(() => {
if (error) {
router.replace("/login");
}
}, [error, router]);
const title = useMemo(() => {
const current = [...navigation]
.sort((left, right) => right.href.length - left.href.length)
.find((item) => matchesPath(pathname, item.href));
return current ?? navigation[0];
}, [pathname]);
if (isLoading || !data) {
return (
<div className="fullscreen-shell">
<div className="pulse-card">...</div>
</div>
);
}
return (
<div className="shell-grid shell-grid-studio">
<aside className="shell-sidebar">
<div className="brand-block brand-block-studio">
<div className="brand-visual" aria-hidden="true">
<span className="brand-orb brand-orb-primary" />
<span className="brand-orb brand-orb-secondary" />
</div>
<span className="brand-kicker">AI VIDEO STUDIO</span>
<h1>AIVideo</h1>
<p> AI </p>
<div className="brand-pill-row">
<span className="brand-pill">Creative matrix</span>
<span className="brand-pill">Mock providers</span>
<span className="brand-pill">Local assets</span>
</div>
<div className="brand-status">
<span className="status-dot" />
线
</div>
</div>
<div className="sidebar-note">
<div className="sidebar-note-label">Capability Grid</div>
<div className="sidebar-metrics">
<div className="sidebar-metric">
<strong>Prompt</strong>
<span></span>
</div>
<div className="sidebar-metric">
<strong>Assets</strong>
<span></span>
</div>
<div className="sidebar-metric">
<strong>Tasks</strong>
<span></span>
</div>
</div>
</div>
<nav className="sidebar-nav">
{navigation.map((item, index) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={clsx("nav-item", {
active: title.href === item.href,
})}
>
<Icon size={18} />
<div>
<span>{item.label}</span>
<small>{item.description}</small>
</div>
<div className="nav-index">{String(index + 1).padStart(2, "0")}</div>
</Link>
);
})}
</nav>
<div className="profile-card">
<div className="profile-card-main">
<div className="profile-avatar">
{(data.nickname || data.username || "AI")
.slice(0, 2)
.toUpperCase()}
</div>
<div className="profile-name">{data.nickname || data.username}</div>
<div className="profile-meta">{data.email}</div>
</div>
<div className="profile-card-stats">
<div className="profile-stat">
<span></span>
<strong>Creator</strong>
</div>
<div className="profile-stat">
<span>ID</span>
<strong>{(data.publicId || "AIVIDEO").slice(-8)}</strong>
</div>
</div>
<button
className="ghost-button"
onClick={async () => {
await api.post("/api/v1/auth/logout");
router.replace("/login");
}}
>
<LogOut size={16} />
退
</button>
</div>
</aside>
<main className="shell-main">
<header className="shell-header">
<div className="shell-header-content">
<div className="header-kicker">Creative Ops Console</div>
<h2>{title.label}</h2>
<p className="shell-header-copy">{title.description}</p>
</div>
<div className="shell-header-meta">
<div className="header-chip header-chip-success">
<Activity size={14} />
</div>
<div className="header-chip">{data.publicId || "UNAVAILABLE"}</div>
<div className="header-chip"> / / mock </div>
</div>
</header>
<section className="shell-content">{children}</section>
</main>
</div>
);
}