feat: 添加动漫色情检测(WD Tagger v3),媒体库展示优化

- 新增 anime_nsfw 模块:WD Tagger v3 模型检测动漫/二次元图片

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

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

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

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

- 移动文件按钮不再限制扫描完成后才显示
This commit is contained in:
2025-08-24 21:14:04 +08:00
parent bd84bc98e8
commit d6798ef1ee
12 changed files with 934 additions and 31 deletions
+111 -3
View File
@@ -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;
}