- 新增 anime_nsfw 模块:WD Tagger v3 模型检测动漫/二次元图片 - 修复误判:当 general 评分最高时判定为安全,避免非动漫图误判 - 数据库存储四个 rating 原始分数(JSON),无需重扫即可查看 - 媒体库新增动漫色情检测列、文件路径列(支持一键复制) - 修复预览图加载占位符显示两个点的问题 - 移动文件按钮不再限制扫描完成后才显示
704 lines
25 KiB
Vue
704 lines
25 KiB
Vue
<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>
|