feat(create): 用户首页 - 整合最近画布与灵感大厅

压缩创作首屏,增加最近画布与创建入口,合并灵感大厅并完善响应式与可访问性。修复四个文件的 CI 格式问题,完成 Web checks 对应验证,更新发布版本为 v1.6.8.2。
This commit is contained in:
ddcat666
2026-10-14 14:58:04 +08:00
parent 4e2d7e6924
commit 1a6b21d969
21 changed files with 1016 additions and 461 deletions

View File

@@ -1,5 +1,13 @@
# CHANGELOG
## v1.6.8.2
- 压缩创作首页首屏,一行展示创建画布与最近更新的 4 个画布,优化窄屏浏览与加载状态。
- 将灵感大厅合并到创作首页,移除独立灵感页面与导航,保留搜索、筛选、收藏、编辑和导入导出。
- 优化灵感卡片、封面失败占位、主题适配与键盘/触摸交互;使用灵感后回填提示词并返回输入区。
- 修复图片参数空切片兼容、局部重绘默认模型同步、远端参考素材选择与跨域图片工具导出问题。
- 升级注意:本版本无数据库结构变更。
## v1.6.8.1
- 新增千问 AI(QianWenAI MAAS)图像协议插件,补齐协议清单、接口文档和回归测试。

View File

@@ -1 +1 @@
v1.6.8.1
v1.6.8.2

View File

@@ -485,28 +485,24 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
开启时一级分类也可在后台新增、改名、排序、停用和选择图示,未归入技能仍有默认入口。
## 创作首页纵深画廊与灵感库
## 创作首页、最近画布与灵感大厅
创作页空态是直线纵深图片画廊:11 张横版卡片沿 Z 轴前后排队,当前一张在最前、最大,其余依次后退缩小并左右对称。滚轮、两侧按钮和自动轮播都能切换,切换只动 `transform` 与 `opacity`。卡片带玻璃描边、圆角和投影,底部是「行动点 / 标题 / 描述」三段信息,只有最前那张露出。队列跟随输入框的创作类型:选图片只排图片灵感,选视频只排视频灵感,选文本用本项目原创条目。每次进场和每次换类型都重新随机抽一批,并记住最近出现过哪些,走完一轮自动换批。
根路径 `/` 与 `/create` 共用创作首页。空态首屏提供紧凑标题、文本/图片/视频/Agent 模式、真实提示词输入区和快捷创作入口。下方一行展示创建画布与最近更新的 4 个画布,支持打开、查看全部和窄屏横向浏览;读取失败提供重试,创建后明确区分本机保存与云端同步。
工作区左侧「创作」下方新增灵感页 `/inspirations`,展示全部提示词,按来源、分类和创作类型筛选,支持搜索、收藏、视图切换和分页;自建灵感支持新建、编辑、删除和 JSON 导入导出,改动只存在本地。
灵感大厅合并到创作首页下方,不再提供独立灵感导航或 `/inspirations` 页面路由。类型与搜索常驻,来源、分类、收藏/隐藏和排序收拢在更多筛选中;默认每页 12 条。新建、编辑、隐藏/恢复、收藏与 JSON 导入导出继续保留,本地改动按账号 scope 持久化。使用灵感会关闭详情、回填首页提示词并返回输入区,不自动生成。
内置灵感的提示词统一提供中文正文:详情阅读、复制、编辑和一键带入创作使用同一份中文内容,首页画廊保持一致。已有中文来源直接采用中文版本,其余外文正文翻译后随静态目录发布,不需要客户等待在线翻译。模型名、引用标记和模板参数保留原有用途。上游原页仍可从来源链接访问。
内置提示词统一提供中文正文,详情阅读、复制、编辑和回填创作使用同一份中文内容。已有中文来源直接采用中文版本,其余外文正文翻译后随静态目录发布;模型名、引用标记和模板参数保留原有用途。来源及原页链接显示遵循后台来源展示开关。
译文维护在 `web/scripts/data/inspiration-prompts-zh.json`,记录原文 SHA-256;导入脚本在写入目录前校验原文版本。上游正文更新或新增条目时需补齐匹配的译文,缺译会停止导入,避免重新发布英文提示词。
译文维护在 `web/scripts/data/inspiration-prompts-zh.json`,记录原文 SHA-256;导入脚本写入目录前校验原文版本,缺译会停止导入。
生产 Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,支持直接打开和刷新;`/inspirations/*.json` 继续提供静态目录数据,避免页面路径与同名目录冲突而跳转到容器端口。
外部提示词由 `web/scripts/import-inspirations.mjs` 同步:`awesome-seedance`、好好学 AI 提示词库、YouMind 的两个姊妹库。脚本同时为画廊池生成本地缩略图(560px、webp、约 35KB 一张)。上游封面平均 400KB~700KB,直接引用一屏 11 张要拉 5MB 以上,换成本地缩略图后约 400KB。
画廊池走 `public/inspirations/gallery-pool.json` 运行时拉取,不打进首屏 JS:几百条提示词正文压在包里是白背的,而这份数据只服务画廊一处。池子按来源等距抽取以横跨各分类,并且只收描述非空的条目,保证卡片三行版式不缺角;这些条目仍留在全量目录里供灵感页展示。
外部提示词由 `web/scripts/import-inspirations.mjs` 同步。`web/public/inspirations/*.json` 继续提供静态目录数据,灵感大厅进入可视区域附近后才按需加载全量目录,浏览器缓存与会话内缓存避免重复请求;封面保持稳定比例,加载失败显示占位内容。
### 相关实现
- `web/src/pages/create/creation-inspiration-tunnel.tsx`、`creation-inspiration-tunnel.css`:纵深画廊组件与样式。
- `web/src/pages/inspirations/`:灵感页、详情弹窗、编辑弹窗与样式。
- `web/src/lib/inspirations/`:来源登记表、条目类型与解析、原创条目、上游版本号(`catalog.ts`、`resolve.ts`、`source-revisions.ts`)。
- `web/src/services/inspiration-catalog.ts`:全量目录与画廊池的运行时加载和内存缓存。
- `web/src/pages/create/index.tsx`、`creation-product.css`:首页组合、提示词回填与紧凑布局。
- `web/src/pages/create/creation-recent-canvases.tsx`:最近画布查询、创建与快捷入口。
- `web/src/pages/inspirations/`:内嵌灵感大厅、详情/编辑弹窗与样式。
- `web/src/lib/inspirations/`:来源登记、条目类型、解析、原创条目与上游版本。
- `web/src/services/inspiration-catalog.ts`:静态目录的运行时加载与内存缓存。
- `web/src/stores/use-inspiration-store.ts`:自建、覆盖、隐藏与收藏的本地持久化。
- `web/scripts/import-inspirations.mjs`:外部来源同步、缩略图生成与画廊池落盘。
- `web/public/inspirations/`、`web/public/inspiration-thumbs/`:全量目录、画廊池与本地缩略图。
- `web/scripts/import-inspirations.mjs`:外部来源同步、缩略图生成与目录落盘。

View File

@@ -92,13 +92,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- [ ] 在本地未缓存云端素材的浏览器中选择图片、视频和音频,确认支持的条目可选并能添加;跨页选择后确认应保留选择顺序,再次打开弹窗应保留已选状态。
- [ ] 切换到图片创作及不同视频模型,确认不支持的媒体类型保持禁用,参考数量仍受模型上限约束;云端素材读取失败时保留弹窗并显示错误。
## 灵感页路由与导航
## 创作首页与灵感大厅整合
- 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。
- 已修复:顶部面包屑补齐灵感路由标题,显示「站点名称 / 灵感」,与侧栏一致,不再回退到站点名称。
- [x] 使用生产同版本 Nginx 容器复现旧配置的 301;修改后配置检查通过,两个页面路径及查询参数返回入口文件且无重定向,五个真实 JSON 文件返回原内容和正确类型。该验证只覆盖网关,不替代真实页面验收。
- [ ] 更新前端镜像后,通过实际域名直接打开和刷新两个路径,确认不跳转到 `:3000`、不返回 403,且灵感目录正常加载。
- [ ] 通过侧栏进入、直接打开及刷新灵感页,确认面包屑显示「灵感」。
- 已实现:首页首屏压缩为标题、创作模式、提示词区与快捷入口;无附件时参考内容入口合并到输入工具栏。下方一行展示创建入口和最近更新的 4 个画布,保留查看全部、加载失败重试和窄屏横向浏览。
- 已实现:灵感大厅迁入创作首页,移除独立页面路由、侧栏入口与预加载;保留搜索、类型/来源/分类/收藏隐藏筛选、分页、详情、编辑、导入导出。使用灵感会关闭详情、回填提示词并返回输入区,不自动生成。
- 已实现:来源展示开关同时作用于卡片和详情;封面失败显示占位,触摸操作常显,键盘焦点与减少动效可用。根路径及 `/create` 的页面壳和侧栏高亮一致。
- 已验证:前端生产构建、最终类型检查、UI lint 与补丁格式检查通过;已有路由、游客访问与灵感目录专项测试 13 项通过。真实 Chrome 游客首页 11 项断言通过:1440/2048/768/390px 无页面横向溢出、真实主题按钮切换暗色、创建入口打开登录弹窗、灵感详情回填真实输入框、独立灵感导航移除、`/create` 高亮与无未捕获运行时异常。已查看实际页面截图;未提交生成任务或用真实账号创建画布。
- 已验证:与 GitHub Actions 同命令的冻结依赖安装、变更文件格式检查、Typecheck 和 UI lint 通过;前端全量测试 2302 项通过、0 失败,pretest 通过;预演 P0 Chrome E2E 65 项断言通过。修复灵感大厅、详情、样式和最近画布组件的四处 CI 格式问题;发布版本与更新日志对齐 `v1.6.8.2`。
- [ ] 用户验收:登录态最近画布排序、创建并进入画布、云端同步失败提示、查看全部;切换账号后列表不串数据。
- [ ] 用户验收:明暗主题与 390/768/1440px 布局、滚动、弹窗、空态和完整长提示词编辑;收藏、新建/编辑、隐藏恢复与 JSON 导入导出正常,来源开关显示一致。
## 画布素材缺失时的本地强制覆盖

View File

@@ -23,7 +23,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const showGlobalTopBar = !hideChrome;
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
const creationWorkspace = pathname === "/";
const creationWorkspace = pathname === "/" || pathname === "/create";
const isMobileViewport = () => window.innerWidth < 1024;

View File

@@ -45,7 +45,6 @@ function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[];
{
items: [
{ ...toolItem("create", "/"), id: "home", title: "创作" },
{ ...toolItem("inspirations", "/inspirations"), title: "灵感" },
{ ...toolItem("projects", "/projects"), title: "短剧制作" },
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
],
@@ -338,6 +337,7 @@ export function workspaceNavActiveId(pathname: string, section?: string | null)
const segments = pathname.split("/").filter(Boolean);
if (segments[0] === "toolbox" && segments[1]) return `toolbox-${segments[1]}`;
const slug = segments[0] || "home";
if (slug === "create") return "home";
return slug === "settings" && section ? `settings:${section}` : slug;
}

View File

@@ -15,7 +15,6 @@ import { openWorkspaceWallet } from "@/lib/workspace-wallet";
const PAGE_TITLES: Record<string, string> = {
home: "创作",
create: "创作",
inspirations: "灵感",
projects: "短剧制作",
canvas: "自由画布",
tasks: "创作历史",

View File

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

View File

@@ -2,7 +2,6 @@ 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"),
};
@@ -11,7 +10,6 @@ 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

@@ -28,7 +28,7 @@ export function CreationLoginDialog({ open, onClose }: { open: boolean; onClose:
await login({ username: username.trim(), password });
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
message.success("登录成功,请再次点击生成");
message.success("登录成功,可以继续创作了");
onClose();
} catch (error) {
message.error(error instanceof Error ? error.message : "登录失败");
@@ -40,7 +40,7 @@ export function CreationLoginDialog({ open, onClose }: { open: boolean; onClose:
return (
<AppModal open={open} onCancel={onClose} title="登录后开始创作" footer={null} centered width="min(440px, calc(100vw - 32px))">
<div className="space-y-4">
<p className="text-sm text-foreground/60">你的创作内容会保留。登录成功后,请再次点击生成。</p>
<p className="text-sm text-foreground/60">你的创作内容会保留。登录成功后,即可继续创作或创建画布。</p>
<form className="space-y-3" onSubmit={(event) => void submit(event)}>
<Input
size="large"

View File

@@ -1,4 +1,58 @@
/* Creation-specific geometry. Shared controls still use the workspace tokens. */
.creation-home-scroll { display: block; width: 100%; min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: var(--space-6) var(--space-8) var(--space-12); }
.creation-home-scroll > * { width: 100%; max-width: 1280px; margin-inline: auto; }
.creation-home-scroll > .creation-launchpad { width: min(960px, 100%); margin-inline: auto; }
.creation-start-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
.creation-start-heading h1 { margin: 0; color: var(--user-ink); font-size: var(--typ-display-sm); font-weight: 650; letter-spacing: -.025em; line-height: 1.3; }
.creation-start-heading p { margin: var(--space-1) 0 0; color: var(--user-ink-muted); font-size: var(--fs-caption); line-height: 1.6; }
.creation-history-entry { display: inline-flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); min-height: var(--user-control-height); border: 0; border-radius: var(--user-radius-control); padding: var(--space-2) var(--space-3); background: var(--user-surface-muted); color: var(--user-ink-muted); font-size: var(--fs-caption); cursor: pointer; }
.creation-history-entry:hover { background: var(--user-surface-hover); color: var(--user-ink); }
.creation-history-entry svg { width: var(--user-icon-size); height: var(--user-icon-size); }
.creation-recent-canvases { margin-top: var(--space-6); }
.creation-section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
.creation-section-heading h2 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--user-ink); font-size: var(--fs-body); font-weight: 600; }
.creation-section-heading h2 svg { width: var(--user-icon-size); height: var(--user-icon-size); color: var(--user-ink-muted); }
.creation-section-link { display: inline-flex; align-items: center; gap: var(--space-1); color: var(--user-ink-muted); font-size: var(--fs-caption); }
.creation-section-link:hover { color: var(--user-ink); }
.creation-section-link svg { width: var(--user-icon-size); height: var(--user-icon-size); }
.creation-canvas-strip { display: flex; gap: var(--space-3); overflow-x: auto; padding: var(--space-1) var(--space-1) var(--space-2); margin-inline: calc(-1 * var(--space-1)); scroll-snap-type: x proximity; }
.creation-canvas-strip-item { min-width: 200px; flex: 1 0 200px; scroll-snap-align: start; }
.creation-canvas-tile { display: flex; align-items: center; gap: var(--space-3); width: 100%; min-width: 0; height: 88px; border: 0; border-radius: var(--user-radius-card); padding: var(--space-3); background: var(--library-surface); color: var(--user-ink); text-align: left; cursor: pointer; transition: background var(--user-motion), transform var(--user-motion); }
.creation-canvas-tile:hover { background: var(--library-surface-hover); transform: translateY(-2px); }
.creation-canvas-tile:disabled { cursor: wait; opacity: .5; transform: none; }
.creation-canvas-thumbnail { display: block; width: 56px; height: 56px; flex: 0 0 56px; overflow: hidden; border-radius: var(--user-radius-control); background: var(--library-cover-surface); }
.creation-canvas-thumbnail.is-create { display: grid; place-items: center; background: var(--library-icon-surface); color: var(--user-ink-muted); }
.creation-canvas-thumbnail.is-create svg { width: var(--space-6); height: var(--space-6); }
.creation-canvas-thumbnail .canvas-project-empty { background: var(--library-cover-surface); }
.creation-canvas-thumbnail .canvas-project-empty-icon { width: var(--space-5); height: var(--space-5); margin: 0; }
.creation-canvas-thumbnail .canvas-project-empty :is(span, small) { display: none; }
.creation-canvas-copy { display: flex; min-width: 0; flex-direction: column; gap: var(--space-1-half); }
.creation-canvas-copy strong { overflow: hidden; font-size: var(--fs-caption); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.creation-canvas-copy small { overflow: hidden; color: var(--user-ink-muted); font-size: var(--fs-label); text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.creation-canvas-strip-state { display: flex; min-width: 0; flex: 4 0 240px; align-items: center; gap: var(--space-3); border-radius: var(--user-radius-card); padding: var(--space-4); color: var(--user-ink-muted); background: var(--library-surface); font-size: var(--fs-caption); }
.creation-canvas-strip-state > svg { width: var(--space-5); height: var(--space-5); flex-shrink: 0; }
.creation-canvas-strip-state button { flex: 0 0 auto; margin-left: auto; border: 0; border-radius: var(--user-radius-control); padding: var(--space-2) var(--space-3); background: var(--library-icon-surface); color: var(--user-ink); cursor: pointer; }
.creation-canvas-skeleton { display: flex; height: 88px; align-items: center; gap: var(--space-3); border-radius: var(--user-radius-card); padding: var(--space-3); background: var(--library-surface); }
.creation-canvas-skeleton > span { width: 56px; height: 56px; flex: 0 0 56px; border-radius: var(--user-radius-control); background: var(--library-cover-surface); }
.creation-canvas-skeleton > span:last-child { width: 60%; height: var(--space-5); flex: 1; }
.creation-hall-loading { margin-top: var(--space-8); padding: var(--space-6); border-radius: var(--user-radius-card); background: var(--library-surface); color: var(--user-ink-muted); font-size: var(--fs-caption); }
.creation-source-notice { margin-top: var(--space-5); color: var(--user-ink-muted); font-size: var(--fs-caption); line-height: 1.7; }
.creation-source-notice summary { width: fit-content; padding-block: var(--space-2); cursor: pointer; }
.creation-source-notice p { margin: var(--space-2) 0; }
.creation-source-notice a { color: var(--user-ink); text-decoration: underline; text-underline-offset: var(--space-half); }
:is(.creation-history-entry, .creation-section-link, .creation-canvas-tile, .creation-canvas-strip-state button):focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
@media (max-width: 720px) {
.creation-home-scroll { padding: var(--space-4) var(--space-4) var(--space-8); }
.creation-start-heading { align-items: flex-start; margin-bottom: var(--space-4); gap: var(--space-2); }
.creation-start-heading h1 { font-size: var(--fs-title); }
.creation-history-entry { padding: var(--space-2); }
.creation-history-entry span { display: none; }
.creation-canvas-strip-item { min-width: 220px; flex-basis: 220px; }
}
@media (prefers-reduced-motion: reduce) {
.creation-canvas-tile { transition: none; }
.creation-canvas-tile:hover { transform: none; }
}
.creation-mode-tabs { position: relative; isolation: isolate; display: grid; width: min(456px, 100%); margin: 0 auto 20px; padding: 4px; border-radius: 18px; background: var(--user-surface-muted); }
.creation-mode-indicator { position: absolute; z-index: -1; inset: 0; border-radius: 14px; background: var(--user-surface); box-shadow: 0 2px 8px color-mix(in srgb, var(--user-ink) 5%, transparent); }
.creation-mode-button { position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; height: 44px; padding: 0 12px; border: 0; border-radius: 14px; background: transparent; color: var(--user-ink-muted); font-size: 14px; font-weight: 600; cursor: pointer; transition: color var(--user-motion), transform var(--user-motion); }

View File

@@ -0,0 +1,135 @@
import { useMemo, useRef, useState } from "react";
import { App } from "antd";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpRight, Clock3, LoaderCircle, PanelsTopLeft, Plus } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { ProjectPreview } from "@/components/canvas/canvas-project-card";
import { getActiveUserScope } from "@/lib/user-scope";
import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
import { listRemoteCanvasProjectsPage, type CanvasLibrarySummary } from "@/services/api/user-data";
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useUserStore } from "@/stores/use-user-store";
const RECENT_COUNT = 4;
const updatedFormatter = new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
export function CreationRecentCanvases({ onRequireLogin }: { onRequireLogin: () => void }) {
const { message } = App.useApp();
const navigate = useNavigate();
const userId = useUserStore((state) => state.user?.id);
const sessionHydrated = useUserStore((state) => state.hydrated);
const canvasHydrated = useCanvasStore((state) => state.hydrated);
const localProjects = useCanvasStore((state) => state.projects);
const [creating, setCreating] = useState(false);
const creatingRef = useRef(false);
const recentQuery = useQuery({
queryKey: ["canvas-library", userId, "recent"],
queryFn: ({ signal }) => listRemoteCanvasProjectsPage({ page: 1, pageSize: RECENT_COUNT, sort: "updated", signal }),
enabled: Boolean(userId) && sessionHydrated,
});
const recentProjects = useMemo<CanvasLibrarySummary[]>(
() =>
userId
? recentQuery.data?.projects || []
: [...localProjects]
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
.slice(0, RECENT_COUNT)
.map((project) => ({ ...project, nodeCount: project.nodes.length, previewNodes: project.nodes.slice(0, 4) })),
[localProjects, recentQuery.data, userId],
);
const loading = !sessionHydrated || !canvasHydrated || Boolean(userId && recentQuery.isPending);
const preload = () => {
void loadCanvasProjectPage();
};
const createCanvas = async () => {
if (!userId) {
onRequireLogin();
return;
}
if (!canvasHydrated || creatingRef.current) return;
const scope = getActiveUserScope();
creatingRef.current = true;
setCreating(true);
try {
const { id, syncError } = await createCanvasProjectWithRemoteSync("未命名画布");
await flushCanvasStorePersistence();
if (getActiveUserScope() !== scope || useUserStore.getState().user?.id !== userId) return;
if (syncError) message.warning("画布已保存在本机,云端同步尚未完成,将自动重试。");
navigate(`/canvas/${id}`);
} catch (error) {
if (getActiveUserScope() === scope) message.error(error instanceof Error ? error.message : "创建画布失败,请重试");
} finally {
creatingRef.current = false;
setCreating(false);
}
};
return (
<section className="creation-recent-canvases" aria-labelledby="creation-recent-title">
<header className="creation-section-heading">
<h2 id="creation-recent-title">
<Clock3 aria-hidden="true" />
最近画布
</h2>
<Link to="/canvas" className="creation-section-link">
查看全部
<ArrowUpRight aria-hidden="true" />
</Link>
</header>
<div className="creation-canvas-strip creation-scrollbar" role="list" aria-label="最近更新的画布" aria-busy={loading}>
<div className="creation-canvas-strip-item" role="listitem">
<button type="button" className="creation-canvas-tile is-create" disabled={!sessionHydrated || !canvasHydrated || creating} onClick={() => void createCanvas()} onPointerEnter={preload} onFocus={preload}>
<span className="creation-canvas-thumbnail is-create">{creating ? <LoaderCircle className="animate-spin" aria-hidden="true" /> : <Plus aria-hidden="true" />}</span>
<span className="creation-canvas-copy">
<strong>{creating ? "正在创建…" : "创建画布"}</strong>
<small>从空白开始</small>
</span>
</button>
</div>
{loading ? (
Array.from({ length: RECENT_COUNT }, (_, index) => (
<div key={index} className="creation-canvas-strip-item creation-canvas-skeleton" role="listitem" aria-label="正在读取画布">
<span />
<span />
</div>
))
) : userId && recentQuery.isError ? (
<div className="creation-canvas-strip-state" role="listitem">
<span role="alert">最近画布暂时无法读取</span>
<button type="button" onClick={() => void recentQuery.refetch()} disabled={recentQuery.isFetching}>
{recentQuery.isFetching ? "重试中…" : "重试"}
</button>
</div>
) : recentProjects.length ? (
recentProjects.map((project) => (
<div className="creation-canvas-strip-item" role="listitem" key={project.id}>
<Link to={`/canvas/${project.id}`} className="creation-canvas-tile" onPointerEnter={preload} onFocus={preload} title={project.title}>
<span className="creation-canvas-thumbnail" aria-hidden="true">
<ProjectPreview project={{ id: project.id, nodes: project.previewNodes }} preferLatestImage />
</span>
<span className="creation-canvas-copy">
<strong>{project.title || "未命名画布"}</strong>
<small>
{project.nodeCount} 个节点 · <time dateTime={project.updatedAt}>{Number.isFinite(Date.parse(project.updatedAt)) ? updatedFormatter.format(new Date(project.updatedAt)) : "时间未知"}</time>
</small>
</span>
</Link>
</div>
))
) : (
<div className="creation-canvas-strip-state" role="listitem">
<PanelsTopLeft aria-hidden="true" />
<span>{userId ? "还没有画布,把第一个想法留在这里。" : "登录后,最近的画布会显示在这里。"}</span>
{!userId ? (
<button type="button" onClick={onRequireLogin}>
登录
</button>
) : null}
</div>
)}
</div>
</section>
);
}

View File

@@ -305,7 +305,7 @@ export function CreationComposer(props: ComposerProps) {
<div className="creation-chat-writing-surface">
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能;回车发送,Shift+回车换行" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
{props.attachments.length || referencesSupported ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
{props.attachments.length ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
{trackState.isExpanded ? <div className="creation-reference-panel-header">
<div className="creation-reference-filter-tabs" role="group" aria-label="筛选参考内容">
{([
@@ -374,6 +374,7 @@ export function CreationComposer(props: ComposerProps) {
<footer className="creation-chat-dock">
<div className="creation-chat-controls">
{props.variant === "thread" ? <ModePicker mode={props.mode} onModeChange={props.onModeChange} /> : null}
{referencesSupported && !props.attachments.length ? <Tooltip title={addReferenceLabel}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={interactionBusy || !canAddMoreReferences} aria-label={addReferenceLabel}><Plus aria-hidden="true" /><span>参考内容</span></button></Tooltip> : null}
<VoiceRecordingButton
className="creation-voice-trigger"
disabled={interactionBusy}

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, useLocation } from "react-router";
import { useNavigate } from "react-router";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
@@ -35,14 +35,16 @@ import { creationLibraryDisabledReason, creationLibrarySelectionToAttachments }
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, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
import { CreationInspirationTunnel } from "./creation-inspiration-tunnel";
import { declaredInspirationSources } from "@/lib/inspirations/catalog";
import { fillablePrompt } from "@/lib/inspirations/resolve";
import { CreationRecentCanvases } from "./creation-recent-canvases";
import { CreationAgentEntry } from "./creation-agent-entry";
import { createCreationSubmitGate } from "./creation-submit-gate";
import { creationVideoConfig } from "./creation-generation-config";
import { CreationLoginDialog } from "./creation-login-dialog";
const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal })));
const InspirationHall = lazy(() => import("@/pages/inspirations"));
const loadCreationRuntime = () => import("./creation-runtime");
type CreationRuntime = Awaited<ReturnType<typeof loadCreationRuntime>>;
@@ -69,7 +71,6 @@ 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);
@@ -393,21 +394,6 @@ 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 });
@@ -1010,6 +996,13 @@ export default function CreatePage() {
const generationActive = activeConversation.messages.some((message) => message.role === "assistant" && message.status === "pending");
const startPrompt = (nextMode: CreationMode, nextPrompt: string) => {
setAgentMode(false);
selectMode(nextMode);
setPrompt(nextPrompt);
threadScrollRef.current?.scrollTo({ top: 0, behavior: reducedMotion ? "auto" : "smooth" });
window.requestAnimationFrame(() => composerFocusRef.current?.focus({ preventScroll: true }));
};
const composerProps = {
mode,
@@ -1065,9 +1058,6 @@ export default function CreatePage() {
return <>
<div className="creation-home relative flex h-full min-h-0 flex-col overflow-hidden">
{isEmpty ? <>
<div className="creation-top-actions">
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
</div>
<AnimatePresence>
{launchpadCondensed && !agentMode ? <motion.div className="creation-floating-prompt" key="floating-prompt"
style={{ x: "-50%" }}
@@ -1081,26 +1071,29 @@ export default function CreatePage() {
}}><Maximize2 /></button></Tooltip>
</motion.div> : null}
</AnimatePresence>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
<div className="creation-home-heading">
<h1>和{brandName}聊聊创作想法</h1>
<p>从一个画面、一个角色或一句话开始,继续你的创作。</p>
</div>
<CreationInspirationTunnel
mode={mode}
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
/>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-home-scroll creation-scrollbar">
<section ref={launchpadRef} className="creation-launchpad" aria-label="开始创作">
<header className="creation-start-heading">
<div>
<h1>和{brandName}聊聊创作想法</h1>
<p>从一个画面、一个角色或一句话开始。</p>
</div>
<button type="button" className="creation-history-entry" aria-label="查看历史对话" aria-expanded={historyOpen} onClick={() => setHistoryOpen(true)}><History aria-hidden="true" /><span>历史对话</span></button>
</header>
<div className={cn("creation-composer-stage is-home-mode", agentMode && "is-agent-mode")}>
<CreationModeTabs mode={mode} agentActive={agentMode} onAgentSelect={() => setAgentMode(true)} onModeChange={(next) => { setAgentMode(false); selectMode(next); }} />
{agentMode ? <CreationAgentEntry /> : <div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>}
</div>
<CreationEmptySuggest
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
onStartPrompt={startPrompt}
onOpenLibrary={() => { setAgentMode(false); selectMode("image"); setLibraryOpen(true); }}
/>
</section>
{inspirationSourcesVisible ? <div className="creation-inspiration-credit">
<CreationRecentCanvases onRequireLogin={() => setLoginDialogOpen(true)} />
<Suspense fallback={<div className="creation-hall-loading" role="status">正在准备灵感大厅…</div>}>
<InspirationHall onUse={(item) => startPrompt(item.mode, fillablePrompt(item))} />
</Suspense>
{inspirationSourcesVisible ? <div className="creation-source-notice">
<details>
<summary>素材来源</summary>
{declaredInspirationSources().map((source) => (

View File

@@ -1,19 +1,19 @@
import { App, Button, Dropdown, Input } from "antd";
import { Download, Heart, Import, MoreHorizontal, Pencil, Plus, RotateCcw, Search, Trash2 } from "lucide-react";
import { ChevronDown, Download, Heart, Import, MoreHorizontal, Pencil, Plus, RotateCcw, Search, SlidersHorizontal, 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 { PaginationBar } 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 { 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 { useUserStore } from "@/stores/use-user-store";
import { InspirationCover, InspirationDetailModal } from "./inspiration-detail-modal";
import { InspirationEditorModal, type InspirationCategoryChoice } from "./inspiration-editor-modal";
import "./inspirations.css";
@@ -50,9 +50,9 @@ function downloadText(text: string, fileName: string) {
URL.revokeObjectURL(url);
}
export default function InspirationsPage() {
export function InspirationHall({ onUse }: { onUse: (item: CreationInspiration) => void }) {
const { message, modal } = App.useApp();
const navigate = useNavigate();
const inspirationSourcesVisible = useUserStore((state) => state.features.inspirationSourcesVisible);
const custom = useInspirationStore((state) => state.custom);
const overrides = useInspirationStore((state) => state.overrides);
@@ -68,8 +68,11 @@ export default function InspirationsPage() {
const importPayload = useInspirationStore((state) => state.importPayload);
const [catalog, setCatalog] = useState<InspirationCatalog | null>(null);
const [shouldLoadCatalog, setShouldLoadCatalog] = useState(false);
const [loadError, setLoadError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const [source, setSource] = useState<SourceFilter>("all");
const [modeFilter, setModeFilter] = useState<ModeFilter>("all");
@@ -79,7 +82,7 @@ export default function InspirationsPage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(24);
const [pageSize, setPageSize] = useState(12);
const [detail, setDetail] = useState<CreationInspiration | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
@@ -87,6 +90,26 @@ export default function InspirationsPage() {
const importRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
if (typeof IntersectionObserver === "undefined") {
setShouldLoadCatalog(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setShouldLoadCatalog(true);
observer.disconnect();
},
{ rootMargin: "240px" },
);
observer.observe(section);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!shouldLoadCatalog) return;
let cancelled = false;
setLoadError("");
loadInspirationCatalog()
@@ -99,7 +122,11 @@ export default function InspirationsPage() {
return () => {
cancelled = true;
};
}, [reloadKey]);
}, [shouldLoadCatalog, reloadKey]);
useEffect(() => {
if (!inspirationSourcesVisible) setSource("all");
}, [inspirationSourcesVisible]);
// 筛选条件一变就回第一页,否则会停在一个已经不存在的位置上。
useEffect(() => {
@@ -133,19 +160,18 @@ export default function InspirationsPage() {
[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 });
}
for (const item of [...resolved, ...hiddenItems]) {
if (source !== "all" && (item.sourceId ?? "original") !== source) continue;
const value = item.category?.trim();
if (!value) continue;
const current = merged.get(value);
merged.set(value, { label: current?.label ?? categoryLabel(item), count: (current?.count ?? 0) + 1 });
}
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]);
}, [categoryLabel, hiddenItems, resolved, source]);
const sourceCounts = useMemo(() => {
const counts = new Map<SourceFilter, number>();
@@ -172,13 +198,25 @@ export default function InspirationsPage() {
}, [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],
() =>
(
[
{ label: "全部", value: "all" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "文本", value: "text" },
] as const
).map((option) => ({
value: option.value,
label: (
<>
<span>{option.label}</span>
{catalog ? <span className="inspiration-mode-count">{modeCounts[option.value].toLocaleString("zh-CN")}</span> : null}
</>
),
title: catalog ? `${option.label} ${modeCounts[option.value]} 条` : option.label,
})),
[catalog, modeCounts],
);
const visible = useMemo(() => {
@@ -198,13 +236,27 @@ export default function InspirationsPage() {
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 lastPage = Math.max(1, Math.ceil(visible.length / pageSize));
const currentPage = Math.min(page, lastPage);
const paged = useMemo(() => visible.slice((currentPage - 1) * pageSize, currentPage * pageSize), [visible, currentPage, pageSize]);
useEffect(() => {
setPage((current) => Math.min(current, lastPage));
}, [lastPage]);
const handlePageChange = (next: number, size: number) => {
setPage(next);
setPageSize(size);
window.requestAnimationFrame(() => {
sectionRef.current?.scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
});
};
const handleUse = useCallback(
(item: CreationInspiration) => {
navigate("/create", { state: { inspirationPrompt: fillablePrompt(item), inspirationMode: item.mode } });
setDetail(null);
onUse(item);
},
[navigate],
[onUse],
);
const handleCopy = useCallback(
@@ -254,7 +306,9 @@ export default function InspirationsPage() {
setModeFilter("all");
setCategory("all");
setView("all");
setSort("default");
setSearch("");
setPage(1);
message.success("灵感已创建");
},
[createEntry, editing, message, updateEntry],
@@ -277,24 +331,162 @@ export default function InspirationsPage() {
[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>
const advancedFilterCount = Number(source !== "all") + Number(category !== "all") + Number(view !== "all") + Number(sort !== "default");
const hasFilters = Boolean(advancedFilterCount || modeFilter !== "all" || search.trim());
const resetFilters = () => {
setSource("all");
setModeFilter("all");
setCategory("all");
setView("all");
setSort("default");
setSearch("");
};
return (
<section ref={sectionRef} id="creation-inspirations" className="inspiration-hall" aria-labelledby="inspiration-hall-title">
<header className="inspiration-hall-header">
<div>
<h2 id="inspiration-hall-title">灵感大厅</h2>
<p>点开查看完整提示词,用喜欢的创意继续创作。</p>
</div>
<div className="inspiration-hall-actions">
<Button
disabled={!hydrated}
icon={<Plus className="size-4" />}
onClick={() => {
setEditing(null);
setEditorOpen(true);
}}
>
新建灵感
</Button>
<Dropdown
trigger={["click"]}
placement="bottomRight"
menu={{
items: [
{ key: "import", label: "导入灵感", icon: <Import className="size-4" />, disabled: !hydrated },
{ key: "export", label: "导出本地灵感", icon: <Download className="size-4" />, disabled: !hydrated },
],
onClick: ({ key }) => {
if (key === "import") importRef.current?.click();
if (key === "export") handleExport();
},
}}
>
<Button type="text" icon={<MoreHorizontal className="size-4" />} aria-label="更多灵感管理操作" title="更多操作" />
</Dropdown>
</div>
</header>
<div className="inspiration-hall-toolbar">
<SegmentedControl options={modeOptions} value={modeFilter} onChange={(value) => setModeFilter(value as ModeFilter)} ariaLabel="按创作类型筛选灵感" className="inspiration-hall-modes" />
<Input allowClear prefix={<Search className="size-4" />} placeholder="搜索标题、描述或标签" aria-label="搜索灵感" value={search} onChange={(event) => setSearch(event.target.value)} className="inspiration-hall-search" />
<Button type="text" icon={<SlidersHorizontal className="size-4" />} aria-expanded={filtersOpen} aria-controls="inspiration-hall-filters" onClick={() => setFiltersOpen((open) => !open)} className="inspiration-hall-filter-toggle">
更多筛选{advancedFilterCount ? ` · ${advancedFilterCount}` : ""}
<ChevronDown className={filtersOpen ? "is-open" : undefined} aria-hidden="true" />
</Button>
<span className="inspiration-hall-count" role="status">
{catalog && hydrated ? `共 ${visible.length.toLocaleString("zh-CN")} 条` : ""}
</span>
</div>
{filtersOpen ? (
<div id="inspiration-hall-filters" className="inspiration-hall-filters" role="group" aria-label="灵感进阶筛选">
<div className="inspiration-hall-field">
<span>浏览范围</span>
<Select options={VIEW_OPTIONS} value={view} onChange={(value) => setView(value as ViewFilter)} ariaLabel="按全部、收藏或隐藏状态筛选灵感" />
</div>
{inspirationSourcesVisible ? (
<div className="inspiration-hall-field">
<span>来源</span>
<Select
options={[
{ label: "全部来源", 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");
}}
ariaLabel="按来源筛选灵感"
/>
</div>
) : null}
<div className="inspiration-hall-field">
<span>分类</span>
<Select
showSearch
optionFilterProp="label"
options={[{ label: "全部分类", value: "all" }, ...categoryChoices.map((choice) => ({ value: choice.value, label: `${choice.label} (${choice.count})` }))]}
value={category}
onChange={setCategory}
ariaLabel="按分类筛选灵感"
/>
</div>
<div className="inspiration-hall-field">
<span>排序</span>
<Select options={SORT_OPTIONS} value={sort} onChange={(value) => setSort(value as SortKey)} ariaLabel="灵感排序" />
</div>
{hasFilters ? (
<Button type="text" icon={<RotateCcw className="size-4" />} onClick={resetFilters} className="inspiration-hall-reset">
重置筛选
</Button>
) : null}
</div>
) : null}
<div className="inspiration-hall-results">
{!shouldLoadCatalog ? (
<div className="inspiration-hall-deferred" aria-hidden="true" />
) : loadError ? (
<WorkspaceErrorState title="灵感目录加载失败" description={loadError} onRetry={() => setReloadKey((key) => key + 1)} />
) : !catalog || !hydrated ? (
<WorkspaceLoadingState label="正在准备灵感" rows={3} />
) : paged.length ? (
<>
<div className="inspiration-gallery">
{paged.map((item) => {
const id = catalogIdOf(item);
return (
<InspirationCard
key={id}
item={item}
favorite={favorites.includes(id)}
hidden={view === "hidden"}
showSources={inspirationSourcesVisible}
categoryLabel={categoryLabel(item)}
onOpen={() => setDetail(item)}
onToggleFavorite={() => toggleFavorite(id)}
onRestore={() => {
restoreEntry(id);
message.success("灵感已恢复显示");
}}
onEdit={() => {
setEditing(item);
setEditorOpen(true);
}}
onDelete={() => handleDelete(item)}
/>
);
})}
</div>
<PaginationBar current={currentPage} pageSize={pageSize} total={visible.length} pageSizeOptions={[12, 24, 48]} alwaysShow onChange={handlePageChange} />
</>
) : (
<WorkspaceState
icon="empty"
title={view === "favorites" ? "还没有收藏" : view === "hidden" ? "没有隐藏的条目" : "没有符合条件的灵感"}
description={view === "favorites" ? "点卡片右上角的爱心,把喜欢的创意留在这里。" : "试试其他类型、分类或关键词。"}
action={hasFilters ? <Button onClick={resetFilters}>重置筛选</Button> : undefined}
/>
)}
</div>
<input
ref={importRef}
type="file"
@@ -302,175 +494,10 @@ export default function InspirationsPage() {
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}
@@ -486,7 +513,6 @@ export default function InspirationsPage() {
}}
onDelete={handleDelete}
/>
<InspirationEditorModal
open={editorOpen}
item={editing}
@@ -497,6 +523,91 @@ export default function InspirationsPage() {
}}
onSubmit={handleSubmit}
/>
</WorkspacePage>
</section>
);
}
function InspirationCard({
item,
favorite,
hidden,
showSources,
categoryLabel,
onOpen,
onToggleFavorite,
onRestore,
onEdit,
onDelete,
}: {
item: CreationInspiration;
favorite: boolean;
hidden: boolean;
showSources: boolean;
categoryLabel: string;
onOpen: () => void;
onToggleFavorite: () => void;
onRestore: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const favoriteLabel = favorite ? "取消收藏" : "收藏";
return (
<article className="inspiration-card">
<button type="button" className="inspiration-card-open" aria-label={`查看灵感:${item.title}`} aria-haspopup="dialog" onClick={onOpen}>
<span className="inspiration-card-cover">
<InspirationCover key={item.image} image={item.image} />
{showSources ? <span className="inspiration-card-source">{inspirationSourceOf(item)?.label ?? "未标注来源"}</span> : null}
</span>
<span className="inspiration-card-body">
<strong className="inspiration-card-title">{item.title}</strong>
<span className="inspiration-card-desc">{item.description}</span>
<span className="inspiration-card-tags">
<span className="inspiration-chip">{categoryLabel}</span>
{item.ratio ? <span className="inspiration-chip">{item.ratio}</span> : null}
{showSources && item.credit ? <span className="inspiration-chip">{item.credit}</span> : null}
</span>
</span>
</button>
<div className={`inspiration-card-actions${favorite || menuOpen ? " is-visible" : ""}`}>
<Tooltip title={favoriteLabel}>
<button type="button" data-icon-only className="inspiration-card-action" aria-label={`${favoriteLabel}:${item.title}`} aria-pressed={favorite} onClick={onToggleFavorite}>
<span aria-hidden="true">
<Heart className="size-3.5" fill={favorite ? "currentColor" : "none"} />
</span>
</button>
</Tooltip>
{hidden ? (
<Tooltip title="恢复显示">
<button type="button" data-icon-only className="inspiration-card-action" aria-label={`恢复显示:${item.title}`} onClick={onRestore}>
<span aria-hidden="true">
<RotateCcw className="size-3.5" />
</span>
</button>
</Tooltip>
) : (
<Dropdown
trigger={["click"]}
placement="bottomRight"
open={menuOpen}
onOpenChange={setMenuOpen}
menu={{
items: [
{ key: "edit", icon: <Pencil className="size-3.5" />, label: "编辑", onClick: onEdit },
{ key: "delete", icon: <Trash2 className="size-3.5" />, label: isCustomInspiration(catalogIdOf(item)) ? "删除" : "隐藏", danger: true, onClick: onDelete },
],
}}
>
<button type="button" data-icon-only className="inspiration-card-action" aria-label={`更多灵感操作:${item.title}`} aria-haspopup="menu" aria-expanded={menuOpen}>
<span aria-hidden="true">
<MoreHorizontal className="size-3.5" />
</span>
</button>
</Dropdown>
)}
</div>
</article>
);
}
export default InspirationHall;

View File

@@ -1,11 +1,14 @@
import { Button } from "antd";
import { Copy, ExternalLink, Heart, Pencil, Trash2, Wand2 } from "lucide-react";
import { useState } from "react";
import { Tooltip } from "@/components/ui/base/tooltip";
import { AppModal } from "@/components/ui/product/app-modal";
import { MediaPlaceholder } from "@/components/ui/product/media-placeholder";
import { catalogIdOf, inspirationSourceOf, type CreationInspiration } from "@/lib/inspirations/catalog";
import { isCustomInspiration } from "@/lib/inspirations/resolve";
import { modeLabels } from "@/pages/create/creation-types";
import { useUserStore } from "@/stores/use-user-store";
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -37,15 +40,28 @@ export function InspirationDetailModal({
onToggleFavorite: (item: CreationInspiration) => void;
onCopy: (text: string, label: string) => void;
}) {
const showSources = useUserStore((state) => state.features.inspirationSourcesVisible);
const [usingPrompt, setUsingPrompt] = useState(false);
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}>
<AppModal
open={Boolean(item)}
onCancel={onClose}
width={880}
title={null}
footer={null}
flush
focusTriggerAfterClose={!usingPrompt}
afterOpenChange={(visible) => {
if (visible) setUsingPrompt(false);
}}
>
{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 className="inspiration-detail-cover">
<InspirationCover key={item.image} image={item.image} />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5">
@@ -56,28 +72,36 @@ export function InspirationDetailModal({
</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 type="button" data-icon-only className="inspiration-icon-button" aria-label={favorite ? "取消收藏灵感" : "收藏灵感"} aria-pressed={favorite} onClick={() => onToggleFavorite(item)}>
<span aria-hidden="true">
<Heart className="size-4" fill={favorite ? "currentColor" : "none"} />
</span>
</button>
</Tooltip>
<Tooltip title="编辑">
<button type="button" className="inspiration-icon-button" onClick={() => onEdit(item)}>
<Pencil className="size-4" />
<button type="button" data-icon-only className="inspiration-icon-button" aria-label="编辑灵感" onClick={() => onEdit(item)}>
<span aria-hidden="true">
<Pencil className="size-4" />
</span>
</button>
</Tooltip>
<Tooltip title={custom ? "删除" : "从库中隐藏"}>
<button type="button" className="inspiration-icon-button" onClick={() => onDelete(item)}>
<Trash2 className="size-4" />
<button type="button" data-icon-only className="inspiration-icon-button" aria-label={custom ? "删除灵感" : "隐藏灵感"} onClick={() => onDelete(item)}>
<span aria-hidden="true">
<Trash2 className="size-4" />
</span>
</button>
</Tooltip>
</div>
</div>
<div className="flex flex-col gap-1.5">
<MetaRow label="来源">
{source?.name ?? "原创"}
{item.credit ? ` · ${item.credit}` : ""}
</MetaRow>
{showSources ? (
<MetaRow label="来源">
{source?.name ?? (item.sourceId && item.sourceId !== "original" ? (source?.label ?? "未标注来源") : "原创")}
{item.credit ? ` · ${item.credit}` : ""}
</MetaRow>
) : null}
<MetaRow label="分类">{categoryLabel(item)}</MetaRow>
<MetaRow label="类型">
{modeLabels[item.mode]}
@@ -94,7 +118,7 @@ export function InspirationDetailModal({
</span>
</MetaRow>
) : null}
{item.sourceUrl ? (
{showSources && 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" />
@@ -109,7 +133,15 @@ export function InspirationDetailModal({
<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
type="primary"
icon={<Wand2 className="size-4" />}
onClick={() => {
setUsingPrompt(true);
onClose();
onUse(item);
}}
>
用这个创意创作
</Button>
</div>
@@ -119,6 +151,11 @@ export function InspirationDetailModal({
);
}
export function InspirationCover({ image }: { image: string }) {
const [failed, setFailed] = useState(false);
return failed ? <MediaPlaceholder failed /> : <img src={image} alt="" loading="lazy" decoding="async" referrerPolicy="no-referrer" onError={() => setFailed(true)} />;
}
function PromptSection({ label, text, onCopy }: { label: string; text: string; onCopy: (text: string, label: string) => void }) {
return (
<section className="flex flex-col gap-2">

View File

@@ -1,47 +1,193 @@
/* 灵感库画廊。卡片是主体:封面占满、信息压在底部、悬停整体抬起来。
颜色、圆角、阴影一律走项目 token,不写字面值。 */
/* 创作首页中的灵感大厅:标题与常用筛选常驻,完整管理功能按需展开。 */
.inspiration-hall {
min-width: 0;
width: 100%;
margin-top: var(--space-8);
scroll-margin-block-start: var(--space-24);
color: var(--user-ink);
}
.inspiration-hall-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.inspiration-hall-header > div:first-child {
min-width: 0;
}
.inspiration-hall-header h2 {
margin: 0;
color: var(--user-ink);
font-size: var(--fs-title);
font-weight: 650;
letter-spacing: -0.025em;
line-height: 1.35;
}
.inspiration-hall-header p {
margin: var(--space-1) 0 0;
color: var(--user-ink-muted);
font-size: var(--fs-caption);
line-height: 1.6;
}
.inspiration-hall-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
}
.inspiration-hall-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
margin-bottom: var(--space-4);
}
.inspiration-hall-modes {
flex: 0 0 auto;
max-width: 100%;
}
.inspiration-mode-count {
color: var(--user-ink-muted);
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
}
.inspiration-hall-search.ant-input-affix-wrapper {
width: clamp(200px, 24%, 300px);
min-width: 0;
margin-left: auto;
background: var(--user-surface-muted);
border: 0;
border-radius: var(--user-radius-control);
color: var(--user-ink);
font-size: var(--fs-caption);
box-shadow: none;
}
.inspiration-hall-search .ant-input-prefix {
color: var(--user-ink-soft);
}
.inspiration-hall-search.ant-input-affix-wrapper:focus-within {
outline: var(--stroke-2) solid var(--control-focus-ring);
outline-offset: var(--space-half);
}
.inspiration-hall-filter-toggle > svg:last-child {
width: var(--space-3);
height: var(--space-3);
transition: transform var(--user-motion);
}
.inspiration-hall-filter-toggle > svg.is-open {
transform: rotate(180deg);
}
.inspiration-hall-count {
color: var(--user-ink-soft);
font-size: var(--fs-caption);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.inspiration-hall-filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
align-items: end;
gap: var(--space-3);
padding: var(--space-4);
margin-bottom: var(--space-5);
border-radius: var(--user-radius-card);
background: var(--user-surface-muted);
}
.inspiration-hall-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-2);
}
.inspiration-hall-field > span {
color: var(--user-ink-muted);
font-size: var(--fs-label);
font-weight: 600;
}
.inspiration-hall-field .ant-select {
width: 100%;
min-width: 0;
}
.inspiration-hall-reset {
justify-self: start;
}
.inspiration-hall-results {
min-width: 0;
}
.inspiration-hall-deferred {
min-height: calc(var(--space-24) * 2);
}
.inspiration-gallery {
display: grid;
gap: var(--space-4, 16px);
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
gap: var(--space-4);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 232px), 1fr));
align-items: stretch;
}
.inspiration-card {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
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;
border: 0;
border-radius: var(--user-radius-card);
background: var(--library-surface);
box-shadow: none;
transition:
transform var(--user-motion, 180ms) cubic-bezier(0.2, 0.8, 0.3, 1),
box-shadow var(--user-motion, 180ms) ease,
border-color var(--user-motion, 180ms) ease;
transform var(--user-motion),
background-color var(--user-motion);
}
.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-within {
background: var(--library-surface-hover);
transform: translateY(var(--library-hover-offset));
}
.inspiration-card:focus-visible {
outline: 2px solid var(--user-accent);
outline-offset: 2px;
.inspiration-card-open {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
border: 0;
border-radius: inherit;
padding: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
touch-action: manipulation;
}
.inspiration-card-open:focus-visible {
outline: var(--stroke-2) solid var(--control-focus-ring);
outline-offset: calc(-1 * var(--stroke-2));
}
.inspiration-card-cover {
position: relative;
display: block;
width: 100%;
flex-shrink: 0;
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--user-surface-muted);
background: var(--library-cover-surface);
}
.inspiration-card-cover img {
@@ -49,93 +195,97 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 420ms cubic-bezier(0.2, 0.8, 0.3, 1);
transition: transform var(--user-motion);
}
.inspiration-card:hover .inspiration-card-cover img {
transform: scale(1.045);
transform: scale(1.025);
}
/* 封面底部压一层暗角,让源标签始终读得清。 */
.inspiration-card-cover::after {
content: "";
.inspiration-card-source {
position: absolute;
inset: auto 0 0;
height: 46%;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.58));
pointer-events: none;
bottom: var(--space-2);
left: var(--space-2);
max-width: calc(100% - var(--space-4));
overflow: hidden;
padding: var(--space-1) var(--space-2);
border-radius: var(--r-full);
background: var(--user-surface-raised);
color: var(--user-ink-muted);
font-size: var(--fs-tiny);
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
.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, 0.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;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: var(--space-1, 4px);
gap: var(--space-1);
opacity: 0;
transition: opacity var(--user-motion, 180ms) ease;
pointer-events: none;
transition: opacity var(--user-motion);
}
.inspiration-card-actions.is-visible,
.inspiration-card:hover .inspiration-card-actions,
.inspiration-card:focus-within .inspiration-card-actions {
opacity: 1;
pointer-events: auto;
}
.inspiration-card-action {
display: grid;
place-items: center;
width: 28px;
height: 28px;
color: #fff;
background: rgba(20, 22, 26, 0.5);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: var(--r-full, 999px);
width: var(--space-8);
height: var(--space-8);
padding: 0;
border: 0;
border-radius: var(--r-full);
background: var(--user-surface-raised);
color: var(--user-ink);
cursor: pointer;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: background-color var(--user-motion, 180ms) ease;
touch-action: manipulation;
transition:
background-color var(--user-motion),
color var(--user-motion);
}
.inspiration-card-action:hover {
background: rgba(20, 22, 26, 0.72);
.inspiration-card-action > span {
display: inline-flex;
}
.inspiration-card-action:hover,
.inspiration-card-action[aria-expanded="true"] {
background: var(--user-surface-hover);
}
.inspiration-card-action[aria-pressed="true"] {
color: var(--control-selected-fg);
background: var(--control-selected-bg);
}
.inspiration-card-action.is-on {
color: var(--user-accent);
background: color-mix(in srgb, var(--user-surface) 88%, transparent);
.inspiration-card-action:focus-visible {
outline: var(--stroke-2) solid var(--control-focus-ring);
outline-offset: var(--space-half);
}
.inspiration-card-body {
display: flex;
width: 100%;
min-width: 0;
flex: 1;
flex-direction: column;
gap: var(--space-1, 4px);
padding: var(--space-3, 12px);
gap: var(--space-1);
padding: var(--space-3);
}
.inspiration-card-title {
overflow: hidden;
color: var(--user-ink);
font-size: var(--fs-body, 14px);
font-size: var(--fs-body);
font-weight: 600;
line-height: 1.35;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -143,8 +293,8 @@
.inspiration-card-desc {
display: -webkit-box;
overflow: hidden;
color: var(--user-ink-soft);
font-size: var(--fs-caption, 12px);
color: var(--user-ink-muted);
font-size: var(--fs-caption);
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -152,133 +302,213 @@
.inspiration-card-tags {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--space-1, 4px);
margin-top: var(--space-1, 4px);
gap: var(--space-1);
margin-top: var(--space-2);
}
/* 分类标签。未选中态必须有看得出边界的底色:
页面本身是纯白,只给 #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);
gap: var(--space-1);
max-width: 100%;
overflow: hidden;
padding: var(--space-1) var(--space-3);
border: 0;
border-radius: var(--r-full);
color: var(--user-ink-muted);
background: var(--user-surface-muted);
font-size: var(--fs-caption);
line-height: 1.7;
text-overflow: ellipsis;
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;
color: var(--control-selected-fg);
background: var(--control-selected-bg);
}
.inspiration-chip:focus-visible {
outline: 2px solid var(--user-accent, #5f55d9);
outline-offset: 2px;
outline: var(--stroke-2) solid var(--control-focus-ring);
outline-offset: var(--space-half);
}
/* 标签里的计数:比正文弱一档,但仍要在两种底色上都读得出来。 */
.inspiration-chip-count {
font-variant-numeric: tabular-nums;
opacity: 0.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;
display: block;
padding: var(--space-half) var(--space-2);
color: var(--user-ink-muted);
background: color-mix(in srgb, var(--user-ink) 6%, transparent);
font-size: var(--fs-tiny);
}
.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))` 的全局浮起规则,这里的悬停是自定义的。 */
/* 详情图标按钮使用 span 与 data-icon-only,避开共享按钮的装饰 hover。 */
.inspiration-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
width: var(--space-8);
height: var(--space-8);
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);
border: 0;
border-radius: var(--r-full);
color: var(--user-ink-muted);
background: var(--user-surface-muted);
cursor: pointer;
touch-action: manipulation;
transition:
color var(--user-motion, 180ms) ease,
background-color var(--user-motion, 180ms) ease,
border-color var(--user-motion, 180ms) ease;
color var(--user-motion),
background-color var(--user-motion);
}
.inspiration-icon-button > span {
display: inline-flex;
}
.inspiration-icon-button:hover {
color: var(--user-ink);
background: var(--user-surface-muted);
border-color: color-mix(in srgb, var(--user-ink) 20%, transparent);
background: var(--user-surface-hover);
}
.inspiration-icon-button[aria-pressed="true"] {
color: var(--user-accent);
border-color: color-mix(in srgb, var(--user-accent) 42%, transparent);
color: var(--control-selected-fg);
background: var(--control-selected-bg);
}
.inspiration-icon-button:focus-visible {
outline: 2px solid var(--user-accent);
outline-offset: 2px;
outline: var(--stroke-2) solid var(--control-focus-ring);
outline-offset: var(--space-half);
}
/* 提示词正文:等宽、可滚动、保留换行,复制按钮钉在右上角。 */
.inspiration-detail-cover {
position: relative;
flex-shrink: 0;
height: 256px;
overflow: hidden;
border-radius: var(--r-xl);
background: var(--library-cover-surface);
}
.inspiration-detail-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* 提示词正文保留原始换行,长内容只在区域内部滚动。 */
.inspiration-prompt-block {
position: relative;
max-height: 320px;
padding: var(--space-3, 12px);
padding: var(--space-3);
overflow: auto;
border: 1px solid color-mix(in srgb, var(--user-ink) 10%, transparent);
border-radius: var(--r-lg, 12px);
border: 0;
border-radius: var(--r-lg);
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);
font-family: var(--font-mono);
font-size: var(--fs-caption);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 720px) {
.inspiration-hall-header {
align-items: flex-start;
gap: var(--space-3);
}
.inspiration-hall-header h2 {
font-size: var(--fs-heading-lg);
}
.inspiration-hall-search.ant-input-affix-wrapper {
order: 3;
width: 100%;
margin-left: 0;
}
.inspiration-hall-count {
margin-left: auto;
}
.inspiration-hall-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inspiration-gallery {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.inspiration-card-body {
padding: var(--space-2-half);
}
.inspiration-card-source {
font-size: var(--fs-tiny);
}
.inspiration-card-tags {
gap: var(--space-half);
}
.inspiration-mode-count {
display: none;
}
.inspiration-detail-cover {
height: 224px;
}
}
@media (max-width: 460px) {
.inspiration-hall-header {
flex-wrap: wrap;
}
.inspiration-hall-toolbar {
gap: var(--space-2);
}
.inspiration-hall-modes {
width: 100%;
}
.inspiration-hall-modes > button {
flex: 1;
}
.inspiration-card-source {
left: var(--space-1);
bottom: var(--space-1);
max-width: calc(100% - var(--space-2));
padding-inline: var(--space-1-half);
}
.inspiration-card-title {
font-size: var(--fs-caption);
}
.inspiration-card-tags {
display: none;
}
}
@media (max-width: 340px) {
.inspiration-gallery,
.inspiration-hall-filters {
grid-template-columns: minmax(0, 1fr);
}
}
@media (hover: none), (pointer: coarse) {
.inspiration-card-actions {
opacity: 1;
pointer-events: auto;
}
.inspiration-card-action,
.inspiration-icon-button {
width: var(--space-10);
height: var(--space-10);
}
}
@media (prefers-reduced-motion: reduce) {
.inspiration-card,
.inspiration-card-cover img {
.inspiration-card-cover img,
.inspiration-card-actions,
.inspiration-card-action,
.inspiration-icon-button,
.inspiration-hall-filter-toggle > svg:last-child {
transition: none;
}
.inspiration-card:hover,
.inspiration-card:focus-visible {
transform: none;
}
.inspiration-card:focus-within,
.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, loadInspirationsPage, loadProjectDetailPage, loadProjectsPage } from "@/lib/workspace-route-modules";
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, 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";
@@ -49,7 +49,6 @@ const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
const CreatePage = lazy(loadCreatePage);
const ImageWorkbenchPage = lazy(() => import("@/pages/workbench/image-workbench-route"));
const VideoWorkbenchPage = lazy(() => import("@/pages/workbench/video-workbench-page"));
const InspirationsPage = lazy(loadInspirationsPage);
const NotFound = lazy(() => import("@/pages/not-found"));
const SkillsPage = lazy(() => import("@/pages/skills"));
const PluginsPage = lazy(() => import("@/pages/plugins"));
@@ -136,7 +135,6 @@ export const router = createBrowserRouter([
</RequireAuth>
),
},
{ path: "/inspirations", element: deferred(<InspirationsPage />) },
{
path: "/tasks",
element: (

View File

@@ -1,8 +1,7 @@
/**
* 全量灵感目录的运行时加载。
*
* 3MB 量级的目录不能打进主 JS,全量留给灵感页按需拉取;
* 首页纵深画廊的池子同理,连提示词正文有几百 KB,走 gallery-pool.json,
* 3MB 量级的目录不打进主 JS,首页灵感大厅进入可视区域附近后按需拉取;
* 浏览器 HTTP 缓存之外再做一层内存缓存,同一次会话只请求一遍。
*/
import { curatedInspirations, type CreationInspiration, type InspirationSourceId } from "@/lib/inspirations/catalog";

View File

@@ -18419,16 +18419,16 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
border-radius: 22px !important;
}
.app-user-workspace .creation-launchpad .creation-chat-composer.is-empty .creation-chat-writing-surface {
min-height: 152px !important;
padding: 22px 24px 10px !important;
min-height: 96px !important;
padding: var(--space-4) var(--space-5) var(--space-2) !important;
}
.app-user-workspace .creation-launchpad .creation-chat-composer.is-empty .creation-chat-mention-editor {
min-height: 64px !important;
min-height: 48px !important;
font-size: 16px !important;
}
.app-user-workspace .creation-launchpad .creation-chat-dock {
min-height: 64px !important;
padding: 8px 16px 16px !important;
min-height: 52px !important;
padding: var(--space-2) var(--space-4) var(--space-3) !important;
border-top: 0 !important;
}
.app-user-workspace .creation-launchpad .creation-chat-controls .creation-chat-control {
@@ -18464,9 +18464,9 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
.app-user-workspace .creation-empty-suggest .suggest-card {
position: relative;
isolation: isolate;
min-height: 80px !important;
min-height: 64px !important;
margin: 0 !important;
padding: 12px !important;
padding: var(--space-2) var(--space-3) !important;
border: 1px solid transparent !important;
border-radius: var(--user-radius-card) !important;
background: transparent !important;
@@ -18627,7 +18627,7 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
.app-user-workspace .creation-chat-mention-editor { min-height: 120px !important; font-size: 17px !important; }
.app-user-workspace .creation-empty-suggest { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.app-user-workspace .creation-empty-suggest .creation-hero-prompt,
.app-user-workspace .creation-empty-suggest .suggest-card { min-height: 80px !important; }
.app-user-workspace .creation-empty-suggest .suggest-card { min-height: 64px !important; }
.app-user-workspace .creation-featured-works { margin-top: 48px !important; }
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -655,8 +655,8 @@
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs .creation-mode-button { min-width: 0; min-height: 0; flex-direction: column; gap: 3px; padding: 7px 3px; font-size: 11px; }
.app-user-workspace .creation-composer-stage.is-thread-mode > .creation-mode-tabs .creation-mode-indicator { inset: 0; width: auto; height: auto; transform: none; }
.app-user-workspace .creation-composer-stage.is-home-mode { display: block; }
.app-user-workspace .creation-composer-stage.is-home-mode > .creation-mode-tabs { display: grid; width: min(456px, 100%); grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: 1fr; margin: 0 auto 20px; padding: 4px; }
.app-user-workspace .creation-composer-stage.is-home-mode > .creation-mode-tabs .creation-mode-button { min-height: 46px; flex-direction: row; gap: 6px; padding: 0 8px; font-size: 13px; }
.app-user-workspace .creation-composer-stage.is-home-mode > .creation-mode-tabs { display: grid; width: min(456px, 100%); grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: 1fr; margin: 0 auto var(--space-3); padding: var(--space-1); }
.app-user-workspace .creation-composer-stage.is-home-mode > .creation-mode-tabs .creation-mode-button { min-height: 38px; height: 38px; flex-direction: row; gap: var(--space-1-half); padding: 0 var(--space-2); font-size: var(--fs-caption); }
.app-user-workspace .creation-composer-stage .creation-empty-composer { min-width: 0; flex: 1 1 auto; }
.app-user-workspace .creation-composer-stage.is-agent-mode .creation-agent-entry { min-width: 0; flex: 1 1 auto; }