503 lines
26 KiB
TypeScript
503 lines
26 KiB
TypeScript
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<InspirationCatalog | null>(null);
|
|
const [loadError, setLoadError] = useState("");
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
|
|
const [source, setSource] = useState<SourceFilter>("all");
|
|
const [modeFilter, setModeFilter] = useState<ModeFilter>("all");
|
|
const [category, setCategory] = useState("all");
|
|
const [view, setView] = useState<ViewFilter>("all");
|
|
const [sort, setSort] = useState<SortKey>("default");
|
|
const [search, setSearch] = useState("");
|
|
const debouncedSearch = useDebouncedValue(search, 250);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(24);
|
|
|
|
const [detail, setDetail] = useState<CreationInspiration | null>(null);
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [editing, setEditing] = useState<CreationInspiration | null>(null);
|
|
const importRef = useRef<HTMLInputElement>(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<string, Map<string, string>>();
|
|
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<CategoryChoice[]>(() => {
|
|
if (!catalog) return [];
|
|
const sources = source === "all" ? SOURCE_ORDER : [source];
|
|
const merged = new Map<string, { label: string; count: number }>();
|
|
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<SourceFilter, number>();
|
|
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<ModeFilter, number> = { 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<typeof createEntry>[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 = (
|
|
<>
|
|
<Button icon={<Import className="size-4" />} onClick={() => importRef.current?.click()}>
|
|
导入
|
|
</Button>
|
|
<Button icon={<Download className="size-4" />} onClick={handleExport}>
|
|
导出
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<Plus className="size-4" />}
|
|
onClick={() => {
|
|
setEditing(null);
|
|
setEditorOpen(true);
|
|
}}
|
|
>
|
|
新建灵感
|
|
</Button>
|
|
<input
|
|
ref={importRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
// 同一个文件连续导入两次也要触发,所以每次都清空 value。
|
|
event.target.value = "";
|
|
if (file) void handleImportFile(file);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
const total = catalog ? resolved.length + hiddenItems.length : 0;
|
|
|
|
return (
|
|
<WorkspacePage>
|
|
<PageHeader title="灵感库" description={`${total} 条提示词,按来源、分类和创作类型挑,点开可以一键带去创作页`} actions={headerActions} />
|
|
|
|
{loadError ? (
|
|
<WorkspaceErrorState title="灵感目录加载失败" description={loadError} onRetry={() => setReloadKey((key) => key + 1)} />
|
|
) : !catalog || !hydrated ? (
|
|
<WorkspaceLoadingState label="正在加载灵感目录" detail="首次进入需要拉取全量目录,之后会走浏览器缓存" rows={4} />
|
|
) : (
|
|
<div className="mt-4 flex flex-col gap-4">
|
|
{/* 图片 / 视频 / 文本是两大主类,放在最上面做一级筛选。 */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<SegmentedControl options={modeOptions} value={modeFilter} onChange={(value) => setModeFilter(value as ModeFilter)} ariaLabel="按创作类型筛选" />
|
|
<span className="text-xs text-foreground/50">共 {visible.length} 条</span>
|
|
</div>
|
|
|
|
{/* 分类标签是二级筛选:一眼看到库里都有什么,点一下就能缩小范围。 */}
|
|
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="按分类筛选">
|
|
<button type="button" className="inspiration-chip" aria-pressed={category === "all"} onClick={() => setCategory("all")}>
|
|
全部分类
|
|
<span className="inspiration-chip-count">{source === "all" ? resolved.length : (sourceCounts.get(source) ?? 0)}</span>
|
|
</button>
|
|
{categoryChoices.slice(0, 16).map((choice) => (
|
|
<button key={choice.value} type="button" className="inspiration-chip" aria-pressed={category === choice.value} onClick={() => setCategory(category === choice.value ? "all" : choice.value)}>
|
|
{choice.label}
|
|
<span className="inspiration-chip-count">{choice.count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input allowClear prefix={<Search className="size-4 text-foreground/40" />} placeholder="搜标题、描述或标签" value={search} onChange={(event) => setSearch(event.target.value)} className="max-w-xs" />
|
|
<SegmentedControl options={VIEW_OPTIONS} value={view} onChange={(value) => setView(value as ViewFilter)} />
|
|
<Select
|
|
options={[
|
|
{ label: `全部来源 (${resolved.length})`, value: "all" },
|
|
...SOURCE_ORDER.filter((key) => (sourceCounts.get(key) ?? 0) > 0).map((key) => ({
|
|
label: `${inspirationSources[key].name ?? inspirationSources[key].label} (${sourceCounts.get(key) ?? 0})`,
|
|
value: key,
|
|
})),
|
|
]}
|
|
value={source}
|
|
onChange={(value) => {
|
|
setSource(value as SourceFilter);
|
|
setCategory("all");
|
|
}}
|
|
className="w-52"
|
|
/>
|
|
<Select options={SORT_OPTIONS} value={sort} onChange={(value) => setSort(value as SortKey)} className="w-32" />
|
|
<span className="text-xs text-foreground/50">共 {visible.length} 条</span>
|
|
</div>
|
|
|
|
{paged.length ? (
|
|
<div className="inspiration-gallery">
|
|
{paged.map((item) => {
|
|
const id = catalogIdOf(item);
|
|
const favorite = favorites.includes(id);
|
|
return (
|
|
<article
|
|
key={id}
|
|
className="inspiration-card"
|
|
tabIndex={0}
|
|
role="button"
|
|
onClick={() => setDetail(item)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setDetail(item);
|
|
}
|
|
}}
|
|
>
|
|
<div className="inspiration-card-cover">
|
|
<img src={item.image} alt="" loading="lazy" decoding="async" referrerPolicy="no-referrer" />
|
|
<span className="inspiration-card-source">{inspirationSourceOf(item).label}</span>
|
|
<div className="inspiration-card-actions">
|
|
<Tooltip title={favorite ? "取消收藏" : "收藏"}>
|
|
<button
|
|
type="button"
|
|
className="inspiration-card-action"
|
|
aria-pressed={favorite}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
toggleFavorite(id);
|
|
}}
|
|
>
|
|
<Heart className="size-3.5" fill={favorite ? "currentColor" : "none"} />
|
|
</button>
|
|
</Tooltip>
|
|
{view === "hidden" ? (
|
|
<Tooltip title="恢复显示">
|
|
<button
|
|
type="button"
|
|
className="inspiration-card-action"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
restoreEntry(id);
|
|
}}
|
|
>
|
|
<RotateCcw className="size-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
) : (
|
|
<Dropdown
|
|
trigger={["click"]}
|
|
menu={{
|
|
items: [
|
|
{ key: "edit", icon: <Pencil className="size-3.5" />, label: "编辑" },
|
|
{ key: "delete", icon: <Trash2 className="size-3.5" />, label: isCustomInspiration(id) ? "删除" : "隐藏", danger: true },
|
|
],
|
|
onClick: ({ key, domEvent }) => {
|
|
domEvent.stopPropagation();
|
|
if (key === "edit") {
|
|
setEditing(item);
|
|
setEditorOpen(true);
|
|
}
|
|
if (key === "delete") handleDelete(item);
|
|
},
|
|
}}
|
|
>
|
|
<button type="button" className="inspiration-card-action" aria-label="更多操作" onClick={(event) => event.stopPropagation()}>
|
|
<MoreHorizontal className="size-3.5" />
|
|
</button>
|
|
</Dropdown>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="inspiration-card-body">
|
|
<h3 className="inspiration-card-title">{item.title}</h3>
|
|
<p className="inspiration-card-desc">{item.description}</p>
|
|
<div className="inspiration-card-tags">
|
|
<span className="inspiration-chip">{categoryLabel(item)}</span>
|
|
{item.ratio ? <span className="inspiration-chip">{item.ratio}</span> : null}
|
|
{item.credit ? <span className="inspiration-chip">{item.credit}</span> : null}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<WorkspaceState
|
|
icon="empty"
|
|
title={view === "favorites" ? "还没有收藏" : view === "hidden" ? "没有隐藏的条目" : "没有符合条件的灵感"}
|
|
description={view === "favorites" ? "在卡片右上角点一下爱心,收藏的灵感会集中在这里。" : "换个来源、分类或关键词再试试。"}
|
|
/>
|
|
)}
|
|
|
|
<PaginationBar
|
|
current={page}
|
|
pageSize={pageSize}
|
|
total={visible.length}
|
|
onChange={(next, size) => {
|
|
setPage(next);
|
|
setPageSize(size);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<InspirationDetailModal
|
|
item={detail}
|
|
favorite={detail ? favorites.includes(catalogIdOf(detail)) : false}
|
|
categoryLabel={categoryLabel}
|
|
onClose={() => setDetail(null)}
|
|
onUse={handleUse}
|
|
onCopy={handleCopy}
|
|
onToggleFavorite={(item) => toggleFavorite(catalogIdOf(item))}
|
|
onEdit={(item) => {
|
|
setDetail(null);
|
|
setEditing(item);
|
|
setEditorOpen(true);
|
|
}}
|
|
onDelete={handleDelete}
|
|
/>
|
|
|
|
<InspirationEditorModal
|
|
open={editorOpen}
|
|
item={editing}
|
|
categoryOptions={categoryChoices}
|
|
onClose={() => {
|
|
setEditorOpen(false);
|
|
setEditing(null);
|
|
}}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</WorkspacePage>
|
|
);
|
|
}
|