feat: 添加动漫色情检测(WD Tagger v3),媒体库展示优化
- 新增 anime_nsfw 模块:WD Tagger v3 模型检测动漫/二次元图片 - 修复误判:当 general 评分最高时判定为安全,避免非动漫图误判 - 数据库存储四个 rating 原始分数(JSON),无需重扫即可查看 - 媒体库新增动漫色情检测列、文件路径列(支持一键复制) - 修复预览图加载占位符显示两个点的问题 - 移动文件按钮不再限制扫描完成后才显示
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<span class="title">媒体库</span>
|
||||
<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>
|
||||
|
||||
@@ -37,12 +40,15 @@
|
||||
<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
|
||||
<el-image
|
||||
v-if="isImage(scope.row.mediaType)"
|
||||
:src="getPreviewUrl(scope.row.filePath)"
|
||||
: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>
|
||||
@@ -55,6 +61,16 @@
|
||||
<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>
|
||||
@@ -89,6 +105,21 @@
|
||||
<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
|
||||
@@ -159,6 +190,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
manualNsfwAvg: 0,
|
||||
list: [],
|
||||
total: 0,
|
||||
resolutionOptions: RES_STANDARD.concat(resNonStandard()),
|
||||
@@ -182,6 +214,7 @@ export default {
|
||||
mounted() {
|
||||
this.loadOptions()
|
||||
this.loadList()
|
||||
this.loadManualNsfwAvg()
|
||||
},
|
||||
methods: {
|
||||
async loadOptions() {
|
||||
@@ -255,15 +288,52 @@ export default {
|
||||
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;
|
||||
@@ -276,6 +346,44 @@ export default {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user