优化页面、修复BUG
This commit is contained in:
38
.mimocode/plans/1783312583124-clever-cabin.md
Normal file
38
.mimocode/plans/1783312583124-clever-cabin.md
Normal 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/` 等)。
|
||||
@@ -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
79
client/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章导出下拉菜单
|
||||
* 支持 Markdown / 纯文本 / PDF / PNG 四种格式,PDF 与 PNG 可选深浅色主题
|
||||
* 支持 Markdown / 纯文本 / PDF / PNG / PPT,PDF、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: '与页面视觉一致' },
|
||||
]
|
||||
|
||||
/** 主题选项 */
|
||||
|
||||
245
client/src/components/ppt/PptSlideCanvas.vue
Normal file
245
client/src/components/ppt/PptSlideCanvas.vue
Normal 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>
|
||||
169
client/src/components/ppt/PptSlideViewer.vue
Normal file
169
client/src/components/ppt/PptSlideViewer.vue
Normal 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>
|
||||
172
client/src/composables/usePptPresentation.ts
Normal file
172
client/src/composables/usePptPresentation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
213
client/src/pages/BlogPptPreview.vue
Normal file
213
client/src/pages/BlogPptPreview.vue
Normal 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>
|
||||
@@ -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 } },
|
||||
|
||||
506
client/src/styles/pptPreview.css
Normal file
506
client/src/styles/pptPreview.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
@@ -138,10 +132,316 @@ function escapeHtml(str: string): string {
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 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 已渲染正文 HTML(PDF/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'
|
||||
|
||||
618
client/src/utils/articlePptSlides.ts
Normal file
618
client/src/utils/articlePptSlides.ts
Normal 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]')
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user