feat: 增加 Web 管理界面,扫描整理改为页面点击触发
- main.go 改为 Web 服务入口(Gin + 内嵌前端 dist + 自动打开浏览器) - internal/service: 新增扫描整理后台任务,支持状态机与实时日志捕获 - internal/api: 新增统一响应、媒体列表/统计、扫描开始/状态接口 - internal/store: 追加 ListMedia/CountMedia/Stats 查询,MediaInfo 补 JSON tag - internal/config: application.properties 配置加载(端口/数据库/自动开浏览器) - frontend: Vue2 + ElementUI + Vite 工程,含扫描整理/统计看板/媒体库三页 - build.sh 整合前端构建,输出单一 kenaito-media-picker.exe
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<span class="title">媒体库</span>
|
||||
<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-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 prop="fileName" label="文件名" min-width="220" 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="类型" 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="110" 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 prop="codec" label="编码" width="90" 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="80" 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="90" 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 label="归类目录" min-width="180" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.destDir">-</span>
|
||||
<span v-else>{{ scope.row.destDir }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="入库时间" width="150" 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,
|
||||
list: [],
|
||||
total: 0,
|
||||
resolutionOptions: RES_STANDARD.concat(resNonStandard()),
|
||||
codecOptions: [],
|
||||
formatOptions: [],
|
||||
query: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
mediaType: '',
|
||||
orientation: '',
|
||||
resolution: '',
|
||||
codec: '',
|
||||
format: '',
|
||||
keyword: '',
|
||||
sortField: '',
|
||||
sortOrder: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadOptions()
|
||||
this.loadList()
|
||||
},
|
||||
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'
|
||||
},
|
||||
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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: #1f3a6b;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user