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,86 +1,295 @@
"use client";
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";
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() {
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({
queryKey: ["asset-list"],
queryFn: () => api.get<AssetItem[]>("/api/v1/assets"),
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
mutationFn: async () => {
if (!uploadFile) {
throw new Error("请先选择文件");
}
const formData = new FormData();
formData.append("file", file);
formData.append("file", uploadFile);
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({
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 (
<div className="two-col-grid">
<section className="panel">
<h3></h3>
<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>
const filteredAssets = useMemo(() => {
let items = [...(assetsQuery.data ?? [])].sort(
(left, right) =>
dayjs(right.createdAt).valueOf() - dayjs(left.createdAt).valueOf(),
);
<section className="panel">
<h3></h3>
<div className="list-grid">
{assetsQuery.data?.map((asset) => (
<div className="list-item" key={asset.id}>
<strong>{asset.originalFilename}</strong>
<div className="muted">
{asset.mediaType} · {Math.round(asset.fileSize / 1024)} KB
</div>
<div className="row" style={{ marginTop: 12 }}>
<a className="ghost-button" href={asset.publicUrl} target="_blank">
</a>
<button
className="danger-button"
onClick={() => deleteMutation.mutate(asset.id)}
>
</button>
</div>
</div>
if (libraryType !== "all") {
items = items.filter((asset) => asset.mediaType === libraryType);
}
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>
</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>
);
}