feat: 工作空间文件预览改为侧边栏内嵌面板
- 将文件预览从模态弹窗改为侧边栏内嵌面板,不打断用户操作流程 - 支持双拖动调整:外部调整整体宽度(400-800px),内部调整文件树宽度(180-300px) - 左右分栏布局:左侧文件树 + 右侧预览面板 - 对话区域保留最小宽度 480px,保证可用性 - 新建 FilePreviewPanel 组件,删除 FilePreviewModal 组件 - 更新相关样式和规格文档
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 文件预览弹窗显示文件信息
|
||||
文件预览弹窗应显示文件的基本信息。
|
||||
文件预览面板应显示文件的基本信息,嵌入在侧边栏右侧,不使用模态弹窗。
|
||||
|
||||
#### Scenario: 显示文件基本信息
|
||||
- **WHEN** 文件预览弹窗打开
|
||||
- **THEN** 弹窗显示文件名、文件类型、文件大小、修改时间
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板显示文件名、文件类型、文件大小、修改时间
|
||||
|
||||
#### Scenario: 弹窗标题显示文件名
|
||||
- **WHEN** 文件预览弹窗打开
|
||||
- **THEN** 弹窗 header 显示文件名和关闭按钮(×)
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板 header 显示文件图标、文件名和关闭按钮(×)
|
||||
|
||||
#### Scenario: 弹窗宽度适中
|
||||
- **WHEN** 文件预览弹窗打开
|
||||
- **THEN** 弹窗宽度为 600px,最大宽度为 90vw
|
||||
#### Scenario: 面板嵌入侧边栏
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板嵌入在侧边栏右侧,不覆盖整个页面
|
||||
|
||||
### Requirement: 文本文件预览
|
||||
文本文件预览应显示文件内容。
|
||||
@@ -75,15 +75,15 @@ Office 文件预览应显示文件信息和提示。
|
||||
- **THEN** 文件信息显示音频时长(如 "03:45")
|
||||
|
||||
### Requirement: 文件预览弹窗操作
|
||||
文件预览弹窗应提供下载和关闭操作。
|
||||
文件预览面板应提供下载和关闭操作。
|
||||
|
||||
#### Scenario: 点击下载按钮
|
||||
- **WHEN** 用户点击预览弹窗的"下载"按钮
|
||||
- **WHEN** 用户点击预览面板的"下载"按钮
|
||||
- **THEN** 显示 Toast 提示"文件下载中..."
|
||||
|
||||
#### Scenario: 点击关闭按钮
|
||||
- **WHEN** 用户点击预览弹窗的"关闭"按钮
|
||||
- **THEN** 预览弹窗关闭
|
||||
- **WHEN** 用户点击预览面板的"关闭"按钮
|
||||
- **THEN** 预览面板关闭,文件树保持显示
|
||||
|
||||
### Requirement: 文件右键菜单
|
||||
文件项应支持右键菜单操作。
|
||||
@@ -118,3 +118,33 @@ Office 文件预览应显示文件信息和提示。
|
||||
#### Scenario: 取消删除
|
||||
- **WHEN** 用户点击确认弹窗的"取消"按钮
|
||||
- **THEN** 弹窗关闭,不执行删除操作
|
||||
|
||||
### Requirement: 文件预览面板始终可见
|
||||
文件预览面板应始终可见,不打断用户操作流程。
|
||||
|
||||
#### Scenario: 点击文件立即显示预览
|
||||
- **WHEN** 用户点击文件树中的文件
|
||||
- **THEN** 预览面板立即在右侧显示文件内容,不弹出模态框
|
||||
|
||||
#### Scenario: 切换文件预览面板保持打开
|
||||
- **WHEN** 用户点击另一个文件
|
||||
- **THEN** 预览面板内容更新为新文件内容,面板保持打开状态
|
||||
|
||||
#### Scenario: 预览面板独立滚动
|
||||
- **WHEN** 预览文件内容过长
|
||||
- **THEN** 预览面板内容区域独立滚动,不影响文件树
|
||||
|
||||
### Requirement: 预览面板布局
|
||||
预览面板应采用固定布局结构。
|
||||
|
||||
#### Scenario: 预览面板布局结构
|
||||
- **WHEN** 预览面板显示
|
||||
- **THEN** 面板从上到下依次为:Header(文件名+关闭按钮)、Info(文件信息)、Content(文件内容)、Actions(操作按钮)
|
||||
|
||||
#### Scenario: 预览面板最小宽度
|
||||
- **WHEN** 预览面板显示
|
||||
- **THEN** 预览面板最小宽度为 200px,保证内容可读
|
||||
|
||||
#### Scenario: 预览面板自适应宽度
|
||||
- **WHEN** 侧边栏宽度或文件树宽度改变
|
||||
- **THEN** 预览面板宽度自动调整(sidebarWidth - fileTreeWidth - dividerWidth)
|
||||
|
||||
@@ -158,3 +158,37 @@
|
||||
#### Scenario: 包含空文件夹示例
|
||||
- **WHEN** 文件树加载
|
||||
- **THEN** 显示至少一个空文件夹
|
||||
|
||||
### Requirement: 文件树宽度可调整
|
||||
文件树宽度应支持拖动调整。
|
||||
|
||||
#### Scenario: 文件树默认宽度
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树默认宽度为 200px
|
||||
|
||||
#### Scenario: 文件树宽度范围
|
||||
- **WHEN** 用户拖动分隔线调整文件树宽度
|
||||
- **THEN** 文件树宽度在 180px 到 300px 之间变化
|
||||
|
||||
#### Scenario: 文件树宽度约束
|
||||
- **WHEN** 文件树宽度调整到边界值
|
||||
- **THEN** 最小 180px 保证文件名可读,最大 300px 避免压缩预览面板
|
||||
|
||||
### Requirement: 文件树作为侧边栏左侧面板
|
||||
文件树应作为侧边栏的左侧面板显示。
|
||||
|
||||
#### Scenario: 文件树固定在左侧
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树固定在侧边栏左侧,flex-shrink: 0,不被压缩
|
||||
|
||||
#### Scenario: 文件树独立滚动
|
||||
- **WHEN** 文件树内容过多
|
||||
- **THEN** 文件树独立滚动(overflow-y: auto),不影响预览面板
|
||||
|
||||
#### Scenario: 文件树背景色
|
||||
- **WHEN** 文件树显示
|
||||
- **THEN** 文件树背景色为 var(--color-bg-2),与侧边栏整体一致
|
||||
|
||||
#### Scenario: 文件树右侧边框
|
||||
- **WHEN** 文件树显示
|
||||
- **THEN** 文件树右侧显示 1px 边框(var(--color-border-2)),与预览面板分隔
|
||||
|
||||
@@ -30,15 +30,15 @@
|
||||
- **THEN** 工作空间侧边栏关闭,恢复为隐藏状态
|
||||
|
||||
### Requirement: 工作空间侧边栏布局
|
||||
工作空间侧边栏应包含 header 区域和 content 区域。
|
||||
工作空间侧边栏应包含 header 区域和 content 区域,content 区域采用左右分栏布局。
|
||||
|
||||
#### Scenario: 侧边栏 header 显示标题和关闭按钮
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** header 区域显示"工作空间"标题和关闭按钮(×)
|
||||
|
||||
#### Scenario: 侧边栏 content 显示文件树
|
||||
#### Scenario: 侧边栏 content 左右分栏
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** content 区域显示文件树组件,可滚动查看文件列表
|
||||
- **THEN** content 区域分为左侧文件树和右侧预览面板,使用 Flex 布局
|
||||
|
||||
### Requirement: 侧边栏展开按钮位置
|
||||
展开按钮应固定在对话界面的右上角,位于 header 下方。
|
||||
@@ -71,11 +71,11 @@
|
||||
|
||||
#### Scenario: 默认宽度
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 侧边栏默认宽度为 280px
|
||||
- **THEN** 侧边栏默认宽度为 500px
|
||||
|
||||
#### Scenario: 拖动调整宽度
|
||||
- **WHEN** 用户拖动侧边栏左边缘的手柄
|
||||
- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 200px 到 500px
|
||||
- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 400px 到 800px
|
||||
|
||||
#### Scenario: 拖动手柄始终显示
|
||||
- **WHEN** 侧边栏打开
|
||||
@@ -122,3 +122,48 @@
|
||||
#### Scenario: 按钮间距
|
||||
- **WHEN** 标题栏按钮显示
|
||||
- **THEN** 按钮之间有 4px 间距
|
||||
|
||||
### Requirement: 侧边栏内部左右分栏布局
|
||||
侧边栏 content 区域应采用左右分栏布局。
|
||||
|
||||
#### Scenario: 文件树在左侧
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树显示在左侧,默认宽度 200px,范围 180-300px
|
||||
|
||||
#### Scenario: 预览面板在右侧
|
||||
- **WHEN** 用户点击文件树中的文件
|
||||
- **THEN** 预览面板显示在右侧,宽度自适应(flex: 1)
|
||||
|
||||
#### Scenario: 分隔线显示
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树和预览面板之间显示 1px 分隔线
|
||||
|
||||
### Requirement: 双拖动调整机制
|
||||
侧边栏应支持两层拖动调整。
|
||||
|
||||
#### Scenario: 外部拖动调整整体宽度
|
||||
- **WHEN** 用户拖动侧边栏左边缘手柄
|
||||
- **THEN** 侧边栏整体宽度改变(400-800px),文件树宽度不变,预览面板宽度自适应
|
||||
|
||||
#### Scenario: 内部拖动调整文件树宽度
|
||||
- **WHEN** 用户拖动文件树和预览面板之间的分隔线
|
||||
- **THEN** 文件树宽度改变(180-300px),侧边栏整体宽度不变,预览面板宽度自适应
|
||||
|
||||
#### Scenario: 分隔线可拖动
|
||||
- **WHEN** 用户 hover 分隔线
|
||||
- **THEN** 分隔线背景色变为 var(--color-primary),鼠标变为 ew-resize
|
||||
|
||||
#### Scenario: 分隔线拖动区域扩大
|
||||
- **WHEN** 用户在分隔线附近 4px 范围内按下鼠标
|
||||
- **THEN** 开始拖动调整文件树宽度
|
||||
|
||||
### Requirement: 对话区域最小宽度约束
|
||||
对话区域应保留最小宽度,保证可用性。
|
||||
|
||||
#### Scenario: 对话区域最小宽度
|
||||
- **WHEN** 侧边栏宽度调整
|
||||
- **THEN** 对话区域宽度不小于 480px,保证输入框和消息区可用
|
||||
|
||||
#### Scenario: 侧边栏最大宽度限制
|
||||
- **WHEN** 对话区域宽度接近 480px
|
||||
- **THEN** 侧边栏宽度不再增加,保证对话区域可用性
|
||||
|
||||
@@ -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;
|
||||
156
src/components/workspace/FilePreviewPanel.jsx
Normal file
156
src/components/workspace/FilePreviewPanel.jsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
background: var(--color-bg-1);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
min-width: 480px; // 保证对话区域最小宽度
|
||||
}
|
||||
|
||||
.chat-content__messages,
|
||||
|
||||
Reference in New Issue
Block a user