优化页面、修复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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user