优化页面、修复BUG

This commit is contained in:
李琦
2026-07-14 09:37:13 +08:00
parent 9a666b9103
commit 8ac9f591a0
18 changed files with 1699 additions and 53 deletions

View File

@@ -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: '🌐' }
]

View File

@@ -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"

View File

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

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

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

View File

@@ -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"

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

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

View File

@@ -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') },

View File

@@ -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(),
})
)
}

View File

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

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

View 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),
}
}

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

View File

@@ -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)

View 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"`
}

View File

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

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