mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat(prompts): unify sources and add personal library
This commit is contained in:
@@ -2591,11 +2591,11 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
|
||||
const insertAssistantText = useCallback(
|
||||
(text: string) => {
|
||||
(text: string, title?: string) => {
|
||||
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
||||
const node = {
|
||||
...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }),
|
||||
title: text.slice(0, 32) || "Assistant Text",
|
||||
title: title || text.slice(0, 32) || "Assistant Text",
|
||||
};
|
||||
|
||||
setNodes((prev) => [...prev, node]);
|
||||
@@ -2608,7 +2608,7 @@ function InfiniteCanvasPage() {
|
||||
const handleAssetInsert = useCallback(
|
||||
(payload: InsertAssetPayload) => {
|
||||
if (payload.kind === "text") {
|
||||
insertAssistantText(payload.content);
|
||||
insertAssistantText(payload.content, payload.title);
|
||||
} else if (payload.kind === "video") {
|
||||
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
||||
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { App, Button, Input, Modal, Space } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { PersonalPrompt, PersonalPromptInput } from "@/stores/use-prompt-store";
|
||||
|
||||
const EMPTY_PROMPT: PersonalPromptInput = {
|
||||
title: "",
|
||||
prompt: "",
|
||||
description: "",
|
||||
coverUrl: "",
|
||||
referenceImageUrls: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
export function MyPromptEditorDialog({ open, prompt, onSave, onClose }: { open: boolean; prompt: PersonalPrompt | null; onSave: (value: PersonalPromptInput) => void; onClose: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [draft, setDraft] = useState<PersonalPromptInput>(EMPTY_PROMPT);
|
||||
const [tags, setTags] = useState("");
|
||||
const [referenceImages, setReferenceImages] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(prompt ? toInput(prompt) : EMPTY_PROMPT);
|
||||
setTags(prompt?.tags.join(", ") || "");
|
||||
setReferenceImages(prompt?.referenceImageUrls.join("\n") || "");
|
||||
}, [open, prompt]);
|
||||
|
||||
const patch = (value: Partial<PersonalPromptInput>) => setDraft((current) => ({ ...current, ...value }));
|
||||
const save = () => {
|
||||
if (!draft.title.trim()) return message.warning("请输入标题");
|
||||
if (!draft.prompt.trim()) return message.warning("请输入提示词");
|
||||
onSave({
|
||||
...draft,
|
||||
title: draft.title.trim(),
|
||||
prompt: draft.prompt.trim(),
|
||||
description: draft.description.trim(),
|
||||
coverUrl: draft.coverUrl.trim(),
|
||||
tags: splitValues(tags, /[,,\n]/),
|
||||
referenceImageUrls: splitValues(referenceImages, /\n/),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={prompt ? "编辑提示词" : "新增提示词"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={680}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 pt-2">
|
||||
<label>
|
||||
<span className="mb-1.5 block text-sm font-medium">标题</span>
|
||||
<Input value={draft.title} onChange={(event) => patch({ title: event.target.value })} placeholder="例如:白底商品图" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1.5 block text-sm font-medium">提示词</span>
|
||||
<Input.TextArea rows={7} value={draft.prompt} onChange={(event) => patch({ prompt: event.target.value })} placeholder="输入可直接使用的提示词" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1.5 block text-sm font-medium">说明(可选)</span>
|
||||
<Input.TextArea rows={2} value={draft.description} onChange={(event) => patch({ description: event.target.value })} />
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className="mb-1.5 block text-sm font-medium">封面 URL(可选)</span>
|
||||
<Input value={draft.coverUrl} onChange={(event) => patch({ coverUrl: event.target.value })} placeholder="https://..." />
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1.5 block text-sm font-medium">标签(可选)</span>
|
||||
<Input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="商品, 摄影" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span className="mb-1.5 block text-sm font-medium">参考图 URL(可选,每行一个)</span>
|
||||
<Input.TextArea rows={3} value={referenceImages} onChange={(event) => setReferenceImages(event.target.value)} placeholder={"https://...\nhttps://..."} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function toInput(prompt: PersonalPrompt): PersonalPromptInput {
|
||||
return {
|
||||
title: prompt.title,
|
||||
prompt: prompt.prompt,
|
||||
description: prompt.description,
|
||||
coverUrl: prompt.coverUrl,
|
||||
referenceImageUrls: prompt.referenceImageUrls,
|
||||
tags: prompt.tags,
|
||||
imageMode: prompt.imageMode,
|
||||
imageModel: prompt.imageModel,
|
||||
imageSize: prompt.imageSize,
|
||||
imageCount: prompt.imageCount,
|
||||
};
|
||||
}
|
||||
|
||||
function splitValues(value: string, separator: RegExp) {
|
||||
return Array.from(new Set(value.split(separator).map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Copy, FolderPlus } from "lucide-react";
|
||||
import { BookmarkPlus, Copy, FileText, 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 }) {
|
||||
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset, onSavePrompt }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void; onSavePrompt?: (prompt: Prompt) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
|
||||
@@ -11,7 +11,8 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
<>
|
||||
<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.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" /> : <div className="grid aspect-[4/3] w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-9" /></div>}
|
||||
{prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-3 gap-2">{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => <img key={url} src={url} alt="" className="aspect-square w-full rounded-md object-cover" loading="lazy" />)}</div> : null}
|
||||
{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">
|
||||
@@ -22,10 +23,9 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
|
||||
<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>
|
||||
{prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null}
|
||||
<Space wrap className="mt-5">
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
||||
复制提示词
|
||||
@@ -35,6 +35,11 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
加入我的资产
|
||||
</Button>
|
||||
) : null}
|
||||
{onSavePrompt ? (
|
||||
<Button icon={<BookmarkPlus className="size-4" />} onClick={() => onSavePrompt(prompt)}>
|
||||
保存到我的提示词
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+105
-80
@@ -1,29 +1,41 @@
|
||||
import { FolderPlus, Search } from "lucide-react";
|
||||
import { type UIEvent, useEffect, useState } from "react";
|
||||
import { App, Button, Empty, Input, Spin, Tag } from "antd";
|
||||
import { BookmarkPlus, FolderPlus, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, type UIEvent, useEffect, useMemo, useState } from "react";
|
||||
import { App, Button, Empty, Input, Popconfirm, Space, Spin, Tabs, Tag, Tooltip } from "antd";
|
||||
|
||||
import { PromptCard } from "@/components/prompts/prompt-card";
|
||||
import { usePromptList } from "@/components/prompts/use-prompt-list";
|
||||
import { MyPromptEditorDialog } from "./components/my-prompt-editor-dialog";
|
||||
import { PromptDetailDialog } from "./components/prompt-detail-dialog";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
|
||||
import { usePromptStore, type PersonalPrompt, type PersonalPromptInput } from "@/stores/use-prompt-store";
|
||||
import { ALL_PROMPTS_OPTION, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [activeTab, setActiveTab] = useState("library");
|
||||
const [titleKeyword, setTitleKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingPrompt, setEditingPrompt] = useState<PersonalPrompt | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const personalPrompts = usePromptStore((state) => state.prompts);
|
||||
const addPrompt = usePromptStore((state) => state.addPrompt);
|
||||
const updatePrompt = usePromptStore((state) => state.updatePrompt);
|
||||
const removePrompt = usePromptStore((state) => state.removePrompt);
|
||||
const copyText = useCopyText();
|
||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
|
||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory, enabled: activeTab === "library" });
|
||||
const filteredPersonalPrompts = useMemo(() => {
|
||||
const keyword = titleKeyword.trim().toLowerCase();
|
||||
if (!keyword) return personalPrompts;
|
||||
return personalPrompts.filter((item) => [item.title, item.prompt, item.description, ...item.tags].join(" ").toLowerCase().includes(keyword));
|
||||
}, [personalPrompts, titleKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}
|
||||
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}, [message, query.error, query.isError]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
@@ -36,91 +48,104 @@ export default function PromptsPage() {
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
const saveToMyPrompts = (item: Prompt) => {
|
||||
addPrompt(toPersonalInput(item));
|
||||
message.success("已保存到我的提示词");
|
||||
};
|
||||
|
||||
const openNewPrompt = () => {
|
||||
setEditingPrompt(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEditPrompt = (item: PersonalPrompt) => {
|
||||
setEditingPrompt(item);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const savePersonalPrompt = (input: PersonalPromptInput) => {
|
||||
if (editingPrompt) updatePrompt(editingPrompt.id, input);
|
||||
else addPrompt(input);
|
||||
message.success(editingPrompt ? "提示词已更新" : "提示词已添加");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
if (activeTab !== "library") return;
|
||||
const target = event.currentTarget;
|
||||
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
|
||||
};
|
||||
|
||||
const personalItems = filteredPersonalPrompts.map(personalPromptToPrompt);
|
||||
const visibleCount = activeTab === "library" ? totalPrompts : filteredPersonalPrompts.length;
|
||||
|
||||
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)]"
|
||||
onScroll={handleListScroll}
|
||||
>
|
||||
<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">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
||||
</div>
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-60 items-center justify-center">
|
||||
<Spin />
|
||||
<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)]" onScroll={handleListScroll}>
|
||||
<div className="mx-auto max-w-7xl pb-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">提示词中心</h1>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">当前共 {visibleCount} 条提示词</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isLoading ? (
|
||||
<>
|
||||
<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={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
|
||||
</div>
|
||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||
<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">
|
||||
{promptCategoryOptions.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) => (
|
||||
<Tag.CheckableTag
|
||||
key={tag}
|
||||
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
|
||||
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
|
||||
onChange={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
{activeTab === "personal" ? (
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={openNewPrompt}>
|
||||
新增提示词
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Tabs className="mt-5" activeKey={activeTab} onChange={setActiveTab} items={[{ key: "library", label: "提示词库" }, { key: "personal", label: `我的提示词 (${personalPrompts.length})` }]} />
|
||||
<div className="mx-auto mt-2 w-full max-w-2xl">
|
||||
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} />
|
||||
</div>
|
||||
{activeTab === "library" ? (
|
||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||
<PromptFilter label="分类" options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} />
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!query.isLoading ? (
|
||||
<div>
|
||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{promptItems.map((item) => (
|
||||
<PromptCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={() => setSelectedPrompt(item)}
|
||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||
extraAction={
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||
加入我的资产
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
|
||||
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "library" && query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
||||
{activeTab === "library" && !query.isLoading ? (
|
||||
<PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <><Button size="small" icon={<BookmarkPlus className="size-3.5" />} onClick={() => saveToMyPrompts(item)}>保存</Button><Tooltip title="加入我的资产"><Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)} /></Tooltip></>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" />
|
||||
) : null}
|
||||
{activeTab === "personal" ? (
|
||||
<PromptGrid
|
||||
items={personalItems}
|
||||
onOpen={setSelectedPrompt}
|
||||
onCopy={(item) => copyText(item.prompt, "提示词已复制")}
|
||||
renderActions={(item) => {
|
||||
const personal = personalPrompts.find((prompt) => prompt.id === item.id)!;
|
||||
return <Space size={0}><Tooltip title="编辑"><Button type="text" size="small" icon={<Pencil className="size-3.5" />} onClick={() => openEditPrompt(personal)} /></Tooltip><Popconfirm title="删除这条提示词?" okText="删除" cancelText="取消" onConfirm={() => removePrompt(item.id)}><Tooltip title="删除"><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} /></Tooltip></Popconfirm></Space>;
|
||||
}}
|
||||
emptyText="还没有保存提示词"
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "library" ? <div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div> : null}
|
||||
</main>
|
||||
|
||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
|
||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={selectedPrompt?.sourceId === "personal" ? undefined : savePromptAsset} onSavePrompt={selectedPrompt?.sourceId === "personal" ? undefined : saveToMyPrompts} />
|
||||
<MyPromptEditorDialog open={editorOpen} prompt={editingPrompt} onSave={savePersonalPrompt} onClose={() => setEditorOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) {
|
||||
return <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">{label}</div><div className="flex flex-wrap gap-2">{options.map((option) => <Tag.CheckableTag key={option} checked={selected === option} className={cn("prompt-filter-tag", selected === option && "is-active")} onChange={() => onChange(option)}>{option}</Tag.CheckableTag>)}</div></div>;
|
||||
}
|
||||
|
||||
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
|
||||
return <div><div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
|
||||
}
|
||||
|
||||
function toPersonalInput(item: Prompt): PersonalPromptInput {
|
||||
return { title: item.title, prompt: item.prompt, description: item.description, coverUrl: item.coverUrl, referenceImageUrls: item.referenceImageUrls, tags: item.tags, imageMode: item.imageMode, imageModel: item.imageModel, imageSize: item.imageSize, imageCount: item.imageCount };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user