feat: redesign user workspace console
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user