feat: initialize aivideo project
This commit is contained in:
26
frontend-web/src/components/providers.tsx
Normal file
26
frontend-web/src/components/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
100
frontend-web/src/components/register-form.tsx
Normal file
100
frontend-web/src/components/register-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
118
frontend-web/src/components/site-shell.tsx
Normal file
118
frontend-web/src/components/site-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
33
frontend-web/src/components/status-badge.tsx
Normal file
33
frontend-web/src/components/status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user