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

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

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

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

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

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

413 lines
16 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">
<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>