mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 09:24:24 +08:00
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Empty, Input, Modal, Pagination, Spin, Tabs, Tag } from "antd";
|
||||
import { Search } from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
|
||||
|
||||
export type AssetPickerTab = "my-assets" | "library";
|
||||
|
||||
export type InsertAssetPayload =
|
||||
| { kind: "text"; content: string; title: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; storageKey?: string };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
defaultTab?: AssetPickerTab;
|
||||
onInsert: (payload: InsertAssetPayload) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab);
|
||||
}, [open, defaultTab]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="选择素材"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
||||
items={[
|
||||
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
|
||||
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
];
|
||||
|
||||
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [inserting, setInserting] = useState<string | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["asset-picker-library", keyword, kindFilter, page],
|
||||
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const items = query.data?.items || [];
|
||||
const total = query.data?.total || 0;
|
||||
|
||||
const handleInsert = async (asset: AssetLibraryItem) => {
|
||||
try {
|
||||
setInserting(asset.id);
|
||||
if (asset.type === "text") {
|
||||
onInsert({ kind: "text", content: asset.content, title: asset.title });
|
||||
} else {
|
||||
const dataUrl = await remoteImageToDataUrl(asset.url);
|
||||
onInsert({ kind: "image", dataUrl, title: asset.title });
|
||||
}
|
||||
} catch {
|
||||
message.error("插入失败");
|
||||
} finally {
|
||||
setInserting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value || "all"}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex justify-center py-16"><Spin /></div>
|
||||
) : items.length ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{items.map((asset) => (
|
||||
<PickerCard
|
||||
key={asset.id}
|
||||
title={asset.title}
|
||||
kind={asset.type}
|
||||
cover={asset.coverUrl}
|
||||
loading={inserting === asset.id}
|
||||
onClick={() => void handleInsert(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
||||
)}
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{cover ? (
|
||||
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
|
||||
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : "文本"}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">
|
||||
插入
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function remoteImageToDataUrl(url: string) {
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
const blob = response.data as 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);
|
||||
});
|
||||
}
|
||||
|
||||
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return assets
|
||||
.filter((a) => a.kind === "text" || a.kind === "image")
|
||||
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
|
||||
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
|
||||
}, [assets, keyword, kindFilter]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
setPage((v) => Math.min(v, maxPage));
|
||||
}, [filtered.length]);
|
||||
|
||||
const handleInsert = (asset: Asset) => {
|
||||
if (asset.kind === "text") {
|
||||
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
|
||||
} else {
|
||||
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{kindOptions.map((opt) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visible.length ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{visible.map((asset) => (
|
||||
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
||||
)}
|
||||
|
||||
{filtered.length > PAGE_SIZE && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user