Files
ttstd-infinite-canvas/web/src/stores/use-inspiration-store.ts
地球观察员 04689257d2 feat(create): 创作首页与灵感库 - 纵深画廊、灵感页面、外部提示词池 (#708)
* feat(create): 创作首页与灵感库 - 纵深画廊、灵感页面、外部提示词池

创作首页空态换成直线纵深图片画廊:11 张横版卡片沿 Z 轴前后排队,当前一张
在最前最大,滚轮、左右按钮或自动轮播切换,卡片带玻璃描边和"行动点 / 标题 /
描述"三段信息,跟随输入框的创作类型联动换队。队列是随机取样并记住最近出现过的
条目,走完一轮自动换批,不再反复看到同样那几张。

左侧「创作」下方新增灵感页:1341 条提示词按来源、分类和创作类型筛选,
支持搜索、收藏,以及自建灵感的新建、编辑、删除和 JSON 导入导出。

新增 scripts/import-inspirations.mjs,从 awesome-seedance、好好学 AI 和
YouMind 两个姊妹库同步条目,并为画廊池生成本地 webp 缩略图(560px / q72,
约 35KB 一张)。上游封面平均 400KB~700KB,直接引用一屏 11 张要拉 5MB 以上,
换成本地缩略图后约 400KB。

画廊池改走 public/inspirations/gallery-pool.json 运行时拉取:几百条提示词正文
压在首屏 JS 上是白背的,而这份数据只服务画廊一处。缩略图等距抽取,横跨各来源
的全部分类;只收描述非空的条目,保证卡片三行版式不缺角。

* docs(create): 功能清单与地图 - 补充纵深画廊与灵感库

功能清单新增「创作首页纵深画廊与灵感库」一节,并在功能地图里记下画廊、
灵感页、运行时目录加载和导入脚本各自的代码入口;待测试清单记下浏览器验收结果
与仍未确认的边界(弱网退回、窄屏、文本条目偏少、外部来源署名)。
2026-10-08 19:55:04 +08:00

226 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 灵感库的本地管理状态:用户的增删改、收藏与隐藏。
*
* 内置目录(public/inspirations 与 lib/inspirations/highlights)始终只读,
* 这里只保存叠加层,重新导入外部目录后用户的改动仍然对得上 id。
* 落盘走 localforage 的用户作用域,切换账号不会串数据;理由见 AGENTS 第 4 节。
*/
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { catalogIdOf, type CreationInspiration, type InspirationSourceId } from "@/lib/inspirations/catalog";
import { CUSTOM_ID_PREFIX, isCustomInspiration, type InspirationOverride } from "@/lib/inspirations/resolve";
import { localForageStorageForScope } from "@/lib/localforage-storage";
export const INSPIRATION_STORE_KEY = "open_ai_canvas:inspirations";
/** 导入导出文件的格式版本,字段变化时递增并在这里做迁移。 */
export const INSPIRATION_PAYLOAD_VERSION = 1;
/** 用户可以自己填的部分;id、来源和署名由系统生成。 */
export type InspirationDraft = {
title: string;
description: string;
image: string;
mode: CreationInspiration["mode"];
prompt: string;
promptZh?: string;
category?: string;
tags?: string[];
ratio?: string;
};
export type InspirationPayload = {
version: number;
exportedAt: string;
custom: CreationInspiration[];
overrides: Record<string, InspirationOverride>;
hidden: string[];
favorites: string[];
};
export type InspirationImportResult = {
created: number;
updated: number;
skipped: number;
};
type InspirationStore = {
hydrated: boolean;
custom: CreationInspiration[];
overrides: Record<string, InspirationOverride>;
hidden: string[];
favorites: string[];
createEntry: (draft: InspirationDraft) => string;
updateEntry: (id: string, patch: InspirationOverride) => void;
/** 自建条目真删,内置条目转为隐藏——内置数据来自外部目录,删了下次同步还会回来。 */
removeEntry: (id: string) => void;
restoreEntry: (id: string) => void;
toggleFavorite: (id: string) => void;
exportPayload: () => string;
importPayload: (text: string) => InspirationImportResult;
resetCustomizations: () => void;
/** 持久化回填完成;页面据此决定要不要显示骨架态。 */
markHydrated: () => void;
};
function nonEmpty(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) throw new Error(`缺少必填字段 ${field}`);
return value.trim();
}
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const items = value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim());
return items.length ? items : undefined;
}
/** 编辑器传来的草稿先按同一套规则收敛,不做「空字符串也写进去」的兜底。 */
export function normalizeDraft(input: unknown): InspirationDraft {
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("灵感内容格式不正确");
const raw = input as Record<string, unknown>;
if (raw.mode !== "text" && raw.mode !== "image" && raw.mode !== "video") throw new Error("创作类型只能是 text、image 或 video");
return {
title: nonEmpty(raw.title, "标题"),
description: typeof raw.description === "string" ? raw.description.trim() : "",
image: nonEmpty(raw.image, "封面地址"),
mode: raw.mode,
prompt: nonEmpty(raw.prompt, "提示词正文"),
...(typeof raw.promptZh === "string" && raw.promptZh.trim() ? { promptZh: raw.promptZh.trim() } : {}),
...(typeof raw.category === "string" && raw.category.trim() ? { category: raw.category.trim() } : {}),
...(asStringArray(raw.tags) ? { tags: asStringArray(raw.tags) } : {}),
...(typeof raw.ratio === "string" && raw.ratio.trim() ? { ratio: raw.ratio.trim() } : {}),
};
}
/** 导入的外部条目同样要过一遍校验:来源不明的文件不能直接写进本地库。 */
function normalizeImportedEntry(input: unknown): CreationInspiration | null {
try {
const draft = normalizeDraft(input);
const raw = input as Record<string, unknown>;
const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `${CUSTOM_ID_PREFIX}${crypto.randomUUID()}`;
return {
...draft,
id: id.startsWith(CUSTOM_ID_PREFIX) ? id : `${CUSTOM_ID_PREFIX}${id}`,
sourceId: (typeof raw.sourceId === "string" ? raw.sourceId : "original") as InspirationSourceId,
...(typeof raw.credit === "string" && raw.credit.trim() ? { credit: raw.credit.trim() } : {}),
...(typeof raw.sourceUrl === "string" && raw.sourceUrl.trim() ? { sourceUrl: raw.sourceUrl.trim() } : {}),
};
} catch {
return null;
}
}
export const useInspirationStore = create<InspirationStore>()(
persist(
(set, get) => ({
hydrated: false,
custom: [],
overrides: {},
hidden: [],
favorites: [],
createEntry: (draft) => {
const id = `${CUSTOM_ID_PREFIX}${crypto.randomUUID()}`;
const entry: CreationInspiration = { ...normalizeDraft(draft), id, sourceId: "original" };
set((state) => ({ custom: [entry, ...state.custom] }));
return id;
},
updateEntry: (id, patch) => {
if (!id) throw new Error("缺少要修改的灵感 ID");
const sanitized = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined && value !== ""));
if (!Object.keys(sanitized).length) return;
if (isCustomInspiration(id)) {
set((state) => ({ custom: state.custom.map((item) => (catalogIdOf(item) === id ? { ...item, ...sanitized } : item)) }));
return;
}
set((state) => ({ overrides: { ...state.overrides, [id]: { ...state.overrides[id], ...sanitized } } }));
},
removeEntry: (id) => {
if (!id) throw new Error("缺少要删除的灵感 ID");
if (isCustomInspiration(id)) {
set((state) => ({
custom: state.custom.filter((item) => catalogIdOf(item) !== id),
favorites: state.favorites.filter((item) => item !== id),
}));
return;
}
set((state) => ({
hidden: state.hidden.includes(id) ? state.hidden : [...state.hidden, id],
favorites: state.favorites.filter((item) => item !== id),
overrides: Object.fromEntries(Object.entries(state.overrides).filter(([key]) => key !== id)),
}));
},
restoreEntry: (id) => set((state) => ({ hidden: state.hidden.filter((item) => item !== id) })),
toggleFavorite: (id) => {
if (!id) throw new Error("缺少要收藏的灵感 ID");
set((state) => ({ favorites: state.favorites.includes(id) ? state.favorites.filter((item) => item !== id) : [id, ...state.favorites] }));
},
exportPayload: () => {
const state = get();
const payload: InspirationPayload = {
version: INSPIRATION_PAYLOAD_VERSION,
exportedAt: new Date().toISOString(),
custom: state.custom,
overrides: state.overrides,
hidden: state.hidden,
favorites: state.favorites,
};
return JSON.stringify(payload, null, 2);
},
importPayload: (text) => {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("文件不是合法的 JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("文件结构不是灵感导出格式");
const raw = parsed as Record<string, unknown>;
if (raw.version !== INSPIRATION_PAYLOAD_VERSION) throw new Error(`不支持的文件版本:${String(raw.version)},当前支持 ${INSPIRATION_PAYLOAD_VERSION}`);
if (!Array.isArray(raw.custom)) throw new Error("文件缺少 custom 列表");
const incoming = raw.custom.map(normalizeImportedEntry);
const entries = incoming.filter((item): item is CreationInspiration => item !== null);
const skipped = incoming.length - entries.length;
const existing = new Map(get().custom.map((item) => [catalogIdOf(item), item]));
let created = 0;
let updated = 0;
for (const entry of entries) {
if (existing.has(entry.id!)) updated += 1;
else created += 1;
}
const overrides = raw.overrides && typeof raw.overrides === "object" && !Array.isArray(raw.overrides) ? (raw.overrides as Record<string, InspirationOverride>) : {};
const hidden = Array.isArray(raw.hidden) ? raw.hidden.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : [];
const favorites = Array.isArray(raw.favorites) ? raw.favorites.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : [];
set((state) => ({
custom: [...entries, ...state.custom.filter((item) => !entries.some((incomingItem) => incomingItem.id === catalogIdOf(item)))],
overrides: { ...state.overrides, ...overrides },
hidden: Array.from(new Set([...state.hidden, ...hidden])),
favorites: Array.from(new Set([...state.favorites, ...favorites])),
}));
return { created, updated, skipped };
},
resetCustomizations: () => set({ custom: [], overrides: {}, hidden: [], favorites: [] }),
markHydrated: () => set({ hydrated: true }),
}),
{
name: INSPIRATION_STORE_KEY,
storage: createJSONStorage(() => localForageStorageForScope()),
partialize: (state) => ({ custom: state.custom, overrides: state.overrides, hidden: state.hidden, favorites: state.favorites }),
onRehydrateStorage: () => (state) => {
state?.markHydrated();
},
},
),
);