Files
kenaito-media-picker/frontend/src/views/MediaList.vue
T
2025-09-24 21:19:55 +08:00

437 lines
17 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="getNsfwLevelType(scope.row.nsfwScore)" size="small">
{{ getNsfwLevelName(scope.row.nsfwScore) }} {{ (scope.row.nsfwScore * 100).toFixed(1) }}%
</el-tag>
<div v-if="scope.row.nsfwDetections" class="nsfw-threshold">{{ formatNsfwDetections(scope.row.nsfwDetections) }}</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="getNsfwLevelType(scope.row.animeNsfwScore)" size="small">
{{ getNsfwLevelName(scope.row.animeNsfwScore) }} {{ (scope.row.animeNsfwScore * 100).toFixed(1) }}%
</el-tag>
</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('复制失败')
})
},
getNsfwLevelName(score) {
if (score < 0.25) return '安全'
if (score < 0.5) return '低俗'
if (score < 0.75) return '擦边'
return '色情'
},
getNsfwLevelType(score) {
if (score < 0.25) return 'success'
if (score < 0.5) return 'warning'
if (score < 0.75) return ''
return 'danger'
},
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 ''
}
},
formatNsfwDetections(detectionsJson) {
if (!detectionsJson) return ''
try {
const detections = JSON.parse(detectionsJson)
if (!Array.isArray(detections) || detections.length === 0) return ''
return detections.map(d => {
const className = d.class.replace(/_/g, ' ').toLowerCase()
return `${className}: ${(d.score * 100).toFixed(0)}%`
}).join(', ')
} 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>