refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录

- 重构后端与前端,全程大白话注释(不懂程序也能看懂)
- 移动阶段改为全局并行(MoveAll,工人数=CPU核心数)
- 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿)
- 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬
- 媒体库去掉归类目录列,看板去掉重复/归类分布统计
This commit is contained in:
2025-05-11 21:05:35 +08:00
parent f915a9b1ac
commit 8979c744a8
26 changed files with 1044 additions and 683 deletions
+108 -51
View File
@@ -160,53 +160,74 @@
<script>
import request from '../utils/request'
/**
* 扫描整理页面。
*
* 页面干四件事:
* 1. 开始扫描:填好扫描目录和存放目录,点按钮发给后端
* 2. 实时看进度:通过 WebSocket 专线接收后端主动推来的进度(实时),
* 页面刷新/断线后会自动重连;万一 WebSocket 不通,就退回"每 3 秒问一次"的低频轮询,
* 保证进度永远不会卡住不动
* 3. 看运行日志:后端干活时打印的日志会推到这里逐行显示
* 4. 选目录:弹窗一层层翻服务器上的文件夹,挑一个目录
*/
export default {
name: 'Scan',
data() {
return {
starting: false,
scanDir: localStorage.getItem('scan-dir') || '',
destDir: localStorage.getItem('scan-dest-dir') || '',
// ---------- 开始扫描 ----------
starting: false, // 点完按钮后、等后端回话前的"转圈"
scanDir: localStorage.getItem('scan-dir') || '', // 要扫描的目录(记住上次填的)
destDir: localStorage.getItem('scan-dest-dir') || '', // 整理后文件放哪(记住上次填的)
// ---------- 扫描进度与状态 ----------
status: {
state: 'idle',
message: '',
started: '',
elapsed: 0,
rootDir: '',
destDir: '',
lines: [],
initialized: false,
stage: '',
progress: 0,
done: 0,
total: 0
state: 'idle', // 任务状态:idle 空闲 / running 运行中 / done 完成 / error 出错
message: '', // 一句话说明(出错原因等)
started: '', // 开始时间
elapsed: 0, // 已运行秒数
rootDir: '', // 扫描根目录
destDir: '', // 存放目录
lines: [], // 运行日志
initialized: false, // 是否已经拿到过第一份状态(用于回填存放目录)
stage: '', // 当前阶段:scan 扫描 / organize 归类 / move 移动
progress: 0, // 总进度百分比
done: 0, // 已完成数
total: 0 // 总数
},
ws: null,
wsAlive: false,
usingPolling: false,
wsClosed: false,
wsReconnectTimer: null,
wsReconnectDelay: 1000,
pollTimer: null,
dirPickerVisible: false,
pickerTarget: 'scan',
dirLoading: false,
// ---------- 实时推送(WebSocket)与兜底轮询 ----------
ws: null, // 当前 WebSocket 连接
wsAlive: false, // 专线是不是通着
usingPolling: false, // 是不是退回了轮询模式(浏览器不支持 WebSocket 时)
wsClosed: false, // 页面准备关闭,不再重连(销毁时置 true)
wsReconnectTimer: null, // 重连的倒计时
wsReconnectDelay: 1000, // 重连间隔(失败会翻倍,最多 5 秒)
pollTimer: null, // 兜底轮询的计时器
// ---------- 目录选择弹窗 ----------
dirPickerVisible: false, // 弹窗是否显示
pickerTarget: 'scan', // 当前选的是扫描目录还是存放目录
dirLoading: false, // 弹窗里正在翻目录
fsData: {
current: '',
parent: '',
drives: [],
dirs: []
current: '', // 当前所在目录
parent: '', // 上一级
drives: [], // Windows 盘符
dirs: [] // 当前目录下的子目录
}
}
},
mounted() {
// 进页面先问一次当前状态(可能上次扫描到一半),然后连上实时专线
this.fetchStatus()
this.connectWs()
},
destroyed() {
// 页面关了:断开专线、停掉计时器,免得后台还在偷偷干活
this.closeWs()
},
computed: {
// 当前阶段的文字(给用户看的)
stageText() {
return {
scan: '扫描文件',
@@ -214,20 +235,21 @@ export default {
move: '移动文件'
}[this.status.stage] || ''
},
// 总数未知(遍历目录树阶段)时只显示已发现数,避免卡在 0/0
// 进度文字:总数已知就显示 "已完成/总数";
// 总数还不知道(后端还在翻目录)就只显示"已发现 N 个文件",避免卡在 0/0
progressText() {
const {total, done} = this.status
if (total > 0) return done + '/' + total
if (done > 0) return '已发现 ' + done + ' 个文件'
return '准备中…'
},
// 后端按当前阶段速率估算的剩余时间;0 表示无法估算
// 剩余时间(后端按当前速度估的;0 表示还估不出来就不显示)
etaText() {
const eta = this.status.eta
if (!eta || eta <= 0) return ''
return this.fmtDuration(Math.round(eta))
},
// 实时推送连接状态文案
// 实时专线的连接状态文案
connText() {
if (this.wsAlive) return {icon: 'el-icon-link', text: '实时推送已连接'}
if (this.usingPolling) return {icon: 'el-icon-refresh', text: '轮询模式'}
@@ -235,37 +257,48 @@ export default {
}
},
methods: {
// 统一处理状态更新(HTTP 响应与 WebSocket 消息共用)
// ==================== 状态更新 ====================
// 统一处理一份状态快照(WebSocket 推来的、HTTP 问来的都用这个函数)。
// 它负责:存下状态、回填上次的存放目录、日志有新增就滚到底、任务结束弹个提示。
applyStatus(data) {
if (!data) return
const wasRunning = this.status.state === 'running'
const isRunning = data.state === 'running'
const wasRunning = this.status.state === 'running' // 之前是不是在跑
const isRunning = data.state === 'running' // 现在是不是在跑
const prevLineCount = (this.status.lines || []).length
// 第一次拿到状态时,把后端记的"上次存放目录"回填进来
if (!this.status.initialized && data.destDir) {
this.destDir = data.destDir
}
this.status = data
this.status.initialized = true
// 日志有新行就滚到最下面(否则来回乱滚)
if (isRunning && data.lines && data.lines.length !== prevLineCount) {
this.scrollBottom()
}
// 从"运行中"变到"结束"(完成或出错)时,弹个提示
if (wasRunning && !isRunning) {
this.$message.success(data.message || '扫描完成')
}
},
// 用 HTTP 问一次当前状态(连上专线后、以及兜底轮询时用)
async fetchStatus() {
try {
const res = await request.get('/api/scan/status')
this.applyStatus(res.data)
} catch (e) {
// 网络异常:看门狗/轮询会继续重试,WS 模式忽略(等待 WS 重连)
// 网络暂时不通:轮询会继续重试,WebSocket 模式则等专线自动重连
}
},
// WebSocket 连接:页面刷新后自动重连,失败指数退避重试(上限 5 秒)。
// 同时维持一个低频 HTTP 轮询作为看门狗:WS 正常时保证兜底刷新,
// WS 不可用/断开时也能低频更新,界面永不冻结。
// ==================== 实时推送(WebSocket) ====================
// 建立专线。页面刷新后会自动重新连上;连失败会退避重试(1 秒 → 2 秒 → 4 秒 → 封顶 5 秒)。
// 同时开一个低频轮询当"看门狗":就算专线悄悄断了,也能兜底刷新,界面永远不会卡住。
connectWs() {
if (this.ws || this.wsClosed) return
// 浏览器太老不支持 WebSocket:直接退回 1 秒轮询
if (!window.WebSocket) {
this.usingPolling = true
this.wsAlive = false
@@ -276,6 +309,7 @@ export default {
const ws = new WebSocket(proto + location.host + '/api/scan/ws')
this.ws = ws
ws.onopen = () => {
// 连上了:重连间隔归零,先问一次当前状态,再开低频轮询兜底
this.wsAlive = true
this.usingPolling = false
this.wsReconnectDelay = 1000
@@ -283,25 +317,31 @@ export default {
this.startPoll(3000)
}
ws.onmessage = (e) => {
// 后端推来一份状态:解析后更新页面
try {
this.applyStatus(JSON.parse(e.data))
} catch (err) {
// 忽略无法解析的消息
// 内容坏了就忽略,等下一份
}
}
ws.onerror = () => {
// 出错就关掉连接,onclose 里会自动重连
ws.close()
}
ws.onclose = () => {
// 断了:标记掉线;页面要关的话就不再连了
this.ws = null
this.wsAlive = false
if (this.wsClosed) return
// 隔一小会儿重连(间隔逐次翻倍,封顶 5 秒)
this.wsReconnectTimer = setTimeout(() => {
this.wsReconnectDelay = Math.min(this.wsReconnectDelay * 2, 5000)
this.connectWs()
}, this.wsReconnectDelay)
}
},
// 页面销毁时调用:断开专线、停掉所有计时器
closeWs() {
this.wsClosed = true
this.wsAlive = false
@@ -315,7 +355,9 @@ export default {
}
this.stopPoll()
},
// 周期轮询:WS 正常时每 3 秒兜底,无 WS 环境时每 1 秒
// 开一个周期轮询:专线正常时每 3 秒兜底刷新一次;
// 没有 WebSocket 的浏览器退回 1 秒一次
startPoll(interval) {
this.stopPoll()
this.pollTimer = setInterval(() => this.fetchStatus(), interval || 3000)
@@ -326,37 +368,37 @@ export default {
this.pollTimer = null
}
},
// 秒 → "X 分 Y 秒" / "X 小时 Y 分"
fmtDuration(sec) {
if (!sec) return ''
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return h + ' 小时 ' + m + ' 分'
if (m > 0) return m + ' 分 ' + s + ' 秒'
return s + ' 秒'
},
// ==================== 开始扫描 ====================
async handleStart() {
this.starting = true
try {
// 记住这次填的目录,下次打开页面自动填上
localStorage.setItem('scan-dir', this.scanDir)
localStorage.setItem('scan-dest-dir', this.destDir)
const res = await request.post('/api/scan/start', {
dir: this.scanDir.trim(),
destDir: this.destDir.trim()
})
// 后端立刻回了当前状态(已进入运行中),直接展示
this.applyStatus(res.data)
this.$message.success('已开始扫描整理')
} finally {
this.starting = false
}
},
// ==================== 目录选择弹窗 ====================
// 打开弹窗;target 决定这次选的是"扫描目录"还是"存放目录"
openDirPicker(target) {
this.pickerTarget = target || 'scan'
this.dirPickerVisible = true
const initial = target === 'dest' ? this.destDir.trim() : this.scanDir.trim()
this.loadFs(initial)
},
// 向后端要某个目录下的子目录列表
async loadFs(dir) {
this.dirLoading = true
try {
@@ -368,6 +410,7 @@ export default {
this.dirLoading = false
}
},
// 点"选中此目录":把选中的目录填到对应的输入框里
confirmDir() {
if (!this.fsData.current) return
if (this.pickerTarget === 'dest') {
@@ -379,6 +422,20 @@ export default {
}
this.dirPickerVisible = false
},
// ==================== 小工具 ====================
// 秒 → "X 小时 Y 分" / "X 分 Y 秒" / "X 秒"
fmtDuration(sec) {
if (!sec) return ''
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return h + ' 小时 ' + m + ' 分'
if (m > 0) return m + ' 分 ' + s + ' 秒'
return s + ' 秒'
},
// 让日志框滚到最下面(等页面渲染完再滚)
scrollBottom() {
this.$nextTick(() => {
const box = this.$refs.logBox