优化页面、修复BUG
This commit is contained in:
@@ -75,6 +75,7 @@ const isFullWidthPage = computed(() => {
|
||||
return path.startsWith('/admin') || path === '/login'
|
||||
|| path.startsWith('/blog/')
|
||||
|| path.startsWith('/works/')
|
||||
|| path.startsWith('/columns')
|
||||
})
|
||||
|
||||
// 应用网站配置到页面标题和meta标签
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 视频模型
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user