feat: 添加工作空间侧边栏和文件树功能
- 新增工作空间侧边栏组件,支持展开/关闭和拖动调整宽度 - 实现文件树组件,支持文件夹展开/折叠,显示文件大小和修改时间 - 添加文件预览弹窗,支持文本、Office、图片、视频、音频等多种文件类型 - 实现文件右键菜单,提供下载、重命名、删除操作入口 - 使用 react-icons 图标库替代 emoji,提升视觉一致性 - 优化拖动性能,使用 requestAnimationFrame 确保流畅跟手 - 新增工作空间相关规范文档(workspace-sidebar、file-tree、file-preview)
This commit is contained in:
62
src/components/workspace/FileContextMenu.jsx
Normal file
62
src/components/workspace/FileContextMenu.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FiDownload, FiEdit2, FiTrash2 } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 文件右键菜单组件
|
||||
*/
|
||||
function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClose }) {
|
||||
const handleAction = (action) => {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
onDownload && onDownload(file);
|
||||
break;
|
||||
case 'rename':
|
||||
onRename && onRename(file);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete && onDelete(file);
|
||||
break;
|
||||
}
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="file-context-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('download')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiDownload size={14} />
|
||||
</span>
|
||||
下载
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('rename')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiEdit2 size={14} />
|
||||
</span>
|
||||
重命名
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item file-context-menu__item--danger"
|
||||
onClick={() => handleAction('delete')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiTrash2 size={14} />
|
||||
</span>
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileContextMenu;
|
||||
154
src/components/workspace/FilePreviewModal.jsx
Normal file
154
src/components/workspace/FilePreviewModal.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
25
src/components/workspace/FileTree.jsx
Normal file
25
src/components/workspace/FileTree.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import FileTreeItem from './FileTreeItem.jsx';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件树组件
|
||||
* 渲染工作空间的文件和文件夹结构
|
||||
*/
|
||||
function FileTree({ onFileClick, onMenuClick }) {
|
||||
const files = api.workspace.getFiles();
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{files.map(item => (
|
||||
<FileTreeItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onFileClick={onFileClick}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTree;
|
||||
137
src/components/workspace/FileTreeItem.jsx
Normal file
137
src/components/workspace/FileTreeItem.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { FiChevronRight, FiFolder, FiFile, FiFileText, FiCode, FiImage, FiVideo, FiMusic } from 'react-icons/fi';
|
||||
import { FaFileWord, FaFileExcel, FaFilePowerpoint, FaFilePdf, FaFileArchive } from 'react-icons/fa';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件树项组件
|
||||
* 渲染单个文件或文件夹项
|
||||
*/
|
||||
function FileTreeItem({ item, onFileClick, onMenuClick }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const isFolder = item.type === 'folder';
|
||||
|
||||
// 根据文件类型获取图标组件
|
||||
const getIconComponent = () => {
|
||||
if (isFolder) return <FiFolder size={16} />;
|
||||
|
||||
const fileType = item.fileType;
|
||||
switch (fileType) {
|
||||
case 'text':
|
||||
case 'markdown':
|
||||
return <FiFileText size={16} />;
|
||||
case 'json':
|
||||
case 'javascript':
|
||||
case 'python':
|
||||
case 'css':
|
||||
case 'html':
|
||||
return <FiCode size={16} />;
|
||||
case 'word':
|
||||
return <FaFileWord size={16} />;
|
||||
case 'excel':
|
||||
return <FaFileExcel size={16} />;
|
||||
case 'powerpoint':
|
||||
return <FaFilePowerpoint size={16} />;
|
||||
case 'image':
|
||||
return <FiImage size={16} />;
|
||||
case 'video':
|
||||
return <FiVideo size={16} />;
|
||||
case 'audio':
|
||||
return <FiMusic size={16} />;
|
||||
case 'pdf':
|
||||
return <FaFilePdf size={16} />;
|
||||
case 'archive':
|
||||
return <FaFileArchive size={16} />;
|
||||
default:
|
||||
return <FiFile size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化修改时间 (2026-04-09 -> 04-09)
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '';
|
||||
const parts = time.split('-');
|
||||
return `${parts[1]}-${parts[2]}`;
|
||||
};
|
||||
|
||||
// 点击文件夹:展开/折叠
|
||||
const handleFolderClick = () => {
|
||||
if (isFolder) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击文件:打开预览
|
||||
const handleFileClick = () => {
|
||||
if (!isFolder && onFileClick) {
|
||||
onFileClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击操作按钮
|
||||
const handleMenuClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
if (onMenuClick && !showMenu) {
|
||||
onMenuClick(item, e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-tree__node">
|
||||
<div
|
||||
className={`file-tree__item file-tree__item--${item.type}`}
|
||||
onClick={isFolder ? handleFolderClick : handleFileClick}
|
||||
>
|
||||
{/* 展开/折叠箭头 */}
|
||||
{isFolder && (
|
||||
<span className={`file-tree__toggle ${isExpanded ? 'file-tree__toggle--expanded' : ''}`}>
|
||||
<FiChevronRight size={14} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="file-tree__icon">{getIconComponent()}</span>
|
||||
|
||||
{/* 名称 */}
|
||||
<span className="file-tree__name">{item.name}</span>
|
||||
|
||||
{/* 文件大小 */}
|
||||
{!isFolder && item.size && (
|
||||
<span className="file-tree__size">{item.size}</span>
|
||||
)}
|
||||
|
||||
{/* 修改时间 */}
|
||||
{!isFolder && item.modifiedAt && (
|
||||
<span className="file-tree__time">{formatTime(item.modifiedAt)}</span>
|
||||
)}
|
||||
|
||||
{/* 操作按钮(文件夹和文件都显示) */}
|
||||
<span
|
||||
className="file-tree__actions"
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
⋯
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 子项(文件夹展开时显示) */}
|
||||
{isFolder && isExpanded && item.children && (
|
||||
<div className="file-tree__children">
|
||||
{item.children.map(child => (
|
||||
<FileTreeItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
onFileClick={onFileClick}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTreeItem;
|
||||
227
src/components/workspace/WorkspaceSidebar.jsx
Normal file
227
src/components/workspace/WorkspaceSidebar.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FiX, FiFolder } from 'react-icons/fi';
|
||||
import FileTree from './FileTree.jsx';
|
||||
import FileContextMenu from './FileContextMenu.jsx';
|
||||
import FilePreviewModal from './FilePreviewModal.jsx';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
|
||||
/**
|
||||
* 工作空间侧边栏组件
|
||||
* 管理展开/关闭状态,集成文件树、右键菜单和预览弹窗
|
||||
*/
|
||||
function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
const [contextMenu, setContextMenu] = useState(null); // { file, position }
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
const [deleteFile, setDeleteFile] = useState(null);
|
||||
const [width, setWidth] = useState(280); // 默认宽度 280px
|
||||
const sidebarRef = useRef(null);
|
||||
const isResizingRef = useRef(false);
|
||||
const lastXRef = useRef(0);
|
||||
|
||||
// 拖动调整宽度 - 使用 requestAnimationFrame 优化
|
||||
const handleMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
isResizingRef.current = true;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
// 添加拖动时的样式
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.transition = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId = null;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isResizingRef.current) return;
|
||||
|
||||
// 取消之前的动画帧
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
const deltaX = lastXRef.current - e.clientX;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
setWidth(prevWidth => {
|
||||
const newWidth = prevWidth + deltaX;
|
||||
// 限制宽度范围:最小 200px,最大 500px
|
||||
return Math.max(200, Math.min(500, newWidth));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isResizingRef.current) return;
|
||||
|
||||
isResizingRef.current = false;
|
||||
|
||||
// 恢复样式
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.transition = '';
|
||||
}
|
||||
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭右键菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
if (contextMenu) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
// 文件点击:打开预览
|
||||
const handleFileClick = (file) => {
|
||||
setPreviewFile(file);
|
||||
};
|
||||
|
||||
// 右键菜单点击
|
||||
const handleMenuClick = (file, e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
// 菜单显示在按钮左侧,避免超出页面
|
||||
const menuWidth = 140; // 菜单宽度
|
||||
const x = rect.right - menuWidth;
|
||||
const y = rect.bottom + 4;
|
||||
|
||||
setContextMenu({
|
||||
file,
|
||||
position: {
|
||||
x: Math.max(8, x), // 确保不超出左边界
|
||||
y,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 下载操作
|
||||
const handleDownload = (file) => {
|
||||
alert('文件下载中...');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 重命名操作
|
||||
const handleRename = (file) => {
|
||||
alert('文件已重命名');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = (file) => {
|
||||
setDeleteFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 确认删除
|
||||
const handleDeleteConfirm = () => {
|
||||
alert('文件已删除');
|
||||
setDeleteFile(null);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="workspace-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<div className="workspace-sidebar__header">
|
||||
<div className="workspace-sidebar__title">工作空间</div>
|
||||
<div className="workspace-sidebar__close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-sidebar__content">
|
||||
<FileTree
|
||||
onFileClick={handleFileClick}
|
||||
onMenuClick={handleMenuClick}
|
||||
/>
|
||||
</div>
|
||||
{/* 拖动调整宽度的手柄 */}
|
||||
<div
|
||||
className="workspace-sidebar__resize-handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="workspace-sidebar__resize-lines">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<FileContextMenu
|
||||
file={contextMenu.file}
|
||||
position={contextMenu.position}
|
||||
onDownload={handleDownload}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件预览弹窗 */}
|
||||
{previewFile && (
|
||||
<FilePreviewModal
|
||||
file={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{deleteFile && (
|
||||
<Modal
|
||||
visible={true}
|
||||
title="确认删除"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteFile(null)}
|
||||
>
|
||||
确定要删除文件「{deleteFile.name}」吗?
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作空间展开按钮组件
|
||||
*/
|
||||
export function WorkspaceToggleBtn({ onClick }) {
|
||||
return (
|
||||
<div className="workspace-toggle-btn" onClick={onClick} title="工作空间">
|
||||
<FiFolder size={18} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSidebar;
|
||||
245
src/data/workspace.js
Normal file
245
src/data/workspace.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// 工作空间文件数据
|
||||
|
||||
// 文件图标映射
|
||||
export const fileIcons = {
|
||||
folder: '📁',
|
||||
// 文本类型
|
||||
text: '📄',
|
||||
markdown: '📝',
|
||||
json: '📋',
|
||||
javascript: '💻',
|
||||
python: '🐍',
|
||||
css: '🎨',
|
||||
html: '🌐',
|
||||
// Office 类型
|
||||
word: '📘',
|
||||
excel: '📗',
|
||||
powerpoint: '📙',
|
||||
// 媒体类型
|
||||
image: '🖼️',
|
||||
video: '🎬',
|
||||
audio: '🎵',
|
||||
// 其他
|
||||
pdf: '📕',
|
||||
archive: '📦',
|
||||
unknown: '📄'
|
||||
};
|
||||
|
||||
// 根据文件扩展名获取文件类型
|
||||
export function getFileType(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
|
||||
// 文本类型
|
||||
if (['txt'].includes(ext)) return 'text';
|
||||
if (['md'].includes(ext)) return 'markdown';
|
||||
if (['json'].includes(ext)) return 'json';
|
||||
if (['js', 'jsx', 'ts', 'tsx'].includes(ext)) return 'javascript';
|
||||
if (['py'].includes(ext)) return 'python';
|
||||
if (['css', 'scss', 'sass'].includes(ext)) return 'css';
|
||||
if (['html', 'htm'].includes(ext)) return 'html';
|
||||
|
||||
// Office 类型
|
||||
if (['doc', 'docx'].includes(ext)) return 'word';
|
||||
if (['xls', 'xlsx'].includes(ext)) return 'excel';
|
||||
if (['ppt', 'pptx'].includes(ext)) return 'powerpoint';
|
||||
|
||||
// 媒体类型
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext)) return 'image';
|
||||
if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio';
|
||||
|
||||
// 其他
|
||||
if (['pdf'].includes(ext)) return 'pdf';
|
||||
if (['zip', 'rar', 'tar', 'gz'].includes(ext)) return 'archive';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// 工作空间文件树数据
|
||||
export const workspaceFiles = [
|
||||
{
|
||||
id: 'folder_1',
|
||||
name: 'documents',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_1',
|
||||
name: 'report.docx',
|
||||
type: 'file',
|
||||
fileType: 'word',
|
||||
size: '156 KB',
|
||||
modifiedAt: '2026-04-09'
|
||||
},
|
||||
{
|
||||
id: 'file_2',
|
||||
name: 'notes.txt',
|
||||
type: 'file',
|
||||
fileType: 'text',
|
||||
size: '1.2 KB',
|
||||
modifiedAt: '2026-04-10',
|
||||
content: '这是笔记内容...\n\n1. 第一点\n2. 第二点\n3. 第三点\n\n完成!'
|
||||
},
|
||||
{
|
||||
id: 'file_3',
|
||||
name: 'presentation.pptx',
|
||||
type: 'file',
|
||||
fileType: 'powerpoint',
|
||||
size: '2.3 MB',
|
||||
modifiedAt: '2026-04-08'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_2',
|
||||
name: 'code',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_4',
|
||||
name: 'app.js',
|
||||
type: 'file',
|
||||
fileType: 'javascript',
|
||||
size: '3.5 KB',
|
||||
modifiedAt: '2026-04-08',
|
||||
content: `import React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<h1>Hello World</h1>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;`
|
||||
},
|
||||
{
|
||||
id: 'file_5',
|
||||
name: 'style.css',
|
||||
type: 'file',
|
||||
fileType: 'css',
|
||||
size: '1.8 KB',
|
||||
modifiedAt: '2026-04-07',
|
||||
content: `.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2563EB;
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'file_6',
|
||||
name: 'config.json',
|
||||
type: 'file',
|
||||
fileType: 'json',
|
||||
size: '856 B',
|
||||
modifiedAt: '2026-04-06',
|
||||
content: `{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample application",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_3',
|
||||
name: 'media',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_7',
|
||||
name: 'photo.jpg',
|
||||
type: 'file',
|
||||
fileType: 'image',
|
||||
size: '245 KB',
|
||||
dimensions: '1920 × 1080',
|
||||
modifiedAt: '2026-04-07'
|
||||
},
|
||||
{
|
||||
id: 'file_8',
|
||||
name: 'demo.mp4',
|
||||
type: 'file',
|
||||
fileType: 'video',
|
||||
size: '12.5 MB',
|
||||
duration: '02:35',
|
||||
modifiedAt: '2026-04-06'
|
||||
},
|
||||
{
|
||||
id: 'file_9',
|
||||
name: 'music.mp3',
|
||||
type: 'file',
|
||||
fileType: 'audio',
|
||||
size: '4.8 MB',
|
||||
duration: '03:45',
|
||||
modifiedAt: '2026-04-05'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'file_10',
|
||||
name: 'README.md',
|
||||
type: 'file',
|
||||
fileType: 'markdown',
|
||||
size: '2.1 KB',
|
||||
modifiedAt: '2026-04-08',
|
||||
content: `# 项目说明
|
||||
|
||||
这是一个示例项目,用于展示工作空间文件管理功能。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- 文件树展示
|
||||
- 文件预览
|
||||
- 文件操作
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 点击文件查看预览
|
||||
2. 右键菜单进行操作
|
||||
|
||||
## 注意事项
|
||||
|
||||
这是一个原型项目,文件操作仅为演示。`
|
||||
},
|
||||
{
|
||||
id: 'file_11',
|
||||
name: 'data.xlsx',
|
||||
type: 'file',
|
||||
fileType: 'excel',
|
||||
size: '89 KB',
|
||||
modifiedAt: '2026-04-04'
|
||||
}
|
||||
];
|
||||
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { getChatScenes } from '../../data/conversations.js';
|
||||
import { FiPaperclip, FiCode, FiSend, FiChevronDown } from 'react-icons/fi';
|
||||
import { api } from '../../services/api.js';
|
||||
import WorkspaceSidebar, { WorkspaceToggleBtn } from '../../components/workspace/WorkspaceSidebar.jsx';
|
||||
|
||||
function ModelSelector({ selectedModel, onSelectModel, variant = 'standard' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -138,6 +139,7 @@ function ChatPage() {
|
||||
const html = chatScenes[currentScene] || '';
|
||||
const chatMessagesRef = useRef(null);
|
||||
const [isCompact, setIsCompact] = useState(false);
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false); // 工作空间侧边栏状态
|
||||
|
||||
const defaultPlatformModel = api.admin.modelConfigs.list().find(c => c.isActive);
|
||||
const defaultProjectModel = api.consoleModels.project.list().find(c => c.isActive);
|
||||
@@ -184,41 +186,54 @@ function ChatPage() {
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
<div className="chat-content">
|
||||
<div className="chat-messages">
|
||||
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-container">
|
||||
<div className="chat-input-box chat-input-box--horizontal">
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={setSelectedModel}
|
||||
variant={isCompact ? 'compact' : 'standard'}
|
||||
/>
|
||||
<div className="chat-input-main">
|
||||
<textarea
|
||||
className="chat-input"
|
||||
placeholder="输入消息... Enter 发送,Shift+Enter 换行"
|
||||
rows="1"
|
||||
<div className="chat-main">
|
||||
{/* 工作空间展开按钮(侧边栏关闭时显示) */}
|
||||
{!workspaceOpen && (
|
||||
<WorkspaceToggleBtn onClick={() => setWorkspaceOpen(true)} />
|
||||
)}
|
||||
|
||||
<div className="chat-content">
|
||||
<div className="chat-messages">
|
||||
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-container">
|
||||
<div className="chat-input-box chat-input-box--horizontal">
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={setSelectedModel}
|
||||
variant={isCompact ? 'compact' : 'standard'}
|
||||
/>
|
||||
<div className="chat-input-actions">
|
||||
<div className="chat-input-tools">
|
||||
<div className="chat-input-tool" title="上传文件">
|
||||
<FiPaperclip />
|
||||
</div>
|
||||
<div className="chat-input-tool" title="代码块">
|
||||
<FiCode />
|
||||
<div className="chat-input-main">
|
||||
<textarea
|
||||
className="chat-input"
|
||||
placeholder="输入消息... Enter 发送,Shift+Enter 换行"
|
||||
rows="1"
|
||||
/>
|
||||
<div className="chat-input-actions">
|
||||
<div className="chat-input-tools">
|
||||
<div className="chat-input-tool" title="上传文件">
|
||||
<FiPaperclip />
|
||||
</div>
|
||||
<div className="chat-input-tool" title="代码块">
|
||||
<FiCode />
|
||||
</div>
|
||||
</div>
|
||||
<button className="chat-send-btn">
|
||||
<FiSend />
|
||||
</button>
|
||||
</div>
|
||||
<button className="chat-send-btn">
|
||||
<FiSend />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作空间侧边栏 */}
|
||||
<WorkspaceSidebar
|
||||
isOpen={workspaceOpen}
|
||||
onClose={() => setWorkspaceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { scheduledTasks } from '../data/tasks.js';
|
||||
import { adminDepartments, adminUsers, adminProjects, adminOverview, adminLogs, modelConfigs } from '../data/adminData.js';
|
||||
import { projectModelConfigs } from '../data/projectModelConfigs.js';
|
||||
import { userModelConfigs } from '../data/userModelConfigs.js';
|
||||
import { workspaceFiles, fileIcons, getFileType } from '../data/workspace.js';
|
||||
|
||||
/**
|
||||
* 用户相关 API
|
||||
@@ -454,6 +455,52 @@ export const consoleModelConfigsApi = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 工作空间相关 API
|
||||
*/
|
||||
export const workspaceApi = {
|
||||
/**
|
||||
* 获取工作空间文件树
|
||||
* @returns {Array} 文件树数据
|
||||
*/
|
||||
getFiles: () => workspaceFiles,
|
||||
|
||||
/**
|
||||
* 根据 ID 获取文件
|
||||
* @param {string} id - 文件 ID
|
||||
* @returns {Object|undefined} 文件对象
|
||||
*/
|
||||
getFileById: (id) => {
|
||||
// 递归查找文件
|
||||
const findFile = (items) => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item;
|
||||
if (item.type === 'folder' && item.children) {
|
||||
const found = findFile(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return findFile(workspaceFiles);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文件图标
|
||||
* @param {string} fileType - 文件类型
|
||||
* @returns {string} 图标 emoji
|
||||
*/
|
||||
getFileIcon: (fileType) => fileIcons[fileType] || fileIcons.unknown,
|
||||
|
||||
/**
|
||||
* 根据文件名获取文件类型
|
||||
* @param {string} filename - 文件名
|
||||
* @returns {string} 文件类型
|
||||
*/
|
||||
getFileType: getFileType,
|
||||
};
|
||||
|
||||
export const api = {
|
||||
user,
|
||||
skills: skillsApi,
|
||||
@@ -464,6 +511,7 @@ export const api = {
|
||||
tasks: tasksApi,
|
||||
admin: adminApi,
|
||||
consoleModels: consoleModelConfigsApi,
|
||||
workspace: workspaceApi,
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
@forward 'search-bar';
|
||||
@forward 'stat-card';
|
||||
@forward 'header';
|
||||
@forward 'workspace';
|
||||
|
||||
.page-back-btn {
|
||||
display: inline-flex;
|
||||
|
||||
344
src/styles/components/workspace/_index.scss
Normal file
344
src/styles/components/workspace/_index.scss
Normal file
@@ -0,0 +1,344 @@
|
||||
// 工作空间组件样式
|
||||
|
||||
@use '../../tokens' as *;
|
||||
|
||||
// 工作空间侧边栏
|
||||
.workspace-sidebar {
|
||||
width: 240px;
|
||||
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;
|
||||
position: relative;
|
||||
|
||||
&--collapsed {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-1);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__resize-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
div {
|
||||
width: 4px;
|
||||
height: 2px;
|
||||
background: var(--color-text-3);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&__resize-handle:hover &__resize-lines div {
|
||||
background: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
// 工作空间展开按钮
|
||||
.workspace-toggle-btn {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 16px);
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-2);
|
||||
transition: all 0.2s;
|
||||
z-index: 150;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
color: var(--color-text-1);
|
||||
border-color: var(--color-border-3);
|
||||
}
|
||||
}
|
||||
|
||||
// 文件树
|
||||
.file-tree {
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 1px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
&--folder {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
&--file {
|
||||
padding-left: 28px; // 文件缩进
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 2px;
|
||||
color: var(--color-text-3);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__size {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__time {
|
||||
width: 40px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
margin-left: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
&__children {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件预览弹窗
|
||||
.file-preview-modal {
|
||||
&__info {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
&__info-item {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__info-label {
|
||||
width: 80px;
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info-value {
|
||||
flex: 1;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-2);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__placeholder-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__placeholder-text {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件右键菜单
|
||||
.file-context-menu {
|
||||
position: absolute;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-3);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.16);
|
||||
z-index: $z-index-modal;
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--color-text-1);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--color-danger);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-danger-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
// 聊天主区域(包含内容区和右侧工作空间侧边栏)
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// 聊天顶部栏
|
||||
.chat-layout__header,
|
||||
.chat-header {
|
||||
|
||||
Reference in New Issue
Block a user