feat(create): 用户首页 - 整合最近画布与灵感大厅
压缩创作首屏,增加最近画布与创建入口,合并灵感大厅并完善响应式与可访问性。修复四个文件的 CI 格式问题,完成 Web checks 对应验证,更新发布版本为 v1.6.8.2。
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v1.6.8.2
|
||||
|
||||
- 压缩创作首页首屏,一行展示创建画布与最近更新的 4 个画布,优化窄屏浏览与加载状态。
|
||||
- 将灵感大厅合并到创作首页,移除独立灵感页面与导航,保留搜索、筛选、收藏、编辑和导入导出。
|
||||
- 优化灵感卡片、封面失败占位、主题适配与键盘/触摸交互;使用灵感后回填提示词并返回输入区。
|
||||
- 修复图片参数空切片兼容、局部重绘默认模型同步、远端参考素材选择与跨域图片工具导出问题。
|
||||
- 升级注意:本版本无数据库结构变更。
|
||||
|
||||
## v1.6.8.1
|
||||
|
||||
- 新增千问 AI(QianWenAI MAAS)图像协议插件,补齐协议清单、接口文档和回归测试。
|
||||
|
||||
@@ -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`:外部来源同步、缩略图生成与目录落盘。
|
||||
|
||||
@@ -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 导入导出正常,来源开关显示一致。
|
||||
|
||||
## 画布素材缺失时的本地强制覆盖
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { openWorkspaceWallet } from "@/lib/workspace-wallet";
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
home: "创作",
|
||||
create: "创作",
|
||||
inspirations: "灵感",
|
||||
projects: "短剧制作",
|
||||
canvas: "自由画布",
|
||||
tasks: "创作历史",
|
||||
|
||||
@@ -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: "短剧创作",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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); }
|
||||
|
||||
135
web/src/pages/create/creation-recent-canvases.tsx
Normal file
135
web/src/pages/create/creation-recent-canvases.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 全量灵感目录的运行时加载。
|
||||
*
|
||||
* 3MB 量级的目录不能打进主 JS,全量留给灵感页按需拉取;
|
||||
* 首页纵深画廊的池子同理,连提示词正文有几百 KB,走 gallery-pool.json,
|
||||
* 3MB 量级的目录不打进主 JS,首页灵感大厅进入可视区域附近后按需拉取;
|
||||
* 浏览器 HTTP 缓存之外再做一层内存缓存,同一次会话只请求一遍。
|
||||
*/
|
||||
import { curatedInspirations, type CreationInspiration, type InspirationSourceId } from "@/lib/inspirations/catalog";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user