feat: redesign user workspace console
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
|
||||||
素材类型
|
|
||||||
<select value={mediaType} onChange={(event) => setMediaType(event.target.value)}>
|
|
||||||
<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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
if (libraryType !== "all") {
|
||||||
<h3>已上传素材</h3>
|
items = items.filter((asset) => asset.mediaType === libraryType);
|
||||||
<div className="list-grid">
|
}
|
||||||
{assetsQuery.data?.map((asset) => (
|
|
||||||
<div className="list-item" key={asset.id}>
|
if (keyword.trim()) {
|
||||||
<strong>{asset.originalFilename}</strong>
|
const loweredKeyword = keyword.trim().toLowerCase();
|
||||||
<div className="muted">
|
items = items.filter((asset) =>
|
||||||
{asset.mediaType} · {Math.round(asset.fileSize / 1024)} KB
|
[asset.originalFilename, asset.assetNo]
|
||||||
</div>
|
.filter(Boolean)
|
||||||
<div className="row" style={{ marginTop: 12 }}>
|
.some((field) => field.toLowerCase().includes(loweredKeyword)),
|
||||||
<a className="ghost-button" href={asset.publicUrl} target="_blank">
|
);
|
||||||
查看文件
|
}
|
||||||
</a>
|
|
||||||
<button
|
return items;
|
||||||
className="danger-button"
|
}, [assetsQuery.data, keyword, libraryType]);
|
||||||
onClick={() => deleteMutation.mutate(asset.id)}
|
|
||||||
>
|
return (
|
||||||
删除
|
<div className="panel asset-management-shell">
|
||||||
</button>
|
<div className="workbench-header">
|
||||||
</div>
|
<div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
|
||||||
|
<label className="library-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索文件名 / asset id"
|
||||||
|
value={keyword}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<div
|
||||||
|
className={clsx("inline-feedback", {
|
||||||
|
"is-success": feedback.tone === "success",
|
||||||
|
"is-error": feedback.tone === "error",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{feedback.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
className="danger-button compact-button"
|
||||||
|
onClick={() => deleteMutation.mutate(asset.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">当前没有匹配素材,先上传或调整筛选条件。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
<span>{item.label}</span>
|
<div>
|
||||||
|
<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 className="header-chip">{data.publicId}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-chip">{data.publicId}</div>
|
|
||||||
</header>
|
</header>
|
||||||
<section className="shell-content">{children}</section>
|
<section className="shell-content">{children}</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user