feat(scan): 添加自定义扫描目录选择功能

- 在扫描页面添加目录输入框和选择按钮
- 实现文件系统浏览器,支持Windows盘符和目录浏览
- 添加扫描目录参数传递到后端服务
- 显示当前扫描目录路径信息
- 修复前端空数组访问错误
- 更新文档说明扫描功能变化
This commit is contained in:
2025-04-24 21:05:00 +08:00
parent e053b378fb
commit 14056ba7b9
13 changed files with 460 additions and 135 deletions
+88
View File
@@ -0,0 +1,88 @@
// Package api 提供服务器文件系统浏览接口,供前端目录选择器使用(只列出目录,不列文件)。
package api
import (
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/gin-gonic/gin"
)
// FsController 服务器文件系统浏览控制器
type FsController struct{}
// NewFsController 创建文件系统浏览控制器
func NewFsController() *FsController {
return &FsController{}
}
// FsListResult 目录浏览结果
type FsListResult struct {
Current string `json:"current"` // 当前目录绝对路径;盘符层为空
Parent string `json:"parent"` // 父目录;根/盘符层时等于 Current
Drives []string `json:"drives"` // Windows 盘符列表(非 Windows 为空)
Dirs []FsDir `json:"dirs"` // 子目录列表(不含文件)
}
// FsDir 子目录项
type FsDir struct {
Name string `json:"name"`
Path string `json:"path"`
}
// List GET /api/fs/list?dir= 列出指定目录的子目录(不含文件);
// dir 为空时返回 Windows 盘符列表或 Unix 根目录。
func (c *FsController) List(ctx *gin.Context) {
dir := strings.TrimSpace(ctx.Query("dir"))
if dir == "" {
if runtime.GOOS == "windows" {
Success(ctx, FsListResult{Drives: listLogicalDrives()})
return
}
dir = string(os.PathSeparator)
}
abs, err := filepath.Abs(dir)
if err != nil {
Error(ctx, http.StatusBadRequest, "路径解析失败: "+err.Error())
return
}
st, err := os.Stat(abs)
if err != nil || !st.IsDir() {
Error(ctx, http.StatusBadRequest, "目录不存在或不可访问: "+abs)
return
}
entries, err := os.ReadDir(abs)
if err != nil {
Error(ctx, http.StatusBadRequest, "读取目录失败: "+err.Error())
return
}
parent := filepath.Dir(abs)
if parent == abs {
parent = abs
}
dirs := make([]FsDir, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
dirs = append(dirs, FsDir{Name: e.Name(), Path: filepath.Join(abs, e.Name())})
}
sort.Slice(dirs, func(i, j int) bool {
return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name)
})
Success(ctx, FsListResult{
Current: abs,
Parent: parent,
Drives: nil,
Dirs: dirs,
})
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package api
// listLogicalDrives 非 Windows 平台无盘符概念,返回空
func listLogicalDrives() []string {
return nil
}
+20
View File
@@ -0,0 +1,20 @@
//go:build windows
package api
import "golang.org/x/sys/windows"
// listLogicalDrives 返回 Windows 所有逻辑盘符(如 C:\、D:\)
func listLogicalDrives() []string {
bits, err := windows.GetLogicalDrives()
if err != nil {
return nil
}
drives := make([]string, 0, 26)
for i := 0; i < 26; i++ {
if bits&(1<<uint(i)) != 0 {
drives = append(drives, string(rune('A'+i))+":\\")
}
}
return drives
}
+3
View File
@@ -28,6 +28,9 @@ func NewRouter(st *store.Store) *gin.Engine {
sc := NewScanController(service.NewScanService(st))
apiGroup.POST("/scan/start", sc.Start)
apiGroup.GET("/scan/status", sc.Status)
fc := NewFsController()
apiGroup.GET("/fs/list", fc.List)
}
return r
}
+11 -1
View File
@@ -18,9 +18,19 @@ func NewScanController(svc *service.ScanService) *ScanController {
return &ScanController{service: svc}
}
// scanStartReq 扫描启动请求;dir 为空时使用服务当前运行目录
type scanStartReq struct {
Dir string `json:"dir"`
}
// Start POST /api/scan/start 触发一次扫描整理
func (c *ScanController) Start(ctx *gin.Context) {
if err := c.service.Start(); err != nil {
var req scanStartReq
if err := ctx.ShouldBindJSON(&req); err != nil {
Error(ctx, http.StatusBadRequest, "请求参数错误:"+err.Error())
return
}
if err := c.service.Start(req.Dir); err != nil {
Error(ctx, http.StatusBadRequest, err.Error())
return
}
+32 -10
View File
@@ -34,6 +34,7 @@ type ScanStatus struct {
Message string `json:"message"` // 简要说明
Started string `json:"started"` // 开始时间
Elapsed int64 `json:"elapsed"` // 已运行秒数
RootDir string `json:"rootDir"` // 本次扫描的根目录
Lines []string `json:"lines"` // 运行日志
}
@@ -44,6 +45,7 @@ type ScanService struct {
state ScanState
msg string
started time.Time
rootDir string
lines []string
}
@@ -52,20 +54,26 @@ func NewScanService(st *store.Store) *ScanService {
return &ScanService{store: st, state: ScanIdle}
}
// Start 启动一次扫描整理;已在运行时返回错误
func (s *ScanService) Start() error {
// Start 启动一次扫描整理;已在运行时返回错误。rootDir 为空时默认扫描当前运行目录。
func (s *ScanService) Start(rootDir string) error {
s.mu.Lock()
if s.state == ScanRunning {
s.mu.Unlock()
return errors.New("扫描整理正在运行中,请等待完成")
}
rootDir = resolveRootDir(rootDir)
if st, err := os.Stat(rootDir); err != nil || !st.IsDir() {
s.mu.Unlock()
return fmt.Errorf("扫描目录不存在或不可访问: %s", rootDir)
}
s.state = ScanRunning
s.msg = ""
s.started = time.Now()
s.rootDir = rootDir
s.lines = nil
s.mu.Unlock()
go s.run()
go s.run(rootDir)
return nil
}
@@ -82,12 +90,29 @@ func (s *ScanService) Status() ScanStatus {
Message: s.msg,
Started: s.started.Format("2006-01-02 15:04:05"),
Elapsed: elapsed,
RootDir: s.rootDir,
Lines: append([]string{}, s.lines...),
}
}
// resolveRootDir 解析扫描根目录:空值回退当前工作目录,并做绝对路径归一化
func resolveRootDir(dir string) string {
if strings.TrimSpace(dir) == "" {
wd, err := os.Getwd()
if err == nil {
return wd
}
return "."
}
abs, err := filepath.Abs(strings.TrimSpace(dir))
if err != nil {
return strings.TrimSpace(dir)
}
return abs
}
// run 后台执行:捕获 stdout 日志流式写入 lines,结束后更新状态
func (s *ScanService) run() {
func (s *ScanService) run(rootDir string) {
r, w, err := os.Pipe()
if err != nil {
s.finish(ScanError, "创建日志管道失败: "+err.Error())
@@ -112,7 +137,7 @@ func (s *ScanService) run() {
}
}()
scanErr := s.runScan()
scanErr := s.runScan(rootDir)
os.Stdout = oldStdout
_ = w.Close()
@@ -144,12 +169,9 @@ func sanitizeLine(line string) string {
}
// runScan 执行完整整理流程:扫描 → 过滤 → 释放解码器 → 归类 → 移动
func (s *ScanService) runScan() error {
rootDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("获取当前路径异常: %v", err)
}
func (s *ScanService) runScan(rootDir string) error {
pickerDir := filepath.Join(rootDir, filepath.Base(rootDir)+"-picker")
fmt.Printf("=== 扫描根目录: %s \n", rootDir)
fmt.Printf("=== 整理输出目录(白名单): %s \n", pickerDir)
res, err := scanner.Scan(scanner.Options{
+3 -1
View File
@@ -285,7 +285,9 @@ func (s *Store) Stats() (*MediaStats, error) {
if s == nil || s.engine == nil {
return &MediaStats{}, nil
}
st := &MediaStats{}
st := &MediaStats{
ResCount: []KV{}, // 避免空库时返回 null,前端 .slice 直接可用
}
if total, err := s.engine.Count(new(MediaInfo)); err == nil {
st.TotalFiles = total