feat(prompts): add atlascloud logo SVG and update prompt source handling

This commit is contained in:
HouYunFei
2026-07-25 11:55:00 +08:00
parent 49851f2b5f
commit 0179724467
13 changed files with 80 additions and 346 deletions
+15 -55
View File
@@ -9,12 +9,11 @@ import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { fetchSourcePrompts, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
import { fetchSourcePrompts, type Prompt } from "@/services/api/prompts";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
import { usePromptStore } from "@/stores/use-prompt-store";
import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -444,10 +443,9 @@ function AssetCover({ asset }: { asset: Asset }) {
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const sources = usePromptSourceStore((state) => state.sources);
const personalPrompts = usePromptStore((state) => state.prompts);
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
const [keyword, setKeyword] = useState("");
const [expanded, setExpanded] = useState<Record<string, boolean>>({ personal: true });
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [detail, setDetail] = useState<Prompt | null>(null);
const copyPrompt = async (prompt: string) => {
@@ -466,32 +464,19 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<div className="space-y-1">
<PersonalPromptGroup
items={personalPrompts.map(personalPromptToPrompt)}
keyword={keyword}
open={!!expanded.personal}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, personal: !prev.personal }))}
onInsert={onInsert}
onView={setDetail}
/>
{enabledSources.length ? (
<>
{enabledSources.map((source) => (
<PromptSourceGroup
key={source.id}
sourceId={source.id}
sourceName={source.name}
keyword={keyword}
open={!!expanded[source.id]}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, [source.id]: !prev[source.id] }))}
onInsert={onInsert}
onView={setDetail}
/>
))}
</>
) : personalPrompts.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" /> : null}
{enabledSources.length ? enabledSources.map((source) => (
<PromptSourceGroup
key={source.id}
sourceId={source.id}
sourceName={source.name}
keyword={keyword}
open={!!expanded[source.id]}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, [source.id]: !prev[source.id] }))}
onInsert={onInsert}
onView={setDetail}
/>
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" />}
</div>
</div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
@@ -499,31 +484,6 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
);
});
function PersonalPromptGroup({ items, keyword, open, theme, onToggle, onInsert, onView }: { items: Prompt[]; keyword: string; open: boolean; theme: CanvasTheme; onToggle: () => void; onInsert: (payload: InsertAssetPayload) => void; onView: (prompt: Prompt) => void }) {
const showResults = open || !!keyword.trim();
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return query ? items.filter((item) => [item.title, item.prompt, item.description, ...item.tags].join(" ").toLowerCase().includes(query)) : items;
}, [items, keyword]);
return (
<div>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
<BookOpen className="size-3.5" />
<span className="min-w-0 flex-1 truncate"></span>
<span className="opacity-50">{filtered.length}</span>
</button>
{showResults ? (
<div className="space-y-1.5 px-1 pb-2 pt-1">
{filtered.map((item) => <PromptRow key={item.id} item={item} theme={theme} onInsert={() => onInsert({ kind: "text", content: item.prompt, title: item.title })} onView={() => onView(item)} />)}
{!filtered.length ? <div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "还没有保存提示词"}</div> : null}
</div>
) : null}
</div>
);
}
function PromptSourceGroup({
sourceId,
sourceName,
@@ -45,7 +45,7 @@ export function ConfigPromptSources() {
const handleDelete = (source: PromptSource) => {
modal.confirm({
title: `删除「${source.name}」?`,
content: "来源配置会被移除,已经保存到我的提示词的内容不受影响。",
content: "来源配置会被移除,已经加入我的资产的内容不受影响。",
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
@@ -12,7 +12,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
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, includePersonal: true });
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]));
@@ -1,14 +1,19 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } 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, includePersonal = false }: { keyword: string; tags: string[]; category: string; enabled?: boolean; includePersonal?: boolean }) {
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword);
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => clearTimeout(timer);
}, [keyword]);
const query = useInfiniteQuery({
queryKey: ["prompts", keyword, tags, category, includePersonal],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE, includePersonal }),
queryKey: ["prompts", debouncedKeyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword: debouncedKeyword, 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,
@@ -1,108 +0,0 @@
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 { BookmarkPlus, Copy, FileText, FolderPlus } from "lucide-react";
import { 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, onSavePrompt }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void; onSavePrompt?: (prompt: Prompt) => void }) {
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}>
@@ -35,11 +35,6 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset, onSav
</Button>
) : null}
{onSavePrompt ? (
<Button icon={<BookmarkPlus className="size-4" />} onClick={() => onSavePrompt(prompt)}>
</Button>
) : null}
</Space>
</div>
</div>
+26 -92
View File
@@ -1,38 +1,24 @@
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 { FolderPlus, Search } from "lucide-react";
import { type ReactNode, type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } 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 { usePromptStore, type PersonalPrompt, type PersonalPromptInput } from "@/stores/use-prompt-store";
import { ALL_PROMPTS_OPTION, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
import { ALL_PROMPTS_OPTION, 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, 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]);
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
@@ -48,104 +34,52 @@ export default function PromptsPage() {
message.success("已加入我的资产");
};
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="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>
{activeTab === "personal" ? (
<Button type="primary" icon={<Plus className="size-4" />} onClick={openNewPrompt}>
</Button>
) : null}
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-4 py-6 [background-size:16px_16px] sm:px-6 lg:py-8 dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
<div className="mx-auto max-w-7xl">
<div className="text-center">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
</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">
<div className="mt-5 grid items-start gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6">
<aside className="thin-scrollbar max-h-72 overflow-y-auto border-b border-stone-200 pb-5 lg:sticky lg:top-0 lg:max-h-[calc(100dvh-6rem)] lg:border-b-0 lg:border-r lg:pb-8 lg:pr-5 dark:border-stone-800">
<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">
<div className="mt-6">
<div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500"></div>
<div className="flex flex-wrap gap-1.5">
{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}
</aside>
<section className="min-w-0">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} />
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" /></div> : null}
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
</section>
</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={selectedPrompt?.sourceId === "personal" ? undefined : savePromptAsset} onSavePrompt={selectedPrompt?.sourceId === "personal" ? undefined : saveToMyPrompts} />
<MyPromptEditorDialog open={editorOpen} prompt={editingPrompt} onSave={savePersonalPrompt} onClose={() => setEditorOpen(false)} />
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</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>;
return <div><div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{label}</div><div className="flex flex-wrap gap-1.5">{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>;
return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{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 };
}
+4 -19
View File
@@ -2,7 +2,6 @@ import localforage from "localforage";
import { runPromptSource, type RawPrompt } from "./prompt-source-runtime";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
import { usePromptStore, type PersonalPrompt } from "@/stores/use-prompt-store";
import type { PromptSource } from "./prompt-source-presets";
export type Prompt = RawPrompt & {
@@ -12,7 +11,6 @@ export type Prompt = RawPrompt & {
};
export const ALL_PROMPTS_OPTION = "全部";
export const PERSONAL_PROMPTS_CATEGORY = "我的提示词";
export type PromptListResponse = {
items: Prompt[];
@@ -76,17 +74,6 @@ function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
}));
}
export function personalPromptToPrompt(item: PersonalPrompt): Prompt {
return {
...item,
coverUrl: item.coverUrl || item.referenceImageUrls[0] || "",
sourceId: "personal",
category: PERSONAL_PROMPTS_CATEGORY,
githubUrl: "",
preview: "",
};
}
async function readSourceCache(sourceId: string) {
return promptCacheStore.getItem<SourceCache>(cacheKey(sourceId));
}
@@ -135,7 +122,7 @@ async function getSourcePrompts(source: PromptSource): Promise<Prompt[]> {
return (await readSourceCache(source.id))?.items || [];
}
async function getAllPrompts(includePersonal: boolean): Promise<Prompt[]> {
async function getAllPrompts(): Promise<Prompt[]> {
const settled = await Promise.all(
enabledSources().map(async (source) => {
try {
@@ -145,19 +132,17 @@ async function getAllPrompts(includePersonal: boolean): Promise<Prompt[]> {
}
}),
);
const personal = includePersonal ? usePromptStore.getState().prompts.map(personalPromptToPrompt) : [];
return [...personal, ...settled.flat()];
return settled.flat();
}
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20, includePersonal = true }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number; includePersonal?: boolean } = {}) {
const items = await getAllPrompts(includePersonal);
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getAllPrompts();
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
const categories = enabledSources().map((source) => source.name);
if (includePersonal && usePromptStore.getState().prompts.length) categories.unshift(PERSONAL_PROMPTS_CATEGORY);
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
-53
View File
@@ -1,53 +0,0 @@
import { nanoid } from "nanoid";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { localForageStorage } from "@/lib/localforage-storage";
export type PersonalPrompt = {
id: string;
title: string;
prompt: string;
description: string;
coverUrl: string;
referenceImageUrls: string[];
tags: string[];
createdAt: string;
updatedAt: string;
imageMode?: string;
imageModel?: string;
imageSize?: string;
imageCount?: number;
};
export type PersonalPromptInput = Omit<PersonalPrompt, "id" | "createdAt" | "updatedAt">;
type PromptStore = {
hydrated: boolean;
prompts: PersonalPrompt[];
addPrompt: (prompt: PersonalPromptInput) => string;
updatePrompt: (id: string, prompt: PersonalPromptInput) => void;
removePrompt: (id: string) => void;
};
export const usePromptStore = create<PromptStore>()(
persist(
(set) => ({
hydrated: false,
prompts: [],
addPrompt: (prompt) => {
const id = nanoid();
const now = new Date().toISOString();
set((state) => ({ prompts: [{ ...prompt, id, createdAt: now, updatedAt: now }, ...state.prompts] }));
return id;
},
updatePrompt: (id, prompt) => set((state) => ({ prompts: state.prompts.map((item) => (item.id === id ? { ...item, ...prompt, updatedAt: new Date().toISOString() } : item)) })),
removePrompt: (id) => set((state) => ({ prompts: state.prompts.filter((item) => item.id !== id) })),
}),
{
name: "infinite-canvas:prompt_store",
storage: createJSONStorage(() => localForageStorage),
onRehydrateStorage: () => () => usePromptStore.setState({ hydrated: true }),
},
),
);