diff --git a/design-research-folder-card.md b/design-research-folder-card.md
new file mode 100644
index 000000000..83509d30f
--- /dev/null
+++ b/design-research-folder-card.md
@@ -0,0 +1,655 @@
+# 画布列表卡片设计调研报告
+
+## 一、图片风格分析
+
+### 核心设计元素识别
+
+通过对提供的文件夹卡片图片分析,识别出以下关键设计特征:
+
+#### 1. **玻璃拟态(Glassmorphism)效果**
+- 半透明背景:使用 `backdrop-filter: blur()` 实现背景模糊
+- 磨砂玻璃质感:白色半透明叠加层(rgba(255,255,255,0.1) ~ 0.15)
+- 边框高光:顶部和左侧有微妙的白色边框(1px solid rgba(255,255,255,0.18))
+
+#### 2. **渐变网格背景(Gradient Mesh)**
+- 每个卡片顶部有独特的渐变色带(红橙、蓝青、橙红等色系)
+- 渐变呈现流动的、非线性的网格状分布
+- 类似 macOS Ventura 的壁纸风格,具有深度和立体感
+- 渐变从顶部向下过渡到白色/透明
+
+#### 3. **多层阴影系统**
+- **主阴影**:较大的扩散阴影 `0 8px 32px rgba(0,0,0,0.12)`
+- **环境阴影**:模拟物理深度 `0 2px 8px rgba(0,0,0,0.06)`
+- **内阴影**:卡片内部微妙的阴影增强立体感
+- 悬停时阴影加深并向上提升:`transform: translateY(-4px)`
+
+#### 4. **圆角处理**
+- 大圆角设计:约 `16px - 20px` (r-2xl ~ r-3xl)
+- 所有元素保持一致的圆角系统
+- 内部按钮使用更小的圆角(8-12px)
+
+#### 5. **布局结构**
+- **三态展示**:
+ 1. 正常态(左上):紧凑文件夹样式 + 悬浮按钮
+ 2. 堆叠态(右侧):多层卡片堆叠效果,展示深度
+ 3. 展开态(左下):完整信息展示 + 时间戳
+
+#### 6. **交互反馈**
+- 悬停提升效果(Y轴 -4px)
+- 鼠标跟随的光泽效果(Glare)
+- 3D 倾斜动画(轻微的 rotateX/rotateY)
+- 按钮悬浮显示/隐藏
+
+---
+
+## 二、当前项目状态分析
+
+### 现有组件架构
+
+#### 1. **CanvasProjectCard** (`canvas-project-card.tsx`)
+```typescript
+// 当前结构
+
+
+
+
复选框
+
节点数量
+
+
+
+```
+
+**当前特点**:
+- 扁平化设计,缺少层次感
+- 简单的边框和阴影
+- 预览区域占比大(aspect-video)
+- 无玻璃拟态效果
+- 无渐变背景装饰
+
+#### 2. **CometCard** (`comet-card.tsx`)
+**已有能力**:
+- ✅ 3D 倾斜动画(rotateX/rotateY)
+- ✅ 鼠标跟随效果
+- ✅ 光泽层(glare)
+- ✅ 悬停提升动画
+- ✅ 平滑的弹簧动画
+
+**缺少**:
+- ❌ 玻璃拟态背景
+- ❌ 渐变网格装饰
+- ❌ 多层阴影系统
+
+---
+
+## 三、Aceternity UI 调研
+
+### 相关组件清单
+
+#### 1. **3D Card Effect**
+- 组件路径:`/components/3d-card-effect`
+- 功能:鼠标跟随的3D倾斜效果
+- 与 CometCard 功能重叠,我们已有类似实现
+
+#### 2. **Card Hover Effect**
+- 组件路径:`/components/card-hover-effect`
+- 功能:悬停时的边框光晕和背景渐变
+- 可借鉴:边框渐变的实现方式
+
+#### 3. **Background Gradient**
+- 组件路径:`/components/background-gradient`
+- 功能:动态渐变背景
+- **重要**:可用于卡片顶部的渐变网格效果
+
+#### 4. **Meteors**
+- 组件路径:`/components/meteors`
+- 功能:流星划过的动画效果
+- 可选:为卡片增加动态装饰
+
+#### 5. **Glass Morphism**
+- 虽然 Aceternity 没有专门的玻璃拟态组件
+- 但在多个卡片组件中使用了 `backdrop-blur` 和半透明背景
+- 需要自行实现
+
+---
+
+## 四、设计方案
+
+### 方案 A:渐进式升级(推荐)
+
+**目标**:在现有 CanvasProjectCard 基础上,逐步添加玻璃拟态和渐变效果。
+
+#### 4.1 布局设计
+
+**网格布局**:
+```css
+/* 响应式网格 */
+.canvas-library-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: var(--space-6); /* 24px */
+ padding: var(--space-6);
+}
+
+/* 断点优化 */
+@media (min-width: 640px) {
+ /* 2列:640px - 1024px */
+ grid-template-columns: repeat(2, 1fr);
+}
+
+@media (min-width: 1024px) {
+ /* 3列:1024px - 1440px */
+ grid-template-columns: repeat(3, 1fr);
+}
+
+@media (min-width: 1440px) {
+ /* 4列:>1440px */
+ grid-template-columns: repeat(4, 1fr);
+}
+```
+
+**每行显示数量**:
+- 移动端(<640px):1列(自适应宽度)
+- 平板(640-1024px):2列
+- 桌面(1024-1440px):3列
+- 大屏(>1440px):4列
+
+#### 4.2 卡片尺寸
+
+```typescript
+// 卡片结构
+
+ // 宽高比:16:10 (比现有的16:9更接近文件夹比例)
+ // 最小宽度:280px
+ // 最大宽度:360px(防止在大屏上过度拉伸)
+
+```
+
+**尺寸规范**:
+- **宽度**:280-360px(由网格自动控制)
+- **高度**:自适应(由内容决定)
+- **预览区高度**:aspect-[16/10] (~175-225px)
+- **内容区高度**:~80px(标题 + 元信息)
+
+#### 4.3 视觉效果分层
+
+```
+[层级从底到顶]
+├─ 1. 基础背景(白色/暗色,根据主题)
+├─ 2. 渐变网格层(顶部装饰)
+├─ 3. 玻璃拟态层(backdrop-blur + 半透明)
+├─ 4. 内容层(文字、图标、预览)
+├─ 5. 边框高光层(顶部和左侧)
+└─ 6. 光泽层(hover时的glare效果)
+```
+
+---
+
+## 五、CSS 实现方案
+
+### 5.1 玻璃拟态基础样式
+
+```css
+.canvas-card-glass {
+ position: relative;
+ background: rgba(255, 255, 255, 0.08);
+ backdrop-filter: blur(12px) saturate(180%);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: var(--r-2xl); /* 16px */
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.12),
+ 0 2px 8px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
+ box-shadow 0.3s ease;
+}
+
+/* 暗色主题 */
+.dark .canvas-card-glass {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.4),
+ 0 2px 8px rgba(0, 0, 0, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.08);
+}
+
+.canvas-card-glass:hover {
+ transform: translateY(-4px);
+ box-shadow:
+ 0 16px 48px rgba(0, 0, 0, 0.18),
+ 0 4px 12px rgba(0, 0, 0, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.15);
+}
+```
+
+### 5.2 渐变网格装饰
+
+```css
+.canvas-card-gradient-mesh {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 120px;
+ border-radius: var(--r-2xl) var(--r-2xl) 0 0;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.canvas-card-gradient-mesh::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at 20% 30%, rgba(255, 100, 80, 0.6) 0%, transparent 50%),
+ radial-gradient(circle at 80% 20%, rgba(100, 180, 255, 0.5) 0%, transparent 50%),
+ radial-gradient(circle at 50% 70%, rgba(255, 180, 100, 0.4) 0%, transparent 60%);
+ filter: blur(40px);
+ opacity: 0.8;
+}
+
+.canvas-card-gradient-mesh::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg,
+ rgba(255,255,255,0) 0%,
+ rgba(255,255,255,0.95) 80%
+ );
+}
+
+/* 每张卡片随机不同的渐变色 */
+.canvas-card-gradient-mesh[data-variant="1"]::before {
+ background:
+ radial-gradient(circle at 25% 25%, rgba(255, 50, 100, 0.7) 0%, transparent 50%),
+ radial-gradient(circle at 75% 30%, rgba(80, 120, 255, 0.6) 0%, transparent 50%);
+}
+
+.canvas-card-gradient-mesh[data-variant="2"]::before {
+ background:
+ radial-gradient(circle at 30% 20%, rgba(0, 200, 180, 0.6) 0%, transparent 50%),
+ radial-gradient(circle at 70% 40%, rgba(100, 255, 150, 0.5) 0%, transparent 50%);
+}
+
+.canvas-card-gradient-mesh[data-variant="3"]::before {
+ background:
+ radial-gradient(circle at 20% 35%, rgba(255, 150, 0, 0.7) 0%, transparent 50%),
+ radial-gradient(circle at 80% 25%, rgba(200, 50, 80, 0.6) 0%, transparent 50%);
+}
+```
+
+### 5.3 完整卡片样式
+
+```css
+.canvas-card-folder {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 280px;
+ cursor: pointer;
+ isolation: isolate;
+}
+
+.canvas-card-folder-inner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ background: rgba(255, 255, 255, 0.08);
+ backdrop-filter: blur(12px) saturate(180%);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: var(--r-2xl);
+ overflow: hidden;
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.12),
+ 0 2px 8px rgba(0, 0, 0, 0.06);
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+.canvas-card-folder:hover .canvas-card-folder-inner {
+ transform: translateY(-4px) scale(1.01);
+ box-shadow:
+ 0 16px 48px rgba(0, 0, 0, 0.18),
+ 0 4px 12px rgba(0, 0, 0, 0.1);
+ border-color: rgba(255, 255, 255, 0.25);
+}
+
+/* 预览区域 */
+.canvas-card-preview {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ overflow: hidden;
+ z-index: 1;
+}
+
+.canvas-card-preview img,
+.canvas-card-preview video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.3s ease;
+}
+
+.canvas-card-folder:hover .canvas-card-preview img,
+.canvas-card-folder:hover .canvas-card-preview video {
+ transform: scale(1.05);
+}
+
+/* 内容区域 */
+.canvas-card-content {
+ position: relative;
+ padding: var(--space-4);
+ z-index: 1;
+}
+
+.canvas-card-title {
+ font-size: var(--fs-body);
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--color-neutral-900);
+ margin-bottom: var(--space-2);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.dark .canvas-card-title {
+ color: var(--color-neutral-50);
+}
+
+.canvas-card-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--fs-caption);
+ color: var(--color-neutral-600);
+}
+
+.dark .canvas-card-meta {
+ color: var(--color-neutral-400);
+}
+
+/* 悬浮按钮 */
+.canvas-card-action-button {
+ position: absolute;
+ bottom: var(--space-3);
+ right: var(--space-3);
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(8px);
+ border-radius: var(--r-lg);
+ color: white;
+ opacity: 0;
+ transform: translateY(8px);
+ transition: all 0.25s ease;
+ z-index: 10;
+}
+
+.canvas-card-folder:hover .canvas-card-action-button {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.canvas-card-action-button:hover {
+ background: rgba(0, 0, 0, 0.8);
+ transform: scale(1.1);
+}
+```
+
+---
+
+## 六、组件实现方案
+
+### 6.1 新建组件:CanvasFolderCard
+
+```tsx
+// web/src/components/canvas/canvas-folder-card.tsx
+
+import { motion } from "motion/react";
+import { Plus, MoreHorizontal } from "lucide-react";
+import { CometCard } from "@/components/ui/aceternity/comet-card";
+import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
+
+interface CanvasFolderCardProps {
+ project: CanvasProject;
+ variant?: number; // 1-3,决定渐变色方案
+ onClick?: () => void;
+ onAction?: () => void;
+}
+
+export function CanvasFolderCard({
+ project,
+ variant = 1,
+ onClick,
+ onAction
+}: CanvasFolderCardProps) {
+ return (
+
+
+ {/* 渐变网格装饰 */}
+
+
+ {/* 预览区域 */}
+
+
+ {/* 内容区域 */}
+
+
{project.title}
+
+ {project.nodes.length} 节点
+ ·
+
+
+
+
+ {/* 悬浮按钮 */}
+
{
+ e.stopPropagation();
+ onAction?.();
+ }}
+ whileHover={{ scale: 1.1 }}
+ whileTap={{ scale: 0.95 }}
+ aria-label="更多操作"
+ >
+
+
+
+
+ );
+}
+
+// 预览组件保持原有逻辑
+function ProjectPreview({ project }: { project: CanvasProject }) {
+ // ... 复用现有的 ProjectPreview 逻辑
+}
+
+function formatTime(value: string) {
+ // ... 复用现有的时间格式化逻辑
+}
+```
+
+### 6.2 集成到现有页面
+
+```tsx
+// 在 pages/home/index.tsx 或 pages/canvas/index.tsx 中
+
+import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
+
+// 替换现有的 CanvasProjectCard
+
+ {canvasProjects.map((project, index) => (
+ navigate(`/canvas/${project.id}`)}
+ onAction={() => handleMore(project)}
+ />
+ ))}
+
+```
+
+---
+
+## 七、技术要点
+
+### 7.1 性能优化
+
+```typescript
+// 1. 渐变色方案预计算
+const GRADIENT_VARIANTS = [
+ { id: 1, colors: ['rgba(255,50,100,0.7)', 'rgba(80,120,255,0.6)'] },
+ { id: 2, colors: ['rgba(0,200,180,0.6)', 'rgba(100,255,150,0.5)'] },
+ { id: 3, colors: ['rgba(255,150,0,0.7)', 'rgba(200,50,80,0.6)'] },
+];
+
+// 2. 使用 CSS 变量动态注入
+
+
+// 3. backdrop-filter 回退方案
+@supports not (backdrop-filter: blur(12px)) {
+ .canvas-card-folder-inner {
+ background: rgba(255, 255, 255, 0.95); /* 完全不透明 */
+ }
+}
+```
+
+### 7.2 无障碍支持
+
+```tsx
+// 完整的 ARIA 标签
+ e.key === 'Enter' && onClick?.()}
+>
+ {/* ... */}
+
+```
+
+### 7.3 暗色主题适配
+
+```css
+/* 自动适配当前主题 */
+@media (prefers-color-scheme: dark) {
+ .canvas-card-folder-inner {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.12);
+ }
+
+ .canvas-card-gradient-mesh::after {
+ background: linear-gradient(180deg,
+ rgba(10, 10, 15, 0) 0%,
+ rgba(10, 10, 15, 0.95) 80%
+ );
+ }
+}
+```
+
+---
+
+## 八、实施建议
+
+### 阶段 1:基础重构(1-2天)
+1. ✅ 创建 `CanvasFolderCard` 组件
+2. ✅ 实现玻璃拟态样式
+3. ✅ 集成 `CometCard` 实现3D效果
+4. ✅ 调整网格布局为新的响应式方案
+
+### 阶段 2:视觉增强(2-3天)
+1. ✅ 添加渐变网格装饰层
+2. ✅ 实现多层阴影系统
+3. ✅ 优化悬停动画和过渡
+4. ✅ 添加光泽层(glare effect)
+
+### 阶段 3:细节打磨(1-2天)
+1. ✅ 暗色主题适配
+2. ✅ 无障碍测试和优化
+3. ✅ 性能测试(backdrop-filter 的性能影响)
+4. ✅ 跨浏览器兼容性测试
+
+### 阶段 4:用户测试(1天)
+1. ✅ A/B 测试(新旧卡片对比)
+2. ✅ 收集用户反馈
+3. ✅ 根据反馈微调
+
+---
+
+## 九、风险和注意事项
+
+### 9.1 性能风险
+- **backdrop-filter 性能消耗**:在大量卡片(50+)时可能影响滚动性能
+ - 解决方案:使用虚拟滚动或懒加载
+ - 回退方案:低性能设备禁用 backdrop-filter
+
+### 9.2 兼容性
+- **Safari < 15**:backdrop-filter 支持不完整
+ - 解决方案:检测支持度,提供回退样式
+
+### 9.3 可访问性
+- 确保玻璃拟态效果不影响文字对比度(WCAG AA 标准:4.5:1)
+- 光泽效果不应干扰屏幕阅读器
+
+---
+
+## 十、参考资源
+
+### 设计灵感
+- macOS Ventura 文件夹设计
+- iOS 16+ 小组件玻璃拟态
+- Figma Community: Glassmorphism UI Kit
+
+### 技术文档
+- [MDN: backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter)
+- [CSS Tricks: Glassmorphism](https://css-tricks.com/glassmorphism/)
+- [Aceternity UI Components](https://ui.aceternity.com/components)
+
+### 现有代码
+- `/web/src/components/canvas/canvas-project-card.tsx`
+- `/web/src/components/ui/aceternity/comet-card.tsx`
+- `/web/src/styles/globals.css` (token system)
+
+---
+
+## 总结
+
+这套设计方案结合了:
+1. **玻璃拟态**的现代感和层次感
+2. **渐变网格**的视觉吸引力和品牌差异化
+3. **3D交互**的沉浸感(已有CometCard支持)
+4. **响应式布局**的灵活性(1-4列自适应)
+
+建议采用**渐进式升级**策略,先实现核心玻璃拟态效果,再逐步添加渐变装饰和高级交互,确保每个阶段都能交付可用版本。
diff --git a/web/src/components/canvas/canvas-folder-card.tsx b/web/src/components/canvas/canvas-folder-card.tsx
new file mode 100644
index 000000000..e8d9b55a0
--- /dev/null
+++ b/web/src/components/canvas/canvas-folder-card.tsx
@@ -0,0 +1,129 @@
+import { Dropdown, Input } from "antd";
+import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
+import type { KeyboardEvent } from "react";
+
+import { ProjectPreview, formatProjectTime } from "@/components/canvas/canvas-project-card";
+import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
+import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
+import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
+import { cn } from "@/lib/utils";
+
+type CanvasFolderCardProps = {
+ project: CanvasProject;
+ projectName?: string;
+ onClick: () => void;
+};
+
+/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */
+export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolderCardProps) {
+ const renameProject = useCanvasStore((state) => state.renameProject);
+ const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
+ const editingId = useCanvasUiStore((state) => state.editingProjectId);
+ const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
+ const startEditing = useCanvasUiStore((state) => state.startEditingProject);
+ const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
+ const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
+ const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
+ const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
+ const editing = editingId === project.id;
+ const selected = selectedIds.includes(project.id);
+
+ const saveTitle = () => {
+ renameProject(project.id, editingTitle);
+ stopEditing();
+ };
+
+ const handleOpenKeyDown = (event: KeyboardEvent) => {
+ if (event.target !== event.currentTarget) return;
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ if (!editing) onClick();
+ }
+ };
+
+ return (
+
+ !editing && onClick()} onKeyDown={handleOpenKeyDown}>
+
+
+
+ {editing ? (
+ setEditingTitle(event.target.value)}
+ onClick={(event) => event.stopPropagation()}
+ onBlur={saveTitle}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") saveTitle();
+ if (event.key === "Escape") stopEditing();
+ }}
+ autoFocus
+ />
+ ) : (
+ {project.title}
+ )}
+
+
+ {projectName || "自由画布"}
+
+ ·
+
+ {project.nodes.length} 节点
+
+ ·
+
+
+
+
+
+
+ event.stopPropagation()}>
+ toggleSelected(project.id, event.target.checked)}
+ aria-label={`选择 ${project.title}`}
+ />
+
+
+ event.stopPropagation()}>
+ {!editing ? (
+
+ ) : null}
+
+
domEvent.stopPropagation(),
+ items: [
+ { key: "export", icon: , label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") },
+ { type: "divider" },
+ { key: "delete", danger: true, icon: , label: "删除", onClick: () => setDeleteIds([project.id]) },
+ ],
+ }}
+ >
+
+
+
+
+ );
+}
diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx
index 18066aba0..87a70d28a 100644
--- a/web/src/components/canvas/canvas-project-card.tsx
+++ b/web/src/components/canvas/canvas-project-card.tsx
@@ -105,14 +105,17 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
);
}
-function ProjectPreview({ project }: { project: CanvasProject }) {
+export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
const mediaNodes = project.nodes
.flatMap((node) => {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
const url = getNodeMediaUrl(node);
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
});
- const media = mediaNodes.find(({ node }) => node.type === CanvasNodeType.Image) || mediaNodes[0];
+ const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
+ const media = preferLatestImage
+ ? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
+ : imageNodes[0] || mediaNodes[0];
if (media) {
const { node, url, storageKey } = media;
return (
@@ -145,7 +148,7 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
function getNodeMediaUrl(node: CanvasNodeData) {
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
if (resourceId) return resourceFileUrl(resourceId);
- return resolveBackendApiUrl(node.metadata?.content || "");
+ return resolveBackendApiUrl(node.metadata?.previewContent || node.metadata?.content || "");
}
function buildNodePreviewLayout(nodes: CanvasNodeData[]) {
@@ -191,7 +194,7 @@ function isPreviewUrl(value?: string) {
return Boolean(value && (/^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value)));
}
-function formatProjectTime(value: string) {
+export function formatProjectTime(value: string) {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) return "刚刚修改";
const elapsed = Math.max(0, Date.now() - timestamp);
diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx
index 158f434e2..edb0e3a7a 100644
--- a/web/src/components/layout/app-top-nav.tsx
+++ b/web/src/components/layout/app-top-nav.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
@@ -84,9 +84,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
mobileSidebarExpanded && "is-mobile-expanded",
desktopSidebarCollapsed && "is-collapsed",
)}
- style={desktopSidebarCollapsed ? ({ "--workspace-sidebar-width": "0px" } as CSSProperties) : undefined}
>
- setPaletteOpen(true)} />
+ setPaletteOpen(true)}
+ onExpand={() => setDesktopSidebarCollapsed(false)}
+ />
) : null}
diff --git a/web/src/components/layout/workspace-account-menu.tsx b/web/src/components/layout/workspace-account-menu.tsx
index 8e6863f7d..19fc30b1d 100644
--- a/web/src/components/layout/workspace-account-menu.tsx
+++ b/web/src/components/layout/workspace-account-menu.tsx
@@ -1,17 +1,16 @@
import { Popover, Switch } from "antd";
-import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
-import { useEffect, useState, type ReactNode } from "react";
+import { CircleUserRound, LogIn, Moon, Sun } from "lucide-react";
+import { useEffect, useState } from "react";
import { Link } from "react-router";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
-import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
-/** 顶部栏头像账户菜单:账户信息、管理入口、版本、深色模式与退出登录。 */
+/** 顶部栏头像账户菜单:只承载账户信息、版本和显示偏好;管理与退出入口统一放在侧栏。 */
export function WorkspaceAccountMenu() {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
@@ -19,7 +18,6 @@ export function WorkspaceAccountMenu() {
const hydrated = useUserStore((state) => state.hydrated);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
- const { handleLogout } = useWorkspaceLogout();
const [menuOpen, setMenuOpen] = useState(false);
const balance = availableMicrocredits === null
@@ -34,11 +32,12 @@ export function WorkspaceAccountMenu() {
-
+
{user.displayName || user.username}
@@ -46,13 +45,7 @@ export function WorkspaceAccountMenu() {
- {user.role === "admin" ? (
-
- ) : null}
-
-
+
@@ -61,14 +54,6 @@ export function WorkspaceAccountMenu() {
深色模式
setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
-
-
)}
>
@@ -83,10 +68,6 @@ export function WorkspaceAccountMenu() {
);
}
-function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) {
- return {icon}{label};
-}
-
function UserAvatar({ user, className }: { user: LocalUser; className?: string }) {
const [failed, setFailed] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx
index d46adf695..13867cc16 100644
--- a/web/src/components/layout/workspace-sidebar-nav.tsx
+++ b/web/src/components/layout/workspace-sidebar-nav.tsx
@@ -1,4 +1,4 @@
-import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, Plus, Search } from "lucide-react";
+import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, PanelLeftOpen, Plus, Search, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
@@ -40,7 +40,7 @@ function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
}
-function buildNav(features: FeatureAvailability, balance: string): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
+function buildNav(features: FeatureAvailability, balance: string, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
const groups: WorkspaceNavGroup[] = [
{
items: [
@@ -63,6 +63,7 @@ function buildNav(features: FeatureAvailability, balance: string): { groups: Wor
const settingsSections = SETTINGS_SECTIONS.filter((section) => section.key !== "channels" || features.customChannelsEnabled);
const footer: WorkspaceNavItem[] = [
+ ...(isAdmin ? [{ id: "admin", title: "管理员后台", icon: ShieldCheck, to: "/admin" }] : []),
{
...toolItem("settings", "/settings"),
children: settingsSections.map((section) => ({
@@ -77,7 +78,7 @@ function buildNav(features: FeatureAvailability, balance: string): { groups: Wor
return { groups, footer };
}
-function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) {
+function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void }) {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
@@ -87,6 +88,22 @@ function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) {
navigate(to);
};
+ if (collapsed) {
+ return (
+
+ );
+ }
+
return (
)}
- {hasChildren ? (
+ {hasChildren && !collapsed ? (
@@ -257,6 +280,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
onOpenSearch={onOpenSearch}
onLogout={onLogout}
level={level + 1}
+ collapsed={false}
/>
))}
@@ -266,12 +290,13 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
);
}
-function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
+function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collapsed }: {
group: WorkspaceNavGroup;
activeId: string;
onNavigate: () => void;
onOpenSearch: () => void;
onLogout: () => void;
+ collapsed: boolean;
}) {
const [isOpen, setIsOpen] = useState(true);
const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:")));
@@ -291,13 +316,14 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
onSelect={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={onLogout}
+ collapsed={collapsed}
/>
))}
);
// 无标题分组(核心导航入口)常驻展示,不做折叠。
- if (!group.heading) {
+ if (!group.heading || collapsed) {
return
{content}
;
}
@@ -322,7 +348,7 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
);
}
-export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: () => void; onOpenSearch: () => void }) {
+export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void }) {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const features = useUserStore((state) => state.features);
@@ -335,7 +361,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
? "--"
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
- const { groups, footer } = useMemo(() => buildNav(features, balance), [features, balance]);
+ const { groups, footer } = useMemo(() => buildNav(features, balance, user?.role === "admin"), [features, balance, user?.role]);
const slug = pathname.split("/").filter(Boolean)[0] || "create";
const section = searchParams.get("section");
@@ -357,18 +383,20 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
}, [groups]);
return (
-
-
+
+
-
+
@@ -379,6 +407,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 pb-3 pt-2",
+ collapsed && "is-collapsed",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
@@ -391,6 +420,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
onNavigate={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={() => void handleLogout()}
+ collapsed={collapsed}
/>
))}
@@ -405,6 +435,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
onSelect={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={() => void handleLogout()}
+ collapsed={collapsed}
/>
))}
diff --git a/web/src/components/prompt/prompt-code-editor.tsx b/web/src/components/prompt/prompt-code-editor.tsx
index 5fce75c13..90969596e 100644
--- a/web/src/components/prompt/prompt-code-editor.tsx
+++ b/web/src/components/prompt/prompt-code-editor.tsx
@@ -1,5 +1,4 @@
-import { EditorView } from "@codemirror/view";
-import CodeMirror from "@uiw/react-codemirror";
+import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx
index 399a4ca2e..ab17d38c2 100644
--- a/web/src/pages/canvas/index.tsx
+++ b/web/src/pages/canvas/index.tsx
@@ -10,7 +10,8 @@ import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/works
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
-import { CanvasCreateCard, CanvasProjectCard } from "@/components/canvas/canvas-project-card";
+import { CanvasCreateCard } from "@/components/canvas/canvas-project-card";
+import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
import type { CanvasExportFile } from "@/types/canvas-export";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
@@ -326,7 +327,12 @@ export default function CanvasPage() {
{showCreateCard ? : null}
{visibleProjects.map((project) => (
-
+ enterProject(project.id)}
+ />
))}
) : (
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 73d7ad095..5bc0893cc 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -403,8 +403,9 @@
--workspace-sidebar: var(--workspace-navigation);
--workspace-accent: #171717;
--workspace-accent-soft: rgba(17, 17, 17, 0.07);
- --workspace-sidebar-width: 260px; /* 侧栏展开宽度(组件 token)*/
- --workspace-sidebar-nav-width: 260px; /* 侧栏导航内容宽度,不受折叠状态影响 */
+ --workspace-sidebar-width: 208px; /* 侧栏展开宽度:较原 260px 收窄 20% */
+ --workspace-sidebar-nav-width: 208px; /* 展开态导航内容宽度 */
+ --workspace-sidebar-collapsed-width: 56px; /* 折叠态保留可发现的图标菜单轨 */
--workspace-shell-gutter: 6px; /* 工作区外壳画布留白与卡片间隙(组件 token)*/
@@ -817,6 +818,7 @@
position: fixed;
inset: 0;
isolation: isolate;
+ overflow: hidden;
}
textarea {
resize: none;
@@ -986,6 +988,11 @@
box-shadow: none;
}
+ .workspace-account-popover .ant-popover-inner {
+ border-color: color-mix(in srgb, var(--foreground) 7%, transparent) !important;
+ box-shadow: var(--elevation-overlay) !important;
+ }
+
body.app-spatial-overlays {
--workspace-overlay-bg: rgba(244, 244, 245, 1);
--workspace-overlay-bg-strong: rgba(250, 250, 250, 1);
@@ -1239,46 +1246,85 @@
box-shadow: none;
}
- /* 工作区卡片(侧栏 / 主区)共享外观:边框、圆角、阴影(深浅主题对称) */
+ /* 工作区外壳通过表面明度与间距分层,避免边框和内高光叠加造成线条感。 */
.app-workspace-sidebar,
.app-workspace-stage {
- border: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- border-radius: var(--r-xl);
- box-shadow:
- 0 14px 44px color-mix(in srgb, var(--foreground) 12%, transparent),
- inset 0 1px 0 color-mix(in srgb, white 6%, transparent);
- }
-
- .dark .app-workspace-sidebar,
- .dark .app-workspace-stage {
- box-shadow:
- 0 14px 44px rgba(0, 0, 0, 0.45),
- inset 0 1px 0 color-mix(in srgb, white 6%, transparent);
- }
-
- .app-workspace-sidebar {
- width: var(--workspace-sidebar-width, 260px);
- background: var(--workspace-sidebar);
- transition: width var(--motion-state) var(--ease-aceternity), border-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
- }
-
- .app-workspace-shell .app-workspace-sidebar.is-collapsed {
- width: 0 !important;
border: 0;
+ border-radius: var(--r-xl);
box-shadow: none;
}
- /* 侧栏折叠后主区四周贴齐视口:取消外壳留白、行间距与卡片圆角,四周无间隙 */
- .app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) {
- padding: 0;
+ .app-workspace-sidebar {
+ width: var(--workspace-sidebar-width, 208px);
+ background: color-mix(in srgb, var(--workspace-sidebar) 96%, var(--foreground) 4%);
+ transition: width var(--motion-state) var(--ease-aceternity);
}
- .app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) .app-workspace-main-row {
+ .app-workspace-shell .app-workspace-sidebar.is-collapsed {
+ width: var(--workspace-sidebar-collapsed-width) !important;
+ }
+
+ .app-workspace-sidebar-nav {
+ width: var(--workspace-sidebar-nav-width);
+ transition: width var(--motion-state) var(--ease-aceternity);
+ }
+
+ .app-workspace-sidebar-nav.is-collapsed {
+ width: var(--workspace-sidebar-collapsed-width);
+ }
+
+ .app-workspace-sidebar-rail-header {
+ display: grid;
+ min-height: 56px;
+ padding: 10px;
+ place-items: center;
+ }
+
+ .app-workspace-sidebar-rail-button {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ place-items: center;
+ border-radius: var(--r-sm);
+ color: color-mix(in srgb, var(--foreground) 66%, transparent);
+ transition: color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out;
+ }
+
+ .app-workspace-sidebar-rail-button:hover,
+ .app-workspace-sidebar-rail-button:focus-visible {
+ background: var(--surface-hover);
+ color: var(--foreground);
+ }
+
+ .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search {
+ padding: 0 10px 4px;
+ }
+
+ .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search > button,
+ .app-workspace-nav-link.is-collapsed {
+ justify-content: center;
+ gap: 0;
+ padding-inline: 0;
+ }
+
+ .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search-label,
+ .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search-shortcut,
+ .app-workspace-nav-link.is-collapsed .app-workspace-nav-title,
+ .app-workspace-nav-link.is-collapsed .app-workspace-nav-meta {
+ display: none;
+ }
+
+ .app-workspace-nav-link.is-collapsed .app-workspace-nav-main {
gap: 0;
}
- .app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) .app-workspace-stage {
- border-radius: 0;
+ .app-workspace-sidebar-scroll-area.is-collapsed {
+ gap: 6px;
+ padding: 4px 10px 8px;
+ }
+
+ .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-footer {
+ padding: 8px 10px 10px;
}
@@ -3537,8 +3583,8 @@
.canvas-library-count { display: flex; align-items: baseline; gap: 6px; justify-self: end; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); white-space: nowrap; }
.canvas-library-count strong { color: var(--foreground); font-size: var(--fs-heading-lg); font-variant-numeric: tabular-nums; font-weight: 500; letter-spacing: .02em; }
- /* 画布库保持固定列节奏,行列间距一致。 */
- .canvas-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: stretch; gap: var(--space-5) var(--space-4); }
+ /* 画布库使用稳定的最小列宽;卡片不靠负边距或溢出制造层叠。 */
+ .canvas-library-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); align-items: stretch; gap: var(--space-6) var(--space-5); }
.canvas-library-page .app-pagination-bar { position: static; margin-top: var(--space-4); border: 0; background: transparent; padding: var(--space-1-half) var(--space-2); backdrop-filter: none; }
/* === 画布卡片重构(对应 docs/ui-design-system.md)=== */
@@ -3613,6 +3659,166 @@
.app-canvas-selection-toolbar { border-color: color-mix(in srgb, var(--workspace-accent) 25%, var(--workspace-border)); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground)); }
+ /* 画布文件夹:去掉旧的三层叠压,改为更干净的文件夹封面 + 预览图 + 信息带。 */
+ .canvas-folder-card {
+ position: relative;
+ min-width: 0;
+ overflow: hidden;
+ border-radius: var(--r-xl);
+ background: var(--card-surface);
+ color: var(--foreground);
+ isolation: isolate;
+ content-visibility: auto;
+ contain-intrinsic-size: 0 320px;
+ box-shadow: none;
+ transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease-out, box-shadow var(--motion-state) ease-out;
+ }
+
+ .canvas-folder-card:hover, .canvas-folder-card:focus-within {
+ background: var(--card-surface-hover);
+ transform: translateY(-2px);
+ }
+
+ .canvas-folder-card:active { transform: translateY(-1px) scale(.995); transition-duration: var(--motion-instant); }
+ .canvas-folder-card.is-selected { box-shadow: 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 54%, transparent); }
+
+ .canvas-folder-open {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ flex-direction: column;
+ gap: 0;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: inherit;
+ text-align: left;
+ transition: transform var(--motion-state) var(--motion-ease-out);
+ }
+
+ .canvas-folder-open:focus:not(:focus-visible) { outline: none; }
+ .canvas-folder-open:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
+ .canvas-folder-card:hover .canvas-folder-open, .canvas-folder-card:focus-within .canvas-folder-open { transform: translateY(-1px); }
+ .canvas-folder-card:active .canvas-folder-open { transform: none; }
+
+ .canvas-folder-preview {
+ position: relative;
+ display: block;
+ flex: 0 0 auto;
+ aspect-ratio: 16 / 10;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: 0;
+ background: var(--canvas-cover-surface);
+ box-shadow: none;
+ }
+
+ .canvas-folder-preview > :where(.canvas-project-media, .canvas-project-video, .canvas-project-empty, .canvas-project-preview-canvas),
+ .canvas-folder-preview .cached-resource-image-shell { width: 100%; height: 100%; }
+ .canvas-folder-preview .canvas-project-media img, .canvas-folder-preview .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
+ .canvas-folder-preview .canvas-project-media img { filter: saturate(.94) contrast(1.02); transition: transform 420ms var(--motion-ease-out), filter 240ms ease-out; }
+ .canvas-folder-card:hover .canvas-folder-preview .canvas-project-media img { filter: saturate(1) contrast(1.04); transform: scale(1.025); }
+ .canvas-folder-preview .canvas-project-preview-canvas { background: var(--canvas-cover-surface); }
+
+ .canvas-folder-body {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 6px;
+ padding: var(--space-2);
+ }
+
+ .canvas-folder-heading-row { display: flex; min-width: 0; align-items: center; }
+ .canvas-folder-title { overflow: hidden; color: var(--foreground); font-size: var(--fs-heading-lg); font-weight: 640; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
+ .canvas-folder-title-input { width: 100%; min-width: 0; }
+ .canvas-folder-meta {
+ display: flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 10px;
+ color: color-mix(in srgb, var(--foreground) 42%, transparent);
+ font-size: var(--fs-caption);
+ line-height: 1.45;
+ }
+ .canvas-folder-meta-item { min-width: 0; }
+ .canvas-folder-meta-separator { color: color-mix(in srgb, var(--foreground) 24%, transparent); }
+
+ .canvas-folder-select {
+ position: absolute;
+ z-index: 4;
+ top: 12px;
+ left: 12px;
+ display: grid;
+ width: 30px;
+ height: 30px;
+ place-items: center;
+ border-radius: var(--r-full);
+ border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
+ background: color-mix(in srgb, var(--background) 72%, transparent);
+ opacity: 0;
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ transform: translateY(-2px);
+ transition: opacity var(--motion-instant) ease-out, transform var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out;
+ }
+
+ .canvas-folder-select.is-visible, .canvas-folder-card:hover .canvas-folder-select, .canvas-folder-card:focus-within .canvas-folder-select { opacity: 1; transform: translateY(0); }
+ .canvas-folder-select input { width: 14px; height: 14px; margin: 0; accent-color: var(--control-check-bg); }
+
+ .canvas-folder-actions {
+ position: absolute;
+ z-index: 4;
+ top: 12px;
+ right: 12px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
+ border-radius: var(--r-full);
+ background: color-mix(in srgb, var(--background) 72%, transparent);
+ opacity: 0;
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ transform: translateY(-2px);
+ transition: opacity var(--motion-instant) ease-out, transform var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out;
+ }
+
+ .canvas-folder-card:hover .canvas-folder-actions, .canvas-folder-card:focus-within .canvas-folder-actions { opacity: 1; transform: translateY(0); }
+
+ .canvas-folder-rename, .canvas-folder-action {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ cursor: pointer;
+ place-items: center;
+ border: 0;
+ border-radius: var(--r-full);
+ background: transparent;
+ color: color-mix(in srgb, var(--foreground) 42%, transparent);
+ transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
+ }
+
+ :where(.canvas-folder-rename, .canvas-folder-action):hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
+ :where(.canvas-folder-rename, .canvas-folder-action) svg { width: 16px; height: 16px; }
+
+ @media (max-width: 639px) {
+ .canvas-folder-open { gap: 0; padding: 0; }
+ .canvas-folder-title { font-size: var(--fs-heading); }
+ .canvas-folder-select, .canvas-folder-actions { opacity: 1; transform: none; }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .canvas-folder-card, .canvas-folder-open, .canvas-folder-preview .canvas-project-media img, .canvas-folder-select, .canvas-folder-actions { transition: none; }
+ .canvas-folder-card:hover, .canvas-folder-card:focus-within, .canvas-folder-card:active { transform: none; }
+ .canvas-folder-card:hover .canvas-folder-open, .canvas-folder-card:focus-within .canvas-folder-open { transform: none; }
+ .canvas-folder-card:hover .canvas-folder-preview .canvas-project-media img { transform: none; }
+ }
+
/* 作品库式页面:与画布列表共享浏览节奏,内容优先于边框和工具栏。 */
.library-page .app-page-header { border-bottom: 0; }
.library-page .app-page-header h1 { font-size: var(--fs-title); font-weight: 620; }
@@ -3839,6 +4045,7 @@
.canvas-library-page .canvas-library-grid {
flex: 1;
+ gap: var(--space-5);
}
.canvas-library-page .canvas-library-search {
@@ -4505,20 +4712,8 @@
.task-record-actions { opacity: 1; transform: none; }
}
- @media (max-width: 1600px) and (min-width: 1101px) {
- .canvas-library-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); }
- }
-
- @media (max-width: 1100px) and (min-width: 900px) {
- .canvas-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); }
- }
-
- @media (max-width: 899px) and (min-width: 640px) {
- .canvas-library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); }
- }
-
@media (max-width: 639px) {
- .canvas-library-grid { grid-template-columns: 1fr; gap: var(--space-4); }
+ .canvas-library-grid { grid-template-columns: 1fr; gap: var(--space-5); }
.canvas-library-discovery { padding-inline: 0; }
.canvas-library-filters { min-width: 0; }
.canvas-library-filter { max-width: 150px; padding-inline: 6px; }