- 新增 anime_nsfw 模块:WD Tagger v3 模型检测动漫/二次元图片 - 修复误判:当 general 评分最高时判定为安全,避免非动漫图误判 - 数据库存储四个 rating 原始分数(JSON),无需重扫即可查看 - 媒体库新增动漫色情检测列、文件路径列(支持一键复制) - 修复预览图加载占位符显示两个点的问题 - 移动文件按钮不再限制扫描完成后才显示
413 lines
16 KiB
Vue
413 lines
16 KiB
Vue
<template>
|
||
<div class="page-card">
|
||
<div class="toolbar">
|
||
<div class="toolbar-left">
|
||
<span class="title">媒体库</span>
|
||
<span v-if="manualNsfwAvg > 0" class="nsfw-avg">手动标记 NSFW 均值:{{ (manualNsfwAvg * 100).toFixed(2) }}%</span>
|
||
</div>
|
||
<el-button type="primary" size="small" icon="el-icon-refresh" @click="loadList">刷新</el-button>
|
||
</div>
|
||
|
||
<div class="filter-bar">
|
||
<el-select v-model="query.mediaType" placeholder="类型" clearable size="small" style="width: 110px" @change="handleFilterChange">
|
||
<el-option label="图片" value="image"/>
|
||
<el-option label="视频" value="video"/>
|
||
<el-option label="音频" value="audio"/>
|
||
</el-select>
|
||
<el-select v-model="query.orientation" placeholder="方向" clearable size="small" style="width: 110px" @change="handleFilterChange">
|
||
<el-option label="横屏" value="horizontal"/>
|
||
<el-option label="竖屏" value="vertical"/>
|
||
<el-option label="等比" value="square"/>
|
||
</el-select>
|
||
<el-select v-model="query.resolution" placeholder="分辨率" clearable filterable size="small" style="width: 130px" @change="handleFilterChange">
|
||
<el-option v-for="r in resolutionOptions" :key="r" :label="r" :value="r"/>
|
||
</el-select>
|
||
<el-select v-model="query.codec" placeholder="编码" clearable filterable size="small" style="width: 120px" @change="handleFilterChange">
|
||
<el-option v-for="c in codecOptions" :key="c" :label="c" :value="c"/>
|
||
</el-select>
|
||
<el-select v-model="query.format" placeholder="格式" clearable filterable size="small" style="width: 120px" @change="handleFilterChange">
|
||
<el-option v-for="f in formatOptions" :key="f" :label="f" :value="f"/>
|
||
</el-select>
|
||
<el-select v-model="query.nsfwOnly" placeholder="色情" clearable size="small" style="width: 110px" @change="handleFilterChange">
|
||
<el-option label="仅色情" :value="true"/>
|
||
</el-select>
|
||
<el-input v-model="query.keyword" placeholder="搜索文件名" size="small" clearable style="width: 180px" @keyup.enter.native="handleFilterChange" @clear="handleFilterChange">
|
||
<i slot="prefix" class="el-input__icon el-icon-search"/>
|
||
</el-input>
|
||
<el-button type="primary" size="small" icon="el-icon-search" @click="handleFilterChange">查询</el-button>
|
||
</div>
|
||
|
||
<el-table :data="list" v-loading="loading" border stripe @sort-change="handleSortChange">
|
||
<el-table-column label="预览" width="80" align="center">
|
||
<template slot-scope="scope">
|
||
<el-image
|
||
v-if="isImage(scope.row.mediaType)"
|
||
:src="getPreviewUrl(scope.row.filePath)"
|
||
:preview-src-list="[getPreviewUrl(scope.row.filePath)]"
|
||
style="width: 60px; height: 60px"
|
||
fit="cover">
|
||
<div slot="placeholder" class="image-placeholder">
|
||
<i class="el-icon-loading"></i>
|
||
</div>
|
||
<div slot="error" class="image-error">
|
||
<i class="el-icon-picture-outline"></i>
|
||
</div>
|
||
</el-image>
|
||
<span v-else class="muted">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<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>
|
||
</el-table-column>
|
||
<el-table-column label="路径" min-width="160">
|
||
<template slot-scope="scope">
|
||
<div class="path-cell">
|
||
<span class="file-path" :title="scope.row.filePath">{{ scope.row.filePath }}</span>
|
||
<el-tooltip content="复制路径" placement="top">
|
||
<i class="el-icon-document-copy copy-icon" @click="copyPath(scope.row.filePath)"/>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" width="80" align="center">
|
||
<template slot-scope="scope">
|
||
<el-tag :type="typeTag(scope.row.mediaType)" size="small">{{ typeName(scope.row.mediaType) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="orientation" label="方向" width="80" align="center">
|
||
<template slot-scope="scope">
|
||
<span class="muted" v-if="!scope.row.orientation">-</span>
|
||
<span v-else>{{ scope.row.orientation }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<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>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="尺寸" width="120" align="center">
|
||
<template slot-scope="scope">
|
||
<span v-if="scope.row.width > 0">{{ scope.row.width }}×{{ scope.row.height }}</span>
|
||
<span v-else class="muted">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="色情检测" width="140" align="center">
|
||
<template slot-scope="scope">
|
||
<div v-if="scope.row.nsfwScore > 0">
|
||
<el-tag :type="scope.row.nsfwScore >= scope.row.nsfwThreshold ? 'danger' : 'warning'" size="small">
|
||
{{ (scope.row.nsfwScore * 100).toFixed(1) }}%
|
||
</el-tag>
|
||
<div class="nsfw-threshold">阈值: {{ (scope.row.nsfwThreshold * 100).toFixed(0) }}%</div>
|
||
</div>
|
||
<span v-else class="muted">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="动漫色情检测" width="140" align="center">
|
||
<template slot-scope="scope">
|
||
<div v-if="scope.row.animeNsfwScore > 0">
|
||
<el-tag :type="scope.row.animeNsfwScore >= scope.row.animeNsfwThreshold ? 'danger' : 'warning'" size="small">
|
||
{{ (scope.row.animeNsfwScore * 100).toFixed(1) }}%
|
||
</el-tag>
|
||
<div class="nsfw-threshold">阈值: {{ (scope.row.animeNsfwThreshold * 100).toFixed(0) }}%</div>
|
||
</div>
|
||
<div v-else-if="scope.row.animeNsfwRatings">
|
||
<el-tag type="info" size="small">安全</el-tag>
|
||
<div class="nsfw-threshold">{{ formatAnimeRatings(scope.row.animeNsfwRatings) }}</div>
|
||
</div>
|
||
<span v-else class="muted">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="色情标记" width="100" align="center">
|
||
<template slot-scope="scope">
|
||
<el-switch
|
||
:value="scope.row.manualNsfw === 'Y'"
|
||
active-color="#F56C6C"
|
||
@change="handleManualNsfwChange(scope.row)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<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="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="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>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="fileSize" label="大小" width="100" align="right" sortable="custom">
|
||
<template slot-scope="scope">{{ scope.row.sizeText }}</template>
|
||
</el-table-column>
|
||
<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>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-pagination
|
||
class="pagination"
|
||
background
|
||
layout="total, sizes, prev, pager, next"
|
||
:total="total"
|
||
:page-size="query.pageSize"
|
||
:current-page="query.page"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
@current-change="handlePageChange"
|
||
@size-change="handleSizeChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import request from '../utils/request'
|
||
|
||
const RES_STANDARD = ['720P', '1D5KP', '1080P', '2DKP', '2D5KP', 'UltraWide2KP', 'UltraWide3KP', 'UltraWide4KP', '4KP', '4KHP', '8KP']
|
||
|
||
function resNonStandard() {
|
||
const list = ['普通', '原图']
|
||
for (let i = 1; i <= 19; i++) {
|
||
list.splice(list.length - 1, 0, i + 'K')
|
||
}
|
||
return list
|
||
}
|
||
|
||
export default {
|
||
name: 'MediaList',
|
||
data() {
|
||
return {
|
||
loading: false,
|
||
manualNsfwAvg: 0,
|
||
list: [],
|
||
total: 0,
|
||
resolutionOptions: RES_STANDARD.concat(resNonStandard()),
|
||
codecOptions: [],
|
||
formatOptions: [],
|
||
query: {
|
||
page: 1,
|
||
pageSize: 10,
|
||
mediaType: '',
|
||
orientation: '',
|
||
resolution: '',
|
||
codec: '',
|
||
format: '',
|
||
nsfwOnly: null,
|
||
keyword: '',
|
||
sortField: '',
|
||
sortOrder: ''
|
||
}
|
||
}
|
||
},
|
||
mounted() {
|
||
this.loadOptions()
|
||
this.loadList()
|
||
this.loadManualNsfwAvg()
|
||
},
|
||
methods: {
|
||
async loadOptions() {
|
||
try {
|
||
const res = await request.post('/api/media/stats')
|
||
const data = res.data || {}
|
||
this.codecOptions = (data.codecCount || []).map((item) => item.key)
|
||
this.formatOptions = (data.formatCount || []).map((item) => item.key)
|
||
} catch (e) {
|
||
// 拦截器已提示
|
||
}
|
||
},
|
||
async loadList() {
|
||
this.loading = true
|
||
try {
|
||
const res = await request.post('/api/media/list', this.query)
|
||
this.list = res.data.list || []
|
||
this.total = res.data.total || 0
|
||
} finally {
|
||
this.loading = false
|
||
}
|
||
},
|
||
handleFilterChange() {
|
||
this.query.page = 1
|
||
this.loadList()
|
||
},
|
||
handlePageChange(page) {
|
||
this.query.page = page
|
||
this.loadList()
|
||
},
|
||
handleSizeChange(size) {
|
||
this.query.pageSize = size
|
||
this.query.page = 1
|
||
this.loadList()
|
||
},
|
||
handleSortChange({prop, order}) {
|
||
const map = {
|
||
resolution: 'width',
|
||
duration: 'duration',
|
||
fileSize: 'fileSize',
|
||
createdAt: 'createdAt'
|
||
}
|
||
this.query.sortField = map[prop] || ''
|
||
this.query.sortOrder = order === 'descending' ? 'desc' : order === 'ascending' ? 'asc' : ''
|
||
this.query.page = 1
|
||
this.loadList()
|
||
},
|
||
typeName(type) {
|
||
return {image: '图片', video: '视频', audio: '音频'}[type] || type
|
||
},
|
||
typeTag(type) {
|
||
return {image: '', video: 'primary', audio: 'warning'}[type] || 'info'
|
||
},
|
||
isImage(type) {
|
||
return type === 'image'
|
||
},
|
||
getPreviewUrl(filePath) {
|
||
return '/api/media/preview?path=' + encodeURIComponent(filePath)
|
||
},
|
||
formatTime(value) {
|
||
if (!value) return '-'
|
||
const d = new Date(value)
|
||
const pad = (n) => (n < 10 ? '0' + n : '' + n)
|
||
return (
|
||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
|
||
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
|
||
)
|
||
},
|
||
async handleManualNsfwChange(row) {
|
||
const newValue = row.manualNsfw === 'Y' ? 'N' : 'Y'
|
||
try {
|
||
await request.put(`/api/media/${row.id}/manual-nsfw`, { value: newValue })
|
||
row.manualNsfw = newValue
|
||
this.loadManualNsfwAvg()
|
||
} catch (e) {
|
||
// 拦截器已提示
|
||
}
|
||
},
|
||
async loadManualNsfwAvg() {
|
||
try {
|
||
const res = await request.get('/api/media/manual-nsfw-avg')
|
||
this.manualNsfwAvg = (res.data && res.data.avg) || 0
|
||
} catch (e) {
|
||
// 忽略
|
||
}
|
||
},
|
||
copyPath(path) {
|
||
navigator.clipboard.writeText(path).then(() => {
|
||
this.$message.success('路径已复制')
|
||
}).catch(() => {
|
||
this.$message.error('复制失败')
|
||
})
|
||
},
|
||
formatAnimeRatings(ratingsJson) {
|
||
if (!ratingsJson) return ''
|
||
try {
|
||
const r = JSON.parse(ratingsJson)
|
||
return `G=${(r.general * 100).toFixed(0)} S=${(r.sensitive * 100).toFixed(0)} Q=${(r.questionable * 100).toFixed(0)} E=${(r.explicit * 100).toFixed(0)}`
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.nsfw-avg {
|
||
font-size: 13px;
|
||
color: #F56C6C;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.file-name {
|
||
color: #1f3a6b;
|
||
}
|
||
|
||
.file-path {
|
||
color: #606266;
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
}
|
||
|
||
.path-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.copy-icon {
|
||
cursor: pointer;
|
||
color: #909399;
|
||
font-size: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.copy-icon:hover {
|
||
color: #409EFF;
|
||
}
|
||
|
||
.image-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 60px;
|
||
height: 60px;
|
||
background: #f5f7fa;
|
||
color: #c0c4cc;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.muted {
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.image-error {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 60px;
|
||
height: 60px;
|
||
background: #f5f7fa;
|
||
color: #c0c4cc;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.nsfw-threshold {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.pagination {
|
||
margin-top: 16px;
|
||
text-align: right;
|
||
}
|
||
</style>
|