feat: 扫描整理并行化、WebSocket 实时进度与统计看板

- 移动阶段并行处理文件,按原子计数上报累计进度
- 扫描遍历阶段实时上报已发现文件数,界面不再卡在 0/0
- 新增 /api/scan/ws WebSocket 实时推送扫描状态;前端断线指数退避自动重连,并以低频轮询兜底
- 新增统计看板与媒体库筛选/排序,目录选择器路径不存在时回退到最近可用父目录
- 存放目录支持自定义并持久化,默认输出到 KMP已整理
This commit is contained in:
2025-05-08 21:16:20 +08:00
parent 36d7ecced8
commit f915a9b1ac
22 changed files with 1342 additions and 281 deletions
+256 -44
View File
@@ -2,12 +2,25 @@
<div class="page-card">
<div class="toolbar">
<span class="title">扫描整理</span>
<span class="conn-hint" :class="{off: !wsAlive && !usingPolling}">
<i :class="connText.icon"></i>{{ connText.text }}
</span>
</div>
<div class="scan-tip">
<i class="el-icon-warning-outline"></i>
点击开始后,程序将扫描所选目录并自动归类媒体文件(<b>会移动文件</b>,整理前请先备份)。
整理期间请保持服务运行,结束后可在「媒体库」查看结果。
<div class="scan-tip-body">
<p><b>会移动文件,整理前请先备份!</b></p>
<ul>
<li>程序将<b>递归扫描</b>扫描目录下的全部文件,图片/视频/音频会被探测并按规则归类移动</li>
<li>归类后的文件<b>移动</b>到「存放目录」下的分类子目录(未填写时默认在扫描目录下创建 <b>KMP已整理</b> 文件夹)</li>
<li>跨磁盘(移动失败时)自动降级为<b>复制后删除</b>,原文件仍会从源目录移除</li>
<li>与库中已有记录 <b>MD5 相同的重复文件</b>会被移动到「重复文件」目录</li>
<li>无法读取/不支持的格式会被移动到「读取异常」「已忽略」目录</li>
<li>扫描会自动跳过 <b>KMP已整理</b> 输出目录、<b>media-info.db</b> 数据库与 ffprobe 临时文件</li>
<li>整理期间请保持服务运行,结束后可在「媒体库」查看结果</li>
</ul>
</div>
</div>
<div class="scan-form">
@@ -19,7 +32,18 @@
clearable
size="medium"
@keyup.enter.native="handleStart">
<el-button slot="append" icon="el-icon-folder-opened" @click="openDirPicker">选择目录</el-button>
<el-button slot="append" icon="el-icon-folder-opened" @click="openDirPicker('scan')">选择目录</el-button>
</el-input>
</div>
<div class="form-row" style="margin-top: 10px">
<span class="form-label">存放目录</span>
<el-input
v-model="destDir"
placeholder="留空则默认存放到扫描目录下的「KMP已整理」文件夹"
clearable
size="medium"
@keyup.enter.native="handleStart">
<el-button slot="append" icon="el-icon-folder-opened" @click="openDirPicker('dest')">选择目录</el-button>
</el-input>
</div>
</div>
@@ -33,8 +57,16 @@
:loading="starting"
@click="handleStart">开始扫描</el-button>
<div v-else class="running-hint">
<i class="el-icon-loading"></i>
<span>扫描整理中,已运行 {{ status.elapsed }} 秒...</span>
<span>
<i class="el-icon-loading"></i>
扫描整理中,已运行 {{ status.elapsed }} 秒...({{ stageText }} {{ progressText }})
<span v-if="etaText" class="eta-text">预计剩余 {{ etaText }}</span>
</span>
<el-progress
class="scan-progress"
:percentage="status.progress"
:stroke-width="14"
:status="status.progress >= 100 ? 'success' : ''"/>
</div>
</div>
@@ -56,7 +88,7 @@
class="state-alert"/>
<el-dialog
title="选择要整理的目录"
title="选择目录"
:visible.sync="dirPickerVisible"
width="520px"
append-to-body>
@@ -115,8 +147,10 @@
<span class="muted" v-if="status.rootDir">扫描目录:{{ status.rootDir }}</span>
<span class="muted" v-if="status.started">开始于 {{ status.started }}</span>
</div>
<div ref="logBox" class="log-body" v-loading="status.state === 'running'">
<div v-if="status.lines.length === 0" class="muted empty-log">暂无日志,点击「开始扫描」后在此查看</div>
<div ref="logBox" class="log-body">
<div v-if="status.lines.length === 0" class="muted empty-log">
{{ status.state === 'running' ? '扫描进行中,等待日志输出…' : '暂无日志,点击「开始扫描」后在此查看' }}
</div>
<div v-for="(line, idx) in status.lines" :key="idx" class="log-line">{{ line }}</div>
</div>
</div>
@@ -132,16 +166,30 @@ export default {
return {
starting: false,
scanDir: localStorage.getItem('scan-dir') || '',
destDir: localStorage.getItem('scan-dest-dir') || '',
status: {
state: 'idle',
message: '',
started: '',
elapsed: 0,
rootDir: '',
lines: []
destDir: '',
lines: [],
initialized: false,
stage: '',
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,
fsData: {
current: '',
@@ -152,44 +200,162 @@ export default {
}
},
mounted() {
this.loadStatus()
this.fetchStatus()
this.connectWs()
},
destroyed() {
this.stopPoll()
this.closeWs()
},
computed: {
stageText() {
return {
scan: '扫描文件',
organize: '整理归类',
move: '移动文件'
}[this.status.stage] || ''
},
// 总数未知(遍历目录树阶段)时只显示已发现数,避免卡在 0/0
progressText() {
const {total, done} = this.status
if (total > 0) return done + '/' + total
if (done > 0) return '已发现 ' + done + ' 个文件'
return '准备中…'
},
// 后端按当前阶段速率估算的剩余时间;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: '轮询模式'}
return {icon: 'el-icon-loading', text: '实时连接中断,自动重连中…'}
}
},
methods: {
async loadStatus() {
// 统一处理状态更新(HTTP 响应与 WebSocket 消息共用)
applyStatus(data) {
if (!data) return
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 || '扫描完成')
}
},
async fetchStatus() {
try {
const res = await request.get('/api/scan/status')
const data = res.data || {}
const wasRunning = this.status.state === 'running'
const isRunning = data.state === 'running'
this.status = data
if (isRunning && !wasRunning) this.startPoll()
if (wasRunning && !isRunning) {
this.stopPoll()
this.$message.success(data.message || '扫描完成')
}
if (isRunning) this.scrollBottom()
this.applyStatus(res.data)
} catch (e) {
// 拦截器已提示
// 网络异常:看门狗/轮询会继续重试,WS 模式忽略(等待 WS 重连)
}
},
// WebSocket 连接:页面刷新后自动重连,失败指数退避重试(上限 5 秒)。
// 同时维持一个低频 HTTP 轮询作为看门狗:WS 正常时保证兜底刷新,
// WS 不可用/断开时也能低频更新,界面永不冻结。
connectWs() {
if (this.ws || this.wsClosed) return
if (!window.WebSocket) {
this.usingPolling = true
this.wsAlive = false
this.startPoll(1000)
return
}
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
const ws = new WebSocket(proto + location.host + '/api/scan/ws')
this.ws = ws
ws.onopen = () => {
this.wsAlive = true
this.usingPolling = false
this.wsReconnectDelay = 1000
this.fetchStatus()
this.startPoll(3000)
}
ws.onmessage = (e) => {
try {
this.applyStatus(JSON.parse(e.data))
} catch (err) {
// 忽略无法解析的消息
}
}
ws.onerror = () => {
ws.close()
}
ws.onclose = () => {
this.ws = null
this.wsAlive = false
if (this.wsClosed) return
this.wsReconnectTimer = setTimeout(() => {
this.wsReconnectDelay = Math.min(this.wsReconnectDelay * 2, 5000)
this.connectWs()
}, this.wsReconnectDelay)
}
},
closeWs() {
this.wsClosed = true
this.wsAlive = false
if (this.ws) {
this.ws.close()
this.ws = null
}
if (this.wsReconnectTimer) {
clearTimeout(this.wsReconnectTimer)
this.wsReconnectTimer = null
}
this.stopPoll()
},
// 周期轮询:WS 正常时每 3 秒兜底,无 WS 环境时每 1 秒
startPoll(interval) {
this.stopPoll()
this.pollTimer = setInterval(() => this.fetchStatus(), interval || 3000)
},
stopPoll() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
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)
const res = await request.post('/api/scan/start', { dir: this.scanDir.trim() })
this.status = res.data || this.status
this.startPoll()
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
}
},
openDirPicker() {
openDirPicker(target) {
this.pickerTarget = target || 'scan'
this.dirPickerVisible = true
this.loadFs(this.scanDir.trim())
const initial = target === 'dest' ? this.destDir.trim() : this.scanDir.trim()
this.loadFs(initial)
},
async loadFs(dir) {
this.dirLoading = true
@@ -204,19 +370,14 @@ export default {
},
confirmDir() {
if (!this.fsData.current) return
this.scanDir = this.fsData.current
localStorage.setItem('scan-dir', this.scanDir)
this.dirPickerVisible = false
},
startPoll() {
this.stopPoll()
this.pollTimer = setInterval(() => this.loadStatus(), 1000)
},
stopPoll() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
if (this.pickerTarget === 'dest') {
this.destDir = this.fsData.current
localStorage.setItem('scan-dest-dir', this.destDir)
} else {
this.scanDir = this.fsData.current
localStorage.setItem('scan-dir', this.scanDir)
}
this.dirPickerVisible = false
},
scrollBottom() {
this.$nextTick(() => {
@@ -229,7 +390,25 @@ export default {
</script>
<style scoped>
.conn-hint {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #67c23a;
}
.conn-hint.off {
color: #e6a23c;
}
.conn-hint i {
font-size: 13px;
}
.scan-tip {
display: flex;
gap: 10px;
background: #f8fbff;
border: 1px solid #e6efff;
border-radius: 8px;
@@ -242,7 +421,25 @@ export default {
.scan-tip .el-icon-warning-outline {
color: #faad14;
margin-right: 4px;
margin-top: 3px;
flex-shrink: 0;
}
.scan-tip-body {
flex: 1;
}
.scan-tip-body p {
margin: 0 0 6px;
}
.scan-tip-body ul {
margin: 0;
padding-left: 18px;
}
.scan-tip-body li {
margin-bottom: 2px;
}
.scan-form {
@@ -321,13 +518,28 @@ export default {
}
.running-hint {
display: inline-flex;
align-items: center;
gap: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
max-width: 640px;
}
.running-hint > span {
color: #1677ff;
font-size: 14px;
}
.scan-progress {
width: 100%;
}
.eta-text {
color: #909399;
font-size: 12px;
margin-left: 6px;
}
.state-alert {
margin-bottom: 16px;
}