feat: initialize aivideo project

This commit is contained in:
2026-04-17 18:33:05 +08:00
commit 14b18d67fe
162 changed files with 26251 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
"use client";
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 2_000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { startTransition, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api, ApiError } from "@/lib/api";
export function RegisterForm({ inviteCode = "" }: { inviteCode?: string }) {
const router = useRouter();
const [form, setForm] = useState({
account: "",
password: "12345678",
inviteCode,
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError("");
try {
await api.post("/api/v1/auth/register", {
account: form.account,
password: form.password,
invite_code: form.inviteCode || undefined,
});
startTransition(() => router.replace("/workspace/create"));
} catch (err) {
setError(err instanceof ApiError ? err.message : "注册失败");
} finally {
setLoading(false);
}
}
return (
<div className="auth-grid">
<section className="auth-hero">
<div>
<div className="eyebrow">New Account</div>
<h1 className="headline"></h1>
<p className="subcopy">
</p>
</div>
<div className="panel">
<h3></h3>
<p className="muted"></p>
</div>
</section>
<section className="fullscreen-shell">
<form className="auth-card" onSubmit={handleSubmit}>
<div className="eyebrow">Create Account</div>
<h2 style={{ marginTop: 8 }}></h2>
<div className="form-stack">
<label className="field-label">
<input
placeholder="user@example.com"
value={form.account}
onChange={(event) =>
setForm((previous) => ({ ...previous, account: event.target.value }))
}
/>
</label>
<label className="field-label">
<input
type="password"
value={form.password}
onChange={(event) =>
setForm((previous) => ({ ...previous, password: event.target.value }))
}
/>
</label>
<label className="field-label">
<input
value={form.inviteCode}
onChange={(event) =>
setForm((previous) => ({ ...previous, inviteCode: event.target.value }))
}
/>
</label>
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
<button className="primary-button" disabled={loading} type="submit">
{loading ? "注册中..." : "注册并领取初始积分"}
</button>
<Link href="/login" className="ghost-button">
</Link>
</div>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import {
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: "创建任务", icon: Sparkles },
{ href: "/workspace/tasks", label: "任务记录", icon: FolderKanban },
{ href: "/workspace/assets", label: "素材管理", icon: ImagePlus },
{ href: "/wallet", label: "钱包概览", icon: Coins },
{ href: "/wallet/recharge", label: "充值中心", icon: Coins },
{ href: "/wallet/redeem", label: "兑换密钥", icon: Ticket },
{ href: "/invite", label: "邀请中心", icon: Users },
{ href: "/profile", label: "个人资料", icon: UserRound },
];
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.find((item) => pathname.startsWith(item.href));
return current?.label ?? "AIVideo";
}, [pathname]);
if (isLoading || !data) {
return (
<div className="fullscreen-shell">
<div className="pulse-card">...</div>
</div>
);
}
return (
<div className="shell-grid">
<aside className="shell-sidebar">
<div className="brand-block">
<span className="brand-kicker">AI VIDEO PLATFORM</span>
<h1>AIVideo</h1>
<p></p>
</div>
<nav className="sidebar-nav">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={clsx("nav-item", {
active: pathname.startsWith(item.href),
})}
>
<Icon size={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="profile-card">
<div>
<div className="profile-name">{data.nickname || data.username}</div>
<div className="profile-meta">{data.email}</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>
<div className="header-kicker">Creative Ops</div>
<h2>{title}</h2>
</div>
<div className="header-chip">{data.publicId}</div>
</header>
<section className="shell-content">{children}</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import clsx from "clsx";
const statusMap: Record<string, string> = {
queued: "soft",
submitted: "soft",
running: "warn",
succeeded: "success",
failed: "danger",
cancelled: "ghost",
pending: "soft",
paid: "success",
unused: "success",
used: "ghost",
disabled: "danger",
};
export function StatusBadge({ value }: { value: string }) {
const tone = statusMap[value] ?? "soft";
return (
<span
className={clsx("status-badge", {
"tone-soft": tone === "soft",
"tone-success": tone === "success",
"tone-warn": tone === "warn",
"tone-danger": tone === "danger",
"tone-ghost": tone === "ghost",
})}
>
{value}
</span>
);
}