style(docs): improve typography and layout consistency

- Standardize font sizes and line heights across docs
- Add ChapterIntroduction component for consistent chapter headers
- Fix markdown formatting and whitespace issues
- Improve code block and table styling
- Add font size and line height controls to layout
This commit is contained in:
sanbuphy
2026-01-13 14:42:34 +08:00
parent 7c546e62f8
commit 1d25eb9b9b
20 changed files with 1655 additions and 945 deletions
+2 -3
View File
@@ -47,7 +47,7 @@ export default defineConfig({
{
text: '1. 认识 AI IDE 工具',
link: '/stage-1/1.1-introduction-to-ai-ide/'
},
},
{
text: '2. 动手做出原型',
link: '/stage-1/1.2-building-prototype/'
@@ -71,8 +71,7 @@ export default defineConfig({
{
text: '附录 B:常见报错及解决方案',
link: '/stage-1/appendix-b-common-errors/'
}
,
},
{
text: '附录示例:贪吃蛇游戏教程',
link: '/stage-1/appendix-articles/example0-1/vibe-coding-tools-snake-game-tutorial'
+238 -2
View File
@@ -2,6 +2,7 @@
import DefaultTheme from 'vitepress/theme'
import { useData } from 'vitepress'
import TextType from './components/TextType.vue'
import { onMounted, ref, watch } from 'vue'
const { frontmatter } = useData()
@@ -12,16 +13,251 @@ const homeTaglineTyping = {
postDeletingDelay: 500,
deletingSpeed: 18
}
const FONT_SIZE_STORAGE_KEY = 'ev-doc-font-size'
const LINE_HEIGHT_STORAGE_KEY = 'ev-doc-line-height'
const MIN_FONT_SIZE = 12
const MAX_FONT_SIZE = 18
const DEFAULT_FONT_SIZE = 13
const MIN_LINE_HEIGHT = 1.25
const MAX_LINE_HEIGHT = 1.8
const DEFAULT_LINE_HEIGHT = 1.5
const fontSize = ref(DEFAULT_FONT_SIZE)
const lineHeight = ref(DEFAULT_LINE_HEIGHT)
const isHydrated = ref(false)
const clampFontSize = (value) => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return DEFAULT_FONT_SIZE
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, numeric))
}
const clampLineHeight = (value) => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return DEFAULT_LINE_HEIGHT
return Math.min(MAX_LINE_HEIGHT, Math.max(MIN_LINE_HEIGHT, numeric))
}
const applyFontSize = (size) => {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--ev-doc-font-size', `${size}px`)
}
const applyLineHeight = (value) => {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--ev-doc-line-height', String(value))
}
const decreaseFontSize = () => {
fontSize.value = clampFontSize(fontSize.value - 1)
}
const increaseFontSize = () => {
fontSize.value = clampFontSize(fontSize.value + 1)
}
const resetFontSize = () => {
fontSize.value = DEFAULT_FONT_SIZE
}
const resetLineHeight = () => {
lineHeight.value = DEFAULT_LINE_HEIGHT
}
onMounted(() => {
const saved = clampFontSize(localStorage.getItem(FONT_SIZE_STORAGE_KEY))
const savedLineHeight = clampLineHeight(localStorage.getItem(LINE_HEIGHT_STORAGE_KEY))
fontSize.value = saved
lineHeight.value = savedLineHeight
applyFontSize(saved)
applyLineHeight(savedLineHeight)
isHydrated.value = true
})
watch(fontSize, (next) => {
if (!isHydrated.value) return
const normalized = clampFontSize(next)
applyFontSize(normalized)
localStorage.setItem(FONT_SIZE_STORAGE_KEY, String(normalized))
})
watch(lineHeight, (next) => {
if (!isHydrated.value) return
const normalized = clampLineHeight(next)
applyLineHeight(normalized)
localStorage.setItem(LINE_HEIGHT_STORAGE_KEY, String(normalized))
})
</script>
<template>
<DefaultTheme.Layout>
<template #nav-bar-content-after>
<ClientOnly>
<el-popover placement="bottom-end" trigger="click" :width="260">
<template #reference>
<button
class="ev-fontsize-button"
type="button"
aria-label="阅读设置"
style="margin-left: 16px; padding: 0; width: 32px;"
>
<el-icon :size="16"><Setting /></el-icon>
</button>
</template>
<div class="ev-fontsize-panel">
<div class="ev-setting-group">
<div class="ev-setting-header">
<div class="ev-setting-title">字号</div>
<div class="ev-setting-value">{{ fontSize }}px</div>
</div>
<div class="ev-fontsize-actions">
<button
class="ev-fontsize-action"
type="button"
@click="decreaseFontSize"
>
A-
</button>
<button
class="ev-fontsize-action"
type="button"
@click="resetFontSize"
>
默认
</button>
<button
class="ev-fontsize-action"
type="button"
@click="increaseFontSize"
>
A+
</button>
</div>
<el-slider v-model="fontSize" :min="MIN_FONT_SIZE" :max="MAX_FONT_SIZE" :step="1" />
</div>
<div class="ev-setting-group">
<div class="ev-setting-header">
<div class="ev-setting-title">行距</div>
<div class="ev-setting-value">{{ lineHeight.toFixed(2) }}</div>
</div>
<div class="ev-fontsize-actions">
<button class="ev-fontsize-action" type="button" @click="resetLineHeight">
默认
</button>
<button
class="ev-fontsize-action"
type="button"
@click="lineHeight = clampLineHeight(lineHeight - 0.05)"
>
更紧
</button>
<button
class="ev-fontsize-action"
type="button"
@click="lineHeight = clampLineHeight(lineHeight + 0.05)"
>
更松
</button>
</div>
<el-slider
v-model="lineHeight"
:min="MIN_LINE_HEIGHT"
:max="MAX_LINE_HEIGHT"
:step="0.05"
/>
</div>
</div>
</el-popover>
</ClientOnly>
</template>
<template #home-hero-info-after>
<div v-if="frontmatter.layout === 'home' && frontmatter.hero?.tagline" class="vp-typed-tagline">
<div
v-if="frontmatter.layout === 'home' && frontmatter.hero?.tagline"
class="vp-typed-tagline"
>
<ClientOnly>
<TextType :text="frontmatter.hero.tagline" v-bind="homeTaglineTyping" :loop="true" />
<TextType
:text="frontmatter.hero.tagline"
v-bind="homeTaglineTyping"
:loop="true"
/>
</ClientOnly>
</div>
</template>
</DefaultTheme.Layout>
</template>
<style>
.ev-fontsize-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 32px;
padding: 0 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 999px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.ev-fontsize-button:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.ev-fontsize-panel {
display: grid;
gap: 12px;
}
.ev-setting-group {
display: grid;
gap: 8px;
}
.ev-setting-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.ev-setting-title {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.ev-setting-value {
font-size: 12px;
color: var(--vp-c-text-2);
}
.ev-fontsize-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.ev-fontsize-action {
height: 32px;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 13px;
cursor: pointer;
}
.ev-fontsize-action:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
</style>
@@ -0,0 +1,266 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
duration: {
type: String,
default: ''
},
expectedOutput: {
type: String,
default: ''
},
coreOutput: {
type: String,
default: ''
},
assignment: {
type: String,
default: ''
},
tags: {
type: Array,
default: () => []
}
})
const hasMeta = computed(() => props.duration || props.expectedOutput || props.coreOutput || props.assignment)
const hasTags = computed(() => props.tags && props.tags.length > 0)
</script>
<template>
<div class="chapter-introduction">
<!-- Learning Objective -->
<div class="objective-section">
<div class="objective-label">
<span class="icon">🎯</span>
<span class="title">本章学习目标</span>
</div>
<div class="content">
<!-- If tags are provided, show tags list -->
<div v-if="hasTags" class="tags-container">
<span v-for="(tag, index) in tags" :key="index" class="objective-tag">
{{ tag }}
</span>
</div>
<!-- Slot content (full description) always rendered below tags if tags exist, or alone if not -->
<div class="description-text" :class="{ 'has-tags': hasTags }">
<slot></slot>
</div>
</div>
</div>
<!-- Metrics Grid -->
<div v-if="hasMeta" class="metrics-grid">
<!-- Duration Card -->
<div v-if="duration" class="metric-card time-card">
<div class="card-icon"></div>
<div class="card-content">
<div class="card-label">预计耗时</div>
<div class="card-value" v-html="duration"></div>
</div>
</div>
<!-- Output Card -->
<div v-if="expectedOutput || coreOutput" class="metric-card output-card">
<div class="card-icon">📦</div>
<div class="card-content">
<div class="card-label">预期产出</div>
<div class="output-container">
<div v-if="coreOutput" class="core-output">{{ coreOutput }}</div>
<div v-if="expectedOutput" class="output-desc" v-html="expectedOutput"></div>
</div>
</div>
</div>
<!-- Assignment Card -->
<div v-if="assignment" class="metric-card task-card">
<div class="card-icon">📝</div>
<div class="card-content">
<div class="card-label">课后任务</div>
<div class="card-value" v-html="assignment"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.chapter-introduction {
margin: 24px 0;
border-radius: 16px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
}
.objective-section {
padding: 24px 28px;
background: linear-gradient(to right, rgba(var(--vp-c-brand-rgb), 0.05), transparent);
border-bottom: 1px dashed var(--vp-c-divider);
}
.objective-label {
display: flex;
align-items: center;
margin-bottom: 16px;
color: var(--vp-c-brand);
}
.icon {
font-size: 20px;
margin-right: 8px;
}
.title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.content {
font-size: 16px;
line-height: 1.7;
color: var(--vp-c-text-1);
font-weight: 500;
}
/* Tags Styling */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.description-text {
font-size: 16px;
line-height: 1.7;
color: var(--vp-c-text-1);
}
.description-text.has-tags {
margin-top: 16px;
font-size: 14px;
color: var(--vp-c-text-2);
border-top: 1px solid var(--vp-c-divider);
padding-top: 12px;
}
.objective-tag {
display: inline-flex;
align-items: center;
padding: 6px 14px;
background-color: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 99px;
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
transition: all 0.2s;
}
.objective-tag:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
background-color: var(--vp-c-bg-soft);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* Metrics Grid */
.metrics-grid {
display: flex;
flex-wrap: wrap;
gap: 1px;
background-color: var(--vp-c-divider);
border-top: 1px solid var(--vp-c-divider);
}
.metric-card {
flex: 1 1 240px;
background-color: var(--vp-c-bg-soft);
padding: 20px 24px;
display: flex;
align-items: flex-start;
gap: 16px;
transition: background-color 0.2s;
}
.metric-card:hover {
background-color: var(--vp-c-bg-alt);
}
.card-icon {
font-size: 24px;
line-height: 1;
padding-top: 2px;
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
}
.card-label {
font-size: 12px;
color: var(--vp-c-text-2);
margin-bottom: 8px;
font-weight: 600;
text-transform: uppercase;
}
.card-value {
font-size: 14px;
line-height: 1.5;
color: var(--vp-c-text-1);
}
.card-value :deep(strong) {
display: inline-block;
color: var(--vp-c-brand-dark);
font-weight: 800;
font-size: 16px;
margin-top: 2px;
}
/* Output Container Styling */
.output-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.core-output {
font-size: 18px;
font-weight: 800;
color: var(--vp-c-brand);
line-height: 1.4;
margin-bottom: 2px;
}
.output-desc {
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.output-desc :deep(strong) {
color: var(--vp-c-text-1);
font-weight: 600;
}
/* Mobile adjustments */
@media (max-width: 640px) {
.metric-card {
padding: 16px 20px;
flex-basis: 100%;
}
.objective-section {
padding: 20px;
}
}
</style>
+4 -4
View File
@@ -18,10 +18,10 @@ defineProps({
items: {
type: Array,
default: () => [
{ title: "困境与机会", description: "普通人的编程新可能" },
{ title: "能力初探", description: "60秒极速开发体验" },
{ title: "原生实战", description: "打造AI原生贪吃蛇" },
{ title: "拓展创造", description: "举一反三做游戏" }
{ title: '困境与机会', description: '普通人的编程新可能' },
{ title: '能力初探', description: '60秒极速开发体验' },
{ title: '原生实战', description: '打造AI原生贪吃蛇' },
{ title: '拓展创造', description: '举一反三做游戏' }
]
}
})
+55 -18
View File
@@ -1,5 +1,12 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, useAttrs, watchEffect } from 'vue'
import {
computed,
onMounted,
onUnmounted,
ref,
useAttrs,
watchEffect
} from 'vue'
const props = defineProps({
text: {
@@ -91,7 +98,9 @@ const currentTextIndex = ref(0)
const isVisible = ref(!props.startOnVisible)
const containerRef = ref(null)
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]))
const textArray = computed(() =>
Array.isArray(props.text) ? props.text : [props.text]
)
const cursorStyle = computed(() => ({
animationDuration: `${props.cursorBlinkDuration}s`
@@ -104,8 +113,14 @@ const currentColor = computed(() => {
const getRandomSpeed = () => {
if (!props.variableSpeed) return props.typingSpeed
const min = typeof props.variableSpeed.min === 'number' ? props.variableSpeed.min : props.typingSpeed
const max = typeof props.variableSpeed.max === 'number' ? props.variableSpeed.max : props.typingSpeed
const min =
typeof props.variableSpeed.min === 'number'
? props.variableSpeed.min
: props.typingSpeed
const max =
typeof props.variableSpeed.max === 'number'
? props.variableSpeed.max
: props.typingSpeed
if (max <= min) return min
return Math.random() * (max - min) + min
}
@@ -114,7 +129,7 @@ let observer
onMounted(() => {
if (!props.startOnVisible || !containerRef.value) return
observer = new IntersectionObserver(
entries => {
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
isVisible.value = true
@@ -131,7 +146,7 @@ onUnmounted(() => {
if (observer) observer.disconnect()
})
watchEffect(onCleanup => {
watchEffect((onCleanup) => {
if (!isVisible.value) return
if (!textArray.value.length) {
@@ -140,13 +155,16 @@ watchEffect(onCleanup => {
}
const currentText = textArray.value[currentTextIndex.value] ?? ''
const processedText = props.reverseMode ? String(currentText).split('').reverse().join('') : String(currentText)
const processedText = props.reverseMode
? String(currentText).split('').reverse().join('')
: String(currentText)
if (!isClient) {
return
}
const shouldStopAtEnd = !props.loop && currentTextIndex.value === textArray.value.length - 1
const shouldStopAtEnd =
!props.loop && currentTextIndex.value === textArray.value.length - 1
let timeoutId
@@ -155,11 +173,15 @@ watchEffect(onCleanup => {
if (!displayedText.value) {
isDeleting.value = false
if (props.onSentenceComplete) {
props.onSentenceComplete(textArray.value[currentTextIndex.value], currentTextIndex.value)
props.onSentenceComplete(
textArray.value[currentTextIndex.value],
currentTextIndex.value
)
}
if (shouldStopAtEnd) return
timeoutId = setTimeout(() => {
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length
currentTextIndex.value =
(currentTextIndex.value + 1) % textArray.value.length
currentCharIndex.value = 0
}, props.postDeletingDelay)
return
@@ -172,10 +194,13 @@ watchEffect(onCleanup => {
}
if (currentCharIndex.value < processedText.length) {
timeoutId = setTimeout(() => {
displayedText.value += processedText[currentCharIndex.value]
currentCharIndex.value += 1
}, props.variableSpeed ? getRandomSpeed() : props.typingSpeed)
timeoutId = setTimeout(
() => {
displayedText.value += processedText[currentCharIndex.value]
currentCharIndex.value += 1
},
props.variableSpeed ? getRandomSpeed() : props.typingSpeed
)
return
}
@@ -185,7 +210,11 @@ watchEffect(onCleanup => {
}, props.pauseDuration)
}
if (currentCharIndex.value === 0 && !isDeleting.value && !displayedText.value) {
if (
currentCharIndex.value === 0 &&
!isDeleting.value &&
!displayedText.value
) {
timeoutId = setTimeout(schedule, props.initialDelay)
} else {
schedule()
@@ -197,7 +226,9 @@ watchEffect(onCleanup => {
const shouldHideCursor = computed(() => {
if (!props.hideCursorWhileTyping) return false
const currentText = textArray.value[currentTextIndex.value] ?? ''
const processedText = props.reverseMode ? String(currentText).split('').reverse().join('') : String(currentText)
const processedText = props.reverseMode
? String(currentText).split('').reverse().join('')
: String(currentText)
return currentCharIndex.value < processedText.length || isDeleting.value
})
</script>
@@ -209,13 +240,19 @@ const shouldHideCursor = computed(() => {
:class="['text-type', className]"
v-bind="attrs"
>
<span class="text-type__content" :style="{ color: currentColor || 'inherit' }">
<span
class="text-type__content"
:style="{ color: currentColor || 'inherit' }"
>
{{ displayedText }}
</span>
<span
v-if="showCursor"
class="text-type__cursor"
:class="[cursorClassName, shouldHideCursor ? 'text-type__cursor--hidden' : '']"
:class="[
cursorClassName,
shouldHideCursor ? 'text-type__cursor--hidden' : ''
]"
:style="cursorStyle"
>
{{ cursorCharacter }}
+14 -9
View File
@@ -7,13 +7,17 @@ import TypeIt from 'typeit'
import { onMounted, watch, nextTick } from 'vue'
import { useRoute, useData } from 'vitepress'
import './style.css'
import Layout from './Layout.vue'
import StepBar from './components/StepBar.vue'
import ChapterIntroduction from './components/ChapterIntroduction.vue'
export default {
extends: DefaultTheme,
Layout,
enhanceApp({ app }) {
app.use(ElementPlus)
app.component('StepBar', StepBar)
app.component('ChapterIntroduction', ChapterIntroduction)
},
setup() {
const route = useRoute()
@@ -67,7 +71,7 @@ export default {
const taglineEl = document.querySelector('.VPHomeHero .tagline')
if (taglineEl) {
taglineEl.innerHTML = ''
const typeIt = new TypeIt(taglineEl, {
speed: 50,
startDelay: 500,
@@ -75,9 +79,9 @@ export default {
})
taglineData.forEach((text) => {
typeIt.type(text).pause(2000).delete().pause(500)
typeIt.type(text).pause(2000).delete().pause(500)
})
typeIt.go()
}
}
@@ -85,7 +89,7 @@ export default {
const optimizeImages = () => {
const images = document.querySelectorAll('.vp-doc img')
images.forEach(img => {
images.forEach((img) => {
if (img.complete) {
applyImageStyle(img)
} else {
@@ -133,11 +137,12 @@ export default {
watch(
() => route.path,
() => nextTick(() => {
initViewer()
initTypewriter()
optimizeImages()
})
() =>
nextTick(() => {
initViewer()
initTypewriter()
optimizeImages()
})
)
}
}
+64 -2
View File
@@ -2,10 +2,70 @@
/* Easy-Vibe Theme Fix v2025-01-12 */
/* 通过变量控制分组底部留白(默认 24px) */
--vp-sidebar-nav-section-gap: 8px;
--ev-doc-font-size: 13px;
--ev-doc-line-height: 1.5;
}
.vp-doc {
font-size: 15px;
font-size: var(--ev-doc-font-size);
line-height: var(--ev-doc-line-height);
--el-font-size-extra-large: calc(var(--ev-doc-font-size) + 6px);
--el-font-size-large: calc(var(--ev-doc-font-size) + 4px);
--el-font-size-medium: calc(var(--ev-doc-font-size) + 2px);
--el-font-size-base: var(--ev-doc-font-size);
--el-font-size-small: calc(var(--ev-doc-font-size) - 1px);
--el-font-size-extra-small: calc(var(--ev-doc-font-size) - 2px);
--el-font-line-height-primary: var(--ev-doc-line-height);
}
.vp-doc :where(p, ul, ol, table, blockquote, pre, details, figure) {
margin: 10px 0;
}
.vp-doc :where(li) {
margin: 4px 0;
}
.vp-doc :where(ul, ol) {
padding-left: 1.15em;
}
.vp-doc :where(h1, h2, h3, h4, h5, h6) {
line-height: 1.3;
}
.vp-doc :where(h1) {
margin: 22px 0 12px;
}
.vp-doc :where(h2) {
margin: 20px 0 10px;
}
.vp-doc h2 {
margin: 16px 0 8px !important;
padding-top: 10px !important;
border-top: 0 !important;
}
.vp-doc :where(h3) {
margin: 18px 0 8px;
}
.vp-doc :where(h4, h5, h6) {
margin: 16px 0 8px;
}
.vp-doc :where(hr) {
margin: 14px 0;
}
.vp-doc :where(th, td) {
padding: 6px 10px;
}
.vp-doc :where(:not(pre) > code) {
font-size: 0.95em;
}
/* 生产环境(带 data-v-* 的 scoped 样式)会比 class 选择器更高优先级。
@@ -44,7 +104,9 @@
/* 进一步压缩分组标题与第一项之间的间距 */
:where(html) .VPSidebarItem.level-0 + .VPSidebarItem.level-1,
:where(html) .VPSidebarItem.level-0[data-v-d81de50c] + .VPSidebarItem.level-1[data-v-d81de50c] {
:where(html)
.VPSidebarItem.level-0[data-v-d81de50c]
+ .VPSidebarItem.level-1[data-v-d81de50c] {
margin-top: -2px !important;
}