news 2026/9/5 3:38:09

从零构建跨语言手书网页应用:Vue 3时序控制与国际化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建跨语言手书网页应用:Vue 3时序控制与国际化实践

在实际的跨语言内容创作和技术实现中,将一个创意概念(如“世界上的另一个我”)转化为可交互、可传播的数字作品,是一个涉及创意、技术和工程实践的完整流程。本文将以“【ch/法】世界上的另一个我手书”这一概念为引,深入探讨如何从零开始,利用现代前端技术和多媒体工具,构建一个融合中法双语、具备动态叙事能力的“手书”风格网页应用。这个过程不仅关乎代码编写,更涉及项目规划、资源管理、跨语言文本处理、动画时序控制以及最终的部署优化。无论你是希望将个人创意项目产品化的开发者,还是对交互式叙事和前端动画技术感兴趣的工程师,本文都将提供一个从概念到上线的完整实践路径。

1. 理解项目核心:什么是“手书”风格网页应用

“手书”一词源于同人创作领域,通常指作者亲手绘制并配以音乐和文字的短片,风格偏向个人化、情感化和叙事性。将其移植到网页技术领域,意味着我们需要创建一个单页应用(SPA),它能够按时间轴同步展示视觉元素(图片、SVG、Canvas绘图)、文本内容(旁白、对话)和音频,为用户带来线性的、沉浸式的浏览体验。

对于“世界上的另一个我”这个主题,技术实现需要解决几个关键问题:如何优雅地处理中法双语切换,确保UI和叙事文本都能无缝转换;如何精确地控制动画、文本显示与背景音乐的播放进度;以及如何管理可能数量庞大的素材(如图片帧、音频片段、翻译文本),并保持代码的可维护性。

从技术选型上看,一个典型的实现栈可能包括:

  • 框架:Vue.js 或 React,用于构建响应式UI和状态管理。本文将以Vue 3为例,因其组合式API对复杂时序逻辑的组织更友好。
  • 动画与时序控制:CSS@keyframes动画、Web Animations API,或更专业的动画库如gsap,用于实现复杂的序列动画。
  • 路由与状态管理:Vue Router用于单页面内的场景切换,Pinia(Vue 3推荐)或Vuex用于管理全局状态(如语言、播放进度、用户交互状态)。
  • 构建工具:Vite,提供极快的冷启动和模块热更新,非常适合此类以媒体资源为主的项目开发。
  • 部署:静态站点托管服务,如Vercel、Netlify或GitHub Pages。

2. 项目初始化与工程结构规划

在开始编码前,合理的项目结构是后续高效开发的基础。我们将使用Vite快速搭建一个Vue 3项目,并规划一个清晰易懂的目录结构。

2.1 环境准备与项目创建

首先,确保你的开发环境已安装Node.js(建议版本16以上)和npm或yarn。

通过命令行创建项目:

npm create vite@latest another-me-handbook -- --template vue cd another-me-handbook npm install

安装项目可能需要的额外依赖:

npm install pinia vue-router npm install sass --save-dev # 可选,用于使用Sass编写样式

2.2 核心目录结构设计

创建以下目录和文件,这有助于分离关注点:

src/ ├── assets/ # 静态资源 │ ├── images/ # 所有图片素材(按场景分文件夹) │ ├── audio/ # 背景音乐、音效 │ └── fonts/ # 自定义字体 ├── components/ # 可复用组件 │ ├── SceneRenderer.vue # 场景渲染器 │ ├── Subtitle.vue # 字幕/旁白组件 │ ├── ControlBar.vue # 播放控制条 │ └── LanguageToggle.vue # 语言切换器 ├── composables/ # Vue 3组合式函数 │ └── useTimelineController.js # 核心时序控制逻辑 ├── stores/ # Pinia状态管理 │ └── app.js # 全局状态(语言、播放状态、进度) ├── locales/ # 国际化语言文件 │ ├── zh-CN.json # 中文文本 │ └── fr-FR.json # 法文文本 ├── scenes/ # 场景数据定义(核心) │ └── data.js # 定义所有场景的时间轴、资源、文本 ├── router/ # 路由配置 │ └── index.js ├── views/ # 页面级组件 │ └── HandbookView.vue # 主手书播放视图 ├── App.vue └── main.js

这种结构将数据(scenes/、locales/)、逻辑(composables/、stores/)、UI组件(components/、views/)和资源(assets/)清晰地分离开。

2.3 配置Vite优化资源处理

由于项目包含大量图片和音频,需要在vite.config.js中进行一些优化配置,确保资源能被正确引用和构建。

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, // 优化构建 build: { assetsInlineLimit: 4096, // 小于4kb的资源内联为base64 rollupOptions: { output: { // 对资源文件进行更好的分类 assetFileNames: (assetInfo) => { let extType = assetInfo.name.split('.').at(1); if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { extType = 'images'; } else if (/mp3|wav|ogg|m4a/i.test(extType)) { extType = 'audio'; } return `assets/${extType}/[name]-[hash][extname]`; }, }, }, }, })

3. 构建核心:场景数据模型与国际化

“手书”的本质是一个基于时间轴的多媒体序列。我们需要一个数据结构来精确描述每个时刻应该发生什么。

3.1 定义场景时间轴数据结构

src/scenes/data.js中,我们定义一个场景数组。每个场景代表手书中的一个段落或镜头。

// src/scenes/data.js export const scenes = [ { id: 'scene_1_intro', startTime: 0, // 单位:秒,相对于整个手书的开始 duration: 10, // 场景持续时间 background: '@/assets/images/scene1/bg.jpg', // 背景图 elements: [ // 该场景内的动画元素 { type: 'image', src: '@/assets/images/scene1/character.png', start: 0, // 相对于场景开始的时间 end: 10, animation: { from: { opacity: 0, x: -100 }, to: { opacity: 1, x: 0 } } }, { type: 'text', contentKey: 'scene1.introText', // 对应语言文件的键 start: 2, end: 8, style: { top: '70%', fontSize: '1.5rem', color: 'white' } } ], audio: { // 场景专属音效或旁白 src: '@/assets/audio/scene1_narration.mp3', startAt: 0 } }, { id: 'scene_2_meet', startTime: 10, // 紧接着上一个场景结束 duration: 15, background: '@/assets/images/scene2/bg.jpg', elements: [ // ... 更多元素 ] } // ... 更多场景 ]; // 计算总时长 export const totalDuration = scenes.reduce((sum, scene) => sum + scene.duration, 0);

这个数据结构将时间、视觉元素、音频和文本绑定在一起,是驱动整个应用的“剧本”。

3.2 实现国际化(i18n)与语言切换

为了支持中法双语,我们使用简单的JSON文件管理文本,并通过Pinia store管理当前语言状态。

首先,创建语言文件:

// src/locales/zh-CN.json { "meta": { "title": "世界上的另一个我" }, "scene1": { "introText": "在世界的另一端,是否存在着一个截然不同却又无比相似的我?" }, "ui": { "play": "播放", "pause": "暂停", "restart": "重播", "language": "语言" } }
// src/locales/fr-FR.json { "meta": { "title": "L'autre moi à l'autre bout du monde" }, "scene1": { "introText": "À l'autre bout du monde, existe-t-il un moi totalement différent mais si similaire ?" }, "ui": { "play": "Lecture", "pause": "Pause", "restart": "Redémarrer", "language": "Langue" } }

接着,创建Pinia store来管理应用状态:

// src/stores/app.js import { defineStore } from 'pinia' import { ref, computed } from 'vue' import zhCN from '@/locales/zh-CN.json' import frFR from '@/locales/fr-FR.json' export const useAppStore = defineStore('app', () => { const language = ref('zh-CN') // 当前语言 const isPlaying = ref(false) // 是否正在播放 const currentTime = ref(0) // 当前播放时间(秒) const totalTime = ref(0) // 总时长,由场景数据初始化 const locales = { 'zh-CN': zhCN, 'fr-FR': frFR } // 计算属性,获取当前语言的翻译 const t = computed(() => { return (key) => { const keys = key.split('.') let value = locales[language.value] for (const k of keys) { if (value && value[k] !== undefined) { value = value[k] } else { console.warn(`Translation key "${key}" not found in ${language.value}`) return key } } return value } }) // 切换语言 function toggleLanguage() { language.value = language.value === 'zh-CN' ? 'fr-FR' : 'zh-CN' } return { language, isPlaying, currentTime, totalTime, t, toggleLanguage } })

4. 实现时序控制器与核心播放组件

这是项目中最复杂也最核心的部分。我们需要一个控制器来管理全局时间线,根据当前时间驱动场景切换、元素动画和文本显示。

4.1 创建时序控制组合式函数

使用Vue 3的组合式API封装时序逻辑,使其可复用且与组件解耦。

// src/composables/useTimelineController.js import { ref, watch, onUnmounted } from 'vue' import { useAppStore } from '@/stores/app' import { scenes, totalDuration } from '@/scenes/data' export function useTimelineController() { const appStore = useAppStore() let animationFrameId = null let lastTimestamp = 0 // 初始化总时长 appStore.totalTime = totalDuration // 核心播放循环 const playLoop = (timestamp) => { if (!lastTimestamp) lastTimestamp = timestamp const delta = (timestamp - lastTimestamp) / 1000 // 转换为秒 lastTimestamp = timestamp if (appStore.isPlaying) { // 更新当前时间,不超过总时长 appStore.currentTime = Math.min(appStore.currentTime + delta, appStore.totalTime) // 如果播放到结尾,自动暂停 if (appStore.currentTime >= appStore.totalTime) { appStore.isPlaying = false } } if (appStore.isPlaying || appStore.currentTime < appStore.totalTime) { animationFrameId = requestAnimationFrame(playLoop) } } // 监听播放状态,启动或停止循环 watch(() => appStore.isPlaying, (newVal) => { if (newVal) { lastTimestamp = 0 animationFrameId = requestAnimationFrame(playLoop) } else { if (animationFrameId) { cancelAnimationFrame(animationFrameId) animationFrameId = null } } }, { immediate: true }) // 根据当前时间获取活跃场景和元素 const activeScene = computed(() => { return scenes.find(scene => appStore.currentTime >= scene.startTime && appStore.currentTime < scene.startTime + scene.duration ) || scenes[0] // 默认返回第一个场景 }) const activeElements = computed(() => { if (!activeScene.value) return [] const sceneRelativeTime = appStore.currentTime - activeScene.value.startTime return activeScene.value.elements.filter(el => sceneRelativeTime >= el.start && sceneRelativeTime <= el.end ) }) // 清理 onUnmounted(() => { if (animationFrameId) cancelAnimationFrame(animationFrameId) }) // 控制方法 function setCurrentTime(time) { appStore.currentTime = Math.max(0, Math.min(time, appStore.totalTime)) } function togglePlay() { appStore.isPlaying = !appStore.isPlaying } return { activeScene, activeElements, setCurrentTime, togglePlay } }

这个控制器利用requestAnimationFrame实现了一个高精度的播放循环,并提供了计算属性来获取当前应渲染的场景和元素。

4.2 构建场景渲染器与字幕组件

有了控制器,我们就可以创建UI组件来呈现内容。

首先是场景渲染器组件 (src/components/SceneRenderer.vue),负责显示背景和动态元素。

<template> <div class="scene-container"> <!-- 背景图 --> <img v-if="activeScene?.background" :src="activeScene.background" class="scene-background" alt="Scene Background" /> <!-- 动态元素 --> <div v-for="(element, index) in activeElements" :key="index" class="scene-element"> <img v-if="element.type === 'image'" :src="element.src" :style="getElementStyle(element)" :alt="`Element ${index}`" /> <Subtitle v-else-if="element.type === 'text'" :content-key="element.contentKey" :style="element.style" /> </div> </div> </template> <script setup> import { computed } from 'vue' import { useTimelineController } from '@/composables/useTimelineController' import Subtitle from './Subtitle.vue' const { activeScene, activeElements } = useTimelineController() // 根据元素的动画定义计算当前样式(简化版,实际可用GSAP) const getElementStyle = (element) => { if (!element.animation) return {} // 这里应实现根据时间插值计算样式,例如 opacity, transform // 为简化示例,直接返回初始样式 return element.animation.from || {} } </script> <style scoped> .scene-container { position: relative; width: 100%; height: 100vh; overflow: hidden; } .scene-background { position: absolute; width: 100%; height: 100%; object-fit: cover; } .scene-element { position: absolute; } </style>

然后是字幕组件 (src/components/Subtitle.vue),它从store中获取对应语言的文本。

<template> <div class="subtitle" :style="style"> {{ displayText }} </div> </template> <script setup> import { computed } from 'vue' import { useAppStore } from '@/stores/app' const props = defineProps({ contentKey: { type: String, required: true }, style: { type: Object, default: () => ({}) } }) const appStore = useAppStore() const displayText = computed(() => appStore.t(props.contentKey)) </script> <style scoped> .subtitle { position: absolute; text-align: center; width: 100%; padding: 1rem; background-color: rgba(0, 0, 0, 0.6); color: white; font-family: 'Your Handwriting Font', sans-serif; transition: opacity 0.3s ease; } </style>

4.3 集成控制栏与语言切换器

最后,将控制功能集成到主视图。

<!-- src/views/HandbookView.vue --> <template> <div class="handbook-view"> <SceneRenderer /> <div class="controls-overlay"> <ControlBar /> <LanguageToggle /> </div> </div> </template> <script setup> import SceneRenderer from '@/components/SceneRenderer.vue' import ControlBar from '@/components/ControlBar.vue' import LanguageToggle from '@/components/LanguageToggle.vue' </script> <style scoped> .handbook-view { position: relative; height: 100vh; } .controls-overlay { position: absolute; bottom: 20px; width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 0 2rem; box-sizing: border-box; } </style>

控制栏组件 (src/components/ControlBar.vue) 提供播放、暂停、进度条控制。

<template> <div class="control-bar"> <button @click="togglePlay">{{ isPlaying ? appStore.t('ui.pause') : appStore.t('ui.play') }}</button> <span>{{ formattedCurrentTime }} / {{ formattedTotalTime }}</span> <input type="range" :min="0" :max="appStore.totalTime" :step="0.1" :value="appStore.currentTime" @input="onSeek" class="progress-slider" /> <button @click="restart">{{ appStore.t('ui.restart') }}</button> </div> </template> <script setup> import { computed } from 'vue' import { useAppStore } from '@/stores/app' import { useTimelineController } from '@/composables/useTimelineController' const appStore = useAppStore() const { togglePlay, setCurrentTime } = useTimelineController() const formattedCurrentTime = computed(() => formatTime(appStore.currentTime)) const formattedTotalTime = computed(() => formatTime(appStore.totalTime)) function formatTime(seconds) { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` } function onSeek(event) { setCurrentTime(parseFloat(event.target.value)) } function restart() { setCurrentTime(0) if (!appStore.isPlaying) { // 如果当前是暂停状态,重启后自动播放 togglePlay() } } </script>

语言切换器 (src/components/LanguageToggle.vue) 则是一个简单的按钮。

<template> <button class="language-toggle" @click="appStore.toggleLanguage"> {{ appStore.t('ui.language') }}: {{ appStore.language === 'zh-CN' ? 'Français' : '中文' }} </button> </template> <script setup> import { useAppStore } from '@/stores/app' const appStore = useAppStore() </script>

5. 运行、验证与性能优化

完成核心组件开发后,需要运行项目并验证功能,同时考虑性能优化。

5.1 运行与基础功能验证

  1. 启动开发服务器

    npm run dev

    访问http://localhost:5173

  2. 验证流程

    • 播放控制:点击播放按钮,观察场景是否按时间轴切换,元素是否出现/消失。
    • 进度控制:拖动进度条,场景和元素是否即时跳转到对应时间点。
    • 语言切换:点击语言切换按钮,检查所有UI文本和场景内的字幕是否立即更新为另一种语言。
    • 响应式:调整浏览器窗口大小,检查布局是否正常。

5.2 关键性能优化策略

由于手书项目可能包含大量高分辨率图片和音频,性能优化至关重要。

  1. 图片优化

    • 格式选择:使用WebP格式,它比JPEG/PNG有更好的压缩率。通过Vite插件(如vite-plugin-imagemin)在构建时自动压缩。
    • 懒加载与预加载:对于非当前场景的图片,使用loading=“lazy”。对于即将播放的下一个场景的关键图片,可以在当前场景播放时进行预加载。
    // 在时序控制器中预加载下一场景的图片 const preloadNextSceneImages = (currentSceneId) => { const nextScene = scenes.find(scene => scene.id > currentSceneId) if (nextScene && nextScene.background) { const img = new Image() img.src = nextScene.background } }
    • 雪碧图(Sprite Sheet):对于序列帧动画,将多张小图合并为一张大图,通过CSSbackground-position切换,减少HTTP请求。
  2. 音频优化

    • 使用Web Audio API进行精细控制:如果需要对音频进行淡入淡出、同步或分析,<audio>标签能力有限,可以考虑使用Web Audio API。
    • 音频分段:将长音频按场景切割成小段,按需加载,减少初始加载时间和内存占用。
  3. 动画性能

    • 使用CSStransformopacity:这两个属性不会触发重排(Reflow)或重绘(Repaint),可以利用GPU加速,性能远优于修改topleftwidth等属性。
    • 启用GPU加速:对动画元素使用will-change: transform;transform: translateZ(0);
    • 避免频繁的垃圾回收:在requestAnimationFrame循环中避免创建新对象,尽量复用。
  4. 代码分割与懒加载

    • 如果场景非常多,可以将场景数据按章节拆分,使用动态import()在需要时加载,减少主包体积。
    // 动态加载场景数据 const loadSceneChapter = async (chapterId) => { const module = await import(`@/scenes/chapters/${chapterId}.js`) return module.scenes }

6. 常见问题排查与调试

在开发过程中,你可能会遇到以下典型问题:

问题现象可能原因检查与解决思路
图片/音频资源加载失败(404)1. 路径错误。
2. 资源未放入public或未正确导入assets
3. Vite配置别名未生效。
1. 检查浏览器开发者工具(F12)的“网络(Network)”标签,确认请求的URL是否正确。
2. 放在public下的资源使用绝对路径(如/images/bg.jpg),放在assets下的资源需使用importnew URL()方式引入。
3. 检查vite.config.js中的alias配置。
动画卡顿或不流畅1. 图片尺寸过大。
2. 在动画循环中执行了复杂计算或DOM操作。
3. 未使用CSS硬件加速。
1. 优化图片,使用合适尺寸和格式。
2. 使用 Chrome Performance 工具录制性能,查看是哪部分代码耗时过长。
3. 确保动画属性是transformopacity,并检查是否有不必要的box-shadowborder-radius等开销大的样式。
语言切换后UI未更新1. 组件未响应式依赖appStore.tlanguage
2. 语言文件键名错误或缺失。
1. 确保使用computed属性或模板中直接调用appStore.t(‘key’)。在组件内使用watch监听language变化。
2. 检查浏览器控制台是否有“Translation key not found”警告,核对语言文件中的键名。
播放进度条拖动后画面不同步1.currentTime更新了,但场景/元素的计算属性 (activeScene,activeElements) 未及时更新。
2. 拖动事件处理函数有性能问题。
1. 确保activeSceneactiveElements是响应式的computed属性,其依赖项包含currentTime
2. 对input事件使用防抖(debounce),避免频繁触发重渲染。
移动端触摸事件无效未处理触摸事件。为进度条和控制按钮同时绑定@touchstart@touchmove@touchend事件,并调用event.preventDefault()防止页面滚动。

调试建议

  • 善用 Vue Devtools:检查组件树、Pinia store的状态变化,追踪响应式数据的流动。
  • 利用浏览器性能分析器:录制动画播放过程,查看帧率(FPS)和每一帧的耗时,定位性能瓶颈。
  • 模拟慢速网络:在浏览器开发者工具的“网络(Network)”标签中,将节流(Throttling)设置为“Slow 3G”,测试资源加载和用户体验。

7. 生产环境部署与最佳实践

当本地开发测试完成后,需要将项目构建并部署到线上环境。

  1. 构建生产版本

    npm run build

    该命令会在dist目录下生成优化后的静态文件(HTML, JS, CSS, 以及处理后的资源)。

  2. 部署到静态托管平台(以Vercel为例):

    • 将代码推送到GitHub、GitLab或Bitbucket仓库。
    • 登录 Vercel ,点击“Import Project”,连接你的仓库。
    • Vercel会自动检测到Vite项目,使用默认配置即可。它会为你生成一个唯一的访问域名(如another-me-handbook.vercel.app)。
  3. 生产环境最佳实践清单

    • CDN加速:使用Vercel、Netlify等平台自带的全球CDN,确保资源快速加载。
    • 浏览器缓存策略:配置正确的HTTP缓存头。对于哈希命名的资源文件(如main.abc123.js)可以设置长期缓存,对于index.html则设置不缓存或短时间缓存。
    • 监控与错误追踪:集成像Sentry这样的错误监控工具,捕获前端运行时错误。
    • SEO优化:虽然手书是强交互应用,但至少确保index.html中的<title><meta description>能根据当前语言动态设置(可使用SSR或SSG方案,如Nuxt.js,但这会显著增加复杂度)。
    • 无障碍访问(A11y):为所有按钮添加aria-label,为图片添加alt文本,确保键盘可以操作控制栏,提升网站的可访问性。
    • 版本控制与回滚:部署平台通常支持从历史提交快速回滚,在每次重大更新前做好标记。

通过以上步骤,一个完整的、支持中法双语的“世界上的另一个我手书”网页应用就从概念变成了现实。这个项目模板的核心在于数据驱动时序控制,你可以通过修改scenes/data.js中的数据和locales/下的文本,轻松替换成任何你想要讲述的故事。进一步探索的方向可以包括:集成更强大的动画库(如GSAP)实现更复杂的动效;加入用户交互分支,让故事走向根据选择变化;或者将场景数据存储在云端,实现动态内容更新。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 3:38:05

FBT黑五走空运,九方通逊三重优惠叠着来!一省到底

2026年的黑五&#xff0c;比往年来得更早&#xff0c;也更猛。亚马逊美国站的旺季入仓截止日已经公布&#xff0c;比去年提前了整整一周。按照中国到美国海运常规周期——生产备货、国内集货、海上运输、清关、入仓预约&#xff0c;约需40至60天——当前至9月中旬是安排黑五主力…

作者头像 李华
网站建设 2026/9/5 3:35:04

AI图像生成项目部署指南:从环境搭建到批量API调用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:34:34

Jetson Orin Nano 2边缘AI部署实战:从TensorRT到实体机器人

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:33:51

Cadence Allegro PCB坐标文件导出全攻略:从原理到SMT生产实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:33:10

分布式系统会话管理:低等级API实现多用户会话隔离方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华