优化页面、修复BUG

This commit is contained in:
李琦
2026-07-06 08:19:47 +08:00
parent e1f852c295
commit 9c99f96e94
10 changed files with 445 additions and 87 deletions

View File

@@ -75,6 +75,7 @@ const isFullWidthPage = computed(() => {
return path.startsWith('/admin') || path === '/login'
|| path.startsWith('/blog/')
|| path.startsWith('/works/')
|| path.startsWith('/columns')
})
// 应用网站配置到页面标题和meta标签

View File

@@ -18,24 +18,68 @@
<template v-else-if="video">
<h1 class="font-serif text-3xl md:text-4xl text-white mb-6">{{ video.title }}</h1>
<!-- 有视频地址时挂载 DPlayer 容器 -->
<div
v-if="video.videoUrl"
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
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>
</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="flex gap-4 text-sm text-art-muted mb-4">
<span v-if="video.categoryName">{{ video.categoryName }}</span>
<span>{{ video.createdAt }}</span>
<span v-if="video.createdAt">发布于 {{ video.createdAt }}</span>
</div>
<p v-if="video.description" class="text-white/80 leading-relaxed whitespace-pre-wrap">{{ video.description }}</p>
</div>
@@ -46,25 +90,26 @@
<script setup lang="ts">
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/dist/DPlayer.min.css'
import { fetchVideo, type VideoItem } from '../services/api'
import { fetchVideo, type VideoDetailItem } from '../services/api'
import Icon from '../components/Icon.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const video = ref<VideoItem | null>(null)
const video = ref<VideoDetailItem | null>(null)
const playerRef = ref<HTMLElement | null>(null)
let dp: DPlayer | null = null
/** 销毁 DPlayer 实例,避免内存泄漏 */
/** 销毁 DPlayer 实例 */
const destroyPlayer = () => {
if (dp) { dp.destroy(); dp = null }
}
/** 在容器与视频 URL 就绪后创建 DPlayer */
/** 创建 DPlayer 播放器 */
const initPlayer = async () => {
destroyPlayer()
if (!playerRef.value || !video.value?.videoUrl) return
@@ -77,7 +122,7 @@ const initPlayer = async () => {
})
}
/** 加载视频详情数据 */
/** 加载视频详情 */
const load = async () => {
loading.value = true
error.value = ''
@@ -92,7 +137,6 @@ const load = async () => {
}
}
// 监听 video、容器 DOM、loading 状态,在 DOM 挂载后再初始化播放器
watch([video, playerRef, loading], async ([v, el, isLoading]) => {
if (isLoading || !v?.videoUrl || !el) return
await nextTick()

View File

@@ -6,13 +6,13 @@
<p class="text-art-muted">精选视频内容与系列专辑</p>
</div>
<!-- 分类 Tab -->
<div class="flex flex-wrap gap-2 mb-10">
<!-- 分类 Tab 快捷筛选 -->
<div class="flex flex-wrap gap-2 mb-8">
<button
type="button"
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'"
@click="activeCategory = 0; loadData()"
@click="selectCategory(0)"
>全部</button>
<button
v-for="cat in categories"
@@ -20,19 +20,42 @@
type="button"
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'"
@click="activeCategory = cat.id; loadData()"
@click="selectCategory(cat.id)"
>{{ cat.name }}</button>
</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>
<!-- API 加载失败 -->
<div v-else-if="error" class="text-center py-12">
<p class="text-red-400 mb-4">{{ error }}</p>
<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"
@click="loadData"
@click="loadAll"
>重试</button>
</div>
@@ -44,19 +67,45 @@
<div
v-for="album in albums"
:key="album.id"
class="group cursor-pointer rounded-xl overflow-hidden border border-white/10 hover:border-art-accent/40 transition-colors"
@click="router.push(`/videos/albums/${album.id}`)"
class="group rounded-xl overflow-hidden border border-white/10 hover:border-art-accent/40 transition-colors"
>
<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="" />
</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>
<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 v-else class="mb-10 text-center text-art-muted text-sm py-8 border border-dashed border-white/10 rounded-xl">
暂无专辑
</div>
<!-- 最新视频 -->
<div>
@@ -78,7 +127,10 @@
</div>
<div class="p-4">
<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>
@@ -104,12 +156,15 @@ const albums = ref<VideoAlbum[]>([])
const videos = ref<VideoItem[]>([])
const activeCategory = ref(0)
// 滚动入场动画animate-slide-down 初始 opacity:0需 initObserver 添加 .visible
const { initObserver } = useScrollAnimation()
/**
* 加载视频分类下的专辑与视频列表
*/
/** 切换分类并重新加载数据 */
const selectCategory = (catId: number) => {
activeCategory.value = catId
loadData()
}
/** 加载专辑与视频列表 */
const loadData = async () => {
loading.value = true
error.value = ''
@@ -122,17 +177,21 @@ const loadData = async () => {
console.error('加载视频列表失败:', err)
} finally {
loading.value = false
// 数据渲染后触发 IntersectionObserver使页面从透明变为可见
await initObserver()
}
}
onMounted(async () => {
/** 加载分类并刷新列表 */
const loadAll = async () => {
try {
categories.value = await fetchVideoCategories()
} catch (err) {
error.value = '加载分类失败,请稍后重试'
console.error('加载视频分类失败:', err)
return
}
await loadData()
})
}
onMounted(loadAll)
</script>

View File

@@ -7,7 +7,7 @@
<div class="admin-card overflow-hidden">
<table class="admin-table">
<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>
<tbody>
<tr v-for="item in list" :key="item.id">
@@ -15,6 +15,7 @@
<td class="text-white">{{ item.name }}</td>
<td class="text-art-muted">{{ item.categoryName || '-' }}</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 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>

View File

@@ -1,12 +1,14 @@
<template>
<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>
<div class="flex gap-3">
<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>
</div>
</div>
<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>
@@ -15,6 +17,18 @@
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</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><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>
@@ -28,7 +42,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
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 ImageUpload from '../../components/admin/ImageUpload.vue'
import VideoUpload from '../../components/admin/VideoUpload.vue'
@@ -39,26 +53,40 @@ const toast = useToast()
const submitting = ref(false)
const isEditing = computed(() => !!route.params.id)
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 })
onMounted(async () => {
categories.value = await getAdminVideoCategories()
albums.value = await getAdminVideoAlbums()
if (isEditing.value) {
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 () => {
if (!form.videoUrl) { toast.error('请上传视频'); return }
submitting.value = true
try {
form.poster = form.cover || form.poster
const payload = { ...form, albumIds: selectedAlbumIds.value }
if (isEditing.value) {
await updateVideo(route.params.id as string, form)
await updateVideo(route.params.id as string, payload)
toast.success('更新成功')
} else {
await createVideo(form)
await createVideo(payload)
toast.success('创建成功')
}
router.push('/admin/videos')

View File

@@ -7,7 +7,7 @@
<div class="admin-card overflow-hidden">
<table class="admin-table">
<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>
<tbody>
<tr v-for="item in list" :key="item.id">

View File

@@ -171,6 +171,8 @@ export interface VideoAlbum {
createdAt: string
updatedAt: string
videoCount?: number
latestVideoUpdatedAt?: string
previewVideos?: VideoItem[]
}
export interface VideoItem {
@@ -188,6 +190,24 @@ export interface VideoItem {
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 {
id: number
@@ -2140,8 +2160,8 @@ export const fetchVideos = async (categoryId?: number): Promise<VideoItem[]> =>
return Array.isArray(result) ? result : []
}
export const fetchVideo = async (id: string): Promise<VideoItem> => {
const result = await fetchJson<VideoItem>(`${API_BASE}/videos/${id}`)
export const fetchVideo = async (id: string): Promise<VideoDetailItem> => {
const result = await fetchJson<VideoDetailItem>(`${API_BASE}/videos/${id}`)
if (!result) throw new Error('Video not found')
return result
}
@@ -2205,30 +2225,30 @@ export const getAlbumVideoIds = async (albumId: number): Promise<string[]> => {
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 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 : []
}
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() })
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`, {
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}`, {
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> => {

View File

@@ -30,7 +30,7 @@ func GetVideoAlbums(c *gin.Context) {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoAlbumsResponse(list))
utils.Success(c, repositories.BuildVideoAlbumsResponse(list, true))
}
// GetVideoAlbumByID 获取专辑详情
@@ -49,7 +49,7 @@ func GetVideoAlbumByID(c *gin.Context) {
utils.Error(c, 404, "Album not found")
return
}
utils.Success(c, repositories.BuildVideoAlbumResponse(album))
utils.Success(c, repositories.BuildVideoAlbumResponse(album, true))
}
// GetAlbumVideos 获取专辑内视频
@@ -90,7 +90,7 @@ func GetVideoByID(c *gin.Context) {
utils.Error(c, 404, "Video not found")
return
}
utils.Success(c, repositories.BuildVideoResponse(video))
utils.Success(c, repositories.BuildVideoDetailResponse(video))
}
// ========== 管理 API — 分类 ==========
@@ -158,7 +158,7 @@ func AdminGetVideoAlbums(c *gin.Context) {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoAlbumsResponse(list))
utils.Success(c, repositories.BuildVideoAlbumsResponse(list, false))
}
func AdminCreateVideoAlbum(c *gin.Context) {
@@ -171,7 +171,7 @@ func AdminCreateVideoAlbum(c *gin.Context) {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoAlbumResponse(&item))
utils.Success(c, repositories.BuildVideoAlbumResponse(&item, false))
}
func AdminUpdateVideoAlbum(c *gin.Context) {
@@ -199,7 +199,7 @@ func AdminUpdateVideoAlbum(c *gin.Context) {
return
}
}
utils.Success(c, repositories.BuildVideoAlbumResponse(&req.VideoAlbum))
utils.Success(c, repositories.BuildVideoAlbumResponse(&req.VideoAlbum, false))
}
func AdminDeleteVideoAlbum(c *gin.Context) {
@@ -277,38 +277,61 @@ func AdminGetVideos(c *gin.Context) {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideosResponse(list))
utils.Success(c, repositories.BuildAdminVideosResponse(list))
}
func AdminCreateVideo(c *gin.Context) {
var item models.Video
if err := c.ShouldBindJSON(&item); err != nil {
var req struct {
models.Video
AlbumIDs []uint `json:"albumIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, err.Error())
return
}
if item.ID == "" {
item.ID = "video_" + strconv.FormatInt(time.Now().UnixNano(), 10)
if req.Video.ID == "" {
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)
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) {
id := c.Param("id")
var item models.Video
if err := c.ShouldBindJSON(&item); err != nil {
var req struct {
models.Video
AlbumIDs []uint `json:"albumIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, err.Error())
return
}
item.ID = id
if err := repositories.UpdateVideo(&item); err != nil {
req.Video.ID = id
if err := repositories.UpdateVideo(&req.Video); err != nil {
utils.ServerError(c, err)
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) {
@@ -331,5 +354,5 @@ func AdminGetVideoByID(c *gin.Context) {
utils.Error(c, 404, "Video not found")
return
}
utils.Success(c, repositories.BuildVideoResponse(video))
utils.Success(c, repositories.BuildAdminVideoResponse(video))
}

View File

@@ -83,17 +83,39 @@ func (va *VideoAlbum) BeforeUpdate(tx *gorm.DB) error {
// VideoAlbumResponse 视频专辑 API 响应
type VideoAlbumResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Cover string `json:"cover"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName,omitempty"`
IsActive int `json:"isActive"`
SortOrder uint `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
VideoCount int64 `json:"videoCount,omitempty"`
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Cover string `json:"cover"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName,omitempty"`
IsActive int `json:"isActive"`
SortOrder uint `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
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 视频模型

View File

@@ -139,21 +139,57 @@ func DeleteVideoAlbum(id uint) 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
config.DB.Model(&models.AlbumVideo{}).
q := config.DB.Model(&models.AlbumVideo{}).
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).
Count(&count)
Where("album_videos.album_id = ? AND v.deleted_at = ?", albumID, 0)
if publishedOnly {
q = q.Where("v.is_published = ?", 1)
}
q.Count(&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 := ""
if cat, _ := GetVideoCategoryByID(item.CategoryID); cat != nil {
categoryName = cat.Name
}
return models.VideoAlbumResponse{
latestTs := GetAlbumLatestVideoUpdatedAt(item.ID, publishedOnly)
resp := models.VideoAlbumResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
@@ -164,14 +200,21 @@ func BuildVideoAlbumResponse(item *models.VideoAlbum) models.VideoAlbumResponse
SortOrder: item.SortOrder,
CreatedAt: formatTimestamp(item.CreatedAt),
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))
for i := range list {
res = append(res, BuildVideoAlbumResponse(&list[i]))
res = append(res, BuildVideoAlbumResponse(&list[i], publishedOnly))
}
return res
}
@@ -318,3 +361,120 @@ func SyncAlbumVideos(albumID uint, videoIDs []string) error {
}
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
}