优化页面、修复BUG
This commit is contained in:
@@ -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标签
|
||||
|
||||
127
client/src/components/DPlayerModal.vue
Normal file
127
client/src/components/DPlayerModal.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
79
client/src/components/admin/VideoPickerModal.vue
Normal file
79
client/src/components/admin/VideoPickerModal.vue
Normal 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>
|
||||
118
client/src/components/admin/VideoUpload.vue
Normal file
118
client/src/components/admin/VideoUpload.vue
Normal 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">支持 MP4、WebM 等格式</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>
|
||||
@@ -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: '合作' },
|
||||
|
||||
72
client/src/pages/VideoAlbumDetail.vue
Normal file
72
client/src/pages/VideoAlbumDetail.vue
Normal 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>
|
||||
109
client/src/pages/VideoDetail.vue
Normal file
109
client/src/pages/VideoDetail.vue
Normal 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
138
client/src/pages/Videos.vue
Normal 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>
|
||||
@@ -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">© 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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
84
client/src/pages/admin/VideoAlbumForm.vue
Normal file
84
client/src/pages/admin/VideoAlbumForm.vue
Normal 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>
|
||||
52
client/src/pages/admin/VideoAlbums.vue
Normal file
52
client/src/pages/admin/VideoAlbums.vue
Normal 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>
|
||||
52
client/src/pages/admin/VideoCategories.vue
Normal file
52
client/src/pages/admin/VideoCategories.vue
Normal 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>
|
||||
54
client/src/pages/admin/VideoCategoryForm.vue
Normal file
54
client/src/pages/admin/VideoCategoryForm.vue
Normal 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>
|
||||
67
client/src/pages/admin/VideoForm.vue
Normal file
67
client/src/pages/admin/VideoForm.vue
Normal 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>
|
||||
51
client/src/pages/admin/Videos.vue
Normal file
51
client/src/pages/admin/Videos.vue
Normal 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>
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
17
client/src/types/dplayer.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user