refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录
- 重构后端与前端,全程大白话注释(不懂程序也能看懂) - 移动阶段改为全局并行(MoveAll,工人数=CPU核心数) - 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿) - 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬 - 媒体库去掉归类目录列,看板去掉重复/归类分布统计
This commit is contained in:
@@ -31,16 +31,6 @@
|
||||
<div class="stat-value">{{ fmtSize(stats.minSize) }}</div>
|
||||
<div class="stat-sub">体积最小的文件</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">重复文件</div>
|
||||
<div class="stat-value warning">{{ stats.duplicate }}</div>
|
||||
<div class="stat-sub">额外副本数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">重复占用</div>
|
||||
<div class="stat-value warning">{{ fmtSize(stats.dupSize) }}</div>
|
||||
<div class="stat-sub">可释放空间(约)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总时长</div>
|
||||
<div class="stat-value">{{ fmtDuration(stats.durationTotal) }}</div>
|
||||
@@ -127,16 +117,6 @@
|
||||
<div v-if="!stats.createdCounts || stats.createdCounts.length === 0" class="empty-tip">暂无入库记录</div>
|
||||
</div>
|
||||
|
||||
<div class="panel top-panel">
|
||||
<div class="panel-title">归类目录分布</div>
|
||||
<div v-for="item in (stats.categoryCounts || [])" :key="item.key" class="dist-row">
|
||||
<span class="dist-label">{{ item.key }}</span>
|
||||
<el-progress :percentage="percent(item.count, categoryTotal)" :stroke-width="14" color="#fa8c16"/>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div v-if="!stats.categoryCounts || stats.categoryCounts.length === 0" class="empty-tip">暂无归类记录(扫描整理后显示)</div>
|
||||
</div>
|
||||
|
||||
<div class="panel top-panel">
|
||||
<div class="panel-title">扩展名分布</div>
|
||||
<div v-for="item in stats.extCounts" :key="item.key" class="dist-row">
|
||||
@@ -197,8 +177,6 @@ export default {
|
||||
resCount: [],
|
||||
codecCount: [],
|
||||
formatCount: [],
|
||||
duplicate: 0,
|
||||
dupSize: 0,
|
||||
topLarge: [],
|
||||
durationTotal: 0,
|
||||
durationAvg: 0,
|
||||
@@ -207,7 +185,6 @@ export default {
|
||||
minSize: 0,
|
||||
durationBins: [],
|
||||
createdCounts: [],
|
||||
categoryCounts: [],
|
||||
extCounts: [],
|
||||
mimeCounts: [],
|
||||
audioCodecCount: []
|
||||
@@ -233,9 +210,6 @@ export default {
|
||||
createdTotal() {
|
||||
return this.sumCount(this.stats.createdCounts)
|
||||
},
|
||||
categoryTotal() {
|
||||
return this.sumCount(this.stats.categoryCounts)
|
||||
},
|
||||
extTotal() {
|
||||
return this.sumCount(this.stats.extCounts)
|
||||
},
|
||||
|
||||
@@ -81,12 +81,6 @@
|
||||
<el-table-column prop="fileSize" label="大小" width="100" align="right" sortable="custom">
|
||||
<template slot-scope="scope">{{ scope.row.sizeText }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归类目录" min-width="180" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.destDir">-</span>
|
||||
<span v-else>{{ scope.row.destDir }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="入库时间" width="200" align="center" sortable="custom">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.createdAt">-</span>
|
||||
|
||||
+108
-51
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user