媒体库
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index cc8a1ca..bdff5c0 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -8,7 +8,7 @@ Vue.use(Router)
export default new Router({
routes: [
- {path: '/', redirect: '/dashboard'},
+ {path: '/', redirect: '/scan'},
{path: '/dashboard', component: Dashboard},
{path: '/media', component: MediaList},
{path: '/scan', component: Scan}
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index 1107d76..d4fbded 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -16,6 +16,21 @@
{{ fmtSize(stats.totalSize) }}
所有文件占用
+
+
平均文件
+
{{ fmtSize(stats.avgSize) }}
+
单文件平均体积
+
+
+
最大文件
+
{{ fmtSize(stats.maxSize) }}
+
体积最大的文件
+
+
+
最小文件
+
{{ fmtSize(stats.minSize) }}
+
体积最小的文件
+
重复文件
{{ stats.duplicate }}
@@ -26,6 +41,11 @@
{{ fmtSize(stats.dupSize) }}
可释放空间(约)
+
+
总时长
+
{{ fmtDuration(stats.durationTotal) }}
+
平均 {{ fmtDuration(stats.durationAvg) }}
+
@@ -86,6 +106,66 @@
暂无格式数据
+
+
+
时长分布(视频/音频)
+
+ {{ item.key }}
+
+ {{ item.count }}
+
+
当前媒体库暂无时长数据,扫描含视频/音频的目录后显示
+
+
+
+
入库趋势(近30天)
+
+ {{ item.key }}
+
+ {{ item.count }}
+
+
暂无入库记录
+
+
+
+
归类目录分布
+
+ {{ item.key }}
+
+ {{ item.count }}
+
+
暂无归类记录(扫描整理后显示)
+
+
+
+
扩展名分布
+
+ {{ item.key }}
+
+ {{ item.count }}
+
+
暂无扩展名数据
+
+
+
+
MIME 类型分布
+
+ {{ item.key }}
+
+ {{ item.count }}
+
+
暂无 MIME 数据
+
+
+
+
音频编码分布
+
+ {{ item.key }}
+
+ {{ item.count }}
+
+
当前媒体库暂无音频数据,扫描含音频文件的目录后显示
+
@@ -119,7 +199,18 @@ export default {
formatCount: [],
duplicate: 0,
dupSize: 0,
- topLarge: []
+ topLarge: [],
+ durationTotal: 0,
+ durationAvg: 0,
+ avgSize: 0,
+ maxSize: 0,
+ minSize: 0,
+ durationBins: [],
+ createdCounts: [],
+ categoryCounts: [],
+ extCounts: [],
+ mimeCounts: [],
+ audioCodecCount: []
}
}
},
@@ -135,6 +226,24 @@ export default {
},
formatTotal() {
return this.sumCount(this.stats.formatCount)
+ },
+ durationTotal() {
+ return this.sumCount(this.stats.durationBins)
+ },
+ createdTotal() {
+ return this.sumCount(this.stats.createdCounts)
+ },
+ categoryTotal() {
+ return this.sumCount(this.stats.categoryCounts)
+ },
+ extTotal() {
+ return this.sumCount(this.stats.extCounts)
+ },
+ mimeTotal() {
+ return this.sumCount(this.stats.mimeCounts)
+ },
+ audioCodecTotal() {
+ return this.sumCount(this.stats.audioCodecCount)
}
},
mounted() {
@@ -168,6 +277,15 @@ export default {
}
return size.toFixed(size >= 100 || i === 0 ? 0 : 1) + ' ' + units[i]
},
+ fmtDuration(sec) {
+ if (!sec && sec !== 0) return '-'
+ const h = Math.floor(sec / 3600)
+ const m = Math.floor((sec % 3600) / 60)
+ const s = Math.floor(sec % 60)
+ if (h > 0) return h + 'h' + (m > 0 ? m + 'm' : '')
+ if (m > 0) return m + 'm' + s + 's'
+ return s + 's'
+ },
typeName(type) {
return {image: '图片', video: '视频', audio: '音频'}[type] || type
},
@@ -266,6 +384,10 @@ export default {
text-overflow: ellipsis;
}
+.top-panel .dist-label {
+ width: 260px;
+}
+
.dist-count {
width: 44px;
text-align: right;
diff --git a/frontend/src/views/MediaList.vue b/frontend/src/views/MediaList.vue
index 2a10941..2da4b61 100644
--- a/frontend/src/views/MediaList.vue
+++ b/frontend/src/views/MediaList.vue
@@ -32,7 +32,7 @@
-
+
{{ scope.row.fileName }}
@@ -48,7 +48,7 @@
{{ scope.row.orientation }}
-
+
-
{{ scope.row.resolution }}
@@ -60,19 +60,19 @@
-
-
+
-
{{ scope.row.codec }}
-
+
-
{{ scope.row.format }}
-
+
-
{{ scope.row.durationText }}
@@ -87,7 +87,7 @@
{{ scope.row.destDir }}
-
+
-
{{ formatTime(scope.row.createdAt) }}
@@ -134,7 +134,7 @@ export default {
formatOptions: [],
query: {
page: 1,
- pageSize: 10,
+ pageSize: 20,
mediaType: '',
orientation: '',
resolution: '',
diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue
index d539747..91eeca4 100644
--- a/frontend/src/views/Scan.vue
+++ b/frontend/src/views/Scan.vue
@@ -2,12 +2,25 @@
扫描整理
+
+ {{ connText.text }}
+
- 点击开始后,程序将扫描所选目录并自动归类媒体文件(
会移动文件,整理前请先备份)。
- 整理期间请保持服务运行,结束后可在「媒体库」查看结果。
+
+
会移动文件,整理前请先备份!
+
+ - 程序将递归扫描扫描目录下的全部文件,图片/视频/音频会被探测并按规则归类移动
+ - 归类后的文件移动到「存放目录」下的分类子目录(未填写时默认在扫描目录下创建 KMP已整理 文件夹)
+ - 跨磁盘(移动失败时)自动降级为复制后删除,原文件仍会从源目录移除
+ - 与库中已有记录 MD5 相同的重复文件会被移动到「重复文件」目录
+ - 无法读取/不支持的格式会被移动到「读取异常」「已忽略」目录
+ - 扫描会自动跳过 KMP已整理 输出目录、media-info.db 数据库与 ffprobe 临时文件
+ - 整理期间请保持服务运行,结束后可在「媒体库」查看结果
+
+
@@ -19,7 +32,18 @@
clearable
size="medium"
@keyup.enter.native="handleStart">
- 选择目录
+ 选择目录
+
+
+
+ 存放目录
+
+ 选择目录
@@ -33,8 +57,16 @@
:loading="starting"
@click="handleStart">开始扫描
-
- 扫描整理中,已运行 {{ status.elapsed }} 秒...
+
+
+ 扫描整理中,已运行 {{ status.elapsed }} 秒...({{ stageText }} {{ progressText }})
+ 预计剩余 {{ etaText }}
+
+
@@ -56,7 +88,7 @@
class="state-alert"/>
@@ -115,8 +147,10 @@
扫描目录:{{ status.rootDir }}
开始于 {{ status.started }}
-
-
暂无日志,点击「开始扫描」后在此查看
+
+
+ {{ status.state === 'running' ? '扫描进行中,等待日志输出…' : '暂无日志,点击「开始扫描」后在此查看' }}
+
{{ line }}
@@ -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 {