feat: 无限画布 UI 重构 + 预览键盘控制 + 拖拽体验优化 (0.0.27)
- CanvasPage: AntD Layout 双栏布局 (Sider+Content),预览状态提升解耦 DOM - CanvasSidebar (新): 项目/集数选择器 + 镜头搜索 + 列表 + 固定刷新按钮 - CategoryTabs (新): 双模式分类标签 (Assets/审核) + 数量徽标 + 媒体细分 - CanvasViewport: Ctrl/Cmd 拖拽平移 (pointerEvents:none 防误抓缩略图) - CanvasViewport: previewActiveRef 双守卫阻止预览穿透缩放/拖拽 - MediaPreview: 键盘控制 Space 暂停/播放、<- -> 快退快进 ±0.5s - electron/main.ts: local-media:// 协议 handler 转发 Range 头修复 seek - StageTabs: 无限画布入口按钮 + 全局字号上调 Range seek 调试: 之前 net.fetch 未转发 Range 头 → 浏览器 seek 时 返回 200 全文件而非 206 Partial Content → currentTime 赋值无法定位 → 播放位置回退到 0。修复: 提取 Range 头单向转发到 file:// handler。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,6 +1,37 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||||
|
---
|
||||||
|
## 0.0.27(2026-06-24)
|
||||||
|
|
||||||
|
**无限画布 UI 重构 + 预览键盘控制 + 拖拽体验优化**
|
||||||
|
|
||||||
|
### 无限画布
|
||||||
|
|
||||||
|
- **双栏布局重构**:AntD Layout(Sider + Content),左侧导航 + 右侧画布,页面级别渲染预览组件解耦 DOM
|
||||||
|
- **CanvasSidebar**:项目/集数选择器 + 镜头搜索 + 镜头列表(滚动条 + 底部固定刷新按钮)+ 工作区切换
|
||||||
|
- **CategoryTabs 双模式**:Assets 阶段 → 全部/角色/道具/场景/收藏;其他阶段 → 所有[阶段名]/收藏/待审核/通过/返修/已弃用
|
||||||
|
- **分类数量徽标**:每个标签显示数据计数,"收藏"标签额外显示媒体类型细分(视频/图片/音频)
|
||||||
|
- **Ctrl/Cmd + 拖拽平移**:替代 Space 键修饰,释放 Space 给媒体控制;`pointerEvents: none` 防止拖拽时误抓缩略图
|
||||||
|
- **画布锁**:预览打开时 `previewActiveRef` 双守卫阻止穿透缩放/拖拽
|
||||||
|
- **无限画布入口按钮**:StageTabs 右侧按钮,调用 `OPEN_CANVAS_WINDOW` IPC
|
||||||
|
|
||||||
|
### 预览键盘控制
|
||||||
|
|
||||||
|
- **Space 暂停/播放**:capture 阶段拦截 + `stopPropagation`,避免与原生 `<video controls>` 双重触发
|
||||||
|
- **← → 快退/快进**:capture 阶段直接设置 `currentTime`,步长 ±0.5 秒
|
||||||
|
- **↑ ↓ 音量**:透传给原生 controls 处理
|
||||||
|
|
||||||
|
### 主进程
|
||||||
|
|
||||||
|
- **local-media:// Range 头转发**:协议 handler 只提取并转发 `Range` 请求头到 `net.fetch`,确保音视频 seek 返回正确的 206 Partial Content
|
||||||
|
|
||||||
|
### UI 调整
|
||||||
|
|
||||||
|
- 全局字号上调(侧栏、工具栏、标签、状态栏等)
|
||||||
|
- CanvasSidebar 滚动条定制 + 主题色适配
|
||||||
|
- ProjectSelector / EpisodeSelector 宽度自适应
|
||||||
|
|
||||||
---
|
---
|
||||||
## 0.0.26(2026-06-23)
|
## 0.0.26(2026-06-23)
|
||||||
|
|
||||||
|
|||||||
3
TODO.md
3
TODO.md
@@ -10,6 +10,9 @@
|
|||||||
- **窗口标题动态版控**:登录后通过 IPC 追加 `·个人版`/`·企业版` 后缀
|
- **窗口标题动态版控**:登录后通过 IPC 追加 `·个人版`/`·企业版` 后缀
|
||||||
- **退出登录不再自动清空本地缓存**:`user_id` 隔离 + 手动清理按钮
|
- **退出登录不再自动清空本地缓存**:`user_id` 隔离 + 手动清理按钮
|
||||||
- **无限画布(企业版项目管理)**:独立 BrowserWindow 画布窗口,项目/集数/镜头导航、阶段切换、网格/单行布局、滚轮缩放、拖拽平移、全屏灯箱预览、原生文件拖拽
|
- **无限画布(企业版项目管理)**:独立 BrowserWindow 画布窗口,项目/集数/镜头导航、阶段切换、网格/单行布局、滚轮缩放、拖拽平移、全屏灯箱预览、原生文件拖拽
|
||||||
|
- **无限画布 UI 重构**:AntD Layout 双栏布局(Sider+Content)、CanvasSidebar 导航、CategoryTabs 双模式分类、Ctrl/Cmd+拖拽平移
|
||||||
|
- **预览键盘控制**:Space 暂停/播放、← → 快退快进 ±0.5s、↑ ↓ 音量透传原生 controls
|
||||||
|
- **local-media:// Range 请求支持**:协议头转发修复音视频 seek 失败问题
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -252,12 +252,21 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fwd = filePath.replace(/\\/g, '/');
|
const fwd = filePath.replace(/\\/g, '/');
|
||||||
|
let fileUrl: string;
|
||||||
if (fwd.startsWith('//')) {
|
if (fwd.startsWith('//')) {
|
||||||
// UNC:file://server/share/path(authority = server)
|
fileUrl = 'file:' + fwd;
|
||||||
return net.fetch('file:' + fwd);
|
} else {
|
||||||
|
fileUrl = 'file:///' + fwd;
|
||||||
}
|
}
|
||||||
// 本地:file:///C:/path/to/file(authority 为空)
|
|
||||||
return net.fetch('file:///' + fwd);
|
// 只转发 Range 头,确保音视频 seek 时返回 206 Partial Content。
|
||||||
|
// ⚠️ 不能转发所有 headers(Origin/Referer/Sec-Fetch-* 等),
|
||||||
|
// 否则会干扰 Chromium 内部的 file:// 协议处理,导致请求失败。
|
||||||
|
const rangeHeader = request.headers.get('Range');
|
||||||
|
if (rangeHeader) {
|
||||||
|
return net.fetch(fileUrl, { headers: { Range: rangeHeader } });
|
||||||
|
}
|
||||||
|
return net.fetch(fileUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
setupAppMenu();
|
setupAppMenu();
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// CanvasPage — 无限画布页面
|
// CanvasPage — 无限画布页面(AntD Layout 双栏布局)
|
||||||
//
|
//
|
||||||
// 两栏 flex 布局(Assets 阶段为单栏):
|
// ┌──────────────┬─────────────────────────────────────────┐
|
||||||
// 左侧:ShotList(镜头单选列表)
|
// │ CanvasSidebar│ CanvasToolbar │
|
||||||
// 右侧:CanvasViewport(主画布)
|
// │ ├ 项目选择 │ ├ StageTabs + 无限画布 │
|
||||||
// 顶部:CanvasToolbar
|
// │ ├ 集数选择 │ ├ CategoryTabs + FilterBar │
|
||||||
// 底部:状态栏
|
// │ ├ 镜头搜索 │ ├────────────────────────────────────┤
|
||||||
|
// │ ├ 镜头列表 │ │ CanvasViewport(或 Placeholder) │
|
||||||
|
// │ └ 刷新按钮 │ │ │
|
||||||
|
// └──────────────┴─────────────────────────────────────────┘
|
||||||
|
// └── 底部状态栏 ─────────────────────────┘
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Layout } from 'antd';
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { useCanvasStore } from './store/useCanvasStore';
|
import { useCanvasStore } from './store/useCanvasStore';
|
||||||
import { useDirectoryScan } from './hooks/useDirectoryScan';
|
import { useDirectoryScan } from './hooks/useDirectoryScan';
|
||||||
import { CanvasToolbar } from './toolbar/CanvasToolbar';
|
import { CanvasToolbar } from './toolbar/CanvasToolbar';
|
||||||
import { CanvasViewport } from './core/CanvasViewport';
|
import { CanvasViewport } from './core/CanvasViewport';
|
||||||
import { ShotList } from './navigation/ShotList';
|
import { MediaPreview } from './core/MediaPreview';
|
||||||
|
import { CanvasSidebar } from './navigation/CanvasSidebar';
|
||||||
|
|
||||||
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
/** 中心区域占位提示 */
|
/** 中心区域占位提示 */
|
||||||
function Placeholder({
|
function Placeholder({
|
||||||
@@ -41,7 +50,7 @@ function Placeholder({
|
|||||||
<div style={{ fontSize: 14, color: isDark ? '#A5A3C0' : '#6B7280' }}>{title}</div>
|
<div style={{ fontSize: 14, color: isDark ? '#A5A3C0' : '#6B7280' }}>{title}</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||||
}}
|
}}
|
||||||
@@ -65,6 +74,17 @@ export function CanvasPage() {
|
|||||||
// 启动目录扫描监听
|
// 启动目录扫描监听
|
||||||
useDirectoryScan();
|
useDirectoryScan();
|
||||||
|
|
||||||
|
// ── 全屏预览状态 ──
|
||||||
|
const [previewItemId, setPreviewItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handlePreviewOpen = useCallback((itemId: number) => {
|
||||||
|
setPreviewItemId(itemId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePreviewClose = useCallback(() => {
|
||||||
|
setPreviewItemId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isAssets = currentStage === 'Assets';
|
const isAssets = currentStage === 'Assets';
|
||||||
|
|
||||||
/** 根据当前选中状态决定主视图内容 */
|
/** 根据当前选中状态决定主视图内容 */
|
||||||
@@ -74,7 +94,7 @@ export function CanvasPage() {
|
|||||||
<Placeholder
|
<Placeholder
|
||||||
icon="📂"
|
icon="📂"
|
||||||
title="请先选择一个工作区目录"
|
title="请先选择一个工作区目录"
|
||||||
hint="目录结构需符合 AIGC 生产管线约定(项目/shots/集数/镜头/阶段)"
|
hint="从左侧边栏打开或切换工作区"
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -85,7 +105,7 @@ export function CanvasPage() {
|
|||||||
<Placeholder
|
<Placeholder
|
||||||
icon="📁"
|
icon="📁"
|
||||||
title="请先选择一个项目"
|
title="请先选择一个项目"
|
||||||
hint="从顶部工具栏的项目下拉列表中选择"
|
hint="从左侧边栏的项目下拉列表中选择"
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -113,7 +133,7 @@ export function CanvasPage() {
|
|||||||
<Placeholder
|
<Placeholder
|
||||||
icon="🎬"
|
icon="🎬"
|
||||||
title="请先选择集数"
|
title="请先选择集数"
|
||||||
hint="从顶部工具栏的集数下拉列表中选择"
|
hint="从左侧边栏的集数下拉列表中选择"
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -124,7 +144,7 @@ export function CanvasPage() {
|
|||||||
<Placeholder
|
<Placeholder
|
||||||
icon="🎞️"
|
icon="🎞️"
|
||||||
title="请先选择镜头"
|
title="请先选择镜头"
|
||||||
hint="从左侧镜头列表中点击选择"
|
hint="从左侧边栏的镜头列表中点击选择"
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -141,85 +161,103 @@ export function CanvasPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CanvasViewport />;
|
return <CanvasViewport onPreviewOpen={handlePreviewOpen} previewActive={previewItemId !== null} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const borderColor = isDark ? '#2E2A5A' : '#E5E7EB';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Layout
|
||||||
style={{
|
style={{
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: isDark ? '#0F0D1E' : '#F5F3FF',
|
|
||||||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
hasSider
|
||||||
>
|
>
|
||||||
{/* 顶部工具栏 */}
|
{/* ── 左侧导航边栏 ── */}
|
||||||
<CanvasToolbar />
|
<Sider
|
||||||
|
width={220}
|
||||||
{/* 中心区域:两栏布局 */}
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
background: isDark ? '#0F0D21' : '#F5F3FF',
|
||||||
display: 'flex',
|
borderRight: `1px solid ${borderColor}`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
trigger={null}
|
||||||
>
|
>
|
||||||
{/* 左侧镜头列表(Assets 阶段隐藏) */}
|
<CanvasSidebar />
|
||||||
<ShotList />
|
</Sider>
|
||||||
|
|
||||||
{/* 右侧主内容 */}
|
{/* ── 右侧主内容区 ── */}
|
||||||
<div
|
<Layout
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#0F0D1E' : '#F5F3FF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Content
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{/* 顶部工具栏(两行:阶段标签 + 分类标签/筛选) */}
|
||||||
</div>
|
<CanvasToolbar />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部状态栏 */}
|
{/* 主画布区域 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 28,
|
flex: 1,
|
||||||
flexShrink: 0,
|
display: 'flex',
|
||||||
display: 'flex',
|
overflow: 'hidden',
|
||||||
alignItems: 'center',
|
}}
|
||||||
padding: '0 12px',
|
>
|
||||||
borderTop: `1px solid ${isDark ? '#2E2A5A' : '#E5E7EB'}`,
|
{renderMainContent()}
|
||||||
background: isDark ? '#15132E' : '#EEF2FF',
|
</div>
|
||||||
fontSize: 11,
|
|
||||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
{/* 底部状态栏 */}
|
||||||
}}
|
<div
|
||||||
>
|
style={{
|
||||||
<span>离线模式 · 本地文件系统</span>
|
height: 28,
|
||||||
<span style={{ margin: '0 8px' }}>|</span>
|
flexShrink: 0,
|
||||||
<span>
|
display: 'flex',
|
||||||
{isScanning
|
alignItems: 'center',
|
||||||
? '扫描中…'
|
padding: '0 12px',
|
||||||
: workspaceRoot
|
borderTop: `1px solid ${borderColor}`,
|
||||||
? `项目:${selectedProject || '(未选)'} · 阶段:${currentStage} · ${items.length} 个文件`
|
background: isDark ? '#15132E' : '#EEF2FF',
|
||||||
: '准备就绪'}
|
fontSize: 12,
|
||||||
</span>
|
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||||
{selectedEpisode && (
|
}}
|
||||||
<>
|
>
|
||||||
<span style={{ margin: '0 4px' }}>|</span>
|
<span>离线模式 · 本地文件系统</span>
|
||||||
<span>集数:{selectedEpisode}</span>
|
<span style={{ margin: '0 8px' }}>|</span>
|
||||||
</>
|
<span>
|
||||||
)}
|
{isScanning
|
||||||
{selectedShot && (
|
? '扫描中…'
|
||||||
<>
|
: workspaceRoot
|
||||||
<span style={{ margin: '0 4px' }}>|</span>
|
? `项目:${selectedProject || '(未选)'} · 阶段:${currentStage} · ${items.length} 个文件`
|
||||||
<span>镜头:{selectedShot}</span>
|
: '准备就绪'}
|
||||||
</>
|
</span>
|
||||||
)}
|
{selectedEpisode && (
|
||||||
<span style={{ flex: 1 }} />
|
<>
|
||||||
<span>Phase 1</span>
|
<span style={{ margin: '0 4px' }}>|</span>
|
||||||
</div>
|
<span>集数:{selectedEpisode}</span>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
{selectedShot && (
|
||||||
|
<>
|
||||||
|
<span style={{ margin: '0 4px' }}>|</span>
|
||||||
|
<span>镜头:{selectedShot}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span>Phase 1</span>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{/* 全屏预览(页面级别渲染,与画布 DOM 解耦) */}
|
||||||
|
<MediaPreview itemId={previewItemId} onClose={handlePreviewClose} />
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Grid 模式:固定列数,均匀网格布局
|
// - Grid 模式:固定列数,均匀网格布局
|
||||||
// - Single 模式:按 groupKey 分组,每组一行横向排列
|
// - Single 模式:按 groupKey 分组,每组一行横向排列
|
||||||
// - 滚轮缩放:滚轮直接调整 zoom(无需修饰键),Ctrl/Cmd + 滚轮精细缩放
|
// - 滚轮缩放:滚轮直接调整 zoom(无需修饰键),Ctrl/Cmd + 滚轮精细缩放
|
||||||
// - 鼠标拖拽:mousedown + mousemove 平移(panOffset)
|
// - Ctrl/Cmd + 鼠标拖拽:按住 Ctrl(Win) 或 Cmd(Mac) 后 mousedown + mousemove 平移
|
||||||
// - 分页叠加层:PagerOverlay + ZoomOverlay
|
// - 分页叠加层:PagerOverlay + ZoomOverlay
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ import { useTheme } from '@/components/providers/ThemeProvider';
|
|||||||
import { useCanvasStore } from '../store/useCanvasStore';
|
import { useCanvasStore } from '../store/useCanvasStore';
|
||||||
import { MediaCard } from './MediaCard';
|
import { MediaCard } from './MediaCard';
|
||||||
import { MediaCardContextMenu } from './MediaCardContextMenu';
|
import { MediaCardContextMenu } from './MediaCardContextMenu';
|
||||||
import { MediaPreview } from './MediaPreview';
|
|
||||||
import { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls';
|
import { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls';
|
||||||
import {
|
import {
|
||||||
gridPosition,
|
gridPosition,
|
||||||
@@ -29,7 +28,14 @@ import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../ut
|
|||||||
const ZOOM_MIN = 0.02;
|
const ZOOM_MIN = 0.02;
|
||||||
const ZOOM_MAX = 16.0;
|
const ZOOM_MAX = 16.0;
|
||||||
|
|
||||||
export function CanvasViewport() {
|
interface CanvasViewportProps {
|
||||||
|
/** 双击卡片时回调 */
|
||||||
|
onPreviewOpen: (itemId: number) => void;
|
||||||
|
/** 预览是否打开(阻止画布缩放/拖拽) */
|
||||||
|
previewActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasViewport({ onPreviewOpen, previewActive }: CanvasViewportProps) {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const items = useCanvasStore((s) => s.items);
|
const items = useCanvasStore((s) => s.items);
|
||||||
const layoutMode = useCanvasStore((s) => s.layoutMode);
|
const layoutMode = useCanvasStore((s) => s.layoutMode);
|
||||||
@@ -40,14 +46,19 @@ export function CanvasViewport() {
|
|||||||
const zoom = useCanvasStore((s) => s.zoom);
|
const zoom = useCanvasStore((s) => s.zoom);
|
||||||
const panOffset = useCanvasStore((s) => s.panOffset);
|
const panOffset = useCanvasStore((s) => s.panOffset);
|
||||||
const interactionMode = useCanvasStore((s) => s.interactionMode);
|
const interactionMode = useCanvasStore((s) => s.interactionMode);
|
||||||
|
const favoriteOnly = useCanvasStore((s) => s.favoriteOnly);
|
||||||
const setZoom = useCanvasStore((s) => s.setZoom);
|
const setZoom = useCanvasStore((s) => s.setZoom);
|
||||||
const setPanOffset = useCanvasStore((s) => s.setPanOffset);
|
const setPanOffset = useCanvasStore((s) => s.setPanOffset);
|
||||||
|
|
||||||
// 拖拽状态
|
// 拖拽状态(需按住 Ctrl/Cmd 修饰键)
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 拖拽修饰键状态(Ctrl / Cmd)—— 双通道
|
||||||
|
const [isPanModifier, setIsPanModifier] = useState(false);
|
||||||
|
const panModifierRef = useRef(false);
|
||||||
|
|
||||||
// 预览打开时锁定视口事件(防止穿透缩放/拖拽画布)
|
// 预览打开时锁定视口事件(防止穿透缩放/拖拽画布)
|
||||||
const previewActiveRef = useRef(false);
|
const previewActiveRef = useRef(false);
|
||||||
|
|
||||||
@@ -58,44 +69,41 @@ export function CanvasViewport() {
|
|||||||
useEffect(() => { setZoomRef.current = setZoom; }, [setZoom]);
|
useEffect(() => { setZoomRef.current = setZoom; }, [setZoom]);
|
||||||
const currentZoomRef = useRef(zoom);
|
const currentZoomRef = useRef(zoom);
|
||||||
useEffect(() => { currentZoomRef.current = zoom; }, [zoom]);
|
useEffect(() => { currentZoomRef.current = zoom; }, [zoom]);
|
||||||
|
|
||||||
// 全屏预览状态
|
|
||||||
const [previewItemId, setPreviewItemId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleDoubleClick = useCallback((itemId: number) => {
|
|
||||||
setPreviewItemId(itemId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClosePreview = useCallback(() => {
|
|
||||||
setPreviewItemId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 同步预览状态到 ref(供原生 wheel 监听器读取,避免闭包过期)
|
// 同步预览状态到 ref(供原生 wheel 监听器读取,避免闭包过期)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
previewActiveRef.current = previewItemId !== null;
|
previewActiveRef.current = previewActive;
|
||||||
}, [previewItemId]);
|
}, [previewActive]);
|
||||||
|
|
||||||
|
|
||||||
|
// ── 收藏过滤 ──
|
||||||
|
// 客户端过滤:当 favoriteOnly 为 true 时仅展示 metadata.favorite === true 的条目
|
||||||
|
|
||||||
|
const displayItems = useMemo(() => {
|
||||||
|
if (!favoriteOnly) return items;
|
||||||
|
return items.filter((item) => item.metadata?.favorite === true);
|
||||||
|
}, [items, favoriteOnly]);
|
||||||
|
|
||||||
// ── 分页计算 ──
|
// ── 分页计算 ──
|
||||||
|
|
||||||
const pageItems = useMemo(() => {
|
const pageItems = useMemo(() => {
|
||||||
if (items.length === 0) return [];
|
if (displayItems.length === 0) return [];
|
||||||
if (layoutMode === 'grid') {
|
if (layoutMode === 'grid') {
|
||||||
const { startIndex, endIndex } = gridPageInfo(
|
const { startIndex, endIndex } = gridPageInfo(
|
||||||
items.length,
|
displayItems.length,
|
||||||
cols,
|
cols,
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
currentPage,
|
currentPage,
|
||||||
);
|
);
|
||||||
return items.slice(startIndex, endIndex);
|
return displayItems.slice(startIndex, endIndex);
|
||||||
}
|
}
|
||||||
// single mode
|
// single mode
|
||||||
const { startIndex, endIndex } = singlePageInfo(
|
const { startIndex, endIndex } = singlePageInfo(
|
||||||
items,
|
displayItems,
|
||||||
singleRowsPerPage,
|
singleRowsPerPage,
|
||||||
currentPage,
|
currentPage,
|
||||||
);
|
);
|
||||||
return items.slice(startIndex, endIndex);
|
return displayItems.slice(startIndex, endIndex);
|
||||||
}, [items, layoutMode, cols, rowsPerPage, singleRowsPerPage, currentPage]);
|
}, [displayItems, layoutMode, cols, rowsPerPage, singleRowsPerPage, currentPage]);
|
||||||
|
|
||||||
// ── 位置计算 ──
|
// ── 位置计算 ──
|
||||||
|
|
||||||
@@ -134,6 +142,44 @@ export function CanvasViewport() {
|
|||||||
};
|
};
|
||||||
}, [layoutMode, pageItems, cols]);
|
}, [layoutMode, pageItems, cols]);
|
||||||
|
|
||||||
|
// ── Ctrl/Cmd + 拖拽平移 ──
|
||||||
|
// 按住 Ctrl(Windows)或 Cmd(Mac)后鼠标左键拖拽平移画布。
|
||||||
|
// 修饰键松开时自动取消拖拽,防止画布粘滞。
|
||||||
|
// 同时支持 Ctrl 和 Cmd 组合(keyup 后检查是否仍有一个修饰键被按住)。
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Control' && e.key !== 'Meta') return;
|
||||||
|
if (e.repeat) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setIsPanModifier(true);
|
||||||
|
panModifierRef.current = true;
|
||||||
|
};
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Control' && e.key !== 'Meta') return;
|
||||||
|
// 检查是否所有拖拽修饰键均已松开
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
setIsPanModifier(false);
|
||||||
|
panModifierRef.current = false;
|
||||||
|
setIsDragging(false); // 松修饰键时取消拖拽
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 窗口失焦时重置修饰键状态(防止切出应用导致状态卡住)
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsPanModifier(false);
|
||||||
|
panModifierRef.current = false;
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
window.addEventListener('blur', handleBlur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
window.removeEventListener('blur', handleBlur);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── 滚轮缩放 ──
|
// ── 滚轮缩放 ──
|
||||||
// 纯滚轮 → 快速缩放(10%/tick),Ctrl/Cmd + 滚轮 → 精细缩放(3%/tick)。
|
// 纯滚轮 → 快速缩放(10%/tick),Ctrl/Cmd + 滚轮 → 精细缩放(3%/tick)。
|
||||||
// Mac 触控板捏合手势 → macOS 自动合成 Ctrl+wheel 事件 → 精细缩放。
|
// Mac 触控板捏合手势 → macOS 自动合成 Ctrl+wheel 事件 → 精细缩放。
|
||||||
@@ -180,6 +226,8 @@ export function CanvasViewport() {
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('button')) return;
|
if (target.closest('button')) return;
|
||||||
|
// 必须按住 Ctrl/Cmd 才能拖拽平移画布
|
||||||
|
if (!panModifierRef.current) return;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
dragStart.current = {
|
dragStart.current = {
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
@@ -242,14 +290,20 @@ export function CanvasViewport() {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
cursor: isDragging
|
cursor: isDragging
|
||||||
? 'grabbing'
|
? 'grabbing'
|
||||||
: interactionMode === 'SELECT'
|
: isPanModifier
|
||||||
? 'default'
|
? 'grab'
|
||||||
: 'grab',
|
: 'default',
|
||||||
background: isDark ? '#0F0D1E' : '#F9FAFB',
|
background: isDark ? '#0F0D1E' : '#F9FAFB',
|
||||||
|
// Ctrl/Cmd 拖拽时禁用文本选择,防止误抓媒体元素
|
||||||
|
userSelect: isPanModifier ? 'none' : undefined,
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
// Ctrl/Cmd 拖拽时阻止浏览器原生的图片/链接拖拽行为
|
||||||
|
if (panModifierRef.current) e.preventDefault();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* 画布内容 */}
|
{/* 画布内容 */}
|
||||||
<div
|
<div
|
||||||
@@ -261,6 +315,8 @@ export function CanvasViewport() {
|
|||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
width: canvasWidth,
|
width: canvasWidth,
|
||||||
height: canvasHeight,
|
height: canvasHeight,
|
||||||
|
// 拖拽修饰键激活时禁用缩略图的鼠标事件,所有事件穿透到容器层
|
||||||
|
pointerEvents: isPanModifier ? 'none' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Single 模式的行标签 */}
|
{/* Single 模式的行标签 */}
|
||||||
@@ -277,7 +333,7 @@ export function CanvasViewport() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: isDark ? '#C4B5FD' : '#6366F1',
|
color: isDark ? '#C4B5FD' : '#6366F1',
|
||||||
background: isDark
|
background: isDark
|
||||||
@@ -306,7 +362,7 @@ export function CanvasViewport() {
|
|||||||
y={0}
|
y={0}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
mode={interactionMode}
|
mode={interactionMode}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={onPreviewOpen}
|
||||||
/>
|
/>
|
||||||
</MediaCardContextMenu>
|
</MediaCardContextMenu>
|
||||||
);
|
);
|
||||||
@@ -325,7 +381,7 @@ export function CanvasViewport() {
|
|||||||
background: 'rgba(255, 77, 79, 0.1)',
|
background: 'rgba(255, 77, 79, 0.1)',
|
||||||
border: '1px solid rgba(255, 77, 79, 0.3)',
|
border: '1px solid rgba(255, 77, 79, 0.3)',
|
||||||
color: '#FF4D4F',
|
color: '#FF4D4F',
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -357,11 +413,6 @@ export function CanvasViewport() {
|
|||||||
<ZoomOverlay />
|
<ZoomOverlay />
|
||||||
<ModeSwitch />
|
<ModeSwitch />
|
||||||
|
|
||||||
{/* 全屏预览(双击打开) */}
|
|
||||||
<MediaPreview
|
|
||||||
itemId={previewItemId}
|
|
||||||
onClose={handleClosePreview}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
|
|||||||
const [previewPan, setPreviewPan] = useState({ x: 0, y: 0 });
|
const [previewPan, setPreviewPan] = useState({ x: 0, y: 0 });
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const imgContainerRef = useRef<HTMLDivElement>(null);
|
const imgContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||||
// ref 版 zoom(供稳定闭包的 native wheel listener 读取)
|
// ref 版 zoom(供稳定闭包的 native wheel listener 读取)
|
||||||
const previewZoomRef = useRef(previewZoom);
|
const previewZoomRef = useRef(previewZoom);
|
||||||
@@ -66,13 +68,13 @@ export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
|
|||||||
setPreviewPan({ x: 0, y: 0 });
|
setPreviewPan({ x: 0, y: 0 });
|
||||||
}, [currentId]);
|
}, [currentId]);
|
||||||
|
|
||||||
// 预览内滚轮缩放(native listener + stopPropagation 隔离)
|
// 预览内滚轮缩放(随 currentId 变化重新绑定,解决首次挂载时 ref 为 null 的问题)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = imgContainerRef.current;
|
const el = imgContainerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const handler = (e: WheelEvent) => {
|
const handler = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation(); // 阻止冒泡到 CanvasViewport
|
e.stopPropagation();
|
||||||
const delta = -e.deltaY * 0.002;
|
const delta = -e.deltaY * 0.002;
|
||||||
const cur = previewZoomRef.current;
|
const cur = previewZoomRef.current;
|
||||||
const newZoom = Math.max(0.1, Math.min(8.0, cur + delta));
|
const newZoom = Math.max(0.1, Math.min(8.0, cur + delta));
|
||||||
@@ -80,7 +82,7 @@ export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
|
|||||||
};
|
};
|
||||||
el.addEventListener('wheel', handler, { passive: false });
|
el.addEventListener('wheel', handler, { passive: false });
|
||||||
return () => el.removeEventListener('wheel', handler);
|
return () => el.removeEventListener('wheel', handler);
|
||||||
}, []);
|
}, [currentId]);
|
||||||
|
|
||||||
// ── 拖拽平移(仅在预览内)──
|
// ── 拖拽平移(仅在预览内)──
|
||||||
const handlePreviewMouseDown = useCallback((e: React.MouseEvent) => {
|
const handlePreviewMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -120,6 +122,57 @@ export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
|
|||||||
return () => window.removeEventListener('mouseup', handler);
|
return () => window.removeEventListener('mouseup', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── 键盘媒体控制 ──
|
||||||
|
// 所有按键在 capture 阶段拦截,优先于原生 <video controls>:
|
||||||
|
// 空格 → 暂停/播放(stopPropagation 阻止原生二次触发)
|
||||||
|
// ← / → → 快退/快进 0.5 秒(直接设 currentTime)
|
||||||
|
// ↑ / ↓ → 透传到原生 controls 处理音量
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentId == null) return;
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
const media = video || audio;
|
||||||
|
if (!media) return;
|
||||||
|
|
||||||
|
// 空格 → 暂停/播放
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (media.paused) void media.play();
|
||||||
|
else media.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ← / → → 快退/快进 0.5 秒
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const delta = e.key === 'ArrowLeft' ? -0.5 : 0.5;
|
||||||
|
media.currentTime = Math.max(0, media.currentTime + delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ↑ / ↓ → 不拦截,透传给原生 controls(原生音量 ±10%)
|
||||||
|
};
|
||||||
|
// capture: true → 在原生 video controls 之前拦截
|
||||||
|
window.addEventListener('keydown', handler, true);
|
||||||
|
return () => window.removeEventListener('keydown', handler, true);
|
||||||
|
}, [currentId]);
|
||||||
|
|
||||||
|
// 媒体元素挂载后自动聚焦(确保原生 controls 能接收 ↑/↓ 音量事件)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (videoRef.current) videoRef.current.focus();
|
||||||
|
else if (audioRef.current) audioRef.current.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [currentId]);
|
||||||
|
|
||||||
if (currentId == null) return null;
|
if (currentId == null) return null;
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.id === currentId);
|
const currentItem = items.find((i) => i.id === currentId);
|
||||||
@@ -221,17 +274,19 @@ export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
|
|||||||
)}
|
)}
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
src={mediaUrl}
|
src={mediaUrl}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
style={{ maxWidth: '90vw', maxHeight: '85vh', borderRadius: 4 }}
|
tabIndex={0}
|
||||||
|
style={{ maxWidth: '90vw', maxHeight: '85vh', borderRadius: 4, outline: 'none' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isAudio && (
|
{isAudio && (
|
||||||
<div style={{ textAlign: 'center', color: '#fff' }}>
|
<div style={{ textAlign: 'center', color: '#fff' }}>
|
||||||
<div style={{ fontSize: 48, marginBottom: 16 }}>🎵</div>
|
<div style={{ fontSize: 48, marginBottom: 16 }}>🎵</div>
|
||||||
<div style={{ fontSize: 16, marginBottom: 12 }}>{currentItem.fileName}</div>
|
<div style={{ fontSize: 16, marginBottom: 12 }}>{currentItem.fileName}</div>
|
||||||
<audio src={mediaUrl} controls autoPlay />
|
<audio ref={audioRef} src={mediaUrl} controls autoPlay />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function PagerOverlay() {
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
@@ -91,7 +91,7 @@ export function PagerOverlay() {
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||||
borderLeft: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
borderLeft: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||||||
paddingLeft: 8,
|
paddingLeft: 8,
|
||||||
@@ -138,7 +138,7 @@ export function ZoomOverlay() {
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||||
minWidth: 44,
|
minWidth: 44,
|
||||||
@@ -205,11 +205,11 @@ export function ModeSwitch() {
|
|||||||
onClick={() => switchMode(key)}
|
onClick={() => switchMode(key)}
|
||||||
title={`${label}模式 (${shortcut})`}
|
title={`${label}模式 (${shortcut})`}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: interactionMode === key ? 600 : 400,
|
fontWeight: interactionMode === key ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ marginLeft: 2, fontSize: 10 }}>{shortcut}</span>
|
<span style={{ marginLeft: 2, fontSize: 11 }}>{shortcut}</span>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
340
src/pages/infinite-canvas/navigation/CanvasSidebar.tsx
Normal file
340
src/pages/infinite-canvas/navigation/CanvasSidebar.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
// ============================================================
|
||||||
|
// CanvasSidebar — 左侧导航边栏
|
||||||
|
//
|
||||||
|
// 从上到下依次为:
|
||||||
|
// 1. 项目下拉选择器(ProjectSelector)
|
||||||
|
// 2. 集数下拉选择器(EpisodeSelector,Assets 阶段隐藏)
|
||||||
|
// 3. 镜头号搜索输入框
|
||||||
|
// 4. 镜头单选列表(ShotList,上下滚动)
|
||||||
|
// 5. 刷新镜头列表按钮
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Input, Button, App } from 'antd';
|
||||||
|
import { SearchOutlined, ReloadOutlined, FolderOpenOutlined } from '@ant-design/icons';
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
import { useCanvasStore } from '../store/useCanvasStore';
|
||||||
|
import { ProjectSelector } from './ProjectSelector';
|
||||||
|
import { EpisodeSelector } from './EpisodeSelector';
|
||||||
|
|
||||||
|
export function CanvasSidebar() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
// ── Store 订阅 ──
|
||||||
|
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
|
||||||
|
const selectedProject = useCanvasStore((s) => s.selectedProject);
|
||||||
|
const selectedEpisode = useCanvasStore((s) => s.selectedEpisode);
|
||||||
|
const selectedShot = useCanvasStore((s) => s.selectedShot);
|
||||||
|
const shots = useCanvasStore((s) => s.shots);
|
||||||
|
const currentStage = useCanvasStore((s) => s.currentStage);
|
||||||
|
const selectShot = useCanvasStore((s) => s.selectShot);
|
||||||
|
const setShots = useCanvasStore((s) => s.setShots);
|
||||||
|
const setWorkspace = useCanvasStore((s) => s.setWorkspace);
|
||||||
|
const setProjects = useCanvasStore((s) => s.setProjects);
|
||||||
|
const getDataSource = useCanvasStore((s) => s.getDataSource);
|
||||||
|
|
||||||
|
// ── 本地状态 ──
|
||||||
|
const [shotSearchQuery, setShotSearchQuery] = useState('');
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const isAssets = currentStage === 'Assets';
|
||||||
|
|
||||||
|
// ── 镜头搜索过滤 ──
|
||||||
|
const filteredShots = shotSearchQuery.trim()
|
||||||
|
? shots.filter((s) => s.toLowerCase().includes(shotSearchQuery.toLowerCase()))
|
||||||
|
: shots;
|
||||||
|
|
||||||
|
// ── 选择镜头 ──
|
||||||
|
const handleShotClick = useCallback(
|
||||||
|
(shotName: string) => {
|
||||||
|
selectShot(shotName);
|
||||||
|
},
|
||||||
|
[selectShot],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 刷新镜头列表 ──
|
||||||
|
const handleRefreshShots = useCallback(async () => {
|
||||||
|
if (!workspaceRoot || !selectedProject || !selectedEpisode) return;
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
const dataSource = getDataSource();
|
||||||
|
const shotsPath = `${workspaceRoot}\\${selectedProject}\\shots\\${selectedEpisode}`;
|
||||||
|
const updated = await dataSource.listSubdirs(shotsPath);
|
||||||
|
setShots(updated);
|
||||||
|
message.success(`镜头列表已刷新(${updated.length} 个镜头)`);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`刷新失败: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [workspaceRoot, selectedProject, selectedEpisode, getDataSource, setShots, message]);
|
||||||
|
|
||||||
|
// ── 打开/切换工作区 ──
|
||||||
|
const handleOpenWorkspace = async () => {
|
||||||
|
try {
|
||||||
|
const dataSource = getDataSource();
|
||||||
|
const dir = await dataSource.openDirectoryDialog();
|
||||||
|
if (dir) {
|
||||||
|
setWorkspace(dir);
|
||||||
|
try {
|
||||||
|
const projects = await dataSource.listSubdirs(dir);
|
||||||
|
setProjects(projects);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CanvasSidebar] 加载项目列表失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message || '打开目录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 样式变量 ──
|
||||||
|
const bg = isDark ? '#0F0D21' : '#F5F3FF';
|
||||||
|
const borderColor = isDark ? '#2D2A4A' : '#E5E0F0';
|
||||||
|
const sectionBg = isDark ? '#15132B' : '#EBE8F5';
|
||||||
|
const textColor = isDark ? '#C8C4E0' : '#4A4570';
|
||||||
|
const dimColor = isDark ? '#6E6B90' : '#9CA3AF';
|
||||||
|
const activeBg = isDark ? '#2D2A5A' : '#E8E4FF';
|
||||||
|
const activeColor = isDark ? '#C4B5FD' : '#6D28D9';
|
||||||
|
const hoverBg = isDark ? '#1E1B3B' : '#EDEAFA';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
background: bg,
|
||||||
|
borderRight: `1px solid ${borderColor}`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── 工作区选择 ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderBottom: `1px solid ${borderColor}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
icon={<FolderOpenOutlined />}
|
||||||
|
onClick={() => void handleOpenWorkspace()}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? '#C4B5FD' : '#4338CA',
|
||||||
|
borderColor: isDark ? '#3E3A6A' : '#C7D2FE',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workspaceRoot ? '切换工作区' : '打开工作区'}
|
||||||
|
</Button>
|
||||||
|
{workspaceRoot && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
color: dimColor,
|
||||||
|
textAlign: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={workspaceRoot}
|
||||||
|
>
|
||||||
|
{workspaceRoot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 导航选择器 ── */}
|
||||||
|
{workspaceRoot && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 8px 4px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: dimColor, marginBottom: -2 }}>
|
||||||
|
项目
|
||||||
|
</div>
|
||||||
|
<ProjectSelector />
|
||||||
|
|
||||||
|
{!isAssets && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: dimColor, marginBottom: -2, marginTop: 4 }}>
|
||||||
|
集数
|
||||||
|
</div>
|
||||||
|
<EpisodeSelector />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 镜头搜索 ── */}
|
||||||
|
{workspaceRoot && selectedEpisode && !isAssets && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 8px 4px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined style={{ fontSize: 13, color: dimColor }} />}
|
||||||
|
placeholder="搜索镜头号…"
|
||||||
|
value={shotSearchQuery}
|
||||||
|
onChange={(e) => setShotSearchQuery(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: sectionBg,
|
||||||
|
borderColor: isDark ? '#3E3A6A' : '#D1D5DB',
|
||||||
|
color: textColor,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 镜头列表 ── */}
|
||||||
|
{/* minHeight: 0 是 flexbox 关键修复:防止列表内容撑开容器、导致底部按钮被推出视口 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
minHeight: 0,
|
||||||
|
padding: '2px 0',
|
||||||
|
// 自定义滚动条样式
|
||||||
|
scrollbarWidth: 'thin' as const,
|
||||||
|
scrollbarColor: isDark
|
||||||
|
? '#3E3A6A transparent'
|
||||||
|
: '#C7D2FE transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAssets ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
color: dimColor,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
资产阶段无需选择镜头
|
||||||
|
</div>
|
||||||
|
) : !selectedEpisode ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
color: dimColor,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
请先选择集数
|
||||||
|
</div>
|
||||||
|
) : filteredShots.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
color: dimColor,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shotSearchQuery.trim() ? '无匹配镜头' : '该集数下无镜头'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 镜头计数 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: dimColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
镜头列表
|
||||||
|
<span style={{ fontWeight: 400, marginLeft: 4 }}>
|
||||||
|
({filteredShots.length}{shotSearchQuery.trim() ? ` / ${shots.length}` : ''})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{filteredShots.map((shot) => {
|
||||||
|
const active = shot === selectedShot;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={shot}
|
||||||
|
onClick={() => handleShotClick(shot)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
color: active ? activeColor : textColor,
|
||||||
|
background: active ? activeBg : 'transparent',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
userSelect: 'none',
|
||||||
|
borderLeft: active
|
||||||
|
? `3px solid ${activeColor}`
|
||||||
|
: '3px solid transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!active)
|
||||||
|
(e.currentTarget as HTMLElement).style.background = hoverBg;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!active)
|
||||||
|
(e.currentTarget as HTMLElement).style.background =
|
||||||
|
'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shot}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 刷新按钮(固定在底部) ── */}
|
||||||
|
{workspaceRoot && selectedEpisode && !isAssets && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
borderTop: `1px solid ${borderColor}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: isDark ? '#0F0D1E' : '#EDE9F6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
icon={<ReloadOutlined spin={isRefreshing} />}
|
||||||
|
onClick={() => void handleRefreshShots()}
|
||||||
|
loading={isRefreshing}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${isDark ? '#3E3A6A' : '#C7D2FE'}`,
|
||||||
|
background: isDark
|
||||||
|
? 'rgba(62, 58, 106, 0.4)'
|
||||||
|
: 'rgba(199, 210, 254, 0.3)',
|
||||||
|
color: isDark ? '#C4B5FD' : '#4338CA',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新镜头列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ export function EpisodeSelector() {
|
|||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
placeholder="选择集数"
|
placeholder="选择集数"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 120 }}
|
style={{ width: '100%' }}
|
||||||
options={options}
|
options={options}
|
||||||
disabled={!selectedProject}
|
disabled={!selectedProject}
|
||||||
showSearch
|
showSearch
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function ProjectSelector() {
|
|||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
placeholder="选择项目"
|
placeholder="选择项目"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 140 }}
|
style={{ width: '100%' }}
|
||||||
options={options}
|
options={options}
|
||||||
disabled={!workspaceRoot}
|
disabled={!workspaceRoot}
|
||||||
showSearch
|
showSearch
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
export { ProjectSelector } from './ProjectSelector';
|
export { ProjectSelector } from './ProjectSelector';
|
||||||
export { EpisodeSelector } from './EpisodeSelector';
|
export { EpisodeSelector } from './EpisodeSelector';
|
||||||
export { ShotList } from './ShotList';
|
export { ShotList } from './ShotList';
|
||||||
|
export { CanvasSidebar } from './CanvasSidebar';
|
||||||
export { ContextMenu } from './ContextMenu';
|
export { ContextMenu } from './ContextMenu';
|
||||||
export type { ContextMenuItem } from './ContextMenu';
|
export type { ContextMenuItem } from './ContextMenu';
|
||||||
export { DirectoryCreateModal } from './DirectoryCreateModal';
|
export { DirectoryCreateModal } from './DirectoryCreateModal';
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export interface CanvasState {
|
|||||||
tagQuery: string;
|
tagQuery: string;
|
||||||
auditFilter: AuditStatus | 'all';
|
auditFilter: AuditStatus | 'all';
|
||||||
assetTypeFilter: AssetTypeFilter;
|
assetTypeFilter: AssetTypeFilter;
|
||||||
|
/** 仅显示收藏条目(metadata.favorite === true) */
|
||||||
|
favoriteOnly: boolean;
|
||||||
|
|
||||||
// ── Layout ──
|
// ── Layout ──
|
||||||
layoutMode: LayoutMode;
|
layoutMode: LayoutMode;
|
||||||
@@ -114,7 +116,7 @@ export interface CanvasState {
|
|||||||
filters: Partial<
|
filters: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
CanvasState,
|
CanvasState,
|
||||||
'keywordQuery' | 'tagQuery' | 'auditFilter' | 'assetTypeFilter'
|
'keywordQuery' | 'tagQuery' | 'auditFilter' | 'assetTypeFilter' | 'favoriteOnly'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -159,6 +161,7 @@ function createCanvasStoreLogic(dataSource: ICanvasDataSource, initialWorkspace:
|
|||||||
tagQuery: '',
|
tagQuery: '',
|
||||||
auditFilter: 'all',
|
auditFilter: 'all',
|
||||||
assetTypeFilter: '全部',
|
assetTypeFilter: '全部',
|
||||||
|
favoriteOnly: false,
|
||||||
|
|
||||||
layoutMode: 'grid',
|
layoutMode: 'grid',
|
||||||
cols: 9,
|
cols: 9,
|
||||||
|
|||||||
@@ -1,41 +1,22 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// CanvasFilterBar — 画布筛选栏
|
// CanvasFilterBar — 关键词搜索栏(精简版)
|
||||||
//
|
//
|
||||||
// 提供:关键词搜索、标签筛选、审核状态筛选、镜头查询、资产类型筛选
|
// 仅保留文件名/描述关键词搜索。
|
||||||
// 每个筛选条件变化后触发 useDirectoryScan(防抖 300ms)
|
// 资产类型筛选 → 已移至 CategoryTabs(Assets 阶段)。
|
||||||
|
// 审核状态筛选 → 已移至 CategoryTabs(非 Assets 阶段)。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Input, Select } from 'antd';
|
import { Input } from 'antd';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { useCanvasStore, type AuditStatus, type AssetTypeFilter } from '../store/useCanvasStore';
|
import { useCanvasStore } from '../store/useCanvasStore';
|
||||||
|
|
||||||
const AUDIT_OPTIONS: Array<{ value: AuditStatus | 'all'; label: string }> = [
|
|
||||||
{ value: 'all', label: '全部审核' },
|
|
||||||
{ value: 'pending', label: '待审核' },
|
|
||||||
{ value: 'approved', label: '已通过' },
|
|
||||||
{ value: 'rework', label: '需返工' },
|
|
||||||
{ value: 'deprecated', label: '已废弃' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ASSET_TYPE_OPTIONS: Array<{ value: AssetTypeFilter; label: string }> = [
|
|
||||||
{ value: '全部', label: '全部资产' },
|
|
||||||
{ value: '角色', label: '角色' },
|
|
||||||
{ value: '道具', label: '道具' },
|
|
||||||
{ value: '场景', label: '场景' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CanvasFilterBar() {
|
export function CanvasFilterBar() {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const keywordQuery = useCanvasStore((s) => s.keywordQuery);
|
const keywordQuery = useCanvasStore((s) => s.keywordQuery);
|
||||||
const auditFilter = useCanvasStore((s) => s.auditFilter);
|
|
||||||
const assetTypeFilter = useCanvasStore((s) => s.assetTypeFilter);
|
|
||||||
const currentStage = useCanvasStore((s) => s.currentStage);
|
|
||||||
const setFilters = useCanvasStore((s) => s.setFilters);
|
const setFilters = useCanvasStore((s) => s.setFilters);
|
||||||
|
|
||||||
const isAssets = currentStage === 'Assets';
|
|
||||||
|
|
||||||
const handleKeywordChange = useCallback(
|
const handleKeywordChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFilters({ keywordQuery: e.target.value });
|
setFilters({ keywordQuery: e.target.value });
|
||||||
@@ -51,39 +32,14 @@ export function CanvasFilterBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1 }}>
|
<Input
|
||||||
{/* 关键词搜索 */}
|
placeholder="搜索文件名/描述…"
|
||||||
<Input
|
prefix={<SearchOutlined />}
|
||||||
placeholder="搜索文件名/描述…"
|
value={keywordQuery}
|
||||||
prefix={<SearchOutlined />}
|
onChange={handleKeywordChange}
|
||||||
value={keywordQuery}
|
allowClear
|
||||||
onChange={handleKeywordChange}
|
size="small"
|
||||||
allowClear
|
style={{ width: 180, ...inputStyle }}
|
||||||
size="small"
|
/>
|
||||||
style={{ width: 180, ...inputStyle }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 审核状态筛选(仅可审核阶段) */}
|
|
||||||
{!isAssets && (
|
|
||||||
<Select
|
|
||||||
value={auditFilter}
|
|
||||||
onChange={(v) => setFilters({ auditFilter: v })}
|
|
||||||
size="small"
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={AUDIT_OPTIONS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 资产类型筛选(仅 Assets 阶段) */}
|
|
||||||
{isAssets && (
|
|
||||||
<Select
|
|
||||||
value={assetTypeFilter}
|
|
||||||
onChange={(v) => setFilters({ assetTypeFilter: v })}
|
|
||||||
size="small"
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={ASSET_TYPE_OPTIONS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,25 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// CanvasToolbar — 顶部工具栏包装器
|
// CanvasToolbar — 画布内容区顶部工具栏(简化版)
|
||||||
//
|
//
|
||||||
// 包含:工作区选择 + 项目选择 + 集数选择 + StageTabs + FilterBar
|
// 包含两行:
|
||||||
|
// 第一行:StageTabs(阶段标签 + 无限画布按钮)
|
||||||
|
// 第二行:CategoryTabs(资产分类标签) + CanvasFilterBar(搜索 + 审核)
|
||||||
|
//
|
||||||
|
// 注:项目/集数选择器已移至 CanvasSidebar。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { Button, message } from 'antd';
|
|
||||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { useCanvasStore } from '../store/useCanvasStore';
|
import { useCanvasStore } from '../store/useCanvasStore';
|
||||||
import { StageTabs } from './StageTabs';
|
import { StageTabs } from './StageTabs';
|
||||||
|
import { CategoryTabs } from './CategoryTabs';
|
||||||
import { CanvasFilterBar } from './CanvasFilterBar';
|
import { CanvasFilterBar } from './CanvasFilterBar';
|
||||||
import { ProjectSelector } from '../navigation/ProjectSelector';
|
|
||||||
import { EpisodeSelector } from '../navigation/EpisodeSelector';
|
|
||||||
|
|
||||||
export function CanvasToolbar() {
|
export function CanvasToolbar() {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
|
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
|
||||||
const setWorkspace = useCanvasStore((s) => s.setWorkspace);
|
|
||||||
const setProjects = useCanvasStore((s) => s.setProjects);
|
|
||||||
const getDataSource = useCanvasStore((s) => s.getDataSource);
|
|
||||||
const isScanning = useCanvasStore((s) => s.isScanning);
|
const isScanning = useCanvasStore((s) => s.isScanning);
|
||||||
const totalCount = useCanvasStore((s) => s.totalCount);
|
const totalCount = useCanvasStore((s) => s.totalCount);
|
||||||
|
|
||||||
const handleOpenWorkspace = async () => {
|
|
||||||
try {
|
|
||||||
const dataSource = getDataSource();
|
|
||||||
const dir = await dataSource.openDirectoryDialog();
|
|
||||||
if (dir) {
|
|
||||||
setWorkspace(dir);
|
|
||||||
// 异步加载项目列表
|
|
||||||
try {
|
|
||||||
const projects = await dataSource.listSubdirs(dir);
|
|
||||||
setProjects(projects);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[CanvasToolbar] 加载项目列表失败:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
void message.error((err as Error).message || '打开目录失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const barBg = isDark
|
const barBg = isDark
|
||||||
? 'linear-gradient(180deg, #1E1B4B 0%, #15132E 100%)'
|
? 'linear-gradient(180deg, #1E1B4B 0%, #15132E 100%)'
|
||||||
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)';
|
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)';
|
||||||
@@ -55,41 +34,23 @@ export function CanvasToolbar() {
|
|||||||
background: barBg,
|
background: barBg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 第一行:工作区 + 项目选择器 + 集数选择器 + 阶段标签 */}
|
{/* 第一行:阶段标签 + 无限画布 + 扫描状态 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
gap: 8,
|
height: 36,
|
||||||
height: 40,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<StageTabs />
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<FolderOpenOutlined />}
|
|
||||||
onClick={() => void handleOpenWorkspace()}
|
|
||||||
style={{
|
|
||||||
color: isDark ? '#C4B5FD' : '#4338CA',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{workspaceRoot ? '切换' : '打开'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{workspaceRoot && <ProjectSelector />}
|
|
||||||
{workspaceRoot && <EpisodeSelector />}
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
<StageTabs />
|
|
||||||
|
|
||||||
{isScanning && (
|
{isScanning && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
color: isDark ? '#C4B5FD' : '#6366F1',
|
color: isDark ? '#C4B5FD' : '#6366F1',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
@@ -101,9 +62,10 @@ export function CanvasToolbar() {
|
|||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{totalCount} 个文件
|
{totalCount} 个文件
|
||||||
@@ -111,7 +73,7 @@ export function CanvasToolbar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第二行:筛选栏 */}
|
{/* 第二行:分类标签 + 筛选栏 */}
|
||||||
{workspaceRoot && (
|
{workspaceRoot && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -119,8 +81,11 @@ export function CanvasToolbar() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '4px 12px 6px',
|
padding: '4px 12px 6px',
|
||||||
borderTop: `1px solid ${borderColor}`,
|
borderTop: `1px solid ${borderColor}`,
|
||||||
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<CategoryTabs />
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
<CanvasFilterBar />
|
<CanvasFilterBar />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
253
src/pages/infinite-canvas/toolbar/CategoryTabs.tsx
Normal file
253
src/pages/infinite-canvas/toolbar/CategoryTabs.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// ============================================================
|
||||||
|
// CategoryTabs — 内容分类标签栏(阶段感知 + 数量徽标)
|
||||||
|
//
|
||||||
|
// Assets 阶段 → 资产分类:
|
||||||
|
// 全部 | 角色(chr) | 道具(prp) | 场景(set) | 收藏
|
||||||
|
// → 映射到 store.assetTypeFilter + favoriteOnly
|
||||||
|
//
|
||||||
|
// 其他阶段 → 审核分类:
|
||||||
|
// 所有[阶段名] | 收藏 | 待审核 | 通过 | 返修 | 已弃用
|
||||||
|
// → 映射到 store.auditFilter + favoriteOnly
|
||||||
|
//
|
||||||
|
// 每个标签显示对应数据的数量徽标(基于当前已加载 items 计算)。
|
||||||
|
// "收藏"标签额外显示媒体类型细分(视频/图片/音频)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
import { useCanvasStore, type AssetTypeFilter, type AuditStatus } from '../store/useCanvasStore';
|
||||||
|
|
||||||
|
interface CategoryTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具:根据扩展名判断媒体类型 ──
|
||||||
|
|
||||||
|
type MediaType = 'video' | 'image' | 'audio' | 'other';
|
||||||
|
|
||||||
|
const VIDEO_EXTS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv']);
|
||||||
|
const AUDIO_EXTS = new Set(['mp3', 'wav', 'aac', 'flac', 'ogg', 'wma', 'm4a']);
|
||||||
|
const IMAGE_EXTS = new Set([
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff', 'tga', 'exr', 'psd',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getMediaType(ext: string): MediaType {
|
||||||
|
const lower = ext.toLowerCase();
|
||||||
|
if (VIDEO_EXTS.has(lower)) return 'video';
|
||||||
|
if (IMAGE_EXTS.has(lower)) return 'image';
|
||||||
|
if (AUDIO_EXTS.has(lower)) return 'audio';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具:根据目录路径推断资产类型 ──
|
||||||
|
|
||||||
|
function inferAssetType(path: string): AssetTypeFilter {
|
||||||
|
const lower = path.toLowerCase();
|
||||||
|
// 路径中包含 \chr\ → 角色,\prp\ → 道具,\set\ → 场景
|
||||||
|
if (lower.includes('\\chr\\') || lower.includes('/chr/')) return '角色';
|
||||||
|
if (lower.includes('\\prp\\') || lower.includes('/prp/')) return '道具';
|
||||||
|
if (lower.includes('\\set\\') || lower.includes('/set/')) return '场景';
|
||||||
|
return '全部';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryTabs() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const currentStage = useCanvasStore((s) => s.currentStage);
|
||||||
|
const assetTypeFilter = useCanvasStore((s) => s.assetTypeFilter);
|
||||||
|
const auditFilter = useCanvasStore((s) => s.auditFilter);
|
||||||
|
const favoriteOnly = useCanvasStore((s) => s.favoriteOnly);
|
||||||
|
const items = useCanvasStore((s) => s.items);
|
||||||
|
const setFilters = useCanvasStore((s) => s.setFilters);
|
||||||
|
|
||||||
|
const isAssets = currentStage === 'Assets';
|
||||||
|
|
||||||
|
// 阶段中文名映射
|
||||||
|
const stageLabel: Record<string, string> = {
|
||||||
|
Assets: '资产',
|
||||||
|
Storyboard: '分镜',
|
||||||
|
Keyframe: '关键帧',
|
||||||
|
Video: '视频',
|
||||||
|
Audio: '音频',
|
||||||
|
LipSync: '口型',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 分类数量统计 ──
|
||||||
|
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
let favoriteMediaCounts: Record<MediaType, number> = { video: 0, image: 0, audio: 0, other: 0 };
|
||||||
|
|
||||||
|
if (isAssets) {
|
||||||
|
// Assets 阶段:按 assetType (chr/prp/set) 分类统计
|
||||||
|
counts['全部'] = items.length;
|
||||||
|
counts['角色'] = 0;
|
||||||
|
counts['道具'] = 0;
|
||||||
|
counts['场景'] = 0;
|
||||||
|
counts['收藏'] = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const at = inferAssetType(item.path);
|
||||||
|
if (at === '角色') counts['角色']++;
|
||||||
|
else if (at === '道具') counts['道具']++;
|
||||||
|
else if (at === '场景') counts['场景']++;
|
||||||
|
if (item.metadata?.favorite) {
|
||||||
|
counts['收藏']++;
|
||||||
|
favoriteMediaCounts[getMediaType(item.ext)]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非 Assets 阶段:按审核状态 + 收藏统计
|
||||||
|
counts['all'] = items.length;
|
||||||
|
counts['pending'] = 0;
|
||||||
|
counts['approved'] = 0;
|
||||||
|
counts['rework'] = 0;
|
||||||
|
counts['deprecated'] = 0;
|
||||||
|
counts['收藏'] = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const status = item.metadata?.audit_status;
|
||||||
|
if (status) counts[status] = (counts[status] || 0) + 1;
|
||||||
|
if (item.metadata?.favorite) {
|
||||||
|
counts['收藏']++;
|
||||||
|
favoriteMediaCounts[getMediaType(item.ext)]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { counts, favoriteMediaCounts };
|
||||||
|
}, [items, isAssets]);
|
||||||
|
|
||||||
|
// ── 根据阶段决定标签组 ──
|
||||||
|
|
||||||
|
const tabs: CategoryTab[] = useMemo(() => {
|
||||||
|
if (isAssets) {
|
||||||
|
return [
|
||||||
|
{ key: '全部', label: '全部' },
|
||||||
|
{ key: '角色', label: '角色' },
|
||||||
|
{ key: '道具', label: '道具' },
|
||||||
|
{ key: '场景', label: '场景' },
|
||||||
|
{ key: '收藏', label: '⭐ 收藏' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const label = stageLabel[currentStage] || currentStage;
|
||||||
|
return [
|
||||||
|
{ key: 'all', label: `所有${label}` },
|
||||||
|
{ key: '收藏', label: '⭐ 收藏' },
|
||||||
|
{ key: 'pending', label: '待审核' },
|
||||||
|
{ key: 'approved', label: '通过' },
|
||||||
|
{ key: 'rework', label: '返修' },
|
||||||
|
{ key: 'deprecated', label: '已弃用' },
|
||||||
|
];
|
||||||
|
}, [isAssets, currentStage]);
|
||||||
|
|
||||||
|
// ── 点击处理 ──
|
||||||
|
|
||||||
|
const handleTabClick = (key: string) => {
|
||||||
|
if (isAssets) {
|
||||||
|
if (key === '收藏') {
|
||||||
|
setFilters({ favoriteOnly: !favoriteOnly, assetTypeFilter: '全部' });
|
||||||
|
} else {
|
||||||
|
setFilters({ assetTypeFilter: key as AssetTypeFilter, favoriteOnly: false });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (key === '收藏') {
|
||||||
|
setFilters({ favoriteOnly: !favoriteOnly, auditFilter: 'all' });
|
||||||
|
} else {
|
||||||
|
setFilters({ auditFilter: key as AuditStatus | 'all', favoriteOnly: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 判断标签是否激活 ──
|
||||||
|
|
||||||
|
const isTabActive = (tab: CategoryTab): boolean => {
|
||||||
|
if (tab.key === '收藏') return favoriteOnly;
|
||||||
|
if (isAssets) return !favoriteOnly && tab.key === assetTypeFilter;
|
||||||
|
return !favoriteOnly && tab.key === auditFilter;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 收藏媒体细分提示 ──
|
||||||
|
|
||||||
|
const buildFavoriteTooltip = () => {
|
||||||
|
const { favoriteMediaCounts } = categoryCounts;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (favoriteMediaCounts.video > 0) parts.push(`视频:${favoriteMediaCounts.video}`);
|
||||||
|
if (favoriteMediaCounts.image > 0) parts.push(`图片:${favoriteMediaCounts.image}`);
|
||||||
|
if (favoriteMediaCounts.audio > 0) parts.push(`音频:${favoriteMediaCounts.audio}`);
|
||||||
|
if (favoriteMediaCounts.other > 0) parts.push(`其他:${favoriteMediaCounts.other}`);
|
||||||
|
return parts.length > 0 ? ` ${parts.join(' / ')}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeBg = isDark ? '#3E3A6A' : '#E0E7FF';
|
||||||
|
const activeColor = isDark ? '#C4B5FD' : '#4338CA';
|
||||||
|
const inactiveColor = isDark ? '#6E6B90' : '#9CA3AF';
|
||||||
|
const badgeBg = isDark ? 'rgba(110, 107, 144, 0.5)' : 'rgba(156, 163, 175, 0.3)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0,
|
||||||
|
padding: '2px 4px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isDark ? 'rgba(21, 19, 46, 0.8)' : 'rgba(245, 243, 255, 0.8)',
|
||||||
|
border: `1px solid ${isDark ? '#2E2A5A' : '#E5E7EB'}`,
|
||||||
|
overflowX: 'auto',
|
||||||
|
scrollbarWidth: 'none' as const,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = isTabActive(tab);
|
||||||
|
const count = categoryCounts.counts[tab.key] ?? 0;
|
||||||
|
const tooltip = tab.key === '收藏' ? `${count} 个收藏${buildFavoriteTooltip()}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabClick(tab.key)}
|
||||||
|
title={tooltip ?? undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
|
padding: '3px 10px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
background: isActive ? activeBg : 'transparent',
|
||||||
|
color: isActive ? activeColor : inactiveColor,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: 18,
|
||||||
|
height: 17,
|
||||||
|
padding: '0 4px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: badgeBg,
|
||||||
|
color: isActive ? activeColor : inactiveColor,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// StageTabs — 阶段切换标签栏
|
// StageTabs — 阶段切换标签栏 + 无限画布按钮
|
||||||
//
|
//
|
||||||
// 六个阶段:Storyboard / Keyframe / Video / Audio / LipSync / Assets
|
// 六个阶段标签:Assets / Storyboard / Keyframe / Video / Audio / LipSync
|
||||||
// 点击切换 store.currentStage,触发 useDirectoryScan 重新扫描
|
// 点击切换 store.currentStage,触发 useDirectoryScan 重新扫描。
|
||||||
|
//
|
||||||
|
// "无限画布"按钮:通过 IPC 打开独立的画布 BrowserWindow,
|
||||||
|
// 关闭其他页面不影响该窗口。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { ExpandOutlined } from '@ant-design/icons';
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { useCanvasStore, type StageType } from '../store/useCanvasStore';
|
import { useCanvasStore, type StageType } from '../store/useCanvasStore';
|
||||||
import { STAGE_CONFIGS } from '../utils/constants';
|
import { STAGE_CONFIGS } from '../utils/constants';
|
||||||
@@ -13,6 +19,17 @@ export function StageTabs() {
|
|||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const currentStage = useCanvasStore((s) => s.currentStage);
|
const currentStage = useCanvasStore((s) => s.currentStage);
|
||||||
const setStage = useCanvasStore((s) => s.setStage);
|
const setStage = useCanvasStore((s) => s.setStage);
|
||||||
|
const getProjectPath = useCanvasStore((s) => s.getProjectPath);
|
||||||
|
|
||||||
|
/** 打开独立的无限画布窗口 */
|
||||||
|
const handleOpenCanvasWindow = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const projectPath = getProjectPath();
|
||||||
|
window.canvas?.openWindow(projectPath ?? undefined);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[StageTabs] 打开无限画布窗口失败:', err);
|
||||||
|
}
|
||||||
|
}, [getProjectPath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
@@ -29,7 +46,7 @@ export function StageTabs() {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
background: isActive
|
background: isActive
|
||||||
? isDark
|
? isDark
|
||||||
@@ -52,6 +69,38 @@ export function StageTabs() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 18,
|
||||||
|
background: isDark ? '#3E3A6A' : '#D1D5DB',
|
||||||
|
margin: '0 4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 无限画布按钮 */}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ExpandOutlined />}
|
||||||
|
onClick={() => void handleOpenCanvasWindow()}
|
||||||
|
title="在新窗口中打开无限画布(关闭其他页面不影响此窗口)"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${isDark ? '#3E3A6A' : '#C7D2FE'}`,
|
||||||
|
background: isDark
|
||||||
|
? 'rgba(62, 58, 106, 0.4)'
|
||||||
|
: 'rgba(224, 231, 255, 0.6)',
|
||||||
|
color: isDark ? '#C4B5FD' : '#4338CA',
|
||||||
|
padding: '2px 10px',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
无限画布
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
export { CanvasToolbar } from './CanvasToolbar';
|
export { CanvasToolbar } from './CanvasToolbar';
|
||||||
export { CanvasFilterBar } from './CanvasFilterBar';
|
export { CanvasFilterBar } from './CanvasFilterBar';
|
||||||
export { StageTabs } from './StageTabs';
|
export { StageTabs } from './StageTabs';
|
||||||
|
export { CategoryTabs } from './CategoryTabs';
|
||||||
|
|||||||
Reference in New Issue
Block a user