diff --git a/application.properties b/application.properties
index 74f2d74..712e05c 100644
--- a/application.properties
+++ b/application.properties
@@ -12,8 +12,6 @@ ffmpeg.path=
organize.rotateImages=false
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
organize.workers=8
-# 扫描完成后是否移动文件到分类目录(true=移动,默认;false=只归类不移动,文件留在原位)
-organize.moveFiles=false
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet)
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
# 模型:优先加载程序运行目录下 lib/nwfs/320n.onnx(推荐随程序分发,离线可用);
diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue
index 5890081..dd8c465 100644
--- a/frontend/src/views/Scan.vue
+++ b/frontend/src/views/Scan.vue
@@ -60,7 +60,12 @@
- 扫描整理中,已运行 {{ status.elapsed }} 秒...({{ stageText }} {{ progressText }})
+
+ 移动文件中,已运行 {{ status.elapsed }} 秒...({{ progressText }})
+
+
+ 扫描整理中,已运行 {{ status.elapsed }} 秒...({{ stageText }} {{ progressText }})
+
预计剩余 {{ etaText }}
+
+ 移动文件到分类目录
+ 根据扫描归类结果,将文件移动到「存放目录」下的分类子目录
+
+
0 {
@@ -218,12 +210,8 @@ func (o *Organizer) addToDir(f scanner.File, dir string) {
// addClassified 归档一个文件并记进续传表:把"源路径 → 目标目录"写进 scan_log,
// 这样下次重扫时这个文件直接复用目标目录,不再重新探测/识别。
-// 注意:moveFiles=false 时不写续传记录,因为文件没有真正移动。
func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, headMd5, tailMd5 string) {
o.add(f, subDir)
- if !o.moveFiles {
- return // 不移动文件时不写续传记录
- }
if o.store == nil {
return
}
@@ -241,11 +229,7 @@ func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, hea
// matchResume 判断一个文件上次是否已归类过,且内容没变。
// 命中返回上次的目标目录和 ok=true;内容变了/没记录则 ok=false(要重新探测)。
-// 注意:moveFiles=false 时禁用续传跳过,每次都完整探测。
func (o *Organizer) matchResume(f scanner.File) (store.ScanLog, bool) {
- if !o.moveFiles {
- return store.ScanLog{}, false // 不移动文件时禁用续传
- }
if o.resume == nil {
return store.ScanLog{}, false
}
diff --git a/internal/service/scan.go b/internal/service/scan.go
index acb7e1d..6618dcd 100644
--- a/internal/service/scan.go
+++ b/internal/service/scan.go
@@ -24,6 +24,7 @@ import (
"time"
"kenaito-media-picker/internal/config"
+ "kenaito-media-picker/internal/mover"
"kenaito-media-picker/internal/nsfw"
"kenaito-media-picker/internal/organize"
"kenaito-media-picker/internal/probe"
@@ -234,6 +235,135 @@ func (s *ScanService) Cancel() error {
return nil
}
+// Move 触发文件移动操作。
+// 根据 scan_log 中记录的分类结果,将文件从源路径移动到目标目录。
+// 只有在扫描完成(state == done)且当前没有任务运行时才能调用。
+func (s *ScanService) Move() error {
+ s.mu.Lock()
+
+ // 正在跑就不能再开一个,直接拒绝
+ if s.state == ScanRunning {
+ s.mu.Unlock()
+ return errors.New("扫描整理正在运行中,请等待完成")
+ }
+
+ // 必须先完成一次扫描,才有 scan_log 记录
+ if s.state != ScanDone {
+ s.mu.Unlock()
+ return errors.New("请先完成扫描后再执行移动")
+ }
+
+ // 把状态改成"运行中",清空上一次的残留信息
+ s.state = ScanRunning
+ s.msg = ""
+ s.started = time.Now()
+ s.lines = nil
+ s.stage = stageMove
+ s.done = 0
+ s.total = 0
+ s.ctx, s.cancel = context.WithCancel(context.Background())
+ s.mu.Unlock()
+
+ go s.runMove(s.ctx)
+ return nil
+}
+
+// runMove 是移动文件的后台任务。
+// 它从数据库读取 scan_log 记录,构建移动任务列表,然后并行执行文件移动。
+func (s *ScanService) runMove(ctx context.Context) {
+ // 创建日志管道(和 run() 一样,截取 fmt.Println 输出)
+ r, w, err := os.Pipe()
+ if err != nil {
+ s.finish(ScanError, "创建日志管道失败: "+err.Error())
+ return
+ }
+ oldStdout := os.Stdout
+ os.Stdout = w
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 1024*1024), 1024*1024)
+ for sc.Scan() {
+ line := sanitizeLine(sc.Text())
+ if line == "" {
+ continue
+ }
+ s.mu.Lock()
+ if len(s.lines) >= maxLogLines {
+ s.lines = append([]string{}, s.lines[len(s.lines)/2:]...)
+ }
+ s.lines = append(s.lines, line)
+ s.mu.Unlock()
+ }
+ }()
+
+ defer func() {
+ os.Stdout = oldStdout
+ _ = w.Close()
+ <-done
+ if rcv := recover(); rcv != nil {
+ s.finish(ScanError, fmt.Sprintf("移动文件内部错误: %v\n%s", rcv, debug.Stack()))
+ }
+ }()
+
+ // 从数据库读取 scan_log 记录
+ if s.store == nil {
+ s.finish(ScanError, "数据库不可用,无法执行移动")
+ return
+ }
+
+ logs, err := s.store.LoadScanLog()
+ if err != nil {
+ s.finish(ScanError, "加载续传记录失败: "+err.Error())
+ return
+ }
+
+ if len(logs) == 0 {
+ fmt.Println("=== 没有待移动的文件(scan_log 为空)")
+ s.finish(ScanDone, "没有待移动的文件")
+ return
+ }
+
+ // 构建移动任务列表
+ jobs := make([]mover.MoveJob, 0, len(logs))
+ for _, sl := range logs {
+ // 检查源文件是否还存在
+ if _, err := os.Stat(sl.SourcePath); err != nil {
+ fmt.Printf("=== 源文件不存在,跳过: %s\n", sl.SourcePath)
+ continue
+ }
+ jobs = append(jobs, mover.MoveJob{Source: sl.SourcePath, TargetDir: sl.TargetDir})
+ }
+
+ if len(jobs) == 0 {
+ fmt.Println("=== 所有源文件均已不存在,无需移动")
+ s.finish(ScanDone, "没有需要移动的文件(源文件已不存在)")
+ return
+ }
+
+ fmt.Printf("=== 开始移动文件,共 %d 个任务\n", len(jobs))
+ s.setProgress(stageMove, 0, len(jobs))
+
+ // 执行移动
+ mv := &mover.Mover{}
+ mv.MoveAll(jobs, func(doneCount int) {
+ s.setProgress(stageMove, doneCount, len(jobs))
+ if ctx.Err() != nil {
+ return
+ }
+ })
+
+ if ctx.Err() != nil {
+ s.finish(ScanCancelled, "移动已中止")
+ return
+ }
+
+ fmt.Println("=== 文件移动完成")
+ s.finish(ScanDone, fmt.Sprintf("文件移动完成,共移动 %d 个文件", len(jobs)))
+}
+
// Status 生成一份"当前进度快照"返回给前端。
func (s *ScanService) Status() ScanStatus {
s.mu.Lock()
@@ -530,7 +660,6 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
org.SetNSFWDetector(nsfwDet)
org.SetRotateImages(config.GetAppConfig().RotateImages)
- org.SetMoveFiles(config.GetAppConfig().MoveFiles)
// 整个"整理"分两个阶段,界面上共用一条进度条:
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
@@ -566,17 +695,12 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
return ctx.Err()
}
- // 移动:把文件搬进各自的分类目录,并记录去向
- if config.GetAppConfig().MoveFiles {
- org.Flush()
- } else {
- // 不移动文件,但仍需写入媒体记录到数据库
- org.FlushRecordsOnly()
- fmt.Println("=== 配置 organize.moveFiles=false,跳过文件移动(媒体记录已保存)")
- }
+ // 扫描完成后只写入媒体记录和续传记录,不移动文件。
+ // 文件移动由用户在页面上手动触发(POST /api/scan/move)。
+ org.FlushRecordsOnly()
// 把进度条顶满 100%,再打印"整理完成"
s.setProgress(stageMove, totalWork, totalWork)
- fmt.Println("=== 整理完成")
+ fmt.Println("=== 扫描整理完成,请在页面点击「移动文件」按钮执行文件移动")
return nil
}