优化页面、修复BUG
This commit is contained in:
@@ -75,6 +75,7 @@ const isFullWidthPage = computed(() => {
|
|||||||
return path.startsWith('/admin') || path === '/login'
|
return path.startsWith('/admin') || path === '/login'
|
||||||
|| path.startsWith('/blog/')
|
|| path.startsWith('/blog/')
|
||||||
|| path.startsWith('/works/')
|
|| path.startsWith('/works/')
|
||||||
|
|| path.startsWith('/columns')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 应用网站配置到页面标题和meta标签
|
// 应用网站配置到页面标题和meta标签
|
||||||
|
|||||||
@@ -18,24 +18,68 @@
|
|||||||
<template v-else-if="video">
|
<template v-else-if="video">
|
||||||
<h1 class="font-serif text-3xl md:text-4xl text-white mb-6">{{ video.title }}</h1>
|
<h1 class="font-serif text-3xl md:text-4xl text-white mb-6">{{ video.title }}</h1>
|
||||||
|
|
||||||
<!-- 有视频地址时挂载 DPlayer 容器 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="video.videoUrl"
|
v-if="video.videoUrl"
|
||||||
ref="playerRef"
|
ref="playerRef"
|
||||||
class="w-full aspect-video rounded-xl overflow-hidden bg-black mb-8"
|
class="w-full aspect-video rounded-xl overflow-hidden bg-black mb-6"
|
||||||
></div>
|
></div>
|
||||||
<!-- 无视频地址时的占位提示 -->
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="w-full aspect-video rounded-xl overflow-hidden bg-black/40 border border-white/10 flex items-center justify-center mb-8"
|
class="w-full aspect-video rounded-xl overflow-hidden bg-black/40 border border-white/10 flex items-center justify-center mb-6"
|
||||||
>
|
>
|
||||||
<p class="text-art-muted text-sm">暂无视频地址</p>
|
<p class="text-art-muted text-sm">暂无视频地址</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 专辑内前后导航:前 2 + 后 2 -->
|
||||||
|
<div v-if="video.albumContext" class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<p class="text-sm text-art-muted">
|
||||||
|
来自专辑 <span class="text-white">{{ video.albumContext.albumName }}</span>
|
||||||
|
</p>
|
||||||
|
<router-link
|
||||||
|
:to="`/videos/albums/${video.albumContext.albumId}`"
|
||||||
|
class="text-xs text-art-accent hover:underline"
|
||||||
|
>返回专辑</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
<!-- 前序视频 -->
|
||||||
|
<div
|
||||||
|
v-for="pv in video.albumContext.prevVideos"
|
||||||
|
:key="'prev-' + pv.id"
|
||||||
|
class="shrink-0 w-36 cursor-pointer group"
|
||||||
|
@click="router.push(`/videos/${pv.id}`)"
|
||||||
|
>
|
||||||
|
<div class="aspect-video rounded-lg overflow-hidden border border-white/10 group-hover:border-art-accent/50 transition-colors bg-black/40">
|
||||||
|
<img v-if="pv.cover" :src="pv.cover" class="w-full h-full object-cover opacity-80 group-hover:opacity-100" alt="" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-art-muted mt-1 line-clamp-2 group-hover:text-white">{{ pv.title }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- 当前视频高亮 -->
|
||||||
|
<div class="shrink-0 w-36">
|
||||||
|
<div class="aspect-video rounded-lg overflow-hidden border-2 border-art-accent bg-black/40">
|
||||||
|
<img v-if="video.cover" :src="video.cover" class="w-full h-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-art-accent mt-1 line-clamp-2 font-medium">正在播放</p>
|
||||||
|
</div>
|
||||||
|
<!-- 后续视频 -->
|
||||||
|
<div
|
||||||
|
v-for="nv in video.albumContext.nextVideos"
|
||||||
|
:key="'next-' + nv.id"
|
||||||
|
class="shrink-0 w-36 cursor-pointer group"
|
||||||
|
@click="router.push(`/videos/${nv.id}`)"
|
||||||
|
>
|
||||||
|
<div class="aspect-video rounded-lg overflow-hidden border border-white/10 group-hover:border-art-accent/50 transition-colors bg-black/40">
|
||||||
|
<img v-if="nv.cover" :src="nv.cover" class="w-full h-full object-cover opacity-80 group-hover:opacity-100" alt="" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-art-muted mt-1 line-clamp-2 group-hover:text-white">{{ nv.title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="max-w-3xl">
|
<div class="max-w-3xl">
|
||||||
<div class="flex gap-4 text-sm text-art-muted mb-4">
|
<div class="flex gap-4 text-sm text-art-muted mb-4">
|
||||||
<span v-if="video.categoryName">{{ video.categoryName }}</span>
|
<span v-if="video.categoryName">{{ video.categoryName }}</span>
|
||||||
<span>{{ video.createdAt }}</span>
|
<span v-if="video.createdAt">发布于 {{ video.createdAt }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="video.description" class="text-white/80 leading-relaxed whitespace-pre-wrap">{{ video.description }}</p>
|
<p v-if="video.description" class="text-white/80 leading-relaxed whitespace-pre-wrap">{{ video.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,25 +90,26 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import DPlayer from 'dplayer'
|
import DPlayer from 'dplayer'
|
||||||
import 'dplayer/dist/DPlayer.min.css'
|
import 'dplayer/dist/DPlayer.min.css'
|
||||||
import { fetchVideo, type VideoItem } from '../services/api'
|
import { fetchVideo, type VideoDetailItem } from '../services/api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const video = ref<VideoItem | null>(null)
|
const video = ref<VideoDetailItem | null>(null)
|
||||||
const playerRef = ref<HTMLElement | null>(null)
|
const playerRef = ref<HTMLElement | null>(null)
|
||||||
let dp: DPlayer | null = null
|
let dp: DPlayer | null = null
|
||||||
|
|
||||||
/** 销毁 DPlayer 实例,避免内存泄漏 */
|
/** 销毁 DPlayer 实例 */
|
||||||
const destroyPlayer = () => {
|
const destroyPlayer = () => {
|
||||||
if (dp) { dp.destroy(); dp = null }
|
if (dp) { dp.destroy(); dp = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在容器与视频 URL 就绪后创建 DPlayer */
|
/** 创建 DPlayer 播放器 */
|
||||||
const initPlayer = async () => {
|
const initPlayer = async () => {
|
||||||
destroyPlayer()
|
destroyPlayer()
|
||||||
if (!playerRef.value || !video.value?.videoUrl) return
|
if (!playerRef.value || !video.value?.videoUrl) return
|
||||||
@@ -77,7 +122,7 @@ const initPlayer = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加载视频详情数据 */
|
/** 加载视频详情 */
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -92,7 +137,6 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 video、容器 DOM、loading 状态,在 DOM 挂载后再初始化播放器
|
|
||||||
watch([video, playerRef, loading], async ([v, el, isLoading]) => {
|
watch([video, playerRef, loading], async ([v, el, isLoading]) => {
|
||||||
if (isLoading || !v?.videoUrl || !el) return
|
if (isLoading || !v?.videoUrl || !el) return
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
<p class="text-art-muted">精选视频内容与系列专辑。</p>
|
<p class="text-art-muted">精选视频内容与系列专辑。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分类 Tab -->
|
<!-- 分类 Tab 快捷筛选 -->
|
||||||
<div class="flex flex-wrap gap-2 mb-10">
|
<div class="flex flex-wrap gap-2 mb-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-1.5 rounded-full text-sm border transition-colors"
|
class="px-4 py-1.5 rounded-full text-sm border transition-colors"
|
||||||
:class="activeCategory === 0 ? 'border-art-accent text-art-accent bg-art-accent/10' : 'border-white/10 text-art-muted hover:text-white'"
|
:class="activeCategory === 0 ? 'border-art-accent text-art-accent bg-art-accent/10' : 'border-white/10 text-art-muted hover:text-white'"
|
||||||
@click="activeCategory = 0; loadData()"
|
@click="selectCategory(0)"
|
||||||
>全部</button>
|
>全部</button>
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
@@ -20,19 +20,42 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-1.5 rounded-full text-sm border transition-colors"
|
class="px-4 py-1.5 rounded-full text-sm border transition-colors"
|
||||||
:class="activeCategory === cat.id ? 'border-art-accent text-art-accent bg-art-accent/10' : 'border-white/10 text-art-muted hover:text-white'"
|
:class="activeCategory === cat.id ? 'border-art-accent text-art-accent bg-art-accent/10' : 'border-white/10 text-art-muted hover:text-white'"
|
||||||
@click="activeCategory = cat.id; loadData()"
|
@click="selectCategory(cat.id)"
|
||||||
>{{ cat.name }}</button>
|
>{{ cat.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 分类概览卡片 -->
|
||||||
|
<div v-if="categories.length" class="mb-14">
|
||||||
|
<h3 class="font-mono text-xs text-art-muted uppercase tracking-widest mb-6">分类</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="'card-' + cat.id"
|
||||||
|
class="group p-5 rounded-xl border cursor-pointer transition-colors"
|
||||||
|
:class="activeCategory === cat.id ? 'border-art-accent/50 bg-art-accent/5' : 'border-white/10 hover:border-art-accent/30 hover:bg-white/5'"
|
||||||
|
@click="selectCategory(cat.id)"
|
||||||
|
>
|
||||||
|
<h4 class="text-white font-medium group-hover:text-art-accent transition-colors">{{ cat.name }}</h4>
|
||||||
|
<p v-if="cat.description" class="text-xs text-art-muted mt-2 line-clamp-2">{{ cat.description }}</p>
|
||||||
|
<div class="flex gap-4 mt-3 text-xs text-art-muted">
|
||||||
|
<span>{{ cat.videoCount ?? 0 }} 个视频</span>
|
||||||
|
<span>{{ cat.albumCount ?? 0 }} 个专辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!loading && !error" class="mb-10 text-center text-art-muted text-sm py-6 border border-dashed border-white/10 rounded-xl">
|
||||||
|
暂无视频分类,请先在后台创建分类
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center py-20"><div class="animate-spin h-10 w-10 border-t-2 border-art-accent rounded-full"></div></div>
|
<div v-if="loading" class="flex justify-center py-20"><div class="animate-spin h-10 w-10 border-t-2 border-art-accent rounded-full"></div></div>
|
||||||
|
|
||||||
<!-- API 加载失败 -->
|
|
||||||
<div v-else-if="error" class="text-center py-12">
|
<div v-else-if="error" class="text-center py-12">
|
||||||
<p class="text-red-400 mb-4">{{ error }}</p>
|
<p class="text-red-400 mb-4">{{ error }}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2 border border-white/20 rounded-full text-sm text-white hover:border-art-accent hover:text-art-accent transition-colors"
|
class="px-4 py-2 border border-white/20 rounded-full text-sm text-white hover:border-art-accent hover:text-art-accent transition-colors"
|
||||||
@click="loadData"
|
@click="loadAll"
|
||||||
>重试</button>
|
>重试</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,19 +67,45 @@
|
|||||||
<div
|
<div
|
||||||
v-for="album in albums"
|
v-for="album in albums"
|
||||||
:key="album.id"
|
:key="album.id"
|
||||||
class="group cursor-pointer rounded-xl overflow-hidden border border-white/10 hover:border-art-accent/40 transition-colors"
|
class="group rounded-xl overflow-hidden border border-white/10 hover:border-art-accent/40 transition-colors"
|
||||||
@click="router.push(`/videos/albums/${album.id}`)"
|
|
||||||
>
|
>
|
||||||
<div class="aspect-video bg-gray-900 overflow-hidden">
|
<div
|
||||||
|
class="aspect-video bg-gray-900 overflow-hidden cursor-pointer"
|
||||||
|
@click="router.push(`/videos/albums/${album.id}`)"
|
||||||
|
>
|
||||||
<img v-if="album.cover" :src="album.cover" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" alt="" />
|
<img v-if="album.cover" :src="album.cover" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<!-- 专辑预览视频缩略图 -->
|
||||||
|
<div
|
||||||
|
v-if="album.previewVideos?.length"
|
||||||
|
class="flex gap-1 px-2 py-2 bg-black/30 border-t border-white/5 overflow-x-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="pv in album.previewVideos"
|
||||||
|
:key="pv.id"
|
||||||
|
class="shrink-0 w-16 h-10 rounded overflow-hidden cursor-pointer border border-transparent hover:border-art-accent/50"
|
||||||
|
@click.stop="router.push(`/videos/${pv.id}`)"
|
||||||
|
>
|
||||||
|
<img v-if="pv.cover" :src="pv.cover" class="w-full h-full object-cover" alt="" />
|
||||||
|
<div v-else class="w-full h-full bg-white/5 flex items-center justify-center">
|
||||||
|
<Icon name="play" :size="12" class="text-white/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 cursor-pointer" @click="router.push(`/videos/albums/${album.id}`)">
|
||||||
<h4 class="text-white font-medium group-hover:text-art-accent transition-colors">{{ album.name }}</h4>
|
<h4 class="text-white font-medium group-hover:text-art-accent transition-colors">{{ album.name }}</h4>
|
||||||
<p class="text-xs text-art-muted mt-1">{{ album.videoCount ?? 0 }} 个视频</p>
|
<p class="text-xs text-art-muted mt-1">
|
||||||
|
{{ album.videoCount ?? 0 }} 个视频
|
||||||
|
<span v-if="album.latestVideoUpdatedAt" class="ml-2">· 更新于 {{ album.latestVideoUpdatedAt }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="album.categoryName" class="text-xs text-art-muted/70 mt-1">{{ album.categoryName }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="mb-10 text-center text-art-muted text-sm py-8 border border-dashed border-white/10 rounded-xl">
|
||||||
|
暂无专辑
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 最新视频 -->
|
<!-- 最新视频 -->
|
||||||
<div>
|
<div>
|
||||||
@@ -78,7 +127,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h4 class="text-white font-medium line-clamp-2">{{ video.title }}</h4>
|
<h4 class="text-white font-medium line-clamp-2">{{ video.title }}</h4>
|
||||||
<p class="text-xs text-art-muted mt-1">{{ video.categoryName || '未分类' }}</p>
|
<p class="text-xs text-art-muted mt-1">
|
||||||
|
{{ video.categoryName || '未分类' }}
|
||||||
|
<span v-if="video.createdAt" class="ml-2">· 发布于 {{ video.createdAt }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,12 +156,15 @@ const albums = ref<VideoAlbum[]>([])
|
|||||||
const videos = ref<VideoItem[]>([])
|
const videos = ref<VideoItem[]>([])
|
||||||
const activeCategory = ref(0)
|
const activeCategory = ref(0)
|
||||||
|
|
||||||
// 滚动入场动画:animate-slide-down 初始 opacity:0,需 initObserver 添加 .visible
|
|
||||||
const { initObserver } = useScrollAnimation()
|
const { initObserver } = useScrollAnimation()
|
||||||
|
|
||||||
/**
|
/** 切换分类并重新加载数据 */
|
||||||
* 加载视频分类下的专辑与视频列表
|
const selectCategory = (catId: number) => {
|
||||||
*/
|
activeCategory.value = catId
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载专辑与视频列表 */
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -122,17 +177,21 @@ const loadData = async () => {
|
|||||||
console.error('加载视频列表失败:', err)
|
console.error('加载视频列表失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// 数据渲染后触发 IntersectionObserver,使页面从透明变为可见
|
|
||||||
await initObserver()
|
await initObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
/** 加载分类并刷新列表 */
|
||||||
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
categories.value = await fetchVideoCategories()
|
categories.value = await fetchVideoCategories()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
error.value = '加载分类失败,请稍后重试'
|
||||||
console.error('加载视频分类失败:', err)
|
console.error('加载视频分类失败:', err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
await loadData()
|
await loadData()
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="admin-card overflow-hidden">
|
<div class="admin-card overflow-hidden">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>ID</th><th>名称</th><th>分类</th><th>视频数</th><th>状态</th><th class="text-right">操作</th></tr>
|
<tr><th>ID</th><th>名称</th><th>分类</th><th>视频数</th><th>最新视频更新</th><th>状态</th><th class="text-right">操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in list" :key="item.id">
|
<tr v-for="item in list" :key="item.id">
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<td class="text-white">{{ item.name }}</td>
|
<td class="text-white">{{ item.name }}</td>
|
||||||
<td class="text-art-muted">{{ item.categoryName || '-' }}</td>
|
<td class="text-art-muted">{{ item.categoryName || '-' }}</td>
|
||||||
<td class="text-art-muted">{{ item.videoCount ?? 0 }}</td>
|
<td class="text-art-muted">{{ item.videoCount ?? 0 }}</td>
|
||||||
|
<td class="text-xs text-art-muted">{{ item.latestVideoUpdatedAt || '-' }}</td>
|
||||||
<td><span :class="item.isActive ? 'text-green-400' : 'text-red-400'">{{ item.isActive ? '启用' : '禁用' }}</span></td>
|
<td><span :class="item.isActive ? 'text-green-400' : 'text-red-400'">{{ item.isActive ? '启用' : '禁用' }}</span></td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<router-link :to="`/admin/video-albums/${item.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs mr-2">编辑</router-link>
|
<router-link :to="`/admin/video-albums/${item.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs mr-2">编辑</router-link>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full animate-reveal">
|
<div class="w-full animate-reveal">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<!-- 悬浮顶栏:滚动时取消/保存始终可见 -->
|
||||||
|
<div class="sticky top-0 z-20 -mx-6 px-6 py-4 mb-4 bg-art-admin-bg/95 backdrop-blur-md border-b border-white/5 flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-serif italic text-white">{{ isEditing ? '编辑视频' : '新建视频' }}</h1>
|
<h1 class="text-2xl font-serif italic text-white">{{ isEditing ? '编辑视频' : '新建视频' }}</h1>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="button" class="admin-btn-secondary" @click="router.push('/admin/videos')">取消</button>
|
<button type="button" class="admin-btn-secondary" @click="router.push('/admin/videos')">取消</button>
|
||||||
<button type="button" class="admin-btn-primary" :disabled="submitting" @click="handleSubmit">{{ submitting ? '提交中...' : '保存' }}</button>
|
<button type="button" class="admin-btn-primary" :disabled="submitting" @click="handleSubmit">{{ submitting ? '提交中...' : '保存' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card p-6 max-w-3xl space-y-5">
|
<div class="admin-card p-6 max-w-3xl space-y-5">
|
||||||
<div><label class="block text-xs text-art-muted mb-1">标题</label><input v-model="form.title" class="admin-input" required /></div>
|
<div><label class="block text-xs text-art-muted mb-1">标题</label><input v-model="form.title" class="admin-input" required /></div>
|
||||||
<div><label class="block text-xs text-art-muted mb-1">分类</label>
|
<div><label class="block text-xs text-art-muted mb-1">分类</label>
|
||||||
@@ -15,6 +17,18 @@
|
|||||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 所属专辑多选 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-art-muted mb-2">所属专辑(可多选)</label>
|
||||||
|
<div v-if="albums.length" class="max-h-48 overflow-y-auto space-y-2 border border-white/10 rounded-lg p-3">
|
||||||
|
<label v-for="a in albums" :key="a.id" class="flex items-center gap-3 p-2 rounded hover:bg-white/5 cursor-pointer">
|
||||||
|
<input type="checkbox" :value="a.id" v-model="selectedAlbumIds" />
|
||||||
|
<span class="text-sm text-white">{{ a.name }}</span>
|
||||||
|
<span v-if="a.categoryName" class="text-xs text-art-muted">({{ a.categoryName }})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-art-muted">暂无专辑,请先在「视频专辑」中创建</p>
|
||||||
|
</div>
|
||||||
<div><label class="block text-xs text-art-muted mb-1">视频文件</label><VideoUpload v-model="form.videoUrl" :category-id="1" /></div>
|
<div><label class="block text-xs text-art-muted mb-1">视频文件</label><VideoUpload v-model="form.videoUrl" :category-id="1" /></div>
|
||||||
<div><label class="block text-xs text-art-muted mb-1">封面图</label><ImageUpload v-model="form.cover" :category-id="1" /></div>
|
<div><label class="block text-xs text-art-muted mb-1">封面图</label><ImageUpload v-model="form.cover" :category-id="1" /></div>
|
||||||
<div><label class="block text-xs text-art-muted mb-1">描述</label><textarea v-model="form.description" rows="4" class="admin-input resize-none" /></div>
|
<div><label class="block text-xs text-art-muted mb-1">描述</label><textarea v-model="form.description" rows="4" class="admin-input resize-none" /></div>
|
||||||
@@ -28,7 +42,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, onMounted } from 'vue'
|
import { reactive, ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getAdminVideoCategories, getAdminVideo, createVideo, updateVideo } from '../../services/api'
|
import { getAdminVideoCategories, getAdminVideoAlbums, getAdminVideo, createVideo, updateVideo, type VideoAlbum } from '../../services/api'
|
||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
import ImageUpload from '../../components/admin/ImageUpload.vue'
|
import ImageUpload from '../../components/admin/ImageUpload.vue'
|
||||||
import VideoUpload from '../../components/admin/VideoUpload.vue'
|
import VideoUpload from '../../components/admin/VideoUpload.vue'
|
||||||
@@ -39,26 +53,40 @@ const toast = useToast()
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const isEditing = computed(() => !!route.params.id)
|
const isEditing = computed(() => !!route.params.id)
|
||||||
const categories = ref<{ id: number; name: string }[]>([])
|
const categories = ref<{ id: number; name: string }[]>([])
|
||||||
|
const albums = ref<VideoAlbum[]>([])
|
||||||
|
const selectedAlbumIds = ref<number[]>([])
|
||||||
const form = reactive({ title: '', description: '', videoUrl: '', cover: '', poster: '', categoryId: 0, isPublished: 1 })
|
const form = reactive({ title: '', description: '', videoUrl: '', cover: '', poster: '', categoryId: 0, isPublished: 1 })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
categories.value = await getAdminVideoCategories()
|
categories.value = await getAdminVideoCategories()
|
||||||
|
albums.value = await getAdminVideoAlbums()
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
const item = await getAdminVideo(route.params.id as string)
|
const item = await getAdminVideo(route.params.id as string)
|
||||||
Object.assign(form, { title: item.title, description: item.description, videoUrl: item.videoUrl, cover: item.cover, poster: item.poster, categoryId: item.categoryId, isPublished: item.isPublished })
|
Object.assign(form, {
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
videoUrl: item.videoUrl,
|
||||||
|
cover: item.cover,
|
||||||
|
poster: item.poster,
|
||||||
|
categoryId: item.categoryId,
|
||||||
|
isPublished: item.isPublished,
|
||||||
|
})
|
||||||
|
selectedAlbumIds.value = item.albumIds ?? []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 提交视频表单,同步专辑关联 */
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.videoUrl) { toast.error('请上传视频'); return }
|
if (!form.videoUrl) { toast.error('请上传视频'); return }
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
form.poster = form.cover || form.poster
|
form.poster = form.cover || form.poster
|
||||||
|
const payload = { ...form, albumIds: selectedAlbumIds.value }
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await updateVideo(route.params.id as string, form)
|
await updateVideo(route.params.id as string, payload)
|
||||||
toast.success('更新成功')
|
toast.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createVideo(form)
|
await createVideo(payload)
|
||||||
toast.success('创建成功')
|
toast.success('创建成功')
|
||||||
}
|
}
|
||||||
router.push('/admin/videos')
|
router.push('/admin/videos')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="admin-card overflow-hidden">
|
<div class="admin-card overflow-hidden">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>标题</th><th>分类</th><th>状态</th><th>创建时间</th><th class="text-right">操作</th></tr>
|
<tr><th>标题</th><th>分类</th><th>状态</th><th>发布时间</th><th class="text-right">操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in list" :key="item.id">
|
<tr v-for="item in list" :key="item.id">
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export interface VideoAlbum {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
videoCount?: number
|
videoCount?: number
|
||||||
|
latestVideoUpdatedAt?: string
|
||||||
|
previewVideos?: VideoItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoItem {
|
export interface VideoItem {
|
||||||
@@ -188,6 +190,24 @@ export interface VideoItem {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 专辑内前后导航上下文 */
|
||||||
|
export interface VideoAlbumNavContext {
|
||||||
|
albumId: number
|
||||||
|
albumName: string
|
||||||
|
prevVideos: VideoItem[]
|
||||||
|
nextVideos: VideoItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频详情(含专辑导航) */
|
||||||
|
export interface VideoDetailItem extends VideoItem {
|
||||||
|
albumContext?: VideoAlbumNavContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理端视频(含所属专辑) */
|
||||||
|
export interface AdminVideoItem extends VideoItem {
|
||||||
|
albumIds?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
// 分类相关类型
|
// 分类相关类型
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number
|
id: number
|
||||||
@@ -2140,8 +2160,8 @@ export const fetchVideos = async (categoryId?: number): Promise<VideoItem[]> =>
|
|||||||
return Array.isArray(result) ? result : []
|
return Array.isArray(result) ? result : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchVideo = async (id: string): Promise<VideoItem> => {
|
export const fetchVideo = async (id: string): Promise<VideoDetailItem> => {
|
||||||
const result = await fetchJson<VideoItem>(`${API_BASE}/videos/${id}`)
|
const result = await fetchJson<VideoDetailItem>(`${API_BASE}/videos/${id}`)
|
||||||
if (!result) throw new Error('Video not found')
|
if (!result) throw new Error('Video not found')
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -2205,30 +2225,30 @@ export const getAlbumVideoIds = async (albumId: number): Promise<string[]> => {
|
|||||||
return Array.isArray(result) ? result : []
|
return Array.isArray(result) ? result : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAdminVideos = async (categoryId?: number): Promise<VideoItem[]> => {
|
export const getAdminVideos = async (categoryId?: number): Promise<AdminVideoItem[]> => {
|
||||||
const q = categoryId ? `?categoryId=${categoryId}` : ''
|
const q = categoryId ? `?categoryId=${categoryId}` : ''
|
||||||
const response = await authFetch(`${API_BASE}/admin/videos${q}`, { headers: getAuthHeaders() })
|
const response = await authFetch(`${API_BASE}/admin/videos${q}`, { headers: getAuthHeaders() })
|
||||||
const result = await parseApiResponse<VideoItem[]>(response)
|
const result = await parseApiResponse<AdminVideoItem[]>(response)
|
||||||
return Array.isArray(result) ? result : []
|
return Array.isArray(result) ? result : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAdminVideo = async (id: string): Promise<VideoItem> => {
|
export const getAdminVideo = async (id: string): Promise<AdminVideoItem> => {
|
||||||
const response = await authFetch(`${API_BASE}/admin/videos/${id}`, { headers: getAuthHeaders() })
|
const response = await authFetch(`${API_BASE}/admin/videos/${id}`, { headers: getAuthHeaders() })
|
||||||
return await parseApiResponse<VideoItem>(response)
|
return await parseApiResponse<AdminVideoItem>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createVideo = async (data: Partial<VideoItem>): Promise<VideoItem> => {
|
export const createVideo = async (data: Partial<VideoItem> & { albumIds?: number[] }): Promise<AdminVideoItem> => {
|
||||||
const response = await authFetch(`${API_BASE}/admin/videos`, {
|
const response = await authFetch(`${API_BASE}/admin/videos`, {
|
||||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(data),
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
return await parseApiResponse<VideoItem>(response)
|
return await parseApiResponse<AdminVideoItem>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateVideo = async (id: string, data: Partial<VideoItem>): Promise<VideoItem> => {
|
export const updateVideo = async (id: string, data: Partial<VideoItem> & { albumIds?: number[] }): Promise<AdminVideoItem> => {
|
||||||
const response = await authFetch(`${API_BASE}/admin/videos/${id}`, {
|
const response = await authFetch(`${API_BASE}/admin/videos/${id}`, {
|
||||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(data),
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
return await parseApiResponse<VideoItem>(response)
|
return await parseApiResponse<AdminVideoItem>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteVideo = async (id: string): Promise<void> => {
|
export const deleteVideo = async (id: string): Promise<void> => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func GetVideoAlbums(c *gin.Context) {
|
|||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoAlbumsResponse(list))
|
utils.Success(c, repositories.BuildVideoAlbumsResponse(list, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVideoAlbumByID 获取专辑详情
|
// GetVideoAlbumByID 获取专辑详情
|
||||||
@@ -49,7 +49,7 @@ func GetVideoAlbumByID(c *gin.Context) {
|
|||||||
utils.Error(c, 404, "Album not found")
|
utils.Error(c, 404, "Album not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoAlbumResponse(album))
|
utils.Success(c, repositories.BuildVideoAlbumResponse(album, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbumVideos 获取专辑内视频
|
// GetAlbumVideos 获取专辑内视频
|
||||||
@@ -90,7 +90,7 @@ func GetVideoByID(c *gin.Context) {
|
|||||||
utils.Error(c, 404, "Video not found")
|
utils.Error(c, 404, "Video not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoResponse(video))
|
utils.Success(c, repositories.BuildVideoDetailResponse(video))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 管理 API — 分类 ==========
|
// ========== 管理 API — 分类 ==========
|
||||||
@@ -158,7 +158,7 @@ func AdminGetVideoAlbums(c *gin.Context) {
|
|||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoAlbumsResponse(list))
|
utils.Success(c, repositories.BuildVideoAlbumsResponse(list, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminCreateVideoAlbum(c *gin.Context) {
|
func AdminCreateVideoAlbum(c *gin.Context) {
|
||||||
@@ -171,7 +171,7 @@ func AdminCreateVideoAlbum(c *gin.Context) {
|
|||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoAlbumResponse(&item))
|
utils.Success(c, repositories.BuildVideoAlbumResponse(&item, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminUpdateVideoAlbum(c *gin.Context) {
|
func AdminUpdateVideoAlbum(c *gin.Context) {
|
||||||
@@ -199,7 +199,7 @@ func AdminUpdateVideoAlbum(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoAlbumResponse(&req.VideoAlbum))
|
utils.Success(c, repositories.BuildVideoAlbumResponse(&req.VideoAlbum, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminDeleteVideoAlbum(c *gin.Context) {
|
func AdminDeleteVideoAlbum(c *gin.Context) {
|
||||||
@@ -277,38 +277,61 @@ func AdminGetVideos(c *gin.Context) {
|
|||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideosResponse(list))
|
utils.Success(c, repositories.BuildAdminVideosResponse(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminCreateVideo(c *gin.Context) {
|
func AdminCreateVideo(c *gin.Context) {
|
||||||
var item models.Video
|
var req struct {
|
||||||
if err := c.ShouldBindJSON(&item); err != nil {
|
models.Video
|
||||||
|
AlbumIDs []uint `json:"albumIds"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
utils.Error(c, 400, err.Error())
|
utils.Error(c, 400, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if item.ID == "" {
|
if req.Video.ID == "" {
|
||||||
item.ID = "video_" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
req.Video.ID = "video_" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
}
|
}
|
||||||
if err := repositories.CreateVideo(&item); err != nil {
|
if err := repositories.CreateVideo(&req.Video); err != nil {
|
||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoResponse(&item))
|
if req.AlbumIDs != nil {
|
||||||
|
if err := repositories.SyncVideoAlbums(req.Video.ID, req.AlbumIDs); err != nil {
|
||||||
|
utils.ServerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
utils.Success(c, repositories.BuildAdminVideoResponse(&req.Video))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminUpdateVideo(c *gin.Context) {
|
func AdminUpdateVideo(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
var item models.Video
|
var req struct {
|
||||||
if err := c.ShouldBindJSON(&item); err != nil {
|
models.Video
|
||||||
|
AlbumIDs []uint `json:"albumIds"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
utils.Error(c, 400, err.Error())
|
utils.Error(c, 400, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item.ID = id
|
req.Video.ID = id
|
||||||
if err := repositories.UpdateVideo(&item); err != nil {
|
if err := repositories.UpdateVideo(&req.Video); err != nil {
|
||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoResponse(&item))
|
if req.AlbumIDs != nil {
|
||||||
|
if err := repositories.SyncVideoAlbums(id, req.AlbumIDs); err != nil {
|
||||||
|
utils.ServerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
video, _ := repositories.GetVideoByID(id)
|
||||||
|
if video == nil {
|
||||||
|
utils.Error(c, 404, "Video not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, repositories.BuildAdminVideoResponse(video))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminDeleteVideo(c *gin.Context) {
|
func AdminDeleteVideo(c *gin.Context) {
|
||||||
@@ -331,5 +354,5 @@ func AdminGetVideoByID(c *gin.Context) {
|
|||||||
utils.Error(c, 404, "Video not found")
|
utils.Error(c, 404, "Video not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, repositories.BuildVideoResponse(video))
|
utils.Success(c, repositories.BuildAdminVideoResponse(video))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,17 +83,39 @@ func (va *VideoAlbum) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
|
|
||||||
// VideoAlbumResponse 视频专辑 API 响应
|
// VideoAlbumResponse 视频专辑 API 响应
|
||||||
type VideoAlbumResponse struct {
|
type VideoAlbumResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CategoryID uint `json:"categoryId"`
|
CategoryID uint `json:"categoryId"`
|
||||||
CategoryName string `json:"categoryName,omitempty"`
|
CategoryName string `json:"categoryName,omitempty"`
|
||||||
IsActive int `json:"isActive"`
|
IsActive int `json:"isActive"`
|
||||||
SortOrder uint `json:"sortOrder"`
|
SortOrder uint `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
VideoCount int64 `json:"videoCount,omitempty"`
|
VideoCount int64 `json:"videoCount,omitempty"`
|
||||||
|
LatestVideoUpdatedAt string `json:"latestVideoUpdatedAt,omitempty"`
|
||||||
|
PreviewVideos []VideoResponse `json:"previewVideos,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoAlbumNavContext 视频详情页专辑内前后导航
|
||||||
|
type VideoAlbumNavContext struct {
|
||||||
|
AlbumID uint `json:"albumId"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
PrevVideos []VideoResponse `json:"prevVideos"`
|
||||||
|
NextVideos []VideoResponse `json:"nextVideos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoDetailResponse 视频详情 API 响应(含专辑导航)
|
||||||
|
type VideoDetailResponse struct {
|
||||||
|
VideoResponse
|
||||||
|
AlbumContext *VideoAlbumNavContext `json:"albumContext,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminVideoResponse 管理端视频响应(含所属专辑 ID)
|
||||||
|
type AdminVideoResponse struct {
|
||||||
|
VideoResponse
|
||||||
|
AlbumIDs []uint `json:"albumIds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video 视频模型
|
// Video 视频模型
|
||||||
|
|||||||
@@ -139,21 +139,57 @@ func DeleteVideoAlbum(id uint) error {
|
|||||||
Update("deleted_at", time.Now().Unix()).Error
|
Update("deleted_at", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAlbumVideoCount(albumID uint) int64 {
|
// GetAlbumVideoCount 统计专辑内视频数量;publishedOnly 为 true 时仅统计已发布视频
|
||||||
|
func GetAlbumVideoCount(albumID uint, publishedOnly bool) int64 {
|
||||||
var count int64
|
var count int64
|
||||||
config.DB.Model(&models.AlbumVideo{}).
|
q := config.DB.Model(&models.AlbumVideo{}).
|
||||||
Joins("JOIN videos v ON v.id = album_videos.video_id").
|
Joins("JOIN videos v ON v.id = album_videos.video_id").
|
||||||
Where("album_videos.album_id = ? AND v.deleted_at = ? AND v.is_published = ?", albumID, 0, 1).
|
Where("album_videos.album_id = ? AND v.deleted_at = ?", albumID, 0)
|
||||||
Count(&count)
|
if publishedOnly {
|
||||||
|
q = q.Where("v.is_published = ?", 1)
|
||||||
|
}
|
||||||
|
q.Count(&count)
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildVideoAlbumResponse(item *models.VideoAlbum) models.VideoAlbumResponse {
|
// GetAlbumLatestVideoUpdatedAt 获取专辑内视频最新更新时间戳
|
||||||
|
func GetAlbumLatestVideoUpdatedAt(albumID uint, publishedOnly bool) int64 {
|
||||||
|
var ts int64
|
||||||
|
q := config.DB.Model(&models.Video{}).
|
||||||
|
Select("COALESCE(MAX(videos.updated_at), 0)").
|
||||||
|
Joins("JOIN album_videos av ON av.video_id = videos.id").
|
||||||
|
Where("av.album_id = ? AND videos.deleted_at = ?", albumID, 0)
|
||||||
|
if publishedOnly {
|
||||||
|
q = q.Where("videos.is_published = ?", 1)
|
||||||
|
}
|
||||||
|
q.Scan(&ts)
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbumPreviewVideos 获取专辑预览视频列表(按 sort_order,最多 limit 条)
|
||||||
|
func GetAlbumPreviewVideos(albumID uint, limit int, publishedOnly bool) ([]models.Video, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
var list []models.Video
|
||||||
|
q := config.DB.Model(&models.Video{}).
|
||||||
|
Joins("JOIN album_videos av ON av.video_id = videos.id").
|
||||||
|
Where("av.album_id = ? AND videos.deleted_at = ?", albumID, 0)
|
||||||
|
if publishedOnly {
|
||||||
|
q = q.Where("videos.is_published = ?", 1)
|
||||||
|
}
|
||||||
|
err := q.Order("av.sort_order ASC, av.created_at ASC").Limit(limit).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildVideoAlbumResponse 构建专辑响应;publishedOnly 控制统计与预览是否仅含已发布视频
|
||||||
|
func BuildVideoAlbumResponse(item *models.VideoAlbum, publishedOnly bool) models.VideoAlbumResponse {
|
||||||
categoryName := ""
|
categoryName := ""
|
||||||
if cat, _ := GetVideoCategoryByID(item.CategoryID); cat != nil {
|
if cat, _ := GetVideoCategoryByID(item.CategoryID); cat != nil {
|
||||||
categoryName = cat.Name
|
categoryName = cat.Name
|
||||||
}
|
}
|
||||||
return models.VideoAlbumResponse{
|
latestTs := GetAlbumLatestVideoUpdatedAt(item.ID, publishedOnly)
|
||||||
|
resp := models.VideoAlbumResponse{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Description: item.Description,
|
Description: item.Description,
|
||||||
@@ -164,14 +200,21 @@ func BuildVideoAlbumResponse(item *models.VideoAlbum) models.VideoAlbumResponse
|
|||||||
SortOrder: item.SortOrder,
|
SortOrder: item.SortOrder,
|
||||||
CreatedAt: formatTimestamp(item.CreatedAt),
|
CreatedAt: formatTimestamp(item.CreatedAt),
|
||||||
UpdatedAt: formatTimestamp(item.UpdatedAt),
|
UpdatedAt: formatTimestamp(item.UpdatedAt),
|
||||||
VideoCount: GetAlbumVideoCount(item.ID),
|
VideoCount: GetAlbumVideoCount(item.ID, publishedOnly),
|
||||||
}
|
}
|
||||||
|
if latestTs > 0 {
|
||||||
|
resp.LatestVideoUpdatedAt = formatTimestamp(latestTs)
|
||||||
|
}
|
||||||
|
if previews, err := GetAlbumPreviewVideos(item.ID, 5, publishedOnly); err == nil && len(previews) > 0 {
|
||||||
|
resp.PreviewVideos = BuildVideosResponse(previews)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildVideoAlbumsResponse(list []models.VideoAlbum) []models.VideoAlbumResponse {
|
func BuildVideoAlbumsResponse(list []models.VideoAlbum, publishedOnly bool) []models.VideoAlbumResponse {
|
||||||
res := make([]models.VideoAlbumResponse, 0, len(list))
|
res := make([]models.VideoAlbumResponse, 0, len(list))
|
||||||
for i := range list {
|
for i := range list {
|
||||||
res = append(res, BuildVideoAlbumResponse(&list[i]))
|
res = append(res, BuildVideoAlbumResponse(&list[i], publishedOnly))
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
@@ -318,3 +361,120 @@ func SyncAlbumVideos(albumID uint, videoIDs []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAlbumIDsByVideoID 查询视频所属专辑 ID 列表
|
||||||
|
func GetAlbumIDsByVideoID(videoID string) ([]uint, error) {
|
||||||
|
var ids []uint
|
||||||
|
err := config.DB.Model(&models.AlbumVideo{}).
|
||||||
|
Where("video_id = ?", videoID).
|
||||||
|
Pluck("album_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrimaryActiveAlbumForVideo 取视频关联的第一个启用专辑(按专辑 sort_order)
|
||||||
|
func GetPrimaryActiveAlbumForVideo(videoID string) (*models.VideoAlbum, error) {
|
||||||
|
var album models.VideoAlbum
|
||||||
|
err := config.DB.Model(&models.VideoAlbum{}).
|
||||||
|
Joins("JOIN album_videos av ON av.album_id = video_albums.id").
|
||||||
|
Where("av.video_id = ? AND video_albums.deleted_at = ? AND video_albums.is_active = ?", videoID, 0, 1).
|
||||||
|
Order("video_albums.sort_order ASC, video_albums.id ASC").
|
||||||
|
First(&album).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &album, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbumVideoNeighbors 获取专辑内当前视频的前后邻居(按 sort_order)
|
||||||
|
func GetAlbumVideoNeighbors(albumID uint, videoID string, before, after int, publishedOnly bool) (prev, next []models.Video, err error) {
|
||||||
|
list, err := GetVideosByAlbumID(albumID, publishedOnly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for i, v := range list {
|
||||||
|
if v.ID == videoID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
start := idx - before
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
prev = list[start:idx]
|
||||||
|
end := idx + after + 1
|
||||||
|
if end > len(list) {
|
||||||
|
end = len(list)
|
||||||
|
}
|
||||||
|
next = list[idx+1 : end]
|
||||||
|
return prev, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildVideoDetailResponse 构建视频详情响应(含专辑前后导航)
|
||||||
|
func BuildVideoDetailResponse(item *models.Video) models.VideoDetailResponse {
|
||||||
|
resp := models.VideoDetailResponse{
|
||||||
|
VideoResponse: BuildVideoResponse(item),
|
||||||
|
}
|
||||||
|
album, err := GetPrimaryActiveAlbumForVideo(item.ID)
|
||||||
|
if err != nil || album == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
prev, next, err := GetAlbumVideoNeighbors(album.ID, item.ID, 2, 2, true)
|
||||||
|
if err != nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp.AlbumContext = &models.VideoAlbumNavContext{
|
||||||
|
AlbumID: album.ID,
|
||||||
|
AlbumName: album.Name,
|
||||||
|
PrevVideos: BuildVideosResponse(prev),
|
||||||
|
NextVideos: BuildVideosResponse(next),
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbumIDsByVideoIDList 供管理端读取视频所属专辑
|
||||||
|
func GetAlbumIDsByVideoIDList(videoID string) []uint {
|
||||||
|
ids, err := GetAlbumIDsByVideoID(videoID)
|
||||||
|
if err != nil {
|
||||||
|
return []uint{}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAdminVideoResponse 构建管理端视频响应(含 albumIds)
|
||||||
|
func BuildAdminVideoResponse(item *models.Video) models.AdminVideoResponse {
|
||||||
|
return models.AdminVideoResponse{
|
||||||
|
VideoResponse: BuildVideoResponse(item),
|
||||||
|
AlbumIDs: GetAlbumIDsByVideoIDList(item.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAdminVideosResponse 批量构建管理端视频响应
|
||||||
|
func BuildAdminVideosResponse(list []models.Video) []models.AdminVideoResponse {
|
||||||
|
res := make([]models.AdminVideoResponse, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
res = append(res, BuildAdminVideoResponse(&list[i]))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncVideoAlbums 同步视频所属专辑(先删后建)
|
||||||
|
func SyncVideoAlbums(videoID string, albumIDs []uint) error {
|
||||||
|
if err := config.DB.Where("video_id = ?", videoID).Delete(&models.AlbumVideo{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, albumID := range albumIDs {
|
||||||
|
if albumID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := AddVideoToAlbum(albumID, videoID, uint(i+1)); err != nil {
|
||||||
|
log.Printf("SyncVideoAlbums error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user