feat(watermark): nested menu and 3 table list pages (#7 #8)

This commit is contained in:
root
2026-03-10 17:02:58 +08:00
parent 9e0d321177
commit 0d12ce5201
7 changed files with 698 additions and 26 deletions
+24
View File
@@ -28,3 +28,27 @@ export function deleteWatermarkTask(id) {
method: 'delete' method: 'delete'
}) })
} }
export function getVideoParseLogs(params) {
return request({
url: '/api/admin/watermark/video-parse-logs',
method: 'get',
params
})
}
export function getVideoParseUnlocks(params) {
return request({
url: '/api/admin/watermark/video-parse-unlocks',
method: 'get',
params
})
}
export function getVideoDownloadFailures(params) {
return request({
url: '/api/admin/watermark/video-download-failures',
method: 'get',
params
})
}
+64 -24
View File
@@ -6,7 +6,6 @@
@click="closeMobileMenu" @click="closeMobileMenu"
></div> ></div>
<!-- 侧边栏 -->
<el-aside :width="asideWidth" class="sidebar" :class="{ 'sidebar-mobile': isMobile }"> <el-aside :width="asideWidth" class="sidebar" :class="{ 'sidebar-mobile': isMobile }">
<div class="logo"> <div class="logo">
<span v-if="!isCollapse">管理后台</span> <span v-if="!isCollapse">管理后台</span>
@@ -20,20 +19,36 @@
router router
@select="handleMenuSelect" @select="handleMenuSelect"
> >
<el-menu-item <template v-for="routeItem in menuRoutes" :key="routeKey(routeItem)">
v-for="route in menuRoutes" <el-sub-menu
:key="route.path" v-if="hasChildren(routeItem)"
:index="route.path" :index="resolvePath(routeItem.path)"
> >
<el-icon><component :is="route.meta.icon" /></el-icon> <template #title>
<template #title>{{ route.meta.title }}</template> <el-icon><component :is="routeItem.meta.icon" /></el-icon>
</el-menu-item> <span>{{ routeItem.meta.title }}</span>
</template>
<el-menu-item
v-for="child in visibleChildren(routeItem)"
:key="routeKey(child)"
:index="resolvePath(routeItem.path, child.path)"
>
{{ child.meta?.title || child.name }}
</el-menu-item>
</el-sub-menu>
<el-menu-item
v-else
:index="resolvePath(routeItem.path)"
>
<el-icon><component :is="routeItem.meta.icon" /></el-icon>
<template #title>{{ routeItem.meta.title }}</template>
</el-menu-item>
</template>
</el-menu> </el-menu>
</el-aside> </el-aside>
<!-- 主内容区 -->
<el-container> <el-container>
<!-- 顶部导航栏 -->
<el-header class="header"> <el-header class="header">
<div class="header-left"> <div class="header-left">
<el-icon class="collapse-icon" @click="toggleCollapse"> <el-icon class="collapse-icon" @click="toggleCollapse">
@@ -59,7 +74,6 @@
</div> </div>
</el-header> </el-header>
<!-- 主内容 -->
<el-main class="main-content"> <el-main class="main-content">
<router-view /> <router-view />
</el-main> </el-main>
@@ -122,26 +136,51 @@ watch(
} }
) )
// 获取菜单路由(过滤掉隐藏的路由)
const menuRoutes = computed(() => { const menuRoutes = computed(() => {
const routes = router.getRoutes() const rootRoute = router.options.routes.find((item) => item.path === '/')
const mainRoute = routes.find(r => r.path === '/') if (!rootRoute || !rootRoute.children) return []
if (!mainRoute || !mainRoute.children) return [] return rootRoute.children.filter((item) => !item.meta?.hidden)
return mainRoute.children.filter(r => !r.meta?.hidden)
}) })
// 当前激活的菜单 const visibleChildren = (routeItem) => {
return (routeItem.children || []).filter((child) => !child.meta?.hidden)
}
const hasChildren = (routeItem) => {
return visibleChildren(routeItem).length > 0
}
const routeKey = (routeItem) => {
return `${routeItem.path || ''}-${routeItem.name || ''}`
}
const resolvePath = (parentPath = '', childPath = '') => {
const normalize = (value) => {
if (!value) return '/'
return value.startsWith('/') ? value : `/${value}`
}
const parent = normalize(parentPath)
if (!childPath) {
return parent
}
if (childPath.startsWith('/')) {
return childPath
}
return `${parent.replace(/\/$/, '')}/${childPath}`.replace(/\/{2,}/g, '/')
}
const activeMenu = computed(() => { const activeMenu = computed(() => {
const { path } = route const { path } = route
// 如果是子路由,返回父路由路径 if (path === '/watermark') {
return '/watermark/video-parse-logs'
}
if (path.includes('/create') || path.includes('/edit') || /\/\d+$/.test(path)) { if (path.includes('/create') || path.includes('/edit') || /\/\d+$/.test(path)) {
return '/' + path.split('/')[1] return '/' + path.split('/')[1]
} }
return path return path
}) })
// 切换侧边栏折叠状态
const toggleCollapse = () => { const toggleCollapse = () => {
if (isMobile.value) { if (isMobile.value) {
mobileMenuVisible.value = !mobileMenuVisible.value mobileMenuVisible.value = !mobileMenuVisible.value
@@ -167,7 +206,6 @@ const handleResize = () => {
} }
} }
// 处理下拉菜单命令
const handleCommand = async (command) => { const handleCommand = async (command) => {
if (command === 'logout') { if (command === 'logout') {
try { try {
@@ -222,11 +260,13 @@ const handleCommand = async (command) => {
background: #304156; background: #304156;
} }
:deep(.el-menu-item) { :deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: #bfcbd9; color: #bfcbd9;
} }
:deep(.el-menu-item:hover) { :deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background: #263445 !important; background: #263445 !important;
color: #fff; color: #fff;
} }
+23 -2
View File
@@ -72,8 +72,29 @@ const routes = [
{ {
path: 'watermark', path: 'watermark',
name: 'Watermark', name: 'Watermark',
component: () => import('../views/watermark/index.vue'), component: () => import('../views/watermark/layout.vue'),
meta: { title: '去水印管理', icon: 'Brush' } redirect: '/watermark/video-parse-logs',
meta: { title: '去水印小程序', icon: 'Brush' },
children: [
{
path: 'video-parse-logs',
name: 'VideoParseLogs',
component: () => import('../views/watermark/video-parse-logs.vue'),
meta: { title: '解析日志' }
},
{
path: 'video-parse-unlocks',
name: 'VideoParseUnlocks',
component: () => import('../views/watermark/video-parse-unlocks.vue'),
meta: { title: '广告解锁' }
},
{
path: 'video-download-failures',
name: 'VideoDownloadFailures',
component: () => import('../views/watermark/video-download-failures.vue'),
meta: { title: '下载失败上报' }
}
]
}, },
{ {
path: 'settings', path: 'settings',
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
@@ -0,0 +1,171 @@
<template>
<el-card>
<template #header>
<div class="header-row">
<div class="filters">
<el-input
v-model="query.keyword"
placeholder="搜索失败链接/错误信息/UA"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-input
v-model="query.domain"
placeholder="来源域名"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="domain" label="域名" min-width="140" />
<el-table-column label="失败链接" min-width="280" show-overflow-tooltip>
<template #default="{ row }">
<el-link :href="row.failed_url" target="_blank" type="primary" :underline="false">
{{ row.failed_url }}
</el-link>
</template>
</el-table-column>
<el-table-column label="错误信息" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ row.error_message || '-' }}
</template>
</el-table-column>
<el-table-column prop="client_ip" label="IP" width="140" />
<el-table-column label="UA" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.user_agent || '-' }}
</template>
</el-table-column>
<el-table-column label="上报时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.reported_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="loadData"
@size-change="handleSizeChange"
/>
</div>
</el-card>
</template>
<script setup>
import dayjs from 'dayjs'
import { onMounted, reactive, ref } from 'vue'
import { getVideoDownloadFailures } from '../../api/watermark'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const dateRange = ref([])
const query = reactive({
page: 1,
page_size: 20,
keyword: '',
domain: ''
})
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const buildParams = () => {
const params = {
page: query.page,
page_size: query.page_size,
keyword: query.keyword || undefined,
domain: query.domain || undefined
}
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
params.date_from = dateRange.value[0]
params.date_to = dateRange.value[1]
}
return params
}
const loadData = async () => {
loading.value = true
try {
const res = await getVideoDownloadFailures(buildParams())
const payload = res.data || {}
list.value = payload.list || []
total.value = payload.total || 0
query.page = payload.page || query.page
query.page_size = payload.page_size || query.page_size
} finally {
loading.value = false
}
}
const handleSearch = () => {
query.page = 1
loadData()
}
const resetSearch = () => {
query.keyword = ''
query.domain = ''
dateRange.value = []
handleSearch()
}
const handleSizeChange = () => {
query.page = 1
loadData()
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<template>
<el-card>
<template #header>
<div class="header-row">
<div class="filters">
<el-input
v-model="query.keyword"
placeholder="搜索原文/解析链接/错误信息"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.mini_program_id"
clearable
style="width: 180px"
placeholder="全部小程序"
@change="handleSearch"
>
<el-option
v-for="item in miniProgramOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-input-number
v-model="query.user_id"
:min="1"
:controls="false"
placeholder="用户ID"
style="width: 130px"
/>
<el-select
v-model="query.free_quota_used"
clearable
style="width: 150px"
placeholder="免费次数"
@change="handleSearch"
>
<el-option label="计入免费" :value="true" />
<el-option label="不计入免费" :value="false" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="小程序" min-width="140">
<template #default="{ row }">
{{ row.mini_program_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="user_id" label="用户ID" width="90" />
<el-table-column label="原始内容" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ row.request_content || '-' }}
</template>
</el-table-column>
<el-table-column label="解析链接" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<el-link
v-if="row.parsed_url"
:href="row.parsed_url"
target="_blank"
type="primary"
:underline="false"
>
{{ row.parsed_url }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="免费次数" width="100">
<template #default="{ row }">
<el-tag :type="row.free_quota_used ? 'success' : 'info'">
{{ row.free_quota_used ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时(ms)" width="100" />
<el-table-column label="错误信息" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.error_message || '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="loadData"
@size-change="handleSizeChange"
/>
</div>
</el-card>
</template>
<script setup>
import dayjs from 'dayjs'
import { onMounted, reactive, ref } from 'vue'
import { getMiniPrograms } from '../../api/miniProgram'
import { getVideoParseLogs } from '../../api/watermark'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const miniProgramOptions = ref([])
const dateRange = ref([])
const query = reactive({
page: 1,
page_size: 20,
keyword: '',
mini_program_id: undefined,
user_id: undefined,
free_quota_used: undefined
})
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const loadMiniProgramOptions = async () => {
const res = await getMiniPrograms({ page: 1, page_size: 200 })
const payload = res.data || {}
miniProgramOptions.value = payload.list || []
}
const buildParams = () => {
const params = {
page: query.page,
page_size: query.page_size,
keyword: query.keyword || undefined,
mini_program_id: query.mini_program_id,
user_id: query.user_id,
free_quota_used: query.free_quota_used
}
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
params.date_from = dateRange.value[0]
params.date_to = dateRange.value[1]
}
return params
}
const loadData = async () => {
loading.value = true
try {
const res = await getVideoParseLogs(buildParams())
const payload = res.data || {}
list.value = payload.list || []
total.value = payload.total || 0
query.page = payload.page || query.page
query.page_size = payload.page_size || query.page_size
} finally {
loading.value = false
}
}
const handleSearch = () => {
query.page = 1
loadData()
}
const resetSearch = () => {
query.keyword = ''
query.mini_program_id = undefined
query.user_id = undefined
query.free_quota_used = undefined
dateRange.value = []
handleSearch()
}
const handleSizeChange = () => {
query.page = 1
loadData()
}
onMounted(async () => {
await Promise.all([loadMiniProgramOptions(), loadData()])
})
</script>
<style scoped>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<el-card>
<template #header>
<div class="header-row">
<div class="filters">
<el-select
v-model="query.mini_program_id"
clearable
style="width: 180px"
placeholder="全部小程序"
@change="handleSearch"
>
<el-option
v-for="item in miniProgramOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-input-number
v-model="query.user_id"
:min="1"
:controls="false"
placeholder="用户ID"
style="width: 130px"
/>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="小程序" min-width="160">
<template #default="{ row }">
{{ row.mini_program_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column label="解锁日期" width="140">
<template #default="{ row }">
{{ formatDate(row.unlock_date) }}
</template>
</el-table-column>
<el-table-column label="广告完成时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.ad_watched_at) }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="loadData"
@size-change="handleSizeChange"
/>
</div>
</el-card>
</template>
<script setup>
import dayjs from 'dayjs'
import { onMounted, reactive, ref } from 'vue'
import { getMiniPrograms } from '../../api/miniProgram'
import { getVideoParseUnlocks } from '../../api/watermark'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const miniProgramOptions = ref([])
const dateRange = ref([])
const query = reactive({
page: 1,
page_size: 20,
mini_program_id: undefined,
user_id: undefined
})
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatDate = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD')
}
const loadMiniProgramOptions = async () => {
const res = await getMiniPrograms({ page: 1, page_size: 200 })
const payload = res.data || {}
miniProgramOptions.value = payload.list || []
}
const buildParams = () => {
const params = {
page: query.page,
page_size: query.page_size,
mini_program_id: query.mini_program_id,
user_id: query.user_id
}
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
params.date_from = dateRange.value[0]
params.date_to = dateRange.value[1]
}
return params
}
const loadData = async () => {
loading.value = true
try {
const res = await getVideoParseUnlocks(buildParams())
const payload = res.data || {}
list.value = payload.list || []
total.value = payload.total || 0
query.page = payload.page || query.page
query.page_size = payload.page_size || query.page_size
} finally {
loading.value = false
}
}
const handleSearch = () => {
query.page = 1
loadData()
}
const resetSearch = () => {
query.mini_program_id = undefined
query.user_id = undefined
dateRange.value = []
handleSearch()
}
const handleSizeChange = () => {
query.page = 1
loadData()
}
onMounted(async () => {
await Promise.all([loadMiniProgramOptions(), loadData()])
})
</script>
<style scoped>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>