feat: 工作空间文件预览改为侧边栏内嵌面板

- 将文件预览从模态弹窗改为侧边栏内嵌面板,不打断用户操作流程
- 支持双拖动调整:外部调整整体宽度(400-800px),内部调整文件树宽度(180-300px)
- 左右分栏布局:左侧文件树 + 右侧预览面板
- 对话区域保留最小宽度 480px,保证可用性
- 新建 FilePreviewPanel 组件,删除 FilePreviewModal 组件
- 更新相关样式和规格文档
This commit is contained in:
2026-04-17 19:34:29 +08:00
parent 5f333b116a
commit ff4217c72a
8 changed files with 548 additions and 208 deletions

View File

@@ -1,154 +0,0 @@
import { FiX, FiDownload } from 'react-icons/fi';
import api from '../../services/api.js';
/**
* 文件预览弹窗组件
* 根据文件类型显示不同的预览内容
*/
function FilePreviewModal({ file, onClose }) {
if (!file) return null;
const icon = api.workspace.getFileIcon(file.fileType);
// 获取文件类型描述
const getTypeDesc = (fileType) => {
const typeMap = {
text: '文本文件',
markdown: 'Markdown 文件',
json: 'JSON 文件',
javascript: 'JavaScript 文件',
python: 'Python 文件',
css: 'CSS 文件',
html: 'HTML 文件',
word: 'Word 文档',
excel: 'Excel 文档',
powerpoint: 'PowerPoint 文档',
image: '图片文件',
video: '视频文件',
audio: '音频文件',
pdf: 'PDF 文件',
};
return typeMap[fileType] || '文件';
};
// 渲染文件信息
const renderFileInfo = () => (
<div className="file-preview-modal__info">
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">类型</span>
<span className="file-preview-modal__info-value">{getTypeDesc(file.fileType)}</span>
</div>
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">大小</span>
<span className="file-preview-modal__info-value">{file.size}</span>
</div>
{file.dimensions && (
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">尺寸</span>
<span className="file-preview-modal__info-value">{file.dimensions}</span>
</div>
)}
{file.duration && (
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">时长</span>
<span className="file-preview-modal__info-value">{file.duration}</span>
</div>
)}
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">修改</span>
<span className="file-preview-modal__info-value">{file.modifiedAt}</span>
</div>
</div>
);
// 渲染预览内容
const renderPreview = () => {
// 文本类型:显示内容
if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
return (
<div className="file-preview-modal__content">
<pre className="file-preview-modal__code">{file.content || '(无内容)'}</pre>
</div>
);
}
// Office 类型:显示占位符
if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">
点击下载查看完整内容
</div>
</div>
);
}
// 图片类型:显示占位符
if (file.fileType === 'image') {
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">
图片预览区域
</div>
</div>
);
}
// 视频/音频类型:显示占位符
if (['video', 'audio'].includes(file.fileType)) {
const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">{text}</div>
</div>
);
}
// 其他类型
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">
预览不可用
</div>
</div>
);
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">{file.name}</div>
<div className="modal-close" onClick={onClose}>
<FiX size={18} />
</div>
</div>
<div className="modal-body">
{renderFileInfo()}
{renderPreview()}
</div>
<div className="modal-footer">
<button
className="btn"
onClick={() => {
alert('文件下载中...');
onClose();
}}
>
<FiDownload />
下载
</button>
<button className="btn btn--primary" onClick={onClose}>
关闭
</button>
</div>
</div>
</div>
);
}
export default FilePreviewModal;

View File

@@ -0,0 +1,156 @@
import { FiX, FiDownload } from 'react-icons/fi';
import api from '../../services/api.js';
/**
* 文件预览面板组件
* 显示在侧边栏右侧,不打断用户操作
*/
function FilePreviewPanel({ file, onClose }) {
if (!file) return null;
const icon = api.workspace.getFileIcon(file.fileType);
// 获取文件类型描述
const getTypeDesc = (fileType) => {
const typeMap = {
text: '文本文件',
markdown: 'Markdown 文件',
json: 'JSON 文件',
javascript: 'JavaScript 文件',
python: 'Python 文件',
css: 'CSS 文件',
html: 'HTML 文件',
word: 'Word 文档',
excel: 'Excel 文档',
powerpoint: 'PowerPoint 文档',
image: '图片文件',
video: '视频文件',
audio: '音频文件',
pdf: 'PDF 文件',
};
return typeMap[fileType] || '文件';
};
// 渲染文件信息
const renderFileInfo = () => (
<div className="file-preview-panel__info">
<div className="file-preview-panel__info-item">
<span className="label">类型</span>
<span className="value">{getTypeDesc(file.fileType)}</span>
</div>
<div className="file-preview-panel__info-item">
<span className="label">大小</span>
<span className="value">{file.size}</span>
</div>
{file.dimensions && (
<div className="file-preview-panel__info-item">
<span className="label">尺寸</span>
<span className="value">{file.dimensions}</span>
</div>
)}
{file.duration && (
<div className="file-preview-panel__info-item">
<span className="label">时长</span>
<span className="value">{file.duration}</span>
</div>
)}
<div className="file-preview-panel__info-item">
<span className="label">修改</span>
<span className="value">{file.modifiedAt}</span>
</div>
</div>
);
// 渲染预览内容
const renderPreview = () => {
// 文本类型:显示内容
if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
return (
<div className="file-preview-panel__content">
<pre className="file-preview-panel__code">{file.content || '(无内容)'}</pre>
</div>
);
}
// Office 类型:显示占位符
if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
return (
<div className="file-preview-panel__placeholder">
<div className="file-preview-panel__placeholder-icon">{icon}</div>
<div className="file-preview-panel__placeholder-text">
点击下载查看完整内容
</div>
</div>
);
}
// 图片类型:显示占位符
if (file.fileType === 'image') {
return (
<div className="file-preview-panel__placeholder">
<div className="file-preview-panel__placeholder-icon">{icon}</div>
<div className="file-preview-panel__placeholder-text">
图片预览区域
</div>
</div>
);
}
// 视频/音频类型:显示占位符
if (['video', 'audio'].includes(file.fileType)) {
const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
return (
<div className="file-preview-panel__placeholder">
<div className="file-preview-panel__placeholder-icon">{icon}</div>
<div className="file-preview-panel__placeholder-text">{text}</div>
</div>
);
}
// 其他类型
return (
<div className="file-preview-panel__placeholder">
<div className="file-preview-panel__placeholder-icon">{icon}</div>
<div className="file-preview-panel__placeholder-text">
预览不可用
</div>
</div>
);
};
// 下载操作
const handleDownload = () => {
alert('文件下载中...');
};
return (
<div className="file-preview-panel">
{/* Header - 文件名 + 关闭按钮 */}
<div className="file-preview-panel__header">
<div className="file-preview-panel__icon">{icon}</div>
<div className="file-preview-panel__name">{file.name}</div>
<div className="file-preview-panel__close" onClick={onClose}>
<FiX size={16} />
</div>
</div>
{/* Info - 文件信息 */}
{renderFileInfo()}
{/* Content - 文件内容预览 */}
<div className="file-preview-panel__content-wrapper">
{renderPreview()}
</div>
{/* Actions - 操作按钮 */}
<div className="file-preview-panel__actions">
<button className="btn" onClick={handleDownload}>
<FiDownload />
下载
</button>
</div>
</div>
);
}
export default FilePreviewPanel;

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { FiX, FiFolder, FiRefreshCw, FiPlus, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
import FileTree from './FileTree.jsx';
import FileContextMenu from './FileContextMenu.jsx';
import FilePreviewModal from './FilePreviewModal.jsx';
import FilePreviewPanel from './FilePreviewPanel.jsx';
import RenameModal from './RenameModal.jsx';
import CreateModal from './CreateModal.jsx';
import FileDetailModal from './FileDetailModal.jsx';
@@ -12,7 +12,7 @@ import api from '../../services/api.js';
/**
* 工作空间侧边栏组件
* 管理展开/关闭状态,集成文件树、右键菜单和预览弹窗
* 支持双拖动调整:整体宽度和文件树宽度
*/
function WorkspaceSidebar({ isOpen, onClose }) {
const [contextMenu, setContextMenu] = useState(null); // { file, position }
@@ -26,19 +26,24 @@ function WorkspaceSidebar({ isOpen, onClose }) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [toast, setToast] = useState(null); // { type, message }
const [width, setWidth] = useState(280); // 默认宽度 280px
// 宽度状态
const [sidebarWidth, setSidebarWidth] = useState(500); // 侧边栏整体宽度
const [fileTreeWidth, setFileTreeWidth] = useState(200); // 文件树宽度
// Refs
const sidebarRef = useRef(null);
const isResizingRef = useRef(false);
const isResizingSidebarRef = useRef(false); // 是否正在调整侧边栏宽度
const isResizingTreeRef = useRef(false); // 是否正在调整文件树宽度
const lastXRef = useRef(0);
const dropdownRef = useRef(null);
// 拖动调整宽度 - 使用 requestAnimationFrame 优化
const handleMouseDown = (e) => {
// ========== 拖动调整:侧边栏整体宽度 ==========
const handleSidebarMouseDown = (e) => {
e.preventDefault();
isResizingRef.current = true;
isResizingSidebarRef.current = true;
lastXRef.current = e.clientX;
// 添加拖动时的样式
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
@@ -47,36 +52,56 @@ function WorkspaceSidebar({ isOpen, onClose }) {
}
};
// ========== 拖动调整:文件树宽度 ==========
const handleDividerMouseDown = (e) => {
e.preventDefault();
e.stopPropagation(); // 防止触发侧边栏拖动
isResizingTreeRef.current = true;
lastXRef.current = e.clientX;
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
};
// ========== 统一的鼠标移动和释放处理 ==========
useEffect(() => {
let animationFrameId = null;
const handleMouseMove = (e) => {
if (!isResizingRef.current) return;
if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
// 取消之前的动画帧
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// 使用 requestAnimationFrame 批量更新
animationFrameId = requestAnimationFrame(() => {
const deltaX = lastXRef.current - e.clientX;
const deltaX = e.clientX - lastXRef.current;
lastXRef.current = e.clientX;
setWidth(prevWidth => {
const newWidth = prevWidth + deltaX;
// 限制宽度范围:最小 200px最大 500px
return Math.max(200, Math.min(500, newWidth));
});
// 调整侧边栏宽度(向左拖动减小,向右拖动增加)
if (isResizingSidebarRef.current) {
setSidebarWidth(prev => {
const newWidth = prev - deltaX; // 注意:向左拖动是负数
return Math.max(400, Math.min(800, newWidth));
});
}
// 调整文件树宽度(向右拖动增加,向左拖动减小)
if (isResizingTreeRef.current) {
setFileTreeWidth(prev => {
const newWidth = prev + deltaX;
return Math.max(180, Math.min(300, newWidth));
});
}
});
};
const handleMouseUp = () => {
if (!isResizingRef.current) return;
if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
isResizingRef.current = false;
isResizingSidebarRef.current = false;
isResizingTreeRef.current = false;
// 恢复样式
document.body.style.cursor = '';
document.body.style.userSelect = '';
@@ -289,7 +314,7 @@ function WorkspaceSidebar({ isOpen, onClose }) {
<div
className="workspace-sidebar"
ref={sidebarRef}
style={{ width: `${width}px` }}
style={{ width: `${sidebarWidth}px` }}
>
<div className="workspace-sidebar__header">
<div className="workspace-sidebar__title">工作空间</div>
@@ -337,16 +362,41 @@ function WorkspaceSidebar({ isOpen, onClose }) {
<FiX size={18} />
</div>
</div>
{/* Content - 左右分栏 */}
<div className="workspace-sidebar__content">
<FileTree
onFileClick={handleFileClick}
onMenuClick={handleMenuClick}
{/* FileTree */}
<div
className="workspace-sidebar__file-tree"
style={{ width: `${fileTreeWidth}px` }}
>
<FileTree
onFileClick={handleFileClick}
onMenuClick={handleMenuClick}
/>
</div>
{/* 分隔线(可拖动调整) */}
<div
className="workspace-sidebar__divider"
onMouseDown={handleDividerMouseDown}
/>
{/* Preview Panel */}
{previewFile && (
<div className="workspace-sidebar__preview">
<FilePreviewPanel
file={previewFile}
onClose={() => setPreviewFile(null)}
/>
</div>
)}
</div>
{/* 拖动调整宽度的手柄 */}
{/* 外部拖动手柄(调整侧边栏整体宽度) */}
<div
className="workspace-sidebar__resize-handle"
onMouseDown={handleMouseDown}
onMouseDown={handleSidebarMouseDown}
>
<div className="workspace-sidebar__resize-lines">
<div></div>
@@ -373,14 +423,6 @@ function WorkspaceSidebar({ isOpen, onClose }) {
/>
)}
{/* 文件预览弹窗 */}
{previewFile && (
<FilePreviewModal
file={previewFile}
onClose={() => setPreviewFile(null)}
/>
)}
{/* 删除确认弹窗 */}
{deleteFile && (
<Modal

View File

@@ -4,14 +4,16 @@
// 工作空间侧边栏
.workspace-sidebar {
width: 240px;
width: 500px;
min-width: 400px;
max-width: 800px;
background: var(--color-bg-2);
border-left: 1px solid var(--color-border-2);
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
transition: width 0.3s ease, opacity 0.3s ease;
transition: width 0.3s ease;
position: relative;
&--collapsed {
@@ -119,11 +121,57 @@
}
}
// ========== Content - 左右分栏 ==========
&__content {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
// ========== FileTree ==========
&__file-tree {
min-width: 180px;
max-width: 300px;
flex-shrink: 0;
overflow-y: auto;
padding: 8px;
min-height: 0;
background: var(--color-bg-2);
}
// ========== 分隔线(可拖动) ==========
&__divider {
width: 1px;
background: var(--color-border-2);
flex-shrink: 0;
cursor: ew-resize;
position: relative;
transition: background 0.2s;
// 增加可拖动区域
&::before {
content: '';
position: absolute;
top: 0;
left: -4px;
right: -4px;
bottom: 0;
cursor: ew-resize;
}
&:hover {
background: var(--color-primary);
}
}
// ========== Preview Panel ==========
&__preview {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-1);
}
&__resize-handle {
@@ -366,6 +414,144 @@
}
}
// ========== 文件预览面板 ==========
.file-preview-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
// Header
&__header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-2);
gap: 8px;
flex-shrink: 0;
background: var(--color-bg-2);
}
&__icon {
font-size: 20px;
flex-shrink: 0;
}
&__name {
flex: 1;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-3);
transition: all 0.2s;
flex-shrink: 0;
&:hover {
background: var(--color-bg-1);
color: var(--color-text-1);
}
}
// Info
&__info {
padding: 12px 16px;
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border-2);
flex-shrink: 0;
}
&__info-item {
display: flex;
font-size: 13px;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
.label {
width: 60px;
color: var(--color-text-3);
flex-shrink: 0;
}
.value {
flex: 1;
color: var(--color-text-1);
}
}
// Content Wrapper
&__content-wrapper {
flex: 1;
overflow-y: auto;
min-height: 0;
}
// Content
&__content {
padding: 16px;
}
&__code {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-1);
white-space: pre-wrap;
word-break: break-all;
background: var(--color-bg-2);
padding: 12px;
border-radius: var(--radius-sm);
margin: 0;
}
// Placeholder
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
color: var(--color-text-3);
min-height: 200px;
}
&__placeholder-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
&__placeholder-text {
font-size: 13px;
}
// Actions
&__actions {
padding: 12px 16px;
border-top: 1px solid var(--color-border-2);
display: flex;
gap: 8px;
flex-shrink: 0;
background: var(--color-bg-2);
}
}
// 文件右键菜单
.file-context-menu {
position: absolute;

View File

@@ -245,6 +245,7 @@
background: var(--color-bg-1);
min-height: 0;
overflow: hidden;
min-width: 480px; // 保证对话区域最小宽度
}
.chat-content__messages,