优化页面、修复BUG
This commit is contained in:
@@ -250,6 +250,7 @@ const menuItems = ref<MenuItemWithState[]>([
|
||||
isOpen: false,
|
||||
children: [
|
||||
{ title: '全局配置', path: '/admin/settings', icon: '🛠️' },
|
||||
{ title: 'PPT 模板', path: '/admin/ppt-templates', icon: '📽️' },
|
||||
{ title: '操作日志', path: '/admin/logs', icon: '📋' },
|
||||
{ title: '访问日志', path: '/admin/access-logs', icon: '🌐' }
|
||||
]
|
||||
|
||||
@@ -14,18 +14,20 @@ import '../../styles/pptPreview.css'
|
||||
|
||||
interface Props {
|
||||
slide: PptSlide
|
||||
theme?: 'light' | 'dark'
|
||||
compact?: boolean
|
||||
/** 分步揭示:0=仅标题居中;Infinity=显示全部(缩略图) */
|
||||
visibleStep?: number
|
||||
slideDirection?: SlideDirection
|
||||
templateVars?: Record<string, string>
|
||||
revealTransition?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
theme: 'dark',
|
||||
compact: false,
|
||||
visibleStep: Number.POSITIVE_INFINITY,
|
||||
slideDirection: 'none',
|
||||
templateVars: () => ({}),
|
||||
revealTransition: 'ppt-reveal',
|
||||
})
|
||||
|
||||
type ImageLoadState = 'initial' | 'noReferrer' | 'failed'
|
||||
@@ -33,7 +35,6 @@ type ImageLoadState = 'initial' | 'noReferrer' | 'failed'
|
||||
const imageLoadStates = ref<Map<string, ImageLoadState>>(new Map())
|
||||
const videoRefs = ref<Map<number, HTMLVideoElement>>(new Map())
|
||||
|
||||
const themeClass = computed(() => `ppt-theme-${props.theme}`)
|
||||
const showFull = computed(() => !Number.isFinite(props.visibleStep))
|
||||
|
||||
const titleMetaLines = computed(() => {
|
||||
@@ -127,7 +128,7 @@ const stopMediaEvent = (e: Event) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ppt-preview-root" :class="themeClass">
|
||||
<div class="ppt-preview-root" :style="templateVars">
|
||||
<!-- 封面 -->
|
||||
<div
|
||||
v-if="slide.kind === 'title'"
|
||||
@@ -135,7 +136,7 @@ const stopMediaEvent = (e: Event) => {
|
||||
:class="{ 'ppt-slide-canvas--focus-title': isTitleFocus }"
|
||||
>
|
||||
<h1 class="ppt-slide-title-main">{{ slide.title }}</h1>
|
||||
<Transition name="ppt-reveal">
|
||||
<Transition :name="revealTransition">
|
||||
<div v-if="showTitleMeta && titleMetaLines.length" class="ppt-slide-title-meta">
|
||||
<div v-for="(line, idx) in titleMetaLines" :key="idx">{{ line }}</div>
|
||||
</div>
|
||||
@@ -159,14 +160,14 @@ const stopMediaEvent = (e: Event) => {
|
||||
{{ sectionTitleParts.parent }}
|
||||
</p>
|
||||
<h2 class="ppt-slide-section-title">{{ sectionTitleParts.title }}</h2>
|
||||
<Transition name="ppt-reveal">
|
||||
<Transition :name="revealTransition">
|
||||
<div v-if="!isTitleFocus" class="ppt-slide-section-accent"></div>
|
||||
</Transition>
|
||||
</header>
|
||||
|
||||
<div v-if="!isTitleFocus" class="ppt-slide-body">
|
||||
<template v-for="(unit, idx) in visibleUnits" :key="idx">
|
||||
<Transition name="ppt-reveal">
|
||||
<Transition :name="revealTransition">
|
||||
<div
|
||||
v-if="isUnitVisible(idx)"
|
||||
class="ppt-block-reveal"
|
||||
|
||||
@@ -12,21 +12,27 @@ import '../../styles/pptPreview.css'
|
||||
|
||||
interface Props {
|
||||
slides: PptSlide[]
|
||||
theme?: 'light' | 'dark'
|
||||
currentIndex: number
|
||||
currentStep: number
|
||||
slideDirection?: SlideDirection
|
||||
isFullscreen?: boolean
|
||||
showThumbnails?: boolean
|
||||
toolbarVisible?: boolean
|
||||
templateVars?: Record<string, string>
|
||||
slideTransitionForward?: string
|
||||
slideTransitionBackward?: string
|
||||
revealTransition?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
theme: 'dark',
|
||||
slideDirection: 'none',
|
||||
isFullscreen: false,
|
||||
showThumbnails: true,
|
||||
toolbarVisible: true,
|
||||
templateVars: () => ({}),
|
||||
slideTransitionForward: 'ppt-slide',
|
||||
slideTransitionBackward: 'ppt-slide-back',
|
||||
revealTransition: 'ppt-reveal',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -60,6 +66,12 @@ const isEdgeToEdge = computed(() => props.isFullscreen && !props.toolbarVisible)
|
||||
|
||||
const FULL_STEP = Number.POSITIVE_INFINITY
|
||||
|
||||
const slideTransitionName = computed(() =>
|
||||
props.slideDirection === 'backward'
|
||||
? props.slideTransitionBackward
|
||||
: props.slideTransitionForward
|
||||
)
|
||||
|
||||
const handleSlideClick = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
if (isMediaInteractionTarget(e.target)) return
|
||||
@@ -74,11 +86,9 @@ const handleSlideContextMenu = (e: MouseEvent) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ppt-preview-root ppt-viewer flex flex-col flex-1 min-h-0 w-full bg-[#0a0a0a]"
|
||||
:class="[
|
||||
`ppt-theme-${theme}`,
|
||||
{ 'ppt-viewer--fullscreen': isEdgeToEdge },
|
||||
]"
|
||||
class="ppt-preview-root ppt-viewer flex flex-col flex-1 min-h-0 w-full"
|
||||
:class="{ 'ppt-viewer--fullscreen': isEdgeToEdge }"
|
||||
:style="templateVars"
|
||||
>
|
||||
<div class="ppt-stage">
|
||||
<button
|
||||
@@ -96,14 +106,15 @@ const handleSlideContextMenu = (e: MouseEvent) => {
|
||||
@click="handleSlideClick"
|
||||
@contextmenu="handleSlideContextMenu"
|
||||
>
|
||||
<Transition :name="slideDirection === 'backward' ? 'ppt-slide-back' : 'ppt-slide'">
|
||||
<Transition :name="slideTransitionName">
|
||||
<PptSlideCanvas
|
||||
v-if="currentSlide"
|
||||
:key="currentIndex"
|
||||
:slide="currentSlide"
|
||||
:theme="theme"
|
||||
:visible-step="currentStep"
|
||||
:slide-direction="slideDirection"
|
||||
:template-vars="templateVars"
|
||||
:reveal-transition="revealTransition"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -142,7 +153,13 @@ const handleSlideContextMenu = (e: MouseEvent) => {
|
||||
@click="emit('goToSlide', idx)"
|
||||
>
|
||||
<div class="ppt-thumbnail-inner">
|
||||
<PptSlideCanvas :slide="slide" :theme="theme" compact :visible-step="FULL_STEP" />
|
||||
<PptSlideCanvas
|
||||
:slide="slide"
|
||||
compact
|
||||
:visible-step="FULL_STEP"
|
||||
:template-vars="templateVars"
|
||||
:reveal-transition="revealTransition"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
130
client/src/composables/usePptTemplate.ts
Normal file
130
client/src/composables/usePptTemplate.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { parsePptTemplateConfig, SYSTEM_DEFAULT_PPT_CONFIG } from '../config/pptTemplateDefaults'
|
||||
import { fetchPptTemplates, type PptTemplateApiItem } from '../services/api'
|
||||
import type { PptTemplate, PptTemplateConfig } from '../types/pptTemplate'
|
||||
import { applyPptTemplate } from '../utils/applyPptTemplate'
|
||||
|
||||
const STORAGE_KEY = 'ppt_selected_template_id'
|
||||
|
||||
function readStoredTemplateId(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const id = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(id) && id > 0 ? id : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredTemplateId(id: number): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(id))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTemplate(raw: PptTemplateApiItem): PptTemplate {
|
||||
return {
|
||||
...raw,
|
||||
config: parsePptTemplateConfig(raw.config),
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackTemplate(): PptTemplate {
|
||||
return {
|
||||
id: 0,
|
||||
name: '默认模板',
|
||||
slug: 'default',
|
||||
description: '',
|
||||
config: SYSTEM_DEFAULT_PPT_CONFIG,
|
||||
isSystem: true,
|
||||
isDefault: true,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePptTemplate() {
|
||||
const templates = ref<PptTemplate[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedTemplateId = ref<number | null>(readStoredTemplateId())
|
||||
|
||||
const loadTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchPptTemplates()
|
||||
templates.value = list.map(normalizeTemplate)
|
||||
if (!templates.value.length) {
|
||||
templates.value = [buildFallbackTemplate()]
|
||||
return
|
||||
}
|
||||
|
||||
const storedId = selectedTemplateId.value
|
||||
const storedExists = storedId != null && templates.value.some((t) => t.id === storedId)
|
||||
if (!storedExists) {
|
||||
const fallback =
|
||||
templates.value.find((t) => t.isDefault) ??
|
||||
templates.value.find((t) => t.isSystem) ??
|
||||
templates.value[0]
|
||||
if (fallback) {
|
||||
selectedTemplateId.value = fallback.id
|
||||
writeStoredTemplateId(fallback.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load PPT templates:', err)
|
||||
templates.value = [buildFallbackTemplate()]
|
||||
selectedTemplateId.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const activeTemplate = computed((): PptTemplate => {
|
||||
if (!templates.value.length) return buildFallbackTemplate()
|
||||
const id = selectedTemplateId.value
|
||||
const found = id != null ? templates.value.find((t) => t.id === id) : undefined
|
||||
if (found) return found
|
||||
return (
|
||||
templates.value.find((t) => t.isDefault) ??
|
||||
templates.value.find((t) => t.isSystem) ??
|
||||
templates.value[0] ??
|
||||
buildFallbackTemplate()
|
||||
)
|
||||
})
|
||||
|
||||
const applied = computed(() => applyPptTemplate(activeTemplate.value.config))
|
||||
|
||||
const templateVars = computed(() => applied.value.templateVars)
|
||||
const slideTransitionForward = computed(() => applied.value.slideTransitionForward)
|
||||
const slideTransitionBackward = computed(() => applied.value.slideTransitionBackward)
|
||||
const revealTransition = computed(() => applied.value.revealTransition)
|
||||
const showThumbnailsDefault = computed(
|
||||
() => activeTemplate.value.config.ui.showThumbnailsDefault
|
||||
)
|
||||
|
||||
const setTemplate = (id: number) => {
|
||||
if (!templates.value.some((t) => t.id === id)) return
|
||||
selectedTemplateId.value = id
|
||||
writeStoredTemplateId(id)
|
||||
}
|
||||
|
||||
const applyConfigPreview = (config: PptTemplateConfig) => applyPptTemplate(config)
|
||||
|
||||
return {
|
||||
templates,
|
||||
loading,
|
||||
selectedTemplateId,
|
||||
activeTemplate,
|
||||
templateVars,
|
||||
slideTransitionForward,
|
||||
slideTransitionBackward,
|
||||
revealTransition,
|
||||
showThumbnailsDefault,
|
||||
loadTemplates,
|
||||
setTemplate,
|
||||
applyConfigPreview,
|
||||
}
|
||||
}
|
||||
72
client/src/config/pptTemplateDefaults.ts
Normal file
72
client/src/config/pptTemplateDefaults.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { PptTemplateConfig } from '../types/pptTemplate'
|
||||
import type { PptSectionSlide } from '../utils/articlePptSlides'
|
||||
|
||||
/** 后台/预览用示例幻灯片 */
|
||||
export const PPT_TEMPLATE_PREVIEW_SLIDE: PptSectionSlide = {
|
||||
kind: 'section',
|
||||
title: '示例章节 · 演示内容',
|
||||
continued: false,
|
||||
blocks: [
|
||||
{ type: 'h2', text: '小节标题' },
|
||||
{ type: 'paragraph', text: '这是模板预览示例段落,修改配色与动画可即时看到效果。' },
|
||||
{ type: 'list', ordered: true, items: ['步骤一:打开应用', '步骤二:完成操作'] },
|
||||
],
|
||||
}
|
||||
|
||||
export const SYSTEM_DEFAULT_PPT_CONFIG: PptTemplateConfig = {
|
||||
colors: {
|
||||
bg: '#141414',
|
||||
text: '#f5f5f5',
|
||||
muted: '#a0a0a0',
|
||||
accent: '#c9a962',
|
||||
codeBg: '#1e1e1e',
|
||||
codeText: '#eeeeee',
|
||||
border: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
animation: {
|
||||
revealDuration: '0.95s',
|
||||
revealLeaveDuration: '0.65s',
|
||||
slideDuration: '0.85s',
|
||||
layoutDuration: '0.75s',
|
||||
revealOffset: '10px',
|
||||
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
},
|
||||
transitions: {
|
||||
slide: 'crossfade',
|
||||
reveal: 'fadeUp',
|
||||
},
|
||||
ui: {
|
||||
showThumbnailsDefault: true,
|
||||
canvasBorderRadius: '0.5rem',
|
||||
},
|
||||
}
|
||||
|
||||
export const SYSTEM_DEFAULT_PPT_CONFIG_JSON = JSON.stringify(SYSTEM_DEFAULT_PPT_CONFIG)
|
||||
|
||||
export function clonePptTemplateConfig(config: PptTemplateConfig): PptTemplateConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as PptTemplateConfig
|
||||
}
|
||||
|
||||
export function createEmptyPptTemplateForm() {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
config: clonePptTemplateConfig(SYSTEM_DEFAULT_PPT_CONFIG),
|
||||
isDefault: false,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePptTemplateConfig(raw: unknown): PptTemplateConfig {
|
||||
const base = clonePptTemplateConfig(SYSTEM_DEFAULT_PPT_CONFIG)
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
|
||||
const input = raw as Partial<PptTemplateConfig>
|
||||
if (input.colors) Object.assign(base.colors, input.colors)
|
||||
if (input.animation) Object.assign(base.animation, input.animation)
|
||||
if (input.transitions) Object.assign(base.transitions, input.transitions)
|
||||
if (input.ui) Object.assign(base.ui, input.ui)
|
||||
return base
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import PptSlideViewer from '../components/ppt/PptSlideViewer.vue'
|
||||
import { usePptPresentation } from '../composables/usePptPresentation'
|
||||
import { usePptTemplate } from '../composables/usePptTemplate'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { fetchPost, type Post } from '../services/api'
|
||||
import { exportAsPptStructured, type ExportTheme } from '../utils/articleExport'
|
||||
import { exportAsPptStructured } from '../utils/articleExport'
|
||||
import { buildPptSlides } from '../utils/articlePptSlides'
|
||||
import { getCachedSiteTitle } from '../router'
|
||||
|
||||
@@ -21,9 +22,22 @@ const postId = route.params.id as string
|
||||
const post = ref<Post | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const theme = ref<ExportTheme>('dark')
|
||||
const isExporting = ref(false)
|
||||
|
||||
const {
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
templateVars,
|
||||
slideTransitionForward,
|
||||
slideTransitionBackward,
|
||||
revealTransition,
|
||||
showThumbnailsDefault,
|
||||
loadTemplates,
|
||||
setTemplate,
|
||||
} = usePptTemplate()
|
||||
|
||||
const showThumbnails = ref(true)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const toolbarVisible = ref(true)
|
||||
let hideToolbarTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -80,10 +94,6 @@ const goBack = () => {
|
||||
router.push(`/blog/${postId}`)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!post.value || isExporting.value) return
|
||||
isExporting.value = true
|
||||
@@ -113,7 +123,14 @@ const handleMouseMove = () => {
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const onTemplateChange = (e: Event) => {
|
||||
const id = Number.parseInt((e.target as HTMLSelectElement).value, 10)
|
||||
if (Number.isFinite(id)) setTemplate(id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTemplates()
|
||||
showThumbnails.value = showThumbnailsDefault.value
|
||||
void fetchDetail()
|
||||
})
|
||||
</script>
|
||||
@@ -146,14 +163,16 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Icon :name="theme === 'dark' ? 'sun' : 'moon'" :size="14" />
|
||||
<span class="hidden sm:inline">{{ theme === 'dark' ? '浅色' : '深色' }}</span>
|
||||
</button>
|
||||
<label v-if="templates.length" class="flex items-center gap-1.5 text-xs text-white/70">
|
||||
<span class="hidden sm:inline">模板</span>
|
||||
<select
|
||||
class="rounded-full border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white/90 outline-none"
|
||||
:value="selectedTemplateId ?? ''"
|
||||
@change="onTemplateChange"
|
||||
>
|
||||
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -194,12 +213,16 @@ onMounted(() => {
|
||||
<PptSlideViewer
|
||||
v-else-if="slides.length"
|
||||
:slides="slides"
|
||||
:theme="theme"
|
||||
:current-index="currentIndex"
|
||||
:current-step="currentStep"
|
||||
:slide-direction="slideDirection"
|
||||
:is-fullscreen="isFullscreen"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:toolbar-visible="toolbarVisible"
|
||||
:template-vars="templateVars"
|
||||
:slide-transition-forward="slideTransitionForward"
|
||||
:slide-transition-backward="slideTransitionBackward"
|
||||
:reveal-transition="revealTransition"
|
||||
class="flex-1 min-h-0 relative"
|
||||
@advance="advance"
|
||||
@retreat="retreat"
|
||||
|
||||
395
client/src/pages/admin/PptTemplateForm.vue
Normal file
395
client/src/pages/admin/PptTemplateForm.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div class="form-container">
|
||||
<h1 class="page-title">{{ isEdit ? '编辑 PPT 模板' : '新建 PPT 模板' }}</h1>
|
||||
|
||||
<div class="form-layout">
|
||||
<div class="form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<section class="form-section">
|
||||
<h3>基础信息</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">名称</label>
|
||||
<input v-model="formData.name" type="text" class="form-input" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Slug</label>
|
||||
<input
|
||||
v-model="formData.slug"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:readonly="isSystemEdit"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea v-model="formData.description" class="form-input" rows="2" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="formData.isDefault" type="checkbox" />
|
||||
设为演示默认模板
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input v-model="formData.isActive" type="checkbox" :disabled="isSystemEdit" />
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">排序</label>
|
||||
<input v-model.number="formData.sortOrder" type="number" class="form-input" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>配色</h3>
|
||||
<div class="color-grid">
|
||||
<div v-for="key in colorKeys" :key="key" class="form-group">
|
||||
<label class="form-label">{{ colorLabels[key] }}</label>
|
||||
<input
|
||||
v-if="key !== 'border'"
|
||||
v-model="formData.config.colors[key]"
|
||||
type="color"
|
||||
class="color-input"
|
||||
/>
|
||||
<input v-model="formData.config.colors[key]" type="text" class="form-input form-input--sm" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>动画</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">揭示时长</label>
|
||||
<input v-model="formData.config.animation.revealDuration" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">揭示退出</label>
|
||||
<input v-model="formData.config.animation.revealLeaveDuration" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">翻页时长</label>
|
||||
<input v-model="formData.config.animation.slideDuration" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">布局时长</label>
|
||||
<input v-model="formData.config.animation.layoutDuration" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">揭示位移</label>
|
||||
<input v-model="formData.config.animation.revealOffset" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group form-group--wide">
|
||||
<label class="form-label">缓动曲线</label>
|
||||
<input v-model="formData.config.animation.easing" class="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>切换效果</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">页间切换</label>
|
||||
<select v-model="formData.config.transitions.slide" class="form-input">
|
||||
<option value="crossfade">交叉淡入 (crossfade)</option>
|
||||
<option value="fade">纯淡入 (fade)</option>
|
||||
<option value="push">推入 (push)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">分步揭示</label>
|
||||
<select v-model="formData.config.transitions.reveal" class="form-input">
|
||||
<option value="fadeUp">上滑淡入 (fadeUp)</option>
|
||||
<option value="fade">纯淡入 (fade)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>UI</h3>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="formData.config.ui.showThumbnailsDefault" type="checkbox" />
|
||||
默认显示缩略图栏
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">幻灯片圆角</label>
|
||||
<input v-model="formData.config.ui.canvasBorderRadius" class="form-input" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="router.back()">取消</button>
|
||||
<button type="submit" class="btn-primary">{{ isEdit ? '保存修改' : '创建模板' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="preview-card">
|
||||
<h3>实时预览</h3>
|
||||
<div class="preview-stage">
|
||||
<PptSlideCanvas
|
||||
:slide="PPT_TEMPLATE_PREVIEW_SLIDE"
|
||||
:visible-step="999"
|
||||
:template-vars="previewApplied.templateVars"
|
||||
:reveal-transition="previewApplied.revealTransition"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PptSlideCanvas from '../../components/ppt/PptSlideCanvas.vue'
|
||||
import {
|
||||
clonePptTemplateConfig,
|
||||
createEmptyPptTemplateForm,
|
||||
parsePptTemplateConfig,
|
||||
PPT_TEMPLATE_PREVIEW_SLIDE,
|
||||
} from '../../config/pptTemplateDefaults'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import {
|
||||
createPptTemplate,
|
||||
fetchAdminPptTemplates,
|
||||
fetchDefaultPptTemplate,
|
||||
updatePptTemplate,
|
||||
} from '../../services/api'
|
||||
import type { PptTemplateColors } from '../../types/pptTemplate'
|
||||
import { applyPptTemplate } from '../../utils/applyPptTemplate'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const isSystemEdit = ref(false)
|
||||
|
||||
const formData = ref(createEmptyPptTemplateForm())
|
||||
|
||||
const colorKeys = [
|
||||
'bg',
|
||||
'text',
|
||||
'muted',
|
||||
'accent',
|
||||
'codeBg',
|
||||
'codeText',
|
||||
'border',
|
||||
] as const satisfies readonly (keyof PptTemplateColors)[]
|
||||
|
||||
const colorLabels: Record<(typeof colorKeys)[number], string> = {
|
||||
bg: '背景',
|
||||
text: '正文',
|
||||
muted: '次要文字',
|
||||
accent: '强调色',
|
||||
codeBg: '代码背景',
|
||||
codeText: '代码文字',
|
||||
border: '边框',
|
||||
}
|
||||
|
||||
const previewApplied = computed(() => applyPptTemplate(formData.value.config))
|
||||
|
||||
const loadForCreate = async () => {
|
||||
try {
|
||||
const defaults = await fetchDefaultPptTemplate()
|
||||
formData.value.config = clonePptTemplateConfig(parsePptTemplateConfig(defaults.config))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.warning('未能加载系统默认模板,使用内置默认值')
|
||||
}
|
||||
}
|
||||
|
||||
const loadForEdit = async () => {
|
||||
const id = Number.parseInt(route.params.id as string, 10)
|
||||
const list = await fetchAdminPptTemplates()
|
||||
const item = list.find((t) => t.id === id)
|
||||
if (!item) {
|
||||
toast.error('模板不存在')
|
||||
router.push('/admin/ppt-templates')
|
||||
return
|
||||
}
|
||||
isSystemEdit.value = item.isSystem
|
||||
formData.value = {
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
description: item.description || '',
|
||||
config: clonePptTemplateConfig(parsePptTemplateConfig(item.config)),
|
||||
isDefault: item.isDefault,
|
||||
sortOrder: item.sortOrder,
|
||||
isActive: item.isActive,
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updatePptTemplate(Number.parseInt(route.params.id as string, 10), formData.value)
|
||||
toast.success('更新成功')
|
||||
} else {
|
||||
await createPptTemplate(formData.value)
|
||||
toast.success('创建成功')
|
||||
}
|
||||
router.push('/admin/ppt-templates')
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
toast.error(err instanceof Error ? err.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
await loadForEdit()
|
||||
} else {
|
||||
await loadForCreate()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 420px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.form-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.preview-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section h3,
|
||||
.preview-card h3 {
|
||||
font-size: 1rem;
|
||||
color: #d4b383;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-group--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-input--sm {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #d4b383;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
202
client/src/pages/admin/PptTemplates.vue
Normal file
202
client/src/pages/admin/PptTemplates.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="ppt-templates-container">
|
||||
<h1 class="page-title">PPT 模板</h1>
|
||||
|
||||
<div class="action-bar">
|
||||
<button class="btn-primary" @click="router.push('/admin/ppt-templates/create')">
|
||||
<span class="btn-icon">➕</span>
|
||||
新建模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>Slug</th>
|
||||
<th>类型</th>
|
||||
<th>默认</th>
|
||||
<th>排序</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in templates" :key="t.id">
|
||||
<td class="table-cell">{{ t.id }}</td>
|
||||
<td class="table-cell">{{ t.name }}</td>
|
||||
<td class="table-cell font-mono text-sm">{{ t.slug }}</td>
|
||||
<td class="table-cell">
|
||||
<span v-if="t.isSystem" class="badge-system">系统默认</span>
|
||||
<span v-else class="text-white/50">自定义</span>
|
||||
</td>
|
||||
<td class="table-cell">{{ t.isDefault ? '是' : '否' }}</td>
|
||||
<td class="table-cell">{{ t.sortOrder }}</td>
|
||||
<td class="table-cell">
|
||||
<span :class="t.isActive ? 'text-green-400' : 'text-red-400'">
|
||||
{{ t.isActive ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-cell actions">
|
||||
<button class="btn-edit" title="编辑" @click="router.push(`/admin/ppt-templates/${t.id}/edit`)">
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
v-if="!t.isSystem"
|
||||
class="btn-delete"
|
||||
title="删除"
|
||||
@click="handleDelete(t.id)"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!templates.length && !loading" class="empty-state">
|
||||
<p>暂无模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { deletePptTemplate, fetchAdminPptTemplates, type PptTemplateApiItem } from '../../services/api'
|
||||
import { parsePptTemplateConfig } from '../../config/pptTemplateDefaults'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const templates = ref<PptTemplateApiItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const loadTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchAdminPptTemplates()
|
||||
templates.value = list.map((t) => ({
|
||||
...t,
|
||||
config: parsePptTemplateConfig(t.config),
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error('加载模板失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该模板?')) return
|
||||
try {
|
||||
await deletePptTemplate(id)
|
||||
toast.success('已删除')
|
||||
await loadTemplates()
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
toast.error(err instanceof Error ? err.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ppt-templates-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #d4b383;
|
||||
color: #1a1a1a;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-edit:hover,
|
||||
.btn-delete:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.badge-system {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(212, 179, 131, 0.2);
|
||||
color: #d4b383;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -171,7 +171,11 @@ const routes = [
|
||||
|
||||
// 系统配置
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./pages/admin/Settings.vue') },
|
||||
|
||||
|
||||
{ path: 'ppt-templates', name: 'admin-ppt-templates', component: () => import('./pages/admin/PptTemplates.vue') },
|
||||
{ path: 'ppt-templates/create', name: 'admin-ppt-templates-create', component: () => import('./pages/admin/PptTemplateForm.vue') },
|
||||
{ path: 'ppt-templates/:id/edit', name: 'admin-ppt-templates-edit', component: () => import('./pages/admin/PptTemplateForm.vue') },
|
||||
|
||||
// 操作日志
|
||||
{ path: 'logs', name: 'admin-logs', component: () => import('./pages/admin/Logs.vue') },
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import type { PptTemplateConfig, PptTemplateFormData } from '../types/pptTemplate'
|
||||
|
||||
export const API_BASE = '/api'
|
||||
|
||||
@@ -2257,3 +2258,65 @@ export const deleteVideo = async (id: string): Promise<void> => {
|
||||
}))
|
||||
}
|
||||
|
||||
// PPT 模板
|
||||
export interface PptTemplateApiItem {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
config: PptTemplateConfig | string
|
||||
isSystem: boolean
|
||||
isDefault: boolean
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export const fetchPptTemplates = async (): Promise<PptTemplateApiItem[]> => {
|
||||
const response = await fetch(`${API_BASE}/ppt-templates`)
|
||||
return (await parseApiResponse<PptTemplateApiItem[]>(response)) || []
|
||||
}
|
||||
|
||||
export const fetchDefaultPptTemplate = async (): Promise<PptTemplateApiItem> => {
|
||||
const response = await fetch(`${API_BASE}/ppt-templates/default`)
|
||||
return await parseApiResponse<PptTemplateApiItem>(response)
|
||||
}
|
||||
|
||||
export const fetchAdminPptTemplates = async (): Promise<PptTemplateApiItem[]> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/ppt-templates`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return (await parseApiResponse<PptTemplateApiItem[]>(response)) || []
|
||||
}
|
||||
|
||||
export const createPptTemplate = async (data: PptTemplateFormData): Promise<PptTemplateApiItem> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/ppt-templates`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return await parseApiResponse<PptTemplateApiItem>(response)
|
||||
}
|
||||
|
||||
export const updatePptTemplate = async (
|
||||
id: number,
|
||||
data: PptTemplateFormData
|
||||
): Promise<PptTemplateApiItem> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/ppt-templates/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return await parseApiResponse<PptTemplateApiItem>(response)
|
||||
}
|
||||
|
||||
export const deletePptTemplate = async (id: number): Promise<void> => {
|
||||
await parseApiResponse(
|
||||
await authFetch(`${API_BASE}/admin/ppt-templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
/* PPT 预览幻灯片样式 */
|
||||
|
||||
.ppt-preview-root {
|
||||
--ppt-bg: #f8f7f4;
|
||||
--ppt-text: #1a1a1a;
|
||||
--ppt-muted: #666666;
|
||||
--ppt-accent: #c9a962;
|
||||
--ppt-code-bg: #1e1e1e;
|
||||
--ppt-code-text: #eeeeee;
|
||||
--ppt-border: rgba(0, 0, 0, 0.08);
|
||||
--ppt-chrome-h: 8.5rem;
|
||||
--ppt-ease-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ppt-duration-reveal: 0.95s;
|
||||
--ppt-duration-reveal-leave: 0.65s;
|
||||
--ppt-duration-slide: 0.85s;
|
||||
--ppt-duration-layout: 0.75s;
|
||||
--ppt-reveal-offset: 10px;
|
||||
}
|
||||
|
||||
.ppt-preview-root.ppt-theme-dark {
|
||||
--ppt-bg: #141414;
|
||||
--ppt-text: #f5f5f5;
|
||||
--ppt-muted: #a0a0a0;
|
||||
@@ -25,6 +8,14 @@
|
||||
--ppt-code-bg: #1e1e1e;
|
||||
--ppt-code-text: #eeeeee;
|
||||
--ppt-border: rgba(255, 255, 255, 0.1);
|
||||
--ppt-chrome-h: 8.5rem;
|
||||
--ppt-ease-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ppt-duration-reveal: 0.95s;
|
||||
--ppt-duration-reveal-leave: 0.65s;
|
||||
--ppt-duration-slide: 0.85s;
|
||||
--ppt-duration-layout: 0.75s;
|
||||
--ppt-reveal-offset: 10px;
|
||||
--ppt-canvas-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* 舞台与缩放容器 */
|
||||
@@ -121,7 +112,7 @@
|
||||
background: var(--ppt-bg);
|
||||
color: var(--ppt-text);
|
||||
border: 1px solid var(--ppt-border);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: var(--ppt-canvas-radius, 0.5rem);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -453,6 +444,93 @@
|
||||
transform: translateX(8px) scale(0.985);
|
||||
}
|
||||
|
||||
/* 页间切换:纯淡入淡出 */
|
||||
.ppt-slide-fade-enter-active,
|
||||
.ppt-slide-fade-leave-active,
|
||||
.ppt-slide-back-fade-enter-active,
|
||||
.ppt-slide-back-fade-leave-active {
|
||||
transition: opacity var(--ppt-duration-slide) var(--ppt-ease-soft);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ppt-slide-fade-leave-active,
|
||||
.ppt-slide-back-fade-leave-active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ppt-slide-fade-enter-active,
|
||||
.ppt-slide-back-fade-enter-active {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ppt-slide-fade-enter-from,
|
||||
.ppt-slide-fade-leave-to,
|
||||
.ppt-slide-back-fade-enter-from,
|
||||
.ppt-slide-back-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 页间切换:推入(更明显位移) */
|
||||
.ppt-slide-push-enter-active,
|
||||
.ppt-slide-push-leave-active,
|
||||
.ppt-slide-back-push-enter-active,
|
||||
.ppt-slide-back-push-leave-active {
|
||||
transition:
|
||||
opacity var(--ppt-duration-slide) var(--ppt-ease-soft),
|
||||
transform var(--ppt-duration-slide) var(--ppt-ease-soft);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ppt-slide-push-leave-active,
|
||||
.ppt-slide-back-push-leave-active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ppt-slide-push-enter-active,
|
||||
.ppt-slide-back-push-enter-active {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ppt-slide-push-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px) scale(0.98);
|
||||
}
|
||||
|
||||
.ppt-slide-push-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-24px) scale(0.98);
|
||||
}
|
||||
|
||||
.ppt-slide-back-push-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-24px) scale(0.98);
|
||||
}
|
||||
|
||||
.ppt-slide-back-push-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(24px) scale(0.98);
|
||||
}
|
||||
|
||||
/* 分步揭示:纯淡入 */
|
||||
.ppt-reveal-fade-enter-active {
|
||||
transition: opacity var(--ppt-duration-reveal) var(--ppt-ease-soft);
|
||||
}
|
||||
|
||||
.ppt-reveal-fade-leave-active {
|
||||
transition: opacity var(--ppt-duration-reveal-leave) var(--ppt-ease-soft);
|
||||
}
|
||||
|
||||
.ppt-reveal-fade-enter-from,
|
||||
.ppt-reveal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ppt-thumbnail {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 7rem;
|
||||
@@ -485,10 +563,20 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ppt-reveal-enter-active,
|
||||
.ppt-reveal-leave-active,
|
||||
.ppt-reveal-fade-enter-active,
|
||||
.ppt-reveal-fade-leave-active,
|
||||
.ppt-slide-enter-active,
|
||||
.ppt-slide-leave-active,
|
||||
.ppt-slide-back-enter-active,
|
||||
.ppt-slide-back-leave-active,
|
||||
.ppt-slide-fade-enter-active,
|
||||
.ppt-slide-fade-leave-active,
|
||||
.ppt-slide-back-fade-enter-active,
|
||||
.ppt-slide-back-fade-leave-active,
|
||||
.ppt-slide-push-enter-active,
|
||||
.ppt-slide-push-leave-active,
|
||||
.ppt-slide-back-push-enter-active,
|
||||
.ppt-slide-back-push-leave-active,
|
||||
.ppt-slide-section-header,
|
||||
.ppt-slide-section-title,
|
||||
.ppt-slide-canvas {
|
||||
@@ -497,10 +585,20 @@
|
||||
|
||||
.ppt-reveal-enter-from,
|
||||
.ppt-reveal-leave-to,
|
||||
.ppt-reveal-fade-enter-from,
|
||||
.ppt-reveal-fade-leave-to,
|
||||
.ppt-slide-enter-from,
|
||||
.ppt-slide-leave-to,
|
||||
.ppt-slide-back-enter-from,
|
||||
.ppt-slide-back-leave-to {
|
||||
.ppt-slide-back-leave-to,
|
||||
.ppt-slide-fade-enter-from,
|
||||
.ppt-slide-fade-leave-to,
|
||||
.ppt-slide-back-fade-enter-from,
|
||||
.ppt-slide-back-fade-leave-to,
|
||||
.ppt-slide-push-enter-from,
|
||||
.ppt-slide-push-leave-to,
|
||||
.ppt-slide-back-push-enter-from,
|
||||
.ppt-slide-back-push-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
69
client/src/types/pptTemplate.ts
Normal file
69
client/src/types/pptTemplate.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type PptSlideTransition = 'crossfade' | 'fade' | 'push'
|
||||
export type PptRevealTransition = 'fadeUp' | 'fade'
|
||||
|
||||
export interface PptTemplateColors {
|
||||
bg: string
|
||||
text: string
|
||||
muted: string
|
||||
accent: string
|
||||
codeBg: string
|
||||
codeText: string
|
||||
border: string
|
||||
}
|
||||
|
||||
export interface PptTemplateAnimation {
|
||||
revealDuration: string
|
||||
revealLeaveDuration: string
|
||||
slideDuration: string
|
||||
layoutDuration: string
|
||||
revealOffset: string
|
||||
easing: string
|
||||
}
|
||||
|
||||
export interface PptTemplateTransitions {
|
||||
slide: PptSlideTransition
|
||||
reveal: PptRevealTransition
|
||||
}
|
||||
|
||||
export interface PptTemplateUi {
|
||||
showThumbnailsDefault: boolean
|
||||
canvasBorderRadius: string
|
||||
}
|
||||
|
||||
export interface PptTemplateConfig {
|
||||
colors: PptTemplateColors
|
||||
animation: PptTemplateAnimation
|
||||
transitions: PptTemplateTransitions
|
||||
ui: PptTemplateUi
|
||||
}
|
||||
|
||||
export interface PptTemplate {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
config: PptTemplateConfig
|
||||
isSystem: boolean
|
||||
isDefault: boolean
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface PptTemplateFormData {
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
config: PptTemplateConfig
|
||||
isDefault: boolean
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface PptTemplateApplied {
|
||||
templateVars: Record<string, string>
|
||||
slideTransitionForward: string
|
||||
slideTransitionBackward: string
|
||||
revealTransition: string
|
||||
}
|
||||
52
client/src/utils/applyPptTemplate.ts
Normal file
52
client/src/utils/applyPptTemplate.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
PptTemplateApplied,
|
||||
PptTemplateConfig,
|
||||
PptRevealTransition,
|
||||
PptSlideTransition,
|
||||
} from '../types/pptTemplate'
|
||||
|
||||
export function getSlideTransitionNames(slide: PptSlideTransition): {
|
||||
forward: string
|
||||
backward: string
|
||||
} {
|
||||
switch (slide) {
|
||||
case 'fade':
|
||||
return { forward: 'ppt-slide-fade', backward: 'ppt-slide-back-fade' }
|
||||
case 'push':
|
||||
return { forward: 'ppt-slide-push', backward: 'ppt-slide-back-push' }
|
||||
default:
|
||||
return { forward: 'ppt-slide', backward: 'ppt-slide-back' }
|
||||
}
|
||||
}
|
||||
|
||||
export function getRevealTransitionName(reveal: PptRevealTransition): string {
|
||||
return reveal === 'fade' ? 'ppt-reveal-fade' : 'ppt-reveal'
|
||||
}
|
||||
|
||||
/** 将模板 config 转为 CSS 变量与 transition 名 */
|
||||
export function applyPptTemplate(config: PptTemplateConfig): PptTemplateApplied {
|
||||
const { colors, animation, transitions, ui } = config
|
||||
const slideNames = getSlideTransitionNames(transitions.slide)
|
||||
|
||||
return {
|
||||
templateVars: {
|
||||
'--ppt-bg': colors.bg,
|
||||
'--ppt-text': colors.text,
|
||||
'--ppt-muted': colors.muted,
|
||||
'--ppt-accent': colors.accent,
|
||||
'--ppt-code-bg': colors.codeBg,
|
||||
'--ppt-code-text': colors.codeText,
|
||||
'--ppt-border': colors.border,
|
||||
'--ppt-ease-soft': animation.easing,
|
||||
'--ppt-duration-reveal': animation.revealDuration,
|
||||
'--ppt-duration-reveal-leave': animation.revealLeaveDuration,
|
||||
'--ppt-duration-slide': animation.slideDuration,
|
||||
'--ppt-duration-layout': animation.layoutDuration,
|
||||
'--ppt-reveal-offset': animation.revealOffset,
|
||||
'--ppt-canvas-radius': ui.canvasBorderRadius,
|
||||
},
|
||||
slideTransitionForward: slideNames.forward,
|
||||
slideTransitionBackward: slideNames.backward,
|
||||
revealTransition: getRevealTransitionName(transitions.reveal),
|
||||
}
|
||||
}
|
||||
225
server/handlers/ppt_template.go
Normal file
225
server/handlers/ppt_template.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
"github.com/niangaodev/art-code/repositories"
|
||||
"github.com/niangaodev/art-code/utils"
|
||||
)
|
||||
|
||||
func pptTemplatePayloads(list []models.PptTemplate) ([]models.PptTemplatePayload, error) {
|
||||
out := make([]models.PptTemplatePayload, 0, len(list))
|
||||
for i := range list {
|
||||
p, err := repositories.ToPptTemplatePayload(&list[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPptTemplates 公开:获取启用的 PPT 模板
|
||||
func GetPptTemplates(c *gin.Context) {
|
||||
list, err := repositories.GetActivePptTemplates()
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
payloads, err := pptTemplatePayloads(list)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
if payloads == nil {
|
||||
payloads = []models.PptTemplatePayload{}
|
||||
}
|
||||
utils.Success(c, payloads)
|
||||
}
|
||||
|
||||
// GetDefaultPptTemplate 公开:获取系统默认模板
|
||||
func GetDefaultPptTemplate(c *gin.Context) {
|
||||
t, err := repositories.GetSystemPptTemplate()
|
||||
if err != nil {
|
||||
utils.Error(c, 404, "Default template not found")
|
||||
return
|
||||
}
|
||||
payload, err := repositories.ToPptTemplatePayload(t)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, payload)
|
||||
}
|
||||
|
||||
// AdminGetPptTemplates 管理:获取全部模板
|
||||
func AdminGetPptTemplates(c *gin.Context) {
|
||||
list, err := repositories.GetAllPptTemplates()
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
payloads, err := pptTemplatePayloads(list)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
if payloads == nil {
|
||||
payloads = []models.PptTemplatePayload{}
|
||||
}
|
||||
utils.Success(c, payloads)
|
||||
}
|
||||
|
||||
// AdminCreatePptTemplate 管理:创建模板
|
||||
func AdminCreatePptTemplate(c *gin.Context) {
|
||||
var req models.PptTemplatePayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Error(c, 400, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Slug = strings.TrimSpace(req.Slug)
|
||||
if req.Name == "" || req.Slug == "" {
|
||||
utils.Error(c, 400, "Name and slug are required")
|
||||
return
|
||||
}
|
||||
if req.Slug == repositories.DefaultPptTemplateSlug {
|
||||
utils.Error(c, 400, "Slug 'default' is reserved for system template")
|
||||
return
|
||||
}
|
||||
|
||||
configJSON, err := repositories.SerializePptTemplateConfig(req.Config)
|
||||
if err != nil {
|
||||
utils.Error(c, 400, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
|
||||
t := models.PptTemplate{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Config: configJSON,
|
||||
IsSystem: false,
|
||||
IsDefault: req.IsDefault,
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
|
||||
if err := repositories.CreatePptTemplate(&t); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if t.IsDefault {
|
||||
_ = repositories.ClearDefaultPptTemplatesExcept(t.ID)
|
||||
}
|
||||
|
||||
payload, err := repositories.ToPptTemplatePayload(&t)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
utils.SuccessWithMsg(c, "PPT template created", payload)
|
||||
}
|
||||
|
||||
// AdminUpdatePptTemplate 管理:更新模板
|
||||
func AdminUpdatePptTemplate(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
|
||||
utils.Error(c, 400, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := repositories.GetPptTemplateByID(id)
|
||||
if err != nil {
|
||||
utils.Error(c, 404, "Template not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.PptTemplatePayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Error(c, 400, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
utils.Error(c, 400, "Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
configJSON, err := repositories.SerializePptTemplateConfig(req.Config)
|
||||
if err != nil {
|
||||
utils.Error(c, 400, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
|
||||
t := *existing
|
||||
t.Name = req.Name
|
||||
t.Description = strings.TrimSpace(req.Description)
|
||||
t.Config = configJSON
|
||||
t.SortOrder = req.SortOrder
|
||||
|
||||
if existing.IsSystem {
|
||||
t.Slug = repositories.DefaultPptTemplateSlug
|
||||
t.IsSystem = true
|
||||
t.IsActive = true
|
||||
t.IsDefault = req.IsDefault
|
||||
} else {
|
||||
req.Slug = strings.TrimSpace(req.Slug)
|
||||
if req.Slug == "" {
|
||||
utils.Error(c, 400, "Slug is required")
|
||||
return
|
||||
}
|
||||
if req.Slug == repositories.DefaultPptTemplateSlug {
|
||||
utils.Error(c, 400, "Slug 'default' is reserved")
|
||||
return
|
||||
}
|
||||
t.Slug = req.Slug
|
||||
t.IsDefault = req.IsDefault
|
||||
t.IsActive = req.IsActive
|
||||
}
|
||||
|
||||
if err := repositories.UpdatePptTemplate(&t); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if t.IsDefault {
|
||||
_ = repositories.ClearDefaultPptTemplatesExcept(t.ID)
|
||||
}
|
||||
|
||||
payload, err := repositories.ToPptTemplatePayload(&t)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
utils.SuccessWithMsg(c, "PPT template updated", payload)
|
||||
}
|
||||
|
||||
// AdminDeletePptTemplate 管理:删除模板
|
||||
func AdminDeletePptTemplate(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
|
||||
utils.Error(c, 400, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repositories.DeletePptTemplate(id); err != nil {
|
||||
if errors.Is(err, repositories.ErrPptTemplateSystemDelete) {
|
||||
utils.Error(c, 400, "System default template cannot be deleted")
|
||||
return
|
||||
}
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessWithMsg(c, "PPT template deleted", nil)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func main() {
|
||||
repositories.MigrateUserAvatar()
|
||||
repositories.MigrateUserProfileFields()
|
||||
repositories.MigrateVideoModule()
|
||||
repositories.MigratePptTemplates()
|
||||
|
||||
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
||||
@@ -108,6 +109,10 @@ func main() {
|
||||
api.GET("/testimonials", handlers.GetTestimonials)
|
||||
api.GET("/partners", handlers.GetPartners)
|
||||
|
||||
// PPT 模板
|
||||
api.GET("/ppt-templates", handlers.GetPptTemplates)
|
||||
api.GET("/ppt-templates/default", handlers.GetDefaultPptTemplate)
|
||||
|
||||
// 咨询相关路由
|
||||
api.POST("/inquiries", handlers.SubmitInquiry)
|
||||
api.GET("/email-suffixes", handlers.GetEmailSuffixes)
|
||||
@@ -258,6 +263,12 @@ func main() {
|
||||
authAdmin.PUT("/partners/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdatePartner)
|
||||
authAdmin.DELETE("/partners/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeletePartner)
|
||||
|
||||
// PPT 模板管理 (复用 settings 权限)
|
||||
authAdmin.GET("/ppt-templates", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetPptTemplates)
|
||||
authAdmin.POST("/ppt-templates", middleware.PermissionMiddleware("settings", "create"), handlers.AdminCreatePptTemplate)
|
||||
authAdmin.PUT("/ppt-templates/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdatePptTemplate)
|
||||
authAdmin.DELETE("/ppt-templates/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeletePptTemplate)
|
||||
|
||||
// 咨询管理
|
||||
authAdmin.GET("/inquiries", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetInquiries)
|
||||
authAdmin.PUT("/inquiries/:id/status", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateInquiryStatus)
|
||||
|
||||
58
server/models/ppt_template.go
Normal file
58
server/models/ppt_template.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PptTemplate PPT 演示模板
|
||||
type PptTemplate 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"`
|
||||
Config string `json:"-" gorm:"column:config;type:longtext"`
|
||||
IsSystem bool `json:"isSystem" gorm:"column:is_system;default:0"`
|
||||
IsDefault bool `json:"isDefault" gorm:"column:is_default;default:0"`
|
||||
SortOrder int `json:"sortOrder" gorm:"column:sort_order;default:0"`
|
||||
IsActive bool `json:"isActive" gorm:"column:is_active;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 (PptTemplate) TableName() string {
|
||||
return "ppt_templates"
|
||||
}
|
||||
|
||||
func (t *PptTemplate) BeforeCreate(tx *gorm.DB) error {
|
||||
now := time.Now().Unix()
|
||||
if t.CreatedAt == 0 {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt == 0 {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *PptTemplate) BeforeUpdate(tx *gorm.DB) error {
|
||||
t.UpdatedAt = time.Now().Unix()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PptTemplatePayload API 请求/响应载荷(config 为 JSON 对象)
|
||||
type PptTemplatePayload struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Config interface{} `json:"config"`
|
||||
IsSystem bool `json:"isSystem"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
IsActive bool `json:"isActive"`
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
}
|
||||
@@ -293,3 +293,58 @@ func MigrateVideoModule() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MigratePptTemplates 创建 PPT 模板表并补种系统默认模板
|
||||
func MigratePptTemplates() {
|
||||
log.Printf("Migrating ppt templates...")
|
||||
|
||||
if !tableExists("ppt_templates") {
|
||||
execSQL(`CREATE TABLE ppt_templates (
|
||||
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唯一标识(系统默认为default)',
|
||||
description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '模板描述',
|
||||
config LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PPT样式配置JSON(配色/动画/切换/UI)',
|
||||
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统内置模板(0否 1是,不可删除)',
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否演示默认模板(0否 1是)',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)',
|
||||
is_active 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),
|
||||
UNIQUE INDEX idx_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PPT模板表'`)
|
||||
} else {
|
||||
ensurePptTemplateColumnComments()
|
||||
}
|
||||
|
||||
EnsureSystemPptTemplate()
|
||||
}
|
||||
|
||||
// ensurePptTemplateColumnComments 为已存在但缺少字段备注的 ppt_templates 表补全 COMMENT
|
||||
func ensurePptTemplateColumnComments() {
|
||||
if !tableExists("ppt_templates") {
|
||||
return
|
||||
}
|
||||
|
||||
alters := []string{
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板显示名称'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `slug` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'URL唯一标识(系统默认为default)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '模板描述'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `config` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PPT样式配置JSON(配色/动画/切换/UI)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `is_system` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否系统内置模板(0否 1是,不可删除)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否演示默认模板(0否 1是)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `is_active` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `deleted_at` bigint NOT NULL DEFAULT 0 COMMENT '软删除时间戳'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `created_at` bigint NOT NULL DEFAULT 0 COMMENT '创建时间(Unix秒)'",
|
||||
"ALTER TABLE `ppt_templates` MODIFY COLUMN `updated_at` bigint NOT NULL DEFAULT 0 COMMENT '更新时间(Unix秒)'",
|
||||
"ALTER TABLE `ppt_templates` COMMENT='PPT模板表'",
|
||||
}
|
||||
|
||||
for _, sql := range alters {
|
||||
execSQL(sql)
|
||||
}
|
||||
}
|
||||
|
||||
170
server/repositories/ppt_template_repository.go
Normal file
170
server/repositories/ppt_template_repository.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/niangaodev/art-code/config"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
)
|
||||
|
||||
const DefaultPptTemplateSlug = "default"
|
||||
|
||||
const DefaultPptTemplateConfigJSON = `{"colors":{"bg":"#141414","text":"#f5f5f5","muted":"#a0a0a0","accent":"#c9a962","codeBg":"#1e1e1e","codeText":"#eeeeee","border":"rgba(255, 255, 255, 0.1)"},"animation":{"revealDuration":"0.95s","revealLeaveDuration":"0.65s","slideDuration":"0.85s","layoutDuration":"0.75s","revealOffset":"10px","easing":"cubic-bezier(0.22, 1, 0.36, 1)"},"transitions":{"slide":"crossfade","reveal":"fadeUp"},"ui":{"showThumbnailsDefault":true,"canvasBorderRadius":"0.5rem"}}`
|
||||
|
||||
var ErrPptTemplateSystemDelete = errors.New("system ppt template cannot be deleted")
|
||||
|
||||
func GetActivePptTemplates() ([]models.PptTemplate, error) {
|
||||
var list []models.PptTemplate
|
||||
err := config.DB.Model(&models.PptTemplate{}).
|
||||
Where("deleted_at = ? AND is_active = ?", 0, true).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func GetAllPptTemplates() ([]models.PptTemplate, error) {
|
||||
var list []models.PptTemplate
|
||||
err := config.DB.Model(&models.PptTemplate{}).
|
||||
Where("deleted_at = ?", 0).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func GetPptTemplateByID(id uint) (*models.PptTemplate, error) {
|
||||
var t models.PptTemplate
|
||||
err := config.DB.Model(&models.PptTemplate{}).
|
||||
Where("id = ? AND deleted_at = ?", id, 0).
|
||||
First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func GetSystemPptTemplate() (*models.PptTemplate, error) {
|
||||
var t models.PptTemplate
|
||||
err := config.DB.Model(&models.PptTemplate{}).
|
||||
Where("slug = ? AND deleted_at = ?", DefaultPptTemplateSlug, 0).
|
||||
First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func CreatePptTemplate(t *models.PptTemplate) error {
|
||||
return config.DB.Create(t).Error
|
||||
}
|
||||
|
||||
func UpdatePptTemplate(t *models.PptTemplate) error {
|
||||
return config.DB.Model(&models.PptTemplate{}).
|
||||
Where("id = ? AND deleted_at = ?", t.ID, 0).
|
||||
Updates(map[string]interface{}{
|
||||
"name": t.Name,
|
||||
"slug": t.Slug,
|
||||
"description": t.Description,
|
||||
"config": t.Config,
|
||||
"is_default": t.IsDefault,
|
||||
"sort_order": t.SortOrder,
|
||||
"is_active": t.IsActive,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeletePptTemplate(id uint) error {
|
||||
t, err := GetPptTemplateByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.IsSystem {
|
||||
return ErrPptTemplateSystemDelete
|
||||
}
|
||||
return config.DB.Model(&models.PptTemplate{}).
|
||||
Where("id = ?", id).
|
||||
Update("deleted_at", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
func ClearDefaultPptTemplatesExcept(id uint) error {
|
||||
q := config.DB.Model(&models.PptTemplate{}).
|
||||
Where("deleted_at = ? AND is_default = ?", 0, true)
|
||||
if id > 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
}
|
||||
return q.Update("is_default", false).Error
|
||||
}
|
||||
|
||||
func EnsureSystemPptTemplate() {
|
||||
if !tableExists("ppt_templates") {
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
config.DB.Model(&models.PptTemplate{}).
|
||||
Where("slug = ? AND deleted_at = ?", DefaultPptTemplateSlug, 0).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
t := models.PptTemplate{
|
||||
Name: "默认模板",
|
||||
Slug: DefaultPptTemplateSlug,
|
||||
Description: "系统内置 PPT 演示默认样式",
|
||||
Config: DefaultPptTemplateConfigJSON,
|
||||
IsSystem: true,
|
||||
IsDefault: true,
|
||||
SortOrder: 0,
|
||||
IsActive: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := config.DB.Create(&t).Error; err != nil {
|
||||
log.Printf("Failed to seed system ppt template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ParsePptTemplateConfigJSON(raw string) (map[string]interface{}, error) {
|
||||
var cfg map[string]interface{}
|
||||
if raw == "" {
|
||||
raw = DefaultPptTemplateConfigJSON
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SerializePptTemplateConfig(cfg interface{}) (string, error) {
|
||||
if cfg == nil {
|
||||
return DefaultPptTemplateConfigJSON, nil
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func ToPptTemplatePayload(t *models.PptTemplate) (models.PptTemplatePayload, error) {
|
||||
cfg, err := ParsePptTemplateConfigJSON(t.Config)
|
||||
if err != nil {
|
||||
return models.PptTemplatePayload{}, err
|
||||
}
|
||||
return models.PptTemplatePayload{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Slug: t.Slug,
|
||||
Description: t.Description,
|
||||
Config: cfg,
|
||||
IsSystem: t.IsSystem,
|
||||
IsDefault: t.IsDefault,
|
||||
SortOrder: t.SortOrder,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user