优化页面、修复BUG

This commit is contained in:
李琦
2026-07-11 18:11:59 +08:00
parent c864853845
commit 45ec3a0a31
14 changed files with 2454 additions and 95 deletions

View File

@@ -0,0 +1,38 @@
# Plan: Build 自动打包 dist.zip 并覆盖历史文件
## 现状
- 项目: Vue 3 + Vite 6, pnpm 管理依赖
- 当前 build 脚本: `"build": "vue-tsc && vite build"`
- 构建输出: `dist/` 目录
- 已有手动创建的 `dist.zip` 在项目根目录
## 方案
使用 `bestzip` 这个轻量 CLI 工具,在 build 命令后自动将 `dist/` 打包为 `dist.zip`,并覆盖旧文件。
### 步骤
#### 1. 安装 bestzip 作为 devDependency
```bash
pnpm add -D bestzip
```
#### 2. 修改 `package.json` 的 scripts.build
```diff
- "build": "vue-tsc && vite build"
+ "build": "vue-tsc && vite build && bestzip dist.zip dist/*"
```
`bestzip` 的行为:
- 如果 `dist.zip` 已存在,会**自动覆盖**
-`dist/` 下所有文件(含子目录)打包进 `dist.zip`
- 跨平台支持Windows / macOS / Linux
#### 涉及文件
- `D:\myCode\Ai\Trae\博客\client\package.json` — 修改 build 脚本 + 新增依赖
### 验证方式
```bash
pnpm build
```
构建完成后检查 `dist.zip` 是否生成且内容正确(包含 `index.html``assets/` 等)。

View File

@@ -23,6 +23,7 @@
"lucide-vue-next": "^0.446.0",
"markdown-it": "^14.1.0",
"md-editor-v3": "^6.3.1",
"pptxgenjs": "^4.0.1",
"vue": "^3.5.13",
"vue-echarts": "^8.0.1",
"vue-router": "^4.4.5"

79
client/pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
md-editor-v3:
specifier: ^6.3.1
version: 6.3.1(vue@3.5.26(typescript@5.6.3))
pptxgenjs:
specifier: ^4.0.1
version: 4.0.1
vue:
specifier: ^3.5.13
version: 3.5.26(typescript@5.6.3)
@@ -62,7 +65,7 @@ importers:
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.4(vite@6.4.1(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))
version: 5.2.4(vite@6.4.1(@types/node@22.20.1)(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))
autoprefixer:
specifier: ^10.4.20
version: 10.4.23(postcss@8.5.6)
@@ -80,7 +83,7 @@ importers:
version: 5.6.3
vite:
specifier: ^6.0.5
version: 6.4.1(jiti@1.21.7)
version: 6.4.1(@types/node@22.20.1)(jiti@1.21.7)
vue-tsc:
specifier: ^2.1.10
version: 2.2.12(typescript@5.6.3)
@@ -586,6 +589,9 @@ packages:
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
@@ -1628,6 +1634,9 @@ packages:
https-browserify@1.0.0:
resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
https@1.0.0:
resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -1638,6 +1647,14 @@ packages:
iferr@0.1.5:
resolution: {integrity: sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==}
image-size@1.2.1:
resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==}
engines: {node: '>=16.x'}
hasBin: true
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-local@1.0.0:
resolution: {integrity: sha512-vAaZHieK9qjGo58agRBg+bhHX3hoTZU/Oa3GESWLz7t1U62fk63aHuDJJEteXoDeTCcPmUT+z38gkHPZkkmpmQ==}
engines: {node: '>=4'}
@@ -1809,6 +1826,9 @@ packages:
jspdf@2.5.2:
resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
kind-of@3.2.2:
resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==}
engines: {node: '>=0.10.0'}
@@ -1829,6 +1849,9 @@ packages:
resolution: {integrity: sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==}
engines: {node: '>=6'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -2282,6 +2305,9 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
pptxgenjs@4.0.1:
resolution: {integrity: sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -2337,6 +2363,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
@@ -2756,6 +2785,9 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
union-value@1.0.1:
resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==}
engines: {node: '>=0.10.0'}
@@ -3546,6 +3578,10 @@ snapshots:
'@types/mdurl@2.0.0': {}
'@types/node@22.20.1':
dependencies:
undici-types: 6.21.0
'@types/raf@3.4.3':
optional: true
@@ -3553,9 +3589,9 @@ snapshots:
'@vavt/util@2.1.1': {}
'@vitejs/plugin-vue@5.2.4(vite@6.4.1(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))':
'@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.20.1)(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))':
dependencies:
vite: 6.4.1(jiti@1.21.7)
vite: 6.4.1(@types/node@22.20.1)(jiti@1.21.7)
vue: 3.5.26(typescript@5.6.3)
'@volar/language-core@2.4.15':
@@ -4818,6 +4854,8 @@ snapshots:
https-browserify@1.0.0: {}
https@1.0.0: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -4826,6 +4864,12 @@ snapshots:
iferr@0.1.5: {}
image-size@1.2.1:
dependencies:
queue: 6.0.2
immediate@3.0.6: {}
import-local@1.0.0:
dependencies:
pkg-dir: 2.0.0
@@ -4982,6 +5026,13 @@ snapshots:
dompurify: 2.5.9
html2canvas: 1.4.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
kind-of@3.2.2:
dependencies:
is-buffer: 1.1.6
@@ -5000,6 +5051,10 @@ snapshots:
dependencies:
invert-kv: 2.0.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -5486,6 +5541,13 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
pptxgenjs@4.0.1:
dependencies:
'@types/node': 22.20.1
https: 1.0.0
image-size: 1.2.1
jszip: 3.10.1
process-nextick-args@2.0.1: {}
process@0.11.10: {}
@@ -5538,6 +5600,10 @@ snapshots:
queue-microtask@1.2.3: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
raf@3.4.1:
dependencies:
performance-now: 2.1.0
@@ -6083,6 +6149,8 @@ snapshots:
uc.micro@2.1.0: {}
undici-types@6.21.0: {}
union-value@1.0.1:
dependencies:
arr-union: 3.1.0
@@ -6141,7 +6209,7 @@ snapshots:
v8-compile-cache@2.4.0: {}
vite@6.4.1(jiti@1.21.7):
vite@6.4.1(@types/node@22.20.1)(jiti@1.21.7):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -6150,6 +6218,7 @@ snapshots:
rollup: 4.55.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
jiti: 1.21.7

View File

@@ -7,36 +7,37 @@
-->
<div class="min-h-screen bg-art-bg text-art-text relative flex flex-col">
<!-- Star Background (Only for frontend) -->
<StarBackground v-if="!isAdminOrLogin" class="z-0" />
<StarBackground v-if="showFrontendChrome" class="z-0" />
<!-- Global Noise (Only for frontend) -->
<div v-if="!isAdminOrLogin" class="fixed inset-0 pointer-events-none z-[60] bg-noise opacity-30 mix-blend-overlay"></div>
<div v-if="showFrontendChrome" class="fixed inset-0 pointer-events-none z-[60] bg-noise opacity-30 mix-blend-overlay"></div>
<!-- Toast Container -->
<div id="toast-container" class="toast-container relative z-[70]"></div>
<!-- Header (Hide on Admin & Login) -->
<Header v-if="!isAdminOrLogin" class="z-50" />
<Header v-if="showFrontendChrome" class="z-50" />
<!-- Mobile Menu (Hide on Admin & Login) -->
<MobileMenu v-if="!isAdminOrLogin" class="z-50" />
<MobileMenu v-if="showFrontendChrome" class="z-50" />
<!-- Main Content -->
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
<main :class="[
isPresentationPage ? 'w-full flex-1 relative z-10 min-h-0' :
isFullWidthPage ? 'w-full flex-1 relative z-10' : 'flex-1 pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
]">
<router-view />
</main>
<!-- Footer (Hide on Admin & Login) -->
<Footer v-if="!isAdminOrLogin" class="relative z-10" />
<Footer v-if="showFrontendChrome" class="relative z-10" />
<!-- Snippet Modal (Hide on Admin & Login) -->
<!-- SnippetModal is managed by Snippets.vue page, not here -->
<!-- Inquiry Modal (Hide on Admin & Login) -->
<InquiryModal v-if="!isAdminOrLogin" class="relative z-[80]" />
<InquiryModal v-if="showFrontendChrome" class="relative z-[80]" />
<!-- User Profile Modal (Global) -->
<UserProfileModal
@@ -69,6 +70,11 @@ const isAdminOrLogin = computed(() => {
return path.startsWith('/admin') || path === '/login'
})
// 沉浸式演示页PPT 预览等)
const isPresentationPage = computed(() => route.meta.presentation === true)
const showFrontendChrome = computed(() => !isAdminOrLogin.value && !isPresentationPage.value)
// 全宽布局页面(不受 max-w-7xl 限制)
const isFullWidthPage = computed(() => {
const path = route.path

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 文章导出下拉菜单
* 支持 Markdown / 纯文本 / PDF / PNG 四种格式PDF 与 PNG 可选深浅色主题
* 支持 Markdown / 纯文本 / PDF / PNG / PPTPDF、PNG、PPT 截图可选深浅色主题
*/
import { ref, onMounted, onUnmounted } from 'vue'
import Icon from './Icon.vue'
@@ -37,6 +37,8 @@ const formatOptions: Array<{
{ format: 'txt', label: '纯文本', icon: 'file-type', needsTheme: false, description: '去除 Markdown 语法' },
{ format: 'pdf', label: 'PDF', icon: 'file-down', needsTheme: true, description: '分页 PDF 文档' },
{ format: 'png', label: 'PNG 图片', icon: 'image', needsTheme: true, description: '完整文章长图' },
{ format: 'ppt-structured', label: 'PPT结构化', icon: 'presentation', needsTheme: false, description: '按章节分页,可编辑' },
{ format: 'ppt-screenshot', label: 'PPT截图', icon: 'presentation', needsTheme: true, description: '与页面视觉一致' },
]
/** 主题选项 */

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
/**
* 单张 PPT 幻灯片渲染16:9分步揭示图文/视频自动排版)
*/
import { computed, ref, watch } from 'vue'
import type { PptSlide, RenderUnit } from '../../utils/articlePptSlides'
import {
getSlideRenderUnits,
isStandaloneMediaBlock,
resolveImageUrl,
} from '../../utils/articlePptSlides'
import type { SlideDirection } from '../../composables/usePptPresentation'
import '../../styles/pptPreview.css'
interface Props {
slide: PptSlide
theme?: 'light' | 'dark'
compact?: boolean
/** 分步揭示0=仅标题居中Infinity=显示全部(缩略图) */
visibleStep?: number
slideDirection?: SlideDirection
}
const props = withDefaults(defineProps<Props>(), {
theme: 'dark',
compact: false,
visibleStep: Number.POSITIVE_INFINITY,
slideDirection: 'none',
})
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(() => {
if (props.slide.kind !== 'title') return []
const lines = [
props.slide.date,
props.slide.categoryName || '未分类',
props.slide.userName ? `作者:${props.slide.userName}` : '',
props.slide.tagNames.length ? `标签:${props.slide.tagNames.join(' · ')}` : '',
]
return lines.filter(Boolean) as string[]
})
const allRenderUnits = computed((): RenderUnit[] => {
if (props.slide.kind !== 'section') return []
return getSlideRenderUnits(props.slide)
})
const visibleUnits = computed(() => {
if (showFull.value) return allRenderUnits.value
if (props.visibleStep <= 0) return []
return allRenderUnits.value.slice(0, props.visibleStep)
})
const isTitleFocus = computed(() => {
if (props.slide.kind === 'title') return props.visibleStep === 0
return !showFull.value && props.visibleStep === 0
})
const showTitleMeta = computed(() => {
if (props.slide.kind !== 'title') return false
return showFull.value || props.visibleStep >= 1
})
const setVideoRef = (idx: number, el: HTMLVideoElement | null) => {
if (el) videoRefs.value.set(idx, el)
else videoRefs.value.delete(idx)
}
watch(
() => props.visibleStep,
() => {
videoRefs.value.forEach((video) => {
if (!video.paused) video.pause()
})
}
)
const getImageLoadState = (src: string): ImageLoadState =>
imageLoadStates.value.get(src) ?? 'initial'
const useNoReferrerPolicy = (src: string) => getImageLoadState(src) === 'initial'
const isImageFailed = (src: string) => getImageLoadState(src) === 'failed'
const imageKey = (src: string) => `${src}:${getImageLoadState(src)}`
const onImageError = (src: string) => {
const next = new Map(imageLoadStates.value)
const state = next.get(src) ?? 'initial'
if (state === 'initial') {
next.set(src, 'noReferrer')
} else {
next.set(src, 'failed')
}
imageLoadStates.value = next
}
const isHeroMedia = (unit: RenderUnit) => {
if (props.compact || unit.kind !== 'single') return false
return isStandaloneMediaBlock(unit.block)
}
const isUnitVisible = (unitIdx: number) => showFull.value || unitIdx < props.visibleStep
const stopMediaEvent = (e: Event) => {
e.stopPropagation()
}
</script>
<template>
<div class="ppt-preview-root" :class="themeClass">
<!-- 封面 -->
<div
v-if="slide.kind === 'title'"
class="ppt-slide-canvas ppt-slide-canvas--title"
:class="{ 'ppt-slide-canvas--focus-title': isTitleFocus }"
>
<h1 class="ppt-slide-title-main">{{ slide.title }}</h1>
<Transition name="ppt-reveal">
<div v-if="showTitleMeta && titleMetaLines.length" class="ppt-slide-title-meta">
<div v-for="(line, idx) in titleMetaLines" :key="idx">{{ line }}</div>
</div>
</Transition>
</div>
<!-- 章节页 -->
<div
v-else
class="ppt-slide-canvas"
:class="{
'ppt-slide-canvas--focus-title': isTitleFocus,
'ppt-slide-canvas--has-content': !isTitleFocus,
}"
>
<header
class="ppt-slide-section-header"
:class="{ 'ppt-slide-section-header--top': !isTitleFocus }"
>
<h2 class="ppt-slide-section-title">{{ slide.title }}</h2>
<Transition name="ppt-reveal">
<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">
<div
v-if="isUnitVisible(idx)"
class="ppt-block-reveal"
:class="{ 'ppt-block-reveal--hero': isHeroMedia(unit) }"
>
<template v-if="unit.kind === 'single'">
<h1 v-if="unit.block.type === 'h1'" class="ppt-block-h1">{{ unit.block.text }}</h1>
<h2 v-else-if="unit.block.type === 'h2'" class="ppt-block-h2">{{ unit.block.text }}</h2>
<h3 v-else-if="unit.block.type === 'h3'" class="ppt-block-h3">{{ unit.block.text }}</h3>
<p v-else-if="unit.block.type === 'paragraph'" class="ppt-block-paragraph">
{{ unit.block.text }}
</p>
<div
v-else-if="unit.block.type === 'image'"
class="ppt-block-image-wrap"
:class="{ 'ppt-block-image-wrap--hero': isHeroMedia(unit) }"
@click.stop
@pointerdown.stop
>
<img
v-if="!isImageFailed(unit.block.src)"
:key="imageKey(unit.block.src)"
:src="resolveImageUrl(unit.block.src)"
:alt="unit.block.alt"
class="ppt-block-image"
:referrerpolicy="useNoReferrerPolicy(unit.block.src) ? 'no-referrer' : undefined"
loading="eager"
@error="onImageError(unit.block.src)"
@click.stop
@pointerdown.stop
/>
<div v-else class="ppt-block-image-fallback">[图片: {{ unit.block.alt }}]</div>
</div>
<div
v-else-if="unit.block.type === 'video'"
class="ppt-block-video-wrap"
:class="{ 'ppt-block-video-wrap--hero': isHeroMedia(unit) }"
@click.stop
@pointerdown.stop
@dblclick.stop
>
<video
:ref="(el) => setVideoRef(idx, el as HTMLVideoElement | null)"
controls
playsinline
webkit-playsinline
preload="metadata"
class="ppt-block-video"
:src="resolveImageUrl(unit.block.src)"
:poster="unit.block.poster ? resolveImageUrl(unit.block.poster) : undefined"
@click.stop="stopMediaEvent"
@pointerdown.stop="stopMediaEvent"
@dblclick.stop="stopMediaEvent"
>
<source
v-if="unit.block.mimeType"
:src="resolveImageUrl(unit.block.src)"
:type="unit.block.mimeType"
/>
</video>
</div>
<div v-else-if="unit.block.type === 'code'" class="ppt-block-code">
<div class="ppt-block-code-label">{{ unit.block.lang?.toUpperCase() || 'CODE' }}</div>
<pre class="ppt-block-code-content">{{ unit.block.text }}</pre>
</div>
<ul
v-else-if="unit.block.type === 'list'"
class="ppt-block-list"
:class="{ 'ppt-block-list--ordered': unit.block.ordered }"
>
<li v-for="(item, itemIdx) in unit.block.items" :key="itemIdx">{{ item }}</li>
</ul>
<p v-else-if="unit.block.type === 'placeholder'" class="ppt-block-placeholder">
{{ unit.block.text }}
</p>
</template>
</div>
</Transition>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
/**
* PPT 幻灯片预览器:主视图、缩略图、分步揭示交互
*/
import { computed } from 'vue'
import Icon from '../Icon.vue'
import PptSlideCanvas from './PptSlideCanvas.vue'
import type { PptSlide } from '../../utils/articlePptSlides'
import { getSlideStepCount, isMediaInteractionTarget } from '../../utils/articlePptSlides'
import type { SlideDirection } from '../../composables/usePptPresentation'
import '../../styles/pptPreview.css'
interface Props {
slides: PptSlide[]
theme?: 'light' | 'dark'
currentIndex: number
currentStep: number
slideDirection?: SlideDirection
isFullscreen?: boolean
showThumbnails?: boolean
toolbarVisible?: boolean
}
const props = withDefaults(defineProps<Props>(), {
theme: 'dark',
slideDirection: 'none',
isFullscreen: false,
showThumbnails: true,
toolbarVisible: true,
})
const emit = defineEmits<{
advance: []
retreat: []
goToSlide: [index: number]
}>()
const total = computed(() => props.slides.length)
const currentSlide = computed(() => props.slides[props.currentIndex])
const stepTotal = computed(() => {
const slide = currentSlide.value
return slide ? getSlideStepCount(slide) : 1
})
const canRetreat = computed(() => props.currentStep > 0 || props.currentIndex > 0)
const canAdvance = computed(() => {
const slide = currentSlide.value
if (!slide) return false
const maxStep = getSlideStepCount(slide) - 1
return props.currentStep < maxStep || props.currentIndex < props.slides.length - 1
})
const showThumbnailsBar = computed(
() => props.showThumbnails && (!props.isFullscreen || props.toolbarVisible)
)
const isEdgeToEdge = computed(() => props.isFullscreen && !props.toolbarVisible)
const FULL_STEP = Number.POSITIVE_INFINITY
const handleSlideClick = (e: MouseEvent) => {
if (e.button !== 0) return
if (isMediaInteractionTarget(e.target)) return
emit('advance')
}
const handleSlideContextMenu = (e: MouseEvent) => {
e.preventDefault()
emit('retreat')
}
</script>
<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 },
]"
>
<div class="ppt-stage">
<button
type="button"
class="ppt-nav-btn ppt-nav-btn--prev"
:disabled="!canRetreat"
aria-label="上一步"
@click="emit('retreat')"
>
<Icon name="chevron-left" :size="24" />
</button>
<div
class="ppt-slide-scaler cursor-pointer select-none"
@click="handleSlideClick"
@contextmenu="handleSlideContextMenu"
>
<Transition :name="slideDirection === 'backward' ? 'ppt-slide-back' : 'ppt-slide'">
<PptSlideCanvas
v-if="currentSlide"
:key="currentIndex"
:slide="currentSlide"
:theme="theme"
:visible-step="currentStep"
:slide-direction="slideDirection"
/>
</Transition>
</div>
<button
type="button"
class="ppt-nav-btn ppt-nav-btn--next"
:disabled="!canAdvance"
aria-label="下一步"
@click="emit('advance')"
>
<Icon name="chevron-right" :size="24" />
</button>
</div>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-if="showThumbnailsBar"
class="ppt-thumbnails-bar flex-shrink-0 border-t border-white/10 bg-black/60 backdrop-blur-md px-4 py-3"
>
<div class="flex gap-2 overflow-x-auto pb-1 max-w-full mx-auto">
<button
v-for="(slide, idx) in slides"
:key="idx"
type="button"
class="ppt-thumbnail"
:class="{ 'ppt-thumbnail--active': idx === currentIndex }"
:aria-label="` ${idx + 1} `"
@click="emit('goToSlide', idx)"
>
<div class="ppt-thumbnail-inner">
<PptSlideCanvas :slide="slide" :theme="theme" compact :visible-step="FULL_STEP" />
</div>
</button>
</div>
</div>
</Transition>
<div
v-if="!isFullscreen || toolbarVisible"
class="absolute bottom-20 md:bottom-24 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-black/50 text-white/60 text-xs font-mono pointer-events-none z-10"
>
{{ currentIndex + 1 }} / {{ total }}
<span class="text-white/30 mx-1">·</span>
{{ currentStep + 1 }} / {{ stepTotal }}
</div>
<div
v-if="!isFullscreen"
class="absolute bottom-4 right-4 text-[10px] text-white/30 hidden md:block pointer-events-none text-right leading-relaxed z-10"
>
左键/滚轮下/方向键 下一步<br />
右键/滚轮上 上一步 · F 全屏
</div>
</div>
</template>

View File

@@ -0,0 +1,172 @@
/**
* PPT 预览演示交互:分步揭示、翻页、全屏、键盘与滚轮
*/
import { computed, onMounted, onUnmounted, ref, watchEffect, type Ref } from 'vue'
import { getSlideStepCount, isMediaInteractionTarget, type PptSlide } from '../utils/articlePptSlides'
export type SlideDirection = 'forward' | 'backward' | 'none'
const WHEEL_THROTTLE_MS = 300
export function usePptPresentation(slides: Ref<PptSlide[]>, containerRef: Ref<HTMLElement | null>) {
const currentIndex = ref(0)
const currentStep = ref(0)
const isFullscreen = ref(false)
const slideDirection = ref<SlideDirection>('none')
const totalSlides = computed(() => slides.value.length)
const currentSlide = computed(() => slides.value[currentIndex.value])
const maxStep = computed(() => {
const slide = currentSlide.value
return slide ? getSlideStepCount(slide) - 1 : 0
})
const totalSteps = computed(() => maxStep.value + 1)
const goToSlide = (index: number) => {
if (index < 0 || index >= slides.value.length) return
slideDirection.value = index > currentIndex.value ? 'forward' : index < currentIndex.value ? 'backward' : 'none'
currentIndex.value = index
currentStep.value = 0
}
const advance = () => {
if (currentStep.value < maxStep.value) {
slideDirection.value = 'forward'
currentStep.value += 1
return
}
if (currentIndex.value < slides.value.length - 1) {
slideDirection.value = 'forward'
currentIndex.value += 1
currentStep.value = 0
}
}
const retreat = () => {
if (currentStep.value > 0) {
slideDirection.value = 'backward'
currentStep.value -= 1
return
}
if (currentIndex.value > 0) {
slideDirection.value = 'backward'
const newIndex = currentIndex.value - 1
currentIndex.value = newIndex
const prevSlide = slides.value[newIndex]
currentStep.value = prevSlide ? getSlideStepCount(prevSlide) - 1 : 0
}
}
const syncFullscreenState = () => {
isFullscreen.value = document.fullscreenElement === containerRef.value
}
const toggleFullscreen = async () => {
const el = containerRef.value
if (!el) return
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen()
} else {
await el.requestFullscreen()
}
} catch (err) {
console.error('Fullscreen failed:', err)
}
}
const handleKeydown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return
if (isMediaInteractionTarget(target) || isMediaInteractionTarget(document.activeElement)) return
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
case ' ':
case 'PageDown':
e.preventDefault()
advance()
break
case 'ArrowLeft':
case 'ArrowUp':
case 'PageUp':
e.preventDefault()
retreat()
break
case 'Home':
e.preventDefault()
goToSlide(0)
break
case 'End':
e.preventDefault()
goToSlide(slides.value.length - 1)
break
case 'f':
case 'F':
e.preventDefault()
void toggleFullscreen()
break
default:
break
}
}
let wheelTarget: HTMLElement | null = null
let lastWheelAt = 0
const handleWheel = (e: WheelEvent) => {
if (isMediaInteractionTarget(e.target)) return
if (Math.abs(e.deltaY) < 10) return
const now = Date.now()
if (now - lastWheelAt < WHEEL_THROTTLE_MS) return
lastWheelAt = now
e.preventDefault()
if (e.deltaY > 0) advance()
else retreat()
}
watchEffect((onCleanup) => {
const el = containerRef.value
if (!el || el === wheelTarget) return
if (wheelTarget) wheelTarget.removeEventListener('wheel', handleWheel)
wheelTarget = el
el.addEventListener('wheel', handleWheel, { passive: false })
onCleanup(() => {
el.removeEventListener('wheel', handleWheel)
if (wheelTarget === el) wheelTarget = null
})
})
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('fullscreenchange', syncFullscreenState)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('fullscreenchange', syncFullscreenState)
if (wheelTarget) wheelTarget.removeEventListener('wheel', handleWheel)
if (document.fullscreenElement === containerRef.value) {
void document.exitFullscreen()
}
})
return {
currentIndex,
currentStep,
totalSlides,
totalSteps,
maxStep,
slideDirection,
isFullscreen,
goToSlide,
advance,
retreat,
toggleFullscreen,
}
}

View File

@@ -170,7 +170,17 @@
<span class="px-2 py-0.5 md:px-3 md:py-1 border border-white/20 rounded-full text-[10px] md:text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
<span class="text-xs md:text-sm text-art-muted">{{ post.date }}</span>
</div>
<ArticleExportMenu :post="post" :rendered-html="renderedContent" />
<div class="flex items-center gap-2">
<button
type="button"
class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-md px-3 py-1.5 md:px-4 md:py-2 text-xs md:text-sm text-art-muted hover:text-white hover:bg-white/10 hover:border-art-accent/30 transition-all duration-200"
@click="router.push(`/blog/${postId}/ppt`)"
>
<Icon name="presentation" :size="14" />
<span class="font-medium">PPT 预览</span>
</button>
<ArticleExportMenu :post="post" :rendered-html="renderedContent" />
</div>
</div>
<h1 class="font-serif text-2xl md:text-5xl lg:text-6xl text-white leading-snug md:leading-tight mb-6 md:mb-8 break-words">{{ post.title }}</h1>
<AuthorInfo

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
/**
* 文章 PPT 预览页
*/
import { computed, onMounted, ref } from 'vue'
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 { useToast } from '../composables/useToast'
import { fetchPost, type Post } from '../services/api'
import { exportAsPptStructured, type ExportTheme } from '../utils/articleExport'
import { buildPptSlides } from '../utils/articlePptSlides'
import { getCachedSiteTitle } from '../router'
const route = useRoute()
const router = useRouter()
const toast = useToast()
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 containerRef = ref<HTMLElement | null>(null)
const toolbarVisible = ref(true)
let hideToolbarTimer: ReturnType<typeof setTimeout> | null = null
const slides = computed(() => (post.value ? buildPptSlides(post.value) : []))
const {
currentIndex,
currentStep,
totalSlides,
totalSteps,
slideDirection,
isFullscreen,
goToSlide,
advance,
retreat,
toggleFullscreen,
} = usePptPresentation(slides, containerRef)
const showHeader = computed(
() => !loading.value && !error.value && (!isFullscreen.value || toolbarVisible.value)
)
const pptChromeHeight = computed(() => {
if (loading.value || error.value || !slides.value.length) return '0px'
if (isFullscreen.value && !toolbarVisible.value) return '0px'
let px = 0
if (showHeader.value) px += 52
if (showHeader.value) px += 88
return `${px}px`
})
const fetchDetail = async () => {
loading.value = true
error.value = ''
try {
const data = await fetchPost(postId)
if (data) {
post.value = data
document.title = `${data.title} · PPT 预览 | ${getCachedSiteTitle()}`
} else {
error.value = '未找到该文章'
}
} catch (err) {
console.error('Failed to load post for PPT preview:', err)
error.value = '加载文章失败,请稍后重试'
} finally {
loading.value = false
}
}
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
toast.info('正在生成 PPT…')
try {
const result = await exportAsPptStructured(post.value)
if (result.success) {
toast.success('导出成功')
if (result.warning) toast.warning(result.warning)
} else {
toast.error(result.warning || '导出失败')
}
} catch (err) {
console.error('PPT export failed:', err)
toast.error('导出失败,请稍后重试')
} finally {
isExporting.value = false
}
}
const handleMouseMove = () => {
if (!isFullscreen.value) return
toolbarVisible.value = true
if (hideToolbarTimer) clearTimeout(hideToolbarTimer)
hideToolbarTimer = setTimeout(() => {
toolbarVisible.value = false
}, 3000)
}
onMounted(() => {
void fetchDetail()
})
</script>
<template>
<div
ref="containerRef"
class="flex flex-col h-[100dvh] min-h-0 bg-[#0a0a0a] text-white relative ppt-preview-page"
:style="{ '--ppt-chrome-h': pptChromeHeight }"
@mousemove="handleMouseMove"
>
<header
v-if="showHeader"
class="flex-shrink-0 flex items-center justify-between gap-3 px-4 py-3 border-b border-white/10 bg-[#0a0a0a]/90 backdrop-blur-md z-20"
>
<button
type="button"
class="flex items-center gap-2 text-sm text-white/60 hover:text-white transition-colors"
@click="goBack"
>
<Icon name="arrow-left" :size="16" />
<span class="hidden sm:inline">返回文章</span>
</button>
<div class="text-sm font-medium text-white/80 truncate max-w-[30%] text-center">
{{ post?.title }}
<span v-if="totalSlides" class="text-white/40 font-mono text-xs ml-1">
{{ currentIndex + 1 }}/{{ totalSlides }} · {{ currentStep + 1 }}/{{ totalSteps }}
</span>
</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>
<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="toggleFullscreen"
>
<Icon :name="isFullscreen ? 'minimize' : 'maximize'" :size="14" />
<span class="hidden sm:inline">{{ isFullscreen ? '退出' : '全屏' }}</span>
</button>
<button
type="button"
:disabled="isExporting"
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 disabled:opacity-50"
@click="handleExport"
>
<Icon :name="isExporting ? 'loader-2' : 'download'" :size="14" :class="isExporting ? 'animate-spin' : ''" />
<span class="hidden sm:inline">{{ isExporting ? '导出中…' : '导出 PPT' }}</span>
</button>
</div>
</header>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-art-accent"></div>
</div>
<div v-else-if="error" class="flex-1 flex flex-col items-center justify-center gap-4 text-center px-4">
<p class="text-red-400">{{ error }}</p>
<button
type="button"
class="rounded-full border border-white/10 px-4 py-2 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"
@click="goBack"
>
返回文章
</button>
</div>
<PptSlideViewer
v-else-if="slides.length"
:slides="slides"
:theme="theme"
:current-index="currentIndex"
:current-step="currentStep"
:slide-direction="slideDirection"
:is-fullscreen="isFullscreen"
:toolbar-visible="toolbarVisible"
class="flex-1 min-h-0 relative"
@advance="advance"
@retreat="retreat"
@go-to-slide="goToSlide"
/>
<div v-else class="flex-1 flex items-center justify-center text-white/50 text-sm">
暂无幻灯片内容
</div>
</div>
</template>

View File

@@ -68,6 +68,7 @@ const setPageTitle = async (to: any) => {
const routes = [
{ path: '/', name: 'home', component: () => import('./pages/Home.vue'), meta: { title: '' } },
{ path: '/blog', name: 'blog', component: () => import('./pages/Blog.vue'), meta: { title: '思考' } },
{ path: '/blog/:id/ppt', name: 'blog-ppt-preview', component: () => import('./pages/BlogPptPreview.vue'), meta: { title: 'PPT 预览', dynamic: true, presentation: true } },
{ path: '/blog/:id', name: 'blog-detail', component: () => import('./pages/BlogDetail.vue'), meta: { title: '', dynamic: true } },
{ path: '/columns', name: 'columns', component: () => import('./pages/Columns.vue'), meta: { title: '专栏' } },
{ path: '/columns/:id', name: 'column-detail', component: () => import('./pages/ColumnDetail.vue'), meta: { title: '', dynamic: true } },

View File

@@ -0,0 +1,506 @@
/* 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-preview-root.ppt-theme-dark {
--ppt-bg: #141414;
--ppt-text: #f5f5f5;
--ppt-muted: #a0a0a0;
--ppt-accent: #c9a962;
--ppt-code-bg: #1e1e1e;
--ppt-code-text: #eeeeee;
--ppt-border: rgba(255, 255, 255, 0.1);
}
/* 舞台与缩放容器 */
.ppt-stage {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 0.25rem 0;
container-type: size;
container-name: ppt-stage;
}
.ppt-slide-scaler {
width: min(100cqw, calc(100cqh * 16 / 9));
aspect-ratio: 16 / 9;
max-height: 100cqh;
position: relative;
overflow: hidden;
}
/* 不支持 container query 时的回退 */
@supports not (width: 1cqw) {
.ppt-slide-scaler {
width: min(100%, calc((100dvh - var(--ppt-chrome-h)) * 16 / 9));
max-height: calc(100dvh - var(--ppt-chrome-h));
}
}
.ppt-slide-scaler .ppt-preview-root {
width: 100%;
height: 100%;
}
.ppt-slide-scaler .ppt-slide-canvas {
aspect-ratio: auto;
width: 100%;
height: 100%;
}
.ppt-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
padding: 0.5rem;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 0.7);
transition: color 0.15s, background 0.15s;
}
.ppt-nav-btn:hover:not(:disabled) {
color: #fff;
background: rgba(0, 0, 0, 0.6);
}
.ppt-nav-btn:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.ppt-nav-btn--prev {
left: 0.75rem;
}
.ppt-nav-btn--next {
right: 0.75rem;
}
.ppt-viewer--fullscreen .ppt-stage {
padding: 0;
}
.ppt-viewer--fullscreen .ppt-slide-scaler {
width: 100vw;
height: 100dvh;
max-width: none;
max-height: none;
aspect-ratio: auto;
}
.ppt-viewer--fullscreen .ppt-nav-btn {
display: none;
}
.ppt-slide-canvas {
aspect-ratio: 16 / 9;
width: 100%;
height: 100%;
background: var(--ppt-bg);
color: var(--ppt-text);
border: 1px solid var(--ppt-border);
border-radius: 0.5rem;
overflow: hidden;
display: flex;
flex-direction: column;
padding: clamp(1.25rem, 2.5vh, 2.5rem) clamp(1.25rem, 3vw, 2rem);
box-sizing: border-box;
position: relative;
transition: padding 0.5s var(--ppt-ease-soft);
}
.ppt-viewer--fullscreen .ppt-slide-canvas {
border: none;
border-radius: 0;
}
.ppt-slide-canvas--title {
justify-content: center;
align-items: center;
text-align: center;
}
.ppt-slide-canvas--focus-title {
justify-content: center;
align-items: center;
text-align: center;
}
.ppt-slide-canvas--focus-title .ppt-slide-section-header {
position: absolute;
top: 50%;
left: clamp(1.25rem, 3vw, 2rem);
right: clamp(1.25rem, 3vw, 2rem);
transform: translateY(-50%);
margin-bottom: 0;
flex: 0;
text-align: center;
}
.ppt-slide-canvas--focus-title .ppt-slide-section-title {
font-size: clamp(1.5rem, 3.5vw, 2.5rem);
text-align: center;
margin-bottom: 0;
}
.ppt-slide-canvas--has-content {
justify-content: flex-start;
}
.ppt-slide-section-header {
flex-shrink: 0;
margin-bottom: 1rem;
transition:
transform 0.5s var(--ppt-ease-soft),
opacity 0.5s var(--ppt-ease-soft),
margin 0.5s var(--ppt-ease-soft);
}
.ppt-slide-section-header--top {
position: relative;
top: auto;
left: auto;
right: auto;
transform: none;
text-align: left;
}
.ppt-slide-section-header--top .ppt-slide-section-title {
font-size: clamp(1.125rem, 2.2vw, 1.75rem);
}
.ppt-slide-title-main {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(1.5rem, 3.5vw, 2.75rem);
font-weight: 700;
line-height: 1.25;
margin: 0 0 1.5rem;
color: var(--ppt-text);
}
.ppt-slide-title-meta {
font-size: clamp(0.75rem, 1.5vw, 1rem);
color: var(--ppt-muted);
line-height: 1.8;
}
.ppt-slide-section-title {
font-size: clamp(1.125rem, 2.2vw, 1.75rem);
font-weight: 700;
margin: 0 0 0.5rem;
color: var(--ppt-text);
transition: font-size 0.5s var(--ppt-ease-soft);
}
.ppt-slide-section-accent {
width: 3rem;
height: 0.2rem;
background: var(--ppt-accent);
border-radius: 9999px;
}
.ppt-slide-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
overflow-x: hidden;
}
.ppt-block-reveal {
flex-shrink: 0;
}
.ppt-block-reveal--hero {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.ppt-block-h1 {
font-size: clamp(1.15rem, 2.4vw, 1.625rem);
font-weight: 700;
color: var(--ppt-text);
margin: 0;
line-height: 1.3;
}
.ppt-block-h2 {
font-size: clamp(1rem, 2vw, 1.375rem);
font-weight: 700;
color: var(--ppt-text);
margin: 0;
line-height: 1.35;
}
.ppt-block-h3 {
font-size: clamp(0.95rem, 1.6vw, 1.125rem);
font-weight: 600;
color: var(--ppt-text);
margin: 0;
}
.ppt-block-paragraph {
font-size: clamp(0.8rem, 1.4vw, 1rem);
line-height: 1.65;
color: var(--ppt-text);
margin: 0;
}
.ppt-block-image-wrap,
.ppt-block-video-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
max-height: 45%;
flex-shrink: 0;
pointer-events: auto;
}
.ppt-block-video-wrap {
min-height: 3rem;
}
.ppt-block-image-wrap--hero,
.ppt-block-video-wrap--hero {
flex: 1;
max-height: none;
width: 100%;
}
.ppt-block-image-wrap--hero .ppt-block-image,
.ppt-block-video-wrap--hero .ppt-block-video {
max-height: 100%;
height: 100%;
}
.ppt-thumbnail .ppt-block-image-wrap,
.ppt-thumbnail .ppt-block-video-wrap {
max-height: 45%;
}
.ppt-block-image,
.ppt-block-video {
max-width: 100%;
max-height: 100%;
width: 100%;
object-fit: contain;
border-radius: 0.375rem;
background: #000;
pointer-events: auto;
}
.ppt-block-image-fallback {
font-size: 0.875rem;
color: var(--ppt-muted);
font-style: italic;
text-align: center;
padding: 1rem;
border: 1px dashed var(--ppt-border);
border-radius: 0.375rem;
}
.ppt-block-code {
flex-shrink: 0;
max-height: 40%;
display: flex;
flex-direction: column;
border-radius: 0.5rem;
overflow: hidden;
background: var(--ppt-code-bg);
}
.ppt-block-code-label {
padding: 0.35rem 0.75rem;
font-size: 0.65rem;
font-family: ui-monospace, monospace;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ppt-block-code-content {
margin: 0;
padding: 0.75rem;
font-size: clamp(0.65rem, 1.1vw, 0.8rem);
font-family: 'JetBrains Mono', ui-monospace, monospace;
color: var(--ppt-code-text);
overflow: auto;
white-space: pre;
line-height: 1.5;
flex: 1;
min-height: 0;
}
.ppt-block-list {
margin: 0;
padding-left: 1.25rem;
font-size: clamp(0.8rem, 1.4vw, 1rem);
line-height: 1.6;
color: var(--ppt-text);
}
.ppt-block-list--ordered {
list-style-type: decimal;
}
.ppt-block-placeholder {
font-size: 0.875rem;
color: var(--ppt-muted);
font-style: italic;
margin: 0;
}
/* 内容块揭示动画 */
.ppt-reveal-enter-active {
transition: opacity 0.5s var(--ppt-ease-soft), transform 0.5s var(--ppt-ease-soft);
}
.ppt-reveal-leave-active {
transition: opacity 0.35s var(--ppt-ease-soft), transform 0.35s var(--ppt-ease-soft);
}
.ppt-reveal-enter-from {
opacity: 0;
transform: translateY(6px);
}
.ppt-reveal-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* 幻灯片切换动画crossfade 重叠) */
.ppt-slide-enter-active,
.ppt-slide-leave-active,
.ppt-slide-back-enter-active,
.ppt-slide-back-leave-active {
transition: opacity 0.55s var(--ppt-ease-soft), transform 0.55s var(--ppt-ease-soft);
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ppt-slide-leave-active,
.ppt-slide-back-leave-active {
z-index: 1;
}
.ppt-slide-enter-active,
.ppt-slide-back-enter-active {
z-index: 2;
}
.ppt-slide-enter-from {
opacity: 0;
transform: translateX(8px) scale(0.985);
}
.ppt-slide-leave-to {
opacity: 0;
transform: translateX(-8px) scale(0.985);
}
.ppt-slide-back-enter-from {
opacity: 0;
transform: translateX(-8px) scale(0.985);
}
.ppt-slide-back-leave-to {
opacity: 0;
transform: translateX(8px) scale(0.985);
}
.ppt-thumbnail {
aspect-ratio: 16 / 9;
width: 7rem;
flex-shrink: 0;
border-radius: 0.375rem;
overflow: hidden;
border: 2px solid transparent;
cursor: pointer;
opacity: 0.65;
transition: opacity 0.15s, border-color 0.15s;
background: var(--ppt-bg);
}
.ppt-thumbnail:hover {
opacity: 0.9;
}
.ppt-thumbnail--active {
opacity: 1;
border-color: var(--ppt-accent);
}
.ppt-thumbnail-inner {
transform: scale(0.18);
transform-origin: top left;
width: 555%;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.ppt-reveal-enter-active,
.ppt-reveal-leave-active,
.ppt-slide-enter-active,
.ppt-slide-leave-active,
.ppt-slide-back-enter-active,
.ppt-slide-back-leave-active,
.ppt-slide-section-header,
.ppt-slide-section-title,
.ppt-slide-canvas {
transition-duration: 0.01ms !important;
}
.ppt-reveal-enter-from,
.ppt-reveal-leave-to,
.ppt-slide-enter-from,
.ppt-slide-leave-to,
.ppt-slide-back-enter-from,
.ppt-slide-back-leave-to {
transform: none;
}
}
@media (max-width: 768px) {
.ppt-stage {
padding: 0.25rem 0;
}
.ppt-slide-canvas {
padding: 1rem 1.25rem;
}
.ppt-slide-canvas--focus-title .ppt-slide-section-header {
left: 1.25rem;
right: 1.25rem;
}
.ppt-thumbnail {
width: 5rem;
}
}

View File

@@ -1,10 +1,20 @@
/**
* 文章多格式导出工具
* 支持 Markdown、纯文本、PDF、PNG 四种格式
* 支持 Markdown、纯文本、PDF、PNG、PPT 等格式
*/
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import PptxGenJS from 'pptxgenjs'
import type { Post } from '../services/api'
import {
buildPptSlides,
estimateBlockHeight,
inferVideoExtn,
resolveImageUrl,
type ContentBlock,
type PptSectionSlide,
type PptSlide,
} from './articlePptSlides'
import { buildExportBasename, downloadBlob, downloadText } from './fileDownload'
import '../styles/articleExport.css'
@@ -12,7 +22,7 @@ import '../styles/articleExport.css'
export type ExportTheme = 'light' | 'dark'
/** 支持的导出格式 */
export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png'
export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png' | 'ppt-structured' | 'ppt-screenshot'
/** 导出操作结果 */
export interface ExportResult {
@@ -21,11 +31,11 @@ export interface ExportResult {
}
const EXPORT_CONTAINER_WIDTH = 800
const PPT_CONTENT_X = 0.5
const PPT_CONTENT_W = 9
const PPT_SLIDE_BOTTOM = 5.1
const MAX_VIDEO_EXPORT_BYTES = 30 * 1024 * 1024
/**
* 构建 Markdown frontmatter 元信息块
* @param post 文章数据
*/
function buildMarkdownFrontmatter(post: Post): string {
const tags = post.tags?.map((t) => t.name).join(', ') || ''
const lines = [
@@ -40,42 +50,24 @@ function buildMarkdownFrontmatter(post: Post): string {
return lines.join('\n')
}
/**
* 将 Markdown 转为可读纯文本(轻量剥离语法)
* @param markdown 原始 Markdown 内容
*/
function markdownToPlainText(markdown: string): string {
let text = markdown
// 代码块:保留内容,去掉围栏
text = text.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/^```[\w-]*\n?/, '').replace(/```$/, '').trim()
)
// 行内代码
text = text.replace(/`([^`]+)`/g, '$1')
// 图片:保留 alt 文本
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
// 链接:保留文字
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// 标题前缀
text = text.replace(/^#{1,6}\s+/gm, '')
// 粗体/斜体
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
text = text.replace(/(\*|_)(.*?)\1/g, '$2')
// 引用
text = text.replace(/^>\s+/gm, '')
// 无序列表
text = text.replace(/^[-*+]\s+/gm, '• ')
// HTML 标签
text = text.replace(/<[^>]+>/g, '')
// 多余空行
text = text.replace(/\n{3,}/g, '\n\n')
return text.trim()
}
/**
* 构建纯文本元信息头部
* @param post 文章数据
*/
function buildTextHeader(post: Post): string {
const lines = [
post.title,
@@ -89,12 +81,20 @@ function buildTextHeader(post: Post): string {
return lines.join('\n')
}
/**
* 创建离屏导出 DOM 容器PDF/PNG 共用)
* @param post 文章数据
* @param renderedHtml 已渲染的正文 HTML
* @param theme 导出主题
*/
function sanitizeExportHtml(html: string): string {
if (typeof DOMParser === 'undefined') return html
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('video').forEach((video) => {
const poster = video.getAttribute('poster')
const placeholder = document.createElement('div')
placeholder.className = 'export-video-placeholder'
placeholder.textContent = poster ? '[视频]' : '[视频内容]'
placeholder.style.cssText = 'padding:2rem;text-align:center;color:#888;border:1px dashed #ccc;border-radius:8px;margin:1rem 0;'
video.replaceWith(placeholder)
})
return doc.body.innerHTML
}
function buildExportContainer(post: Post, renderedHtml: string, theme: ExportTheme): HTMLDivElement {
const container = document.createElement('div')
container.className = `article-export-root export-theme-${theme}`
@@ -116,20 +116,14 @@ function buildExportContainer(post: Post, renderedHtml: string, theme: ExportThe
<h1 class="export-title">${escapeHtml(post.title)}</h1>
${post.userName ? `<div class="export-meta export-author">作者:${escapeHtml(post.userName)}</div>` : ''}
</div>
<div class="export-body">${renderedHtml}</div>
<div class="export-body">${sanitizeExportHtml(renderedHtml)}</div>
`
// 移除交互元素,避免截图出现按钮
container.querySelectorAll('.copy-btn').forEach((el) => el.remove())
document.body.appendChild(container)
return container
}
/**
* HTML 特殊字符转义(用于拼接模板字符串)
* @param str 原始字符串
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
@@ -138,10 +132,316 @@ function escapeHtml(str: string): string {
.replace(/"/g, '&quot;')
}
/**
* 使用 html2canvas 将 DOM 转为 Canvas
* @param element 待截图元素
*/
async function fetchImageAsDataUrl(url: string): Promise<{ data: string | null; failed: boolean }> {
try {
const response = await fetch(resolveImageUrl(url), { mode: 'cors' })
if (!response.ok) return { data: null, failed: true }
const blob = await response.blob()
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
return { data, failed: false }
} catch {
return { data: null, failed: true }
}
}
async function fetchVideoAsBase64(
url: string,
mimeType?: string
): Promise<{ data: string | null; extn: string; failed: boolean; tooLarge: boolean }> {
try {
const response = await fetch(resolveImageUrl(url), { mode: 'cors' })
if (!response.ok) return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: false }
const blob = await response.blob()
if (blob.size > MAX_VIDEO_EXPORT_BYTES) {
return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: true }
}
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
return { data, extn: inferVideoExtn(url, blob.type || mimeType), failed: false, tooLarge: false }
} catch {
return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: false }
}
}
function createPptx(): PptxGenJS {
const pptx = new PptxGenJS()
pptx.layout = 'LAYOUT_16x9'
pptx.author = 'Blog Export'
return pptx
}
function addTitleSlideToPptx(pptx: PptxGenJS, slide: Extract<PptSlide, { kind: 'title' }>): void {
const pptSlide = pptx.addSlide()
const metaLines = [
slide.date || '',
slide.categoryName || '未分类',
slide.userName ? `作者:${slide.userName}` : '',
slide.tagNames.length ? `标签:${slide.tagNames.join(' · ')}` : '',
].filter(Boolean)
pptSlide.addText(slide.title, {
x: PPT_CONTENT_X,
y: 1.4,
w: PPT_CONTENT_W,
h: 1.5,
fontSize: 32,
bold: true,
color: '1A1A1A',
align: 'center',
valign: 'middle',
})
if (metaLines.length) {
pptSlide.addText(metaLines.join('\n'), {
x: PPT_CONTENT_X,
y: 3.2,
w: PPT_CONTENT_W,
h: 1.5,
fontSize: 14,
color: '666666',
align: 'center',
valign: 'top',
})
}
}
function addSectionHeader(slide: PptxGenJS.Slide, title: string, y = 0.35): number {
slide.addText(title, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.6,
fontSize: 22,
bold: true,
color: '1A1A1A',
})
slide.addShape('rect' as PptxGenJS.SHAPE_NAME, {
x: PPT_CONTENT_X,
y: y + 0.55,
w: 1.2,
h: 0.05,
fill: { color: 'C9A962' },
line: { color: 'C9A962' },
})
return y + 0.85
}
async function addBlockToPptxSlide(
slide: PptxGenJS.Slide,
block: ContentBlock,
y: number,
mediaWarning: { value: boolean }
): Promise<number> {
switch (block.type) {
case 'h1':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.5,
fontSize: 22,
bold: true,
color: '222222',
})
return y + 0.6
case 'h2':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.45,
fontSize: 18,
bold: true,
color: '333333',
})
return y + 0.5
case 'h3':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.4,
fontSize: 16,
bold: true,
color: '333333',
})
return y + 0.45
case 'paragraph':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: PPT_SLIDE_BOTTOM - y,
fontSize: 14,
color: '444444',
valign: 'top',
breakLine: true,
})
return y + estimateBlockHeight(block) + 0.1
case 'code': {
const label = block.lang ? block.lang.toUpperCase() : 'CODE'
slide.addText(label, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.25,
fontSize: 9,
color: '888888',
})
const codeHeight = estimateBlockHeight(block)
slide.addText(block.text, {
x: PPT_CONTENT_X,
y: y + 0.25,
w: PPT_CONTENT_W,
h: codeHeight,
fontSize: 10,
fontFace: 'Courier New',
color: 'EEEEEE',
fill: { color: '1E1E1E' },
valign: 'top',
margin: 0.08,
breakLine: true,
})
return y + codeHeight + 0.35
}
case 'list': {
const listHeight = estimateBlockHeight(block)
slide.addText(
block.items.map((item) => ({
text: item,
options: { bullet: block.ordered ? { type: 'number' } : true, breakLine: true },
})),
{
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: listHeight,
fontSize: 14,
color: '444444',
valign: 'top',
}
)
return y + listHeight + 0.1
}
case 'image': {
const { data, failed } = await fetchImageAsDataUrl(block.src)
if (failed || !data) {
mediaWarning.value = true
slide.addText(`[图片: ${block.alt}]`, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.4,
fontSize: 12,
color: '888888',
italic: true,
})
return y + 0.45
}
slide.addImage({
data,
x: PPT_CONTENT_X + 1,
y,
w: 7,
h: 3,
sizing: { type: 'contain', w: 7, h: 3 },
})
return y + 3.2
}
case 'video': {
const { data, failed, tooLarge, extn } = await fetchVideoAsBase64(block.src, block.mimeType)
if (failed || !data) {
mediaWarning.value = true
slide.addText(tooLarge ? '[视频: 文件过大,未嵌入]' : '[视频: 无法嵌入]', {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.4,
fontSize: 12,
color: '888888',
italic: true,
})
return y + 0.45
}
slide.addMedia({
type: 'video',
data,
extn,
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 3,
})
return y + 3.5
}
case 'placeholder':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.35,
fontSize: 12,
color: '888888',
italic: true,
})
return y + 0.4
default:
return y
}
}
async function addSectionSlideToPptx(
pptx: PptxGenJS,
sectionSlide: PptSectionSlide,
mediaWarning: { value: boolean }
): Promise<void> {
const slide = pptx.addSlide()
let y = addSectionHeader(slide, sectionSlide.title)
for (const block of sectionSlide.blocks) {
y = await addBlockToPptxSlide(slide, block, y, mediaWarning)
}
}
function canvasToPptSlides(pptx: PptxGenJS, canvas: HTMLCanvasElement): void {
const sliceHeight = Math.floor(canvas.width * (9 / 16))
if (sliceHeight <= 0) return
for (let y = 0; y < canvas.height; y += sliceHeight) {
const height = Math.min(sliceHeight, canvas.height - y)
const sliceCanvas = document.createElement('canvas')
sliceCanvas.width = canvas.width
sliceCanvas.height = height
const ctx = sliceCanvas.getContext('2d')
if (!ctx) continue
ctx.drawImage(canvas, 0, y, canvas.width, height, 0, 0, canvas.width, height)
const data = sliceCanvas.toDataURL('image/png')
const slide = pptx.addSlide()
slide.addImage({ data, x: 0, y: 0, w: '100%', h: '100%' })
}
}
async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasElement; hasImageWarning: boolean }> {
let hasImageWarning = false
@@ -154,9 +454,7 @@ async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasEl
scrollY: -window.scrollY,
scrollX: -window.scrollX,
onclone: (_doc, clonedEl) => {
// 检测跨域图片是否可能加载失败
const imgs = clonedEl.querySelectorAll('img')
imgs.forEach((img) => {
clonedEl.querySelectorAll('img').forEach((img) => {
if (img.src && !img.src.startsWith(window.location.origin) && !img.complete) {
hasImageWarning = true
}
@@ -167,27 +465,16 @@ async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasEl
return { canvas, hasImageWarning }
}
/**
* 将 Canvas 保存为 PNG 文件
* @param canvas 画布
* @param filename 文件名
*/
function canvasToPng(canvas: HTMLCanvasElement, filename: string): void {
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, filename)
}, 'image/png')
}
/**
* 将 Canvas 分页写入 PDF 并下载
* @param canvas 画布
* @param filename 文件名
*/
function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
const pdf = new jsPDF('p', 'mm', 'a4')
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const imgData = canvas.toDataURL('image/png')
const imgWidth = pageWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width
@@ -208,10 +495,6 @@ function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
pdf.save(filename)
}
/**
* 导出文章为 Markdown 文件
* @param post 文章数据
*/
export function exportAsMarkdown(post: Post): ExportResult {
const content = post.content || ''
const frontmatter = buildMarkdownFrontmatter(post)
@@ -220,10 +503,6 @@ export function exportAsMarkdown(post: Post): ExportResult {
return { success: true }
}
/**
* 导出文章为纯文本文件
* @param post 文章数据
*/
export function exportAsTxt(post: Post): ExportResult {
const header = buildTextHeader(post)
const body = markdownToPlainText(post.content || '')
@@ -232,12 +511,6 @@ export function exportAsTxt(post: Post): ExportResult {
return { success: true }
}
/**
* 导出文章为 PNG 长图
* @param post 文章数据
* @param renderedHtml 已渲染正文 HTML
* @param theme 导出主题
*/
export async function exportAsPng(
post: Post,
renderedHtml: string,
@@ -257,12 +530,6 @@ export async function exportAsPng(
}
}
/**
* 导出文章为 PDF 文件
* @param post 文章数据
* @param renderedHtml 已渲染正文 HTML
* @param theme 导出主题
*/
export async function exportAsPdf(
post: Post,
renderedHtml: string,
@@ -282,13 +549,49 @@ export async function exportAsPdf(
}
}
/**
* 统一导出入口
* @param format 导出格式
* @param post 文章数据
* @param renderedHtml 已渲染正文 HTMLPDF/PNG 需要)
* @param theme 导出主题PDF/PNG 需要)
*/
export async function exportAsPptStructured(post: Post): Promise<ExportResult> {
const pptx = createPptx()
const mediaWarning = { value: false }
const slides = buildPptSlides(post)
for (const slide of slides) {
if (slide.kind === 'title') {
addTitleSlideToPptx(pptx, slide)
} else {
await addSectionSlideToPptx(pptx, slide, mediaWarning)
}
}
const basename = buildExportBasename(post.title, post.date)
await pptx.writeFile({ fileName: `${basename}.pptx` })
return {
success: true,
warning: mediaWarning.value ? '部分图片或视频可能因跨域/大小限制未能导出' : undefined,
}
}
export async function exportAsPptScreenshot(
post: Post,
renderedHtml: string,
theme: ExportTheme
): Promise<ExportResult> {
const container = buildExportContainer(post, renderedHtml, theme)
try {
const { canvas, hasImageWarning } = await domToCanvas(container)
const pptx = createPptx()
canvasToPptSlides(pptx, canvas)
const basename = buildExportBasename(post.title, post.date)
await pptx.writeFile({ fileName: `${basename}-screenshot.pptx` })
return {
success: true,
warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
}
} finally {
container.remove()
}
}
export async function exportArticle(
format: ExportFormat,
post: Post,
@@ -304,7 +607,13 @@ export async function exportArticle(
return exportAsPng(post, renderedHtml, theme)
case 'pdf':
return exportAsPdf(post, renderedHtml, theme)
case 'ppt-structured':
return exportAsPptStructured(post)
case 'ppt-screenshot':
return exportAsPptScreenshot(post, renderedHtml, theme)
default:
return { success: false, warning: '不支持的导出格式' }
}
}
export { buildPptSlides } from './articlePptSlides'

View File

@@ -0,0 +1,618 @@
/**
* 文章结构化 PPT 幻灯片模型
* 预览与导出共用同一套分页逻辑
*/
import MarkdownIt from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'
import type { Post } from '../services/api'
export type ContentBlock =
| { type: 'h1'; text: string }
| { type: 'h2'; text: string }
| { type: 'h3'; text: string }
| { type: 'paragraph'; text: string }
| { type: 'code'; text: string; lang?: string }
| { type: 'image'; alt: string; src: string }
| { type: 'video'; src: string; poster?: string; mimeType?: string }
| { type: 'list'; ordered: boolean; items: string[] }
| { type: 'placeholder'; text: string }
export type RenderUnit =
| { kind: 'single'; block: ContentBlock }
| { kind: 'image-text'; image: Extract<ContentBlock, { type: 'image' }>; text: Extract<ContentBlock, { type: 'paragraph' }> }
export interface SlideSection {
title: string
blocks: ContentBlock[]
}
export interface PptTitleSlide {
kind: 'title'
title: string
date?: string
categoryName?: string
userName?: string
tagNames: string[]
}
export interface PptSectionSlide {
kind: 'section'
title: string
blocks: ContentBlock[]
continued: boolean
}
export type PptSlide = PptTitleSlide | PptSectionSlide
const PPT_SLIDE_BOTTOM = 5.1
const SECTION_HEADER_HEIGHT = 0.85
const IMAGE_PLACEHOLDER_GLOBAL_RE = /\[图片(?::[^\]]*)?\]/g
let markdownParser: MarkdownIt | null = null
function getMarkdownParser(): MarkdownIt {
if (!markdownParser) {
markdownParser = new MarkdownIt({ html: true, linkify: true })
}
return markdownParser
}
function renderTokenChildren(children: Token[]): string {
return children
.map((child) => {
if (child.children?.length) return renderTokenChildren(child.children)
switch (child.type) {
case 'text':
case 'code_inline':
return child.content
case 'softbreak':
case 'hardbreak':
return '\n'
case 'image':
return `[图片: ${child.content || child.attrGet('alt') || 'image'}]`
default:
return child.content || ''
}
})
.join('')
}
function renderInlineText(inlineToken: Token): string {
if (!inlineToken.children?.length) return inlineToken.content
return renderTokenChildren(inlineToken.children)
}
/** 移除 inline 渲染产生的 [图片: ...] 占位片段 */
export function stripImagePlaceholders(text: string): string {
return text.replace(IMAGE_PLACEHOLDER_GLOBAL_RE, '').replace(/\s+/g, ' ').trim()
}
function getImgSrc(el: Element): string {
return (
el.getAttribute('src') ||
el.getAttribute('data-src') ||
el.getAttribute('data-original') ||
''
)
}
function htmlHeadingType(tag: string): 'h1' | 'h2' | 'h3' | null {
if (tag === 'H1') return 'h1'
if (tag === 'H2') return 'h2'
if (tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') return 'h3'
return null
}
/** 从 HTML 片段提取块(供 html_inline / 段落内 HTML 使用) */
export function parseInlineHtmlFragment(html: string): ContentBlock[] {
const wrapped = html.trim().startsWith('<') ? html : `<span>${html}</span>`
return parseHtmlBlock(wrapped)
}
/** 从 inline token 提取图片、视频与剥离占位后的文本 */
export function extractMediaFromInline(inlineToken: Token): {
blocks: ContentBlock[]
text: string
} {
const blocks: ContentBlock[] = []
if (!inlineToken.children?.length) {
return { blocks, text: stripImagePlaceholders(inlineToken.content || '') }
}
for (const child of inlineToken.children) {
if (child.type === 'image') {
const src = child.attrGet('src') || ''
if (src) {
blocks.push({
type: 'image',
alt: child.content || child.attrGet('alt') || 'image',
src,
})
}
continue
}
if (child.type === 'html_inline' && child.content) {
parseInlineHtmlFragment(child.content).forEach((block) => pushBlock(blocks, block))
}
}
const text = stripImagePlaceholders(renderInlineText(inlineToken))
return { blocks, text }
}
function pushInlineContent(blocks: ContentBlock[], inline: Token): void {
const { blocks: mediaBlocks, text } = extractMediaFromInline(inline)
mediaBlocks.forEach((block) => pushBlock(blocks, block))
if (text) pushBlock(blocks, { type: 'paragraph', text })
}
function getHeadingText(tokens: Token[], idx: number): string {
const inline = tokens[idx + 1]
return inline?.type === 'inline' ? renderInlineText(inline) : ''
}
function parseListItems(
tokens: Token[],
startIdx: number,
listType: 'bullet_list_close' | 'ordered_list_close',
blocks: ContentBlock[]
): { items: string[]; endIdx: number } {
const items: string[] = []
let i = startIdx + 1
while (i < tokens.length && tokens[i].type !== listType) {
if (tokens[i].type === 'list_item_open') {
for (let j = i + 1; j < tokens.length; j++) {
if (tokens[j].type === 'inline') {
const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
mediaBlocks.forEach((block) => pushBlock(blocks, block))
if (text) items.push(text)
break
}
if (tokens[j].type === 'list_item_close') break
}
}
i++
}
return { items, endIdx: i }
}
function pushBlock(blocks: ContentBlock[], block: ContentBlock): void {
if (block.type === 'paragraph' && !block.text.trim()) return
blocks.push(block)
}
export function inferVideoExtn(src: string, mimeType?: string): string {
if (mimeType?.includes('webm')) return 'webm'
if (mimeType?.includes('quicktime')) return 'mov'
const match = src.match(/\.(\w+)(?:\?|$)/)
if (match) {
const ext = match[1].toLowerCase()
if (['mp4', 'webm', 'mov', 'm4v', 'ogg'].includes(ext)) return ext
}
return 'mp4'
}
function parsePictureSrc(picture: Element): string {
const img = picture.querySelector('img')
if (img) return getImgSrc(img)
const source = picture.querySelector('source')
if (source) {
const srcset = source.getAttribute('srcset') || source.getAttribute('src') || ''
return srcset.split(/\s+/)[0] || ''
}
return ''
}
/** 从 HTML 块按 DOM 顺序提取图片、视频与文本 */
export function parseHtmlBlock(html: string): ContentBlock[] {
if (typeof DOMParser === 'undefined') {
return [{ type: 'placeholder', text: '[嵌入内容]' }]
}
const doc = new DOMParser().parseFromString(html, 'text/html')
const blocks: ContentBlock[] = []
const pushParagraph = (text: string) => {
const trimmed = stripImagePlaceholders(text)
if (trimmed) pushBlock(blocks, { type: 'paragraph', text: trimmed })
}
const parseList = (list: Element) => {
const items = Array.from(list.querySelectorAll(':scope > li'))
.map((li) => stripImagePlaceholders(li.textContent || ''))
.filter(Boolean)
if (items.length) {
blocks.push({ type: 'list', ordered: list.tagName === 'OL', items })
}
}
const parseVideo = (video: HTMLVideoElement) => {
let src = video.getAttribute('src') || ''
let mimeType = video.getAttribute('type') || undefined
if (!src) {
const source = video.querySelector('source')
if (source) {
src = source.getAttribute('src') || ''
mimeType = source.getAttribute('type') || mimeType
}
}
if (src) {
blocks.push({
type: 'video',
src,
poster: video.getAttribute('poster') || undefined,
mimeType,
})
}
}
const walkNode = (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as Element
const tag = el.tagName
const headingType = htmlHeadingType(tag)
if (headingType) {
pushBlock(blocks, { type: headingType, text: el.textContent?.trim() || '' })
return
}
if (tag === 'IMG') {
const src = getImgSrc(el)
if (src) {
blocks.push({
type: 'image',
alt: el.getAttribute('alt') || 'image',
src,
})
}
return
}
if (tag === 'VIDEO') {
parseVideo(el as HTMLVideoElement)
return
}
if (tag === 'PICTURE') {
const src = parsePictureSrc(el)
const img = el.querySelector('img')
if (src) {
blocks.push({
type: 'image',
alt: img?.getAttribute('alt') || 'image',
src,
})
}
return
}
if (tag === 'A') {
el.querySelectorAll('img').forEach((img) => walkNode(img))
el.querySelectorAll('video').forEach((video) => walkNode(video))
return
}
if (tag === 'UL' || tag === 'OL') {
parseList(el)
return
}
if (tag === 'FIGURE') {
const img = el.querySelector('img')
const video = el.querySelector('video')
if (img) walkNode(img)
else if (video) walkNode(video)
const caption = el.querySelector('figcaption')?.textContent?.trim()
if (caption) pushParagraph(caption)
return
}
if (tag === 'P') {
const imgs = el.querySelectorAll('img')
const videos = el.querySelectorAll('video')
if (imgs.length || videos.length) {
imgs.forEach((img) => walkNode(img))
videos.forEach((video) => walkNode(video))
const textOnly = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent || '')
.join('')
.trim()
if (textOnly) pushParagraph(textOnly)
} else {
pushParagraph(el.textContent || '')
}
return
}
if (tag === 'DIV' || tag === 'SECTION' || tag === 'ARTICLE' || tag === 'BLOCKQUOTE' || tag === 'SPAN') {
el.childNodes.forEach((child) => walkNode(child))
return
}
if (el.children.length > 0) {
el.childNodes.forEach((child) => walkNode(child))
return
}
if (el.textContent?.trim()) {
pushParagraph(el.textContent)
}
}
doc.body.childNodes.forEach((child) => walkNode(child))
if (!blocks.length) {
const text = doc.body.textContent?.trim()
if (text) pushParagraph(text)
else blocks.push({ type: 'placeholder', text: '[嵌入内容]' })
}
return blocks
}
/** 将 blocks 分组为渲染单元(图片/视频始终独立成块) */
export function buildRenderUnits(blocks: ContentBlock[]): RenderUnit[] {
return blocks.map((block) => ({ kind: 'single' as const, block }))
}
/** 幻灯片分步总数step0=仅标题) */
export function getSlideStepCount(slide: PptSlide): number {
if (slide.kind === 'title') {
const hasMeta = Boolean(slide.date || slide.categoryName || slide.userName || slide.tagNames.length)
return hasMeta ? 2 : 1
}
return 1 + buildRenderUnits(slide.blocks).length
}
export function getSlideRenderUnits(slide: PptSectionSlide): RenderUnit[] {
return buildRenderUnits(slide.blocks)
}
/** 将 Markdown 解析为引言块与按 H2 分节的幻灯片内容 */
export function parseMarkdownToSections(markdown: string): { intro: ContentBlock[]; sections: SlideSection[] } {
const tokens = getMarkdownParser().parse(markdown, {})
const intro: ContentBlock[] = []
const sections: SlideSection[] = []
let currentBlocks = intro
let currentSection: SlideSection | null = null
const startSection = (title: string) => {
currentSection = { title, blocks: [] }
sections.push(currentSection)
currentBlocks = currentSection.blocks
}
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (token.type === 'heading_open') {
const title = getHeadingText(tokens, i)
if (token.tag === 'h2') {
startSection(title || '章节')
} else if (token.tag === 'h1') {
pushBlock(currentBlocks, { type: 'h1', text: title })
} else if (token.tag === 'h3') {
pushBlock(currentBlocks, { type: 'h3', text: title })
}
i += 2
continue
}
if (token.type === 'paragraph_open') {
const inline = tokens[i + 1]
if (inline?.type === 'inline') {
pushInlineContent(currentBlocks, inline)
}
i += 2
continue
}
if (token.type === 'fence') {
const lang = token.info ? token.info.split(/\s+/g)[0] : undefined
pushBlock(currentBlocks, { type: 'code', text: token.content.trimEnd(), lang })
continue
}
if (token.type === 'bullet_list_open') {
const { items, endIdx } = parseListItems(tokens, i, 'bullet_list_close', currentBlocks)
if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: false, items })
i = endIdx
continue
}
if (token.type === 'ordered_list_open') {
const { items, endIdx } = parseListItems(tokens, i, 'ordered_list_close', currentBlocks)
if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: true, items })
i = endIdx
continue
}
if (token.type === 'blockquote_open') {
const quoteParts: string[] = []
for (let j = i + 1; j < tokens.length && tokens[j].type !== 'blockquote_close'; j++) {
if (tokens[j].type === 'inline') {
const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
mediaBlocks.forEach((block) => pushBlock(currentBlocks, block))
if (text) quoteParts.push(text)
}
}
const quoteText = quoteParts.join('\n').trim()
if (quoteText) pushBlock(currentBlocks, { type: 'paragraph', text: `${quoteText}` })
continue
}
if (token.type === 'table_open') {
pushBlock(currentBlocks, { type: 'placeholder', text: '[表格内容]' })
continue
}
if (token.type === 'html_block') {
parseHtmlBlock(token.content).forEach((block) => pushBlock(currentBlocks, block))
continue
}
if (token.type === 'html_inline') {
parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(currentBlocks, block))
continue
}
}
if (!sections.length && intro.length) {
sections.push({ title: '正文', blocks: [...intro] })
intro.length = 0
}
return { intro, sections }
}
/** 估算内容块在幻灯片上的高度 */
export function estimateBlockHeight(block: ContentBlock): number {
switch (block.type) {
case 'h1':
return 0.6
case 'h2':
return 0.5
case 'h3':
return 0.45
case 'paragraph':
return Math.min(2.5, Math.max(0.45, Math.ceil(block.text.length / 70) * 0.22))
case 'code':
return Math.min(3.2, Math.max(0.8, block.text.split('\n').length * 0.16 + 0.3))
case 'image':
case 'video':
return 4.2
case 'list':
return Math.min(3, Math.max(0.5, block.items.length * 0.28))
case 'placeholder':
return 0.4
default:
return 0.4
}
}
/** 判断是否为独立媒体块(用于 hero 大图布局) */
export function isStandaloneMediaBlock(block: ContentBlock): boolean {
return block.type === 'image' || block.type === 'video'
}
function formatContinuationTitle(title: string, continuation: number): string {
if (continuation <= 0) return title
if (continuation === 1) return `${title}(续)`
return `${title}(续${continuation}`
}
/** 将章节内容按高度分页为多张幻灯片 */
export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[] {
if (!section.blocks.length) return []
const slides: PptSectionSlide[] = []
let currentBlocks: ContentBlock[] = []
let y = SECTION_HEADER_HEIGHT
let continuation = 0
const flushSlide = () => {
if (!currentBlocks.length) return
slides.push({
kind: 'section',
title: formatContinuationTitle(section.title, continuation),
blocks: currentBlocks,
continued: continuation > 0,
})
currentBlocks = []
y = SECTION_HEADER_HEIGHT
}
const startContinuation = () => {
flushSlide()
continuation += 1
}
const ensureSpace = (needed: number) => {
if (y + needed > PPT_SLIDE_BOTTOM && currentBlocks.length) {
startContinuation()
}
}
for (const block of section.blocks) {
ensureSpace(estimateBlockHeight(block))
currentBlocks.push(block)
switch (block.type) {
case 'h1':
y += 0.6
break
case 'h2':
y += 0.5
break
case 'h3':
y += 0.45
break
case 'paragraph':
y += estimateBlockHeight(block) + 0.1
break
case 'code':
y += estimateBlockHeight(block) + 0.35
break
case 'list':
y += estimateBlockHeight(block) + 0.1
break
case 'image':
case 'video':
y += 4.2
break
case 'placeholder':
y += 0.4
break
}
}
flushSlide()
return slides
}
function buildTitleSlide(post: Post): PptTitleSlide {
return {
kind: 'title',
title: post.title,
date: post.date,
categoryName: post.categoryName,
userName: post.userName,
tagNames: post.tags?.map((t) => t.name) || [],
}
}
export function buildPptSlides(post: Post): PptSlide[] {
const slides: PptSlide[] = [buildTitleSlide(post)]
const { intro, sections } = parseMarkdownToSections(post.content || '')
if (intro.length) {
slides.push(...paginateSectionBlocks({ title: '引言', blocks: intro }))
}
for (const section of sections) {
slides.push(...paginateSectionBlocks(section))
}
return slides
}
/** 解析媒体 URL相对路径转绝对路径 */
export function resolveImageUrl(src: string): string {
try {
return new URL(src, window.location.href).href
} catch {
return src
}
}
/** 判断点击/键盘事件是否发生在媒体交互区域 */
export function isMediaInteractionTarget(el: EventTarget | null): boolean {
if (!el || !(el instanceof HTMLElement)) return false
return Boolean(
el.closest('video, audio, .ppt-block-video-wrap, .ppt-block-image-wrap, [controls]')
)
}