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): 功能清单与地图 - 补充纵深画廊与灵感库

功能清单新增「创作首页纵深画廊与灵感库」一节,并在功能地图里记下画廊、
灵感页、运行时目录加载和导入脚本各自的代码入口;待测试清单记下浏览器验收结果
与仍未确认的边界(弱网退回、窄屏、文本条目偏少、外部来源署名)。
This commit is contained in:
地球观察员
2026-10-08 19:55:04 +08:00
committed by GitHub
parent 87ecb3000c
commit dc2cc71058
384 changed files with 3018 additions and 84 deletions

View File

@@ -0,0 +1,225 @@
/**
* 灵感库的本地管理状态:用户的增删改、收藏与隐藏。
*
* 内置目录(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();
},
},
),
);