Files
infinite-canvas/web/src/app/(user)/asset-library/page.tsx
T
2026-05-20 13:29:12 +08:00

219 lines
11 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 { Copy, FolderPlus, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
import axios from "axios";
import copy from "copy-to-clipboard";
import { cn } from "@/lib/utils";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
import { uploadImage } from "@/services/image-storage";
const PAGE_SIZE = 12;
export default function AssetLibraryPage() {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [selectedType, setSelectedType] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
const addAsset = useAssetStore((state) => state.addAsset);
const query = useQuery({
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
retry: false,
});
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
}
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
const copyText = async (value: string) => {
copy(value);
message.success("已复制");
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
<Spin />
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
<div>
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.content)}></Button> : null}
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.url)}></Button> : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}></Button>
</div>
</div>
) : null}
</Drawer>
</div>
);
}
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
const cover = asset.coverUrl;
return (
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{asset.tags.slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}></Button>
</div>
</Card>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return await blobToDataUrl(blob);
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}