This commit is contained in:
2026-06-15 01:48:03 -06:00
parent 2bd1e0399a
commit 379ff41947
40 changed files with 2669 additions and 360 deletions

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { BookCover, TeachingDesign } from '../domain/teachingDesign'
import CoverPage from './CoverPage.vue'
import TeachingDesignPage from './TeachingDesignPage.vue'
defineProps<{
cover: BookCover
selectedId: string
selectedDesign: TeachingDesign | null
}>()
const emit = defineEmits<{
'update:cover': [patch: Partial<BookCover>]
'update:design': [design: TeachingDesign]
}>()
</script>
<template>
<div class="a4-workspace">
<div class="a4-paper">
<CoverPage
v-if="selectedId === 'cover'"
:course-name="cover.courseName"
:teacher-name="cover.teacherName"
:editable="true"
@update:course-name="emit('update:cover', { courseName: $event })"
@update:teacher-name="emit('update:cover', { teacherName: $event })"
/>
<TeachingDesignPage
v-else-if="selectedDesign"
:design="selectedDesign"
:editable="true"
@update:design="emit('update:design', $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import EditableText from './EditableText.vue'
defineProps<{
courseName: string
teacherName: string
editable: boolean
}>()
defineEmits<{
'update:courseName': [value: string]
'update:teacherName': [value: string]
}>()
</script>
<template>
<section class="page cover-page">
<h1 class="cover-title">教学设计</h1>
<div class="cover-field">
<span class="cover-field-label">课程名称</span>
<EditableText
class="cover-field-value"
:model-value="courseName"
label="课程名称"
:editable="editable"
@update:model-value="$emit('update:courseName', $event)"
/>
</div>
<div class="cover-field">
<span class="cover-field-label">教师姓名</span>
<EditableText
class="cover-field-value"
:model-value="teacherName"
label="教师姓名"
:editable="editable"
@update:model-value="$emit('update:teacherName', $event)"
/>
</div>
</section>
</template>

View File

@@ -0,0 +1,15 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import EditableMarkdown from './EditableMarkdown.vue'
describe('EditableMarkdown', () => {
it('renders markdown when blurred and edits raw markdown when activated', async () => {
const wrapper = mount(EditableMarkdown, {
props: { modelValue: '**重点**内容', label: '教师活动' },
})
expect(wrapper.get('.markdown-preview strong').text()).toBe('重点')
await wrapper.get('.markdown-preview').trigger('click')
expect(wrapper.get('textarea').element.value).toBe('**重点**内容')
})
})

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { renderMarkdown } from '../services/markdownRenderer'
const props = withDefaults(
defineProps<{
modelValue: string
label: string
editable?: boolean
}>(),
{ editable: true },
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editing = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function resize(): void {
const element = textareaRef.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${element.scrollHeight}px`
}
function activate(): void {
if (!props.editable) return
editing.value = true
nextTick(() => {
textareaRef.value?.focus()
resize()
})
}
function deactivate(): void {
editing.value = false
}
function onInput(event: Event): void {
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
resize()
}
watch(
() => props.modelValue,
() => {
if (editing.value) nextTick(resize)
},
)
</script>
<template>
<div class="editable-field editable-markdown">
<div
v-if="!editing"
class="markdown-preview"
:class="{ 'markdown-preview--empty': !modelValue }"
:tabindex="editable ? 0 : undefined"
:role="editable ? 'button' : undefined"
:aria-label="label"
@click="activate"
@keydown.enter.prevent="activate"
@keydown.space.prevent="activate"
v-html="modelValue ? renderMarkdown(modelValue) : '&nbsp;'"
></div>
<textarea
v-else
ref="textareaRef"
class="markdown-source"
:aria-label="label"
:value="modelValue"
rows="1"
@input="onInput"
@blur="deactivate"
></textarea>
</div>
</template>

View File

@@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
it('emits updates while keeping an accessible label', async () => {
const wrapper = mount(EditableText, {
props: { modelValue: '旧内容', label: '课题' },
})
await wrapper.get('textarea').setValue('新内容')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['新内容'])
expect(wrapper.get('textarea').attributes('aria-label')).toBe('课题')
})
})

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: string
label: string
multiline?: boolean
editable?: boolean
}>(),
{ editable: true },
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function resize(): void {
const element = textareaRef.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${element.scrollHeight}px`
}
function onInput(event: Event): void {
const value = (event.target as HTMLTextAreaElement).value
emit('update:modelValue', value)
resize()
}
watch(
() => props.modelValue,
() => nextTick(resize),
)
onMounted(resize)
</script>
<template>
<textarea
v-if="editable"
ref="textareaRef"
class="editable-field editable-text"
:class="{ 'editable-text--multiline': multiline }"
:aria-label="label"
:value="modelValue"
rows="1"
@input="onInput"
></textarea>
<span
v-else
class="editable-field editable-text editable-text--static"
:class="{ 'editable-text--multiline': multiline }"
:aria-label="label"
>{{ modelValue }}</span
>
</template>

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
duplicates: string[]
}>()
defineEmits<{
replace: []
keep: []
cancel: []
}>()
</script>
<template>
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="import-conflict-title">
<div class="dialog">
<h2 id="import-conflict-title">发现重名教案</h2>
<p>以下文件名与当前书本中的教案重复</p>
<ul class="dialog-filenames">
<li v-for="filename in duplicates" :key="filename">{{ filename }}</li>
</ul>
<p>替换会用新文件覆盖原位置上的教案保留两者会将新文件作为新的一课追加导入</p>
<div class="dialog-actions">
<button type="button" @click="$emit('replace')">替换</button>
<button type="button" @click="$emit('keep')">保留两者</button>
<button type="button" @click="$emit('cancel')">取消</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
import LessonSidebar from './LessonSidebar.vue'
describe('LessonSidebar', () => {
it('emits a move when one lesson is dropped on another', async () => {
const designs = [createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md')]
const wrapper = mount(LessonSidebar, {
props: { designs, selectedId: designs[0]?.id ?? 'cover' },
})
await wrapper.get('[data-index="0"]').trigger('dragstart')
await wrapper.get('[data-index="1"]').trigger('drop')
expect(wrapper.emitted('move')?.[0]).toEqual([0, 1])
})
})

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { DesignId, TeachingDesign } from '../domain/teachingDesign'
defineProps<{
designs: TeachingDesign[]
selectedId: 'cover' | DesignId
}>()
const emit = defineEmits<{
select: [id: 'cover' | DesignId]
remove: [id: DesignId]
move: [from: number, to: number]
}>()
const dragSourceIndex = ref<number | null>(null)
function onDragStart(index: number): void {
dragSourceIndex.value = index
}
function onDrop(targetIndex: number): void {
if (dragSourceIndex.value !== null && dragSourceIndex.value !== targetIndex) {
emit('move', dragSourceIndex.value, targetIndex)
}
dragSourceIndex.value = null
}
</script>
<template>
<nav class="lesson-sidebar" aria-label="教案目录">
<button
type="button"
class="lesson-sidebar-item lesson-sidebar-cover"
:class="{ 'lesson-sidebar-item--active': selectedId === 'cover' }"
@click="emit('select', 'cover')"
>
封面
</button>
<ul class="lesson-sidebar-list">
<li
v-for="(design, index) in designs"
:key="design.id"
class="lesson-sidebar-item"
:class="{ 'lesson-sidebar-item--active': selectedId === design.id }"
:data-index="index"
draggable="true"
@dragstart="onDragStart(index)"
@dragover.prevent
@drop="onDrop(index)"
>
<button
type="button"
class="lesson-sidebar-select"
@click="emit('select', design.id)"
>
<span class="lesson-sidebar-number">{{ index + 1 }}</span>
<span class="lesson-sidebar-topic">{{ design.topic || design.originalFilename }}</span>
<span v-if="design.warnings.length" class="lesson-sidebar-badge">
{{ design.warnings.length }}
</span>
</button>
<button
type="button"
class="lesson-sidebar-remove"
aria-label="删除教案"
@click="emit('remove', design.id)"
>
×
</button>
</li>
</ul>
</nav>
</template>

View File

@@ -0,0 +1,23 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
import PrintBook from './PrintBook.vue'
describe('PrintBook', () => {
it('renders one cover and every lesson in current order', () => {
const first = createEmptyTeachingDesign('2.md')
first.topic = '第二课'
const second = createEmptyTeachingDesign('1.md')
second.topic = '第一课'
const wrapper = mount(PrintBook, {
props: {
cover: { courseName: 'Web 前端开发', teacherName: '张老师' },
designs: [first, second],
},
})
expect(wrapper.findAll('.print-section')).toHaveLength(3)
expect(wrapper.text().indexOf('第二课')).toBeLessThan(wrapper.text().indexOf('第一课'))
})
})

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { BookCover, TeachingDesign } from '../domain/teachingDesign'
import CoverPage from './CoverPage.vue'
import TeachingDesignPage from './TeachingDesignPage.vue'
defineProps<{
cover: BookCover
designs: TeachingDesign[]
}>()
</script>
<template>
<div class="print-book">
<div class="print-section">
<CoverPage
:course-name="cover.courseName"
:teacher-name="cover.teacherName"
:editable="false"
/>
</div>
<div v-for="design in designs" :key="design.id" class="print-section">
<TeachingDesignPage :design="design" :editable="false" />
</div>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
updatedAt: string
}>()
defineEmits<{
restore: []
discard: []
}>()
</script>
<template>
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="restore-draft-title">
<div class="dialog">
<h2 id="restore-draft-title">发现未保存的草稿</h2>
<p>检测到本地保存的教案最近更新时间{{ updatedAt }}</p>
<p>是否恢复上次编辑的内容</p>
<div class="dialog-actions">
<button type="button" @click="$emit('restore')">恢复</button>
<button type="button" @click="$emit('discard')">放弃</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign, type TeachingDesign } from '../domain/teachingDesign'
import TeachingDesignPage from './TeachingDesignPage.vue'
describe('TeachingDesignPage', () => {
it('adds and removes teaching process rows', async () => {
const design = createEmptyTeachingDesign('1.md')
const wrapper = mount(TeachingDesignPage, {
props: { design, editable: true },
})
await wrapper.get('[data-testid="add-step"]').trigger('click')
expect(
wrapper.emitted<TeachingDesign[]>('update:design')?.at(-1)?.[0]?.processSteps,
).toHaveLength(2)
})
})

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, toRaw } from 'vue'
import { createTeachingStep, type TeachingDesign, type TeachingStep } from '../domain/teachingDesign'
import EditableMarkdown from './EditableMarkdown.vue'
import EditableText from './EditableText.vue'
const props = defineProps<{
design: TeachingDesign
editable: boolean
}>()
const emit = defineEmits<{ 'update:design': [design: TeachingDesign] }>()
function update(mutator: (design: TeachingDesign) => void): void {
const clone = JSON.parse(JSON.stringify(toRaw(props.design))) as TeachingDesign
mutator(clone)
emit('update:design', clone)
}
const displayTitle = computed(
() => props.design.title || `${props.design.topic} 教学设计`,
)
function setField<K extends keyof TeachingDesign>(field: K, value: TeachingDesign[K]): void {
update((design) => {
design[field] = value
})
}
function setStepField<K extends keyof TeachingStep>(
index: number,
field: K,
value: TeachingStep[K],
): void {
update((design) => {
const step = design.processSteps[index]
if (step) step[field] = value
})
}
function addStep(): void {
update((design) => {
design.processSteps.push(createTeachingStep(design.processSteps.length + 1))
})
}
function removeStep(index: number): void {
update((design) => {
design.processSteps.splice(index, 1)
})
}
</script>
<template>
<section class="page teaching-design-page">
<EditableText
class="design-title"
:model-value="displayTitle"
label="教案标题"
:editable="editable"
@update:model-value="setField('title', $event)"
/>
<table class="basic-info-table">
<tbody>
<tr>
<th>课题</th>
<td>
<EditableText
:model-value="design.topic"
label="课题"
:editable="editable"
@update:model-value="setField('topic', $event)"
/>
</td>
</tr>
<tr>
<th>课时</th>
<td>
<EditableText
:model-value="design.duration"
label="课时"
:editable="editable"
@update:model-value="setField('duration', $event)"
/>
</td>
</tr>
<tr>
<th>教学目标</th>
<td class="objectives-cell">
<div class="objective-row">
<span class="objective-label">知识目标</span>
<EditableMarkdown
:model-value="design.knowledgeObjective"
label="知识目标"
:editable="editable"
@update:model-value="setField('knowledgeObjective', $event)"
/>
</div>
<div class="objective-row">
<span class="objective-label">技能目标</span>
<EditableMarkdown
:model-value="design.skillObjective"
label="技能目标"
:editable="editable"
@update:model-value="setField('skillObjective', $event)"
/>
</div>
<div class="objective-row">
<span class="objective-label">素养目标</span>
<EditableMarkdown
:model-value="design.literacyObjective"
label="素养目标"
:editable="editable"
@update:model-value="setField('literacyObjective', $event)"
/>
</div>
</td>
</tr>
<tr>
<th>教学重难点</th>
<td class="objectives-cell">
<div class="objective-row">
<span class="objective-label">重点</span>
<EditableMarkdown
:model-value="design.keyPoint"
label="教学重点"
:editable="editable"
@update:model-value="setField('keyPoint', $event)"
/>
</div>
<div class="objective-row">
<span class="objective-label">难点</span>
<EditableMarkdown
:model-value="design.difficultPoint"
label="教学难点"
:editable="editable"
@update:model-value="setField('difficultPoint', $event)"
/>
</div>
</td>
</tr>
<tr>
<th>教学资源准备</th>
<td>
<EditableMarkdown
:model-value="design.resources"
label="教学资源准备"
:editable="editable"
@update:model-value="setField('resources', $event)"
/>
</td>
</tr>
</tbody>
</table>
<h2 class="section-heading">教学过程</h2>
<table class="process-table">
<thead>
<tr>
<th>教学环节</th>
<th>教学内容</th>
<th>教师活动</th>
<th>学生活动</th>
<th>设计意图</th>
<th v-if="editable" class="no-print"></th>
</tr>
</thead>
<tbody>
<tr v-for="(step, index) in design.processSteps" :key="step.id">
<td class="process-step-name">
<EditableText
:model-value="step.name"
label="教学环节名称"
:editable="editable"
@update:model-value="setStepField(index, 'name', $event)"
/>
<EditableText
:model-value="step.duration"
label="教学环节时长"
:editable="editable"
@update:model-value="setStepField(index, 'duration', $event)"
/>
</td>
<td>
<EditableMarkdown
:model-value="step.content"
label="教学内容"
:editable="editable"
@update:model-value="setStepField(index, 'content', $event)"
/>
</td>
<td>
<EditableMarkdown
:model-value="step.teacherActivity"
label="教师活动"
:editable="editable"
@update:model-value="setStepField(index, 'teacherActivity', $event)"
/>
</td>
<td>
<EditableMarkdown
:model-value="step.studentActivity"
label="学生活动"
:editable="editable"
@update:model-value="setStepField(index, 'studentActivity', $event)"
/>
</td>
<td>
<EditableMarkdown
:model-value="step.intention"
label="设计意图"
:editable="editable"
@update:model-value="setStepField(index, 'intention', $event)"
/>
</td>
<td v-if="editable" class="no-print process-step-actions">
<button
type="button"
:data-testid="`remove-step-${index}`"
:disabled="design.processSteps.length <= 1"
@click="removeStep(index)"
>
删除环节
</button>
</td>
</tr>
</tbody>
</table>
<button v-if="editable" type="button" class="no-print" data-testid="add-step" @click="addStep">
添加教学环节
</button>
<h2 class="section-heading">板书设计</h2>
<EditableText
class="board-design"
:model-value="design.boardDesign"
label="板书设计"
multiline
:editable="editable"
@update:model-value="setField('boardDesign', $event)"
/>
<h2 class="section-heading">教学成效与反思</h2>
<table class="reflection-table">
<tbody>
<tr>
<th>教学成效</th>
<td>
<EditableMarkdown
:model-value="design.effectiveness"
label="教学成效"
:editable="editable"
@update:model-value="setField('effectiveness', $event)"
/>
</td>
</tr>
<tr>
<th>教学反思</th>
<td>
<EditableMarkdown
:model-value="design.reflection"
label="教学反思"
:editable="editable"
@update:model-value="setField('reflection', $event)"
/>
</td>
</tr>
</tbody>
</table>
<template v-if="design.additionalContent || editable">
<h2 class="section-heading">附加内容</h2>
<EditableMarkdown
:model-value="design.additionalContent"
label="附加内容"
:editable="editable"
@update:model-value="setField('additionalContent', $event)"
/>
</template>
<ul v-if="design.warnings.length" class="warning-summary no-print">
<li v-for="warning in design.warnings" :key="warning.code">{{ warning.message }}</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import UploadDropzone from './UploadDropzone.vue'
describe('UploadDropzone', () => {
it('emits every selected file', async () => {
const wrapper = mount(UploadDropzone)
const files = [new File(['# one'], '1.md'), new File(['# two'], '2.md')]
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: files })
await input.trigger('change')
expect(wrapper.emitted('files')?.[0]?.[0]).toEqual(files)
})
})

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue'
withDefaults(defineProps<{ compact?: boolean }>(), {
compact: false,
})
const emit = defineEmits<{ files: [files: File[]] }>()
const inputRef = ref<HTMLInputElement | null>(null)
const isDragOver = ref(false)
function openPicker(): void {
inputRef.value?.click()
}
function onChange(event: Event): void {
const input = event.target as HTMLInputElement
if (input.files) {
emit('files', Array.from(input.files))
}
input.value = ''
}
function onDrop(event: DragEvent): void {
isDragOver.value = false
const files = event.dataTransfer?.files
if (files) {
emit('files', Array.from(files))
}
}
function onDragOver(): void {
isDragOver.value = true
}
function onDragLeave(): void {
isDragOver.value = false
}
defineExpose({ openPicker })
</script>
<template>
<div
class="upload-dropzone"
:class="{
'upload-dropzone--compact': compact,
'upload-dropzone--drag-over': isDragOver,
}"
role="button"
tabindex="0"
@click="openPicker"
@keydown.enter.prevent="openPicker"
@keydown.space.prevent="openPicker"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<input
ref="inputRef"
type="file"
multiple
accept=".md,text/markdown,text/plain"
class="upload-dropzone-input"
@change="onChange"
@click.stop
/>
<template v-if="compact">
<span class="upload-dropzone-label">导入教学设计</span>
</template>
<template v-else>
<p class="upload-dropzone-title">点击或拖拽上传 Markdown 教学设计文件</p>
<p class="upload-dropzone-hint">支持批量导入多个 .md 文件</p>
</template>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { SaveStatus } from '../composables/useTeachingBook'
const props = defineProps<{
lessonCount: number
warningCount: number
saveStatus: SaveStatus
}>()
defineEmits<{
upload: []
print: []
export: []
clear: []
}>()
const saveStatusLabel: Record<SaveStatus, string> = {
idle: '',
saving: '保存中…',
saved: '已保存到本地',
error: '保存失败',
}
</script>
<template>
<header class="workspace-toolbar">
<button type="button" @click="$emit('upload')">导入教案</button>
<button type="button" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
<button type="button" :disabled="lessonCount === 0" @click="$emit('export')">导出 Markdown</button>
<button type="button" :disabled="lessonCount === 0" @click="$emit('clear')">清空</button>
<span class="workspace-toolbar-count"> {{ lessonCount }} </span>
<span v-if="warningCount > 0" class="workspace-toolbar-warning">
{{ warningCount }} 处提示
</span>
<span class="workspace-toolbar-status" :class="`workspace-toolbar-status--${saveStatus}`">
{{ saveStatusLabel[props.saveStatus] }}
</span>
</header>
</template>