mirror of
https://github.com/GSManagerXZ/GameServerManager.git
synced 2026-06-09 01:14:45 +08:00
文件管理
This commit is contained in:
31
README.md
31
README.md
@@ -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
1077
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
134
client/src/components/FileContextMenu.tsx
Normal file
134
client/src/components/FileContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
288
client/src/components/FileDialogs.tsx
Normal file
288
client/src/components/FileDialogs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
client/src/components/FileGridItem.tsx
Normal file
155
client/src/components/FileGridItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
161
client/src/components/MonacoEditor.tsx
Normal file
161
client/src/components/MonacoEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
* {
|
||||
|
||||
605
client/src/pages/FileManagerPage.tsx
Normal file
605
client/src/pages/FileManagerPage.tsx
Normal 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
|
||||
253
client/src/stores/fileStore.ts
Normal file
253
client/src/stores/fileStore.ts
Normal 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
33
client/src/types/file.ts
Normal 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
|
||||
}
|
||||
@@ -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
125
client/src/utils/fileApi.ts
Normal 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()
|
||||
81
client/src/utils/format.ts
Normal file
81
client/src/utils/format.ts
Normal 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))
|
||||
}
|
||||
8
client/src/utils/monaco.ts
Normal file
8
client/src/utils/monaco.ts
Normal 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 }
|
||||
168
client/src/utils/pathUtils.ts
Normal file
168
client/src/utils/pathUtils.ts
Normal 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('/')
|
||||
}
|
||||
@@ -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
13
install-deps.md
Normal 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编辑器内核
|
||||
@@ -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) => {
|
||||
|
||||
76
报错.txt
76
报错.txt
@@ -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)
|
||||
Reference in New Issue
Block a user