优化页面、修复BUG

This commit is contained in:
李琦
2026-07-04 17:58:19 +08:00
parent e4baa4b585
commit 88c447fe18
34 changed files with 5884 additions and 13 deletions

View File

@@ -5,7 +5,7 @@
in child components (like BlogDetail TOC) because it changes the
scrolling context.
-->
<div class="min-h-screen bg-art-bg text-art-text relative">
<div class="min-h-screen bg-art-bg text-art-text relative flex flex-col">
<!-- Star Background (Only for frontend) -->
<StarBackground v-if="!isAdminOrLogin" class="z-0" />
@@ -24,7 +24,7 @@
<!-- Main Content -->
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
<main :class="[
isAdminOrLoginOrBlog ? 'w-full h-full relative z-10' : 'pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
isFullWidthPage ? 'w-full flex-1 relative z-10' : 'flex-1 pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
]">
<router-view />
</main>
@@ -69,10 +69,12 @@ const isAdminOrLogin = computed(() => {
return path.startsWith('/admin') || path === '/login'
})
// Check if current route is Admin or Login page
const isAdminOrLoginOrBlog = computed(() => {
// 全宽布局页面(不受 max-w-7xl 限制)
const isFullWidthPage = computed(() => {
const path = route.path
return path.startsWith('/admin') || path === '/login' || path.startsWith('/blog/')
return path.startsWith('/admin') || path === '/login'
|| path.startsWith('/blog/')
|| path.startsWith('/works/')
})
// 应用网站配置到页面标题和meta标签

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
/**
* DPlayer 弹层播放器
* 用于作品详情、视频详情等场景,不覆盖主图
*/
import { ref, watch, onBeforeUnmount, nextTick } from 'vue'
import DPlayer from 'dplayer'
import 'dplayer/dist/DPlayer.min.css'
import Icon from './Icon.vue'
interface Props {
/** 是否显示弹层 */
visible: boolean
/** 视频播放地址 */
videoUrl: string
/** 封面图poster */
poster?: string
/** 弹层标题 */
title?: string
}
const props = withDefaults(defineProps<Props>(), {
poster: '',
title: '视频播放',
})
const emit = defineEmits<{ close: [] }>()
const playerContainerRef = ref<HTMLElement | null>(null)
let playerInstance: DPlayer | null = null
/** 初始化 DPlayer 实例 */
const initPlayer = () => {
destroyPlayer()
if (!playerContainerRef.value || !props.videoUrl) return
playerInstance = new DPlayer({
container: playerContainerRef.value,
video: {
url: props.videoUrl,
pic: props.poster || undefined,
},
autoplay: true,
theme: '#d4b383',
lang: 'zh-cn',
})
}
/** 销毁播放器实例,释放资源 */
const destroyPlayer = () => {
if (playerInstance) {
playerInstance.destroy()
playerInstance = null
}
}
const handleClose = () => {
destroyPlayer()
emit('close')
}
watch(
() => props.visible,
async (show) => {
if (show) {
await nextTick()
initPlayer()
} else {
destroyPlayer()
}
}
)
watch(
() => props.videoUrl,
async () => {
if (props.visible) {
await nextTick()
initPlayer()
}
}
)
onBeforeUnmount(() => destroyPlayer())
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/85 backdrop-blur-sm p-4 md:p-8"
@click.self="handleClose"
>
<div class="relative w-full max-w-5xl bg-[#0d0d0d] rounded-xl border border-white/10 overflow-hidden shadow-2xl">
<!-- 标题栏 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 class="text-sm md:text-base text-white font-medium truncate pr-4">{{ title }}</h3>
<button
type="button"
class="shrink-0 p-2 rounded-full hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="handleClose"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 播放器容器 -->
<div ref="playerContainerRef" class="w-full aspect-video bg-black"></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
:deep(.dplayer) {
width: 100% !important;
height: 100% !important;
}
</style>

View File

@@ -42,6 +42,14 @@
>
作品
</button>
<button
v-if="visibleMenus.includes('videos')"
@click="goTo('/videos')"
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
:class="{ 'text-white after:w-full': activeNav === 'videos' }"
>
视频
</button>
<button
v-if="visibleMenus.includes('snippets')"
@click="goTo('/snippets')"
@@ -138,6 +146,14 @@
>
作品
</button>
<button
v-if="visibleMenus.includes('videos')"
@click="goTo('/videos')"
class="text-left text-base font-medium py-2 border-b border-white/5 transition-colors"
:class="activeNav === 'videos' ? 'text-art-accent' : 'text-art-muted hover:text-white'"
>
视频
</button>
<button
v-if="visibleMenus.includes('snippets')"
@click="goTo('/snippets')"
@@ -193,7 +209,7 @@ const route = useRoute()
const { cache, getStringSetting } = usePublicSettings()
const activeNav = ref('home')
const siteTitle = ref<string>('')
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services'])
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'videos', 'snippets', 'about', 'services'])
const isMobileMenuOpen = ref(false)
const closeMobileMenu = () => {
@@ -207,6 +223,7 @@ const updateActiveNav = () => {
else if (path === '/columns' || path.startsWith('/columns/')) activeNav.value = 'columns'
else if (path === '/categories' || path.startsWith('/categories/')) activeNav.value = 'categories'
else if (path === '/works' || path.startsWith('/works/')) activeNav.value = 'works'
else if (path === '/videos' || path.startsWith('/videos/')) activeNav.value = 'videos'
else if (path === '/snippets') activeNav.value = 'snippets'
else if (path === '/services') activeNav.value = 'services'
else if (path === '/about') activeNav.value = 'about'

View File

@@ -5,6 +5,7 @@
<a v-if="visibleMenus.includes('blog')" @click="navigate('/blog')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">思考</a>
<a v-if="visibleMenus.includes('columns')" @click="navigate('/columns')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">专栏</a>
<a v-if="visibleMenus.includes('works')" @click="navigate('/works')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">作品</a>
<a v-if="visibleMenus.includes('videos')" @click="navigate('/videos')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">视频</a>
<a v-if="visibleMenus.includes('snippets')" @click="navigate('/snippets')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">代码</a>
<a v-if="visibleMenus.includes('about')" @click="navigate('/about')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">关于</a>
<a v-if="visibleMenus.includes('services')" @click="navigate('/services')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">合作</a>
@@ -19,7 +20,7 @@ import { usePublicSettings } from '../composables/usePublicSettings'
const router = useRouter()
const { cache, getStringSetting } = usePublicSettings()
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services'])
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'videos', 'snippets', 'about', 'services'])
const applyMenuSettings = () => {
const menusRaw = getStringSetting('visible_menus')

View File

@@ -186,6 +186,15 @@ const menuItems = ref<MenuItemWithState[]>([
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
{ title: '作品管理', path: '/admin/works', icon: '🎨' },
{
title: '视频管理',
isOpen: false,
children: [
{ title: '视频分类', path: '/admin/video-categories', icon: '📁' },
{ title: '视频专辑', path: '/admin/video-albums', icon: '📀' },
{ title: '视频列表', path: '/admin/videos', icon: '🎬' },
]
},
{
title: '代码管理',
isOpen: false,

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* 从视频库选择视频(供作品表单等使用)
*/
import { ref, watch, computed } from 'vue'
import { getAdminVideos, type VideoItem } from '../../services/api'
import Icon from '../Icon.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ 'update:show': [value: boolean]; select: [video: VideoItem] }>()
const loading = ref(false)
const videos = ref<VideoItem[]>([])
const keyword = ref('')
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return videos.value
return videos.value.filter(v => v.title.toLowerCase().includes(kw))
})
const loadVideos = async () => {
loading.value = true
try {
videos.value = await getAdminVideos()
} finally {
loading.value = false
}
}
watch(() => props.show, (v) => { if (v) loadVideos() })
const handleClose = () => emit('update:show', false)
const handleSelect = (video: VideoItem) => {
emit('select', video)
handleClose()
}
</script>
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" @click.self="handleClose">
<div class="admin-card max-w-3xl w-full max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between mb-4 pb-4 border-b border-white/10">
<h2 class="text-xl font-serif italic text-white">从视频库选择</h2>
<button type="button" class="text-white/40 hover:text-white" @click="handleClose">
<Icon name="x" :size="20" />
</button>
</div>
<input v-model="keyword" type="text" placeholder="搜索视频标题..." class="admin-input mb-4" />
<div class="flex-1 overflow-y-auto space-y-2">
<div v-if="loading" class="text-center py-12 text-art-muted">加载中...</div>
<div v-else-if="filtered.length === 0" class="text-center py-12 text-art-muted">暂无视频</div>
<button
v-for="video in filtered"
:key="video.id"
type="button"
class="w-full flex items-center gap-4 p-3 rounded-lg border border-white/10 hover:border-art-accent/50 hover:bg-white/5 text-left transition-colors"
@click="handleSelect(video)"
>
<div class="w-24 h-14 rounded overflow-hidden bg-black/40 shrink-0">
<img v-if="video.cover" :src="video.cover" class="w-full h-full object-cover" alt="" />
<div v-else class="w-full h-full flex items-center justify-center text-art-muted">
<Icon name="play" :size="20" />
</div>
</div>
<div class="min-w-0 flex-1">
<div class="text-white font-medium truncate">{{ video.title }}</div>
<div class="text-xs text-art-muted">{{ video.categoryName || '未分类' }}</div>
</div>
</button>
</div>
</div>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
/**
* 视频上传组件
* 支持拖拽/点击上传视频文件,复用附件上传接口
*/
import { ref, computed } from 'vue'
import { useToast } from '../../composables/useToast'
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
const props = withDefaults(defineProps<{
modelValue?: string
categoryId?: number
disabled?: boolean
}>(), {
disabled: false,
})
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const error = ref('')
const isDragging = ref(false)
const toast = useToast()
const videoUrl = computed({
get: () => props.modelValue || '',
set: (val: string) => emit('update:modelValue', val),
})
const triggerFileInput = () => {
if (props.disabled || uploading.value) return
fileInput.value?.click()
}
/** 上传单个视频文件 */
const uploadFile = async (file: File) => {
if (!file.type.startsWith('video/')) {
error.value = '请选择视频文件'
toast.error('请选择视频文件')
return
}
uploading.value = true
error.value = ''
try {
const formData = new FormData()
formData.append('file', file)
if (props.categoryId) formData.append('categoryId', props.categoryId.toString())
formData.append('storageType', 'local')
const result = await parseApiResponse<{ fileUrl: string }>(
await authFetch(`${API_BASE}/admin/attachments/upload`, { method: 'POST', headers: {}, body: formData })
)
videoUrl.value = result.fileUrl
toast.success('视频上传成功')
} catch (err) {
error.value = err instanceof Error ? err.message : '上传失败'
toast.error(error.value)
} finally {
uploading.value = false
}
}
const handleFileSelect = (e: Event) => {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) uploadFile(file)
input.value = ''
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
isDragging.value = false
if (props.disabled) return
const file = e.dataTransfer?.files?.[0]
if (file) uploadFile(file)
}
const removeVideo = () => {
videoUrl.value = ''
}
</script>
<template>
<div class="video-upload">
<div
v-if="!videoUrl"
class="upload-area border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors"
:class="isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50'"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<input ref="fileInput" type="file" accept="video/*" class="hidden" :disabled="disabled" @change="handleFileSelect" />
<div class="space-y-3">
<div class="w-12 h-12 mx-auto flex items-center justify-center text-art-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<p class="text-sm text-art-muted">点击或拖拽视频到此处上传</p>
<p class="text-xs text-art-muted/50">支持 MP4WebM 等格式</p>
</div>
</div>
<div v-else class="relative group rounded-lg overflow-hidden bg-black/40 border border-white/10">
<video :src="videoUrl" class="w-full max-h-64 object-contain" controls preload="metadata" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button type="button" class="px-4 py-2 bg-red-500/80 text-white rounded text-sm" @click="removeVideo">删除</button>
<button type="button" class="px-4 py-2 bg-art-accent text-black rounded text-sm" @click="triggerFileInput">更换</button>
</div>
</div>
<div v-if="uploading" class="mt-3 text-center text-art-accent text-sm flex items-center justify-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-art-accent"></div>
上传中...
</div>
<div v-if="error" class="mt-2 text-red-400 text-sm">{{ error }}</div>
</div>
</template>

View File

@@ -61,7 +61,7 @@ export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
type: 'menu-checkboxes',
group: 'navigation',
public: true,
default: '["home","blog","columns","works","snippets","about","services"]',
default: '["home","blog","columns","works","videos","snippets","about","services"]',
description: '控制前台导航栏显示的菜单项',
},
{
@@ -116,6 +116,7 @@ export const MENU_OPTIONS = [
{ key: 'blog', label: '思考' },
{ key: 'columns', label: '专栏' },
{ key: 'works', label: '作品' },
{ key: 'videos', label: '视频' },
{ key: 'snippets', label: '代码' },
{ key: 'about', label: '关于' },
{ key: 'services', label: '合作' },

View File

@@ -0,0 +1,72 @@
<template>
<section class="page-section block">
<div class="max-w-6xl mx-auto pb-12 md:pb-20">
<button @click="router.push('/videos')" class="mb-8 text-art-muted hover:text-white flex items-center gap-2 text-sm">
<Icon name="arrow-left" :size="16" /> 返回视频
</button>
<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-else-if="error" class="text-red-400 text-center py-12">{{ error }}</div>
<template v-else-if="album">
<div class="mb-10">
<div v-if="album.cover" class="aspect-[21/9] rounded-xl overflow-hidden mb-6 max-h-80">
<img :src="album.cover" class="w-full h-full object-cover" alt="" />
</div>
<h1 class="font-serif text-4xl text-white mb-3">{{ album.name }}</h1>
<p class="text-art-muted max-w-2xl">{{ album.description }}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="video in videos"
:key="video.id"
class="group cursor-pointer rounded-xl overflow-hidden border border-white/10 hover:border-art-accent/40 transition-colors"
@click="router.push(`/videos/${video.id}`)"
>
<div class="aspect-video relative bg-black/40">
<img v-if="video.cover" :src="video.cover" class="w-full h-full object-cover" alt="" />
<div class="absolute inset-0 flex items-center justify-center">
<Icon name="play" :size="24" class="text-white opacity-70 group-hover:opacity-100" />
</div>
</div>
<div class="p-4"><h3 class="text-white text-sm font-medium line-clamp-2">{{ video.title }}</h3></div>
</div>
</div>
<div v-if="videos.length === 0" class="text-center py-12 text-art-muted">专辑内暂无视频</div>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchVideoAlbum, fetchAlbumVideos, type VideoAlbum, type VideoItem } from '../services/api'
import Icon from '../components/Icon.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const album = ref<VideoAlbum | null>(null)
const videos = ref<VideoItem[]>([])
/** 加载专辑详情及专辑内视频列表 */
const load = async () => {
loading.value = true
error.value = ''
try {
const id = Number(route.params.id)
album.value = await fetchVideoAlbum(id)
videos.value = await fetchAlbumVideos(id)
} catch {
error.value = '加载专辑失败'
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.id, load)
</script>

View File

@@ -0,0 +1,109 @@
<template>
<section class="page-section block">
<div class="max-w-6xl mx-auto pb-12 md:pb-20">
<button @click="$router.back()" class="mb-8 text-art-muted hover:text-white flex items-center gap-2 text-sm">
<Icon name="arrow-left" :size="16" /> 返回
</button>
<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-else-if="error" class="text-red-400 text-center py-12">
<p class="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 transition-colors"
@click="load"
>重试</button>
</div>
<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"
></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"
>
<p class="text-art-muted text-sm">暂无视频地址</p>
</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>
</div>
<p v-if="video.description" class="text-white/80 leading-relaxed whitespace-pre-wrap">{{ video.description }}</p>
</div>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import DPlayer from 'dplayer'
import 'dplayer/dist/DPlayer.min.css'
import { fetchVideo, type VideoItem } from '../services/api'
import Icon from '../components/Icon.vue'
const route = useRoute()
const loading = ref(true)
const error = ref('')
const video = ref<VideoItem | null>(null)
const playerRef = ref<HTMLElement | null>(null)
let dp: DPlayer | null = null
/** 销毁 DPlayer 实例,避免内存泄漏 */
const destroyPlayer = () => {
if (dp) { dp.destroy(); dp = null }
}
/** 在容器与视频 URL 就绪后创建 DPlayer */
const initPlayer = async () => {
destroyPlayer()
if (!playerRef.value || !video.value?.videoUrl) return
await nextTick()
dp = new DPlayer({
container: playerRef.value,
video: { url: video.value.videoUrl, pic: video.value.poster || video.value.cover },
theme: '#d4b383',
lang: 'zh-cn',
})
}
/** 加载视频详情数据 */
const load = async () => {
loading.value = true
error.value = ''
destroyPlayer()
try {
video.value = await fetchVideo(route.params.id as string)
} catch {
error.value = '加载视频失败'
video.value = null
} finally {
loading.value = false
}
}
// 监听 video、容器 DOM、loading 状态,在 DOM 挂载后再初始化播放器
watch([video, playerRef, loading], async ([v, el, isLoading]) => {
if (isLoading || !v?.videoUrl || !el) return
await nextTick()
initPlayer()
}, { flush: 'post' })
onMounted(load)
watch(() => route.params.id, load)
onBeforeUnmount(destroyPlayer)
</script>
<style scoped>
:deep(.dplayer) { width: 100% !important; height: 100% !important; }
</style>

138
client/src/pages/Videos.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<section id="videos" class="page-section block animate-slide-down">
<div class="max-w-6xl mx-auto pb-12 md:pb-20">
<div class="mb-12">
<h2 class="font-serif text-5xl italic text-white mb-4">视频</h2>
<p class="text-art-muted">精选视频内容与系列专辑</p>
</div>
<!-- 分类 Tab -->
<div class="flex flex-wrap gap-2 mb-10">
<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()"
>全部</button>
<button
v-for="cat in categories"
:key="cat.id"
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()"
>{{ cat.name }}</button>
</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"
>重试</button>
</div>
<template v-else>
<!-- 专辑 -->
<div v-if="albums.length" class="mb-16">
<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-6">
<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}`)"
>
<div class="aspect-video bg-gray-900 overflow-hidden">
<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">
<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>
</div>
</div>
</div>
</div>
<!-- 最新视频 -->
<div>
<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-6">
<div
v-for="video in videos"
:key="video.id"
class="group cursor-pointer rounded-xl overflow-hidden border border-white/10 hover:border-art-accent/40 transition-colors"
@click="router.push(`/videos/${video.id}`)"
>
<div class="aspect-video bg-black/40 relative overflow-hidden">
<img v-if="video.cover" :src="video.cover" class="w-full h-full object-cover" alt="" />
<div class="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/10 transition-colors">
<div class="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center">
<Icon name="play" :size="20" class="text-white ml-0.5" />
</div>
</div>
</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>
</div>
</div>
</div>
<div v-if="videos.length === 0" class="text-center py-12 text-art-muted">暂无视频</div>
</div>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchVideoCategories, fetchVideoAlbums, fetchVideos, type VideoCategory, type VideoAlbum, type VideoItem } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
import Icon from '../components/Icon.vue'
const router = useRouter()
const loading = ref(false)
const error = ref('')
const categories = ref<VideoCategory[]>([])
const albums = ref<VideoAlbum[]>([])
const videos = ref<VideoItem[]>([])
const activeCategory = ref(0)
// 滚动入场动画animate-slide-down 初始 opacity:0需 initObserver 添加 .visible
const { initObserver } = useScrollAnimation()
/**
* 加载视频分类下的专辑与视频列表
*/
const loadData = async () => {
loading.value = true
error.value = ''
try {
const catId = activeCategory.value || undefined
albums.value = await fetchVideoAlbums(catId)
videos.value = await fetchVideos(catId)
} catch (err) {
error.value = '加载失败,请稍后重试'
console.error('加载视频列表失败:', err)
} finally {
loading.value = false
// 数据渲染后触发 IntersectionObserver使页面从透明变为可见
await initObserver()
}
}
onMounted(async () => {
try {
categories.value = await fetchVideoCategories()
} catch (err) {
console.error('加载视频分类失败:', err)
}
await loadData()
})
</script>

View File

@@ -32,8 +32,12 @@
>
<div class="absolute inset-0 bg-black/40"></div>
<!-- Play Button Overlay -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-24 h-24 rounded-full border border-white/20 bg-white/5 backdrop-blur-md flex items-center justify-center cursor-pointer hover:scale-110 hover:bg-white/10 transition-all duration-500 group-hover:border-art-accent/50 z-20">
<!-- Play Button Overlay有视频时显示点击弹层播放不覆盖主图 -->
<div
v-if="work.videoUrl"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-24 h-24 rounded-full border border-white/20 bg-white/5 backdrop-blur-md flex items-center justify-center cursor-pointer hover:scale-110 hover:bg-white/10 transition-all duration-500 group-hover:border-art-accent/50 z-20"
@click="videoModalOpen = true"
>
<Icon name="play" :size="32" class="text-white fill-white ml-1" />
<div class="absolute inset-0 rounded-full border border-white/20 animate-pulse-slow"></div>
</div>
@@ -143,6 +147,14 @@
<!-- <footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">&copy; 2024 年糕崽崽. 保留所有权利.</p></footer>-->
</div>
</div>
<DPlayerModal
:visible="videoModalOpen"
:video-url="work.videoUrl || ''"
:poster="work.heroImg"
:title="work.title"
@close="videoModalOpen = false"
/>
</div>
</section>
</template>
@@ -153,6 +165,7 @@ import { useRoute, useRouter } from 'vue-router'
import { fetchWork, Work } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
import Icon from '../components/Icon.vue'
import DPlayerModal from '../components/DPlayerModal.vue'
const route = useRoute()
const router = useRouter()
@@ -173,6 +186,7 @@ const work = ref<Work>({
const loading = ref(true)
const error = ref('')
const videoModalOpen = ref(false)
const { initObserver } = useScrollAnimation()

View File

@@ -1,7 +1,6 @@
<template>
<section id="works" class="page-section block animate-slide-down">
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20">
<div class="mb-32">
<div class="mb-32">
<h2 class="font-serif text-5xl italic text-white mb-6">精选作品</h2>
<p class="text-art-muted">这里展示了我参与设计和开发的核心项目</p>
</div>
@@ -110,7 +109,6 @@
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,84 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-4">
<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/video-albums')">取消</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.name" class="admin-input" required /></div>
<div><label class="block text-xs text-art-muted mb-1">所属分类</label>
<select v-model.number="form.categoryId" class="admin-input">
<option :value="0">未分类</option>
<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-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="3" class="admin-input resize-none" /></div>
<div class="flex gap-6">
<div><label class="block text-xs text-art-muted mb-1">排序</label><input v-model.number="form.sortOrder" type="number" class="admin-input w-32" /></div>
<div><label class="block text-xs text-art-muted mb-1">状态</label>
<select v-model.number="form.isActive" class="admin-input w-32"><option :value="1">启用</option><option :value="0">禁用</option></select>
</div>
</div>
<div v-if="isEditing">
<label class="block text-xs text-art-muted mb-2">专辑内视频勾选加入专辑</label>
<div class="max-h-64 overflow-y-auto space-y-2 border border-white/10 rounded-lg p-3">
<label v-for="v in allVideos" :key="v.id" class="flex items-center gap-3 p-2 rounded hover:bg-white/5 cursor-pointer">
<input type="checkbox" :value="v.id" v-model="selectedVideoIds" />
<span class="text-sm text-white">{{ v.title }}</span>
</label>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
getAdminVideoAlbums, getAdminVideoCategories, getAdminVideos, getAlbumVideoIds,
createVideoAlbum, updateVideoAlbum, type VideoCategory, type VideoItem,
} from '../../services/api'
import { useToast } from '../../composables/useToast'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const submitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const categories = ref<VideoCategory[]>([])
const allVideos = ref<VideoItem[]>([])
const selectedVideoIds = ref<string[]>([])
const form = reactive({ name: '', description: '', cover: '', categoryId: 0, isActive: 1, sortOrder: 0 })
onMounted(async () => {
categories.value = await getAdminVideoCategories()
allVideos.value = await getAdminVideos()
if (isEditing.value) {
const albums = await getAdminVideoAlbums()
const item = albums.find(a => a.id === Number(route.params.id))
if (item) Object.assign(form, { name: item.name, description: item.description, cover: item.cover, categoryId: item.categoryId, isActive: item.isActive, sortOrder: item.sortOrder })
selectedVideoIds.value = await getAlbumVideoIds(Number(route.params.id))
}
})
const handleSubmit = async () => {
submitting.value = true
try {
const payload = { ...form, videoIds: selectedVideoIds.value }
if (isEditing.value) {
await updateVideoAlbum(Number(route.params.id), payload)
toast.success('更新成功')
} else {
await createVideoAlbum(payload)
toast.success('创建成功')
}
router.push('/admin/video-albums')
} catch { toast.error('保存失败') } finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-serif italic text-white">视频专辑</h1>
<button @click="router.push('/admin/video-albums/create')" class="admin-btn-primary">+ 新建专辑</button>
</div>
<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>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id">
<td class="font-mono text-xs text-white/40">#{{ item.id }}</td>
<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><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>
<button @click="handleDelete(item.id)" class="admin-btn-danger py-1 px-3 text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminVideoAlbums, deleteVideoAlbum, type VideoAlbum } from '../../services/api'
import { useToast } from '../../composables/useToast'
const router = useRouter()
const toast = useToast()
const list = ref<VideoAlbum[]>([])
onMounted(async () => {
try { list.value = await getAdminVideoAlbums() } catch { toast.error('加载失败') }
})
const handleDelete = async (id: number) => {
if (!confirm('确定删除?')) return
try {
await deleteVideoAlbum(id)
list.value = list.value.filter(i => i.id !== id)
toast.success('已删除')
} catch { toast.error('删除失败') }
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-serif italic text-white">视频分类</h1>
<button @click="router.push('/admin/video-categories/create')" class="admin-btn-primary">+ 新建分类</button>
</div>
<div class="admin-card overflow-hidden">
<table class="admin-table">
<thead>
<tr><th>ID</th><th>名称</th><th>Slug</th><th>排序</th><th class="text-right">操作</th></tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id" class="group">
<td class="font-mono text-xs text-white/40">#{{ item.id }}</td>
<td class="text-white">{{ item.name }}</td>
<td class="text-xs font-mono text-art-muted">{{ item.slug }}</td>
<td class="text-art-muted">{{ item.sortOrder }}</td>
<td class="text-right">
<router-link :to="`/admin/video-categories/${item.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs mr-2">编辑</router-link>
<button @click="handleDelete(item.id)" class="admin-btn-danger py-1 px-3 text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="list.length === 0" class="p-12 text-center text-art-muted">暂无分类</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminVideoCategories, deleteVideoCategory, type VideoCategory } from '../../services/api'
import { useToast } from '../../composables/useToast'
const router = useRouter()
const toast = useToast()
const list = ref<VideoCategory[]>([])
onMounted(async () => {
try { list.value = await getAdminVideoCategories() } catch { toast.error('加载失败') }
})
const handleDelete = async (id: number) => {
if (!confirm('确定删除?')) return
try {
await deleteVideoCategory(id)
list.value = list.value.filter(i => i.id !== id)
toast.success('已删除')
} catch { toast.error('删除失败') }
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-4">
<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/video-categories')">取消</button>
<button type="button" class="admin-btn-primary" :disabled="submitting" @click="handleSubmit">{{ submitting ? '提交中...' : '保存' }}</button>
</div>
</div>
<div class="admin-card p-6 max-w-2xl">
<form class="space-y-5" @submit.prevent="handleSubmit">
<div><label class="block text-xs text-art-muted mb-1">名称</label><input v-model="form.name" class="admin-input" required /></div>
<div><label class="block text-xs text-art-muted mb-1">Slug</label><input v-model="form.slug" class="admin-input font-mono" required /></div>
<div><label class="block text-xs text-art-muted mb-1">排序</label><input v-model.number="form.sortOrder" type="number" class="admin-input" /></div>
<div><label class="block text-xs text-art-muted mb-1">描述</label><textarea v-model="form.description" rows="3" class="admin-input resize-none" /></div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAdminVideoCategories, createVideoCategory, updateVideoCategory } from '../../services/api'
import { useToast } from '../../composables/useToast'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const submitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const form = reactive({ name: '', slug: '', description: '', sortOrder: 0 })
onMounted(async () => {
if (!isEditing.value) return
const list = await getAdminVideoCategories()
const item = list.find(c => c.id === Number(route.params.id))
if (item) Object.assign(form, { name: item.name, slug: item.slug, description: item.description, sortOrder: item.sortOrder })
})
const handleSubmit = async () => {
submitting.value = true
try {
if (isEditing.value) {
await updateVideoCategory(Number(route.params.id), form)
toast.success('更新成功')
} else {
await createVideoCategory(form)
toast.success('创建成功')
}
router.push('/admin/video-categories')
} catch { toast.error('保存失败') } finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-4">
<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>
<select v-model.number="form.categoryId" class="admin-input">
<option :value="0">未分类</option>
<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-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>
<div><label class="block text-xs text-art-muted mb-1">发布状态</label>
<select v-model.number="form.isPublished" class="admin-input w-40"><option :value="1">发布</option><option :value="0">草稿</option></select>
</div>
</div>
</div>
</template>
<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 { useToast } from '../../composables/useToast'
import ImageUpload from '../../components/admin/ImageUpload.vue'
import VideoUpload from '../../components/admin/VideoUpload.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const submitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const categories = ref<{ id: number; name: string }[]>([])
const form = reactive({ title: '', description: '', videoUrl: '', cover: '', poster: '', categoryId: 0, isPublished: 1 })
onMounted(async () => {
categories.value = await getAdminVideoCategories()
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 })
}
})
const handleSubmit = async () => {
if (!form.videoUrl) { toast.error('请上传视频'); return }
submitting.value = true
try {
form.poster = form.cover || form.poster
if (isEditing.value) {
await updateVideo(route.params.id as string, form)
toast.success('更新成功')
} else {
await createVideo(form)
toast.success('创建成功')
}
router.push('/admin/videos')
} catch { toast.error('保存失败') } finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-serif italic text-white">视频管理</h1>
<button @click="router.push('/admin/videos/create')" class="admin-btn-primary">+ 新建视频</button>
</div>
<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>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id">
<td class="text-white">{{ item.title }}</td>
<td class="text-art-muted">{{ item.categoryName || '-' }}</td>
<td><span :class="item.isPublished ? 'text-green-400' : 'text-red-400'">{{ item.isPublished ? '已发布' : '草稿' }}</span></td>
<td class="text-xs text-art-muted">{{ item.createdAt }}</td>
<td class="text-right">
<router-link :to="`/admin/videos/${item.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs mr-2">编辑</router-link>
<button @click="handleDelete(item.id)" class="admin-btn-danger py-1 px-3 text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminVideos, deleteVideo, type VideoItem } from '../../services/api'
import { useToast } from '../../composables/useToast'
const router = useRouter()
const toast = useToast()
const list = ref<VideoItem[]>([])
onMounted(async () => {
try { list.value = await getAdminVideos() } catch { toast.error('加载失败') }
})
const handleDelete = async (id: string) => {
if (!confirm('确定删除?')) return
try {
await deleteVideo(id)
list.value = list.value.filter(i => i.id !== id)
toast.success('已删除')
} catch { toast.error('删除失败') }
}
</script>

View File

@@ -63,6 +63,32 @@
{{ errors.heroImg }}
</div>
</div>
<!-- 演示视频 -->
<div class="form-group">
<label>演示视频可选</label>
<div class="flex gap-2 mb-3">
<button
type="button"
class="admin-btn-secondary text-xs py-1 px-3"
:class="videoSource === 'upload' ? 'ring-1 ring-art-accent' : ''"
@click="videoSource = 'upload'"
>上传视频</button>
<button
type="button"
class="admin-btn-secondary text-xs py-1 px-3"
:class="videoSource === 'library' ? 'ring-1 ring-art-accent' : ''"
@click="videoSource = 'library'"
>从视频库选择</button>
</div>
<VideoUpload v-if="videoSource === 'upload'" v-model="form.heroVideo" :category-id="1" />
<div v-else class="space-y-2">
<button type="button" class="admin-btn-secondary text-sm" @click="showVideoPicker = true">
{{ selectedVideoTitle || '选择视频' }}
</button>
<button v-if="form.videoId" type="button" class="text-xs text-red-400 ml-2" @click="clearLibraryVideo">清除</button>
</div>
</div>
<!-- Description Field -->
<div class="form-group">
@@ -233,6 +259,12 @@
</div>
</form>
</div>
<VideoPickerModal
:show="showVideoPicker"
@update:show="showVideoPicker = $event"
@select="handleVideoPick"
/>
</div>
</template>
@@ -242,6 +274,9 @@ import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createWork, updateWork, fetchWork } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
import VideoUpload from '../../components/admin/VideoUpload.vue'
import VideoPickerModal from '../../components/admin/VideoPickerModal.vue'
import type { VideoItem } from '../../services/api'
const router = useRouter()
const route = useRoute()
@@ -251,6 +286,23 @@ const toast = useToast()
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
/** 视频来源upload 独立上传 / library 视频库 */
const videoSource = ref<'upload' | 'library'>('upload')
const showVideoPicker = ref(false)
const selectedVideoTitle = ref('')
/** 从视频库选择后回调 */
const handleVideoPick = (video: VideoItem) => {
form.videoId = video.id
form.heroVideo = ''
selectedVideoTitle.value = video.title
}
/** 清除视频库选择 */
const clearLibraryVideo = () => {
form.videoId = ''
selectedVideoTitle.value = ''
}
// Form data
const form = reactive({
@@ -258,6 +310,8 @@ const form = reactive({
category: '',
year: '',
heroImg: '',
heroVideo: '',
videoId: '',
desc: '',
techStack: [] as { category: string; items: string[] }[],
gallery: [] as string[],
@@ -365,6 +419,13 @@ const handleSubmit = async () => {
isSubmitting.value = true
try {
// 互斥:上传与视频库二选一
if (videoSource.value === 'upload') {
form.videoId = ''
} else {
form.heroVideo = ''
}
if (isEditing.value) {
// Update existing work
await updateWork(route.params.id as string, form)
@@ -401,6 +462,14 @@ onMounted(async () => {
form.category = work.category
form.year = work.year
form.heroImg = work.heroImg
form.heroVideo = work.heroVideo || ''
form.videoId = work.videoId || ''
if (form.videoId) {
videoSource.value = 'library'
selectedVideoTitle.value = form.videoId
} else if (form.heroVideo) {
videoSource.value = 'upload'
}
form.desc = work.desc
form.techStack = work.techStack || []
form.gallery = work.gallery || []

View File

@@ -73,6 +73,9 @@ const routes = [
{ path: '/columns/:id', name: 'column-detail', component: () => import('./pages/ColumnDetail.vue'), meta: { title: '', dynamic: true } },
{ path: '/works', name: 'works', component: () => import('./pages/Works.vue'), meta: { title: '作品' } },
{ path: '/works/:id', name: 'work-detail', component: () => import('./pages/WorkDetail.vue'), meta: { title: '', dynamic: true } },
{ path: '/videos', name: 'videos', component: () => import('./pages/Videos.vue'), meta: { title: '视频' } },
{ path: '/videos/albums/:id', name: 'video-album-detail', component: () => import('./pages/VideoAlbumDetail.vue'), meta: { title: '', dynamic: true } },
{ path: '/videos/:id', name: 'video-detail', component: () => import('./pages/VideoDetail.vue'), meta: { title: '', dynamic: true } },
{ path: '/snippets', name: 'snippets', component: () => import('./pages/Snippets.vue'), meta: { title: '代码' } },
{ path: '/services', name: 'services', component: () => import('./pages/Services.vue'), meta: { title: '合作' } },
{ path: '/about', name: 'about', component: () => import('./pages/About.vue'), meta: { title: '关于' } },
@@ -121,6 +124,17 @@ const routes = [
{ path: 'works', name: 'admin-works', component: () => import('./pages/admin/Works.vue') },
{ path: 'works/create', name: 'admin-works-create', component: () => import('./pages/admin/WorkForm.vue') },
{ path: 'works/:id/edit', name: 'admin-works-edit', component: () => import('./pages/admin/WorkForm.vue') },
// 视频管理
{ path: 'video-categories', name: 'admin-video-categories', component: () => import('./pages/admin/VideoCategories.vue') },
{ path: 'video-categories/create', name: 'admin-video-categories-create', component: () => import('./pages/admin/VideoCategoryForm.vue') },
{ path: 'video-categories/:id/edit', name: 'admin-video-categories-edit', component: () => import('./pages/admin/VideoCategoryForm.vue') },
{ path: 'video-albums', name: 'admin-video-albums', component: () => import('./pages/admin/VideoAlbums.vue') },
{ path: 'video-albums/create', name: 'admin-video-albums-create', component: () => import('./pages/admin/VideoAlbumForm.vue') },
{ path: 'video-albums/:id/edit', name: 'admin-video-albums-edit', component: () => import('./pages/admin/VideoAlbumForm.vue') },
{ path: 'videos', name: 'admin-videos', component: () => import('./pages/admin/Videos.vue') },
{ path: 'videos/create', name: 'admin-videos-create', component: () => import('./pages/admin/VideoForm.vue') },
{ path: 'videos/:id/edit', name: 'admin-videos-edit', component: () => import('./pages/admin/VideoForm.vue') },
// 代码片段管理
{ path: 'snippets', name: 'admin-snippets', component: () => import('./pages/admin/Snippets.vue') },

View File

@@ -136,6 +136,9 @@ export interface Work {
category: string
year: string
heroImg: string
heroVideo?: string
videoId?: string
videoUrl?: string
desc: string
techStack: { category: string; items: string[] }[]
gallery: string[]
@@ -143,6 +146,48 @@ export interface Work {
next: string
}
// 视频模块类型
export interface VideoCategory {
id: number
name: string
slug: string
description: string
sortOrder: number
createdAt: string
updatedAt: string
videoCount?: number
albumCount?: number
}
export interface VideoAlbum {
id: number
name: string
description: string
cover: string
categoryId: number
categoryName?: string
isActive: number
sortOrder: number
createdAt: string
updatedAt: string
videoCount?: number
}
export interface VideoItem {
id: string
title: string
description: string
videoUrl: string
cover: string
poster: string
categoryId: number
categoryName?: string
duration: number
isPublished: number
createdAt: string
updatedAt: string
}
// 分类相关类型
export interface Category {
id: number
@@ -2064,3 +2109,131 @@ export const deleteAttachmentCategory = async (id: number): Promise<void> => {
throw error
}
}
// ========== 视频模块 API ==========
export const fetchVideoCategories = async (): Promise<VideoCategory[]> => {
const result = await fetchJson<VideoCategory[]>(`${API_BASE}/video-categories`)
return Array.isArray(result) ? result : []
}
export const fetchVideoAlbums = async (categoryId?: number): Promise<VideoAlbum[]> => {
const q = categoryId ? `?categoryId=${categoryId}` : ''
const result = await fetchJson<VideoAlbum[]>(`${API_BASE}/video-albums${q}`)
return Array.isArray(result) ? result : []
}
export const fetchVideoAlbum = async (id: number): Promise<VideoAlbum> => {
const result = await fetchJson<VideoAlbum>(`${API_BASE}/video-albums/${id}`)
if (!result) throw new Error('Album not found')
return result
}
export const fetchAlbumVideos = async (albumId: number): Promise<VideoItem[]> => {
const result = await fetchJson<VideoItem[]>(`${API_BASE}/video-albums/${albumId}/videos`)
return Array.isArray(result) ? result : []
}
export const fetchVideos = async (categoryId?: number): Promise<VideoItem[]> => {
const q = categoryId ? `?categoryId=${categoryId}` : ''
const result = await fetchJson<VideoItem[]>(`${API_BASE}/videos${q}`)
return Array.isArray(result) ? result : []
}
export const fetchVideo = async (id: string): Promise<VideoItem> => {
const result = await fetchJson<VideoItem>(`${API_BASE}/videos/${id}`)
if (!result) throw new Error('Video not found')
return result
}
export const getAdminVideoCategories = async (): Promise<VideoCategory[]> => {
const response = await authFetch(`${API_BASE}/admin/video-categories`, { headers: getAuthHeaders() })
const result = await parseApiResponse<VideoCategory[]>(response)
return Array.isArray(result) ? result : []
}
export const createVideoCategory = async (data: Partial<VideoCategory>): Promise<VideoCategory> => {
const response = await authFetch(`${API_BASE}/admin/video-categories`, {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(data),
})
return await parseApiResponse<VideoCategory>(response)
}
export const updateVideoCategory = async (id: number, data: Partial<VideoCategory>): Promise<VideoCategory> => {
const response = await authFetch(`${API_BASE}/admin/video-categories/${id}`, {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(data),
})
return await parseApiResponse<VideoCategory>(response)
}
export const deleteVideoCategory = async (id: number): Promise<void> => {
await parseApiResponse(await authFetch(`${API_BASE}/admin/video-categories/${id}`, {
method: 'DELETE', headers: getAuthHeaders(),
}))
}
export const getAdminVideoAlbums = async (categoryId?: number): Promise<VideoAlbum[]> => {
const q = categoryId ? `?categoryId=${categoryId}` : ''
const response = await authFetch(`${API_BASE}/admin/video-albums${q}`, { headers: getAuthHeaders() })
const result = await parseApiResponse<VideoAlbum[]>(response)
return Array.isArray(result) ? result : []
}
export const createVideoAlbum = async (data: Partial<VideoAlbum> & { videoIds?: string[] }): Promise<VideoAlbum> => {
const response = await authFetch(`${API_BASE}/admin/video-albums`, {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(data),
})
return await parseApiResponse<VideoAlbum>(response)
}
export const updateVideoAlbum = async (id: number, data: Partial<VideoAlbum> & { videoIds?: string[] }): Promise<VideoAlbum> => {
const response = await authFetch(`${API_BASE}/admin/video-albums/${id}`, {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(data),
})
return await parseApiResponse<VideoAlbum>(response)
}
export const deleteVideoAlbum = async (id: number): Promise<void> => {
await parseApiResponse(await authFetch(`${API_BASE}/admin/video-albums/${id}`, {
method: 'DELETE', headers: getAuthHeaders(),
}))
}
export const getAlbumVideoIds = async (albumId: number): Promise<string[]> => {
const response = await authFetch(`${API_BASE}/admin/video-albums/${albumId}/video-ids`, { headers: getAuthHeaders() })
const result = await parseApiResponse<string[]>(response)
return Array.isArray(result) ? result : []
}
export const getAdminVideos = async (categoryId?: number): Promise<VideoItem[]> => {
const q = categoryId ? `?categoryId=${categoryId}` : ''
const response = await authFetch(`${API_BASE}/admin/videos${q}`, { headers: getAuthHeaders() })
const result = await parseApiResponse<VideoItem[]>(response)
return Array.isArray(result) ? result : []
}
export const getAdminVideo = async (id: string): Promise<VideoItem> => {
const response = await authFetch(`${API_BASE}/admin/videos/${id}`, { headers: getAuthHeaders() })
return await parseApiResponse<VideoItem>(response)
}
export const createVideo = async (data: Partial<VideoItem>): Promise<VideoItem> => {
const response = await authFetch(`${API_BASE}/admin/videos`, {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(data),
})
return await parseApiResponse<VideoItem>(response)
}
export const updateVideo = async (id: string, data: Partial<VideoItem>): Promise<VideoItem> => {
const response = await authFetch(`${API_BASE}/admin/videos/${id}`, {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(data),
})
return await parseApiResponse<VideoItem>(response)
}
export const deleteVideo = async (id: string): Promise<void> => {
await parseApiResponse(await authFetch(`${API_BASE}/admin/videos/${id}`, {
method: 'DELETE', headers: getAuthHeaders(),
}))
}

17
client/src/types/dplayer.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
declare module 'dplayer' {
interface DPlayerVideoOptions {
url: string
pic?: string
}
interface DPlayerOptions {
container: HTMLElement
video: DPlayerVideoOptions
autoplay?: boolean
theme?: string
lang?: string
}
export default class DPlayer {
constructor(options: DPlayerOptions)
destroy(): void
}
}