Initial commit: File manager with upload, search, and modal dialogs

This commit is contained in:
Admin
2026-01-22 16:49:21 +08:00
commit 267ae69030
8 changed files with 2884 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go
/go
/pkg
/bin
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Local data
files/
*.log
# Docker
.docker/
docker-compose.override.yml

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY main.go ./
COPY static/ ./static/
RUN echo 'module file-manager' > go.mod && \
echo 'go 1.21' >> go.mod && \
echo '' >> go.mod && \
echo 'require (' >> go.mod && \
echo ' github.com/fsnotify/fsnotify v1.7.0' >> go.mod && \
echo ' github.com/gin-gonic/gin v1.9.1' >> go.mod && \
echo ')' >> go.mod && \
go mod tidy && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o file-manager main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /data
COPY --from=builder /app/file-manager /usr/local/bin/
COPY --from=builder /app/static/ /var/www/static/
EXPOSE 8080
ENV PORT=8080
ENV ROOT=/data
ENTRYPOINT ["file-manager", "-port", "8080", "-root", "/data"]

186
README.md Normal file
View File

@@ -0,0 +1,186 @@
# 📁 文件管理器
一个类似 nginx autoindex 的文件管理器,支持实时显示文件变动、在线上传/删除/移动/重命名等操作。
## 功能特性
### 📂 文件浏览
- 目录导航和面包屑路径
- 文件列表显示(名称、大小、修改时间)
- Emoji 图标显示文件类型
- 支持进入子目录和返回上级
### 📤 上传功能
- 单文件上传
- 批量文件上传
- 拖拽上传支持
### 📥 下载功能
- 单文件下载
- 批量文件打包下载ZIP
- 整个文件夹下载ZIP
### ✏️ 文件操作
- 文件删除(支持批量)
- 文件重命名
- 文件移动(支持跨目录)
### 👁️ 预览功能
- 图片预览
- 文本文件预览
- PDF 文件预览
### 🔄 实时监控
- 文件系统实时监控
- 自动刷新文件列表
- SSE 实时推送通知
### 🎨 界面特性
- 响应式设计
- 深色状态指示
- 流畅动画效果
## 技术栈
- **后端**: Go + Gin
- **前端**: 原生 HTML/CSS/JavaScript
- **实时通信**: Server-Sent Events (SSE)
- **文件监控**: fsnotify
- **部署**: Docker
## 快速开始
### 使用 Docker Compose推荐
```bash
# 构建并启动
docker-compose up -d
# 查看日志
docker-compose logs -f file-manager
# 停止服务
docker-compose down
```
### 使用 Docker
```bash
# 构建镜像
docker build -t file-manager .
# 运行容器
docker run -d \
--name file-manager \
-p 8080:8080 \
-v /path/to/your/files:/data \
file-manager
```
### 本地运行
```bash
# 安装依赖
go mod download
# 启动服务
go run main.go -port 8080 -root ./files
```
## 使用说明
### 访问界面
打开浏览器访问: http://localhost:8080
### 上传文件
1. 点击 **📤 上传** 按钮选择文件
2. 或直接将文件拖拽到页面任意位置
### 下载文件/文件夹
1. 勾选要下载的文件/文件夹
2. 点击 **📥 下载** 按钮
3. 多个文件会打包成 ZIP 下载
### 删除文件
1. 勾选要删除的文件
2. 点击 **🗑️ 删除** 按钮
3. 确认删除操作
### 移动文件
1. 勾选一个文件
2. 点击 **✂️ 移动** 按钮
3. 选择目标目录
4. 点击 **移动** 确认
### 重命名
1. 点击文件操作列的 **✏️** 图标
2. 输入新名称
3. 点击 **确认**
### 新建文件夹
1. 点击 **📁 新建文件夹** 按钮
2. 输入文件夹名称
3. 点击 **创建**
## 配置说明
### 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| PORT | HTTP 监听端口 | 8080 |
| ROOT | 文件根目录 | /data |
### 命令行参数
```bash
file-manager -port 8080 -root /path/to/files
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/files?path=xxx` | 获取文件列表 |
| GET | `/api/preview?path=xxx` | 预览文件 |
| GET | `/api/download?path=xxx` | 下载文件 |
| GET | `/api/download?paths=[xxx,yyy]` | 批量下载 |
| POST | `/api/upload?path=xxx` | 上传文件 |
| POST | `/api/dir` | 创建目录 |
| DELETE | `/api/files` | 删除文件 |
| PUT | `/api/move` | 移动文件 |
| PUT | `/api/rename` | 重命名文件 |
| GET | `/api/watch` | SSE 实时监控 |
## 项目结构
```
file-manager/
├── main.go # 主程序
├── static/
│ ├── index.html # 主页面
│ ├── style.css # 样式
│ └── app.js # 前端逻辑
├── Dockerfile # Docker 构建
├── docker-compose.yml
└── README.md
```
## 安全提示
⚠️ 此文件管理器没有访问控制,请仅在可信环境中使用:
- 不要暴露到公网
- 使用防火墙限制访问
- 或在前端添加认证层
## 许可证
MIT License

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.8'
services:
file-manager:
build:
context: .
dockerfile: Dockerfile
container_name: file-manager
ports:
- "8080:8080"
volumes:
- files-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
volumes:
files-data:
driver: local

714
main.go Normal file
View File

@@ -0,0 +1,714 @@
package main
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"github.com/gin-gonic/gin"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
CanPreview bool `json:"canPreview"`
CanDownload bool `json:"canDownload"`
}
type UploadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type MoveRequest struct {
SrcPath string `json:"srcPath"`
DestPath string `json:"destPath"`
}
type RenameRequest struct {
OldPath string `json:"oldPath"`
NewName string `json:"newName"`
}
type CreateDirRequest struct {
Path string `json:"path"`
Name string `json:"name"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type WatchEvent struct {
Type string `json:"type"`
Path string `json:"path"`
Name string `json:"name"`
}
var (
rootDir string
watcher *fsnotify.Watcher
watchChan chan WatchEvent
)
func init() {
watchChan = make(chan WatchEvent, 100)
}
func getFileType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
return "image"
case ".txt", ".md", ".json", ".xml", ".html", ".css", ".js", ".go", ".py", ".java", ".c", ".cpp", ".h", ".sh":
return "text"
case ".pdf":
return "pdf"
default:
return "other"
}
}
func formatFileSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
func listFiles(c *gin.Context) {
path := c.Query("path")
if path == "" {
path = "."
}
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
file, err := os.Open(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
defer file.Close()
entries, err := file.Readdir(-1)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
var files []FileInfo
for _, entry := range entries {
name := entry.Name()
if name == "." || name == ".." || name == "static" {
continue
}
entryPath := filepath.Join(path, name)
fileType := getFileType(name)
canPreview := false
if !entry.IsDir() {
canPreview = fileType == "image" || fileType == "text" || fileType == "pdf"
}
files = append(files, FileInfo{
Name: name,
Path: entryPath,
IsDir: entry.IsDir(),
Size: entry.Size(),
ModTime: entry.ModTime(),
CanPreview: canPreview,
CanDownload: true,
})
}
c.JSON(http.StatusOK, gin.H{
"files": files,
"path": path,
})
}
func previewFile(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "缺少path参数"})
return
}
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
file, err := os.Open(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
fileType := getFileType(info.Name())
if fileType == "image" {
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
contentType := http.DetectContentType(data)
c.Data(http.StatusOK, contentType, data)
} else if fileType == "text" {
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", data)
} else if fileType == "pdf" {
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Data(http.StatusOK, "application/pdf", data)
} else {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "不支持预览此类型文件"})
}
}
func downloadFile(c *gin.Context) {
pathsParam := c.Query("paths")
if pathsParam == "" {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "缺少path参数"})
return
}
downloadSingle(c, path)
return
}
var paths []string
if err := json.Unmarshal([]byte(pathsParam), &paths); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的paths参数"})
return
}
if len(paths) == 1 {
downloadSingle(c, paths[0])
return
}
downloadMultiple(c, paths)
}
func downloadSingle(c *gin.Context, path string) {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
file, err := os.Open(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
if info.IsDir() {
downloadDirectory(c, path)
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", info.Name()))
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Length", fmt.Sprintf("%d", info.Size()))
io.Copy(c.Writer, file)
}
func downloadDirectory(c *gin.Context, path string) {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
dirName := filepath.Base(path)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip", dirName))
c.Header("Content-Type", "application/zip")
c.Header("Content-Transfer-Encoding", "binary")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
filepath.Walk(fullPath, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(rootDir, filePath)
if relPath == "." {
return nil
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = relPath
header.Method = zip.Deflate
if info.IsDir() {
header.Name += "/"
} else {
writer, err := zw.CreateHeader(header)
if err != nil {
return err
}
if !info.IsDir() {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
io.Copy(writer, file)
}
}
return nil
})
}
func downloadMultiple(c *gin.Context, paths []string) {
c.Header("Content-Disposition", "attachment; filename=files.zip")
c.Header("Content-Type", "application/zip")
c.Header("Content-Transfer-Encoding", "binary")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
for _, path := range paths {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
continue
}
info, err := os.Stat(fullPath)
if err != nil {
continue
}
if info.IsDir() {
filepath.Walk(fullPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return nil
}
relPath, _ := filepath.Rel(rootDir, filePath)
header, err := zip.FileInfoHeader(fileInfo)
if err != nil {
return nil
}
header.Name = relPath
header.Method = zip.Deflate
if fileInfo.IsDir() {
header.Name += "/"
} else {
writer, err := zw.CreateHeader(header)
if err != nil {
return nil
}
file, err := os.Open(filePath)
if err != nil {
return nil
}
defer file.Close()
io.Copy(writer, file)
}
return nil
})
} else {
file, err := os.Open(fullPath)
if err != nil {
continue
}
defer file.Close()
header, err := zip.FileInfoHeader(info)
if err != nil {
continue
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
writer, err := zw.CreateHeader(header)
if err != nil {
continue
}
io.Copy(writer, file)
}
}
}
func uploadFile(c *gin.Context) {
path := c.DefaultQuery("path", "")
if path == "" {
path = "."
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
defer file.Close()
fullPath := filepath.Join(rootDir, path, header.Filename)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: "无效的路径",
})
return
}
outFile, err := os.Create(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
defer outFile.Close()
io.Copy(outFile, file)
watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: header.Filename}
c.JSON(http.StatusOK, UploadResponse{
Success: true,
Message: "上传成功",
})
}
func deleteFiles(c *gin.Context) {
var paths []string
if err := c.ShouldBindJSON(&paths); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
for _, path := range paths {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
continue
}
info, err := os.Stat(fullPath)
if err != nil {
continue
}
if info.IsDir() {
os.RemoveAll(fullPath)
} else {
os.Remove(fullPath)
}
watchChan <- WatchEvent{Type: "delete", Path: fullPath, Name: filepath.Base(path)}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func moveFile(c *gin.Context) {
var req MoveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
srcPath := filepath.Join(rootDir, req.SrcPath)
destPath := filepath.Join(rootDir, req.DestPath)
if !strings.HasPrefix(srcPath, rootDir) || !strings.HasPrefix(destPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
if err := os.Rename(srcPath, destPath); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
watchChan <- WatchEvent{Type: "move", Path: destPath, Name: filepath.Base(destPath)}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func renameFile(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
srcPath := filepath.Join(rootDir, req.OldPath)
newPath := filepath.Join(rootDir, filepath.Dir(req.OldPath), req.NewName)
if !strings.HasPrefix(srcPath, rootDir) || !strings.HasPrefix(newPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
if err := os.Rename(srcPath, newPath); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
watchChan <- WatchEvent{Type: "rename", Path: newPath, Name: req.NewName}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func createDir(c *gin.Context) {
var req CreateDirRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
fullPath := filepath.Join(rootDir, req.Path, req.Name)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
if err := os.MkdirAll(fullPath, 0755); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: req.Name}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func watchFiles(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
c.Writer.Flush()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case event := <-watchChan:
data, _ := json.Marshal(event)
c.SSEvent("message", string(data))
c.Writer.Flush()
case <-ticker.C:
c.SSEvent("ping", time.Now().Format("2006-01-02 15:04:05"))
c.Writer.Flush()
case <-c.Request.Context().Done():
return
}
}
}
func startWatcher() {
var err error
watcher, err = fsnotify.NewWatcher()
if err != nil {
fmt.Printf("警告: 无法创建文件监控器: %v\n", err)
return
}
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create ||
event.Op&fsnotify.Write == fsnotify.Write ||
event.Op&fsnotify.Remove == fsnotify.Remove ||
event.Op&fsnotify.Rename == fsnotify.Rename ||
event.Op&fsnotify.Chmod == fsnotify.Chmod {
relPath, _ := filepath.Rel(rootDir, event.Name)
if relPath == "." {
continue
}
eventType := "modify"
if event.Op&fsnotify.Create == fsnotify.Create {
eventType = "create"
} else if event.Op&fsnotify.Remove == fsnotify.Remove {
eventType = "delete"
} else if event.Op&fsnotify.Rename == fsnotify.Rename {
eventType = "rename"
}
select {
case watchChan <- WatchEvent{Type: eventType, Path: event.Name, Name: filepath.Base(event.Name)}:
default:
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
fmt.Printf("监控错误: %v\n", err)
}
}
}()
watcher.Add(rootDir)
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
watcher.Add(path)
}
return nil
})
}
func main() {
port := "8080"
dir := "./files"
for i := 1; i < len(os.Args); i++ {
switch os.Args[i] {
case "-port":
if i+1 < len(os.Args) {
port = os.Args[i+1]
i++
}
case "-root":
if i+1 < len(os.Args) {
dir = os.Args[i+1]
i++
}
}
}
if dir == "" {
dir = "."
}
if !filepath.IsAbs(dir) {
cwd, _ := os.Getwd()
dir = filepath.Join(cwd, dir)
}
rootDir = dir
if err := os.MkdirAll(rootDir, 0755); err != nil {
fmt.Printf("错误: 无法创建根目录: %v\n", err)
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
router.Use(corsMiddleware())
router.GET("/", func(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
html, err := os.ReadFile("/var/www/static/index.html")
if err != nil {
c.String(http.StatusInternalServerError, "无法加载页面")
return
}
c.String(http.StatusOK, "%s", html)
})
router.Static("/static", "/var/www/static")
api := router.Group("/api")
{
api.GET("/files", listFiles)
api.GET("/preview", previewFile)
api.GET("/download", downloadFile)
api.POST("/upload", uploadFile)
api.DELETE("/files", deleteFiles)
api.PUT("/move", moveFile)
api.PUT("/rename", renameFile)
api.POST("/dir", createDir)
api.GET("/watch", watchFiles)
}
go startWatcher()
addr := fmt.Sprintf(":%s", port)
fmt.Printf("文件管理器已启动: http://localhost:%s\n", port)
fmt.Printf("根目录: %s\n", rootDir)
if err := router.Run(addr); err != nil {
fmt.Printf("错误: %v\n", err)
}
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}

1010
static/app.js Normal file

File diff suppressed because it is too large Load Diff

164
static/index.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📁 文件管理器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header class="header">
<h1>📁 文件管理器</h1>
<div class="status">
<span id="connection-status" class="connected">● 已连接</span>
<span id="path-display">/</span>
</div>
</header>
<nav class="breadcrumb" id="breadcrumb">
<span class="crumb" data-path="/">根目录</span>
</nav>
<div class="toolbar">
<div class="toolbar-left">
<button class="btn btn-primary" id="btn-upload" title="上传文件">📤 上传</button>
<button class="btn btn-secondary" id="btn-new-dir" title="新建文件夹">📁 新建文件夹</button>
</div>
<div class="toolbar-center">
<div class="search-box">
<input type="text" id="search-input" placeholder="搜索文件...">
<button class="search-btn" id="search-btn">🔍</button>
</div>
</div>
<div class="toolbar-right">
<button class="btn btn-secondary" id="btn-download" disabled title="下载选中文件">📥 下载</button>
<button class="btn btn-secondary" id="btn-move" disabled title="移动选中文件">✂️ 移动</button>
<button class="btn btn-danger" id="btn-delete" disabled title="删除选中文件">🗑️ 删除</button>
<button class="btn btn-secondary" id="btn-refresh" title="刷新列表">🔄 刷新</button>
</div>
</div>
<div class="file-list-container">
<table class="file-list" id="file-list">
<thead>
<tr>
<th class="col-checkbox">
<input type="checkbox" id="select-all">
</th>
<th class="col-name">名称</th>
<th class="col-size">大小</th>
<th class="col-time">修改时间</th>
<th class="col-actions">操作</th>
</tr>
</thead>
<tbody id="file-list-body">
</tbody>
</table>
</div>
<div class="drop-zone" id="drop-zone">
<div class="drop-zone-content">
<span class="drop-icon">📂</span>
<p>拖拽文件到此处上传</p>
</div>
</div>
</div>
<div class="modal" id="preview-modal">
<div class="modal-content modal-large">
<div class="modal-header">
<h3 id="preview-title">文件预览</h3>
<button class="modal-close" id="preview-close">&times;</button>
</div>
<div class="modal-body" id="preview-body">
</div>
</div>
</div>
<div class="modal" id="move-modal">
<div class="modal-content">
<div class="modal-header">
<h3>移动文件</h3>
<button class="modal-close" id="move-close">&times;</button>
</div>
<div class="modal-body">
<p id="move-filename" class="move-filename"></p>
<div class="breadcrumb" id="move-breadcrumb"></div>
<div class="dir-tree" id="dir-tree"></div>
<input type="hidden" id="move-source">
<input type="hidden" id="move-dest">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="move-cancel">取消</button>
<button class="btn btn-primary" id="move-confirm">移动</button>
</div>
</div>
</div>
<div class="modal" id="new-dir-modal">
<div class="modal-content">
<div class="modal-header">
<h3>新建文件夹</h3>
<button class="modal-close" id="new-dir-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-dir-name">文件夹名称</label>
<input type="text" id="new-dir-name" placeholder="输入文件夹名称">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="new-dir-cancel">取消</button>
<button class="btn btn-primary" id="new-dir-confirm">创建</button>
</div>
</div>
</div>
<div class="modal" id="upload-modal">
<div class="modal-content">
<div class="modal-header">
<h3>上传文件</h3>
<button class="modal-close" id="upload-close">&times;</button>
</div>
<div class="modal-body">
<div class="upload-list" id="upload-list"></div>
<div class="upload-progress-container" id="upload-progress-container">
<div class="upload-progress-bar" id="upload-progress-bar"></div>
</div>
<div class="upload-stats" id="upload-stats"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="upload-cancel" style="display: none;">取消</button>
<button class="btn btn-primary" id="upload-confirm" style="display: none;">完成</button>
</div>
</div>
</div>
<div class="modal" id="rename-modal">
<div class="modal-content">
<div class="modal-header">
<h3>重命名</h3>
<button class="modal-close" id="rename-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="rename-name">新名称</label>
<input type="text" id="rename-name" placeholder="输入新名称">
</div>
<input type="hidden" id="rename-path">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="rename-cancel">取消</button>
<button class="btn btn-primary" id="rename-confirm">确认</button>
</div>
</div>
</div>
<div class="notification" id="notification"></div>
<input type="file" id="file-input" multiple style="display: none;">
<script src="/static/app.js"></script>
</body>
</html>

723
static/style.css Normal file
View File

@@ -0,0 +1,723 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 2px solid #e0e0e0;
margin-bottom: 20px;
}
.header h1 {
font-size: 28px;
color: #2c3e50;
}
.status {
display: flex;
align-items: center;
gap: 20px;
font-size: 14px;
color: #666;
}
#connection-status {
padding: 5px 12px;
border-radius: 20px;
font-weight: 500;
}
#connection-status.connected {
background: #e8f5e9;
color: #2e7d32;
}
#connection-status.disconnected {
background: #ffebee;
color: #c62828;
}
#path-display {
font-family: 'Monaco', 'Consolas', monospace;
background: #e3f2fd;
padding: 5px 15px;
border-radius: 5px;
color: #1565c0;
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 10px 15px;
background: #fff;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.crumb {
color: #1976d2;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
transition: background 0.2s;
}
.crumb:hover {
background: #e3f2fd;
}
.crumb:last-child {
color: #333;
cursor: default;
background: transparent;
}
.crumb:not(:last-child)::after {
content: '/';
margin-left: 5px;
color: #999;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 15px;
background: #fff;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.toolbar-left,
.toolbar-right,
.toolbar-center {
display: flex;
gap: 10px;
}
.toolbar-center {
flex: 1;
justify-content: center;
min-width: 300px;
}
.search-box {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
padding: 5px 10px;
width: 100%;
max-width: 400px;
}
.search-box input {
flex: 1;
border: none;
background: transparent;
padding: 8px;
font-size: 14px;
outline: none;
}
.search-box input::placeholder {
color: #999;
}
.search-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 5px;
opacity: 0.7;
transition: opacity 0.2s;
}
.search-btn:hover {
opacity: 1;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #2196f3;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1976d2;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-secondary:hover:not(:disabled) {
background: #e0e0e0;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
.file-list-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
overflow: hidden;
}
.file-list {
width: 100%;
border-collapse: collapse;
}
.file-list th,
.file-list td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.file-list th {
background: #fafafa;
font-weight: 600;
color: #666;
font-size: 13px;
text-transform: uppercase;
}
.file-list tbody tr {
transition: background 0.2s;
}
.file-list tbody tr:hover {
background: #f5f5f5;
}
.file-list tbody tr.selected {
background: #e3f2fd;
}
.col-checkbox {
width: 50px;
}
.col-name {
min-width: 200px;
}
.col-size {
width: 120px;
}
.col-time {
width: 180px;
}
.col-actions {
width: 150px;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #1976d2;
}
.file-name:hover {
text-decoration: underline;
}
.file-name.is-dir {
color: #2e7d32;
font-weight: 500;
}
.file-icon {
font-size: 20px;
}
.file-size {
color: #666;
font-size: 13px;
}
.file-time {
color: #666;
font-size: 13px;
}
.file-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 5px 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
border-radius: 4px;
transition: background 0.2s;
}
.action-btn:hover {
background: #e0e0e0;
}
.action-btn.delete:hover {
background: #ffebee;
color: #c62828;
}
.drop-zone {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(33, 150, 243, 0.9);
z-index: 1000;
justify-content: center;
align-items: center;
}
.drop-zone.active {
display: flex;
}
.drop-zone-content {
text-align: center;
color: white;
}
.drop-icon {
font-size: 80px;
display: block;
margin-bottom: 20px;
}
.drop-zone-content p {
font-size: 24px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 20px;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
width: 100%;
max-width: 450px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-large {
max-width: 900px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
font-size: 18px;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #999;
line-height: 1;
}
.modal-close:hover {
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 15px 20px;
border-top: 1px solid #eee;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #666;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #2196f3;
}
.move-filename {
padding: 10px;
background: #f5f5f5;
border-radius: 6px;
margin-bottom: 15px;
font-family: monospace;
}
.dir-tree {
max-height: 300px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 6px;
padding: 10px;
}
.dir-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-radius: 4px;
margin-bottom: 2px;
}
.dir-item:hover {
background: #f5f5f5;
}
.dir-item.selected {
background: #e3f2fd;
}
.dir-item.current {
background: #fff3e0;
}
.preview-image {
max-width: 100%;
max-height: 60vh;
display: block;
margin: 0 auto;
}
.preview-text {
white-space: pre-wrap;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
max-height: 60vh;
overflow: auto;
background: #f5f5f5;
padding: 15px;
border-radius: 6px;
}
.preview-placeholder {
text-align: center;
padding: 60px 20px;
color: #999;
}
.preview-placeholder .icon {
font-size: 80px;
display: block;
margin-bottom: 20px;
}
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
background: #333;
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 2000;
}
.notification.show {
transform: translateY(0);
opacity: 1;
}
.notification.success {
background: #4caf50;
}
.notification.error {
background: #f44336;
}
.notification.info {
background: #2196f3;
}
.empty-message {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-message .icon {
font-size: 60px;
display: block;
margin-bottom: 15px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.loading::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: #2196f3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.toolbar {
flex-direction: column;
}
.toolbar-left,
.toolbar-right,
.toolbar-center {
justify-content: center;
width: 100%;
}
.toolbar-center {
order: -1;
margin-bottom: 10px;
}
.search-box {
max-width: 100%;
}
.file-list th:nth-child(3),
.file-list td:nth-child(3),
.file-list th:nth-child(4),
.file-list td:nth-child(4) {
display: none;
}
.file-actions {
flex-direction: column;
gap: 2px;
}
}
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 5px 10px;
background: #333;
color: white;
font-size: 12px;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.tooltip:hover::after {
opacity: 1;
}
.upload-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 15px;
}
.upload-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
gap: 10px;
}
.upload-item:last-child {
border-bottom: none;
}
.upload-item-icon {
font-size: 24px;
}
.upload-item-info {
flex: 1;
min-width: 0;
}
.upload-item-name {
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-item-status {
font-size: 12px;
color: #666;
}
.upload-progress-container {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
display: none;
}
.upload-progress-container.active {
display: block;
}
.upload-progress-bar {
height: 100%;
background: linear-gradient(90deg, #2196f3, #4caf50);
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.upload-stats {
text-align: center;
font-size: 14px;
color: #666;
display: none;
}
.upload-stats.active {
display: block;
}