Forráskód Böngészése

feat: 初始化FTP服务器项目 - 支持Web管理界面

Your Name 2 hete
commit
1d36000b80
11 módosított fájl, 2714 hozzáadás és 0 törlés
  1. 27 0
      .gitignore
  2. 122 0
      config/config.go
  3. 330 0
      database/db.go
  4. 185 0
      ftp/server.go
  5. 23 0
      go.mod
  6. 69 0
      go.sum
  7. 74 0
      main.go
  8. 498 0
      static/css/style.css
  9. 341 0
      static/index.html
  10. 460 0
      static/js/app.js
  11. 585 0
      web/server.go

+ 27 - 0
.gitignore

@@ -0,0 +1,27 @@
+# 编译产物
+ftp-server.exe
+*.exe
+
+# 数据目录
+data/
+
+# 配置文件(包含密码等敏感信息)
+config.json
+
+# FTP根目录
+ftp_root/
+
+# Go 相关
+vendor/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+Thumbs.db
+Desktop.ini
+.DS_Store

+ 122 - 0
config/config.go

@@ -0,0 +1,122 @@
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"sync"
+)
+
+// Config 全局配置结构
+type Config struct {
+	mu       sync.RWMutex
+	FTP      FTPConfig   `json:"ftp"`
+	Web      WebConfig   `json:"web"`
+	Admin    AdminConfig `json:"admin"`
+	Database DBConfig    `json:"database"`
+}
+
+type FTPConfig struct {
+	Host            string `json:"host"`
+	Port            int    `json:"port"`
+	PassivePortMin  int    `json:"passive_port_min"`
+	PassivePortMax  int    `json:"passive_port_max"`
+	RootDir         string `json:"root_dir"`
+	EnableAnonymous bool   `json:"enable_anonymous"`
+	MaxConnections  int    `json:"max_connections"`
+	IdleTimeout     int    `json:"idle_timeout"` // 秒
+}
+
+type WebConfig struct {
+	Host string `json:"host"`
+	Port int    `json:"port"`
+}
+
+type AdminConfig struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+type DBConfig struct {
+	Path string `json:"path"`
+}
+
+// DefaultConfig 返回默认配置
+func DefaultConfig() *Config {
+	return &Config{
+		FTP: FTPConfig{
+			Host:            "0.0.0.0",
+			Port:            2121,
+			PassivePortMin:  50000,
+			PassivePortMax:  50100,
+			RootDir:         "./ftp_root",
+			EnableAnonymous: false,
+			MaxConnections:  50,
+			IdleTimeout:     300,
+		},
+		Web: WebConfig{
+			Host: "0.0.0.0",
+			Port: 8080,
+		},
+		Admin: AdminConfig{
+			Username: "admin",
+			Password: "admin123",
+		},
+		Database: DBConfig{
+			Path: "./data/ftp.db",
+		},
+	}
+}
+
+// Load 从文件加载配置
+func Load(path string) (*Config, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			cfg := DefaultConfig()
+			if err := cfg.Save(path); err != nil {
+				return nil, err
+			}
+			return cfg, nil
+		}
+		return nil, err
+	}
+
+	var cfg Config
+	if err := json.Unmarshal(data, &cfg); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+// Save 保存配置到文件
+func (c *Config) Save(path string) error {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(c, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0644)
+}
+
+// Get 安全获取配置副本
+func (c *Config) Get() Config {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return *c
+}
+
+// Update 安全更新配置
+func (c *Config) Update(fn func(cfg *Config)) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	fn(c)
+}

+ 330 - 0
database/db.go

@@ -0,0 +1,330 @@
+package database
+
+import (
+	"database/sql"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	_ "modernc.org/sqlite"
+)
+
+// DB 数据库实例
+type DB struct {
+	*sql.DB
+}
+
+// FTPUser FTP用户
+type FTPUser struct {
+	ID           int64  `json:"id"`
+	Username     string `json:"username"`
+	Password     string `json:"password,omitempty"`
+	HomeDir      string `json:"home_dir"`
+	Permissions  string `json:"permissions"`   // "read", "write", "read,write", "admin"
+	QuotaSize    int64  `json:"quota_size"`    // 字节, 0 = 无限制
+	QuotaFiles   int    `json:"quota_files"`   // 文件数, 0 = 无限制
+	UploadRate   int    `json:"upload_rate"`   // KB/s, 0 = 无限制
+	DownloadRate int    `json:"download_rate"` // KB/s, 0 = 无限制
+	Enabled      bool   `json:"enabled"`
+	CreatedAt    string `json:"created_at"`
+	UpdatedAt    string `json:"updated_at"`
+}
+
+// FTPLog FTP操作日志
+type FTPLog struct {
+	ID        int64  `json:"id"`
+	Username  string `json:"username"`
+	IP        string `json:"ip"`
+	Action    string `json:"action"`
+	FilePath  string `json:"file_path"`
+	FileSize  int64  `json:"file_size"`
+	Status    string `json:"status"`
+	CreatedAt string `json:"created_at"`
+}
+
+// OnlineUser 在线用户
+type OnlineUser struct {
+	ID           int       `json:"id"`
+	Username     string    `json:"username"`
+	IP           string    `json:"ip"`
+	LoginTime    time.Time `json:"login_time"`
+	LastActivity time.Time `json:"last_activity"`
+	CurrentDir   string    `json:"current_dir"`
+}
+
+// Open 打开数据库
+func Open(dbPath string) (*DB, error) {
+	dir := filepath.Dir(dbPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, fmt.Errorf("创建数据库目录失败: %w", err)
+	}
+
+	db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
+	if err != nil {
+		return nil, fmt.Errorf("打开数据库失败: %w", err)
+	}
+
+	if err := db.Ping(); err != nil {
+		return nil, fmt.Errorf("连接数据库失败: %w", err)
+	}
+
+	d := &DB{db}
+	if err := d.initTables(); err != nil {
+		return nil, err
+	}
+
+	return d, nil
+}
+
+// initTables 初始化数据表
+func (db *DB) initTables() error {
+	schema := `
+	CREATE TABLE IF NOT EXISTS ftp_users (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		username TEXT NOT NULL UNIQUE,
+		password TEXT NOT NULL,
+		home_dir TEXT NOT NULL,
+		permissions TEXT DEFAULT 'read',
+		quota_size INTEGER DEFAULT 0,
+		quota_files INTEGER DEFAULT 0,
+		upload_rate INTEGER DEFAULT 0,
+		download_rate INTEGER DEFAULT 0,
+		enabled INTEGER DEFAULT 1,
+		created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+	);
+
+	CREATE TABLE IF NOT EXISTS ftp_logs (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		username TEXT DEFAULT '',
+		ip TEXT DEFAULT '',
+		action TEXT NOT NULL,
+		file_path TEXT DEFAULT '',
+		file_size INTEGER DEFAULT 0,
+		status TEXT DEFAULT 'success',
+		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+	);
+
+	CREATE INDEX IF NOT EXISTS idx_logs_username ON ftp_logs(username);
+	CREATE INDEX IF NOT EXISTS idx_logs_created ON ftp_logs(created_at);
+	CREATE INDEX IF NOT EXISTS idx_logs_action ON ftp_logs(action);
+
+	CREATE TABLE IF NOT EXISTS system_stats (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		total_uploads INTEGER DEFAULT 0,
+		total_downloads INTEGER DEFAULT 0,
+		total_upload_bytes INTEGER DEFAULT 0,
+		total_download_bytes INTEGER DEFAULT 0,
+		updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+	);
+	`
+
+	_, err := db.Exec(schema)
+	return err
+}
+
+// --- FTP用户 CRUD ---
+
+// CreateUser 创建FTP用户
+func (db *DB) CreateUser(user *FTPUser) error {
+	now := time.Now().Format("2006-01-02 15:04:05")
+	result, err := db.Exec(`
+		INSERT INTO ftp_users (username, password, home_dir, permissions, quota_size, quota_files, 
+			upload_rate, download_rate, enabled, created_at, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+		user.Username, user.Password, user.HomeDir, user.Permissions,
+		user.QuotaSize, user.QuotaFiles, user.UploadRate, user.DownloadRate,
+		user.Enabled, now, now)
+	if err != nil {
+		return fmt.Errorf("创建用户失败: %w", err)
+	}
+	user.ID, _ = result.LastInsertId()
+	user.CreatedAt = now
+	user.UpdatedAt = now
+	return nil
+}
+
+// GetUser 获取用户
+func (db *DB) GetUser(username string) (*FTPUser, error) {
+	user := &FTPUser{}
+	var enabled int
+	err := db.QueryRow(`
+		SELECT id, username, password, home_dir, permissions, quota_size, quota_files,
+			upload_rate, download_rate, enabled, created_at, updated_at
+		FROM ftp_users WHERE username = ?`, username,
+	).Scan(&user.ID, &user.Username, &user.Password, &user.HomeDir, &user.Permissions,
+		&user.QuotaSize, &user.QuotaFiles, &user.UploadRate, &user.DownloadRate,
+		&enabled, &user.CreatedAt, &user.UpdatedAt)
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	user.Enabled = enabled == 1
+	return user, nil
+}
+
+// ListUsers 列出所有用户
+func (db *DB) ListUsers() ([]FTPUser, error) {
+	rows, err := db.Query(`
+		SELECT id, username, password, home_dir, permissions, quota_size, quota_files,
+			upload_rate, download_rate, enabled, created_at, updated_at
+		FROM ftp_users ORDER BY id`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var users []FTPUser
+	for rows.Next() {
+		var user FTPUser
+		var enabled int
+		err := rows.Scan(&user.ID, &user.Username, &user.Password, &user.HomeDir, &user.Permissions,
+			&user.QuotaSize, &user.QuotaFiles, &user.UploadRate, &user.DownloadRate,
+			&enabled, &user.CreatedAt, &user.UpdatedAt)
+		if err != nil {
+			return nil, err
+		}
+		user.Enabled = enabled == 1
+		users = append(users, user)
+	}
+	return users, nil
+}
+
+// UpdateUser 更新用户
+func (db *DB) UpdateUser(user *FTPUser) error {
+	now := time.Now().Format("2006-01-02 15:04:05")
+	_, err := db.Exec(`
+		UPDATE ftp_users SET home_dir=?, permissions=?, quota_size=?, quota_files=?,
+			upload_rate=?, download_rate=?, enabled=?, updated_at=?
+		WHERE username=?`,
+		user.HomeDir, user.Permissions, user.QuotaSize, user.QuotaFiles,
+		user.UploadRate, user.DownloadRate, user.Enabled, now, user.Username)
+	return err
+}
+
+// UpdateUserPassword 更新用户密码
+func (db *DB) UpdateUserPassword(username, password string) error {
+	now := time.Now().Format("2006-01-02 15:04:05")
+	_, err := db.Exec(`UPDATE ftp_users SET password=?, updated_at=? WHERE username=?`,
+		password, now, username)
+	return err
+}
+
+// DeleteUser 删除用户
+func (db *DB) DeleteUser(username string) error {
+	_, err := db.Exec(`DELETE FROM ftp_users WHERE username=?`, username)
+	return err
+}
+
+// --- 日志 ---
+
+// AddLog 添加操作日志
+func (db *DB) AddLog(log *FTPLog) error {
+	now := time.Now().Format("2006-01-02 15:04:05")
+	result, err := db.Exec(`
+		INSERT INTO ftp_logs (username, ip, action, file_path, file_size, status, created_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?)`,
+		log.Username, log.IP, log.Action, log.FilePath, log.FileSize, log.Status, now)
+	if err != nil {
+		return err
+	}
+	log.ID, _ = result.LastInsertId()
+	log.CreatedAt = now
+	return nil
+}
+
+// QueryLogs 查询日志
+func (db *DB) QueryLogs(username, action string, page, pageSize int) ([]FTPLog, int, error) {
+	where := "WHERE 1=1"
+	args := []interface{}{}
+
+	if username != "" {
+		where += " AND username LIKE ?"
+		args = append(args, "%"+username+"%")
+	}
+	if action != "" {
+		where += " AND action = ?"
+		args = append(args, action)
+	}
+
+	// 获取总数
+	var total int
+	countSQL := fmt.Sprintf("SELECT COUNT(*) FROM ftp_logs %s", where)
+	err := db.QueryRow(countSQL, args...).Scan(&total)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	// 分页查询
+	offset := (page - 1) * pageSize
+	querySQL := fmt.Sprintf(`
+		SELECT id, username, ip, action, file_path, file_size, status, created_at
+		FROM ftp_logs %s ORDER BY id DESC LIMIT ? OFFSET ?`, where)
+	queryArgs := append(args, pageSize, offset)
+
+	rows, err := db.Query(querySQL, queryArgs...)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer rows.Close()
+
+	var logs []FTPLog
+	for rows.Next() {
+		var log FTPLog
+		err := rows.Scan(&log.ID, &log.Username, &log.IP, &log.Action, &log.FilePath,
+			&log.FileSize, &log.Status, &log.CreatedAt)
+		if err != nil {
+			return nil, 0, err
+		}
+		logs = append(logs, log)
+	}
+	return logs, total, nil
+}
+
+// GetLogStats 获取日志统计
+func (db *DB) GetLogStats() (map[string]interface{}, error) {
+	stats := make(map[string]interface{})
+
+	var totalUsers int
+	db.QueryRow("SELECT COUNT(*) FROM ftp_users").Scan(&totalUsers)
+	stats["total_users"] = totalUsers
+
+	var enabledUsers int
+	db.QueryRow("SELECT COUNT(*) FROM ftp_users WHERE enabled=1").Scan(&enabledUsers)
+	stats["enabled_users"] = enabledUsers
+
+	var todayLogins int
+	db.QueryRow("SELECT COUNT(DISTINCT username) FROM ftp_logs WHERE action='login' AND created_at >= date('now')").Scan(&todayLogins)
+	stats["today_logins"] = todayLogins
+
+	var todayUploads int
+	db.QueryRow("SELECT COUNT(*) FROM ftp_logs WHERE action='upload' AND created_at >= date('now')").Scan(&todayUploads)
+	stats["today_uploads"] = todayUploads
+
+	var todayDownloads int
+	db.QueryRow("SELECT COUNT(*) FROM ftp_logs WHERE action='download' AND created_at >= date('now')").Scan(&todayDownloads)
+	stats["today_downloads"] = todayDownloads
+
+	var totalUploadBytes int64
+	db.QueryRow("SELECT COALESCE(SUM(file_size),0) FROM ftp_logs WHERE action='upload'").Scan(&totalUploadBytes)
+	stats["total_upload_bytes"] = totalUploadBytes
+
+	var totalDownloadBytes int64
+	db.QueryRow("SELECT COALESCE(SUM(file_size),0) FROM ftp_logs WHERE action='download'").Scan(&totalDownloadBytes)
+	stats["total_download_bytes"] = totalDownloadBytes
+
+	return stats, nil
+}
+
+// CleanOldLogs 清理旧日志(保留最近N天)
+func (db *DB) CleanOldLogs(days int) (int64, error) {
+	result, err := db.Exec(`DELETE FROM ftp_logs WHERE created_at < datetime('now', ?||' days')`,
+		fmt.Sprintf("-%d", days))
+	if err != nil {
+		return 0, err
+	}
+	return result.RowsAffected()
+}

+ 185 - 0
ftp/server.go

@@ -0,0 +1,185 @@
+package ftp
+
+import (
+	"crypto/tls"
+	"fmt"
+	"log"
+	"os"
+	"sync"
+	"time"
+
+	"ftp-server/config"
+	"ftp-server/database"
+
+	ftpserver "github.com/fclairamb/ftpserverlib"
+	"github.com/spf13/afero"
+)
+
+// Server FTP服务器
+type Server struct {
+	config      *config.Config
+	db          *database.DB
+	ftpServer   *ftpserver.FtpServer
+	onlineMu    sync.RWMutex
+	onlineUsers map[string]*database.OnlineUser
+}
+
+// NewServer 创建FTP服务器
+func NewServer(cfg *config.Config, db *database.DB) *Server {
+	return &Server{
+		config:      cfg,
+		db:          db,
+		onlineUsers: make(map[string]*database.OnlineUser),
+	}
+}
+
+// Start 启动FTP服务器
+func (s *Server) Start() error {
+	ftpCfg := s.config.Get().FTP
+
+	// 确保FTP根目录存在
+	if err := os.MkdirAll(ftpCfg.RootDir, 0755); err != nil {
+		return fmt.Errorf("创建FTP根目录失败: %w", err)
+	}
+
+	server := ftpserver.NewFtpServer(s)
+	s.ftpServer = server
+
+	go func() {
+		if err := server.ListenAndServe(); err != nil {
+			log.Printf("FTP服务器错误: %v", err)
+		}
+	}()
+
+	log.Printf("FTP服务器已启动: %s:%d", ftpCfg.Host, ftpCfg.Port)
+	return nil
+}
+
+// Stop 停止FTP服务器
+func (s *Server) Stop() {
+	if s.ftpServer != nil {
+		s.ftpServer.Stop()
+		log.Println("FTP服务器已停止")
+	}
+}
+
+// GetOnlineUsers 获取在线用户列表
+func (s *Server) GetOnlineUsers() []database.OnlineUser {
+	s.onlineMu.RLock()
+	defer s.onlineMu.RUnlock()
+
+	result := make([]database.OnlineUser, 0, len(s.onlineUsers))
+	for _, u := range s.onlineUsers {
+		result = append(result, *u)
+	}
+	return result
+}
+
+// --- 实现 ftpserverlib.MainDriver 接口 ---
+
+// GetSettings 返回FTP服务器设置
+func (s *Server) GetSettings() (*ftpserver.Settings, error) {
+	ftpCfg := s.config.Get().FTP
+	return &ftpserver.Settings{
+		ListenAddr: fmt.Sprintf("%s:%d", ftpCfg.Host, ftpCfg.Port),
+		PassiveTransferPortRange: ftpserver.PortRange{
+			Start: ftpCfg.PassivePortMin,
+			End:   ftpCfg.PassivePortMax,
+		},
+		ConnectionTimeout: int(time.Duration(ftpCfg.IdleTimeout) * time.Second),
+	}, nil
+}
+
+// ClientConnected 客户端连接
+func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) {
+	return "220 Welcome to FTP Server\r\n", nil
+}
+
+// ClientDisconnected 客户端断开
+func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) {
+	s.onlineMu.Lock()
+	defer s.onlineMu.Unlock()
+
+	for id, u := range s.onlineUsers {
+		if u.IP == cc.RemoteAddr().String() {
+			delete(s.onlineUsers, id)
+			break
+		}
+	}
+}
+
+// AuthUser 认证用户
+func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) {
+	ftpCfg := s.config.Get().FTP
+
+	// 匿名登录
+	if username == "anonymous" {
+		if !ftpCfg.EnableAnonymous {
+			return nil, fmt.Errorf("匿名访问未启用")
+		}
+		if err := os.MkdirAll(ftpCfg.RootDir, 0755); err != nil {
+			return nil, fmt.Errorf("创建根目录失败")
+		}
+		osFs := afero.NewOsFs()
+		boundedFs := afero.NewBasePathFs(osFs, ftpCfg.RootDir)
+		return boundedFs, nil
+	}
+
+	// 数据库用户认证
+	user, err := s.db.GetUser(username)
+	if err != nil {
+		return nil, fmt.Errorf("认证失败")
+	}
+	if user == nil || !user.Enabled {
+		return nil, fmt.Errorf("用户不存在或已禁用")
+	}
+	if user.Password != password {
+		s.db.AddLog(&database.FTPLog{
+			Username: username,
+			IP:       cc.RemoteAddr().String(),
+			Action:   "login_failed",
+			Status:   "failed",
+		})
+		return nil, fmt.Errorf("密码错误")
+	}
+
+	// 记录登录日志
+	s.db.AddLog(&database.FTPLog{
+		Username: username,
+		IP:       cc.RemoteAddr().String(),
+		Action:   "login",
+		Status:   "success",
+	})
+
+	// 记录在线用户
+	s.onlineMu.Lock()
+	s.onlineUsers[username+"_"+cc.RemoteAddr().String()] = &database.OnlineUser{
+		Username:     username,
+		IP:           cc.RemoteAddr().String(),
+		LoginTime:    time.Now(),
+		LastActivity: time.Now(),
+		CurrentDir:   user.HomeDir,
+	}
+	s.onlineMu.Unlock()
+
+	// 确保用户目录存在(自动创建)
+	if err := os.MkdirAll(user.HomeDir, 0755); err != nil {
+		return nil, fmt.Errorf("创建用户目录失败: %v", err)
+	}
+
+	// 返回 afero.Fs 作为 ClientDriver
+	osFs := afero.NewOsFs()
+	boundedFs := afero.NewBasePathFs(osFs, user.HomeDir)
+
+	// 根据权限设置只读
+	if user.Permissions == "read" {
+		return afero.NewReadOnlyFs(boundedFs), nil
+	}
+
+	return boundedFs, nil
+}
+
+// GetTLSConfig 获取TLS配置
+func (s *Server) GetTLSConfig() (*tls.Config, error) {
+	return nil, fmt.Errorf("TLS未配置")
+}

+ 23 - 0
go.mod

@@ -0,0 +1,23 @@
+module ftp-server
+
+go 1.26.2
+
+require (
+	github.com/fclairamb/ftpserverlib v0.30.0
+	github.com/golang-jwt/jwt/v5 v5.3.1
+	github.com/spf13/afero v1.15.0
+	modernc.org/sqlite v1.50.0
+)
+
+require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v1.0.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	golang.org/x/text v0.28.0 // indirect
+	modernc.org/libc v1.72.0 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.11.0 // indirect
+)

+ 69 - 0
go.sum

@@ -0,0 +1,69 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fclairamb/ftpserverlib v0.30.0 h1:caB9sDn1Au//q0j2ev/icPn388qPuk4k1ajSvglDcMQ=
+github.com/fclairamb/ftpserverlib v0.30.0/go.mod h1:QmogtltTOgkihyKza0GNo37Mu4AEzbJ+sH6W9Y0MBIQ=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
+github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
+modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
+modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
+modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
+modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
+modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
+modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

+ 74 - 0
main.go

@@ -0,0 +1,74 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"ftp-server/config"
+	"ftp-server/database"
+	"ftp-server/ftp"
+	"ftp-server/web"
+)
+
+func main() {
+	configPath := flag.String("config", "config.json", "配置文件路径")
+	flag.Parse()
+
+	fmt.Println("========================================")
+	fmt.Println("  FTP Server with Web Management")
+	fmt.Println("========================================")
+	fmt.Println()
+
+	// 加载配置
+	cfg, err := config.Load(*configPath)
+	if err != nil {
+		log.Fatalf("加载配置失败: %v", err)
+	}
+	log.Println("配置加载成功")
+
+	// 打开数据库
+	dbCfg := cfg.Get().Database
+	db, err := database.Open(dbCfg.Path)
+	if err != nil {
+		log.Fatalf("打开数据库失败: %v", err)
+	}
+	defer db.Close()
+	log.Println("数据库初始化成功")
+
+	// 启动FTP服务器
+	ftpServer := ftp.NewServer(cfg, db)
+	if err := ftpServer.Start(); err != nil {
+		log.Fatalf("启动FTP服务器失败: %v", err)
+	}
+
+	// 启动Web管理服务器
+	webServer := web.NewServer(cfg, db, ftpServer)
+
+	// 信号处理
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+	go func() {
+		if err := webServer.Start(); err != nil {
+			log.Fatalf("启动Web服务器失败: %v", err)
+		}
+	}()
+
+	webCfg := cfg.Get().Web
+	fmt.Println()
+	fmt.Printf("  FTP端口: %d\n", cfg.Get().FTP.Port)
+	fmt.Printf("  Web管理: http://localhost:%d\n", webCfg.Port)
+	fmt.Println()
+	fmt.Println("  按 Ctrl+C 停止服务器")
+	fmt.Println()
+
+	<-sigChan
+	fmt.Println()
+	log.Println("正在关闭服务器...")
+	ftpServer.Stop()
+	log.Println("服务器已停止")
+}

+ 498 - 0
static/css/style.css

@@ -0,0 +1,498 @@
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    background: #f0f2f5;
+    color: #333;
+}
+
+/* 登录页面 */
+.login-page {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 100vh;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.login-card {
+    background: #fff;
+    border-radius: 12px;
+    padding: 40px;
+    width: 400px;
+    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+    text-align: center;
+}
+
+.login-card h1 {
+    font-size: 28px;
+    margin-bottom: 8px;
+    color: #1a1a2e;
+}
+
+.login-card p {
+    color: #888;
+    margin-bottom: 30px;
+}
+
+/* 主布局 */
+.main-app {
+    display: flex;
+    min-height: 100vh;
+}
+
+/* 侧边栏 */
+.sidebar {
+    width: 220px;
+    background: #1a1a2e;
+    color: #fff;
+    display: flex;
+    flex-direction: column;
+    position: fixed;
+    height: 100vh;
+    z-index: 100;
+}
+
+.sidebar-header {
+    padding: 20px;
+    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.sidebar-header h2 {
+    font-size: 20px;
+    font-weight: 600;
+}
+
+.sidebar-menu {
+    list-style: none;
+    flex: 1;
+    padding: 10px 0;
+}
+
+.sidebar-menu li {
+    padding: 12px 20px;
+    cursor: pointer;
+    transition: all 0.2s;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    color: rgba(255, 255, 255, 0.7);
+}
+
+.sidebar-menu li:hover {
+    background: rgba(255, 255, 255, 0.1);
+    color: #fff;
+}
+
+.sidebar-menu li.active {
+    background: rgba(255, 255, 255, 0.15);
+    color: #fff;
+    border-left: 3px solid #667eea;
+}
+
+.sidebar-menu .icon {
+    font-size: 16px;
+    width: 20px;
+    text-align: center;
+}
+
+.sidebar-footer {
+    padding: 15px 20px;
+    border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* 内容区 */
+.content {
+    margin-left: 220px;
+    flex: 1;
+    padding: 30px;
+}
+
+.page {
+    display: none;
+}
+
+.page.active {
+    display: block;
+}
+
+.page-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+}
+
+.page-header h2 {
+    margin: 0;
+}
+
+h2 {
+    font-size: 22px;
+    margin-bottom: 20px;
+    color: #1a1a2e;
+}
+
+/* 统计卡片 */
+.stats-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+    gap: 16px;
+}
+
+.stat-card {
+    background: #fff;
+    border-radius: 8px;
+    padding: 20px;
+    text-align: center;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.stat-card.wide {
+    grid-column: span 3;
+}
+
+.stat-value {
+    font-size: 32px;
+    font-weight: 700;
+    color: #667eea;
+    margin-bottom: 4px;
+}
+
+.stat-label {
+    font-size: 13px;
+    color: #888;
+}
+
+/* 表格 */
+.data-table {
+    width: 100%;
+    background: #fff;
+    border-radius: 8px;
+    overflow: hidden;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+    border-collapse: collapse;
+}
+
+.data-table th {
+    background: #f8f9fa;
+    padding: 12px 16px;
+    text-align: left;
+    font-weight: 600;
+    font-size: 13px;
+    color: #666;
+    border-bottom: 2px solid #eee;
+}
+
+.data-table td {
+    padding: 10px 16px;
+    border-bottom: 1px solid #f0f0f0;
+    font-size: 14px;
+}
+
+.data-table tr:hover td {
+    background: #f8f9fa;
+}
+
+.data-table .status-enabled {
+    color: #52c41a;
+    font-weight: 600;
+}
+
+.data-table .status-disabled {
+    color: #ff4d4f;
+    font-weight: 600;
+}
+
+/* 按钮 */
+.btn {
+    padding: 8px 16px;
+    border: 1px solid #d9d9d9;
+    border-radius: 6px;
+    background: #fff;
+    cursor: pointer;
+    font-size: 14px;
+    transition: all 0.2s;
+}
+
+.btn:hover {
+    border-color: #667eea;
+    color: #667eea;
+}
+
+.btn-primary {
+    background: #667eea;
+    color: #fff;
+    border-color: #667eea;
+}
+
+.btn-primary:hover {
+    background: #5a6fd6;
+    color: #fff;
+}
+
+.btn-danger {
+    background: #ff4d4f;
+    color: #fff;
+    border-color: #ff4d4f;
+}
+
+.btn-danger:hover {
+    background: #e04346;
+    color: #fff;
+}
+
+.btn-sm {
+    padding: 4px 12px;
+    font-size: 13px;
+}
+
+.btn-block {
+    width: 100%;
+    padding: 12px;
+    font-size: 16px;
+}
+
+/* 表单 */
+.form-group {
+    margin-bottom: 16px;
+}
+
+.form-group label {
+    display: block;
+    margin-bottom: 6px;
+    font-weight: 500;
+    font-size: 14px;
+    color: #333;
+}
+
+.form-group small {
+    display: block;
+    margin-top: 4px;
+    color: #999;
+    font-size: 12px;
+}
+
+.form-group input,
+.form-group select {
+    width: 100%;
+    padding: 8px 12px;
+    border: 1px solid #d9d9d9;
+    border-radius: 6px;
+    font-size: 14px;
+    transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+    outline: none;
+    border-color: #667eea;
+    box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
+}
+
+.input-sm {
+    padding: 6px 10px;
+    border: 1px solid #d9d9d9;
+    border-radius: 6px;
+    font-size: 13px;
+}
+
+.input-sm:focus {
+    outline: none;
+    border-color: #667eea;
+}
+
+.form-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 16px;
+}
+
+/* 对话框 */
+.modal {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.5);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 1000;
+}
+
+.modal-content {
+    background: #fff;
+    border-radius: 12px;
+    width: 520px;
+    max-width: 90vw;
+    max-height: 85vh;
+    overflow-y: auto;
+    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+
+.modal-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 16px 24px;
+    border-bottom: 1px solid #f0f0f0;
+}
+
+.modal-header h3 {
+    margin: 0;
+    font-size: 18px;
+}
+
+.modal-close {
+    font-size: 24px;
+    cursor: pointer;
+    color: #999;
+    line-height: 1;
+}
+
+.modal-close:hover {
+    color: #333;
+}
+
+.modal-content form {
+    padding: 24px;
+}
+
+.modal-footer {
+    display: flex;
+    justify-content: flex-end;
+    gap: 10px;
+    margin-top: 20px;
+}
+
+/* 面包屑 */
+.breadcrumb {
+    background: #fff;
+    padding: 10px 16px;
+    border-radius: 6px;
+    margin-bottom: 12px;
+    font-size: 14px;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+}
+
+.breadcrumb span {
+    cursor: pointer;
+    color: #667eea;
+}
+
+.breadcrumb span:hover {
+    text-decoration: underline;
+}
+
+/* 过滤栏 */
+.filter-bar {
+    display: flex;
+    gap: 10px;
+    align-items: center;
+}
+
+/* 分页 */
+.pagination {
+    display: flex;
+    justify-content: center;
+    gap: 8px;
+    margin-top: 16px;
+}
+
+.pagination button {
+    padding: 6px 14px;
+    border: 1px solid #d9d9d9;
+    border-radius: 4px;
+    background: #fff;
+    cursor: pointer;
+    font-size: 13px;
+}
+
+.pagination button.active {
+    background: #667eea;
+    color: #fff;
+    border-color: #667eea;
+}
+
+.pagination button:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+}
+
+/* 文件操作 */
+.file-actions {
+    display: flex;
+    gap: 10px;
+}
+
+/* 设置 */
+.settings-section {
+    background: #fff;
+    border-radius: 8px;
+    padding: 24px;
+    margin-bottom: 20px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.settings-section h3 {
+    margin-bottom: 16px;
+    font-size: 16px;
+    color: #1a1a2e;
+    border-bottom: 1px solid #eee;
+    padding-bottom: 8px;
+}
+
+/* Toast提示 */
+.toast {
+    position: fixed;
+    top: 20px;
+    right: 20px;
+    padding: 12px 24px;
+    border-radius: 8px;
+    color: #fff;
+    font-size: 14px;
+    z-index: 2000;
+    display: none;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.toast.success {
+    background: #52c41a;
+    display: block;
+}
+
+.toast.error {
+    background: #ff4d4f;
+    display: block;
+}
+
+/* 操作按钮组 */
+.action-btns {
+    display: flex;
+    gap: 4px;
+}
+
+.action-btns .btn {
+    padding: 4px 8px;
+    font-size: 12px;
+}
+
+/* 文件图标 */
+.file-icon {
+    margin-right: 6px;
+}
+
+.dir-link {
+    color: #667eea;
+    cursor: pointer;
+}
+
+.dir-link:hover {
+    text-decoration: underline;
+}

+ 341 - 0
static/index.html

@@ -0,0 +1,341 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>FTP Server 管理</title>
+    <link rel="stylesheet" href="/css/style.css">
+</head>
+<body>
+    <!-- 登录页面 -->
+    <div id="login-page" class="login-page">
+        <div class="login-card">
+            <h1>FTP Server</h1>
+            <p>Web 管理控制台</p>
+            <form id="login-form">
+                <div class="form-group">
+                    <label>用户名</label>
+                    <input type="text" id="login-username" value="admin" required>
+                </div>
+                <div class="form-group">
+                    <label>密码</label>
+                    <input type="password" id="login-password" value="admin123" required>
+                </div>
+                <button type="submit" class="btn btn-primary btn-block">登 录</button>
+            </form>
+        </div>
+    </div>
+
+    <!-- 主界面 -->
+    <div id="main-app" class="main-app" style="display:none">
+        <!-- 侧边栏 -->
+        <nav class="sidebar">
+            <div class="sidebar-header">
+                <h2>FTP Server</h2>
+            </div>
+            <ul class="sidebar-menu">
+                <li class="active" data-page="dashboard">
+                    <span class="icon">&#9632;</span> 仪表盘
+                </li>
+                <li data-page="users">
+                    <span class="icon">&#9775;</span> 用户管理
+                </li>
+                <li data-page="files">
+                    <span class="icon">&#128193;</span> 文件管理
+                </li>
+                <li data-page="logs">
+                    <span class="icon">&#128196;</span> 操作日志
+                </li>
+                <li data-page="online">
+                    <span class="icon">&#128279;</span> 在线用户
+                </li>
+                <li data-page="settings">
+                    <span class="icon">&#9881;</span> 系统设置
+                </li>
+            </ul>
+            <div class="sidebar-footer">
+                <button id="logout-btn" class="btn btn-sm">退出登录</button>
+            </div>
+        </nav>
+
+        <!-- 内容区 -->
+        <main class="content">
+            <!-- 仪表盘 -->
+            <div id="page-dashboard" class="page active">
+                <h2>仪表盘</h2>
+                <div class="stats-grid">
+                    <div class="stat-card">
+                        <div class="stat-value" id="stat-users">-</div>
+                        <div class="stat-label">总用户数</div>
+                    </div>
+                    <div class="stat-card">
+                        <div class="stat-value" id="stat-enabled">-</div>
+                        <div class="stat-label">启用用户</div>
+                    </div>
+                    <div class="stat-card">
+                        <div class="stat-value" id="stat-online">-</div>
+                        <div class="stat-label">在线用户</div>
+                    </div>
+                    <div class="stat-card">
+                        <div class="stat-value" id="stat-today-logins">-</div>
+                        <div class="stat-label">今日登录</div>
+                    </div>
+                    <div class="stat-card">
+                        <div class="stat-value" id="stat-today-uploads">-</div>
+                        <div class="stat-label">今日上传</div>
+                    </div>
+                    <div class="stat-card">
+                        <div class="stat-value" id="stat-today-downloads">-</div>
+                        <div class="stat-label">今日下载</div>
+                    </div>
+                </div>
+                <div class="stats-grid" style="margin-top:20px">
+                    <div class="stat-card wide">
+                        <div class="stat-value" id="stat-upload-bytes">-</div>
+                        <div class="stat-label">总上传量</div>
+                    </div>
+                    <div class="stat-card wide">
+                        <div class="stat-value" id="stat-download-bytes">-</div>
+                        <div class="stat-label">总下载量</div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- 用户管理 -->
+            <div id="page-users" class="page">
+                <div class="page-header">
+                    <h2>用户管理</h2>
+                    <button class="btn btn-primary" onclick="showAddUser()">添加用户</button>
+                </div>
+                <table class="data-table">
+                    <thead>
+                        <tr>
+                            <th>ID</th>
+                            <th>用户名</th>
+                            <th>主目录</th>
+                            <th>权限</th>
+                            <th>配额</th>
+                            <th>状态</th>
+                            <th>创建时间</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="users-tbody"></tbody>
+                </table>
+            </div>
+
+            <!-- 文件管理 -->
+            <div id="page-files" class="page">
+                <div class="page-header">
+                    <h2>文件管理</h2>
+                    <div class="file-actions">
+                        <button class="btn btn-primary" onclick="uploadFile()">上传文件</button>
+                        <button class="btn" onclick="createFolder()">新建文件夹</button>
+                    </div>
+                </div>
+                <div class="breadcrumb" id="file-breadcrumb">
+                    <span>/</span>
+                </div>
+                <table class="data-table">
+                    <thead>
+                        <tr>
+                            <th>名称</th>
+                            <th>大小</th>
+                            <th>修改时间</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="files-tbody"></tbody>
+                </table>
+            </div>
+
+            <!-- 操作日志 -->
+            <div id="page-logs" class="page">
+                <div class="page-header">
+                    <h2>操作日志</h2>
+                    <div class="filter-bar">
+                        <input type="text" id="log-username" placeholder="用户名筛选" class="input-sm">
+                        <select id="log-action" class="input-sm">
+                            <option value="">全部操作</option>
+                            <option value="login">登录</option>
+                            <option value="login_failed">登录失败</option>
+                            <option value="upload">上传</option>
+                            <option value="download">下载</option>
+                            <option value="delete">删除</option>
+                        </select>
+                        <button class="btn btn-sm" onclick="loadLogs()">查询</button>
+                    </div>
+                </div>
+                <table class="data-table">
+                    <thead>
+                        <tr>
+                            <th>时间</th>
+                            <th>用户</th>
+                            <th>IP</th>
+                            <th>操作</th>
+                            <th>文件路径</th>
+                            <th>大小</th>
+                            <th>状态</th>
+                        </tr>
+                    </thead>
+                    <tbody id="logs-tbody"></tbody>
+                </table>
+                <div class="pagination" id="logs-pagination"></div>
+            </div>
+
+            <!-- 在线用户 -->
+            <div id="page-online" class="page">
+                <h2>在线用户</h2>
+                <table class="data-table">
+                    <thead>
+                        <tr>
+                            <th>用户名</th>
+                            <th>IP地址</th>
+                            <th>登录时间</th>
+                            <th>最后活动</th>
+                            <th>当前目录</th>
+                        </tr>
+                    </thead>
+                    <tbody id="online-tbody"></tbody>
+                </table>
+            </div>
+
+            <!-- 系统设置 -->
+            <div id="page-settings" class="page">
+                <h2>系统设置</h2>
+                <div class="settings-section">
+                    <h3>FTP设置</h3>
+                    <div class="form-grid">
+                        <div class="form-group">
+                            <label>FTP端口</label>
+                            <input type="number" id="cfg-ftp-port" class="input-sm">
+                        </div>
+                        <div class="form-group">
+                            <label>被动端口范围(起始)</label>
+                            <input type="number" id="cfg-ftp-passive-min" class="input-sm">
+                        </div>
+                        <div class="form-group">
+                            <label>被动端口范围(结束)</label>
+                            <input type="number" id="cfg-ftp-passive-max" class="input-sm">
+                        </div>
+                        <div class="form-group">
+                            <label>最大连接数</label>
+                            <input type="number" id="cfg-ftp-max-conn" class="input-sm">
+                        </div>
+                        <div class="form-group">
+                            <label>空闲超时(秒)</label>
+                            <input type="number" id="cfg-ftp-idle-timeout" class="input-sm">
+                        </div>
+                        <div class="form-group">
+                            <label>启用匿名访问</label>
+                            <select id="cfg-ftp-anonymous" class="input-sm">
+                                <option value="true">是</option>
+                                <option value="false">否</option>
+                            </select>
+                        </div>
+                    </div>
+                </div>
+                <div class="settings-section">
+                    <h3>管理员设置</h3>
+                    <div class="form-grid">
+                        <div class="form-group">
+                            <label>管理员用户名</label>
+                            <input type="text" id="cfg-admin-username" class="input-sm">
+                        </div>
+                        <div class="form-group">
+                            <label>新密码(留空不修改)</label>
+                            <input type="password" id="cfg-admin-password" class="input-sm" placeholder="留空不修改">
+                        </div>
+                    </div>
+                </div>
+                <button class="btn btn-primary" onclick="saveConfig()" style="margin-top:20px">保存设置</button>
+            </div>
+        </main>
+    </div>
+
+    <!-- 添加/编辑用户对话框 -->
+    <div id="user-modal" class="modal" style="display:none">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h3 id="user-modal-title">添加用户</h3>
+                <span class="modal-close" onclick="closeUserModal()">&times;</span>
+            </div>
+            <form id="user-form">
+                <input type="hidden" id="user-edit-mode" value="add">
+                <div class="form-group">
+                    <label>用户名</label>
+                    <input type="text" id="user-username" required>
+                </div>
+                <div class="form-group">
+                    <label>密码</label>
+                    <input type="password" id="user-password" required>
+                </div>
+                <div class="form-group">
+                    <label>主目录</label>
+                    <input type="text" id="user-homedir" placeholder="留空自动设置为 /ftp_root/用户名">
+                    <small>目录不存在时将自动创建</small>
+                </div>
+                <div class="form-group">
+                    <label>权限</label>
+                    <select id="user-permissions">
+                        <option value="read">只读</option>
+                        <option value="write">只写</option>
+                        <option value="read,write" selected>读写</option>
+                    </select>
+                </div>
+                <div class="form-grid">
+                    <div class="form-group">
+                        <label>空间配额(MB, 0=无限制)</label>
+                        <input type="number" id="user-quota-size" value="0">
+                    </div>
+                    <div class="form-group">
+                        <label>文件数配额(0=无限制)</label>
+                        <input type="number" id="user-quota-files" value="0">
+                    </div>
+                    <div class="form-group">
+                        <label>上传限速(KB/s, 0=无限制)</label>
+                        <input type="number" id="user-upload-rate" value="0">
+                    </div>
+                    <div class="form-group">
+                        <label>下载限速(KB/s, 0=无限制)</label>
+                        <input type="number" id="user-download-rate" value="0">
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label>
+                        <input type="checkbox" id="user-enabled" checked> 启用账户
+                    </label>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn" onclick="closeUserModal()">取消</button>
+                    <button type="submit" class="btn btn-primary">保存</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+    <!-- 文件上传对话框 -->
+    <div id="upload-modal" class="modal" style="display:none">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h3>上传文件</h3>
+                <span class="modal-close" onclick="closeUploadModal()">&times;</span>
+            </div>
+            <form id="upload-form">
+                <div class="form-group">
+                    <input type="file" id="upload-file" required>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn" onclick="closeUploadModal()">取消</button>
+                    <button type="submit" class="btn btn-primary">上传</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+    <!-- 提示消息 -->
+    <div id="toast" class="toast"></div>
+
+    <script src="/js/app.js"></script>
+</body>
+</html>

+ 460 - 0
static/js/app.js

@@ -0,0 +1,460 @@
+// 全局变量
+let token = localStorage.getItem('ftp_token') || '';
+let currentPath = '';
+let logPage = 1;
+
+// --- API 封装 ---
+async function api(method, url, data) {
+    const opts = {
+        method,
+        headers: {
+            'Authorization': 'Bearer ' + token,
+            'Content-Type': 'application/json'
+        }
+    };
+    if (data && method !== 'GET') {
+        opts.body = JSON.stringify(data);
+    }
+    const resp = await fetch(url, opts);
+    const json = await resp.json();
+    if (resp.status === 401) {
+        token = '';
+        localStorage.removeItem('ftp_token');
+        showLogin();
+        throw new Error('登录已过期');
+    }
+    if (json.error) {
+        throw new Error(json.error);
+    }
+    return json.data;
+}
+
+// --- 工具函数 ---
+function showToast(msg, type = 'success') {
+    const toast = document.getElementById('toast');
+    toast.textContent = msg;
+    toast.className = 'toast ' + type;
+    setTimeout(() => { toast.className = 'toast'; }, 3000);
+}
+
+function formatBytes(bytes) {
+    if (!bytes || bytes === 0) return '0 B';
+    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(1024));
+    return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
+}
+
+function formatTime(t) {
+    if (!t) return '-';
+    return t.replace('T', ' ').substring(0, 19);
+}
+
+function showLogin() {
+    document.getElementById('login-page').style.display = 'flex';
+    document.getElementById('main-app').style.display = 'none';
+}
+
+function showMain() {
+    document.getElementById('login-page').style.display = 'none';
+    document.getElementById('main-app').style.display = 'flex';
+}
+
+// --- 登录 ---
+document.getElementById('login-form').addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const username = document.getElementById('login-username').value;
+    const password = document.getElementById('login-password').value;
+    try {
+        const data = await api('POST', '/api/login', { username, password });
+        token = data.token;
+        localStorage.setItem('ftp_token', token);
+        showMain();
+        loadDashboard();
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+});
+
+document.getElementById('logout-btn').addEventListener('click', () => {
+    token = '';
+    localStorage.removeItem('ftp_token');
+    showLogin();
+});
+
+// --- 导航切换 ---
+document.querySelectorAll('.sidebar-menu li').forEach(li => {
+    li.addEventListener('click', () => {
+        document.querySelectorAll('.sidebar-menu li').forEach(el => el.classList.remove('active'));
+        li.classList.add('active');
+        const page = li.dataset.page;
+        document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
+        document.getElementById('page-' + page).classList.add('active');
+        loadPage(page);
+    });
+});
+
+function loadPage(page) {
+    switch (page) {
+        case 'dashboard': loadDashboard(); break;
+        case 'users': loadUsers(); break;
+        case 'files': loadFiles(currentPath); break;
+        case 'logs': loadLogs(); break;
+        case 'online': loadOnline(); break;
+        case 'settings': loadConfig(); break;
+    }
+}
+
+// --- 仪表盘 ---
+async function loadDashboard() {
+    try {
+        const stats = await api('GET', '/api/dashboard');
+        document.getElementById('stat-users').textContent = stats.total_users || 0;
+        document.getElementById('stat-enabled').textContent = stats.enabled_users || 0;
+        document.getElementById('stat-online').textContent = stats.online_users || 0;
+        document.getElementById('stat-today-logins').textContent = stats.today_logins || 0;
+        document.getElementById('stat-today-uploads').textContent = stats.today_uploads || 0;
+        document.getElementById('stat-today-downloads').textContent = stats.today_downloads || 0;
+        document.getElementById('stat-upload-bytes').textContent = formatBytes(stats.total_upload_bytes);
+        document.getElementById('stat-download-bytes').textContent = formatBytes(stats.total_download_bytes);
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+// --- 用户管理 ---
+async function loadUsers() {
+    try {
+        const users = await api('GET', '/api/users');
+        const tbody = document.getElementById('users-tbody');
+        tbody.innerHTML = users.map(u => `
+            <tr>
+                <td>${u.id}</td>
+                <td>${u.username}</td>
+                <td title="${u.home_dir}">${u.home_dir}</td>
+                <td>${u.permissions}</td>
+                <td>${u.quota_size > 0 ? formatBytes(u.quota_size) : '无限制'}</td>
+                <td><span class="${u.enabled ? 'status-enabled' : 'status-disabled'}">${u.enabled ? '启用' : '禁用'}</span></td>
+                <td>${formatTime(u.created_at)}</td>
+                <td class="action-btns">
+                    <button class="btn btn-sm" onclick="editUser('${u.username}')">编辑</button>
+                    <button class="btn btn-sm" onclick="resetPassword('${u.username}')">改密</button>
+                    <button class="btn btn-sm btn-danger" onclick="deleteUser('${u.username}')">删除</button>
+                </td>
+            </tr>
+        `).join('');
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+function showAddUser() {
+    document.getElementById('user-modal-title').textContent = '添加用户';
+    document.getElementById('user-edit-mode').value = 'add';
+    document.getElementById('user-form').reset();
+    document.getElementById('user-username').disabled = false;
+    document.getElementById('user-password').required = true;
+    document.getElementById('user-enabled').checked = true;
+    document.getElementById('user-modal').style.display = 'flex';
+}
+
+async function editUser(username) {
+    try {
+        const user = await api('GET', '/api/users/' + username);
+        document.getElementById('user-modal-title').textContent = '编辑用户';
+        document.getElementById('user-edit-mode').value = 'edit';
+        document.getElementById('user-username').value = user.username;
+        document.getElementById('user-username').disabled = true;
+        document.getElementById('user-password').value = '';
+        document.getElementById('user-password').required = false;
+        document.getElementById('user-homedir').value = user.home_dir;
+        document.getElementById('user-permissions').value = user.permissions;
+        document.getElementById('user-quota-size').value = Math.round(user.quota_size / 1024 / 1024);
+        document.getElementById('user-quota-files').value = user.quota_files;
+        document.getElementById('user-upload-rate').value = user.upload_rate;
+        document.getElementById('user-download-rate').value = user.download_rate;
+        document.getElementById('user-enabled').checked = user.enabled;
+        document.getElementById('user-modal').style.display = 'flex';
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+document.getElementById('user-form').addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const mode = document.getElementById('user-edit-mode').value;
+    const username = document.getElementById('user-username').value;
+    const password = document.getElementById('user-password').value;
+    const homeDir = document.getElementById('user-homedir').value;
+    const quotaMB = parseInt(document.getElementById('user-quota-size').value) || 0;
+
+    const data = {
+        username,
+        password,
+        home_dir: homeDir,
+        permissions: document.getElementById('user-permissions').value,
+        quota_size: quotaMB * 1024 * 1024,
+        quota_files: parseInt(document.getElementById('user-quota-files').value) || 0,
+        upload_rate: parseInt(document.getElementById('user-upload-rate').value) || 0,
+        download_rate: parseInt(document.getElementById('user-download-rate').value) || 0,
+        enabled: document.getElementById('user-enabled').checked
+    };
+
+    try {
+        if (mode === 'add') {
+            await api('POST', '/api/users', data);
+            showToast('用户添加成功');
+        } else {
+            await api('PUT', '/api/users/' + username, data);
+            if (password) {
+                await api('PUT', '/api/users/' + username + '/password', { password });
+            }
+            showToast('用户更新成功');
+        }
+        closeUserModal();
+        loadUsers();
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+});
+
+function closeUserModal() {
+    document.getElementById('user-modal').style.display = 'none';
+}
+
+async function deleteUser(username) {
+    if (!confirm('确定删除用户 "' + username + '" 吗?')) return;
+    try {
+        await api('DELETE', '/api/users/' + username);
+        showToast('用户已删除');
+        loadUsers();
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+async function resetPassword(username) {
+    const password = prompt('请输入新密码:');
+    if (!password) return;
+    try {
+        await api('PUT', '/api/users/' + username + '/password', { password });
+        showToast('密码已更新');
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+// --- 文件管理 ---
+async function loadFiles(path) {
+    try {
+        const url = '/api/files?path=' + encodeURIComponent(path || '');
+        const data = await api('GET', url);
+        currentPath = data.path;
+        const tbody = document.getElementById('files-tbody');
+        let html = '';
+
+        // 返回上级
+        if (currentPath) {
+            html += `<tr>
+                <td colspan="4"><span class="dir-link" onclick="loadFiles('')">[根目录]</span></td>
+            </tr>`;
+        }
+
+        data.files.forEach(f => {
+            if (f.is_dir) {
+                html += `<tr>
+                    <td><span class="file-icon">&#128193;</span><span class="dir-link" onclick="loadFiles('${f.path.replace(/\\/g, '\\\\')}')">${f.name}</span></td>
+                    <td>-</td>
+                    <td>${formatTime(f.mod_time)}</td>
+                    <td class="action-btns">
+                        <button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', true)">删除</button>
+                    </td>
+                </tr>`;
+            } else {
+                html += `<tr>
+                    <td><span class="file-icon">&#128196;</span>${f.name}</td>
+                    <td>${formatBytes(f.size)}</td>
+                    <td>${formatTime(f.mod_time)}</td>
+                    <td class="action-btns">
+                        <button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', false)">删除</button>
+                    </td>
+                </tr>`;
+            }
+        });
+
+        if (!data.files.length && !currentPath) {
+            html = '<tr><td colspan="4" style="text-align:center;color:#999;padding:40px">目录为空</td></tr>';
+        }
+
+        tbody.innerHTML = html;
+
+        // 面包屑
+        document.getElementById('file-breadcrumb').innerHTML = '<span onclick="loadFiles(\'\')">/</span> ' +
+            currentPath.replace(/\\/g, '/').split('/').filter(Boolean).map((p, i, arr) => {
+                const subPath = arr.slice(0, i + 1).join('/');
+                return '<span onclick="loadFiles(\'' + subPath + '\')">' + p + '</span>';
+            }).join(' / ');
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+async function deleteFile(path, isDir) {
+    const name = path.split(/[\\/]/).pop();
+    if (!confirm('确定删除 "' + name + '" 吗?' + (isDir ? '将删除文件夹内所有内容!' : ''))) return;
+    try {
+        await api('DELETE', '/api/files?path=' + encodeURIComponent(path));
+        showToast('删除成功');
+        loadFiles(currentPath);
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+function uploadFile() {
+    document.getElementById('upload-form').reset();
+    document.getElementById('upload-modal').style.display = 'flex';
+}
+
+function closeUploadModal() {
+    document.getElementById('upload-modal').style.display = 'none';
+}
+
+document.getElementById('upload-form').addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const fileInput = document.getElementById('upload-file');
+    if (!fileInput.files.length) return;
+
+    const formData = new FormData();
+    formData.append('file', fileInput.files[0]);
+
+    try {
+        const resp = await fetch('/api/upload?path=' + encodeURIComponent(currentPath), {
+            method: 'POST',
+            headers: { 'Authorization': 'Bearer ' + token },
+            body: formData
+        });
+        const json = await resp.json();
+        if (json.error) throw new Error(json.error);
+        showToast('上传成功');
+        closeUploadModal();
+        loadFiles(currentPath);
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+});
+
+async function createFolder() {
+    const name = prompt('请输入文件夹名称:');
+    if (!name) return;
+    try {
+        await api('POST', '/api/files', { path: currentPath, name, type: 'dir' });
+        showToast('文件夹已创建');
+        loadFiles(currentPath);
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+// --- 日志 ---
+async function loadLogs() {
+    const username = document.getElementById('log-username').value;
+    const action = document.getElementById('log-action').value;
+    try {
+        const data = await api('GET', `/api/logs?username=${encodeURIComponent(username)}&action=${action}&page=${logPage}&page_size=20`);
+        const tbody = document.getElementById('logs-tbody');
+        if (!data.logs || !data.logs.length) {
+            tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:40px">暂无日志</td></tr>';
+        } else {
+            tbody.innerHTML = data.logs.map(l => {
+                let statusClass = l.status === 'success' ? 'status-enabled' : 'status-disabled';
+                return `<tr>
+                    <td>${formatTime(l.created_at)}</td>
+                    <td>${l.username || '-'}</td>
+                    <td>${l.ip || '-'}</td>
+                    <td>${l.action}</td>
+                    <td title="${l.file_path}">${l.file_path || '-'}</td>
+                    <td>${l.file_size > 0 ? formatBytes(l.file_size) : '-'}</td>
+                    <td><span class="${statusClass}">${l.status}</span></td>
+                </tr>`;
+            }).join('');
+        }
+
+        // 分页
+        const totalPages = Math.ceil(data.total / 20);
+        let pagHtml = `<button ${logPage <= 1 ? 'disabled' : ''} onclick="logPage=${logPage - 1};loadLogs()">上一页</button>`;
+        pagHtml += `<span style="padding:6px 12px">第 ${logPage} / ${totalPages || 1} 页 (共 ${data.total} 条)</span>`;
+        pagHtml += `<button ${logPage >= totalPages ? 'disabled' : ''} onclick="logPage=${logPage + 1};loadLogs()">下一页</button>`;
+        document.getElementById('logs-pagination').innerHTML = pagHtml;
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+// --- 在线用户 ---
+async function loadOnline() {
+    try {
+        const users = await api('GET', '/api/online');
+        const tbody = document.getElementById('online-tbody');
+        if (!users || !users.length) {
+            tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#999;padding:40px">暂无在线用户</td></tr>';
+        } else {
+            tbody.innerHTML = users.map(u => `
+                <tr>
+                    <td>${u.username || '-'}</td>
+                    <td>${u.ip}</td>
+                    <td>${formatTime(u.login_time)}</td>
+                    <td>${formatTime(u.last_activity)}</td>
+                    <td>${u.current_dir || '-'}</td>
+                </tr>
+            `).join('');
+        }
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+// --- 系统设置 ---
+async function loadConfig() {
+    try {
+        const cfg = await api('GET', '/api/config');
+        document.getElementById('cfg-ftp-port').value = cfg.ftp.port;
+        document.getElementById('cfg-ftp-passive-min').value = cfg.ftp.passive_port_min;
+        document.getElementById('cfg-ftp-passive-max').value = cfg.ftp.passive_port_max;
+        document.getElementById('cfg-ftp-max-conn').value = cfg.ftp.max_connections;
+        document.getElementById('cfg-ftp-idle-timeout').value = cfg.ftp.idle_timeout;
+        document.getElementById('cfg-ftp-anonymous').value = String(cfg.ftp.enable_anonymous);
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+async function saveConfig() {
+    const data = {
+        ftp: {
+            port: parseInt(document.getElementById('cfg-ftp-port').value),
+            passive_port_min: parseInt(document.getElementById('cfg-ftp-passive-min').value),
+            passive_port_max: parseInt(document.getElementById('cfg-ftp-passive-max').value),
+            max_connections: parseInt(document.getElementById('cfg-ftp-max-conn').value),
+            idle_timeout: parseInt(document.getElementById('cfg-ftp-idle-timeout').value),
+            enable_anonymous: document.getElementById('cfg-ftp-anonymous').value === 'true'
+        },
+        admin: {
+            username: document.getElementById('cfg-admin-username').value,
+            password: document.getElementById('cfg-admin-password').value
+        }
+    };
+    try {
+        await api('PUT', '/api/config', data);
+        showToast('配置已保存,部分设置需要重启生效');
+    } catch (err) {
+        showToast(err.message, 'error');
+    }
+}
+
+// --- 初始化 ---
+if (token) {
+    showMain();
+    loadDashboard();
+} else {
+    showLogin();
+}

+ 585 - 0
web/server.go

@@ -0,0 +1,585 @@
+package web
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"ftp-server/config"
+	"ftp-server/database"
+	"ftp-server/ftp"
+
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// Server Web管理服务器
+type Server struct {
+	config    *config.Config
+	db        *database.DB
+	ftpServer *ftp.Server
+	jwtSecret []byte
+}
+
+// NewServer 创建Web服务器
+func NewServer(cfg *config.Config, db *database.DB, ftpSrv *ftp.Server) *Server {
+	secret := make([]byte, 32)
+	rand.Read(secret)
+	return &Server{
+		config:    cfg,
+		db:        db,
+		ftpServer: ftpSrv,
+		jwtSecret: secret,
+	}
+}
+
+// Start 启动Web服务
+func (s *Server) Start() error {
+	webCfg := s.config.Get().Web
+
+	mux := http.NewServeMux()
+
+	// 静态文件
+	fs := http.FileServer(http.Dir("./static"))
+	mux.Handle("/", fs)
+
+	// API路由
+	mux.HandleFunc("/api/login", s.handleLogin)
+	mux.HandleFunc("/api/dashboard", s.authMiddleware(s.handleDashboard))
+	mux.HandleFunc("/api/users", s.authMiddleware(s.handleUsers))
+	mux.HandleFunc("/api/users/", s.authMiddleware(s.handleUserOperation))
+	mux.HandleFunc("/api/files", s.authMiddleware(s.handleFileBrowse))
+	mux.HandleFunc("/api/files/", s.authMiddleware(s.handleFileBrowse))
+	mux.HandleFunc("/api/logs", s.authMiddleware(s.handleLogs))
+	mux.HandleFunc("/api/online", s.authMiddleware(s.handleOnline))
+	mux.HandleFunc("/api/config", s.authMiddleware(s.handleConfig))
+	mux.HandleFunc("/api/upload", s.authMiddleware(s.handleUpload))
+
+	addr := fmt.Sprintf("%s:%d", webCfg.Host, webCfg.Port)
+	log.Printf("Web管理界面已启动: http://localhost:%d", webCfg.Port)
+
+	server := &http.Server{
+		Addr:         addr,
+		Handler:      mux,
+		ReadTimeout:  30 * time.Second,
+		WriteTimeout: 30 * time.Second,
+	}
+
+	return server.ListenAndServe()
+}
+
+// --- 中间件 ---
+
+func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		tokenStr := r.Header.Get("Authorization")
+		if tokenStr == "" {
+			// 也支持 cookie
+			if cookie, err := r.Cookie("token"); err == nil {
+				tokenStr = cookie.Value
+			}
+		}
+
+		if strings.HasPrefix(tokenStr, "Bearer ") {
+			tokenStr = tokenStr[7:]
+		}
+
+		if tokenStr == "" {
+			s.jsonError(w, "未登录", http.StatusUnauthorized)
+			return
+		}
+
+		token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
+			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+				return nil, fmt.Errorf("无效的签名方法")
+			}
+			return s.jwtSecret, nil
+		})
+
+		if err != nil || !token.Valid {
+			s.jsonError(w, "登录已过期", http.StatusUnauthorized)
+			return
+		}
+
+		next(w, r)
+	}
+}
+
+// --- 请求处理 ---
+
+// handleLogin 登录
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		Username string `json:"username"`
+		Password string `json:"password"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		s.jsonError(w, "请求格式错误", http.StatusBadRequest)
+		return
+	}
+
+	adminCfg := s.config.Get().Admin
+	if req.Username != adminCfg.Username || req.Password != adminCfg.Password {
+		s.jsonError(w, "用户名或密码错误", http.StatusUnauthorized)
+		return
+	}
+
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+		"username": req.Username,
+		"exp":      time.Now().Add(24 * time.Hour).Unix(),
+	})
+
+	tokenStr, err := token.SignedString(s.jwtSecret)
+	if err != nil {
+		s.jsonError(w, "生成令牌失败", http.StatusInternalServerError)
+		return
+	}
+
+	s.jsonResponse(w, http.StatusOK, map[string]interface{}{
+		"token": tokenStr,
+	})
+}
+
+// handleDashboard 仪表盘
+func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
+	stats, err := s.db.GetLogStats()
+	if err != nil {
+		s.jsonError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// 添加在线用户数
+	if s.ftpServer != nil {
+		online := s.ftpServer.GetOnlineUsers()
+		stats["online_users"] = len(online)
+	} else {
+		stats["online_users"] = 0
+	}
+
+	s.jsonResponse(w, http.StatusOK, stats)
+}
+
+// handleUsers 用户管理
+func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		users, err := s.db.ListUsers()
+		if err != nil {
+			s.jsonError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		if users == nil {
+			users = []database.FTPUser{}
+		}
+		s.jsonResponse(w, http.StatusOK, users)
+
+	case http.MethodPost:
+		var user database.FTPUser
+		if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
+			s.jsonError(w, "请求格式错误", http.StatusBadRequest)
+			return
+		}
+
+		if user.Username == "" || user.Password == "" {
+			s.jsonError(w, "用户名和密码不能为空", http.StatusBadRequest)
+			return
+		}
+
+		if user.HomeDir == "" {
+			ftpCfg := s.config.Get().FTP
+			user.HomeDir = filepath.Join(ftpCfg.RootDir, user.Username)
+		}
+
+		// 自动创建用户目录(如果不存在)
+		if err := os.MkdirAll(user.HomeDir, 0755); err != nil {
+			s.jsonError(w, fmt.Sprintf("创建用户目录失败: %v", err), http.StatusInternalServerError)
+			return
+		}
+
+		if user.Permissions == "" {
+			user.Permissions = "read,write"
+		}
+		user.Enabled = true
+
+		if err := s.db.CreateUser(&user); err != nil {
+			s.jsonError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		s.jsonResponse(w, http.StatusOK, user)
+
+	default:
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+	}
+}
+
+// handleUserOperation 单个用户操作
+func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
+	// 解析路径中的用户名: /api/users/{username} 或 /api/users/{username}/password
+	pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/users/"), "/")
+	username := pathParts[0]
+
+	if username == "" {
+		s.jsonError(w, "用户名不能为空", http.StatusBadRequest)
+		return
+	}
+
+	// 密码修改: PUT /api/users/{username}/password
+	if len(pathParts) > 1 && pathParts[1] == "password" && r.Method == http.MethodPut {
+		var req struct {
+			Password string `json:"password"`
+		}
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			s.jsonError(w, "请求格式错误", http.StatusBadRequest)
+			return
+		}
+		if req.Password == "" {
+			s.jsonError(w, "密码不能为空", http.StatusBadRequest)
+			return
+		}
+		if err := s.db.UpdateUserPassword(username, req.Password); err != nil {
+			s.jsonError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		s.jsonResponse(w, http.StatusOK, map[string]string{"message": "密码已更新"})
+		return
+	}
+
+	switch r.Method {
+	case http.MethodGet:
+		user, err := s.db.GetUser(username)
+		if err != nil {
+			s.jsonError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		if user == nil {
+			s.jsonError(w, "用户不存在", http.StatusNotFound)
+			return
+		}
+		s.jsonResponse(w, http.StatusOK, user)
+
+	case http.MethodPut:
+		var user database.FTPUser
+		if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
+			s.jsonError(w, "请求格式错误", http.StatusBadRequest)
+			return
+		}
+
+		// 如果目录有变化,自动创建新目录
+		if user.HomeDir != "" {
+			if err := os.MkdirAll(user.HomeDir, 0755); err != nil {
+				s.jsonError(w, fmt.Sprintf("创建目录失败: %v", err), http.StatusInternalServerError)
+				return
+			}
+		}
+
+		if err := s.db.UpdateUser(&user); err != nil {
+			s.jsonError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		s.jsonResponse(w, http.StatusOK, map[string]string{"message": "用户已更新"})
+
+	case http.MethodDelete:
+		if err := s.db.DeleteUser(username); err != nil {
+			s.jsonError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		s.jsonResponse(w, http.StatusOK, map[string]string{"message": "用户已删除"})
+
+	default:
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+	}
+}
+
+// FileInfo 文件信息
+type FileInfo struct {
+	Name    string    `json:"name"`
+	Size    int64     `json:"size"`
+	IsDir   bool      `json:"is_dir"`
+	ModTime time.Time `json:"mod_time"`
+	Path    string    `json:"path"`
+}
+
+// handleFileBrowse 文件浏览
+func (s *Server) handleFileBrowse(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+		return
+	}
+
+	dir := r.URL.Query().Get("path")
+	if dir == "" {
+		dir = s.config.Get().FTP.RootDir
+	}
+
+	// 安全检查:确保路径在FTP根目录内
+	rootDir, err := filepath.Abs(s.config.Get().FTP.RootDir)
+	if err != nil {
+		s.jsonError(w, "无效的根目录", http.StatusInternalServerError)
+		return
+	}
+
+	absPath, err := filepath.Abs(dir)
+	if err != nil {
+		s.jsonError(w, "无效的路径", http.StatusBadRequest)
+		return
+	}
+
+	if !strings.HasPrefix(absPath, rootDir) {
+		s.jsonError(w, "路径超出允许范围", http.StatusForbidden)
+		return
+	}
+
+	entries, err := os.ReadDir(absPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			s.jsonResponse(w, http.StatusOK, []FileInfo{})
+			return
+		}
+		s.jsonError(w, fmt.Sprintf("读取目录失败: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	var files []FileInfo
+	for _, entry := range entries {
+		info, err := entry.Info()
+		if err != nil {
+			continue
+		}
+		files = append(files, FileInfo{
+			Name:    entry.Name(),
+			Size:    info.Size(),
+			IsDir:   entry.IsDir(),
+			ModTime: info.ModTime(),
+			Path:    filepath.Join(absPath, entry.Name()),
+		})
+	}
+
+	if files == nil {
+		files = []FileInfo{}
+	}
+
+	s.jsonResponse(w, http.StatusOK, map[string]interface{}{
+		"path":  absPath,
+		"files": files,
+	})
+}
+
+// handleUpload 文件上传
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+		return
+	}
+
+	targetDir := r.URL.Query().Get("path")
+	if targetDir == "" {
+		targetDir = s.config.Get().FTP.RootDir
+	}
+
+	// 安全检查
+	rootDir, _ := filepath.Abs(s.config.Get().FTP.RootDir)
+	absDir, err := filepath.Abs(targetDir)
+	if err != nil || !strings.HasPrefix(absDir, rootDir) {
+		s.jsonError(w, "路径超出允许范围", http.StatusForbidden)
+		return
+	}
+
+	r.ParseMultipartForm(100 << 20) // 100MB最大
+
+	file, header, err := r.FormFile("file")
+	if err != nil {
+		s.jsonError(w, "读取文件失败", http.StatusBadRequest)
+		return
+	}
+	defer file.Close()
+
+	targetPath := filepath.Join(absDir, header.Filename)
+	dst, err := os.Create(targetPath)
+	if err != nil {
+		s.jsonError(w, "创建文件失败", http.StatusInternalServerError)
+		return
+	}
+	defer dst.Close()
+
+	written, err := io.Copy(dst, file)
+	if err != nil {
+		s.jsonError(w, "写入文件失败", http.StatusInternalServerError)
+		return
+	}
+
+	s.jsonResponse(w, http.StatusOK, map[string]interface{}{
+		"message": "上传成功",
+		"size":    written,
+		"path":    targetPath,
+	})
+}
+
+// handleLogs 日志查询
+func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+		return
+	}
+
+	username := r.URL.Query().Get("username")
+	action := r.URL.Query().Get("action")
+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+	pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
+
+	if page <= 0 {
+		page = 1
+	}
+	if pageSize <= 0 {
+		pageSize = 20
+	}
+
+	logs, total, err := s.db.QueryLogs(username, action, page, pageSize)
+	if err != nil {
+		s.jsonError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	if logs == nil {
+		logs = []database.FTPLog{}
+	}
+
+	s.jsonResponse(w, http.StatusOK, map[string]interface{}{
+		"logs":      logs,
+		"total":     total,
+		"page":      page,
+		"page_size": pageSize,
+	})
+}
+
+// handleOnline 在线用户
+func (s *Server) handleOnline(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var users []database.OnlineUser
+	if s.ftpServer != nil {
+		users = s.ftpServer.GetOnlineUsers()
+	}
+	if users == nil {
+		users = []database.OnlineUser{}
+	}
+
+	s.jsonResponse(w, http.StatusOK, users)
+}
+
+// handleConfig 配置管理
+func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		cfg := s.config.Get()
+		// 隐藏敏感信息
+		safeCfg := map[string]interface{}{
+			"ftp": map[string]interface{}{
+				"host":             cfg.FTP.Host,
+				"port":             cfg.FTP.Port,
+				"passive_port_min": cfg.FTP.PassivePortMin,
+				"passive_port_max": cfg.FTP.PassivePortMax,
+				"root_dir":         cfg.FTP.RootDir,
+				"enable_anonymous": cfg.FTP.EnableAnonymous,
+				"max_connections":  cfg.FTP.MaxConnections,
+				"idle_timeout":     cfg.FTP.IdleTimeout,
+			},
+			"web": map[string]interface{}{
+				"host": cfg.Web.Host,
+				"port": cfg.Web.Port,
+			},
+		}
+		s.jsonResponse(w, http.StatusOK, safeCfg)
+
+	case http.MethodPut:
+		var update map[string]interface{}
+		if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
+			s.jsonError(w, "请求格式错误", http.StatusBadRequest)
+			return
+		}
+
+		s.config.Update(func(cfg *config.Config) {
+			if ftpCfg, ok := update["ftp"].(map[string]interface{}); ok {
+				if v, ok := ftpCfg["port"].(float64); ok {
+					cfg.FTP.Port = int(v)
+				}
+				if v, ok := ftpCfg["passive_port_min"].(float64); ok {
+					cfg.FTP.PassivePortMin = int(v)
+				}
+				if v, ok := ftpCfg["passive_port_max"].(float64); ok {
+					cfg.FTP.PassivePortMax = int(v)
+				}
+				if v, ok := ftpCfg["enable_anonymous"].(bool); ok {
+					cfg.FTP.EnableAnonymous = v
+				}
+				if v, ok := ftpCfg["max_connections"].(float64); ok {
+					cfg.FTP.MaxConnections = int(v)
+				}
+				if v, ok := ftpCfg["idle_timeout"].(float64); ok {
+					cfg.FTP.IdleTimeout = int(v)
+				}
+			}
+			if adminCfg, ok := update["admin"].(map[string]interface{}); ok {
+				if v, ok := adminCfg["username"].(string); ok && v != "" {
+					cfg.Admin.Username = v
+				}
+				if v, ok := adminCfg["password"].(string); ok && v != "" {
+					cfg.Admin.Password = v
+				}
+			}
+		})
+
+		if err := s.config.Save("config.json"); err != nil {
+			s.jsonError(w, fmt.Sprintf("保存配置失败: %v", err), http.StatusInternalServerError)
+			return
+		}
+
+		s.jsonResponse(w, http.StatusOK, map[string]string{"message": "配置已保存,部分设置需要重启生效"})
+
+	default:
+		s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
+	}
+}
+
+// --- 工具方法 ---
+
+func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"code": status,
+		"data": data,
+	})
+}
+
+func (s *Server) jsonError(w http.ResponseWriter, msg string, status int) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"code":  status,
+		"error": msg,
+	})
+}
+
+// GenerateToken 生成随机令牌(用于JWT密钥)
+func GenerateToken() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}