优化页面、修复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> => {