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

@@ -45,6 +45,7 @@ function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[];
{
items: [
{ ...toolItem("create", "/"), id: "home", title: "创作" },
{ ...toolItem("inspirations", "/inspirations"), title: "灵感" },
{ ...toolItem("projects", "/projects"), title: "短剧 Agent" },
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
],

View File

@@ -1,4 +1,4 @@
import { Blocks, CircleDollarSign, Clapperboard, Images, LibraryBig, ListTodo, PanelsTopLeft, Settings, WandSparkles } from "lucide-react";
import { Blocks, CircleDollarSign, Clapperboard, GalleryVerticalEnd, Images, LibraryBig, ListTodo, PanelsTopLeft, Settings, WandSparkles } from "lucide-react";
export const navigationTools = [
{
@@ -7,6 +7,12 @@ export const navigationTools = [
icon: WandSparkles,
section: "创作空间",
},
{
slug: "inspirations",
label: "灵感",
icon: GalleryVerticalEnd,
section: "创作空间",
},
{
slug: "projects",
label: "短剧创作",

View File

@@ -0,0 +1,5 @@
/**
* 创作类型。放在共享层是因为灵感目录(lib/inspirations)和创作页都要用它,
* 而 lib 不该反向依赖 pages。creation-assets 仍然转出这个名字,原有 import 不用改。
*/
export type CreationMode = "text" | "image" | "video";

View File

@@ -0,0 +1,155 @@
import type { CreationMode } from "@/lib/creation-mode";
import { HIGHLIGHT_REVISIONS } from "./source-revisions";
export type InspirationSourceId = "original" | "chatgpt-prompts" | "seedance" | "haohaoxue" | "youmind";
export type InspirationSourceInfo = {
/** 卡片来源行上的短标签。 */
label: string;
/** 页脚链接文本,通常是仓库短名或站点名。 */
name?: string;
/** 来源地址:仓库或站点首页。 */
repository?: string;
revision?: string;
license?: string;
notice?: string;
};
/** 来源表:新增外部灵感源时在这里加一项,卡片与页脚都按它渲染许可标识。 */
export const inspirationSources: Record<InspirationSourceId, InspirationSourceInfo> = {
original: { label: "原创提示词" },
"chatgpt-prompts": {
label: "开源改编 · CC0",
name: "awesome-chatgpt-prompts",
repository: "https://github.com/f/awesome-chatgpt-prompts",
revision: "f78a1c5136fa080155d928e0d7e2b4a41ddef03e",
license: "CC0-1.0",
notice: "8 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。",
},
seedance: {
label: "社区精选 · CC BY 4.0",
name: "awesome-seedance",
repository: "https://github.com/LearnPrompt/awesome-seedance",
revision: HIGHLIGHT_REVISIONS.seedance,
license: "CC BY 4.0",
notice: "视频提示词与封面来自 awesome-seedance(数据每日同步自 goodcase.ai),按 CC BY 4.0 署名使用。封面为原案例画面,不代表本平台生成结果。",
},
haohaoxue: {
label: "好好学 AI · 图片提示词",
name: "好好学 AI",
repository: "https://www.haohaoxue.com/prompts/",
revision: HIGHLIGHT_REVISIONS.haohaoxue,
license: "站点公开数据 · robots.txt 允许抓取",
notice: "图片提示词中英双语正文与封面来自好好学 AI 提示词库(haohaoxue.com),该站 robots.txt 明确允许包括 ClaudeBot 在内的抓取。作者署名取数据内置的 author 字段。封面为原提示词示例图,不代表本平台生成结果。",
},
youmind: {
label: "YouMind 提示词库 · CC BY 4.0",
name: "YouMind",
repository: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts",
revision: HIGHLIGHT_REVISIONS.youmind,
license: "CC BY 4.0",
notice: "图片提示词与配图来自 YouMind OpenLab 的 awesome-nano-banana-pro-prompts 与 awesome-gpt-image-2 两个姊妹库(Nano Banana Pro / GPT Image 2 中文提示词),按 CC BY 4.0 署名使用。作者署名取条目内置的 author 字段。配图为原提示词的生成示例,不代表本平台生成结果。",
},
};
export function inspirationSourceOf(item: CreationInspiration): InspirationSourceInfo {
return inspirationSources[item.sourceId ?? "original"];
}
/**
* 灵感条目。
* 外部来源的 id 是 "<来源>:<上游标识>",本项目原创没有 id,由 catalogIdOf 按来源加标题兜底,
* 两者合起来作为收藏、隐藏、改写的稳定键。
*/
export type CreationInspiration = {
id?: string;
title: string;
description: string;
image: string;
mode: CreationMode;
prompt: string;
/** 中文正文,双语来源才有(好好学 AI 的中英双语条目)。 */
promptZh?: string;
/** 上游分类 id,标签表见 public/inspirations/manifest.json。 */
category?: string;
tags?: string[];
/** 画面比例,例如 "4:5";上游有才带。 */
ratio?: string;
featured?: boolean;
/** 来源归属;缺省视为本项目原创。 */
sourceId?: InspirationSourceId;
/** 署名,例如外部案例的作者或提示词角色名。 */
credit?: string;
/** 回到上游原页的链接。 */
sourceUrl?: string;
};
/** 条目在本地存储里的稳定键。 */
export function catalogIdOf(item: CreationInspiration): string {
return item.id ?? `${item.sourceId ?? "original"}:${item.title}`;
}
/** 本项目编写的灵感,与外部导入的条目合并后展示。 */
const curatedWorks: CreationInspiration[] = [
{
title: "雨夜霓虹 · 电影感开场",
description: "宽银幕构图、环境反光与缓慢推进镜头",
image: "/short-drama-styles/cyberpunk-neon.jpg",
mode: "video",
prompt: "雨夜城市街口,霓虹灯倒映在湿润路面,主角撑伞穿过人群,镜头从远景缓慢推进到侧脸特写,电影感光影,16:9",
featured: true,
},
{
title: "港风天台重逢",
description: "复古色彩与克制的人物情绪",
image: "/short-drama-styles/retro-hong-kong.jpg",
mode: "video",
prompt: "九十年代港风天台,黄昏逆光,两位多年未见的旧友隔着晾衣绳对望,风吹动衣角,镜头轻微手持并缓慢靠近",
},
{
title: "暗巷悬疑追逐",
description: "高反差黑色电影与紧张节奏",
image: "/short-drama-styles/suspense-noir.jpg",
mode: "video",
prompt: "黑白悬疑电影风格,侦探在狭窄暗巷中追逐神秘人,路灯切割出强烈明暗,低机位跟拍,节奏逐渐加快",
},
{
title: "东方幻想角色设定",
description: "角色概念图与细腻材质表达",
image: "/short-drama-styles/fantasy-3d.jpg",
mode: "image",
prompt: "东方幻想世界的年轻女剑客角色设定,银白发饰、层叠轻甲与红色披风,山雾背景,细腻材质,电影级概念设计",
},
{
title: "温暖室内对话",
description: "生活化场景与柔和叙事氛围",
image: "/welcome/wing-it/barn.webp",
mode: "text",
prompt: "请帮我写一场发生在暖色灯光客厅里的母女对话戏:女儿准备离开家乡,母亲表面平静但一直在整理旧物,用动作和潜台词推进情绪。",
},
{ title: "森林里的最后一封信", description: "童话叙事 · 从角色愿望建立冲突", image: "/short-drama-styles/storybook-fantasy.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Storyteller", prompt: "作为故事讲述者,为亲子观众写一个关于坚持的森林童话。主角是一只替动物送信的小狐狸,它必须在第一场雪之前送出一封没有地址的信。先给出角色的愿望与阻碍,再设计三个递进的事件,最后用一个可见的行动完成情绪转折。避免说教。" },
{ title: "末班车上的陌生人", description: "短剧编剧 · 让对白藏住真正目的", image: "/short-drama-styles/urban-live-action.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Screenwriter", prompt: "作为编剧,设计一段发生在末班公交车上的三分钟短剧。两位陌生人都声称自己丢失了一本笔记,但只有一人说真话。先定义人物动机、场景限制和各自隐瞒的信息,再写动作、对白与反转结尾。全剧限制两个角色、一个场景,适合低成本实拍。" },
{ title: "未来城市的记忆修补师", description: "科幻大纲 · 世界规则与人物代价", image: "/short-drama-styles/future-tech.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Novelist", prompt: "作为小说家,构思一部关于记忆修补师的未来都市短篇。城市居民能删除痛苦记忆,但主角发现每次删除都会遗失一种颜色。建立三条世界规则、主人公的核心缺陷、五个转折和付出代价的结局。先交付故事大纲,不直接铺写长篇。" },
{ title: "把告别写成一场雨", description: "诗性旁白 · 少解释,多感官细节", image: "/short-drama-styles/nature-healing.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Poet", prompt: "作为诗人,为一段雨中离乡的影像写四组中文旁白,每组不超过四十字。分别使用气味、触感、声音与光线表达告别,不直接使用悲伤、不舍或想念等情绪词。用朴素、可朗读的语言,给每组留一个停顿。" },
{ title: "一瓶饮料的夏日故事", description: "广告策划 · 人群、主张与三个镜头", image: "/short-drama-styles/real-life.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Advertiser", prompt: "作为广告策划,为面向年轻通勤者的无糖气泡饮料设计十五秒广告。定义目标人群、核心场景和一句传播主张,再给出三个镜头的画面、时长、音效与字幕。不要虚构营养或功效数据,用下班后一口清爽的体验讲故事。" },
{ title: "独立咖啡店的视觉语言", description: "品牌创意 · 让配色和语气讲同一件事", image: "/short-drama-styles/retro-hong-kong.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Creative Branding Strategist", prompt: "作为品牌策略师,为社区里一家安静的独立咖啡店提出三种视觉方向。品牌重视慢节奏、邻里关系和手作。每种方向包含色彩、摄影氛围、文字语气、三条社交内容主题和差异化理由。不要模仿已有连锁品牌的标识。" },
{ title: "给剧本做一次镜头诊断", description: "创作复盘 · 从叙事到声音提出修改", image: "/short-drama-styles/black-white-noir.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Film Critic", prompt: "请以电影评论者的视角审阅我接下来提供的剧本或分镜,从叙事、人物、构图、运镜和声音五个维度提出反馈。每条意见都引用我提供的片段,并给出一个能实际拍摄的修改方案。还没有收到材料时,先向我索取,不要假装看过影片。" },
{ title: "旧城街景的时代考据", description: "历史场景 · 区分史实、推测和虚构", image: "/short-drama-styles/period-live-action.jpg", mode: "text", sourceId: "chatgpt-prompts", credit: "Historian", prompt: "作为历史研究者,协助我设计民国早期城市街景的影视场景。先问清具体年份、地点和人物阶层,再列出服饰、交通、招牌、建筑和日常用语的考据清单。明确区分有来源的史实、合理推测与戏剧性虚构,不编造文献。" },
{ title: "月面独行", description: "远景到特写 · 太空探索的孤独感", image: "/short-drama-styles/space-opera.jpg", mode: "video", prompt: "月球背面的荒原,宇航员独自走向一座废弃天线。镜头从极远景缓慢推进,低角度阳光在月尘上拉出长影,步伐沉重,头盔反射星光,克制冷色,宽银幕电影构图,无字幕。" },
{ title: "手作星球早餐", description: "定格动画 · 柔软材质与小动作", image: "/short-drama-styles/clay-stop-motion.jpg", mode: "video", prompt: "黏土定格动画风格,一位小小宇航员在圆形厨房煎一颗星星形状的鸡蛋,鸡蛋轻轻跳起。手作指纹质感、暖奶油色、柔和侧光,固定中景,动作简短连续,可爱但不夸张。" },
{ title: "山水之间的一叶舟", description: "水墨留白 · 横向移镜与轻雾", image: "/short-drama-styles/ink-narrative.jpg", mode: "video", prompt: "中国水墨动画,一叶小舟缓缓穿过层叠远山,薄雾留白,舟上人抬手触碰落下的花瓣。镜头平稳横移,墨色有自然晕染,少量朱红点缀,画面安静,动作节奏舒缓。" },
{ title: "雨后的玻璃花房", description: "治愈场景 · 前景水滴与逆光", image: "/short-drama-styles/nature-healing.jpg", mode: "video", prompt: "清晨雨后的玻璃花房,镜头透过挂着水滴的叶片缓缓推向盛开的白花,阳光从侧后方进入,浅景深,真实植物纹理,空气中薄薄水汽,宁静自然,无人物和文字。" },
{ title: "都市少女角色转面", description: "角色概念 · 统一服装与形体比例", image: "/short-drama-styles/chinese-2d.jpg", mode: "image", prompt: "原创二维动画都市少女角色设定,短发、宽松工装夹克和帆布包,正面、侧面与背面三视图,服装细节和身高比例一致,干净线稿配平涂阴影,暖灰背景,不添加文字,不模仿已有动漫角色。" },
{ title: "梦境里的倒置房间", description: "超现实概念 · 重力反转与柔光", image: "/short-drama-styles/surreal-dream.jpg", mode: "image", prompt: "超现实电影场景概念图,一间漂浮在云海中的倒置卧室,床和书桌悬在天花板上,一扇门通向浅粉色天空。低饱和奶油与雾蓝色,柔和漫反射,精确透视,细腻材质,无人物,无文字。" },
{ title: "海边旅店的黄昏", description: "实拍场景 · 用道具留下人物痕迹", image: "/short-drama-styles/urban-live-action.jpg", mode: "image", prompt: "沿海小城旧旅店房间的电影置景概念图,窗外是黄昏海面,风吹动米白窗帘,桌上有两杯没有喝完的茶和一本翻开的书。暖橙与浅蓝对比,自然胶片颗粒,眼平中广角,没有人物和可读文字。" },
{ title: "鲜亮漫画封面", description: "平面构成 · 大色块与戏剧性透视", image: "/short-drama-styles/comic-pop.jpg", mode: "image", prompt: "原创漫画封面插画,一位骑单车的城市信使跃过雨后积水,戏剧性低角度透视,青蓝、珊瑚红与暖黄色大色块,半调网点,清晰黑色轮廓,背景简洁,为顶部标题留白但不生成文字。" },
{ title: "烘焙店的秘密帮手", description: "三维角色 · 柔和材质与亲切表情", image: "/short-drama-styles/three-d-cartoon.jpg", mode: "image", prompt: "原创三维动画角色概念图,一只穿着面粉围裙的小浣熊站在烘焙台前,鼻尖沾着面粉,手里举着刚烤好的面包。柔软毛发、温暖侧光、自然可爱的比例,橘棕色和奶油白配色,背景轻微虚化。" },
];
/** 本项目编写的条目,灵感页把它单独作为「原创」分类。 */
export const curatedInspirations: CreationInspiration[] = curatedWorks;
/** 需要对外声明的来源(原创不写声明),供页面署名与合规展示复用。 */
export function declaredInspirationSources(): InspirationSourceInfo[] {
return Object.values(inspirationSources).filter((source) => source.notice);
}

View File

@@ -0,0 +1,58 @@
/**
* 灵感目录的合成规则:内置目录是只读的,用户的增删改以「覆盖 / 隐藏 / 新增」三种形式叠在上面。
* 三个集合都不改内置数据本身,这样外部目录重新导入后用户的改动仍然对得上 id。
*/
import { catalogIdOf, type CreationInspiration } from "./catalog";
/** 用户可以改写的字段。id 与 sourceId 是归属标识,不参与改写。 */
export type InspirationOverride = Partial<Pick<CreationInspiration, "title" | "description" | "image" | "mode" | "prompt" | "promptZh" | "category" | "tags" | "ratio">>;
export type InspirationOverlay = {
/** 用户新建的条目,id 一定是自定义前缀。 */
custom: CreationInspiration[];
/** 按 id 覆盖内置条目。 */
overrides: Record<string, InspirationOverride>;
/** 被用户隐藏的内置条目 id。 */
hidden: string[];
};
export const EMPTY_OVERLAY: InspirationOverlay = { custom: [], overrides: {}, hidden: [] };
/** 自定义条目的 id 前缀,用来区分「可以真删」和「只能隐藏」。 */
export const CUSTOM_ID_PREFIX = "custom:";
export function isCustomInspiration(id: string): boolean {
return id.startsWith(CUSTOM_ID_PREFIX);
}
function withoutUndefined<T extends object>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "")) as T;
}
/**
* 把用户的改动叠到内置目录上,并按用户顺序把新建条目插在最前。
* 隐藏只对内置条目生效:新建条目直接删除,没必要留一个隐藏态。
*/
export function resolveInspirations(builtIn: CreationInspiration[], overlay: InspirationOverlay): CreationInspiration[] {
const hidden = new Set(overlay.hidden.filter((id) => !isCustomInspiration(id)));
const resolved = builtIn.flatMap((item) => {
const id = catalogIdOf(item);
if (hidden.has(id)) return [];
const override = overlay.overrides[id];
return [override ? { ...item, ...withoutUndefined(override) } : item];
});
return [...overlay.custom, ...resolved];
}
/** 当前生效条目里属于某个来源的部分,用于灵感页按来源筛选。 */
export function itemsBySource(items: CreationInspiration[], sourceId: string): CreationInspiration[] {
return items.filter((item) => (item.sourceId ?? "original") === sourceId);
}
/**
* 一键填入创作时优先用中文正文:外部图片提示词的原文是英文,
* 中文界面下把英文整段塞进编辑器是坏体验,双语来源就用中文那版。
*/
export function fillablePrompt(item: CreationInspiration): string {
return (item.promptZh || item.prompt || "").trim();
}

View File

@@ -0,0 +1,8 @@
// 由 scripts/import-inspirations.mjs 生成,勿手工编辑。
// 各来源同步时的上游版本号,供灵感页与画廊署名展示。
export const HIGHLIGHT_REVISIONS = {
seedance: "487c166e2f09487016452b90fec5e19a470af883",
haohaoxue: "2026-10-08",
youmind: "nano-banana@99bf4689 + gpt-image-2@3cc3f596",
};

View File

@@ -2,6 +2,7 @@ const workspaceRouteLoaders = {
assets: () => import("@/pages/assets"),
canvas: () => import("@/pages/canvas"),
create: () => import("@/pages/create"),
inspirations: () => import("@/pages/inspirations"),
projects: () => import("@/pages/projects"),
projectDetail: () => import("@/pages/projects/detail"),
};
@@ -10,6 +11,7 @@ export const loadAssetsPage = workspaceRouteLoaders.assets;
export const loadCanvasPage = workspaceRouteLoaders.canvas;
export const loadCanvasProjectPage = () => import("@/pages/canvas/project");
export const loadCreatePage = workspaceRouteLoaders.create;
export const loadInspirationsPage = workspaceRouteLoaders.inspirations;
export const loadProjectDetailPage = workspaceRouteLoaders.projectDetail;
export const loadProjectsPage = workspaceRouteLoaders.projects;

View File

@@ -17,7 +17,9 @@ export type CreationDocumentAttachment = {
previewUrl: string;
};
export type CreationAttachment = ((ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }) | CreationDocumentAttachment;
export type CreationMode = "text" | "image" | "video";
import type { CreationMode } from "@/lib/creation-mode";
export type { CreationMode };
export type CreationAttachmentKind = "image" | "video" | "audio" | "file";
const textDocumentExtensions = [".pdf", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"];

View File

@@ -0,0 +1,388 @@
/* 创作页空态的纵深图片画廊。
卡片沿 Z 轴排队:当前一张在最前、正对画面且最大,其余按层往后推,
每层按等比缩小并向中轴收拢,形成展厅走廊那种一排挂在纵深上的画。
不做弧形——弯曲会立刻丢掉"纵深"的错觉。
缩放在 JS 里按 perspective 公式预先算好,而不是交给 CSS perspective:
平面卡片在固定 z 上做投影,结果就是等比缩放加平移,自己算省掉每帧的投影光栅化。
滚轮或左右按钮切换,切换靠 transform / opacity 的 transition 平滑过渡。 */
/* 内容层压住画廊,但指针事件要能穿过空白处,否则整条画廊都点不到;
真正的交互元素由下面一条规则恢复命中。 */
.creation-home .creation-empty-workspace {
position: relative;
z-index: 1;
pointer-events: none;
}
.creation-home .creation-empty-workspace > * {
pointer-events: auto;
}
/* 画廊占中上部、输入框沉到下方:
先把 main 的居中改掉,否则 justify-content: center 会吃掉 margin-top: auto。 */
.creation-home .creation-empty-workspace {
justify-content: flex-start;
}
.creation-home .creation-home-heading {
margin-top: clamp(16px, 5vh, 52px);
}
.creation-home .creation-empty-workspace > .creation-launchpad {
position: absolute;
left: 50%;
bottom: clamp(16px, 5vh, 52px);
width: min(960px, calc(100% - 32px));
margin: 0;
transform: translateX(-50%);
}
/* ── 让画廊从内容区背后穿过 ──
输入框、模式切换器、快捷卡换成毛玻璃:背后的照片透过来变成模糊底纹,
文字仍落在半透明实色层上保持可读。 */
.creation-home .creation-chat-composer {
background: color-mix(in srgb, var(--user-surface) 70%, transparent) !important;
-webkit-backdrop-filter: blur(24px) saturate(1.4);
backdrop-filter: blur(24px) saturate(1.4);
}
.creation-home .creation-mode-tabs {
background: color-mix(in srgb, var(--user-surface-muted) 72%, transparent) !important;
-webkit-backdrop-filter: blur(18px) saturate(1.3);
backdrop-filter: blur(18px) saturate(1.3);
}
.creation-home .creation-empty-suggest .suggest-card {
background: color-mix(in srgb, var(--creation-surface) 74%, transparent);
-webkit-backdrop-filter: blur(16px) saturate(1.25);
backdrop-filter: blur(16px) saturate(1.25);
}
/* 标题没有玻璃层可依附,用背景色光晕保住可读性,而不是整块盖住画面。 */
.creation-home .creation-home-heading h1 {
text-shadow:
0 2px 20px var(--creation-bg, var(--workspace-page)),
0 0 56px var(--creation-bg, var(--workspace-page));
}
.creation-home .creation-home-heading p {
text-shadow: 0 1px 14px var(--creation-bg, var(--workspace-page));
}
/* ── 画廊本体 ──
上下边界同时定义在这里,两侧切换按钮靠它们算出纵向居中位置。 */
.creation-inspiration-tunnel {
/* 上边让开标题,下边让开输入框——前卡刚好停在输入框上沿之上。 */
--gallery-top: clamp(120px, 14vh, 184px);
--gallery-bottom: clamp(430px, 48vh, 550px);
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.creation-gallery-stage {
position: absolute;
top: var(--gallery-top);
right: 0;
bottom: var(--gallery-bottom);
left: 0;
/* 只有舞台能收到滚轮,避免整页滚动被劫持。 */
pointer-events: auto;
}
.creation-tunnel-card {
position: absolute;
top: 50%;
left: 50%;
width: min(440px, 40vw);
aspect-ratio: 16 / 9;
margin: 0;
padding: 0;
overflow: hidden;
/* 玻璃质感边框:亮描边勾出面板边缘,内侧一圈高光给厚度,
外圈大而软的投影把面板从背景里"抬"起来,避免看成扁平纸片。
阴影只留三层——11 张卡片都在动,每多一层就是每帧多一次大范围重绘。 */
border: 1px solid rgba(255, 255, 255, .5);
border-radius: var(--r-2xl, 16px);
background: var(--creation-surface-raised, #f0f1ee);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .55),
0 0 0 1px rgba(0, 0, 0, .07),
0 28px 54px -26px rgba(0, 0, 0, .52);
cursor: pointer;
/* 只有最前面那张能点,后面的点击会穿到页面上,不去抢焦点。 */
pointer-events: none;
will-change: transform, opacity;
/* 封面就绪前整张卡不可见。先露面板再填图,会出现"右边有图、左边还是空白面板"的观感;
--card-mount 由 .is-ready 翻开,opacity 走 transition 平滑淡入。 */
opacity: calc(var(--card-mount, 0) * var(--card-depth-opacity, 1));
/* 先把卡片按自身尺寸对中,再叠加纵深位移与缩放——位移不会被 scale 影响。
--card-enter-y / --card-enter-scale 是入场位移,封面就绪后归零,
所以卡片是"从略下方、略小的位置浮上来",而不是硬闪出来。 */
transform:
translate(-50%, -50%)
translate(var(--card-x, 0px), var(--card-enter-y, 44px))
scale(calc(var(--card-scale, 1) * var(--card-enter-scale, .95)));
/* 只动 transform 和 opacity,两者都能交给合成层,不触发重排重绘。
缓动先缓后快再长尾软着陆,观感是镜头平稳推拉而不是"弹"过去。 */
transition:
transform 660ms cubic-bezier(.32, .72, 0, 1),
opacity 520ms cubic-bezier(.4, 0, .2, 1);
}
/* 封面已就绪:放行这张卡。 */
.creation-tunnel-card.is-ready {
--card-mount: 1;
--card-enter-y: 0px;
--card-enter-scale: 1;
}
.creation-tunnel-card.is-active {
pointer-events: auto;
}
/* 悬停时抬起放大一点,给"可以点进去"的反馈。
整条 transform 链重写一遍而不是用 translate / scale 独立属性:
独立属性会插在 transform 之前合成,卡片会被缩放带得偏离原位。
抬起用更短的时长——切换是运镜,悬停是手感,两者不该共用一个节奏。 */
.creation-tunnel-card.is-active:hover {
transform:
translate(-50%, -50%)
translate(var(--card-x, 0px), calc(var(--card-enter-y, 0px) - 14px))
scale(calc(var(--card-scale, 1) * var(--card-enter-scale, 1) * 1.04));
transition:
transform 240ms cubic-bezier(.2, .8, .3, 1),
opacity 520ms cubic-bezier(.4, 0, .2, 1);
}
/* 玻璃罩:斜向高光 + 底部压暗,让面板读起来是一块有厚度的玻璃,不是一张图。 */
.creation-tunnel-card::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(158deg, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0) 40%),
linear-gradient(0deg, rgba(0, 0, 0, .18), rgba(0, 0, 0, 0) 44%);
pointer-events: none;
}
.creation-tunnel-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── 卡片上的三段提示词信息:行动点 / 标题 / 描述 ── */
.creation-tunnel-card-info {
position: absolute;
inset: auto 0 0;
display: flex;
flex-direction: column;
gap: var(--space-1, 4px);
padding: 44px var(--space-4, 16px) var(--space-3, 12px);
background: linear-gradient(transparent, rgba(0, 0, 0, .52) 40%, rgba(0, 0, 0, .84));
color: #fff;
text-align: left;
text-shadow: 0 1px 8px rgba(0, 0, 0, .55);
opacity: 0;
/* 比位移稍慢一点收尾,避免文字跟着画面一起"闪"进闪出。 */
transition: opacity 380ms cubic-bezier(.4, 0, .2, 1);
}
.creation-tunnel-card.is-active .creation-tunnel-card-info {
opacity: 1;
}
.creation-tunnel-card-cta {
display: inline-flex;
align-items: center;
gap: var(--space-1, 4px);
font-size: var(--fs-label, 11px);
line-height: 1.2;
opacity: .84;
}
.creation-tunnel-card-cta svg {
width: 12px;
height: 12px;
}
.creation-tunnel-card-title {
font-size: var(--fs-body, 14px);
font-weight: 600;
line-height: 1.3;
}
.creation-tunnel-card-desc {
display: -webkit-box;
overflow: hidden;
font-size: var(--fs-caption, 12px);
line-height: 1.45;
opacity: .84;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* ── 左右切换按钮 ── */
.creation-gallery-arrow {
position: absolute;
/* 对齐画廊那条带子的纵向中线,而不是整屏中线——输入框在下面,整屏中线偏低。 */
top: calc((100% - var(--gallery-top) - var(--gallery-bottom)) / 2 + var(--gallery-top));
display: grid;
place-items: center;
width: 46px;
height: 46px;
padding: 0;
color: var(--user-ink, #fff);
background: color-mix(in srgb, var(--user-surface) 62%, transparent);
border: 1px solid color-mix(in srgb, var(--user-ink) 14%, transparent);
border-radius: var(--r-full, 999px);
box-shadow: 0 14px 30px -14px rgba(0, 0, 0, .55);
-webkit-backdrop-filter: blur(18px) saturate(1.3);
backdrop-filter: blur(18px) saturate(1.3);
cursor: pointer;
pointer-events: auto;
/* 用整条 transform 表达"居中 + 悬停放大":
换成 scale 独立属性会插在 transform 之前合成,按钮会跟着往下掉。 */
transform: translateY(-50%);
transition:
transform var(--user-motion, 180ms) ease,
background-color var(--user-motion, 180ms) ease,
color var(--user-motion, 180ms) ease;
}
.creation-gallery-arrow.is-prev {
left: clamp(12px, 2.4vw, 40px);
}
.creation-gallery-arrow.is-next {
right: clamp(12px, 2.4vw, 40px);
}
.creation-gallery-arrow:hover {
transform: translateY(-50%) scale(1.08);
}
.creation-gallery-arrow:focus-visible {
outline: 2px solid var(--creation-accent, var(--user-accent));
outline-offset: 3px;
}
.creation-gallery-arrow-icon {
display: inline-flex;
}
.creation-gallery-arrow svg {
width: 20px;
height: 20px;
}
/* 卡片对辅助技术隐藏,切换结果改由这条文本播报。 */
.creation-gallery-announce {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* 素材来源署名:CC BY 4.0 要求可追溯,画廊形态下不再有灵感分区页脚。 */
.creation-inspiration-credit {
position: absolute;
right: 22px;
bottom: 14px;
z-index: 2;
max-width: 62ch;
color: var(--creation-faint, var(--user-ink-soft));
font-size: var(--fs-caption, 12px);
line-height: 1.6;
}
.creation-inspiration-credit summary {
cursor: pointer;
list-style: none;
opacity: .72;
transition: opacity var(--user-motion, 180ms) ease;
}
.creation-inspiration-credit summary:hover {
opacity: 1;
}
.creation-inspiration-credit summary::-webkit-details-marker {
display: none;
}
.creation-inspiration-credit p {
margin: 6px 0;
}
.creation-inspiration-credit a {
color: var(--creation-accent, var(--user-accent));
}
/* 减少动效:不做入场位移、悬停也不放大,切换改为瞬时。
(卡片仍然等封面就绪,只是不再有过渡过程。) */
@media (prefers-reduced-motion: reduce) {
.creation-tunnel-card {
--card-enter-y: 0px;
--card-enter-scale: 1;
transition: none;
}
.creation-tunnel-card.is-active:hover {
transform:
translate(-50%, -50%)
translate(var(--card-x, 0px), 0)
scale(var(--card-scale, 1));
}
.creation-gallery-arrow {
transition: none;
}
.creation-gallery-arrow:hover {
transform: translateY(-50%);
}
}
/* 窄屏:横向偏移收小一点,免得侧翼卡片被裁掉。 */
@media (max-width: 1100px) {
.creation-inspiration-tunnel {
--gallery-top: clamp(72px, 11vh, 132px);
--gallery-bottom: clamp(210px, 33vh, 320px);
}
.creation-tunnel-card {
width: min(400px, 48vw);
}
.creation-gallery-arrow {
width: 40px;
height: 40px;
}
}
@media (max-width: 640px) {
.creation-inspiration-credit {
right: 14px;
}
.creation-tunnel-card {
width: 68vw;
}
.creation-tunnel-card-desc {
-webkit-line-clamp: 1;
}
}

View File

@@ -0,0 +1,284 @@
// 创作页空态的纵深图片画廊:卡片沿 Z 轴前后排队,当前一张在最前、最大,
// 其余依次后退缩小,像展厅里一排挂在纵深上的画。
// 滚轮、左右按钮或自动轮播切换,切换靠 CSS transition 平滑过渡。
// 卡片是带玻璃质感边框的实体面板,不做弧形、也不压成薄片。
// 展示内容跟随下方输入框的创作类型:选视频就只排队视频灵感,选图片就只排队图片灵感。
//
// 队列是「随机取样」而不是固定切片:每次进场、每次换创作类型都会重新抽一批,
// 并且记住最近出现过哪些,走完一轮自动换下一批,避免反复看到同样那几张。
//
// 池子不打包,走 public/inspirations/gallery-pool.json:几百条连提示词正文一起
// 就是几百 KB,压进首屏 JS 是白背的,而这份数据只服务这一处。
//
// 每张卡片等自己的封面加载完再露面。不这么做的话,先到的图先显示、
// 慢的那几张只剩一块空面板,观感就是"右边出来了左边还空着"。
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArrowUp, ChevronLeft, ChevronRight } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { catalogIdOf, type CreationInspiration } from "@/lib/inspirations/catalog";
import { loadGalleryPool } from "@/services/inspiration-catalog";
import type { CreationMode } from "./creation-types";
import "./creation-inspiration-tunnel.css";
/** 画廊里排队展示的照片数,取奇数,两侧层数才对得齐。 */
const GALLERY_SIZE = 11;
/** 每后退一层的横向偏移(px)。 */
const STEP_X = 185;
/** 每后退一层的纵深(px)。 */
const STEP_Z = 150;
/** 虚拟焦距,只用来算出"后退一层缩多少",不再是 CSS perspective。 */
const PERSPECTIVE = 1200;
/** 最多同时可见的层数,再远就隐掉。 */
const MAX_DEPTH = 4;
/** 每一层的透明度,越靠后越淡,做出空气透视。 */
const DEPTH_OPACITY = [1, 0.98, 0.95, 0.91, 0.86];
/** 自动轮播间隔。悬停、聚焦或系统开了"减少动效"时暂停。 */
const AUTO_ADVANCE_MS = 6500;
/** 记住最近展示过的条目数,换页/刷新时不会又抽到同一批。 */
const RECENT_MEMORY = 66;
const RECENT_KEY = "yingce:creation-gallery-recent";
/**
* 等封面的兜底时长。正常情况下封面几百毫秒就回来了,这条不会生效;
* 万一某张请求挂住不回,也不能让队列永远缺一块,超时就先放行。
*/
const COVER_FALLBACK_MS = 8000;
/**
* 把"离眼睛多远"换算成这一层的等比缩放。
* 原先交给 CSS perspective 做,但每帧的投影光栅化太贵;
* 平面卡片在固定 z 上做投影变换,结果恰好就是等比缩放加向中轴平移,所以自己算更省。
*/
function depthScale(depth: number): number {
return PERSPECTIVE / (PERSPECTIVE + STEP_Z * depth);
}
function shuffled<T>(list: T[]): T[] {
const next = [...list];
for (let index = next.length - 1; index > 0; index -= 1) {
const swap = Math.floor(Math.random() * (index + 1));
[next[index], next[swap]] = [next[swap], next[index]];
}
return next;
}
function readRecent(mode: CreationMode): string[] {
try {
const raw = window.sessionStorage.getItem(`${RECENT_KEY}:${mode}`);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
}
function writeRecent(mode: CreationMode, ids: string[]) {
try {
window.sessionStorage.setItem(`${RECENT_KEY}:${mode}`, JSON.stringify(ids.slice(-RECENT_MEMORY)));
} catch {
// 隐私模式下 sessionStorage 可能不可用;退化成每次纯随机,不影响功能。
}
}
/** 随机抽一批:先挑没在最近出现过的,不够再用出现过的补齐,抽完把结果记进"最近"。 */
function pickBatch(pool: CreationInspiration[], size: number, mode: CreationMode): CreationInspiration[] {
if (pool.length <= size) return shuffled(pool);
const recent = new Set(readRecent(mode));
const byFreshness = [...shuffled(pool.filter((item) => !recent.has(catalogIdOf(item)))), ...shuffled(pool.filter((item) => recent.has(catalogIdOf(item))))];
const picked = byFreshness.slice(0, size);
writeRecent(mode, [...recent, ...picked.map(catalogIdOf)]);
return picked;
}
/**
* 画廊只服务鼠标氛围:读屏与键盘用户走输入框和快捷入口创作,
* 所以卡片整体对辅助技术隐藏、也不进 Tab 顺序;左右切换按钮仍可聚焦,
* 当前展示的是哪一张由下面那条 aria-live 文本播报。
*/
export function CreationInspirationTunnel({ mode, onStartPrompt }: { mode: CreationMode; onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
/**
* 池子来自 gallery-pool.json,是单独一份静态 JSON,不打包。
* 它没到之前整条画廊不渲染——先摆一小批再换成大池子,用户会看到画面整排重排一次。
*/
const [pool, setPool] = useState<CreationInspiration[]>([]);
const [poolReady, setPoolReady] = useState(false);
const [shots, setShots] = useState<CreationInspiration[]>([]);
const [active, setActive] = useState(0);
const [paused, setPaused] = useState(false);
const [readyIds, setReadyIds] = useState<ReadonlySet<string>>(() => new Set());
const reducedMotion = useReducedMotion();
const stageRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(0);
activeRef.current = active;
const modePool = useMemo(() => pool.filter((item) => item.mode === mode), [pool, mode]);
/** 轮换时读最新的池子,但不把池子写进轮播的依赖——否则池子一到就重置计时。 */
const modePoolRef = useRef(modePool);
modePoolRef.current = modePool;
useEffect(() => {
let alive = true;
// 加载器不会 reject:拉不到就退回打包的原创条目,画廊宁可少几张也不该空着。
loadGalleryPool().then((entries) => {
if (!alive) return;
setPool(entries);
setPoolReady(true);
});
return () => {
alive = false;
};
}, []);
// 抽一批的时机只有两个:池子到货(含进场),以及换创作类型。
useEffect(() => {
if (!poolReady) return;
setShots(pickBatch(modePool, GALLERY_SIZE, mode));
setActive(0);
}, [mode, modePool, poolReady]);
/** 封面就绪(含加载失败)后放行这张卡。失败的也要放行,否则卡片永远不出现。 */
const markReady = useCallback((id: string) => {
setReadyIds((current) => (current.has(id) ? current : new Set(current).add(id)));
}, []);
// 兜底放行:请求挂住不返回时,到点把这一批全部放出来,宁可露出空面板也不留缺口。
useEffect(() => {
if (!shots.length) return;
const timer = window.setTimeout(() => {
setReadyIds((current) => new Set([...current, ...shots.map(catalogIdOf)]));
}, COVER_FALLBACK_MS);
return () => window.clearTimeout(timer);
}, [shots]);
const step = useCallback(
(delta: number) => {
if (!shots.length) return;
setActive((current) => (current + delta + shots.length) % shots.length);
},
[shots.length],
);
// 自动轮播:走完一轮就换一批新的,用户不会反复看到同样那几张。
useEffect(() => {
if (paused || reducedMotion || shots.length < 2) return;
const timer = window.setInterval(() => {
const next = activeRef.current + 1;
if (next < shots.length) {
setActive(next);
return;
}
setShots(pickBatch(modePoolRef.current, GALLERY_SIZE, mode));
setActive(0);
}, AUTO_ADVANCE_MS);
return () => window.clearInterval(timer);
}, [paused, reducedMotion, shots.length, mode]);
// 标签页切到后台时停掉,回来再继续,免得空转。
useEffect(() => {
const handleVisibility = () => setPaused(document.hidden);
document.addEventListener("visibilitychange", handleVisibility);
return () => document.removeEventListener("visibilitychange", handleVisibility);
}, []);
// 滚轮切换:向上退到上一张、向下进到下一张。
useEffect(() => {
const node = stageRef.current;
if (!node) return;
let latched = false;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
// 触控板一次滑动会连发多个事件,锁一小段时间,避免一次滑过好几张。
if (latched) return;
latched = true;
window.setTimeout(() => {
latched = false;
}, 240);
step(event.deltaY > 0 ? 1 : -1);
};
node.addEventListener("wheel", handleWheel, { passive: false });
return () => node.removeEventListener("wheel", handleWheel);
}, [step]);
const current = shots[active];
if (!shots.length) return null;
return (
<div
className="creation-inspiration-tunnel"
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
>
<div ref={stageRef} className="creation-gallery-stage" aria-hidden="true">
{shots.map((inspiration, index) => {
const id = catalogIdOf(inspiration);
// 相对当前卡片的距离,绕成最短路径,形成首尾相接的队列。
const half = Math.floor(shots.length / 2);
const relative = ((index - active + half + shots.length) % shots.length) - half;
const depth = Math.abs(relative);
// 超出可见层数就靠透明度淡出,不切 visibility——
// 硬切会让最远那张在过渡中途"啪"地消失。
const style = {
"--card-x": `${(Math.sign(relative) * STEP_X * depth).toFixed(1)}px`,
"--card-scale": depthScale(depth).toFixed(4),
"--card-depth-opacity": `${DEPTH_OPACITY[Math.min(depth, DEPTH_OPACITY.length - 1)]}`,
zIndex: `${100 - depth}`,
} as CSSProperties;
return (
<button
key={id}
type="button"
tabIndex={-1}
className={`creation-tunnel-card ${depth === 0 ? "is-active" : ""} ${readyIds.has(id) ? "is-ready" : ""}`}
style={style}
onClick={() => onStartPrompt(inspiration.mode, inspiration.prompt)}
>
<img
src={inspiration.image}
alt=""
draggable={false}
decoding="async"
onLoad={() => markReady(id)}
onError={() => markReady(id)}
// 命中浏览器缓存的图可能在挂监听之前就 complete 了,onLoad 不会再触发,这里补一次。
ref={(node) => {
if (node?.complete) markReady(id);
}}
/>
{/* 三段式提示词信息:行动点、标题、描述,只有最前那张露出来。 */}
<span className="creation-tunnel-card-info">
<span className="creation-tunnel-card-cta">
<ArrowUp aria-hidden="true" />
使用这个创意
</span>
<span className="creation-tunnel-card-title">{inspiration.title}</span>
<span className="creation-tunnel-card-desc">{inspiration.description}</span>
</span>
</button>
);
})}
</div>
{/* 图标外面套一层 span 是必须的:workspace-product.css 里有一条
`button:has(> svg):not(:has(> span)):not(:disabled):hover` 会认领"纯图标按钮",
用 !important 改写 color / background 并把 transform 顶成 translateY(-1px),
按钮会当场掉半个身位。带 span 的按钮不在那条规则的射程内。 */}
<button type="button" className="creation-gallery-arrow is-prev" aria-label="上一张灵感" onClick={() => step(-1)}>
<span className="creation-gallery-arrow-icon"><ChevronLeft aria-hidden="true" /></span>
</button>
<button type="button" className="creation-gallery-arrow is-next" aria-label="下一张灵感" onClick={() => step(1)}>
<span className="creation-gallery-arrow-icon"><ChevronRight aria-hidden="true" /></span>
</button>
{/* 卡片本身对辅助技术隐藏,切换结果靠这条播报传达。 */}
<p className="creation-gallery-announce" aria-live="polite">
{current ? `${current.title}:${current.description}` : ""}
</p>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import type { CreationMode } from "./creation-types";
export type CreationInspiration = { title: string; description: string; image: string; mode: CreationMode; prompt: string; featured?: boolean; source?: string };
export const creationFeaturedWorks: CreationInspiration[] = [
{
title: "雨夜霓虹 · 电影感开场",
description: "宽银幕构图、环境反光与缓慢推进镜头",
image: "/short-drama-styles/cyberpunk-neon.jpg",
mode: "video",
prompt: "雨夜城市街口,霓虹灯倒映在湿润路面,主角撑伞穿过人群,镜头从远景缓慢推进到侧脸特写,电影感光影,16:9",
featured: true,
},
{
title: "港风天台重逢",
description: "复古色彩与克制的人物情绪",
image: "/short-drama-styles/retro-hong-kong.jpg",
mode: "video",
prompt: "九十年代港风天台,黄昏逆光,两位多年未见的旧友隔着晾衣绳对望,风吹动衣角,镜头轻微手持并缓慢靠近",
},
{
title: "暗巷悬疑追逐",
description: "高反差黑色电影与紧张节奏",
image: "/short-drama-styles/suspense-noir.jpg",
mode: "video",
prompt: "黑白悬疑电影风格,侦探在狭窄暗巷中追逐神秘人,路灯切割出强烈明暗,低机位跟拍,节奏逐渐加快",
},
{
title: "东方幻想角色设定",
description: "角色概念图与细腻材质表达",
image: "/short-drama-styles/fantasy-3d.jpg",
mode: "image",
prompt: "东方幻想世界的年轻女剑客角色设定,银白发饰、层叠轻甲与红色披风,山雾背景,细腻材质,电影级概念设计",
},
{
title: "温暖室内对话",
description: "生活化场景与柔和叙事氛围",
image: "/welcome/wing-it/barn.webp",
mode: "text",
prompt: "请帮我写一场发生在暖色灯光客厅里的母女对话戏:女儿准备离开家乡,母亲表面平静但一直在整理旧物,用动作和潜台词推进情绪。",
},
{ title: "森林里的最后一封信", description: "童话叙事 · 从角色愿望建立冲突", image: "/short-drama-styles/storybook-fantasy.jpg", mode: "text", source: "Storyteller", prompt: "作为故事讲述者,为亲子观众写一个关于坚持的森林童话。主角是一只替动物送信的小狐狸,它必须在第一场雪之前送出一封没有地址的信。先给出角色的愿望与阻碍,再设计三个递进的事件,最后用一个可见的行动完成情绪转折。避免说教。" },
{ title: "末班车上的陌生人", description: "短剧编剧 · 让对白藏住真正目的", image: "/short-drama-styles/urban-live-action.jpg", mode: "text", source: "Screenwriter", prompt: "作为编剧,设计一段发生在末班公交车上的三分钟短剧。两位陌生人都声称自己丢失了一本笔记,但只有一人说真话。先定义人物动机、场景限制和各自隐瞒的信息,再写动作、对白与反转结尾。全剧限制两个角色、一个场景,适合低成本实拍。" },
{ title: "未来城市的记忆修补师", description: "科幻大纲 · 世界规则与人物代价", image: "/short-drama-styles/future-tech.jpg", mode: "text", source: "Novelist", prompt: "作为小说家,构思一部关于记忆修补师的未来都市短篇。城市居民能删除痛苦记忆,但主角发现每次删除都会遗失一种颜色。建立三条世界规则、主人公的核心缺陷、五个转折和付出代价的结局。先交付故事大纲,不直接铺写长篇。" },
{ title: "把告别写成一场雨", description: "诗性旁白 · 少解释,多感官细节", image: "/short-drama-styles/nature-healing.jpg", mode: "text", source: "Poet", prompt: "作为诗人,为一段雨中离乡的影像写四组中文旁白,每组不超过四十字。分别使用气味、触感、声音与光线表达告别,不直接使用悲伤、不舍或想念等情绪词。用朴素、可朗读的语言,给每组留一个停顿。" },
{ title: "一瓶饮料的夏日故事", description: "广告策划 · 人群、主张与三个镜头", image: "/short-drama-styles/real-life.jpg", mode: "text", source: "Advertiser", prompt: "作为广告策划,为面向年轻通勤者的无糖气泡饮料设计十五秒广告。定义目标人群、核心场景和一句传播主张,再给出三个镜头的画面、时长、音效与字幕。不要虚构营养或功效数据,用下班后一口清爽的体验讲故事。" },
{ title: "独立咖啡店的视觉语言", description: "品牌创意 · 让配色和语气讲同一件事", image: "/short-drama-styles/retro-hong-kong.jpg", mode: "text", source: "Creative Branding Strategist", prompt: "作为品牌策略师,为社区里一家安静的独立咖啡店提出三种视觉方向。品牌重视慢节奏、邻里关系和手作。每种方向包含色彩、摄影氛围、文字语气、三条社交内容主题和差异化理由。不要模仿已有连锁品牌的标识。" },
{ title: "给剧本做一次镜头诊断", description: "创作复盘 · 从叙事到声音提出修改", image: "/short-drama-styles/black-white-noir.jpg", mode: "text", source: "Film Critic", prompt: "请以电影评论者的视角审阅我接下来提供的剧本或分镜,从叙事、人物、构图、运镜和声音五个维度提出反馈。每条意见都引用我提供的片段,并给出一个能实际拍摄的修改方案。还没有收到材料时,先向我索取,不要假装看过影片。" },
{ title: "旧城街景的时代考据", description: "历史场景 · 区分史实、推测和虚构", image: "/short-drama-styles/period-live-action.jpg", mode: "text", source: "Historian", prompt: "作为历史研究者,协助我设计民国早期城市街景的影视场景。先问清具体年份、地点和人物阶层,再列出服饰、交通、招牌、建筑和日常用语的考据清单。明确区分有来源的史实、合理推测与戏剧性虚构,不编造文献。" },
{ title: "月面独行", description: "远景到特写 · 太空探索的孤独感", image: "/short-drama-styles/space-opera.jpg", mode: "video", prompt: "月球背面的荒原,宇航员独自走向一座废弃天线。镜头从极远景缓慢推进,低角度阳光在月尘上拉出长影,步伐沉重,头盔反射星光,克制冷色,宽银幕电影构图,无字幕。" },
{ title: "手作星球早餐", description: "定格动画 · 柔软材质与小动作", image: "/short-drama-styles/clay-stop-motion.jpg", mode: "video", prompt: "黏土定格动画风格,一位小小宇航员在圆形厨房煎一颗星星形状的鸡蛋,鸡蛋轻轻跳起。手作指纹质感、暖奶油色、柔和侧光,固定中景,动作简短连续,可爱但不夸张。" },
{ title: "山水之间的一叶舟", description: "水墨留白 · 横向移镜与轻雾", image: "/short-drama-styles/ink-narrative.jpg", mode: "video", prompt: "中国水墨动画,一叶小舟缓缓穿过层叠远山,薄雾留白,舟上人抬手触碰落下的花瓣。镜头平稳横移,墨色有自然晕染,少量朱红点缀,画面安静,动作节奏舒缓。" },
{ title: "雨后的玻璃花房", description: "治愈场景 · 前景水滴与逆光", image: "/short-drama-styles/nature-healing.jpg", mode: "video", prompt: "清晨雨后的玻璃花房,镜头透过挂着水滴的叶片缓缓推向盛开的白花,阳光从侧后方进入,浅景深,真实植物纹理,空气中薄薄水汽,宁静自然,无人物和文字。" },
{ title: "都市少女角色转面", description: "角色概念 · 统一服装与形体比例", image: "/short-drama-styles/chinese-2d.jpg", mode: "image", prompt: "原创二维动画都市少女角色设定,短发、宽松工装夹克和帆布包,正面、侧面与背面三视图,服装细节和身高比例一致,干净线稿配平涂阴影,暖灰背景,不添加文字,不模仿已有动漫角色。" },
{ title: "梦境里的倒置房间", description: "超现实概念 · 重力反转与柔光", image: "/short-drama-styles/surreal-dream.jpg", mode: "image", prompt: "超现实电影场景概念图,一间漂浮在云海中的倒置卧室,床和书桌悬在天花板上,一扇门通向浅粉色天空。低饱和奶油与雾蓝色,柔和漫反射,精确透视,细腻材质,无人物,无文字。" },
{ title: "海边旅店的黄昏", description: "实拍场景 · 用道具留下人物痕迹", image: "/short-drama-styles/urban-live-action.jpg", mode: "image", prompt: "沿海小城旧旅店房间的电影置景概念图,窗外是黄昏海面,风吹动米白窗帘,桌上有两杯没有喝完的茶和一本翻开的书。暖橙与浅蓝对比,自然胶片颗粒,眼平中广角,没有人物和可读文字。" },
{ title: "鲜亮漫画封面", description: "平面构成 · 大色块与戏剧性透视", image: "/short-drama-styles/comic-pop.jpg", mode: "image", prompt: "原创漫画封面插画,一位骑单车的城市信使跃过雨后积水,戏剧性低角度透视,青蓝、珊瑚红与暖黄色大色块,半调网点,清晰黑色轮廓,背景简洁,为顶部标题留白但不生成文字。" },
{ title: "烘焙店的秘密帮手", description: "三维角色 · 柔和材质与亲切表情", image: "/short-drama-styles/three-d-cartoon.jpg", mode: "image", prompt: "原创三维动画角色概念图,一只穿着面粉围裙的小浣熊站在烘焙台前,鼻尖沾着面粉,手里举着刚烤好的面包。柔软毛发、温暖侧光、自然可爱的比例,橘棕色和奶油白配色,背景轻微虚化。" },
];
export const inspirationSource = {
repository: "https://github.com/f/awesome-chatgpt-prompts",
revision: "f78a1c5136fa080155d928e0d7e2b4a41ddef03e",
license: "CC0-1.0",
notice: "8 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。",
};

View File

@@ -6,7 +6,7 @@ import { ArrowUp, ChevronDown, Clapperboard, FileText, Image as ImageIcon, Spark
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { creationFeaturedWorks, inspirationSource } from "./creation-inspirations";
import { curatedInspirations, inspirationSourceOf, inspirationSources } from "@/lib/inspirations/catalog";
import { modeLabels } from "./creation-types";
import { Button } from "antd";
@@ -82,14 +82,18 @@ export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStart
export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
const [filter, setFilter] = useState<"all" | CreationMode>("all");
const [limit, setLimit] = useState(12);
const filtered = creationFeaturedWorks.filter((item) => filter === "all" || item.mode === filter);
const filtered = curatedInspirations.filter((item) => filter === "all" || item.mode === filter);
// 只列出真正用到的外部来源;原创条目不写来源声明。
const declaredSources = Array.from(new Set(curatedInspirations.map((item) => item.sourceId ?? "original")))
.map((id) => inspirationSources[id])
.filter((source) => source.notice);
return (
<section className="creation-featured-works" aria-labelledby="creation-featured-title">
<div className="creation-featured-heading">
<div>
<h2 id="creation-featured-title">精选灵感</h2>
</div>
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
<p>{curatedInspirations.length} 个创意起点 · 点击填入提示词,不自动生成</p>
</div>
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
{(["all", "video", "image", "text"] as const).map((value) => (
@@ -103,7 +107,7 @@ export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode:
}}
>
{value === "all" ? "全部灵感" : modeLabels[value]}
<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span>
<span>{curatedInspirations.filter((item) => value === "all" || item.mode === value).length}</span>
</button>
))}
</div>
@@ -122,7 +126,7 @@ export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode:
<span>{item.description}</span>
<em>
<Sparkles />
{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}
{inspirationSourceOf(item).label} · {modeLabels[item.mode]}
</em>
</span>
</button>
@@ -139,10 +143,16 @@ export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode:
)}
<details>
<summary>模板与封面来源</summary>
<p>{inspirationSource.notice}</p>
<a href={inspirationSource.repository} target="_blank" rel="noreferrer">
awesome-chatgpt-prompts · CC0
</a>
{declaredSources.map((source) => (
<div key={source.name ?? source.label}>
<p>{source.notice}</p>
{source.repository ? (
<a href={source.repository} target="_blank" rel="noreferrer">
{source.name} · {source.license}
</a>
) : null}
</div>
))}
</details>
</footer>
</section>

View File

@@ -3,7 +3,7 @@ import { App, Spin } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { History, Sparkles, Maximize2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useNavigate } from "react-router";
import { useNavigate, useLocation } from "react-router";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
@@ -31,7 +31,9 @@ import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreation
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
import { defaultCreationMode, modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types";
import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations";
import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
import { CreationComposer, CreationEmptySuggest, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
import { CreationInspirationTunnel } from "./creation-inspiration-tunnel";
import { declaredInspirationSources } from "@/lib/inspirations/catalog";
import { CreationAgentEntry } from "./creation-agent-entry";
import { createCreationSubmitGate } from "./creation-submit-gate";
import { creationVideoConfig } from "./creation-generation-config";
@@ -75,6 +77,7 @@ export default function CreatePage() {
const [agentMode, setAgentMode] = useState(false);
const { message: toast, modal } = App.useApp();
const navigate = useNavigate();
const location = useLocation();
const [openingCanvas, setOpeningCanvas] = useState(false);
const openingCanvasRef = useRef(false);
const brandName = useAppearanceStore((state) => state.appearance.brandName);
@@ -391,6 +394,21 @@ export default function CreatePage() {
}
};
// 灵感库「用这个创意创作」跳过来时把提示词和类型带上。
// 用完立刻清掉路由状态:留着的话,用户前进后退回到这一页会被重复填充一次。
const inspirationHandoff = location.state as { inspirationPrompt?: string; inspirationMode?: CreationMode } | null;
useEffect(() => {
const handoffPrompt = inspirationHandoff?.inspirationPrompt;
if (!handoffPrompt) return;
setAgentMode(false);
if (inspirationHandoff?.inspirationMode) selectMode(inspirationHandoff.inspirationMode);
setPrompt(handoffPrompt);
navigate(".", { replace: true, state: null });
window.requestAnimationFrame(() => composerFocusRef.current?.focus());
// selectMode 每次渲染都是新函数,放进依赖会让本效果反复触发;靠 state 置空来收敛即可。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inspirationHandoff, navigate]);
const setComposerRatio = (value: string) => {
setRatio(value);
if (mode === "image") rememberImageSettings({ ratio: value });
@@ -1046,6 +1064,10 @@ export default function CreatePage() {
}}><Maximize2 /></button></Tooltip>
</motion.div> : null}
</AnimatePresence>
<CreationInspirationTunnel
mode={mode}
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
/>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
<div className="creation-home-heading">
<h1>和{brandName}聊聊创作想法</h1>
@@ -1061,10 +1083,25 @@ export default function CreatePage() {
onOpenLibrary={() => { setAgentMode(false); selectMode("image"); setLibraryOpen(true); }}
/>
</section>
<CreationFeaturedWorks
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
/>
</main>
</main>
<div className="creation-inspiration-credit">
<details>
<summary>素材来源</summary>
{declaredInspirationSources().map((source) => (
<p key={source.name ?? source.label}>
{source.notice}
{source.repository ? (
<>
{" "}
<a href={source.repository} target="_blank" rel="noreferrer">
{source.name} · {source.license}
</a>
</>
) : null}
</p>
))}
</details>
</div>
</> : <div className="creation-thread-workbench">
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">

View File

@@ -0,0 +1,451 @@
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>
);
}

View File

@@ -0,0 +1,123 @@
import { Button } from "antd";
import { Copy, ExternalLink, Heart, Pencil, Trash2, Wand2 } from "lucide-react";
import { Tooltip } from "@/components/ui/base/tooltip";
import { AppModal } from "@/components/ui/product/app-modal";
import { catalogIdOf, inspirationSourceOf, type CreationInspiration } from "@/lib/inspirations/catalog";
import { isCustomInspiration } from "@/lib/inspirations/resolve";
import { modeLabels } from "@/pages/create/creation-types";
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex min-w-0 items-baseline gap-3 text-xs leading-5">
<span className="w-12 shrink-0 text-foreground/45">{label}</span>
<span className="min-w-0 flex-1 text-foreground/78">{children}</span>
</div>
);
}
export function InspirationDetailModal({
item,
categoryLabel,
favorite,
onClose,
onUse,
onEdit,
onDelete,
onToggleFavorite,
onCopy,
}: {
item: CreationInspiration | null;
categoryLabel: (item: CreationInspiration) => string;
favorite: boolean;
onClose: () => void;
onUse: (item: CreationInspiration) => void;
onEdit: (item: CreationInspiration) => void;
onDelete: (item: CreationInspiration) => void;
onToggleFavorite: (item: CreationInspiration) => void;
onCopy: (text: string, label: string) => void;
}) {
const source = item ? inspirationSourceOf(item) : null;
const custom = item ? isCustomInspiration(catalogIdOf(item)) : false;
return (
<AppModal
open={Boolean(item)}
onCancel={onClose}
width={880}
title={null}
footer={null}
styles={{ body: { padding: 0 } }}
>
{item ? (
<div className="flex max-h-[76vh] flex-col">
<div className="relative shrink-0 overflow-hidden" style={{ borderRadius: "var(--r-xl, 14px)" }}>
<img src={item.image} alt="" className="h-56 w-full object-cover sm:h-64" referrerPolicy="no-referrer" />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[var(--fs-heading,16px)] font-semibold leading-6 text-foreground">{item.title}</h2>
{item.description ? <p className="mt-1 text-xs leading-5 text-foreground/58">{item.description}</p> : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Tooltip title={favorite ? "取消收藏" : "收藏"}>
<button type="button" className="inspiration-icon-button" aria-pressed={favorite} onClick={() => onToggleFavorite(item)}>
<Heart className="size-4" fill={favorite ? "currentColor" : "none"} />
</button>
</Tooltip>
<Tooltip title="编辑">
<button type="button" className="inspiration-icon-button" onClick={() => onEdit(item)}><Pencil className="size-4" /></button>
</Tooltip>
<Tooltip title={custom ? "删除" : "从库中隐藏"}>
<button type="button" className="inspiration-icon-button" onClick={() => onDelete(item)}><Trash2 className="size-4" /></button>
</Tooltip>
</div>
</div>
<div className="flex flex-col gap-1.5">
<MetaRow label="来源">{source?.name ?? "原创"}{item.credit ? ` · ${item.credit}` : ""}</MetaRow>
<MetaRow label="分类">{categoryLabel(item)}</MetaRow>
<MetaRow label="类型">{modeLabels[item.mode]}{item.ratio ? ` · ${item.ratio}` : ""}</MetaRow>
{item.tags?.length ? (
<MetaRow label="标签">
<span className="flex flex-wrap gap-1.5">
{item.tags.map((tag) => <span key={tag} className="inspiration-chip">{tag}</span>)}
</span>
</MetaRow>
) : null}
{item.sourceUrl ? (
<MetaRow label="原页">
<a className="inline-flex items-center gap-1 text-primary hover:underline" href={item.sourceUrl} target="_blank" rel="noreferrer">
查看上游内容 <ExternalLink className="size-3" />
</a>
</MetaRow>
) : null}
</div>
<PromptSection label={item.promptZh ? "提示词 · 中文" : "提示词"} text={item.promptZh || item.prompt} onCopy={onCopy} />
{item.promptZh && item.prompt !== item.promptZh ? <PromptSection label="提示词 · 原文" text={item.prompt} onCopy={onCopy} /> : null}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-foreground/8 px-5 py-3">
<Button onClick={onClose}>关闭</Button>
<Button type="primary" icon={<Wand2 className="size-4" />} onClick={() => onUse(item)}>用这个创意创作</Button>
</div>
</div>
) : null}
</AppModal>
);
}
function PromptSection({ label, text, onCopy }: { label: string; text: string; onCopy: (text: string, label: string) => void }) {
return (
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-medium text-foreground/58">{label}</h3>
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => onCopy(text, label)}>复制</Button>
</div>
<div className="inspiration-prompt-block">{text}</div>
</section>
);
}

View File

@@ -0,0 +1,131 @@
import { Button, Form, Input, Select } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import type { CreationInspiration } from "@/lib/inspirations/catalog";
import { normalizeDraft, type InspirationDraft } from "@/stores/use-inspiration-store";
export type InspirationCategoryChoice = { label: string; value: string };
/** 编辑器只收集用户能填的字段;id、来源、署名由本地库自己管。 */
type FormValues = {
title: string;
description?: string;
image: string;
mode: CreationInspiration["mode"];
category?: string;
tags?: string;
ratio?: string;
prompt: string;
promptZh?: string;
};
function toFormValues(item: CreationInspiration | null): FormValues {
return {
title: item?.title ?? "",
description: item?.description ?? "",
image: item?.image ?? "",
mode: item?.mode ?? "image",
category: item?.category ?? "",
tags: item?.tags?.join(",") ?? "",
ratio: item?.ratio ?? "",
prompt: item?.prompt ?? "",
promptZh: item?.promptZh ?? "",
};
}
export function InspirationEditorModal({
open,
item,
categoryOptions,
onClose,
onSubmit,
}: {
open: boolean;
item: CreationInspiration | null;
categoryOptions: InspirationCategoryChoice[];
onClose: () => void;
onSubmit: (draft: InspirationDraft) => void;
}) {
const [form] = Form.useForm<FormValues>();
const handleOk = async () => {
const values = await form.validateFields().catch(() => null);
if (!values) return;
const tags = (values.tags ?? "")
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean);
// 走 store 里同一套收敛规则,表单不做第二套校验。
const draft = normalizeDraft({
title: values.title,
description: values.description ?? "",
image: values.image,
mode: values.mode,
prompt: values.prompt,
promptZh: values.promptZh,
category: values.category,
ratio: values.ratio,
...(tags.length ? { tags } : {}),
});
onSubmit(draft);
form.resetFields();
onClose();
};
return (
<AppModal
open={open}
title={item ? "编辑灵感" : "新建灵感"}
width={720}
onCancel={() => { form.resetFields(); onClose(); }}
afterOpenChange={(visible) => { if (visible) form.setFieldsValue(toFormValues(item)); }}
footer={
<div className="flex justify-end gap-2">
<Button onClick={() => { form.resetFields(); onClose(); }}>取消</Button>
<Button type="primary" onClick={() => void handleOk()}>{item ? "保存" : "创建"}</Button>
</div>
}
>
<Form form={form} layout="vertical" initialValues={toFormValues(item)} preserve={false}>
{/* 字段较多,不限高的话底部按钮会被顶出视口,提交就点不到了。 */}
<div className="max-h-[64vh] overflow-y-auto pr-1">
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请填写标题" }]}>
<Input placeholder="例如:护肤品质感广告主视觉" maxLength={80} />
</Form.Item>
<Form.Item name="description" label="一句话描述">
<Input placeholder="卡片上铺两行,说明这个提示词适合做什么" maxLength={120} />
</Form.Item>
<Form.Item name="image" label="封面地址" rules={[{ required: true, message: "请填写封面图片地址" }]}>
<Input placeholder="https:// 开头的图片地址,或 /public 下的本地路径" />
</Form.Item>
<div className="grid gap-x-4 sm:grid-cols-3">
<Form.Item name="mode" label="创作类型" rules={[{ required: true }]}>
<Select
options={[
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "文本", value: "text" },
]}
/>
</Form.Item>
<Form.Item name="category" label="分类">
<Select allowClear showSearch placeholder="选一个或留空" options={categoryOptions} />
</Form.Item>
<Form.Item name="ratio" label="画面比例">
<Input placeholder="例如 16:9" maxLength={12} />
</Form.Item>
</div>
<Form.Item name="tags" label="标签" extra="用逗号分隔,最多六个">
<Input placeholder="电商产品,海报排版" maxLength={120} />
</Form.Item>
<Form.Item name="prompt" label="提示词正文" rules={[{ required: true, message: "请填写提示词正文" }]}>
<Input.TextArea rows={6} placeholder="粘贴完整提示词;外部英文提示词也可以直接放这里" />
</Form.Item>
<Form.Item name="promptZh" label="中文正文(可选)" extra="填了以后,卡片和「用这个创意创作」会优先用中文这版">
<Input.TextArea rows={4} placeholder="中文改写版,留空则只用上面的正文" />
</Form.Item>
</div>
</Form>
</AppModal>
);
}

View File

@@ -0,0 +1,285 @@
/* 灵感库画廊。卡片是主体:封面占满、信息压在底部、悬停整体抬起来。
颜色、圆角、阴影一律走项目 token,不写字面值。 */
.inspiration-gallery {
display: grid;
gap: var(--space-4, 16px);
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
}
.inspiration-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
/* 玻璃质感:亮描边 + 大而软的投影,和首页纵深画廊同一套语言。 */
border: 1px solid color-mix(in srgb, var(--user-ink) 9%, transparent);
border-radius: var(--r-2xl, 16px);
background: var(--creation-surface, var(--user-surface));
box-shadow: 0 12px 28px -20px color-mix(in srgb, var(--user-ink) 42%, transparent);
text-align: left;
cursor: pointer;
transition:
transform var(--user-motion, 180ms) cubic-bezier(.2, .8, .3, 1),
box-shadow var(--user-motion, 180ms) ease,
border-color var(--user-motion, 180ms) ease;
}
.inspiration-card:hover,
.inspiration-card:focus-visible {
border-color: color-mix(in srgb, var(--user-ink) 18%, transparent);
box-shadow: 0 24px 44px -24px color-mix(in srgb, var(--user-ink) 52%, transparent);
transform: translateY(-3px);
}
.inspiration-card:focus-visible {
outline: 2px solid var(--user-accent);
outline-offset: 2px;
}
.inspiration-card-cover {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--user-surface-muted);
}
.inspiration-card-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 420ms cubic-bezier(.2, .8, .3, 1);
}
.inspiration-card:hover .inspiration-card-cover img {
transform: scale(1.045);
}
/* 封面底部压一层暗角,让源标签始终读得清。 */
.inspiration-card-cover::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 46%;
background: linear-gradient(transparent, rgba(0, 0, 0, .58));
pointer-events: none;
}
.inspiration-card-cover .inspiration-card-source {
position: absolute;
bottom: var(--space-2, 8px);
left: var(--space-2, 8px);
z-index: 1;
padding: 3px var(--space-2, 8px);
border-radius: var(--r-full, 999px);
background: rgba(20, 22, 26, .48);
color: #fff;
font-size: var(--fs-tiny, 10px);
line-height: 1.6;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
/* 悬停才浮出的快捷操作,平时不干扰画面。 */
.inspiration-card-actions {
position: absolute;
top: var(--space-2, 8px);
right: var(--space-2, 8px);
z-index: 2;
display: flex;
gap: var(--space-1, 4px);
opacity: 0;
transition: opacity var(--user-motion, 180ms) ease;
}
.inspiration-card:hover .inspiration-card-actions,
.inspiration-card:focus-within .inspiration-card-actions {
opacity: 1;
}
.inspiration-card-action {
display: grid;
place-items: center;
width: 28px;
height: 28px;
color: #fff;
background: rgba(20, 22, 26, .5);
border: 1px solid rgba(255, 255, 255, .18);
border-radius: var(--r-full, 999px);
cursor: pointer;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: background-color var(--user-motion, 180ms) ease;
}
.inspiration-card-action:hover {
background: rgba(20, 22, 26, .72);
}
.inspiration-card-action.is-on {
color: var(--user-accent);
background: color-mix(in srgb, var(--user-surface) 88%, transparent);
}
.inspiration-card-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1, 4px);
padding: var(--space-3, 12px);
}
.inspiration-card-title {
overflow: hidden;
color: var(--user-ink);
font-size: var(--fs-body, 14px);
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspiration-card-desc {
display: -webkit-box;
overflow: hidden;
color: var(--user-ink-soft);
font-size: var(--fs-caption, 12px);
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.inspiration-card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1, 4px);
margin-top: var(--space-1, 4px);
}
/* 分类标签。未选中态必须有看得出边界的底色:
页面本身是纯白,只给 #f1f1f3 这种 4% 差的浅灰等于没给,整排会糊成"一片白按钮"。 */
.inspiration-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1, 4px);
padding: var(--space-1, 4px) var(--space-3, 12px);
color: var(--user-ink, #1d1d21);
background: color-mix(in srgb, var(--user-ink, #1d1d21) 12%, var(--user-surface, #fff));
border: 1px solid color-mix(in srgb, var(--user-ink, #1d1d21) 22%, transparent);
border-radius: var(--r-full, 999px);
font-size: var(--fs-caption, 12px);
line-height: 1.7;
white-space: nowrap;
cursor: pointer;
transition:
color var(--user-motion, 180ms) ease,
background-color var(--user-motion, 180ms) ease,
border-color var(--user-motion, 180ms) ease;
}
.inspiration-chip:hover {
background: color-mix(in srgb, var(--user-ink, #1d1d21) 18%, var(--user-surface, #fff));
border-color: color-mix(in srgb, var(--user-ink, #1d1d21) 34%, transparent);
}
.inspiration-chip[aria-pressed="true"] {
color: var(--user-surface, #fff);
background: var(--user-ink, #1d1d21);
border-color: transparent;
}
.inspiration-chip:focus-visible {
outline: 2px solid var(--user-accent, #5f55d9);
outline-offset: 2px;
}
/* 标签里的计数:比正文弱一档,但仍要在两种底色上都读得出来。 */
.inspiration-chip-count {
font-variant-numeric: tabular-nums;
opacity: .58;
}
/* 卡片上的分类/署名标签是纯展示,不参与点击,去掉指针与悬停反馈。 */
.inspiration-card-tags .inspiration-chip {
padding: 1px var(--space-2, 8px);
font-size: var(--fs-tiny, 10px);
background: var(--user-surface-muted, #f1f1f3);
border-color: color-mix(in srgb, var(--user-ink, #1d1d21) 12%, transparent);
color: color-mix(in srgb, var(--user-ink, #1d1d21) 72%, transparent);
cursor: default;
}
.inspiration-card-tags .inspiration-chip:hover {
background: var(--user-surface-muted, #f1f1f3);
border-color: color-mix(in srgb, var(--user-ink, #1d1d21) 12%, transparent);
}
/* 详情与列表里的纯图标按钮。带 span 是为了避开 workspace-product.css 里
那条 `button:has(> svg):not(:has(> span))` 的全局浮起规则,这里的悬停是自定义的。 */
.inspiration-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--user-ink-soft);
background: transparent;
border: 1px solid color-mix(in srgb, var(--user-ink) 12%, transparent);
border-radius: var(--r-full, 999px);
cursor: pointer;
transition:
color var(--user-motion, 180ms) ease,
background-color var(--user-motion, 180ms) ease,
border-color var(--user-motion, 180ms) ease;
}
.inspiration-icon-button:hover {
color: var(--user-ink);
background: var(--user-surface-muted);
border-color: color-mix(in srgb, var(--user-ink) 20%, transparent);
}
.inspiration-icon-button[aria-pressed="true"] {
color: var(--user-accent);
border-color: color-mix(in srgb, var(--user-accent) 42%, transparent);
}
.inspiration-icon-button:focus-visible {
outline: 2px solid var(--user-accent);
outline-offset: 2px;
}
/* 提示词正文:等宽、可滚动、保留换行,复制按钮钉在右上角。 */
.inspiration-prompt-block {
position: relative;
max-height: 320px;
padding: var(--space-3, 12px);
overflow: auto;
border: 1px solid color-mix(in srgb, var(--user-ink) 10%, transparent);
border-radius: var(--r-lg, 12px);
background: var(--user-surface-muted);
color: var(--user-ink);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: var(--fs-caption, 12px);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
@media (prefers-reduced-motion: reduce) {
.inspiration-card,
.inspiration-card-cover img {
transition: none;
}
.inspiration-card:hover,
.inspiration-card:focus-visible {
transform: none;
}
.inspiration-card:hover .inspiration-card-cover img {
transform: none;
}
}

View File

@@ -3,7 +3,7 @@ import { createBrowserRouter, Navigate, Outlet, useLocation } from "react-router
import { RequireAuth } from "@/components/auth/require-auth";
import { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectDetailPage, loadProjectsPage } from "@/lib/workspace-route-modules";
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadInspirationsPage, loadProjectDetailPage, loadProjectsPage } from "@/lib/workspace-route-modules";
import { CanvasRefreshShell } from "@/pages/canvas/canvas-refresh-shell";
import { AuthScene } from "@/pages/auth/auth-scene";
import RouteErrorPage from "@/pages/route-error";
@@ -45,6 +45,7 @@ const CanvasPage = lazy(loadCanvasPage);
const CanvasProjectPage = lazy(loadCanvasProjectPage);
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
const CreatePage = lazy(loadCreatePage);
const InspirationsPage = lazy(loadInspirationsPage);
const NotFound = lazy(() => import("@/pages/not-found"));
const SkillsPage = lazy(() => import("@/pages/skills"));
const PluginsPage = lazy(() => import("@/pages/plugins"));
@@ -107,6 +108,7 @@ export const router = createBrowserRouter([
children: [
{ path: "/", element: deferred(<CreatePage />) },
{ path: "/create", element: deferred(<CreatePage />) },
{ path: "/inspirations", element: deferred(<InspirationsPage />) },
{
path: "/tasks",
element: (

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;
}

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();
},
},
),
);