296 lines
9.1 KiB
TypeScript
296 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|