import { App, Button, Dropdown, Input } from "antd"; import { Download, Heart, Import, MoreHorizontal, Pencil, Plus, RotateCcw, Search, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; import { SegmentedControl } from "@/components/ui/base/segmented-control"; import { Select } from "@/components/ui/base/select"; import { Tooltip } from "@/components/ui/base/tooltip"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { catalogIdOf, inspirationSourceOf, inspirationSources, type CreationInspiration, type InspirationSourceId } from "@/lib/inspirations/catalog"; import { fillablePrompt, isCustomInspiration, resolveInspirations } from "@/lib/inspirations/resolve"; import { loadInspirationCatalog, type InspirationCatalog } from "@/services/inspiration-catalog"; import { useInspirationStore } from "@/stores/use-inspiration-store"; import { InspirationDetailModal } from "./inspiration-detail-modal"; import { InspirationEditorModal, type InspirationCategoryChoice } from "./inspiration-editor-modal"; import "./inspirations.css"; type SourceFilter = "all" | InspirationSourceId; type ViewFilter = "all" | "favorites" | "hidden"; type ModeFilter = "all" | CreationInspiration["mode"]; type SortKey = "default" | "title"; /** 分类标签比编辑器里的选项多一个条目数,用于在标签上显示数量。 */ type CategoryChoice = InspirationCategoryChoice & { count: number }; /** 来源筛选的顺序。必须覆盖 inspirationSources 里全部有内容的来源,漏掉就会有条目筛不到。 */ const SOURCE_ORDER: InspirationSourceId[] = ["original", "chatgpt-prompts", "seedance", "haohaoxue", "youmind"]; const VIEW_OPTIONS: { label: string; value: ViewFilter }[] = [ { label: "全部", value: "all" }, { label: "收藏", value: "favorites" }, { label: "已隐藏", value: "hidden" }, ]; const SORT_OPTIONS = [ { label: "默认顺序", value: "default" }, { label: "按标题", value: "title" }, ]; /** 导出文件名带上日期,重复导出不会互相覆盖。 */ function exportFileName() { return `yingce-inspirations-${new Date().toISOString().slice(0, 10)}.json`; } function downloadText(text: string, fileName: string) { const url = URL.createObjectURL(new Blob([text], { type: "application/json" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); } export default function InspirationsPage() { const { message, modal } = App.useApp(); const navigate = useNavigate(); const custom = useInspirationStore((state) => state.custom); const overrides = useInspirationStore((state) => state.overrides); const hidden = useInspirationStore((state) => state.hidden); const favorites = useInspirationStore((state) => state.favorites); const hydrated = useInspirationStore((state) => state.hydrated); const createEntry = useInspirationStore((state) => state.createEntry); const updateEntry = useInspirationStore((state) => state.updateEntry); const removeEntry = useInspirationStore((state) => state.removeEntry); const restoreEntry = useInspirationStore((state) => state.restoreEntry); const toggleFavorite = useInspirationStore((state) => state.toggleFavorite); const exportPayload = useInspirationStore((state) => state.exportPayload); const importPayload = useInspirationStore((state) => state.importPayload); const [catalog, setCatalog] = useState(null); const [loadError, setLoadError] = useState(""); const [reloadKey, setReloadKey] = useState(0); const [source, setSource] = useState("all"); const [modeFilter, setModeFilter] = useState("all"); const [category, setCategory] = useState("all"); const [view, setView] = useState("all"); const [sort, setSort] = useState("default"); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(24); const [detail, setDetail] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [editing, setEditing] = useState(null); const importRef = useRef(null); useEffect(() => { let cancelled = false; setLoadError(""); loadInspirationCatalog() .then((loaded) => { if (!cancelled) setCatalog(loaded); }) .catch((error) => { if (!cancelled) setLoadError(error instanceof Error ? error.message : "灵感目录加载失败"); }); return () => { cancelled = true; }; }, [reloadKey]); // 筛选条件一变就回第一页,否则会停在一个已经不存在的位置上。 useEffect(() => { setPage(1); }, [source, modeFilter, category, view, sort, debouncedSearch, pageSize]); const overlay = useMemo(() => ({ custom, overrides, hidden }), [custom, hidden, overrides]); const resolved = useMemo(() => (catalog ? resolveInspirations(catalog.entries, overlay) : []), [catalog, overlay]); /** 隐藏态在合成结果里已经滤掉了,单独还原一份给「已隐藏」视图。 */ const hiddenItems = useMemo(() => { if (!catalog) return []; const hiddenSet = new Set(hidden); return catalog.entries.filter((item) => hiddenSet.has(catalogIdOf(item))); }, [catalog, hidden]); const categoryLabels = useMemo(() => { const map = new Map>(); for (const [sourceId, options] of Object.entries(catalog?.categoriesBySource ?? {})) { map.set(sourceId, new Map(options.map((option) => [option.id, option.label]))); } return map; }, [catalog]); const categoryLabel = useCallback( (item: CreationInspiration) => { const code = item.category?.trim(); if (!code) return "未分类"; return categoryLabels.get(item.sourceId ?? "original")?.get(code) ?? code; }, [categoryLabels], ); /** 分类标签跟随来源:选了某个来源就不该再出现别的来源的分类。 */ const categoryChoices = useMemo(() => { if (!catalog) return []; const sources = source === "all" ? SOURCE_ORDER : [source]; const merged = new Map(); for (const sourceId of sources) { for (const option of catalog.categoriesBySource[sourceId] ?? []) { const current = merged.get(option.id); merged.set(option.id, { label: option.label, count: (current?.count ?? 0) + option.count }); } } return [...merged.entries()].sort((left, right) => right[1].count - left[1].count || left[1].label.localeCompare(right[1].label, "zh-Hans-CN")).map(([value, { label, count }]) => ({ value, label, count })); }, [catalog, source]); const sourceCounts = useMemo(() => { const counts = new Map(); counts.set("all", resolved.length + hiddenItems.length); for (const item of [...resolved, ...hiddenItems]) { const key = item.sourceId ?? "original"; counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; }, [resolved, hiddenItems]); /** 图片 / 视频 / 文本是库里的两大主类(文本较少),按当前视图与来源统计数量。 */ const modeCounts = useMemo(() => { const counts: Record = { all: 0, image: 0, video: 0, text: 0 }; const pool = view === "hidden" ? hiddenItems : resolved; const favoriteSet = new Set(favorites); for (const item of pool) { if (view === "favorites" && !favoriteSet.has(catalogIdOf(item))) continue; if (source !== "all" && (item.sourceId ?? "original") !== source) continue; counts.all += 1; counts[item.mode] += 1; } return counts; }, [view, resolved, hiddenItems, favorites, source]); const modeOptions = useMemo( () => [ { label: `全部 ${modeCounts.all}`, value: "all" as ModeFilter }, { label: `图片 ${modeCounts.image}`, value: "image" as ModeFilter }, { label: `视频 ${modeCounts.video}`, value: "video" as ModeFilter }, { label: `文本 ${modeCounts.text}`, value: "text" as ModeFilter }, ], [modeCounts], ); const visible = useMemo(() => { const pool = view === "hidden" ? hiddenItems : resolved; const keyword = debouncedSearch.trim().toLowerCase(); const favoriteSet = new Set(favorites); const filtered = pool.filter((item) => { if (view === "favorites" && !favoriteSet.has(catalogIdOf(item))) return false; if (source !== "all" && (item.sourceId ?? "original") !== source) return false; if (modeFilter !== "all" && item.mode !== modeFilter) return false; if (category !== "all" && (item.category?.trim() ?? "") !== category) return false; if (!keyword) return true; const haystack = [item.title, item.description, item.category, categoryLabel(item), ...(item.tags ?? [])].join(" ").toLowerCase(); return haystack.includes(keyword); }); if (sort === "title") return [...filtered].sort((left, right) => left.title.localeCompare(right.title, "zh-Hans-CN")); return filtered; }, [view, resolved, hiddenItems, debouncedSearch, favorites, source, modeFilter, category, sort, categoryLabel]); const paged = useMemo(() => visible.slice((page - 1) * pageSize, page * pageSize), [visible, page, pageSize]); const handleUse = useCallback( (item: CreationInspiration) => { navigate("/create", { state: { inspirationPrompt: fillablePrompt(item), inspirationMode: item.mode } }); }, [navigate], ); const handleCopy = useCallback( (text: string, label: string) => { void navigator.clipboard.writeText(text).then( () => message.success(`${label}已复制`), () => message.error("复制失败,请手动选中文本"), ); }, [message], ); const handleDelete = useCallback( (item: CreationInspiration) => { const id = catalogIdOf(item); const isCustom = isCustomInspiration(id); modal.confirm({ className: "workspace-modal workspace-modal-compact", title: isCustom ? "删除这条灵感?" : "从库中隐藏这条灵感?", content: isCustom ? `「${item.title}」会从本地灵感库移除,此操作不可撤销。` : `「${item.title}」来自外部目录,不能真正删除;隐藏后可用「已隐藏」视图恢复,重新导入外部目录也不会让它复现。`, okText: isCustom ? "删除" : "隐藏", okButtonProps: { danger: true }, cancelText: "保留", onOk: () => { removeEntry(id); if (detail && catalogIdOf(detail) === id) setDetail(null); message.success(isCustom ? "已删除" : "已隐藏"); }, }); }, [detail, message, modal, removeEntry], ); const handleSubmit = useCallback( (draft: Parameters[0]) => { if (editing) { updateEntry(catalogIdOf(editing), draft); message.success("已保存修改"); setDetail((current) => (current && catalogIdOf(current) === catalogIdOf(editing) ? { ...current, ...draft } : current)); setEditing(null); return; } createEntry(draft); // 新建后把筛选整体清掉:自建条目的来源固定是「原创」、分类也常和当前筛选不符, // 不清的话用户刚点完"创建"却看不到自己建的东西。 setSource("all"); setModeFilter("all"); setCategory("all"); setView("all"); setSearch(""); message.success("灵感已创建"); }, [createEntry, editing, message, updateEntry], ); const handleExport = useCallback(() => { downloadText(exportPayload(), exportFileName()); message.success("已导出本地改动(自建条目 / 改写 / 隐藏 / 收藏)"); }, [exportPayload, message]); const handleImportFile = useCallback( async (file: File) => { try { const result = importPayload(await file.text()); message.success(`导入完成:新增 ${result.created},覆盖 ${result.updated}${result.skipped ? `,跳过 ${result.skipped} 条无效数据` : ""}`); } catch (error) { message.error(error instanceof Error ? error.message : "导入失败"); } }, [importPayload, message], ); const headerActions = ( <> { const file = event.target.files?.[0]; // 同一个文件连续导入两次也要触发,所以每次都清空 value。 event.target.value = ""; if (file) void handleImportFile(file); }} /> ); const total = catalog ? resolved.length + hiddenItems.length : 0; return ( {loadError ? ( setReloadKey((key) => key + 1)} /> ) : !catalog || !hydrated ? ( ) : (
{/* 图片 / 视频 / 文本是两大主类,放在最上面做一级筛选。 */}
setModeFilter(value as ModeFilter)} ariaLabel="按创作类型筛选" /> 共 {visible.length} 条
{/* 分类标签是二级筛选:一眼看到库里都有什么,点一下就能缩小范围。 */}
{categoryChoices.slice(0, 16).map((choice) => ( ))}
} placeholder="搜标题、描述或标签" value={search} onChange={(event) => setSearch(event.target.value)} className="max-w-xs" /> setView(value as ViewFilter)} /> setSort(value as SortKey)} className="w-32" /> 共 {visible.length} 条
{paged.length ? (
{paged.map((item) => { const id = catalogIdOf(item); const favorite = favorites.includes(id); return (
setDetail(item)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setDetail(item); } }} >
{inspirationSourceOf(item).label}
{view === "hidden" ? ( ) : ( , label: "编辑" }, { key: "delete", icon: , label: isCustomInspiration(id) ? "删除" : "隐藏", danger: true }, ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "edit") { setEditing(item); setEditorOpen(true); } if (key === "delete") handleDelete(item); }, }} > )}

{item.title}

{item.description}

{categoryLabel(item)} {item.ratio ? {item.ratio} : null} {item.credit ? {item.credit} : null}
); })}
) : ( )} { setPage(next); setPageSize(size); }} />
)} setDetail(null)} onUse={handleUse} onCopy={handleCopy} onToggleFavorite={(item) => toggleFavorite(catalogIdOf(item))} onEdit={(item) => { setDetail(null); setEditing(item); setEditorOpen(true); }} onDelete={handleDelete} /> { setEditorOpen(false); setEditing(null); }} onSubmit={handleSubmit} />
); }