feat: redesign user workspace console

This commit is contained in:
2026-04-22 11:34:25 +08:00
parent 14b18d67fe
commit 745f6f07db
7 changed files with 2017 additions and 331 deletions

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { UserProfile } from "@/lib/types"; import type { UserProfile } from "@/lib/types";
@@ -12,29 +12,25 @@ export default function ProfilePage() {
queryFn: () => api.get<UserProfile>("/api/v1/profile"), queryFn: () => api.get<UserProfile>("/api/v1/profile"),
}); });
const [form, setForm] = useState({ const [form, setForm] = useState({
username: "", username: undefined as string | undefined,
nickname: "", nickname: undefined as string | undefined,
avatarUrl: "",
}); });
const username = form.username ?? profileQuery.data?.username ?? "";
useEffect(() => { const nickname = form.nickname ?? profileQuery.data?.nickname ?? "";
if (profileQuery.data) {
setForm({
username: profileQuery.data.username,
nickname: profileQuery.data.nickname,
avatarUrl: profileQuery.data.avatarUrl,
});
}
}, [profileQuery.data]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
api.put("/api/v1/profile", { api.put("/api/v1/profile", {
username: form.username, username,
nickname: form.nickname, nickname,
avatar_url: form.avatarUrl,
}), }),
onSuccess: () => profileQuery.refetch(), onSuccess: () => {
setForm({
username: undefined,
nickname: undefined,
});
void profileQuery.refetch();
},
}); });
return ( return (
@@ -45,7 +41,7 @@ export default function ProfilePage() {
<label className="field-label"> <label className="field-label">
<input <input
value={form.username} value={username}
onChange={(event) => onChange={(event) =>
setForm((previous) => ({ ...previous, username: event.target.value })) setForm((previous) => ({ ...previous, username: event.target.value }))
} }
@@ -54,21 +50,12 @@ export default function ProfilePage() {
<label className="field-label"> <label className="field-label">
<input <input
value={form.nickname} value={nickname}
onChange={(event) => onChange={(event) =>
setForm((previous) => ({ ...previous, nickname: event.target.value })) setForm((previous) => ({ ...previous, nickname: event.target.value }))
} }
/> />
</label> </label>
<label className="field-label">
URL
<input
value={form.avatarUrl}
onChange={(event) =>
setForm((previous) => ({ ...previous, avatarUrl: event.target.value }))
}
/>
</label>
<button className="primary-button" onClick={() => mutation.mutate()}> <button className="primary-button" onClick={() => mutation.mutate()}>
</button> </button>
@@ -78,6 +65,17 @@ export default function ProfilePage() {
<section className="panel"> <section className="panel">
<h3></h3> <h3></h3>
<div className="list-grid"> <div className="list-grid">
<div className="list-item profile-overview-card">
<div className="profile-avatar profile-avatar-large">
{(profileQuery.data?.nickname || profileQuery.data?.username || "AI")
.slice(0, 2)
.toUpperCase()}
</div>
<div>
<strong>{profileQuery.data?.nickname || profileQuery.data?.username}</strong>
<div className="muted"> URL</div>
</div>
</div>
<div className="list-item"> <div className="list-item">
<strong>{profileQuery.data?.publicId}</strong> <strong>{profileQuery.data?.publicId}</strong>
<div className="muted"> ID</div> <div className="muted"> ID</div>

View File

@@ -1,86 +1,295 @@
"use client"; "use client";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react"; import clsx from "clsx";
import dayjs from "dayjs";
import { AudioLines, LoaderCircle, Search, Trash2, UploadCloud, Video } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/lib/api"; import { api, ApiError } from "@/lib/api";
import type { AssetItem } from "@/lib/types"; import type { AssetItem } from "@/lib/types";
type MediaType = "image" | "video" | "audio";
const mediaTypes = [
{ value: "image", label: "图片", accept: "image/*" },
{ value: "video", label: "视频", accept: "video/*" },
{ value: "audio", label: "音频", accept: "audio/*" },
] as const satisfies Array<{
value: MediaType;
label: string;
accept: string;
}>;
function formatBytes(fileSize: number) {
if (fileSize < 1024) {
return `${fileSize} B`;
}
if (fileSize < 1024 * 1024) {
return `${Math.round(fileSize / 1024)} KB`;
}
return `${(fileSize / 1024 / 1024).toFixed(1)} MB`;
}
function getErrorMessage(error: unknown, fallback: string) {
return error instanceof ApiError ? error.message : fallback;
}
export default function AssetsPage() { export default function AssetsPage() {
const [mediaType, setMediaType] = useState("image"); const [mediaType, setMediaType] = useState<MediaType>("image");
const [libraryType, setLibraryType] = useState<"all" | MediaType>("all");
const [keyword, setKeyword] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [feedback, setFeedback] = useState<{
tone: "success" | "error";
text: string;
} | null>(null);
const assetsQuery = useQuery({ const assetsQuery = useQuery({
queryKey: ["asset-list"], queryKey: ["asset-list"],
queryFn: () => api.get<AssetItem[]>("/api/v1/assets"), queryFn: () => api.get<AssetItem[]>("/api/v1/assets"),
}); });
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: async (file: File) => { mutationFn: async () => {
if (!uploadFile) {
throw new Error("请先选择文件");
}
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", uploadFile);
formData.append("mediaType", mediaType); formData.append("mediaType", mediaType);
return api.post("/api/v1/assets", formData); return api.post<AssetItem>("/api/v1/assets", formData);
},
onSuccess(asset) {
setFeedback({
tone: "success",
text: `素材 ${asset.originalFilename} 上传成功。`,
});
setUploadFile(null);
void assetsQuery.refetch();
},
onError(error) {
setFeedback({
tone: "error",
text: getErrorMessage(error, "素材上传失败"),
});
}, },
onSuccess: () => assetsQuery.refetch(),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (assetId: number) => api.del(`/api/v1/assets/${assetId}`), mutationFn: (assetId: number) => api.del(`/api/v1/assets/${assetId}`),
onSuccess: () => assetsQuery.refetch(), onSuccess: () => {
setFeedback({
tone: "success",
text: "素材已删除。",
});
void assetsQuery.refetch();
},
onError(error) {
setFeedback({
tone: "error",
text: getErrorMessage(error, "素材删除失败"),
});
},
}); });
return ( const filteredAssets = useMemo(() => {
<div className="two-col-grid"> let items = [...(assetsQuery.data ?? [])].sort(
<section className="panel"> (left, right) =>
<h3></h3> dayjs(right.createdAt).valueOf() - dayjs(left.createdAt).valueOf(),
<div className="form-stack"> );
<label className="field-label">
if (libraryType !== "all") {
<select value={mediaType} onChange={(event) => setMediaType(event.target.value)}> items = items.filter((asset) => asset.mediaType === libraryType);
<option value="image"></option>
<option value="video"></option>
<option value="audio"></option>
</select>
</label>
<label className="field-label">
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
uploadMutation.mutate(file);
} }
}}
if (keyword.trim()) {
const loweredKeyword = keyword.trim().toLowerCase();
items = items.filter((asset) =>
[asset.originalFilename, asset.assetNo]
.filter(Boolean)
.some((field) => field.toLowerCase().includes(loweredKeyword)),
);
}
return items;
}, [assetsQuery.data, keyword, libraryType]);
return (
<div className="panel asset-management-shell">
<div className="workbench-header">
<div>
<span className="header-kicker">Asset Center</span>
<h3></h3>
<p className="muted">
</p>
</div>
<span className="mini-note"> {assetsQuery.data?.length ?? 0}</span>
</div>
<div className="asset-upload-panel">
<div className="toolbar">
<div className="segmented-group">
{mediaTypes.map((item) => (
<button
className={clsx("segmented-button", {
active: mediaType === item.value,
})}
key={item.value}
onClick={() => setMediaType(item.value)}
type="button"
>
{item.label}
</button>
))}
</div>
<span className="mini-note"></span>
</div>
<div className="upload-row">
<label className="file-picker">
<input
accept={mediaTypes.find((item) => item.value === mediaType)?.accept}
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
type="file"
/>
<UploadCloud size={18} />
<span>{uploadFile?.name ?? "选择一个素材文件"}</span>
</label>
<button
className="primary-button"
disabled={!uploadFile || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
type="button"
>
{uploadMutation.isPending ? (
<>
<LoaderCircle className="spin" size={16} />
</>
) : (
<>
<UploadCloud size={16} />
</>
)}
</button>
</div>
</div>
<div className="library-toolbar">
<div className="segmented-group">
<button
className={clsx("segmented-button", {
active: libraryType === "all",
})}
onClick={() => setLibraryType("all")}
type="button"
>
</button>
{mediaTypes.map((item) => (
<button
className={clsx("segmented-button", {
active: libraryType === item.value,
})}
key={item.value}
onClick={() => setLibraryType(item.value)}
type="button"
>
{item.label}
</button>
))}
</div>
<label className="library-search">
<Search size={16} />
<input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索文件名 / asset id"
value={keyword}
/> />
</label> </label>
</div> </div>
</section>
<section className="panel"> {feedback ? (
<h3></h3> <div
<div className="list-grid"> className={clsx("inline-feedback", {
{assetsQuery.data?.map((asset) => ( "is-success": feedback.tone === "success",
<div className="list-item" key={asset.id}> "is-error": feedback.tone === "error",
<strong>{asset.originalFilename}</strong> })}
<div className="muted"> >
{asset.mediaType} · {Math.round(asset.fileSize / 1024)} KB {feedback.text}
</div> </div>
<div className="row" style={{ marginTop: 12 }}> ) : null}
<a className="ghost-button" href={asset.publicUrl} target="_blank">
<div className="asset-gallery">
{filteredAssets.length ? (
filteredAssets.map((asset) => (
<article className="asset-library-card" key={asset.id}>
{asset.mediaType === "image" ? (
<div
className="asset-thumb asset-thumb-image"
style={{ backgroundImage: `url(${asset.publicUrl})` }}
>
<span className="asset-media-badge"></span>
</div>
) : asset.mediaType === "video" ? (
<div className="asset-thumb asset-thumb-video">
<video muted preload="metadata" src={asset.publicUrl} />
<span className="asset-media-badge"></span>
</div>
) : (
<div className="asset-thumb asset-thumb-audio">
<AudioLines size={22} />
<span></span>
</div>
)}
<div className="asset-card-body">
<div className="asset-card-title-row">
<strong>{asset.originalFilename}</strong>
</div>
<div className="asset-card-meta">
{asset.assetNo} · {formatBytes(asset.fileSize)}
</div>
<div className="asset-card-meta">
{dayjs(asset.createdAt).format("YYYY-MM-DD HH:mm")}
</div>
<div className="row">
<a
className="ghost-button compact-button"
href={asset.publicUrl}
rel="noreferrer"
target="_blank"
>
{asset.mediaType === "video" ? (
<>
<Video size={14} />
</>
) : (
"查看"
)}
</a> </a>
<button <button
className="danger-button" className="danger-button compact-button"
onClick={() => deleteMutation.mutate(asset.id)} onClick={() => deleteMutation.mutate(asset.id)}
type="button"
> >
<Trash2 size={14} />
</button> </button>
</div> </div>
</div> </div>
))} </article>
))
) : (
<div className="empty-state"></div>
)}
</div> </div>
</section>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Manrope, Space_Grotesk } from "next/font/google";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import "./globals.css"; import "./globals.css";
const displayFont = Space_Grotesk({
subsets: ["latin"],
variable: "--font-display",
});
const bodyFont = Manrope({
subsets: ["latin"],
variable: "--font-body",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "AIVideo", title: "AIVideo",
description: "AI 视频生成平台用户前台", description: "AI 视频生成平台用户前台",
@@ -27,10 +16,9 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body className={`${displayFont.variable} ${bodyFont.variable}`}> <body className="app-fonts">
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>
); );
} }

View File

@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import clsx from "clsx"; import clsx from "clsx";
import { import {
Activity,
Coins, Coins,
FolderKanban, FolderKanban,
ImagePlus, ImagePlus,
@@ -20,16 +21,60 @@ import { api } from "@/lib/api";
import type { UserProfile } from "@/lib/types"; import type { UserProfile } from "@/lib/types";
const navigation = [ const navigation = [
{ href: "/workspace/create", label: "创建任务", icon: Sparkles }, {
{ href: "/workspace/tasks", label: "任务记录", icon: FolderKanban }, href: "/workspace/create",
{ href: "/workspace/assets", label: "素材管理", icon: ImagePlus }, label: "工作台",
{ href: "/wallet", label: "钱包概览", icon: Coins }, description: "生成参数、提示词与素材联动",
{ href: "/wallet/recharge", label: "充值中心", icon: Coins }, icon: Sparkles,
{ href: "/wallet/redeem", label: "兑换密钥", icon: Ticket }, },
{ href: "/invite", label: "邀请中心", icon: Users }, {
{ href: "/profile", label: "个人资料", icon: UserRound }, 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 }) { export function SiteShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -45,8 +90,10 @@ export function SiteShell({ children }: { children: React.ReactNode }) {
}, [error, router]); }, [error, router]);
const title = useMemo(() => { const title = useMemo(() => {
const current = navigation.find((item) => pathname.startsWith(item.href)); const current = [...navigation]
return current?.label ?? "AIVideo"; .sort((left, right) => right.href.length - left.href.length)
.find((item) => matchesPath(pathname, item.href));
return current ?? navigation[0];
}, [pathname]); }, [pathname]);
if (isLoading || !data) { if (isLoading || !data) {
@@ -61,9 +108,13 @@ export function SiteShell({ children }: { children: React.ReactNode }) {
<div className="shell-grid"> <div className="shell-grid">
<aside className="shell-sidebar"> <aside className="shell-sidebar">
<div className="brand-block"> <div className="brand-block">
<span className="brand-kicker">AI VIDEO PLATFORM</span> <span className="brand-kicker">AI VIDEO STUDIO</span>
<h1>AIVideo</h1> <h1>AIVideo</h1>
<p></p> <p></p>
<div className="brand-status">
<span className="status-dot" />
线
</div>
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
@@ -74,18 +125,26 @@ export function SiteShell({ children }: { children: React.ReactNode }) {
key={item.href} key={item.href}
href={item.href} href={item.href}
className={clsx("nav-item", { className={clsx("nav-item", {
active: pathname.startsWith(item.href), active: title.href === item.href,
})} })}
> >
<Icon size={18} /> <Icon size={18} />
<div>
<span>{item.label}</span> <span>{item.label}</span>
<small>{item.description}</small>
</div>
</Link> </Link>
); );
})} })}
</nav> </nav>
<div className="profile-card"> <div className="profile-card">
<div> <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-name">{data.nickname || data.username}</div>
<div className="profile-meta">{data.email}</div> <div className="profile-meta">{data.email}</div>
</div> </div>
@@ -105,14 +164,20 @@ export function SiteShell({ children }: { children: React.ReactNode }) {
<main className="shell-main"> <main className="shell-main">
<header className="shell-header"> <header className="shell-header">
<div> <div>
<div className="header-kicker">Creative Ops</div> <div className="header-kicker">Creative Ops Console</div>
<h2>{title}</h2> <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>
<div className="header-chip">{data.publicId}</div> <div className="header-chip">{data.publicId}</div>
</div>
</header> </header>
<section className="shell-content">{children}</section> <section className="shell-content">{children}</section>
</main> </main>
</div> </div>
); );
} }

View File

@@ -14,6 +14,20 @@ const statusMap: Record<string, string> = {
disabled: "danger", disabled: "danger",
}; };
const statusLabelMap: Record<string, string> = {
queued: "排队中",
submitted: "已提交",
running: "生成中",
succeeded: "已完成",
failed: "失败",
cancelled: "已取消",
pending: "待处理",
paid: "已支付",
unused: "未使用",
used: "已使用",
disabled: "已停用",
};
export function StatusBadge({ value }: { value: string }) { export function StatusBadge({ value }: { value: string }) {
const tone = statusMap[value] ?? "soft"; const tone = statusMap[value] ?? "soft";
return ( return (
@@ -26,8 +40,7 @@ export function StatusBadge({ value }: { value: string }) {
"tone-ghost": tone === "ghost", "tone-ghost": tone === "ghost",
})} })}
> >
{value} {statusLabelMap[value] ?? value}
</span> </span>
); );
} }