mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 00:34:22 +08:00
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Copy } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Card, Tag } from "antd";
|
||||
|
||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actionIcon = <Copy className="size-3.5" />, actionType = "text", extraAction }: { item: Prompt; onOpen: () => void; onCopy: () => void; actionLabel?: string; actionIcon?: ReactNode; actionType?: "text" | "primary"; extraAction?: ReactNode }) {
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={<button type="button" className="block w-full text-left" onClick={onOpen}><img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" /></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">{item.title}</h2>
|
||||
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>{actionLabel}</Button>
|
||||
{extraAction}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, FolderPlus } from "lucide-react";
|
||||
import { Button, Modal, Space, Tag } from "antd";
|
||||
|
||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
|
||||
{prompt ? (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
|
||||
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-1.5">{prompt.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}</div>
|
||||
<Space wrap className="mt-5">
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>复制提示词</Button>
|
||||
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>加入我的素材</Button> : null}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { type UIEvent, useEffect, useState } from "react";
|
||||
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
|
||||
|
||||
import { ALL_PROMPTS_OPTION } from "@/services/api/prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PromptCard } from "./prompt-card";
|
||||
import { usePromptList } from "./use-prompt-list";
|
||||
|
||||
export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
|
||||
const toggleTag = (tag: string) => {
|
||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
||||
};
|
||||
const selectPrompt = (prompt: string) => {
|
||||
onSelect(prompt);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}, [message, query.error, query.isError]);
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
|
||||
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" />
|
||||
</div>
|
||||
<div className="mt-5 grid gap-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">
|
||||
{promptCategories.map((category) => (
|
||||
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
|
||||
{category}
|
||||
</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">
|
||||
{promptTags.map((tag) => {
|
||||
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
|
||||
return (
|
||||
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
|
||||
{query.isLoading ? <div className="flex h-40 items-center justify-center"><Spin /></div> : null}
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
|
||||
))}
|
||||
</div>
|
||||
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
|
||||
{query.isFetchingNextPage ? <div className="py-4 text-center"><Spin size="small" /></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
|
||||
|
||||
export const PROMPT_PAGE_SIZE = 20;
|
||||
|
||||
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["prompts", keyword, tags, category],
|
||||
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) => pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined,
|
||||
enabled,
|
||||
});
|
||||
const firstPage = query.data?.pages[0];
|
||||
return {
|
||||
query,
|
||||
items: useMemo(() => query.data?.pages.flatMap((page) => page.items) || [], [query.data?.pages]),
|
||||
tags: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.tags || [])], [firstPage?.tags]),
|
||||
categories: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.categories || [])], [firstPage?.categories]),
|
||||
total: firstPage?.total || 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user