feat: 扫描整理并行化、WebSocket 实时进度与统计看板
- 移动阶段并行处理文件,按原子计数上报累计进度 - 扫描遍历阶段实时上报已发现文件数,界面不再卡在 0/0 - 新增 /api/scan/ws WebSocket 实时推送扫描状态;前端断线指数退避自动重连,并以低频轮询兜底 - 新增统计看板与媒体库筛选/排序,目录选择器路径不存在时回退到最近可用父目录 - 存放目录支持自定义并持久化,默认输出到 KMP已整理
This commit is contained in:
@@ -12,14 +12,14 @@
|
||||
text-color="#4a5a78"
|
||||
active-text-color="#1677ff"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<i class="el-icon-data-line"></i>
|
||||
<span slot="title">统计看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/scan">
|
||||
<i class="el-icon-video-play"></i>
|
||||
<span slot="title">扫描整理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/dashboard">
|
||||
<i class="el-icon-data-line"></i>
|
||||
<span slot="title">统计看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/media">
|
||||
<i class="el-icon-film"></i>
|
||||
<span slot="title">媒体库</span>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
<div class="stat-value">{{ fmtSize(stats.totalSize) }}</div>
|
||||
<div class="stat-sub">所有文件占用</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">平均文件</div>
|
||||
<div class="stat-value">{{ fmtSize(stats.avgSize) }}</div>
|
||||
<div class="stat-sub">单文件平均体积</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">最大文件</div>
|
||||
<div class="stat-value">{{ fmtSize(stats.maxSize) }}</div>
|
||||
<div class="stat-sub">体积最大的文件</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">最小文件</div>
|
||||
<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>
|
||||
@@ -26,6 +41,11 @@
|
||||
<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>
|
||||
<div class="stat-sub">平均 {{ fmtDuration(stats.durationAvg) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-grid">
|
||||
@@ -86,6 +106,66 @@
|
||||
</div>
|
||||
<div v-if="stats.formatCount.length === 0" class="empty-tip">暂无格式数据</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">时长分布(视频/音频)</div>
|
||||
<div v-for="item in stats.durationBins" :key="item.key" class="dist-row">
|
||||
<span class="dist-label">{{ item.key }}</span>
|
||||
<el-progress :percentage="percent(item.count, durationTotal)" :stroke-width="14" color="#722ed1"/>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div v-if="stats.durationBins.length === 0" class="empty-tip">当前媒体库暂无时长数据,扫描含视频/音频的目录后显示</div>
|
||||
</div>
|
||||
|
||||
<div class="panel top-panel">
|
||||
<div class="panel-title">入库趋势(近30天)</div>
|
||||
<div v-for="item in (stats.createdCounts || [])" :key="item.key" class="dist-row">
|
||||
<span class="dist-label">{{ item.key }}</span>
|
||||
<el-progress :percentage="percent(item.count, createdTotal)" :stroke-width="14" color="#13c2c2"/>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="dist-label">{{ item.key }}</span>
|
||||
<el-progress :percentage="percent(item.count, extTotal)" :stroke-width="14" color="#eb2f96"/>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div v-if="stats.extCounts.length === 0" class="empty-tip">暂无扩展名数据</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">MIME 类型分布</div>
|
||||
<div v-for="item in stats.mimeCounts" :key="item.key" class="dist-row">
|
||||
<span class="dist-label">{{ item.key }}</span>
|
||||
<el-progress :percentage="percent(item.count, mimeTotal)" :stroke-width="14" color="#2f54eb"/>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div v-if="stats.mimeCounts.length === 0" class="empty-tip">暂无 MIME 数据</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">音频编码分布</div>
|
||||
<div v-for="item in stats.audioCodecCount" :key="item.key" class="dist-row">
|
||||
<span class="dist-label">{{ item.key }}</span>
|
||||
<el-progress :percentage="percent(item.count, audioCodecTotal)" :stroke-width="14" color="#13c2c2"/>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div v-if="stats.audioCodecCount.length === 0" class="empty-tip">当前媒体库暂无音频数据,扫描含音频文件的目录后显示</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel top-panel">
|
||||
@@ -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;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe @sort-change="handleSortChange">
|
||||
<el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip>
|
||||
<el-table-column prop="fileName" label="文件名" min-width="100" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="file-name" :title="scope.row.filePath">{{ scope.row.fileName }}</span>
|
||||
</template>
|
||||
@@ -48,7 +48,7 @@
|
||||
<span v-else>{{ scope.row.orientation }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resolution" label="分辨率" width="110" align="center" sortable="custom">
|
||||
<el-table-column prop="resolution" label="分辨率" width="120" align="center" sortable="custom">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.resolution">-</span>
|
||||
<span v-else>{{ scope.row.resolution }}</span>
|
||||
@@ -60,19 +60,19 @@
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="codec" label="编码" width="90" align="center">
|
||||
<el-table-column prop="codec" label="编码" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.codec">-</span>
|
||||
<span v-else>{{ scope.row.codec }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="format" label="格式" width="80" align="center">
|
||||
<el-table-column prop="format" label="格式" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.format">-</span>
|
||||
<span v-else>{{ scope.row.format }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="时长" width="90" align="center" sortable="custom">
|
||||
<el-table-column prop="duration" label="时长" width="120" align="center" sortable="custom">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.durationText">-</span>
|
||||
<span v-else>{{ scope.row.durationText }}</span>
|
||||
@@ -87,7 +87,7 @@
|
||||
<span v-else>{{ scope.row.destDir }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="入库时间" width="150" align="center" sortable="custom">
|
||||
<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>
|
||||
<span v-else>{{ formatTime(scope.row.createdAt) }}</span>
|
||||
@@ -134,7 +134,7 @@ export default {
|
||||
formatOptions: [],
|
||||
query: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
mediaType: '',
|
||||
orientation: '',
|
||||
resolution: '',
|
||||
|
||||
+256
-44
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user