文件管理

This commit is contained in:
小朱
2025-07-08 21:51:04 +08:00
parent c5d3a72ec6
commit 54b98ac6fd
22 changed files with 3275 additions and 139 deletions

View File

@@ -1,12 +1,23 @@
使用React框架写一个游戏面板后端为node.js 目前只需要三个页面即可 首页 终端(需要实现功能) 设置 要同时兼容Windows和Linux
目前后端已经存在了,你需要写一个美观漂亮并且符合游戏风格的面板
要支持全局深色和浅色模式更改
# 配置文件
统一保存在 server/data 目录下
再写一个文件管理功能 使用了 Ant Design (antd) 作为基础UI组件库 使用Monaco Editor VS Code的编辑器内核
其中Monaco Editor 应当在面板中集成
```json
"@monaco-editor/react": "^4.6.0",
"monaco-editor": "^0.45.0"
```
```ts
import loader from '@monaco-editor/loader';
import * as monaco from 'monaco-editor';
# 终端
需要做成拓展性很强,因为在后续功能需要调用此终端 需要做到灵活调用并且终端需要在刷新网页时仍然为刷新网页前的状态和所有命令记录由于后端pty已经是一个编译好的模块可以直接通过进程获取具体信息 具体你可以查看后端代码
要支持终端的所有交互
// 配置@monaco-editor/react使用本地的monaco-editor包而不是CDN
loader.config({ monaco });
# 登录
需要实现用户登录jwt密钥不要采用硬编 而是通过每次启动后随机生成到配置文件
export default loader;
```
- 导入本地安装的 monaco-editor 包
- 使用 loader.config({ monaco }) 告诉 @monaco-editor/react 使用本地的monaco实例而不是从CDN加载
要支持文件的新建、删除、重命名、上传、下载、查看文件内容、切换目录等基础功能 并且支持右键文件的功能
文件管理要支持多选批量操作 做成九宫格形式优先展示文件夹之后再展示文件 要支持路径的输入和识别 默认路径应当设置在程序运行路径下
所有需要用户输入交互操作 专门做一个对话框或表单 要求具备动画

1077
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,19 +10,22 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"antd": "^5.15.0",
"axios": "^1.6.8",
"clsx": "^2.0.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.363.0",
"monaco-editor": "^0.47.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"socket.io-client": "^4.7.4",
"zustand": "^4.4.7",
"react-router-dom": "^6.22.3",
"socket.io-client": "^4.7.5",
"tailwind-merge": "^2.1.0",
"zustand": "^4.5.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"lucide-react": "^0.294.0",
"clsx": "^2.0.0",
"tailwind-merge": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"axios": "^1.6.2"
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"@types/react": "^18.2.43",

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider, theme } from 'antd'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import Layout from '@/components/Layout'
@@ -7,6 +8,7 @@ import LoginPage from '@/pages/LoginPage'
import HomePage from '@/pages/HomePage'
import TerminalPage from '@/pages/TerminalPage'
import SettingsPage from '@/pages/SettingsPage'
import FileManagerPage from '@/pages/FileManagerPage'
import LoadingSpinner from '@/components/LoadingSpinner'
import NotificationContainer from '@/components/NotificationContainer'
@@ -42,7 +44,7 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
function App() {
const { verifyToken, setLoading } = useAuthStore()
const { initTheme } = useThemeStore()
const { theme: currentTheme, initTheme } = useThemeStore()
useEffect(() => {
// 初始化主题
@@ -64,39 +66,49 @@ function App() {
}, [])
return (
<div className="min-h-screen bg-game-gradient">
<Routes>
{/* 公共路由 */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<ConfigProvider
theme={{
algorithm: currentTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: '#1890ff',
},
}}
>
<div className="min-h-screen bg-game-gradient">
<Routes>
{/* 公共路由 */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* 受保护的路由 */}
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/terminal" element={<TerminalPage />} />
<Route path="/files" element={<FileManagerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
{/* 受保护的路由 */}
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/terminal" element={<TerminalPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
{/* 全局通知容器 */}
<NotificationContainer />
</div>
{/* 全局通知容器 */}
<NotificationContainer />
</div>
</ConfigProvider>
)
}

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { Dropdown, Menu } from 'antd'
import type { MenuProps } from 'antd'
import {
EditOutlined,
DeleteOutlined,
CopyOutlined,
ScissorOutlined,
DownloadOutlined,
FileOutlined,
FolderOpenOutlined,
EyeOutlined
} from '@ant-design/icons'
import { FileItem } from '@/types/file'
interface FileContextMenuProps {
children: React.ReactNode
file: FileItem
selectedFiles: Set<string>
onOpen: (file: FileItem) => void
onRename: (file: FileItem) => void
onDelete: (files: FileItem[]) => void
onDownload: (file: FileItem) => void
onCopy: (files: FileItem[]) => void
onCut: (files: FileItem[]) => void
onView: (file: FileItem) => void
}
export const FileContextMenu: React.FC<FileContextMenuProps> = ({
children,
file,
selectedFiles,
onOpen,
onRename,
onDelete,
onDownload,
onCopy,
onCut,
onView
}) => {
const isSelected = selectedFiles.has(file.path)
const selectedCount = selectedFiles.size
const isMultipleSelected = selectedCount > 1
const getSelectedFiles = (): FileItem[] => {
if (isSelected && isMultipleSelected) {
// 如果当前文件被选中且有多个选中项,操作所有选中的文件
return Array.from(selectedFiles).map(path => ({ ...file, path }))
} else {
// 否则只操作当前文件
return [file]
}
}
const menuItems: MenuProps['items'] = [
// 打开/查看
{
key: 'open',
label: file.type === 'directory' ? '打开文件夹' : '打开文件',
icon: file.type === 'directory' ? <FolderOpenOutlined /> : <FileOutlined />,
onClick: () => onOpen(file)
},
// 查看(仅文件)
...(file.type === 'file' ? [{
key: 'view',
label: '预览',
icon: <EyeOutlined />,
onClick: () => onView(file)
}] : []),
{ type: 'divider' as const },
// 重命名(仅单个文件)
...(!isMultipleSelected ? [{
key: 'rename',
label: '重命名',
icon: <EditOutlined />,
onClick: () => onRename(file)
}] : []),
// 复制
{
key: 'copy',
label: isMultipleSelected ? `复制 ${selectedCount}` : '复制',
icon: <CopyOutlined />,
onClick: () => onCopy(getSelectedFiles())
},
// 剪切
{
key: 'cut',
label: isMultipleSelected ? `剪切 ${selectedCount}` : '剪切',
icon: <ScissorOutlined />,
onClick: () => onCut(getSelectedFiles())
},
{ type: 'divider' as const },
// 下载(仅文件)
...(file.type === 'file' && !isMultipleSelected ? [{
key: 'download',
label: '下载',
icon: <DownloadOutlined />,
onClick: () => onDownload(file)
}] : []),
// 删除
{
key: 'delete',
label: isMultipleSelected ? `删除 ${selectedCount}` : '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => onDelete(getSelectedFiles())
}
]
return (
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
placement="bottomLeft"
overlayClassName="file-context-menu"
overlayStyle={{
minWidth: '160px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}
>
{children}
</Dropdown>
)
}

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react'
import { Modal, Input, Form, Upload, Button, message } from 'antd'
import { InboxOutlined } from '@ant-design/icons'
import type { UploadProps } from 'antd'
const { Dragger } = Upload
interface CreateDialogProps {
visible: boolean
type: 'file' | 'folder'
onConfirm: (name: string) => void
onCancel: () => void
}
export const CreateDialog: React.FC<CreateDialogProps> = ({
visible,
type,
onConfirm,
onCancel
}) => {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (visible) {
form.resetFields()
}
}, [visible, form])
const handleOk = async () => {
try {
setLoading(true)
const values = await form.validateFields()
onConfirm(values.name)
form.resetFields()
} catch (error) {
// 验证失败
} finally {
setLoading(false)
}
}
return (
<Modal
title={`创建${type === 'file' ? '文件' : '文件夹'}`}
open={visible}
onOk={handleOk}
onCancel={onCancel}
confirmLoading={loading}
destroyOnClose
>
<Form
form={form}
layout="vertical"
className="mt-4"
>
<Form.Item
name="name"
label={`${type === 'file' ? '文件' : '文件夹'}名称`}
rules={[
{ required: true, message: '请输入名称' },
{
pattern: /^[^<>:"/\\|?*]+$/,
message: '名称不能包含特殊字符'
}
]}
>
<Input
placeholder={`请输入${type === 'file' ? '文件' : '文件夹'}名称`}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
)
}
interface RenameDialogProps {
visible: boolean
currentName: string
onConfirm: (newName: string) => void
onCancel: () => void
}
export const RenameDialog: React.FC<RenameDialogProps> = ({
visible,
currentName,
onConfirm,
onCancel
}) => {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (visible) {
form.setFieldsValue({ name: currentName })
}
}, [visible, currentName, form])
const handleOk = async () => {
try {
setLoading(true)
const values = await form.validateFields()
onConfirm(values.name)
form.resetFields()
} catch (error) {
// 验证失败
} finally {
setLoading(false)
}
}
return (
<Modal
title="重命名"
open={visible}
onOk={handleOk}
onCancel={onCancel}
confirmLoading={loading}
destroyOnClose
>
<Form
form={form}
layout="vertical"
className="mt-4"
>
<Form.Item
name="name"
label="新名称"
rules={[
{ required: true, message: '请输入新名称' },
{
pattern: /^[^<>:"/\\|?*]+$/,
message: '名称不能包含特殊字符'
}
]}
>
<Input
placeholder="请输入新名称"
autoFocus
/>
</Form.Item>
</Form>
</Modal>
)
}
interface UploadDialogProps {
visible: boolean
onConfirm: (files: FileList) => void
onCancel: () => void
}
export const UploadDialog: React.FC<UploadDialogProps> = ({
visible,
onConfirm,
onCancel
}) => {
const [fileList, setFileList] = useState<File[]>([])
const [loading, setLoading] = useState(false)
const uploadProps: UploadProps = {
name: 'files',
multiple: true,
beforeUpload: (file) => {
setFileList(prev => [...prev, file])
return false // 阻止自动上传
},
onRemove: (file) => {
setFileList(prev => prev.filter(f => f.uid !== file.uid))
},
fileList: fileList.map(file => ({
uid: file.name + file.size,
name: file.name,
status: 'done' as const,
originFileObj: file
}))
}
const handleOk = async () => {
if (fileList.length === 0) {
message.warning('请选择要上传的文件')
return
}
setLoading(true)
try {
const files = {
length: fileList.length,
item: (index: number) => fileList[index],
[Symbol.iterator]: function* () {
for (let i = 0; i < this.length; i++) {
yield this.item(i)
}
}
} as FileList
onConfirm(files)
setFileList([])
} finally {
setLoading(false)
}
}
const handleCancel = () => {
setFileList([])
onCancel()
}
return (
<Modal
title="上传文件"
open={visible}
onOk={handleOk}
onCancel={handleCancel}
confirmLoading={loading}
destroyOnClose
width={600}
>
<div className="mt-4">
<Dragger
{...uploadProps}
>
<p className="ant-upload-drag-icon">
<InboxOutlined className="text-4xl text-blue-500" />
</p>
<p className="ant-upload-text text-lg font-medium">
</p>
<p className="ant-upload-hint text-gray-500">
</p>
</Dragger>
</div>
</Modal>
)
}
interface DeleteConfirmDialogProps {
visible: boolean
fileNames: string[]
onConfirm: () => void
onCancel: () => void
}
export const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
visible,
fileNames,
onConfirm,
onCancel
}) => {
const [loading, setLoading] = useState(false)
const handleOk = async () => {
setLoading(true)
try {
onConfirm()
} finally {
setLoading(false)
}
}
return (
<Modal
title="确认删除"
open={visible}
onOk={handleOk}
onCancel={onCancel}
confirmLoading={loading}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<div className="mt-4">
<p className="text-gray-700 dark:text-gray-300 mb-3">
{fileNames.length}
</p>
<div className="max-h-40 overflow-y-auto bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
{fileNames.map((name, index) => (
<div key={index} className="text-sm text-gray-600 dark:text-gray-400 py-1">
{name}
</div>
))}
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,155 @@
import React from 'react'
import { FileItem } from '@/types/file'
import {
FolderOutlined,
FileTextOutlined,
FileImageOutlined,
FileZipOutlined,
VideoCameraOutlined,
AudioOutlined,
CodeOutlined,
FileOutlined
} from '@ant-design/icons'
import { formatFileSize, formatDate } from '@/utils/format'
interface FileGridItemProps {
file: FileItem
isSelected: boolean
onClick: (file: FileItem, event: React.MouseEvent) => void
onDoubleClick: (file: FileItem) => void
}
// 根据文件扩展名获取图标
const getFileIcon = (fileName: string, type: string) => {
if (type === 'directory') {
return <FolderOutlined className="text-blue-500" />
}
const ext = fileName.split('.').pop()?.toLowerCase()
switch (ext) {
case 'txt':
case 'md':
case 'doc':
case 'docx':
return <FileTextOutlined className="text-blue-600" />
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'bmp':
case 'svg':
case 'webp':
return <FileImageOutlined className="text-green-500" />
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
return <FileZipOutlined className="text-orange-500" />
case 'mp4':
case 'avi':
case 'mkv':
case 'mov':
case 'wmv':
case 'flv':
return <VideoCameraOutlined className="text-red-500" />
case 'mp3':
case 'wav':
case 'flac':
case 'aac':
case 'ogg':
return <AudioOutlined className="text-purple-500" />
case 'js':
case 'ts':
case 'jsx':
case 'tsx':
case 'html':
case 'css':
case 'scss':
case 'json':
case 'xml':
case 'py':
case 'java':
case 'cpp':
case 'c':
case 'php':
case 'go':
case 'rs':
return <CodeOutlined className="text-indigo-500" />
default:
return <FileOutlined className="text-gray-500" />
}
}
export const FileGridItem: React.FC<FileGridItemProps> = ({
file,
isSelected,
onClick,
onDoubleClick
}) => {
const handleClick = (event: React.MouseEvent) => {
onClick(file, event)
}
const handleDoubleClick = () => {
onDoubleClick(file)
}
return (
<div
className={`
relative group cursor-pointer p-4 rounded-lg border-2
hover:shadow-lg hover:border-blue-300
${isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750'
}
${file.type === 'directory' ? 'border-dashed' : ''}
`}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{/* 选中状态指示器 */}
{isSelected && (
<div className="absolute top-2 right-2 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<div className="w-2 h-2 bg-white rounded-full"></div>
</div>
)}
{/* 文件图标 */}
<div className="flex flex-col items-center space-y-3">
<div className="text-4xl">
{getFileIcon(file.name, file.type)}
</div>
{/* 文件名 */}
<div className="text-center w-full">
<div
className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate px-1"
title={file.name}
>
{file.name}
</div>
{/* 文件信息 */}
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-1">
{file.type === 'file' && (
<div>{formatFileSize(file.size)}</div>
)}
<div>{formatDate(file.modified)}</div>
</div>
</div>
</div>
{/* 悬停效果 */}
</div>
)
}

View File

@@ -12,7 +12,8 @@ import {
Sun,
Moon,
User,
Gamepad2
Gamepad2,
FolderOpen
} from 'lucide-react'
interface LayoutProps {
@@ -28,6 +29,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigation = [
{ name: '首页', href: '/', icon: Home },
{ name: '终端', href: '/terminal', icon: Terminal },
{ name: '文件管理', href: '/files', icon: FolderOpen },
{ name: '设置', href: '/settings', icon: Settings },
]

View File

@@ -0,0 +1,161 @@
import React, { useRef, useEffect } from 'react'
import { Editor } from '@monaco-editor/react'
import { useThemeStore } from '@/stores/themeStore'
import { getFileExtension } from '@/utils/format'
import type { editor } from 'monaco-editor'
interface MonacoEditorProps {
value: string
onChange: (value: string) => void
fileName?: string
readOnly?: boolean
height?: string | number
onSave?: () => void
}
// 根据文件扩展名获取语言
const getLanguageFromFileName = (fileName: string): string => {
const ext = getFileExtension(fileName)
const languageMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'html': 'html',
'css': 'css',
'scss': 'scss',
'less': 'less',
'json': 'json',
'xml': 'xml',
'md': 'markdown',
'py': 'python',
'java': 'java',
'cpp': 'cpp',
'c': 'c',
'h': 'c',
'php': 'php',
'go': 'go',
'rs': 'rust',
'sql': 'sql',
'yml': 'yaml',
'yaml': 'yaml',
'sh': 'shell',
'bat': 'bat',
'ps1': 'powershell',
'vue': 'html',
'svelte': 'html'
}
return languageMap[ext] || 'plaintext'
}
export const MonacoEditor: React.FC<MonacoEditorProps> = ({
value,
onChange,
fileName = '',
readOnly = false,
height = '100%',
onSave
}) => {
const { theme } = useThemeStore()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const language = getLanguageFromFileName(fileName)
const monacoTheme = theme === 'dark' ? 'vs-dark' : 'vs'
const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => {
editorRef.current = editor
// 添加保存快捷键
editor.addCommand(
// Ctrl+S 或 Cmd+S
2048 | 49, // monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS
() => {
onSave?.()
}
)
// 设置编辑器选项
editor.updateOptions({
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
lineHeight: 20,
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
tabSize: 2,
insertSpaces: true,
renderWhitespace: 'selection',
renderControlCharacters: true,
smoothScrolling: true,
cursorBlinking: 'smooth',
cursorSmoothCaretAnimation: 'on'
})
}
const handleEditorChange = (value: string | undefined) => {
onChange(value || '')
}
// 当主题改变时更新编辑器主题
useEffect(() => {
if (editorRef.current) {
// 使用 monaco.editor.setTheme 来动态切换主题
import('monaco-editor').then(monaco => {
monaco.editor.setTheme(monacoTheme)
})
}
}, [monacoTheme])
return (
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Editor
height={height}
language={language}
value={value}
theme={monacoTheme}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
options={{
readOnly,
selectOnLineNumbers: true,
roundedSelection: false,
scrollBeyondLastLine: false,
automaticLayout: true,
minimap: {
enabled: !readOnly
},
contextmenu: true,
mouseWheelZoom: true,
smoothScrolling: true,
cursorBlinking: 'smooth',
cursorSmoothCaretAnimation: 'on',
renderLineHighlight: 'all',
renderWhitespace: 'selection',
wordWrap: 'on',
lineNumbers: 'on',
glyphMargin: true,
folding: true,
lineDecorationsWidth: 10,
lineNumbersMinChars: 3,
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
useShadows: false,
verticalHasArrows: false,
horizontalHasArrows: false,
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10
}
}}
loading={
<div className="flex items-center justify-center h-full">
<div className="rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
}
/>
</div>
)
}

View File

@@ -2,6 +2,98 @@
@tailwind components;
@tailwind utilities;
/* 文件管理相关样式 */
.file-context-menu .ant-dropdown-menu {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 8px;
padding: 4px;
}
.dark .file-context-menu .ant-dropdown-menu {
background: rgba(31, 41, 55, 0.95);
border: 1px solid rgba(75, 85, 99, 0.3);
}
.file-context-menu .ant-dropdown-menu-item {
border-radius: 4px;
margin: 1px 0;
}
.file-context-menu .ant-dropdown-menu-item:hover {
background: rgba(59, 130, 246, 0.1);
}
.file-context-menu .ant-dropdown-menu-item-danger:hover {
background: rgba(239, 68, 68, 0.1);
}
/* 文件网格项悬停效果 */
.file-grid-item:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
/* Monaco Editor 容器样式 */
.monaco-editor-container {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.dark .monaco-editor-container {
border-color: #374151;
}
/* Ant Design 组件自定义样式 */
.ant-modal {
backdrop-filter: blur(4px);
}
.ant-modal-content {
border-radius: 12px;
overflow: hidden;
}
.ant-tabs-content-holder {
height: calc(100% - 46px);
}
.ant-tabs-tabpane {
height: 100%;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
.dark ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 游戏风格自定义样式 */
@layer base {
* {

View File

@@ -0,0 +1,605 @@
import React, { useEffect, useState, useCallback } from 'react'
import {
Button,
Input,
Breadcrumb,
Spin,
Empty,
message,
Tooltip,
Space,
Tabs,
Card,
Modal
} from 'antd'
import {
HomeOutlined,
FolderOutlined,
PlusOutlined,
UploadOutlined,
DeleteOutlined,
ReloadOutlined,
SearchOutlined,
LeftOutlined,
RightOutlined,
FileTextOutlined,
SaveOutlined,
CloseOutlined
} from '@ant-design/icons'
import { useFileStore } from '@/stores/fileStore'
import { useNotificationStore } from '@/stores/notificationStore'
import { FileGridItem } from '@/components/FileGridItem'
import { FileContextMenu } from '@/components/FileContextMenu'
import {
CreateDialog,
RenameDialog,
UploadDialog,
DeleteConfirmDialog
} from '@/components/FileDialogs'
import { MonacoEditor } from '@/components/MonacoEditor'
import { FileItem } from '@/types/file'
import { fileApiClient } from '@/utils/fileApi'
import { isTextFile } from '@/utils/format'
import { normalizePath, getDirectoryPath, getBasename } from '@/utils/pathUtils'
const { TabPane } = Tabs
const FileManagerPage: React.FC = () => {
const {
currentPath,
files,
selectedFiles,
loading,
error,
openFiles,
activeFile,
setCurrentPath,
loadFiles,
selectFile,
unselectFile,
clearSelection,
toggleFileSelection,
createDirectory,
deleteSelectedFiles,
renameFile,
uploadFiles,
openFile,
closeFile,
saveFile,
setActiveFile,
setError
} = useFileStore()
const { addNotification } = useNotificationStore()
// 对话框状态
const [createDialog, setCreateDialog] = useState<{
visible: boolean
type: 'file' | 'folder'
}>({ visible: false, type: 'folder' })
const [renameDialog, setRenameDialog] = useState<{
visible: boolean
file: FileItem | null
}>({ visible: false, file: null })
const [uploadDialog, setUploadDialog] = useState(false)
const [deleteDialog, setDeleteDialog] = useState(false)
// 路径输入
const [pathInput, setPathInput] = useState('')
const [isEditingPath, setIsEditingPath] = useState(false)
// 搜索
const [searchQuery, setSearchQuery] = useState('')
// 历史记录
const [history, setHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
// 编辑器模态框
const [editorModalVisible, setEditorModalVisible] = useState(false)
// 初始化
useEffect(() => {
loadFiles()
}, [])
// 错误处理
useEffect(() => {
if (error) {
addNotification({
type: 'error',
title: '操作失败',
message: error
})
setError(null)
}
}, [error, addNotification, setError])
// 更新路径输入
useEffect(() => {
setPathInput(currentPath)
}, [currentPath])
// 导航到指定路径
const navigateToPath = useCallback((newPath: string) => {
const normalizedPath = normalizePath(newPath)
// 更新历史记录
const newHistory = history.slice(0, historyIndex + 1)
newHistory.push(normalizedPath)
setHistory(newHistory)
setHistoryIndex(newHistory.length - 1)
setCurrentPath(normalizedPath)
}, [history, historyIndex, setCurrentPath])
// 后退
const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
setCurrentPath(history[newIndex])
}
}
// 前进
const goForward = () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setCurrentPath(history[newIndex])
}
}
// 上级目录
const goUp = () => {
const parentPath = getDirectoryPath(currentPath)
if (parentPath !== currentPath) {
navigateToPath(parentPath)
}
}
// 处理路径输入
const handlePathSubmit = () => {
if (pathInput.trim()) {
navigateToPath(pathInput.trim())
}
setIsEditingPath(false)
}
// 文件点击处理
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) {
// Ctrl/Cmd + 点击:多选
toggleFileSelection(file.path)
} else if (event.shiftKey && selectedFiles.size > 0) {
// Shift + 点击:范围选择
const lastSelected = Array.from(selectedFiles)[selectedFiles.size - 1]
const lastIndex = files.findIndex(f => f.path === lastSelected)
const currentIndex = files.findIndex(f => f.path === file.path)
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex)
const end = Math.max(lastIndex, currentIndex)
const rangeFiles = files.slice(start, end + 1).map(f => f.path)
clearSelection()
rangeFiles.forEach(path => selectFile(path))
}
} else {
// 普通点击:单选
clearSelection()
selectFile(file.path)
}
}
// 文件双击处理
const handleFileDoubleClick = (file: FileItem) => {
if (file.type === 'directory') {
navigateToPath(file.path)
} else if (isTextFile(file.name)) {
openFile(file.path)
setEditorModalVisible(true)
} else {
// 非文本文件,提示下载
message.info('该文件类型不支持在线编辑,请下载查看')
}
}
// 右键菜单处理
const handleContextMenuOpen = (file: FileItem) => {
if (file.type === 'directory') {
navigateToPath(file.path)
} else {
openFile(file.path)
setEditorModalVisible(true)
}
}
const handleContextMenuRename = (file: FileItem) => {
setRenameDialog({ visible: true, file })
}
const handleContextMenuDelete = (files: FileItem[]) => {
// 选中要删除的文件
clearSelection()
files.forEach(file => selectFile(file.path))
setDeleteDialog(true)
}
const handleContextMenuDownload = (file: FileItem) => {
fileApiClient.downloadFile(file.path)
addNotification({
type: 'success',
title: '下载开始',
message: `正在下载 ${file.name}`
})
}
const handleContextMenuCopy = (files: FileItem[]) => {
// TODO: 实现复制功能
message.info('复制功能开发中')
}
const handleContextMenuCut = (files: FileItem[]) => {
// TODO: 实现剪切功能
message.info('剪切功能开发中')
}
const handleContextMenuView = (file: FileItem) => {
if (isTextFile(file.name)) {
openFile(file.path)
setEditorModalVisible(true)
} else {
message.info('该文件类型不支持预览')
}
}
// 对话框处理
const handleCreateConfirm = async (name: string) => {
if (createDialog.type === 'folder') {
const success = await createDirectory(name)
if (success) {
addNotification({
type: 'success',
title: '创建成功',
message: `文件夹 "${name}" 创建成功`
})
}
} else {
// TODO: 创建文件
message.info('创建文件功能开发中')
}
setCreateDialog({ visible: false, type: 'folder' })
}
const handleRenameConfirm = async (newName: string) => {
if (renameDialog.file) {
const success = await renameFile(renameDialog.file.path, newName)
if (success) {
addNotification({
type: 'success',
title: '重命名成功',
message: `"${renameDialog.file.name}" 已重命名为 "${newName}"`
})
}
}
setRenameDialog({ visible: false, file: null })
}
const handleUploadConfirm = async (files: FileList) => {
const success = await uploadFiles(files)
if (success) {
addNotification({
type: 'success',
title: '上传成功',
message: `成功上传 ${files.length} 个文件`
})
}
setUploadDialog(false)
}
const handleDeleteConfirm = async () => {
const success = await deleteSelectedFiles()
if (success) {
addNotification({
type: 'success',
title: '删除成功',
message: `成功删除 ${selectedFiles.size} 个项目`
})
}
setDeleteDialog(false)
}
// 编辑器相关
const handleEditorChange = (path: string, content: string) => {
const newOpenFiles = new Map(openFiles)
newOpenFiles.set(path, content)
// 这里需要更新store中的openFiles
}
const handleSaveFile = async () => {
if (activeFile && openFiles.has(activeFile)) {
const content = openFiles.get(activeFile)!
const success = await saveFile(activeFile, content)
if (success) {
addNotification({
type: 'success',
title: '保存成功',
message: `文件已保存`
})
}
}
}
// 生成面包屑
const generateBreadcrumbs = () => {
const parts = currentPath.split('/').filter(Boolean)
const items = [
{
title: (
<span className="flex items-center cursor-pointer" onClick={() => navigateToPath('/')}>
<HomeOutlined className="mr-1" />
</span>
)
}
]
let currentBreadcrumbPath = ''
parts.forEach((part, index) => {
currentBreadcrumbPath += '/' + part
const breadcrumbPath = currentBreadcrumbPath
items.push({
title: (
<span
className="cursor-pointer hover:text-blue-500 text-gray-900 dark:text-white"
onClick={() => navigateToPath(breadcrumbPath)}
>
{part}
</span>
)
})
})
return items
}
// 过滤文件
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
{/* 工具栏 */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-2">
{/* 导航按钮 */}
<Space>
<Tooltip title="后退">
<Button
icon={<LeftOutlined />}
disabled={historyIndex <= 0}
onClick={goBack}
/>
</Tooltip>
<Tooltip title="前进">
<Button
icon={<RightOutlined />}
disabled={historyIndex >= history.length - 1}
onClick={goForward}
/>
</Tooltip>
<Tooltip title="上级目录">
<Button
icon={<FolderOutlined />}
onClick={goUp}
/>
</Tooltip>
<Tooltip title="刷新">
<Button
icon={<ReloadOutlined />}
onClick={() => loadFiles()}
loading={loading}
/>
</Tooltip>
</Space>
</div>
<div className="flex items-center space-x-2">
{/* 搜索 */}
<Input
placeholder="搜索文件..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-64"
/>
{/* 操作按钮 */}
<Space>
<Tooltip title="新建文件夹">
<Button
icon={<PlusOutlined />}
onClick={() => setCreateDialog({ visible: true, type: 'folder' })}
>
</Button>
</Tooltip>
<Tooltip title="上传文件">
<Button
icon={<UploadOutlined />}
onClick={() => setUploadDialog(true)}
>
</Button>
</Tooltip>
{selectedFiles.size > 0 && (
<Tooltip title="删除选中项">
<Button
danger
icon={<DeleteOutlined />}
onClick={() => setDeleteDialog(true)}
>
({selectedFiles.size})
</Button>
</Tooltip>
)}
</Space>
</div>
</div>
{/* 路径栏 */}
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
{isEditingPath ? (
<Input
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onPressEnter={handlePathSubmit}
onBlur={handlePathSubmit}
autoFocus
/>
) : (
<div onClick={() => setIsEditingPath(true)} className="cursor-pointer">
<Breadcrumb items={generateBreadcrumbs()} />
</div>
)}
</div>
{/* 主内容区 */}
<div className="flex-1 p-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<Spin size="large" />
</div>
) : filteredFiles.length === 0 ? (
<Empty
description="此文件夹为空"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
{filteredFiles.map((file) => (
<FileContextMenu
key={file.path}
file={file}
selectedFiles={selectedFiles}
onOpen={handleContextMenuOpen}
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
onDownload={handleContextMenuDownload}
onCopy={handleContextMenuCopy}
onCut={handleContextMenuCut}
onView={handleContextMenuView}
>
<FileGridItem
file={file}
isSelected={selectedFiles.has(file.path)}
onClick={handleFileClick}
onDoubleClick={handleFileDoubleClick}
/>
</FileContextMenu>
))}
</div>
)}
</div>
{/* 对话框 */}
<CreateDialog
visible={createDialog.visible}
type={createDialog.type}
onConfirm={handleCreateConfirm}
onCancel={() => setCreateDialog({ visible: false, type: 'folder' })}
/>
<RenameDialog
visible={renameDialog.visible}
currentName={renameDialog.file?.name || ''}
onConfirm={handleRenameConfirm}
onCancel={() => setRenameDialog({ visible: false, file: null })}
/>
<UploadDialog
visible={uploadDialog}
onConfirm={handleUploadConfirm}
onCancel={() => setUploadDialog(false)}
/>
<DeleteConfirmDialog
visible={deleteDialog}
fileNames={Array.from(selectedFiles).map(path => path.split('/').pop() || '')}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteDialog(false)}
/>
{/* 编辑器模态框 */}
<Modal
title="文本编辑器"
open={editorModalVisible}
onCancel={() => setEditorModalVisible(false)}
width="90%"
style={{ top: 20 }}
bodyStyle={{ height: '80vh', padding: 0 }}
footer={[
<Button key="close" onClick={() => setEditorModalVisible(false)}>
</Button>,
<Button
key="save"
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveFile}
disabled={!activeFile}
>
</Button>
]}
>
{openFiles.size > 0 && (
<Tabs
type="editable-card"
activeKey={activeFile || undefined}
onChange={setActiveFile}
onEdit={(targetKey, action) => {
if (action === 'remove' && typeof targetKey === 'string') {
closeFile(targetKey)
if (openFiles.size === 1) {
setEditorModalVisible(false)
}
}
}}
className="h-full"
>
{Array.from(openFiles.entries()).map(([filePath, content]) => (
<TabPane
key={filePath}
tab={
<span className="flex items-center">
<FileTextOutlined className="mr-1" />
{getBasename(filePath)}
</span>
}
closable
>
<div style={{ height: 'calc(80vh - 100px)' }}>
<MonacoEditor
value={content}
onChange={(value) => handleEditorChange(filePath, value)}
fileName={getBasename(filePath)}
onSave={handleSaveFile}
/>
</div>
</TabPane>
))}
</Tabs>
)}
</Modal>
</div>
)
}
export default FileManagerPage

View File

@@ -0,0 +1,253 @@
import { create } from 'zustand'
import { FileItem } from '@/types/file'
import { fileApiClient } from '@/utils/fileApi'
interface FileStore {
// 状态
currentPath: string
files: FileItem[]
selectedFiles: Set<string>
loading: boolean
error: string | null
// 编辑器相关
openFiles: Map<string, string> // path -> content
activeFile: string | null
// 操作方法
setCurrentPath: (path: string) => void
loadFiles: (path?: string) => Promise<void>
selectFile: (path: string) => void
selectMultipleFiles: (paths: string[]) => void
unselectFile: (path: string) => void
clearSelection: () => void
toggleFileSelection: (path: string) => void
// 文件操作
createDirectory: (name: string) => Promise<boolean>
deleteSelectedFiles: () => Promise<boolean>
renameFile: (oldPath: string, newName: string) => Promise<boolean>
uploadFiles: (files: FileList) => Promise<boolean>
// 编辑器操作
openFile: (path: string) => Promise<void>
closeFile: (path: string) => void
saveFile: (path: string, content: string) => Promise<boolean>
setActiveFile: (path: string | null) => void
// 工具方法
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
}
export const useFileStore = create<FileStore>((set, get) => ({
// 初始状态
currentPath: '/',
files: [],
selectedFiles: new Set(),
loading: false,
error: null,
openFiles: new Map(),
activeFile: null,
// 设置当前路径
setCurrentPath: (path: string) => {
set({ currentPath: path })
get().loadFiles(path)
},
// 加载文件列表
loadFiles: async (path?: string) => {
const targetPath = path || get().currentPath
set({ loading: true, error: null })
try {
const files = await fileApiClient.listDirectory(targetPath)
// 排序:文件夹优先,然后按名称排序
const sortedFiles = files.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1
}
return a.name.localeCompare(b.name)
})
set({
files: sortedFiles,
currentPath: targetPath,
loading: false,
selectedFiles: new Set() // 清空选择
})
} catch (error: any) {
set({
error: error.message || '加载文件列表失败',
loading: false
})
}
},
// 选择文件
selectFile: (path: string) => {
const selectedFiles = new Set(get().selectedFiles)
selectedFiles.add(path)
set({ selectedFiles })
},
// 选择多个文件
selectMultipleFiles: (paths: string[]) => {
const selectedFiles = new Set(get().selectedFiles)
paths.forEach(path => selectedFiles.add(path))
set({ selectedFiles })
},
// 取消选择文件
unselectFile: (path: string) => {
const selectedFiles = new Set(get().selectedFiles)
selectedFiles.delete(path)
set({ selectedFiles })
},
// 清空选择
clearSelection: () => {
set({ selectedFiles: new Set() })
},
// 切换文件选择状态
toggleFileSelection: (path: string) => {
const selectedFiles = new Set(get().selectedFiles)
if (selectedFiles.has(path)) {
selectedFiles.delete(path)
} else {
selectedFiles.add(path)
}
set({ selectedFiles })
},
// 创建目录
createDirectory: async (name: string) => {
const { currentPath } = get()
const newPath = `${currentPath}/${name}`.replace(/\/+/g, '/')
try {
await fileApiClient.createDirectory(newPath)
await get().loadFiles()
return true
} catch (error: any) {
set({ error: error.message || '创建目录失败' })
return false
}
},
// 删除选中的文件
deleteSelectedFiles: async () => {
const { selectedFiles } = get()
if (selectedFiles.size === 0) return false
try {
await fileApiClient.deleteItems(Array.from(selectedFiles))
await get().loadFiles()
set({ selectedFiles: new Set() })
return true
} catch (error: any) {
set({ error: error.message || '删除文件失败' })
return false
}
},
// 重命名文件
renameFile: async (oldPath: string, newName: string) => {
const newPath = oldPath.replace(/[^/]*$/, newName)
try {
await fileApiClient.renameItem(oldPath, newPath)
await get().loadFiles()
return true
} catch (error: any) {
set({ error: error.message || '重命名失败' })
return false
}
},
// 上传文件
uploadFiles: async (files: FileList) => {
const { currentPath } = get()
try {
await fileApiClient.uploadFiles(currentPath, files)
await get().loadFiles()
return true
} catch (error: any) {
set({ error: error.message || '上传文件失败' })
return false
}
},
// 打开文件
openFile: async (path: string) => {
const { openFiles } = get()
if (openFiles.has(path)) {
set({ activeFile: path })
return
}
try {
const fileContent = await fileApiClient.readFile(path)
const newOpenFiles = new Map(openFiles)
newOpenFiles.set(path, fileContent.content)
set({
openFiles: newOpenFiles,
activeFile: path
})
} catch (error: any) {
set({ error: error.message || '打开文件失败' })
}
},
// 关闭文件
closeFile: (path: string) => {
const { openFiles, activeFile } = get()
const newOpenFiles = new Map(openFiles)
newOpenFiles.delete(path)
const newActiveFile = activeFile === path ?
(newOpenFiles.size > 0 ? Array.from(newOpenFiles.keys())[0] : null) :
activeFile
set({
openFiles: newOpenFiles,
activeFile: newActiveFile
})
},
// 保存文件
saveFile: async (path: string, content: string) => {
try {
await fileApiClient.saveFile(path, content)
const { openFiles } = get()
const newOpenFiles = new Map(openFiles)
newOpenFiles.set(path, content)
set({ openFiles: newOpenFiles })
return true
} catch (error: any) {
set({ error: error.message || '保存文件失败' })
return false
}
},
// 设置活动文件
setActiveFile: (path: string | null) => {
set({ activeFile: path })
},
// 设置加载状态
setLoading: (loading: boolean) => {
set({ loading })
},
// 设置错误信息
setError: (error: string | null) => {
set({ error })
}
}))

33
client/src/types/file.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface FileItem {
name: string
path: string
type: 'file' | 'directory'
size: number
modified: string
}
export interface FileOperationResult {
status: 'success' | 'error'
message: string
data?: any
}
export interface FileUploadProgress {
fileName: string
progress: number
status: 'uploading' | 'completed' | 'error'
}
export interface FileSearchResult {
status: 'success' | 'error'
results: FileItem[]
total_found: number
truncated: boolean
}
export interface FileContent {
content: string
encoding: string
size: number
modified: string
}

View File

@@ -197,4 +197,7 @@ export interface SettingsState {
resetSettings: () => void
loading: boolean
error: string | null
}
}
// 文件管理相关类型
export * from './file'

125
client/src/utils/fileApi.ts Normal file
View File

@@ -0,0 +1,125 @@
import axios from 'axios'
import { FileItem, FileOperationResult, FileSearchResult, FileContent } from '@/types/file'
const API_BASE = '/api/files'
export class FileApiClient {
// 获取目录内容
async listDirectory(path: string = '/'): Promise<FileItem[]> {
const response = await axios.get(`${API_BASE}/list`, {
params: { path }
})
return response.data.data
}
// 读取文件内容
async readFile(path: string, encoding: string = 'utf-8'): Promise<FileContent> {
const response = await axios.get(`${API_BASE}/read`, {
params: { path, encoding }
})
return response.data.data
}
// 保存文件内容
async saveFile(path: string, content: string, encoding: string = 'utf-8'): Promise<FileOperationResult> {
const response = await axios.post(`${API_BASE}/save`, {
path,
content,
encoding
})
return response.data
}
// 创建目录
async createDirectory(path: string): Promise<FileOperationResult> {
const response = await axios.post(`${API_BASE}/mkdir`, {
path
})
return response.data
}
// 删除文件或目录
async deleteItems(paths: string[]): Promise<FileOperationResult> {
const response = await axios.delete(`${API_BASE}/delete`, {
data: { paths }
})
return response.data
}
// 重命名文件或目录
async renameItem(oldPath: string, newPath: string): Promise<FileOperationResult> {
const response = await axios.post(`${API_BASE}/rename`, {
oldPath,
newPath
})
return response.data
}
// 复制文件或目录
async copyItem(sourcePath: string, targetPath: string): Promise<FileOperationResult> {
const response = await axios.post(`${API_BASE}/copy`, {
sourcePath,
targetPath
})
return response.data
}
// 移动文件或目录
async moveItem(sourcePath: string, targetPath: string): Promise<FileOperationResult> {
const response = await axios.post(`${API_BASE}/move`, {
sourcePath,
targetPath
})
return response.data
}
// 搜索文件
async searchFiles(
searchPath: string,
query: string,
type: string = 'all',
caseSensitive: boolean = false,
maxResults: number = 100
): Promise<FileSearchResult> {
const response = await axios.get(`${API_BASE}/search`, {
params: {
path: searchPath,
query,
type,
case_sensitive: caseSensitive,
max_results: maxResults
}
})
return response.data
}
// 下载文件
downloadFile(path: string): void {
const url = `${API_BASE}/download?path=${encodeURIComponent(path)}`
const link = document.createElement('a')
link.href = url
link.download = ''
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 上传文件
async uploadFiles(targetPath: string, files: FileList): Promise<FileOperationResult> {
const formData = new FormData()
formData.append('targetPath', targetPath)
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i])
}
const response = await axios.post(`${API_BASE}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
}
}
export const fileApiClient = new FileApiClient()

View File

@@ -0,0 +1,81 @@
// 格式化文件大小
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化日期
export const formatDate = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
// 今天
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
} else if (diffDays === 1) {
// 昨天
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
} else if (diffDays < 7) {
// 一周内
return diffDays + '天前'
} else {
// 超过一周
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
}
// 获取文件扩展名
export const getFileExtension = (fileName: string): string => {
return fileName.split('.').pop()?.toLowerCase() || ''
}
// 判断是否为图片文件
export const isImageFile = (fileName: string): boolean => {
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp']
return imageExts.includes(getFileExtension(fileName))
}
// 判断是否为文本文件
export const isTextFile = (fileName: string): boolean => {
const textExts = [
'txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'jsx', 'tsx',
'py', 'java', 'cpp', 'c', 'h', 'php', 'go', 'rs', 'sql', 'yml', 'yaml',
'ini', 'conf', 'log', 'csv', 'scss', 'less', 'vue', 'svelte'
]
return textExts.includes(getFileExtension(fileName))
}
// 判断是否为视频文件
export const isVideoFile = (fileName: string): boolean => {
const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v']
return videoExts.includes(getFileExtension(fileName))
}
// 判断是否为音频文件
export const isAudioFile = (fileName: string): boolean => {
const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma']
return audioExts.includes(getFileExtension(fileName))
}
// 判断是否为压缩文件
export const isArchiveFile = (fileName: string): boolean => {
const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz']
return archiveExts.includes(getFileExtension(fileName))
}

View File

@@ -0,0 +1,8 @@
import loader from '@monaco-editor/loader'
import * as monaco from 'monaco-editor'
// 配置@monaco-editor/react使用本地的monaco-editor包而不是CDN
loader.config({ monaco })
export default loader
export { monaco }

View File

@@ -0,0 +1,168 @@
/**
* 浏览器兼容的路径处理工具函数
* 替代Node.js的path模块在浏览器环境中的使用
*/
/**
* 标准化路径类似于path.normalize
* @param pathStr 要标准化的路径
* @returns 标准化后的路径
*/
export function normalizePath(pathStr: string): string {
if (!pathStr) return '/'
// 将反斜杠转换为正斜杠
let normalized = pathStr.replace(/\\/g, '/')
// 确保以/开头
if (!normalized.startsWith('/')) {
normalized = '/' + normalized
}
// 处理多个连续的斜杠
normalized = normalized.replace(/\/+/g, '/')
// 处理 . 和 .. 路径段
const parts = normalized.split('/').filter(part => part !== '')
const stack: string[] = []
for (const part of parts) {
if (part === '..') {
if (stack.length > 0) {
stack.pop()
}
} else if (part !== '.') {
stack.push(part)
}
}
// 重新构建路径
const result = '/' + stack.join('/')
return result === '/' ? '/' : result
}
/**
* 获取路径的目录部分类似于path.dirname
* @param pathStr 文件路径
* @returns 目录路径
*/
export function getDirectoryPath(pathStr: string): string {
if (!pathStr || pathStr === '/') return '/'
const normalized = normalizePath(pathStr)
const lastSlashIndex = normalized.lastIndexOf('/')
if (lastSlashIndex === 0) {
return '/'
}
return normalized.substring(0, lastSlashIndex)
}
/**
* 连接路径类似于path.join
* @param paths 要连接的路径段
* @returns 连接后的路径
*/
export function joinPaths(...paths: string[]): string {
if (paths.length === 0) return '/'
const joined = paths
.filter(path => path && path.length > 0)
.join('/')
return normalizePath(joined)
}
/**
* 获取文件名类似于path.basename
* @param pathStr 文件路径
* @param ext 可选的扩展名,如果提供则会从结果中移除
* @returns 文件名
*/
export function getBasename(pathStr: string, ext?: string): string {
if (!pathStr) return ''
const normalized = normalizePath(pathStr)
const parts = normalized.split('/')
let basename = parts[parts.length - 1] || ''
if (ext && basename.endsWith(ext)) {
basename = basename.substring(0, basename.length - ext.length)
}
return basename
}
/**
* 获取文件扩展名类似于path.extname
* @param pathStr 文件路径
* @returns 文件扩展名(包含点号)
*/
export function getExtension(pathStr: string): string {
if (!pathStr) return ''
const basename = getBasename(pathStr)
const lastDotIndex = basename.lastIndexOf('.')
if (lastDotIndex === -1 || lastDotIndex === 0) {
return ''
}
return basename.substring(lastDotIndex)
}
/**
* 检查路径是否为绝对路径
* @param pathStr 要检查的路径
* @returns 是否为绝对路径
*/
export function isAbsolute(pathStr: string): boolean {
return pathStr && pathStr.startsWith('/')
}
/**
* 获取相对路径
* @param from 起始路径
* @param to 目标路径
* @returns 相对路径
*/
export function getRelativePath(from: string, to: string): string {
const fromNormalized = normalizePath(from)
const toNormalized = normalizePath(to)
if (fromNormalized === toNormalized) {
return '.'
}
const fromParts = fromNormalized.split('/').filter(part => part !== '')
const toParts = toNormalized.split('/').filter(part => part !== '')
// 找到公共前缀
let commonLength = 0
const minLength = Math.min(fromParts.length, toParts.length)
for (let i = 0; i < minLength; i++) {
if (fromParts[i] === toParts[i]) {
commonLength++
} else {
break
}
}
// 构建相对路径
const upLevels = fromParts.length - commonLength
const downParts = toParts.slice(commonLength)
const relativeParts: string[] = []
// 添加向上的路径
for (let i = 0; i < upLevels; i++) {
relativeParts.push('..')
}
// 添加向下的路径
relativeParts.push(...downParts)
return relativeParts.length === 0 ? '.' : relativeParts.join('/')
}

View File

@@ -23,6 +23,10 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
define: {
global: 'globalThis',
'process.env': {},
},
server: {
port: clientPort,
proxy: {

13
install-deps.md Normal file
View File

@@ -0,0 +1,13 @@
# 安装文件管理功能所需依赖
请在client目录下运行以下命令安装依赖
```bash
cd client
npm install antd @monaco-editor/react monaco-editor
```
这将安装:
- antd: Ant Design UI组件库
- @monaco-editor/react: Monaco Editor的React封装
- monaco-editor: VS Code编辑器内核

View File

@@ -19,6 +19,7 @@ import { setupGameRoutes } from './routes/games.js'
import { setupSystemRoutes } from './routes/system.js'
import { setupAuthRoutes } from './routes/auth.js'
import { setAuthManager } from './middleware/auth.js'
import filesRouter from './routes/files.js'
// 获取当前文件目录
const __filename = fileURLToPath(import.meta.url)
@@ -229,6 +230,7 @@ async function startServer() {
app.use('/api/terminal', setupTerminalRoutes(terminalManager))
app.use('/api/game', setupGameRoutes(gameManager))
app.use('/api/system', setupSystemRoutes(systemManager))
app.use('/api/files', filesRouter)
// 前端路由处理SPA支持
app.get('*', (req, res) => {

View File

@@ -1,76 +0,0 @@
20:35:24 [vite] Pre-transform error: D:\Cursor项目\GSM3\client\src\pages\TerminalPage.tsx: Unexpected token, expected "," (703:6)
701 | </div>
702 |
> 703 | {/* 右侧终端显示区域 */}
| ^
704 | <div className="flex-1 flex flex-col min-w-0">
705 | {sessions.length === 0 ? (
706 | <div className="flex-1 flex items-center justify-center bg-gray-900">
20:35:24 [vite] Internal server error: D:\Cursor项目\GSM3\client\src\pages\TerminalPage.tsx: Unexpected token, expected "," (703:6)
701 | </div>
702 |
> 703 | {/* 右侧终端显示区域 */}
| ^
704 | <div className="flex-1 flex flex-col min-w-0">
705 | {sessions.length === 0 ? (
706 | <div className="flex-1 flex items-center justify-center bg-gray-900">
Plugin: vite:react-babel
File: D:/Cursor项目/GSM3/client/src/pages/TerminalPage.tsx:703:6
719| <>
720| {/* 终端头部 */}
721| <div className="flex-shrink-0 bg-gray-800/50 backdrop-blur-sm border-b border-gray-700/50 px-4 py-3">
| ^
722| <div className="flex items-center justify-between">
723| <div className="flex items-center space-x-3">
at constructor (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:367:19)
at TypeScriptParserMixin.raise (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:6627:19)
at TypeScriptParserMixin.unexpected (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:6647:16)
at TypeScriptParserMixin.expect (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:6927:12)
at TypeScriptParserMixin.parseParenAndDistinguishExpression (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11660:14)
at TypeScriptParserMixin.parseExprAtom (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11326:23)
at TypeScriptParserMixin.parseExprAtom (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:4794:20)
at TypeScriptParserMixin.parseExprSubscripts (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11076:23)
at TypeScriptParserMixin.parseUpdate (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11061:21)
at TypeScriptParserMixin.parseMaybeUnary (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11041:23)
at TypeScriptParserMixin.parseMaybeUnary (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9852:18)
at TypeScriptParserMixin.parseMaybeUnaryOrPrivate (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10894:61)
at TypeScriptParserMixin.parseExprOpBaseRightExpr (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10981:34)
at TypeScriptParserMixin.parseExprOpRightExpr (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10976:21)
at TypeScriptParserMixin.parseExprOp (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10942:27)
at TypeScriptParserMixin.parseExprOp (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9389:18)
at TypeScriptParserMixin.parseExprOps (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10903:17)
at TypeScriptParserMixin.parseMaybeConditional (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10876:23)
at TypeScriptParserMixin.parseMaybeAssign (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10826:21)
at TypeScriptParserMixin.parseMaybeAssign (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9801:20)
at TypeScriptParserMixin.parseExpressionBase (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10779:23)
at D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10775:39
at TypeScriptParserMixin.allowInAnd (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:12427:12)
at TypeScriptParserMixin.parseExpression (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10775:17)
at TypeScriptParserMixin.jsxParseExpressionContainer (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:4662:31)
at TypeScriptParserMixin.jsxParseElementAt (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:4741:36)
at TypeScriptParserMixin.jsxParseElement (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:4779:17)
at TypeScriptParserMixin.parseExprAtom (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:4789:19)
at TypeScriptParserMixin.parseExprSubscripts (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11076:23)
at TypeScriptParserMixin.parseUpdate (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11061:21)
at TypeScriptParserMixin.parseMaybeUnary (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11041:23)
at TypeScriptParserMixin.parseMaybeUnary (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9852:18)
at TypeScriptParserMixin.parseMaybeUnaryOrPrivate (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10894:61)
at TypeScriptParserMixin.parseExprOps (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10899:23)
at TypeScriptParserMixin.parseMaybeConditional (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10876:23)
at TypeScriptParserMixin.parseMaybeAssign (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10826:21)
at D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9790:39
at TypeScriptParserMixin.tryParse (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:6935:20)
at TypeScriptParserMixin.parseMaybeAssign (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9790:18)
at D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10795:39
at TypeScriptParserMixin.allowInAnd (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:12427:12)
at TypeScriptParserMixin.parseMaybeAssignAllowIn (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:10795:17)
at TypeScriptParserMixin.parseMaybeAssignAllowInOrVoidPattern (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:12494:17)
at TypeScriptParserMixin.parseParenAndDistinguishExpression (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11674:28)
at TypeScriptParserMixin.parseExprAtom (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11326:23)
at TypeScriptParserMixin.parseExprAtom (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:4794:20)
at TypeScriptParserMixin.parseExprSubscripts (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11076:23)
at TypeScriptParserMixin.parseUpdate (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11061:21)
at TypeScriptParserMixin.parseMaybeUnary (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:11041:23)
at TypeScriptParserMixin.parseMaybeUnary (D:\Cursor项目\GSM3\client\node_modules\@babel\parser\lib\index.js:9852:18)