Files
aivideo/frontend-web/src/app/(dashboard)/workspace/assets/page.tsx

296 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
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, 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<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 () => {
if (!uploadFile) {
throw new Error("请先选择文件");
}
const formData = new FormData();
formData.append("file", uploadFile);
formData.append("mediaType", mediaType);
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, "素材上传失败"),
});
},
});
const deleteMutation = useMutation({
mutationFn: (assetId: number) => api.del(`/api/v1/assets/${assetId}`),
onSuccess: () => {
setFeedback({
tone: "success",
text: "素材已删除。",
});
void assetsQuery.refetch();
},
onError(error) {
setFeedback({
tone: "error",
text: getErrorMessage(error, "素材删除失败"),
});
},
});
const filteredAssets = useMemo(() => {
let items = [...(assetsQuery.data ?? [])].sort(
(left, right) =>
dayjs(right.createdAt).valueOf() - dayjs(left.createdAt).valueOf(),
);
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>
<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>
);
}