* 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): 功能清单与地图 - 补充纵深画廊与灵感库 功能清单新增「创作首页纵深画廊与灵感库」一节,并在功能地图里记下画廊、 灵感页、运行时目录加载和导入脚本各自的代码入口;待测试清单记下浏览器验收结果 与仍未确认的边界(弱网退回、窄屏、文本条目偏少、外部来源署名)。
226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
/**
|
||
* 灵感库的本地管理状态:用户的增删改、收藏与隐藏。
|
||
*
|
||
* 内置目录(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();
|
||
},
|
||
},
|
||
),
|
||
);
|