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 295374650a
commit 04689257d2
384 changed files with 3018 additions and 84 deletions

View File

@@ -0,0 +1,116 @@
/**
* 全量灵感目录的运行时加载。
*
* 3MB 量级的目录不能打进主 JS,全量留给灵感页按需拉取;
* 首页纵深画廊的池子同理,连提示词正文有几百 KB,走 gallery-pool.json,
* 浏览器 HTTP 缓存之外再做一层内存缓存,同一次会话只请求一遍。
*/
import { curatedInspirations, type CreationInspiration, type InspirationSourceId } from "@/lib/inspirations/catalog";
/** manifest.json 里每个来源带的分类码译名表。 */
type SourceManifest = { revision?: string; count?: number; categories?: Record<string, string> };
type CatalogManifest = { generatedAt?: string; sources?: Record<string, SourceManifest> };
export type InspirationCategoryOption = {
id: string;
label: string;
count: number;
};
export type InspirationCatalog = {
/** 原创 + 全部外部来源,已按来源归并。 */
entries: CreationInspiration[];
/** 各来源的分类选项,只保留有条目的那些。 */
categoriesBySource: Record<InspirationSourceId, InspirationCategoryOption[]>;
/** 未知分类码的兜底译名表,页面直接用 code 显示。 */
revisions: Record<string, string>;
loadedAt: number;
};
const CATALOG_SOURCES: { key: InspirationSourceId; file: string }[] = [
{ key: "seedance", file: "/inspirations/seedance.json" },
{ key: "haohaoxue", file: "/inspirations/haohaoxue.json" },
{ key: "youmind", file: "/inspirations/youmind.json" },
];
let pending: Promise<InspirationCatalog> | null = null;
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error(`加载灵感目录失败:${url} 返回 HTTP ${response.status}`);
return (await response.json()) as T;
}
/** 目录文件是构建期生成的,但仍然是外部数据:形状不对就抛错,不能当成空目录糊过去。 */
function readEntries(payload: unknown, file: string): CreationInspiration[] {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error(`${file} 结构不是目录格式`);
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) throw new Error(`${file} 缺少 entries 列表`);
return entries as CreationInspiration[];
}
async function buildCatalog(): Promise<InspirationCatalog> {
const [manifest, ...packs] = await Promise.all([
fetchJson<CatalogManifest>("/inspirations/manifest.json").catch(() => ({ sources: {} }) as CatalogManifest),
...CATALOG_SOURCES.map((source) => fetchJson<unknown>(source.file).then((payload) => readEntries(payload, source.file))),
]);
const entriesBySource = new Map<InspirationSourceId, CreationInspiration[]>();
CATALOG_SOURCES.forEach((source, index) => entriesBySource.set(source.key, packs[index]));
entriesBySource.set("original", curatedInspirations);
const categoriesBySource = {} as Record<InspirationSourceId, InspirationCategoryOption[]>;
const revisions: Record<string, string> = {};
for (const [sourceId, sourceEntries] of entriesBySource) {
if (manifest.sources?.[sourceId]?.revision) revisions[sourceId] = manifest.sources[sourceId]!.revision!;
const labels = manifest.sources?.[sourceId]?.categories ?? {};
const counts = new Map<string, number>();
for (const entry of sourceEntries) {
const category = entry.category?.trim();
if (category) counts.set(category, (counts.get(category) ?? 0) + 1);
}
categoriesBySource[sourceId] = [...counts.entries()]
.map(([id, count]) => ({ id, label: labels[id] ?? id, count }))
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-Hans-CN"));
}
return {
entries: [...entriesBySource.values()].flat(),
categoriesBySource,
revisions,
loadedAt: Date.now(),
};
}
/** 同一次会话只拉一遍;失败后清空缓存,下次进入页面还能重试。 */
export function loadInspirationCatalog(): Promise<InspirationCatalog> {
if (!pending) {
pending = buildCatalog().catch((error) => {
pending = null;
throw error;
});
}
return pending;
}
/** 首页画廊的池子:带本地缩略图的精选条目,正文一并带着,点卡片就能直接填词。 */
const GALLERY_POOL_FILE = "/inspirations/gallery-pool.json";
let galleryPool: Promise<CreationInspiration[]> | null = null;
/**
* 池子有几百条、几百 KB,是全量目录之外的单独一份,只服务画廊一处。
* 拿不到就退回打包的原创条目——画廊宁可少几张,也不该空着。
*/
export function loadGalleryPool(): Promise<CreationInspiration[]> {
if (!galleryPool) {
galleryPool = fetchJson<unknown>(GALLERY_POOL_FILE)
.then((payload) => [...curatedInspirations, ...readEntries(payload, GALLERY_POOL_FILE)])
.catch((error) => {
console.warn("画廊池加载失败,退回打包的原创条目", error);
galleryPool = null;
return curatedInspirations;
});
}
return galleryPool;
}