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

@@ -14,6 +14,7 @@
"@fontsource/noto-sans-sc": "^5.0.0",
"@fontsource/playfair-display": "^5.0.0",
"@types/markdown-it": "^14.1.2",
"dplayer": "^1.25.0",
"echarts": "^6.0.0",
"highlight": "^0.2.4",
"highlight.js": "^11.11.1",

3388
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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
}
}

335
server/handlers/video.go Normal file
View File

@@ -0,0 +1,335 @@
package handlers
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/niangaodev/art-code/models"
"github.com/niangaodev/art-code/repositories"
"github.com/niangaodev/art-code/utils"
)
// ========== 公开 API ==========
// GetVideoCategories 获取视频分类列表
func GetVideoCategories(c *gin.Context) {
list, err := repositories.GetVideoCategories()
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoCategoriesResponse(list))
}
// GetVideoAlbums 获取视频专辑列表
func GetVideoAlbums(c *gin.Context) {
categoryID, _ := strconv.ParseUint(c.Query("categoryId"), 10, 32)
list, err := repositories.GetActiveVideoAlbums(uint(categoryID))
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoAlbumsResponse(list))
}
// GetVideoAlbumByID 获取专辑详情
func GetVideoAlbumByID(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
album, err := repositories.GetVideoAlbumByID(uint(id))
if err != nil {
utils.ServerError(c, err)
return
}
if album == nil || album.IsActive != 1 {
utils.Error(c, 404, "Album not found")
return
}
utils.Success(c, repositories.BuildVideoAlbumResponse(album))
}
// GetAlbumVideos 获取专辑内视频
func GetAlbumVideos(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
list, err := repositories.GetVideosByAlbumID(uint(id), true)
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideosResponse(list))
}
// GetVideos 获取视频列表
func GetVideos(c *gin.Context) {
categoryID, _ := strconv.ParseUint(c.Query("categoryId"), 10, 32)
list, err := repositories.GetVideos(uint(categoryID), true)
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideosResponse(list))
}
// GetVideoByID 获取视频详情
func GetVideoByID(c *gin.Context) {
id := c.Param("id")
video, err := repositories.GetVideoByID(id)
if err != nil {
utils.ServerError(c, err)
return
}
if video == nil || video.IsPublished != 1 {
utils.Error(c, 404, "Video not found")
return
}
utils.Success(c, repositories.BuildVideoResponse(video))
}
// ========== 管理 API — 分类 ==========
func AdminGetVideoCategories(c *gin.Context) {
list, err := repositories.GetVideoCategories()
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoCategoriesResponse(list))
}
func AdminCreateVideoCategory(c *gin.Context) {
var item models.VideoCategory
if err := c.ShouldBindJSON(&item); err != nil {
utils.Error(c, 400, err.Error())
return
}
if err := repositories.CreateVideoCategory(&item); err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoCategoryResponse(&item))
}
func AdminUpdateVideoCategory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid category ID")
return
}
var item models.VideoCategory
if err := c.ShouldBindJSON(&item); err != nil {
utils.Error(c, 400, err.Error())
return
}
item.ID = uint(id)
if err := repositories.UpdateVideoCategory(&item); err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoCategoryResponse(&item))
}
func AdminDeleteVideoCategory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid category ID")
return
}
if err := repositories.DeleteVideoCategory(uint(id)); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Category deleted", nil)
}
// ========== 管理 API — 专辑 ==========
func AdminGetVideoAlbums(c *gin.Context) {
categoryID, _ := strconv.ParseUint(c.Query("categoryId"), 10, 32)
list, err := repositories.GetVideoAlbums(uint(categoryID))
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoAlbumsResponse(list))
}
func AdminCreateVideoAlbum(c *gin.Context) {
var item models.VideoAlbum
if err := c.ShouldBindJSON(&item); err != nil {
utils.Error(c, 400, err.Error())
return
}
if err := repositories.CreateVideoAlbum(&item); err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoAlbumResponse(&item))
}
func AdminUpdateVideoAlbum(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
var req struct {
models.VideoAlbum
VideoIDs []string `json:"videoIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, err.Error())
return
}
req.VideoAlbum.ID = uint(id)
if err := repositories.UpdateVideoAlbum(&req.VideoAlbum); err != nil {
utils.ServerError(c, err)
return
}
if req.VideoIDs != nil {
if err := repositories.SyncAlbumVideos(uint(id), req.VideoIDs); err != nil {
utils.ServerError(c, err)
return
}
}
utils.Success(c, repositories.BuildVideoAlbumResponse(&req.VideoAlbum))
}
func AdminDeleteVideoAlbum(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
if err := repositories.DeleteVideoAlbum(uint(id)); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Album deleted", nil)
}
func AdminAddVideoToAlbum(c *gin.Context) {
albumID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
var req struct {
VideoID string `json:"videoId"`
SortOrder uint `json:"sortOrder"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, err.Error())
return
}
if err := repositories.AddVideoToAlbum(uint(albumID), req.VideoID, req.SortOrder); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Video added to album", nil)
}
func AdminRemoveVideoFromAlbum(c *gin.Context) {
albumID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
videoID := c.Param("videoId")
if err := repositories.RemoveVideoFromAlbum(uint(albumID), videoID); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Video removed from album", nil)
}
func AdminGetAlbumVideoIDs(c *gin.Context) {
albumID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid album ID")
return
}
videos, err := repositories.GetVideosByAlbumID(uint(albumID), false)
if err != nil {
utils.ServerError(c, err)
return
}
ids := make([]string, 0, len(videos))
for _, v := range videos {
ids = append(ids, v.ID)
}
utils.Success(c, ids)
}
// ========== 管理 API — 视频 ==========
func AdminGetVideos(c *gin.Context) {
categoryID, _ := strconv.ParseUint(c.Query("categoryId"), 10, 32)
list, err := repositories.GetVideos(uint(categoryID), false)
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideosResponse(list))
}
func AdminCreateVideo(c *gin.Context) {
var item models.Video
if err := c.ShouldBindJSON(&item); err != nil {
utils.Error(c, 400, err.Error())
return
}
if item.ID == "" {
item.ID = "video_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
if err := repositories.CreateVideo(&item); err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoResponse(&item))
}
func AdminUpdateVideo(c *gin.Context) {
id := c.Param("id")
var item models.Video
if err := c.ShouldBindJSON(&item); err != nil {
utils.Error(c, 400, err.Error())
return
}
item.ID = id
if err := repositories.UpdateVideo(&item); err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildVideoResponse(&item))
}
func AdminDeleteVideo(c *gin.Context) {
id := c.Param("id")
if err := repositories.DeleteVideo(id); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Video deleted", nil)
}
func AdminGetVideoByID(c *gin.Context) {
id := c.Param("id")
video, err := repositories.GetVideoByID(id)
if err != nil {
utils.ServerError(c, err)
return
}
if video == nil {
utils.Error(c, 404, "Video not found")
return
}
utils.Success(c, repositories.BuildVideoResponse(video))
}

View File

@@ -27,6 +27,7 @@ func main() {
repositories.MigratePostUserID()
repositories.MigrateUserAvatar()
repositories.MigrateUserProfileFields()
repositories.MigrateVideoModule()
// 初始化ip2region (如果文件不存在将降级为普通IP记录)
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
@@ -59,6 +60,14 @@ func main() {
api.GET("/works", handlers.GetWorks)
api.GET("/works/:id", handlers.GetWorkByID)
// 视频路由
api.GET("/video-categories", handlers.GetVideoCategories)
api.GET("/video-albums", handlers.GetVideoAlbums)
api.GET("/video-albums/:id", handlers.GetVideoAlbumByID)
api.GET("/video-albums/:id/videos", handlers.GetAlbumVideos)
api.GET("/videos", handlers.GetVideos)
api.GET("/videos/:id", handlers.GetVideoByID)
// 博客路由
api.GET("/posts", handlers.GetPosts)
api.GET("/posts/:id", handlers.GetPost)
@@ -146,6 +155,26 @@ func main() {
authAdmin.PUT("/works/:id", middleware.PermissionMiddleware("works", "update"), handlers.AdminUpdateWork)
authAdmin.DELETE("/works/:id", middleware.PermissionMiddleware("works", "delete"), handlers.AdminDeleteWork)
// 视频管理
authAdmin.GET("/video-categories", middleware.PermissionMiddleware("videos", "read"), handlers.AdminGetVideoCategories)
authAdmin.POST("/video-categories", middleware.PermissionMiddleware("videos", "create"), handlers.AdminCreateVideoCategory)
authAdmin.PUT("/video-categories/:id", middleware.PermissionMiddleware("videos", "update"), handlers.AdminUpdateVideoCategory)
authAdmin.DELETE("/video-categories/:id", middleware.PermissionMiddleware("videos", "delete"), handlers.AdminDeleteVideoCategory)
authAdmin.GET("/video-albums", middleware.PermissionMiddleware("videos", "read"), handlers.AdminGetVideoAlbums)
authAdmin.POST("/video-albums", middleware.PermissionMiddleware("videos", "create"), handlers.AdminCreateVideoAlbum)
authAdmin.PUT("/video-albums/:id", middleware.PermissionMiddleware("videos", "update"), handlers.AdminUpdateVideoAlbum)
authAdmin.DELETE("/video-albums/:id", middleware.PermissionMiddleware("videos", "delete"), handlers.AdminDeleteVideoAlbum)
authAdmin.GET("/video-albums/:id/video-ids", middleware.PermissionMiddleware("videos", "read"), handlers.AdminGetAlbumVideoIDs)
authAdmin.POST("/video-albums/:id/videos", middleware.PermissionMiddleware("videos", "update"), handlers.AdminAddVideoToAlbum)
authAdmin.DELETE("/video-albums/:id/videos/:videoId", middleware.PermissionMiddleware("videos", "update"), handlers.AdminRemoveVideoFromAlbum)
authAdmin.GET("/videos", middleware.PermissionMiddleware("videos", "read"), handlers.AdminGetVideos)
authAdmin.GET("/videos/:id", middleware.PermissionMiddleware("videos", "read"), handlers.AdminGetVideoByID)
authAdmin.POST("/videos", middleware.PermissionMiddleware("videos", "create"), handlers.AdminCreateVideo)
authAdmin.PUT("/videos/:id", middleware.PermissionMiddleware("videos", "update"), handlers.AdminUpdateVideo)
authAdmin.DELETE("/videos/:id", middleware.PermissionMiddleware("videos", "delete"), handlers.AdminDeleteVideo)
// 代码片段管理
authAdmin.GET("/snippets", middleware.PermissionMiddleware("snippets", "read"), handlers.AdminGetSnippets)
authAdmin.POST("/snippets", middleware.PermissionMiddleware("snippets", "create"), handlers.AdminCreateSnippet)

164
server/models/video.go Normal file
View File

@@ -0,0 +1,164 @@
package models
import (
"time"
"gorm.io/gorm"
)
// VideoCategory 视频分类模型
type VideoCategory struct {
ID uint `json:"id" gorm:"primaryKey;column:id"`
Name string `json:"name" gorm:"column:name"`
Slug string `json:"slug" gorm:"column:slug;uniqueIndex"`
Description string `json:"description" gorm:"column:description;type:text"`
SortOrder uint `json:"sortOrder" gorm:"column:sort_order;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
}
func (VideoCategory) TableName() string { return "video_categories" }
func (vc *VideoCategory) BeforeCreate(tx *gorm.DB) error {
now := time.Now().Unix()
if vc.CreatedAt == 0 {
vc.CreatedAt = now
}
if vc.UpdatedAt == 0 {
vc.UpdatedAt = now
}
return nil
}
func (vc *VideoCategory) BeforeUpdate(tx *gorm.DB) error {
vc.UpdatedAt = time.Now().Unix()
return nil
}
// VideoCategoryResponse 视频分类 API 响应
type VideoCategoryResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
SortOrder uint `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
VideoCount int64 `json:"videoCount,omitempty"`
AlbumCount int64 `json:"albumCount,omitempty"`
}
// VideoAlbum 视频专辑模型
type VideoAlbum struct {
ID uint `json:"id" gorm:"primaryKey;column:id"`
Name string `json:"name" gorm:"column:name"`
Description string `json:"description" gorm:"column:description;type:text"`
Cover string `json:"cover" gorm:"column:cover"`
CategoryID uint `json:"categoryId" gorm:"column:category_id;index"`
IsActive int `json:"isActive" gorm:"column:is_active;default:1"`
SortOrder uint `json:"sortOrder" gorm:"column:sort_order;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
}
func (VideoAlbum) TableName() string { return "video_albums" }
func (va *VideoAlbum) BeforeCreate(tx *gorm.DB) error {
now := time.Now().Unix()
if va.CreatedAt == 0 {
va.CreatedAt = now
}
if va.UpdatedAt == 0 {
va.UpdatedAt = now
}
return nil
}
func (va *VideoAlbum) BeforeUpdate(tx *gorm.DB) error {
va.UpdatedAt = time.Now().Unix()
return nil
}
// VideoAlbumResponse 视频专辑 API 响应
type VideoAlbumResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Cover string `json:"cover"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName,omitempty"`
IsActive int `json:"isActive"`
SortOrder uint `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
VideoCount int64 `json:"videoCount,omitempty"`
}
// Video 视频模型
type Video struct {
ID string `json:"id" gorm:"primaryKey;column:id"`
Title string `json:"title" gorm:"column:title"`
Description string `json:"description" gorm:"column:description;type:text"`
VideoURL string `json:"videoUrl" gorm:"column:video_url"`
Cover string `json:"cover" gorm:"column:cover"`
Poster string `json:"poster" gorm:"column:poster"`
CategoryID uint `json:"categoryId" gorm:"column:category_id;index"`
Duration int `json:"duration" gorm:"column:duration;default:0"`
IsPublished int `json:"isPublished" gorm:"column:is_published;default:1"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
}
func (Video) TableName() string { return "videos" }
func (v *Video) BeforeCreate(tx *gorm.DB) error {
now := time.Now().Unix()
if v.CreatedAt == 0 {
v.CreatedAt = now
}
if v.UpdatedAt == 0 {
v.UpdatedAt = now
}
return nil
}
func (v *Video) BeforeUpdate(tx *gorm.DB) error {
v.UpdatedAt = time.Now().Unix()
return nil
}
// VideoResponse 视频 API 响应
type VideoResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
VideoURL string `json:"videoUrl"`
Cover string `json:"cover"`
Poster string `json:"poster"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName,omitempty"`
Duration int `json:"duration"`
IsPublished int `json:"isPublished"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// AlbumVideo 专辑视频关联
type AlbumVideo struct {
AlbumID uint `json:"albumId" gorm:"primaryKey;column:album_id"`
VideoID string `json:"videoId" gorm:"primaryKey;column:video_id"`
SortOrder uint `json:"sortOrder" gorm:"column:sort_order;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
}
func (AlbumVideo) TableName() string { return "album_videos" }
func (av *AlbumVideo) BeforeCreate(tx *gorm.DB) error {
if av.CreatedAt == 0 {
av.CreatedAt = time.Now().Unix()
}
return nil
}

View File

@@ -13,6 +13,8 @@ type Work struct {
Category string `json:"category" gorm:"column:category"`
Year string `json:"year" gorm:"column:year"`
HeroImg string `json:"heroImg" gorm:"column:hero_img"`
HeroVideo string `json:"heroVideo" gorm:"column:hero_video"`
VideoID string `json:"videoId" gorm:"column:video_id"`
Description string `json:"desc" gorm:"column:description;type:text"`
Links string `json:"links" gorm:"column:links;type:text"` // JSON格式存储链接
IsFeatured int `json:"isFeatured" gorm:"column:is_featured;default:0"`
@@ -107,6 +109,9 @@ type WorkResponse struct {
Category string `json:"category"`
Year string `json:"year"`
HeroImg string `json:"heroImg"`
HeroVideo string `json:"heroVideo,omitempty"`
VideoID string `json:"videoId,omitempty"`
VideoURL string `json:"videoUrl,omitempty"`
Desc string `json:"desc"`
TechStack []map[string]interface{} `json:"techStack"`
Gallery []string `json:"gallery"`

View File

@@ -540,6 +540,8 @@ CREATE TABLE `works` (
`category` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作品分类',
`year` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创作年份',
`hero_img` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作品主图URL',
`hero_video` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '演示视频URL',
`video_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联视频库ID',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作品详细描述',
`links` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '作品链接JSON格式',
`is_featured` tinyint(1) NULL DEFAULT 0 COMMENT '是否为精选作品01',
@@ -566,4 +568,74 @@ CREATE TABLE `post_snippets` (
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章代码片段关联表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for video_categories
-- ----------------------------
DROP TABLE IF EXISTS `video_categories`;
CREATE TABLE `video_categories` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`slug` varchar(100) NOT NULL,
`description` text NULL,
`sort_order` int UNSIGNED NOT NULL DEFAULT 0,
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE INDEX `idx_slug`(`slug`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '视频分类表';
-- ----------------------------
-- Table structure for video_albums
-- ----------------------------
DROP TABLE IF EXISTS `video_albums`;
CREATE TABLE `video_albums` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(200) NOT NULL,
`description` text NULL,
`cover` varchar(500) NULL,
`category_id` int UNSIGNED NOT NULL DEFAULT 0,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`sort_order` int UNSIGNED NOT NULL DEFAULT 0,
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_category_id`(`category_id`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '视频专辑表';
-- ----------------------------
-- Table structure for videos
-- ----------------------------
DROP TABLE IF EXISTS `videos`;
CREATE TABLE `videos` (
`id` varchar(50) NOT NULL,
`title` varchar(200) NOT NULL,
`description` text NULL,
`video_url` varchar(500) NOT NULL,
`cover` varchar(500) NULL,
`poster` varchar(500) NULL,
`category_id` int UNSIGNED NOT NULL DEFAULT 0,
`duration` int NOT NULL DEFAULT 0,
`is_published` tinyint(1) NOT NULL DEFAULT 1,
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_category_id`(`category_id`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '视频表';
-- ----------------------------
-- Table structure for album_videos
-- ----------------------------
DROP TABLE IF EXISTS `album_videos`;
CREATE TABLE `album_videos` (
`album_id` int UNSIGNED NOT NULL,
`video_id` varchar(50) NOT NULL,
`sort_order` int UNSIGNED NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`album_id`, `video_id`),
INDEX `idx_video_id`(`video_id`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '专辑视频关联表';
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -3,6 +3,7 @@ package repositories
import (
"fmt"
"log"
"time"
"github.com/niangaodev/art-code/config"
)
@@ -195,3 +196,100 @@ func MigrateUserProfileFields() {
execSQL("ALTER TABLE `users` ADD COLUMN `wechat_qrcode` VARCHAR(500) NULL DEFAULT NULL COMMENT '微信二维码图片URL' AFTER `wechat`")
}
}
// MigrateVideoModule 创建视频模块表及作品视频字段
func MigrateVideoModule() {
log.Printf("Migrating video module...")
if !tableExists("video_categories") {
execSQL(`CREATE TABLE video_categories (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
deleted_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE INDEX idx_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频分类表'`)
}
if !tableExists("video_albums") {
execSQL(`CREATE TABLE video_albums (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
description TEXT NULL,
cover VARCHAR(500) NULL,
category_id INT UNSIGNED NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
deleted_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
INDEX idx_category_id (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频专辑表'`)
}
if !tableExists("videos") {
execSQL(`CREATE TABLE videos (
id VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT NULL,
video_url VARCHAR(500) NOT NULL,
cover VARCHAR(500) NULL,
poster VARCHAR(500) NULL,
category_id INT UNSIGNED NOT NULL DEFAULT 0,
duration INT NOT NULL DEFAULT 0,
is_published TINYINT(1) NOT NULL DEFAULT 1,
deleted_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
INDEX idx_category_id (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频表'`)
}
if !tableExists("album_videos") {
execSQL(`CREATE TABLE album_videos (
album_id INT UNSIGNED NOT NULL,
video_id VARCHAR(50) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (album_id, video_id),
INDEX idx_video_id (video_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='专辑视频关联表'`)
}
if !columnExists("works", "hero_video") {
execSQL("ALTER TABLE `works` ADD COLUMN `hero_video` VARCHAR(500) NULL DEFAULT NULL COMMENT '演示视频URL' AFTER `hero_img`")
}
if !columnExists("works", "video_id") {
execSQL("ALTER TABLE `works` ADD COLUMN `video_id` VARCHAR(50) NULL DEFAULT NULL COMMENT '关联视频库ID' AFTER `hero_video`")
}
// 插入 videos 权限(若不存在)
now := time.Now().Unix()
actions := []string{"read", "create", "update", "delete"}
for _, action := range actions {
var count int64
config.DB.Raw("SELECT COUNT(*) FROM permissions WHERE resource = ? AND action = ? AND deleted_at = 0", "videos", action).Scan(&count)
if count == 0 {
execSQL(fmt.Sprintf("INSERT INTO permissions (name, resource, action, deleted_at, created_at, updated_at) VALUES ('videos:%s', 'videos', '%s', 0, %d, %d)", action, action, now, now))
}
}
// 为 role_id=1 的管理员角色授予 videos 权限
type permRow struct{ ID uint }
var rows []permRow
config.DB.Raw("SELECT id FROM permissions WHERE resource = 'videos' AND deleted_at = 0").Scan(&rows)
for _, row := range rows {
var count int64
config.DB.Raw("SELECT COUNT(*) FROM role_permissions WHERE role_id = 1 AND permission_id = ?", row.ID).Scan(&count)
if count == 0 {
execSQL(fmt.Sprintf("INSERT INTO role_permissions (role_id, permission_id) VALUES (1, %d)", row.ID))
}
}
}

View File

@@ -0,0 +1,320 @@
package repositories
import (
"log"
"time"
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
"gorm.io/gorm"
)
// ========== 视频分类 ==========
func GetVideoCategories() ([]models.VideoCategory, error) {
var list []models.VideoCategory
err := config.DB.Model(&models.VideoCategory{}).
Where("deleted_at = ?", 0).
Order("sort_order ASC, id ASC").
Find(&list).Error
return list, err
}
func GetVideoCategoryByID(id uint) (*models.VideoCategory, error) {
var item models.VideoCategory
err := config.DB.Model(&models.VideoCategory{}).
Where("id = ? AND deleted_at = ?", id, 0).
First(&item).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &item, err
}
func CreateVideoCategory(item *models.VideoCategory) error {
return config.DB.Create(item).Error
}
func UpdateVideoCategory(item *models.VideoCategory) error {
return config.DB.Model(&models.VideoCategory{}).
Where("id = ? AND deleted_at = ?", item.ID, 0).
Updates(map[string]interface{}{
"name": item.Name,
"slug": item.Slug,
"description": item.Description,
"sort_order": item.SortOrder,
"updated_at": time.Now().Unix(),
}).Error
}
func DeleteVideoCategory(id uint) error {
return config.DB.Model(&models.VideoCategory{}).
Where("id = ?", id).
Update("deleted_at", time.Now().Unix()).Error
}
func BuildVideoCategoryResponse(item *models.VideoCategory) models.VideoCategoryResponse {
var videoCount, albumCount int64
config.DB.Model(&models.Video{}).
Where("category_id = ? AND deleted_at = ? AND is_published = ?", item.ID, 0, 1).
Count(&videoCount)
config.DB.Model(&models.VideoAlbum{}).
Where("category_id = ? AND deleted_at = ? AND is_active = ?", item.ID, 0, 1).
Count(&albumCount)
return models.VideoCategoryResponse{
ID: item.ID,
Name: item.Name,
Slug: item.Slug,
Description: item.Description,
SortOrder: item.SortOrder,
CreatedAt: formatTimestamp(item.CreatedAt),
UpdatedAt: formatTimestamp(item.UpdatedAt),
VideoCount: videoCount,
AlbumCount: albumCount,
}
}
func BuildVideoCategoriesResponse(list []models.VideoCategory) []models.VideoCategoryResponse {
res := make([]models.VideoCategoryResponse, 0, len(list))
for i := range list {
res = append(res, BuildVideoCategoryResponse(&list[i]))
}
return res
}
// ========== 视频专辑 ==========
func GetVideoAlbums(categoryID uint) ([]models.VideoAlbum, error) {
var list []models.VideoAlbum
q := config.DB.Model(&models.VideoAlbum{}).Where("deleted_at = ?", 0)
if categoryID > 0 {
q = q.Where("category_id = ?", categoryID)
}
err := q.Order("sort_order ASC, created_at DESC").Find(&list).Error
return list, err
}
func GetActiveVideoAlbums(categoryID uint) ([]models.VideoAlbum, error) {
var list []models.VideoAlbum
q := config.DB.Model(&models.VideoAlbum{}).Where("deleted_at = ? AND is_active = ?", 0, 1)
if categoryID > 0 {
q = q.Where("category_id = ?", categoryID)
}
err := q.Order("sort_order ASC, created_at DESC").Find(&list).Error
return list, err
}
func GetVideoAlbumByID(id uint) (*models.VideoAlbum, error) {
var item models.VideoAlbum
err := config.DB.Model(&models.VideoAlbum{}).
Where("id = ? AND deleted_at = ?", id, 0).
First(&item).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &item, err
}
func CreateVideoAlbum(item *models.VideoAlbum) error {
return config.DB.Create(item).Error
}
func UpdateVideoAlbum(item *models.VideoAlbum) error {
return config.DB.Model(&models.VideoAlbum{}).
Where("id = ? AND deleted_at = ?", item.ID, 0).
Updates(map[string]interface{}{
"name": item.Name,
"description": item.Description,
"cover": item.Cover,
"category_id": item.CategoryID,
"is_active": item.IsActive,
"sort_order": item.SortOrder,
"updated_at": time.Now().Unix(),
}).Error
}
func DeleteVideoAlbum(id uint) error {
return config.DB.Model(&models.VideoAlbum{}).
Where("id = ?", id).
Update("deleted_at", time.Now().Unix()).Error
}
func GetAlbumVideoCount(albumID uint) int64 {
var count int64
config.DB.Model(&models.AlbumVideo{}).
Joins("JOIN videos v ON v.id = album_videos.video_id").
Where("album_videos.album_id = ? AND v.deleted_at = ? AND v.is_published = ?", albumID, 0, 1).
Count(&count)
return count
}
func BuildVideoAlbumResponse(item *models.VideoAlbum) models.VideoAlbumResponse {
categoryName := ""
if cat, _ := GetVideoCategoryByID(item.CategoryID); cat != nil {
categoryName = cat.Name
}
return models.VideoAlbumResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
Cover: item.Cover,
CategoryID: item.CategoryID,
CategoryName: categoryName,
IsActive: item.IsActive,
SortOrder: item.SortOrder,
CreatedAt: formatTimestamp(item.CreatedAt),
UpdatedAt: formatTimestamp(item.UpdatedAt),
VideoCount: GetAlbumVideoCount(item.ID),
}
}
func BuildVideoAlbumsResponse(list []models.VideoAlbum) []models.VideoAlbumResponse {
res := make([]models.VideoAlbumResponse, 0, len(list))
for i := range list {
res = append(res, BuildVideoAlbumResponse(&list[i]))
}
return res
}
// ========== 视频 ==========
func GetVideos(categoryID uint, publishedOnly bool) ([]models.Video, error) {
var list []models.Video
q := config.DB.Model(&models.Video{}).Where("deleted_at = ?", 0)
if categoryID > 0 {
q = q.Where("category_id = ?", categoryID)
}
if publishedOnly {
q = q.Where("is_published = ?", 1)
}
err := q.Order("created_at DESC").Find(&list).Error
return list, err
}
func GetVideoByID(id string) (*models.Video, error) {
var item models.Video
err := config.DB.Model(&models.Video{}).
Where("id = ? AND deleted_at = ?", id, 0).
First(&item).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return &item, err
}
func CreateVideo(item *models.Video) error {
return config.DB.Create(item).Error
}
func UpdateVideo(item *models.Video) error {
return config.DB.Model(&models.Video{}).
Where("id = ? AND deleted_at = ?", item.ID, 0).
Updates(map[string]interface{}{
"title": item.Title,
"description": item.Description,
"video_url": item.VideoURL,
"cover": item.Cover,
"poster": item.Poster,
"category_id": item.CategoryID,
"duration": item.Duration,
"is_published": item.IsPublished,
"updated_at": time.Now().Unix(),
}).Error
}
func DeleteVideo(id string) error {
return config.DB.Model(&models.Video{}).
Where("id = ?", id).
Update("deleted_at", time.Now().Unix()).Error
}
func BuildVideoResponse(item *models.Video) models.VideoResponse {
categoryName := ""
if cat, _ := GetVideoCategoryByID(item.CategoryID); cat != nil {
categoryName = cat.Name
}
poster := item.Poster
if poster == "" {
poster = item.Cover
}
return models.VideoResponse{
ID: item.ID,
Title: item.Title,
Description: item.Description,
VideoURL: item.VideoURL,
Cover: item.Cover,
Poster: poster,
CategoryID: item.CategoryID,
CategoryName: categoryName,
Duration: item.Duration,
IsPublished: item.IsPublished,
CreatedAt: formatTimestamp(item.CreatedAt),
UpdatedAt: formatTimestamp(item.UpdatedAt),
}
}
func BuildVideosResponse(list []models.Video) []models.VideoResponse {
res := make([]models.VideoResponse, 0, len(list))
for i := range list {
res = append(res, BuildVideoResponse(&list[i]))
}
return res
}
// ResolveVideoURL 根据 video_id 解析播放地址
func ResolveVideoURL(videoID string) string {
if videoID == "" {
return ""
}
v, err := GetVideoByID(videoID)
if err != nil || v == nil {
log.Printf("ResolveVideoURL: video not found %s", videoID)
return ""
}
return v.VideoURL
}
// ========== 专辑视频关联 ==========
func GetVideosByAlbumID(albumID uint, publishedOnly bool) ([]models.Video, error) {
var list []models.Video
q := config.DB.Model(&models.Video{}).
Joins("JOIN album_videos av ON av.video_id = videos.id").
Where("av.album_id = ? AND videos.deleted_at = ?", albumID, 0)
if publishedOnly {
q = q.Where("videos.is_published = ?", 1)
}
err := q.Order("av.sort_order ASC, av.created_at ASC").Find(&list).Error
return list, err
}
func AddVideoToAlbum(albumID uint, videoID string, sortOrder uint) error {
av := models.AlbumVideo{
AlbumID: albumID,
VideoID: videoID,
SortOrder: sortOrder,
CreatedAt: time.Now().Unix(),
}
return config.DB.Create(&av).Error
}
func RemoveVideoFromAlbum(albumID uint, videoID string) error {
return config.DB.Where("album_id = ? AND video_id = ?", albumID, videoID).
Delete(&models.AlbumVideo{}).Error
}
func SyncAlbumVideos(albumID uint, videoIDs []string) error {
if err := config.DB.Where("album_id = ?", albumID).Delete(&models.AlbumVideo{}).Error; err != nil {
return err
}
for i, vid := range videoIDs {
if vid == "" {
continue
}
if err := AddVideoToAlbum(albumID, vid, uint(i+1)); err != nil {
log.Printf("SyncAlbumVideos error: %v", err)
return err
}
}
return nil
}

View File

@@ -120,12 +120,24 @@ func BuildWorkResponse(work *models.Work) (*models.WorkResponse, error) {
nextWorkID = ""
}
// 解析视频播放地址:优先 video_id否则 hero_video
videoURL := ""
if work.VideoID != "" {
videoURL = ResolveVideoURL(work.VideoID)
}
if videoURL == "" {
videoURL = work.HeroVideo
}
return &models.WorkResponse{
ID: work.ID,
Title: work.Title,
Category: work.Category,
Year: work.Year,
HeroImg: work.HeroImg,
HeroVideo: work.HeroVideo,
VideoID: work.VideoID,
VideoURL: videoURL,
Desc: work.Description,
TechStack: techStackResponse,
Gallery: galleryImages,
@@ -255,6 +267,8 @@ func UpdateWork(work *models.Work) error {
"category": work.Category,
"year": work.Year,
"hero_img": work.HeroImg,
"hero_video": work.HeroVideo,
"video_id": work.VideoID,
"description": work.Description,
"is_featured": work.IsFeatured,
"updated_at": time.Now().Unix(),

View File

@@ -0,0 +1,127 @@
-- ============================================================
-- 视频模块 + 作品演示视频 数据库迁移脚本
-- 文件server/scripts/migrate_video_module.sql
-- 说明:可重复执行(幂等),适用于已有 nl_blog 数据库的增量升级
-- 用法mysql -u root -p your_database < scripts/migrate_video_module.sql
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 1. 视频分类表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `video_categories` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '分类名称',
`slug` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'URL标识',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '分类描述',
`sort_order` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)',
`deleted_at` bigint NOT NULL DEFAULT 0 COMMENT '软删除时间戳',
`created_at` bigint NOT NULL DEFAULT 0 COMMENT '创建时间Unix秒',
`updated_at` bigint NOT NULL DEFAULT 0 COMMENT '更新时间Unix秒',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `idx_slug`(`slug` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '视频分类表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- 2. 视频专辑表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `video_albums` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '专辑ID',
`name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '专辑名称',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '专辑描述',
`cover` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '专辑封面URL',
`category_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属视频分类ID',
`is_active` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用0否 1是',
`sort_order` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)',
`deleted_at` bigint NOT NULL DEFAULT 0 COMMENT '软删除时间戳',
`created_at` bigint NOT NULL DEFAULT 0 COMMENT '创建时间Unix秒',
`updated_at` bigint NOT NULL DEFAULT 0 COMMENT '更新时间Unix秒',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_category_id`(`category_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '视频专辑表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- 3. 视频表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `videos` (
`id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '视频唯一标识',
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '视频标题',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '视频描述',
`video_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '视频文件URL',
`cover` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '封面图URL',
`poster` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '播放器封面URL',
`category_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属视频分类ID',
`duration` int NOT NULL DEFAULT 0 COMMENT '时长(秒)',
`is_published` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否发布0草稿 1已发布',
`deleted_at` bigint NOT NULL DEFAULT 0 COMMENT '软删除时间戳',
`created_at` bigint NOT NULL DEFAULT 0 COMMENT '创建时间Unix秒',
`updated_at` bigint NOT NULL DEFAULT 0 COMMENT '更新时间Unix秒',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_category_id`(`category_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '视频表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- 4. 专辑-视频关联表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `album_videos` (
`album_id` int UNSIGNED NOT NULL COMMENT '专辑ID',
`video_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '视频ID',
`sort_order` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '专辑内排序',
`created_at` bigint NOT NULL DEFAULT 0 COMMENT '创建时间Unix秒',
PRIMARY KEY (`album_id`, `video_id`) USING BTREE,
INDEX `idx_video_id`(`video_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '专辑视频关联表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- 5. 作品表新增演示视频字段(幂等添加)
-- ----------------------------
-- 5.1 hero_video独立上传的视频 URL
SET @col_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'works' AND COLUMN_NAME = 'hero_video'
);
SET @sql = IF(@col_exists = 0,
'ALTER TABLE `works` ADD COLUMN `hero_video` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT ''演示视频URL'' AFTER `hero_img`',
'SELECT ''works.hero_video already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 5.2 video_id关联 videos 表 ID从视频库选择时使用
SET @col_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'works' AND COLUMN_NAME = 'video_id'
);
SET @sql = IF(@col_exists = 0,
'ALTER TABLE `works` ADD COLUMN `video_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT ''关联视频库ID'' AFTER `hero_video`',
'SELECT ''works.video_id already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ----------------------------
-- 6. 视频模块后台权限videos:read/create/update/delete
-- ----------------------------
INSERT IGNORE INTO `permissions` (`name`, `resource`, `action`, `deleted_at`, `created_at`, `updated_at`) VALUES
('videos:read', 'videos', 'read', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('videos:create', 'videos', 'create', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('videos:update', 'videos', 'update', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('videos:delete', 'videos', 'delete', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- 为管理员角色role_id = 1授予 videos 权限
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT 1, p.id FROM `permissions` p
WHERE p.resource = 'videos' AND p.deleted_at = 0
AND NOT EXISTS (
SELECT 1 FROM `role_permissions` rp
WHERE rp.role_id = 1 AND rp.permission_id = p.id
);
SET FOREIGN_KEY_CHECKS = 1;
SELECT 'migrate_video_module.sql executed successfully' AS result;