Files
kenaito-media-picker/frontend/src/views/Scan.vue
T
odboy d6798ef1ee feat: 添加动漫色情检测(WD Tagger v3),媒体库展示优化
- 新增 anime_nsfw 模块:WD Tagger v3 模型检测动漫/二次元图片

- 修复误判:当 general 评分最高时判定为安全,避免非动漫图误判

- 数据库存储四个 rating 原始分数(JSON),无需重扫即可查看

- 媒体库新增动漫色情检测列、文件路径列(支持一键复制)

- 修复预览图加载占位符显示两个点的问题

- 移动文件按钮不再限制扫描完成后才显示
2025-08-24 21:14:04 +08:00

704 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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>
<div class="scan-tip-body">
<p><b>会移动文件,整理前请先备份!</b></p>
<ul>
<li>程序将<b>递归扫描</b>扫描目录下的全部文件,图片/视频/音频会被探测并按规则归类移动</li>
<li>归类后的文件<b>移动</b>到「存放目录」下的 <b>KMP已整理</b> 分类子目录(未填写时默认在扫描目录下创建 <b>KMP已整理</b> 文件夹)</li>
<li>跨磁盘(移动失败时)自动降级为<b>复制后删除</b>,原文件仍会从源目录移除</li>
<li>与库中已有记录 <b>MD5 相同且路径也相同</b>的文件视为重复,将<b>原地保留不动</b>(MD5 相同但路径不同的文件仍会正常归类)</li>
<li>无法读取的文件会被移动到「图片_读取异常」「视频_读取异常」目录,不支持的格式会被移动到「图片_已忽略」「视频_已忽略」目录</li>
<li>扫描会自动跳过 <b>KMP已整理</b> 输出目录、<b>media-info.db</b> 数据库与 ffprobe 临时文件</li>
<li>若已配置开启色情识别(本机需装 <b>python + nudenet</b>),判为色情的图片会移入「图片_敏感」分类目录,可事后人工复核</li>
<li>整理期间请保持服务运行,结束后可在「媒体库」查看结果</li>
</ul>
</div>
</div>
<div class="scan-form">
<div class="form-row">
<span class="form-label">扫描目录</span>
<el-input
v-model="scanDir"
placeholder="留空则扫描程序当前运行目录,例如 D:\media 或 /Users/me/Pictures"
clearable
size="medium"
@keyup.enter.native="handleStart">
<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>
<div class="scan-actions">
<el-button
v-if="status.state !== 'running'"
type="primary"
size="medium"
icon="el-icon-video-play"
:loading="starting"
@click="handleStart">开始扫描</el-button>
<el-button
type="warning"
size="medium"
icon="el-icon-s-promotion"
:loading="moving"
@click="handleMove">移动文件到存放目录</el-button>
<div v-if="status.state === 'running'" class="running-hint">
<span>
<i class="el-icon-loading"></i>
<template v-if="status.stage === 'move'">
移动文件中,已运行 {{ status.elapsed }} 秒...({{ progressText }})
</template>
<template v-else>
扫描整理中,已运行 {{ status.elapsed }} 秒...({{ stageText }} {{ progressText }})
</template>
<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' : ''"/>
<el-button
type="danger"
size="small"
plain
icon="el-icon-switch-button"
:loading="cancelling"
@click="handleCancel">中止扫描</el-button>
</div>
</div>
<el-alert
v-if="status.state === 'cancelled'"
title="扫描已中止"
type="warning"
:description="status.message"
show-icon
:closable="false"
class="state-alert"/>
<el-alert
v-if="status.state === 'done'"
title="扫描整理完成"
type="success"
:description="status.message"
show-icon
:closable="false"
class="state-alert"/>
<el-alert
v-if="status.state === 'error'"
title="扫描整理失败"
type="error"
:description="status.message"
show-icon
:closable="false"
class="state-alert"/>
<el-dialog
title="选择目录"
:visible.sync="dirPickerVisible"
width="520px"
append-to-body>
<div class="dir-picker">
<div class="dir-nav">
<el-button
v-if="fsData.drives && fsData.drives.length"
size="mini"
icon="el-icon-refresh"
@click="loadFs('')">盘符</el-button>
<el-button
v-else-if="fsData.current"
size="mini"
icon="el-icon-back"
:disabled="fsData.parent === fsData.current"
@click="loadFs(fsData.parent)">上一级</el-button>
<span class="dir-path" :title="fsData.current">{{ fsData.current || '选择磁盘' }}</span>
</div>
<div v-if="fsData.drives && fsData.drives.length" class="dir-list">
<div
v-for="d in fsData.drives"
:key="d"
class="dir-item"
@click="loadFs(d)">
<i class="el-icon-folder"></i>
<span>{{ d }}</span>
</div>
</div>
<div v-else class="dir-list" v-loading="dirLoading">
<div
v-for="d in fsData.dirs"
:key="d.path"
class="dir-item"
@dblclick="loadFs(d.path)">
<i class="el-icon-folder"></i>
<span>{{ d.name }}</span>
</div>
<div v-if="!dirLoading && fsData.dirs.length === 0" class="dir-empty">此目录下没有子目录</div>
</div>
</div>
<div slot="footer">
<el-button @click="dirPickerVisible = false">取消</el-button>
<el-button
type="primary"
:disabled="!fsData.current"
@click="confirmDir">选中此目录</el-button>
</div>
</el-dialog>
<div class="log-panel">
<div class="log-header">
<span>运行日志</span>
<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">
<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>
</div>
</template>
<script>
import request from '../utils/request'
/**
* 扫描整理页面。
*
* 页面干四件事:
* 1. 开始扫描:填好扫描目录和存放目录,点按钮发给后端
* 2. 实时看进度:通过 WebSocket 专线接收后端主动推来的进度(实时),
* 页面刷新/断线后会自动重连;万一 WebSocket 不通,就退回"每 3 秒问一次"的低频轮询,
* 保证进度永远不会卡住不动
* 3. 看运行日志:后端干活时打印的日志会推到这里逐行显示
* 4. 选目录:弹窗一层层翻服务器上的文件夹,挑一个目录
*/
export default {
name: 'Scan',
data() {
return {
// ---------- 开始扫描 ----------
starting: false, // 点完按钮后、等后端回话前的"转圈"
moving: false, // 点完「移动文件」后等后端回话前的"转圈"
cancelling: false, // 点完「中止扫描」后等后端确认的"转圈"
scanDir: '', // 要扫描的目录(从后端状态回填)
destDir: '', // 整理后文件放哪(从后端状态回填)
// ---------- 扫描进度与状态 ----------
status: {
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 // 总数
},
// ---------- 实时推送(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: [], // Windows 盘符
dirs: [] // 当前目录下的子目录
}
}
},
mounted() {
// 进页面先问一次当前状态(可能上次扫描到一半),然后连上实时专线
this.fetchStatus()
this.connectWs()
},
destroyed() {
// 页面关了:断开专线、停掉计时器,免得后台还在偷偷干活
this.closeWs()
},
computed: {
// 当前阶段的文字(给用户看的)
stageText() {
return {
scan: '扫描文件',
organize: '整理归类',
move: '移动文件'
}[this.status.stage] || ''
},
// 进度文字:总数已知就显示 "已完成/总数";
// 总数还不知道(后端还在翻目录)就只显示"已发现 N 个文件",避免卡在 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: {
// ==================== 状态更新 ====================
// 统一处理一份状态快照(WebSocket 推来的、HTTP 问来的都用这个函数)。
// 它负责:存下状态、回填上次的存放目录、日志有新增就滚到底、任务结束弹个提示。
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) {
if (data.rootDir) this.scanDir = data.rootDir
if (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) {
if (data.state === 'cancelled') {
this.$message.warning(data.message || '扫描已中止')
} else {
this.$message.success(data.message || '扫描完成')
}
}
},
// 用 HTTP 问一次当前状态(连上专线后、以及兜底轮询时用)
async fetchStatus() {
try {
const res = await request.get('/api/scan/status')
this.applyStatus(res.data)
} catch (e) {
// 网络暂时不通:轮询会继续重试,WebSocket 模式则等专线自动重连
}
},
// ==================== 实时推送(WebSocket) ====================
// 建立专线。页面刷新后会自动重新连上;连失败会退避重试(1 秒 → 2 秒 → 4 秒 → 封顶 5 秒)。
// 同时开一个低频轮询当"看门狗":就算专线悄悄断了,也能兜底刷新,界面永远不会卡住。
connectWs() {
if (this.ws || this.wsClosed) return
// 浏览器太老不支持 WebSocket:直接退回 1 秒轮询
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 = () => {
// 出错就关掉连接,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
if (this.ws) {
this.ws.close()
this.ws = null
}
if (this.wsReconnectTimer) {
clearTimeout(this.wsReconnectTimer)
this.wsReconnectTimer = null
}
this.stopPoll()
},
// 开一个周期轮询:专线正常时每 3 秒兜底刷新一次;
// 没有 WebSocket 的浏览器退回 1 秒一次
startPoll(interval) {
this.stopPoll()
this.pollTimer = setInterval(() => this.fetchStatus(), interval || 3000)
},
stopPoll() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
},
// ==================== 开始扫描 ====================
async handleStart() {
this.starting = true
try {
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
}
},
// 中止当前扫描:后端取消上下文后,各环节会在最近的检查点停下
async handleCancel() {
this.cancelling = true
try {
const res = await request.post('/api/scan/cancel')
this.applyStatus(res.data)
this.$message.warning('已请求中止,正在停止…')
} finally {
this.cancelling = false
}
},
// 触发文件移动:根据扫描归类的结果,将文件移动到分类目录
async handleMove() {
this.moving = true
try {
const res = await request.post('/api/scan/move')
this.applyStatus(res.data)
this.$message.success('已开始移动文件')
} finally {
this.moving = 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 {
const res = await request.get('/api/fs/list', { params: dir ? { dir } : {} })
this.fsData = res.data || { current: '', parent: '', drives: [], dirs: [] }
} catch (e) {
// 拦截器已提示
} finally {
this.dirLoading = false
}
},
// 点"选中此目录":把选中的目录填到对应的输入框里
confirmDir() {
if (!this.fsData.current) return
if (this.pickerTarget === 'dest') {
this.destDir = this.fsData.current
} else {
this.scanDir = this.fsData.current
}
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
if (box) box.scrollTop = box.scrollHeight
})
}
}
}
</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;
padding: 12px 16px;
color: #4a5a78;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
}
.scan-tip .el-icon-warning-outline {
color: #faad14;
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 {
margin-bottom: 20px;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
max-width: 640px;
}
.form-label {
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: #1f3a6b;
}
.dir-picker .dir-nav {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.dir-picker .dir-path {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 13px;
color: #4a5a78;
background: #f5f7fa;
border-radius: 4px;
padding: 6px 10px;
}
.dir-picker .dir-list {
height: 320px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 6px;
}
.dir-picker .dir-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: #303133;
}
.dir-picker .dir-item:hover {
background: #f0f6ff;
}
.dir-picker .dir-item .el-icon-folder {
color: #faad14;
}
.dir-picker .dir-empty {
padding: 30px 0;
text-align: center;
color: #909399;
font-size: 13px;
}
.scan-actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.running-hint {
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;
}
.log-panel {
border: 1px solid #e6efff;
border-radius: 8px;
overflow: hidden;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: #f0f6ff;
font-size: 13px;
font-weight: 600;
color: #1f3a6b;
}
.log-body {
height: 380px;
overflow-y: auto;
padding: 12px 16px;
background: #0d1b2e;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
}
.log-line {
color: #b7d0f2;
white-space: pre-wrap;
word-break: break-all;
}
.empty-log {
color: #6b8bb8;
}
.muted {
color: #c0c4cc;
font-weight: 400;
font-size: 12px;
}
</style>