223 lines
6.6 KiB
TypeScript
223 lines
6.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|