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

13
src/App.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import App from './App.vue'
describe('App', () => {
beforeEach(() => localStorage.clear())
it('starts with the multi-file upload screen', () => {
const wrapper = mount(App)
expect(wrapper.get('input[type="file"]').attributes('multiple')).toBeDefined()
expect(wrapper.text()).toContain('上传 Markdown')
})
})

View File

@@ -1,7 +1,174 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import { onMounted, ref } from 'vue'
import A4Workspace from './components/A4Workspace.vue'
import ImportConflictDialog from './components/ImportConflictDialog.vue'
import LessonSidebar from './components/LessonSidebar.vue'
import PrintBook from './components/PrintBook.vue'
import RestoreDraftDialog from './components/RestoreDraftDialog.vue'
import UploadDropzone from './components/UploadDropzone.vue'
import WorkspaceToolbar from './components/WorkspaceToolbar.vue'
import { type DuplicateStrategy, useTeachingBook } from './composables/useTeachingBook'
import { clearStoredBook, loadStoredBook } from './services/bookStorage'
import type { TeachingBook, TeachingDesign } from './domain/teachingDesign'
import { createBookZip, downloadBlob } from './services/zipExporter'
const {
book,
saveStatus,
lastError,
selectedDesign,
hasDesigns,
warningCount,
importFiles,
detectDuplicates,
selectPage,
moveDesign,
removeDesign,
updateCover,
updateDesign,
restore,
clearBook,
} = useTeachingBook()
const restoreCandidate = ref<TeachingBook | null>(null)
const pendingFiles = ref<File[]>([])
const duplicateNames = ref<string[]>([])
const errorMessage = ref<string | null>(null)
const uploadRef = ref<InstanceType<typeof UploadDropzone> | null>(null)
onMounted(() => {
const stored = loadStoredBook()
if (stored && stored.designs.length > 0) {
restoreCandidate.value = stored
}
})
function restoreDraft(): void {
if (restoreCandidate.value) {
restore(restoreCandidate.value)
}
restoreCandidate.value = null
}
function discardDraft(): void {
clearStoredBook()
restoreCandidate.value = null
}
async function runImport(files: File[], strategy: DuplicateStrategy): Promise<void> {
const result = await importFiles(files, strategy)
if (result.failed.length > 0) {
errorMessage.value = `${result.failed.length} 个文件导入失败:${result.failed
.map((entry) => `${entry.filename}${entry.message}`)
.join('、')}`
}
}
async function handleFiles(files: File[]): Promise<void> {
const duplicates = detectDuplicates(files)
if (duplicates.length > 0) {
pendingFiles.value = files
duplicateNames.value = duplicates
return
}
await runImport(files, 'keep')
}
async function resolveConflict(strategy: DuplicateStrategy | 'cancel'): Promise<void> {
const files = pendingFiles.value
pendingFiles.value = []
duplicateNames.value = []
if (strategy === 'cancel') return
await runImport(files, strategy)
}
function triggerUpload(): void {
uploadRef.value?.openPicker()
}
function handlePrint(): void {
window.print()
}
async function handleExport(): Promise<void> {
try {
const blob = await createBookZip(book.value.designs)
downloadBlob(blob, 'teaching-design-book.zip')
} catch {
errorMessage.value = '导出失败,请重试。'
}
}
function handleClear(): void {
if (book.value.designs.length === 0) {
return
}
if (window.confirm('确定要清空当前所有教案吗?此操作无法撤销。')) {
clearBook()
clearStoredBook()
}
}
function handleDesignUpdate(design: TeachingDesign): void {
updateDesign(design.id, (target) => Object.assign(target, design))
}
</script>
<template>
<HelloWorld />
<div class="app-shell">
<RestoreDraftDialog
v-if="restoreCandidate"
:updated-at="restoreCandidate.updatedAt"
@restore="restoreDraft"
@discard="discardDraft"
/>
<ImportConflictDialog
v-if="duplicateNames.length > 0"
:duplicates="duplicateNames"
@replace="resolveConflict('replace')"
@keep="resolveConflict('keep')"
@cancel="resolveConflict('cancel')"
/>
<p v-if="errorMessage" class="app-notice app-notice--error" role="alert">
{{ errorMessage }}
<button type="button" @click="errorMessage = null">关闭</button>
</p>
<p v-if="saveStatus === 'error' && lastError" class="app-notice app-notice--error" role="alert">
{{ lastError }}
</p>
<UploadDropzone v-if="!hasDesigns" @files="handleFiles" />
<template v-else>
<WorkspaceToolbar
:lesson-count="book.designs.length"
:warning-count="warningCount"
:save-status="saveStatus"
@upload="triggerUpload"
@print="handlePrint"
@export="handleExport"
@clear="handleClear"
/>
<div class="workspace-layout">
<LessonSidebar
:designs="book.designs"
:selected-id="book.selectedId"
@select="selectPage"
@remove="removeDesign"
@move="moveDesign"
/>
<A4Workspace
:cover="book.cover"
:selected-id="book.selectedId"
:selected-design="selectedDesign"
@update:cover="updateCover"
@update:design="handleDesignUpdate"
/>
</div>
<UploadDropzone ref="uploadRef" compact class="visually-hidden" @files="handleFiles" />
</template>
<PrintBook :cover="book.cover" :designs="book.designs" />
</div>
</template>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

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>

View File

@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTeachingBook } from './useTeachingBook'
describe('useTeachingBook', () => {
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
})
it('imports files in natural order and selects the first lesson', async () => {
const store = useTeachingBook()
const files = [
new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }),
new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }),
]
await store.importFiles(files, 'keep')
expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual([
'2.md',
'10.md',
])
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
})
it('reorders lessons without changing their identities', async () => {
const store = useTeachingBook()
await store.importFiles([
new File(['# One 教学设计'], '1.md'),
new File(['# Two 教学设计'], '2.md'),
], 'keep')
const ids = store.book.value.designs.map((design) => design.id)
store.moveDesign(0, 1)
expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse())
})
})

View File

@@ -0,0 +1,226 @@
import { ref, watch, type Ref } from 'vue'
import {
createEmptyBook,
type BookCover,
type DesignId,
type TeachingBook,
type TeachingDesign,
} from '../domain/teachingDesign'
import { saveBook } from '../services/bookStorage'
import { parseTeachingDesign } from '../services/markdownParser'
import { sortFilesNaturally } from '../services/naturalSort'
const AUTOSAVE_DELAY_MS = 300
export type DuplicateStrategy = 'replace' | 'keep'
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export interface ImportResult {
imported: number
failed: Array<{ filename: string; message: string }>
duplicates: string[]
}
export interface TeachingBookStore {
book: Ref<TeachingBook>
saveStatus: Ref<SaveStatus>
lastError: Ref<string | null>
pendingDuplicateFiles: Ref<File[]>
selectedDesign: Ref<TeachingDesign | null>
hasDesigns: Ref<boolean>
warningCount: Ref<number>
importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise<ImportResult>
detectDuplicates: (files: readonly File[]) => string[]
selectPage: (id: 'cover' | DesignId) => void
moveDesign: (from: number, to: number) => void
removeDesign: (id: DesignId) => void
updateCover: (patch: Partial<BookCover>) => void
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
restore: (book: TeachingBook) => void
clearBook: () => void
}
export function useTeachingBook(): TeachingBookStore {
const book = ref<TeachingBook>(createEmptyBook()) as Ref<TeachingBook>
const saveStatus = ref<SaveStatus>('idle')
const lastError = ref<string | null>(null)
const pendingDuplicateFiles = ref<File[]>([])
const selectedDesign = ref<TeachingDesign | null>(null)
const hasDesigns = ref(false)
const warningCount = ref(0)
function syncDerived(): void {
const current = book.value
hasDesigns.value = current.designs.length > 0
selectedDesign.value =
current.selectedId === 'cover'
? null
: current.designs.find((design) => design.id === current.selectedId) ?? null
warningCount.value = current.designs.reduce(
(total, design) => total + design.warnings.length,
0,
)
}
syncDerived()
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
function touch(): void {
book.value.updatedAt = new Date().toISOString()
}
watch(
book,
() => {
syncDerived()
if (autosaveTimer !== undefined) {
clearTimeout(autosaveTimer)
}
autosaveTimer = setTimeout(() => {
saveStatus.value = 'saving'
const result = saveBook(book.value)
if (result.ok) {
saveStatus.value = 'saved'
lastError.value = null
} else {
saveStatus.value = 'error'
lastError.value = result.message
}
}, AUTOSAVE_DELAY_MS)
},
{ deep: true },
)
function detectDuplicates(files: readonly File[]): string[] {
const existingNames = new Set(book.value.designs.map((design) => design.originalFilename))
return files.map((file) => file.name).filter((name) => existingNames.has(name))
}
async function importFiles(
files: readonly File[],
strategy: DuplicateStrategy,
): Promise<ImportResult> {
const markdownFiles = files.filter((file) => /\.md$/i.test(file.name))
const failed: ImportResult['failed'] = files
.filter((file) => !/\.md$/i.test(file.name))
.map((file) => ({ filename: file.name, message: '仅支持 .md 文件。' }))
const sortedFiles = sortFilesNaturally([...markdownFiles])
const duplicates: string[] = []
let imported = 0
for (const file of sortedFiles) {
try {
const text = await file.text()
const design = parseTeachingDesign(file.name, text)
const existingIndex = book.value.designs.findIndex(
(existing) => existing.originalFilename === file.name,
)
if (existingIndex !== -1) {
duplicates.push(file.name)
if (strategy === 'replace') {
book.value.designs.splice(existingIndex, 1, design)
} else {
book.value.designs.push(design)
}
} else {
book.value.designs.push(design)
}
imported++
} catch (error) {
failed.push({
filename: file.name,
message: error instanceof Error ? error.message : '解析失败。',
})
}
}
if (imported > 0 && book.value.selectedId === 'cover' && book.value.designs.length > 0) {
book.value.selectedId = book.value.designs[0]!.id
}
if (imported > 0) {
touch()
}
return { imported, failed, duplicates }
}
function selectPage(id: 'cover' | DesignId): void {
book.value.selectedId = id
}
function moveDesign(from: number, to: number): void {
const designs = book.value.designs
if (from < 0 || from >= designs.length || to < 0 || to >= designs.length) {
return
}
const [moved] = designs.splice(from, 1)
designs.splice(to, 0, moved!)
touch()
}
function removeDesign(id: DesignId): void {
const designs = book.value.designs
const index = designs.findIndex((design) => design.id === id)
if (index === -1) {
return
}
designs.splice(index, 1)
if (book.value.selectedId === id) {
book.value.selectedId = designs[index]?.id ?? designs[index - 1]?.id ?? 'cover'
}
touch()
}
function updateCover(patch: Partial<BookCover>): void {
Object.assign(book.value.cover, patch)
touch()
}
function updateDesign(id: DesignId, updater: (design: TeachingDesign) => void): void {
const design = book.value.designs.find((candidate) => candidate.id === id)
if (!design) {
return
}
updater(design)
touch()
}
function restore(restored: TeachingBook): void {
book.value = restored
}
function clearBook(): void {
book.value = createEmptyBook()
}
return {
book,
saveStatus,
lastError,
pendingDuplicateFiles,
selectedDesign,
hasDesigns,
warningCount,
importFiles,
detectDuplicates,
selectPage,
moveDesign,
removeDesign,
updateCover,
updateDesign,
restore,
clearBook,
}
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import './print.css'
import App from './App.vue'
createApp(App).mount('#app')

85
src/print.css Normal file
View File

@@ -0,0 +1,85 @@
@page {
size: A4;
margin: 12mm;
}
.print-book {
display: none;
}
@media print {
html,
body,
#app {
margin: 0;
padding: 0;
background: #fff;
}
.app-shell > *:not(.print-book) {
display: none !important;
}
.print-book {
display: block;
}
.print-section {
break-before: page;
}
.print-section:first-child {
break-before: auto;
}
.page {
width: auto;
min-height: 0;
margin: 0;
padding: 0;
box-shadow: none;
}
.process-table {
break-inside: auto;
}
.process-table thead {
display: table-header-group;
}
.process-table tr {
break-inside: avoid;
}
.section-heading {
break-after: avoid;
}
.basic-info-table,
.reflection-table,
.board-design {
break-inside: avoid;
}
.no-print,
.warning-summary {
display: none !important;
}
.markdown-source {
display: none;
}
.editable-text--static,
.markdown-preview {
border-color: transparent;
background: none;
white-space: pre-wrap;
word-break: break-word;
}
.board-design {
word-break: break-word;
}
}

View File

@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createEmptyBook } from '../domain/teachingDesign'
import { clearStoredBook, loadStoredBook, saveBook } from './bookStorage'
describe('bookStorage', () => {
beforeEach(() => localStorage.clear())
it('round-trips a versioned book', () => {
const book = createEmptyBook()
book.cover.courseName = 'Web 前端开发'
expect(saveBook(book)).toEqual({ ok: true })
expect(loadStoredBook()?.cover.courseName).toBe('Web 前端开发')
})
it('returns null for malformed storage', () => {
localStorage.setItem('teaching-design-book', '{bad json')
expect(loadStoredBook()).toBeNull()
})
it('clears saved work', () => {
saveBook(createEmptyBook())
clearStoredBook()
expect(loadStoredBook()).toBeNull()
})
})

View File

@@ -0,0 +1,29 @@
import { BOOK_SCHEMA_VERSION, type TeachingBook } from '../domain/teachingDesign'
const STORAGE_KEY = 'teaching-design-book'
export type SaveResult = { ok: true } | { ok: false; message: string }
export function saveBook(book: TeachingBook): SaveResult {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(book))
return { ok: true }
} catch {
return { ok: false, message: '浏览器存储空间不足,当前修改尚未暂存。' }
}
}
export function loadStoredBook(): TeachingBook | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as TeachingBook
return parsed.schemaVersion === BOOK_SCHEMA_VERSION ? parsed : null
} catch {
return null
}
}
export function clearStoredBook(): void {
localStorage.removeItem(STORAGE_KEY)
}

View File

@@ -0,0 +1,35 @@
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseTeachingDesign } from './markdownParser'
const fixture = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8')
describe('teaching-design corpus', () => {
it.each([
['data/Web/1.md', '个人主页——项目启动与开发环境搭建'],
['data/Python/1.md', '智能学生选课推荐系统——项目启动与Python开发环境搭建'],
['data/C#/8.md', '智能仓储管理系统——异常处理与调试确保系统稳定运行'],
['data/C#/19.md', '智能教室环境监测系统——数据可视化与历史曲线绘制'],
])('parses %s without losing its topic', (path, topic) => {
const design = parseTeachingDesign(path.split('/').at(-1) ?? path, fixture(path))
expect(design.topic).toBe(topic)
expect(design.processSteps.length).toBeGreaterThan(0)
})
it('imports every numbered corpus file without throwing', () => {
const directories = ['data/Web', 'data/Python', 'data/C#']
const paths = directories.flatMap((directory) =>
readdirSync(resolve(process.cwd(), directory))
.filter((name) => /^\d+\.md$/.test(name))
.map((name) => `${directory}/${name}`),
)
expect(paths).toHaveLength(55)
for (const path of paths) {
const design = parseTeachingDesign(path.split('/').at(-1) ?? path, fixture(path))
expect(design.topic || design.title).not.toBe('')
expect(design.originalFilename).toMatch(/\.md$/)
}
})
})

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { parseTeachingDesign } from './markdownParser'
const standard = `# 个人主页——项目启动 教学设计
| **课题** | **个人主页——项目启动** |
|:---|:---|
| **课时** | 1课时40分钟 |
| **教学目标** | **知识目标**:认识 HTML。<br>**技能目标**:创建页面。<br>**素养目标**:规范操作。 |
| **教学重难点** | **重点**HTML。<br>**难点**:路径。 |
| **教学资源准备** | 浏览器。 |
## 教学过程
| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |
|:---|:---|:---|:---|:---|
| **1. 导入**<br>6分钟 | 展示案例。 | **情境创设**<br>提问。 | **观察思考**<br>回答。 | 建立目标。 |
## 板书设计
\`\`\`text
HTML → 浏览器
\`\`\`
## 教学成效与反思
| | |
|:---|:---|
| **教学成效** | 完成页面。 |
| **教学反思** | 加强路径讲解。 |
`
describe('parseTeachingDesign', () => {
it('parses the complete teaching-design structure', () => {
const design = parseTeachingDesign('1.md', standard)
expect(design.topic).toBe('个人主页——项目启动')
expect(design.knowledgeObjective).toBe('认识 HTML。')
expect(design.processSteps[0]).toMatchObject({
name: '1. 导入',
duration: '6分钟',
content: '展示案例。',
})
expect(design.boardDesign).toContain('HTML → 浏览器')
expect(design.reflection).toBe('加强路径讲解。')
expect(design.warnings).toEqual([])
})
it('accepts half-width punctuation and reports missing sections', () => {
const markdown = standard
.replaceAll('', ':')
.replace(/## 板书设计[\s\S]*?(?=## 教学成效与反思)/, '')
const design = parseTeachingDesign('8.md', markdown)
expect(design.knowledgeObjective).toBe('认识 HTML。')
expect(design.boardDesign).toBe('')
expect(design.warnings.some((warning) => warning.code === 'missing-board')).toBe(true)
})
it('parses process steps where the step number is outside the bold name', () => {
const markdown = standard.replace(
'| **1. 导入**<br>6分钟 | 展示案例。 | **情境创设**<br>提问。 | **观察思考**<br>回答。 | 建立目标。 |',
'| 1. **导入**<br>(6分钟) | 展示案例。 | **情境创设**<br>提问。 | **观察思考**<br>回答。 | 建立目标。 |',
)
const design = parseTeachingDesign('1.md', markdown)
expect(design.processSteps[0]).toMatchObject({
name: '1. 导入',
duration: '6分钟',
})
})
it('reports a missing title when no level-one heading exists', () => {
const markdown = standard.replace('# 个人主页——项目启动 教学设计\n\n', '')
const design = parseTeachingDesign('1.md', markdown)
expect(design.warnings.some((warning) => warning.code === 'missing-title')).toBe(true)
})
})

View File

@@ -0,0 +1,282 @@
import {
createEmptyTeachingDesign,
createTeachingStep,
type ParseWarning,
type TeachingDesign,
type TeachingStep,
} from '../domain/teachingDesign'
import { extractMarkdownTable } from './markdownTable'
const BR = /<br\s*\/?>/gi
const LABEL_MARKS = /[*_`]/g
const COLON = /[:]\s*$/
const PAREN_DURATION = /[(]([^()]*)[)]/
const KNOWN_SECTION_HEADINGS = new Set(['教学过程', '板书设计', '教学成效与反思'])
function cleanLabel(value: string): string {
return value.replace(LABEL_MARKS, '').trim()
}
function stripOuterBold(value: string): string {
return value.trim().replace(/^\*\*([\s\S]*)\*\*$/, '$1').trim()
}
function normalizeMultiline(value: string): string {
return value.replace(BR, '\n').trim()
}
function isSectionHeading(line: string, heading: string): boolean {
const trimmed = line.trim()
return (
new RegExp(`^##\\s+${heading}\\s*$`).test(trimmed) || trimmed === `**${heading}**`
)
}
function findSectionIndex(lines: readonly string[], heading: string, fromIndex = 0): number {
for (let index = fromIndex; index < lines.length; index += 1) {
if (isSectionHeading(lines[index] ?? '', heading)) return index
}
return -1
}
function isAnyHeading(line: string): boolean {
const trimmed = line.trim()
return /^##\s+\S/.test(trimmed) || /^\*\*[^*]+\*\*$/.test(trimmed)
}
function findNextHeadingIndex(lines: readonly string[], fromIndex: number): number {
for (let index = fromIndex; index < lines.length; index += 1) {
if (isAnyHeading(lines[index] ?? '')) return index
}
return -1
}
function headingName(line: string): string {
const trimmed = line.trim()
const levelTwo = trimmed.match(/^##\s+(.+)$/)
if (levelTwo) return levelTwo[1]!.trim()
return trimmed.slice(2, -2).trim()
}
function splitLabelledValue(value: string, labels: readonly string[]): Record<string, string> {
const normalized = value.replace(BR, '\n')
const alternation = labels.join('|')
const pattern = new RegExp(`(?:\\*\\*(?:${alternation})\\*\\*|(?:${alternation}))\\s*[:]`, 'g')
const matches = [...normalized.matchAll(pattern)]
const result: Record<string, string> = {}
matches.forEach((match, index) => {
const label = cleanLabel(match[0].replace(COLON, ''))
const start = match.index + match[0].length
const end = index + 1 < matches.length ? matches[index + 1]!.index : normalized.length
result[label] = normalized.slice(start, end).trim()
})
return result
}
function parseStepNameCell(cell: string, fallbackIndex: number): { name: string; duration: string } {
const normalized = cell.replace(BR, '\n')
const parts = normalized
.split('\n')
.map((part) => part.trim())
.filter(Boolean)
let namePart = parts[0] ?? ''
let durationPart = parts[1] ?? ''
let duration = ''
const durationMatch = (durationPart || namePart).match(PAREN_DURATION)
if (durationMatch) {
duration = durationMatch[1]!.trim()
if (durationPart) {
durationPart = ''
} else {
namePart = namePart.replace(durationMatch[0], '').trim()
}
}
const name = cleanLabel(namePart) || createTeachingStep(fallbackIndex).name
return { name, duration }
}
function extractBoardContent(sectionLines: readonly string[]): string {
const fenceStart = sectionLines.findIndex((line) => /^\s*(`{3,}|~{3,})/.test(line))
if (fenceStart < 0) {
return sectionLines.join('\n').trim()
}
const fenceMatch = sectionLines[fenceStart]!.match(/^\s*(`{3,}|~{3,})/)!
const fenceChar = fenceMatch[1]![0]!
const fenceLength = fenceMatch[1]!.length
let fenceEnd = sectionLines.length
for (let index = fenceStart + 1; index < sectionLines.length; index += 1) {
const close = sectionLines[index]!.match(/^\s*(`+|~+)\s*$/)
if (close && close[1]![0] === fenceChar && close[1]!.length >= fenceLength) {
fenceEnd = index
break
}
}
return sectionLines.slice(fenceStart + 1, fenceEnd).join('\n').trim()
}
export function parseTeachingDesign(filename: string, markdown: string): TeachingDesign {
const design = createEmptyTeachingDesign(filename)
const warnings: ParseWarning[] = []
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const titleLineIndex = lines.findIndex((line) => /^#\s+\S/.test(line.trim()))
let headingTitle = ''
if (titleLineIndex >= 0) {
headingTitle = lines[titleLineIndex]!.trim().replace(/^#\s+/, '').trim()
} else {
warnings.push({ code: 'missing-title', message: '未找到课程标题(一级标题)。' })
}
const basicTable = extractMarkdownTable(lines, titleLineIndex + 1)
const basicFieldsFound = new Set<string>()
if (basicTable) {
for (const row of [basicTable.header, ...basicTable.rows]) {
const label = cleanLabel(row[0] ?? '')
const value = (row[1] ?? '').trim()
switch (label) {
case '课题':
design.topic = stripOuterBold(value)
basicFieldsFound.add('topic')
break
case '课时':
design.duration = value
basicFieldsFound.add('duration')
break
case '教学目标': {
const objectives = splitLabelledValue(value, ['知识目标', '技能目标', '素养目标'])
design.knowledgeObjective = objectives['知识目标'] ?? ''
design.skillObjective = objectives['技能目标'] ?? ''
design.literacyObjective = objectives['素养目标'] ?? ''
basicFieldsFound.add('objectives')
break
}
case '教学重难点': {
const points = splitLabelledValue(value, ['重点', '难点'])
design.keyPoint = points['重点'] ?? ''
design.difficultPoint = points['难点'] ?? ''
basicFieldsFound.add('points')
break
}
case '教学资源准备':
design.resources = value
basicFieldsFound.add('resources')
break
default:
break
}
}
}
if (!basicTable) {
warnings.push({ code: 'missing-basic-field', message: '未找到基本信息表格。' })
} else {
const requiredFields: Array<[string, string]> = [
['topic', '课题'],
['duration', '课时'],
['objectives', '教学目标'],
['points', '教学重难点'],
['resources', '教学资源准备'],
]
for (const [key, label] of requiredFields) {
if (!basicFieldsFound.has(key)) {
warnings.push({ code: 'missing-basic-field', message: `缺少"${label}"信息。` })
}
}
}
const titleWithoutSuffix = headingTitle.replace(/\s*教学设计\s*$/, '').trim()
design.title = titleWithoutSuffix && titleWithoutSuffix !== design.topic ? headingTitle : ''
const processIndex = findSectionIndex(lines, '教学过程', titleLineIndex + 1)
if (processIndex < 0) {
warnings.push({ code: 'missing-process', message: '未找到教学过程章节。' })
} else {
const processTable = extractMarkdownTable(lines, processIndex + 1)
if (!processTable || processTable.header.length < 5) {
warnings.push({ code: 'invalid-process-table', message: '教学过程表格格式不正确。' })
} else {
const steps: TeachingStep[] = []
processTable.rows.forEach((row, index) => {
if (row.length < 5) return
const [nameCell, content, teacherActivity, studentActivity, intention] = row
const { name, duration } = parseStepNameCell(nameCell ?? '', index + 1)
steps.push({
id: crypto.randomUUID(),
name,
duration,
content: normalizeMultiline(content ?? ''),
teacherActivity: normalizeMultiline(teacherActivity ?? ''),
studentActivity: normalizeMultiline(studentActivity ?? ''),
intention: normalizeMultiline(intention ?? ''),
})
})
if (steps.length > 0) {
design.processSteps = steps
} else {
warnings.push({ code: 'invalid-process-table', message: '教学过程表格中没有有效的环节行。' })
}
}
}
const boardIndex = findSectionIndex(lines, '板书设计', titleLineIndex + 1)
if (boardIndex < 0) {
warnings.push({ code: 'missing-board', message: '未找到板书设计章节。' })
} else {
const nextHeadingIndex = findNextHeadingIndex(lines, boardIndex + 1)
const sectionEnd = nextHeadingIndex < 0 ? lines.length : nextHeadingIndex
design.boardDesign = extractBoardContent(lines.slice(boardIndex + 1, sectionEnd))
}
const reflectionIndex = findSectionIndex(lines, '教学成效与反思', titleLineIndex + 1)
if (reflectionIndex < 0) {
warnings.push({ code: 'missing-reflection', message: '未找到教学成效与反思章节。' })
} else {
const reflectionTable = extractMarkdownTable(lines, reflectionIndex + 1)
if (!reflectionTable) {
warnings.push({ code: 'missing-reflection', message: '教学成效与反思表格格式不正确。' })
} else {
for (const row of reflectionTable.rows) {
const label = cleanLabel(row[0] ?? '')
const value = normalizeMultiline(row[1] ?? '')
if (label === '教学成效') design.effectiveness = value
if (label === '教学反思') design.reflection = value
}
}
}
const additionalParts: string[] = []
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!
if (!isAnyHeading(line)) continue
const name = headingName(line)
if (KNOWN_SECTION_HEADINGS.has(name)) continue
const nextHeadingIndex = findNextHeadingIndex(lines, index + 1)
const sectionEnd = nextHeadingIndex < 0 ? lines.length : nextHeadingIndex
const content = lines.slice(index + 1, sectionEnd).join('\n').trim()
if (content) {
additionalParts.push(`## ${name}\n\n${content}`)
}
}
if (additionalParts.length > 0) {
design.additionalContent = additionalParts.join('\n\n')
warnings.push({ code: 'unclassified-content', message: '存在未识别的章节内容。' })
}
design.warnings = warnings
return design
}

View File

@@ -0,0 +1,12 @@
import MarkdownIt from 'markdown-it'
const renderer = new MarkdownIt({
html: false,
breaks: true,
linkify: false,
typographer: false,
})
export function renderMarkdown(value: string): string {
return renderer.render(value || '')
}

View File

@@ -101,23 +101,65 @@ export function splitMarkdownRow(row: string): string[] {
const dividerCellPattern = /^:?-{3,}:?$/
function startsWithPipe(line: string): boolean {
const FENCE_OPEN_PATTERN = / {0,3}(`{3,}|~{3,})/
const FENCE_CLOSE_PATTERN = / {0,3}(`+|~+)\s*$/
function isTableRow(line: string): boolean {
const leading = line.match(/^[ \t]*/)?.[0] ?? ''
if (leading.includes('\t') || leading.length >= 4) {
return false
}
return line.trimStart().startsWith('|')
}
function computeFenceMask(lines: readonly string[]): boolean[] {
const mask = new Array<boolean>(lines.length).fill(false)
let fenceChar: string | null = null
let fenceLength = 0
for (let index = 0; index < lines.length; index++) {
const line = lines[index]!
if (fenceChar === null) {
const open = line.match(new RegExp(`^${FENCE_OPEN_PATTERN.source}`))
if (open) {
mask[index] = true
fenceChar = open[1]![0]!
fenceLength = open[1]!.length
}
continue
}
mask[index] = true
const close = line.match(new RegExp(`^${FENCE_CLOSE_PATTERN.source}`))
if (close && close[1]![0] === fenceChar && close[1]!.length >= fenceLength) {
fenceChar = null
fenceLength = 0
}
}
return mask
}
export function extractMarkdownTable(
lines: readonly string[],
fromIndex = 0,
): MarkdownTable | null {
const insideFence = computeFenceMask(lines)
for (
let start = Math.max(0, fromIndex);
start < lines.length - 1;
start++
) {
if (insideFence[start] || insideFence[start + 1]) {
continue
}
const headerLine = lines[start]!
const dividerLine = lines[start + 1]!
if (!startsWithPipe(headerLine) || !startsWithPipe(dividerLine)) {
if (!isTableRow(headerLine) || !isTableRow(dividerLine)) {
continue
}
@@ -135,7 +177,11 @@ export function extractMarkdownTable(
const rows: string[][] = []
let end = start + 1
while (end + 1 < lines.length && startsWithPipe(lines[end + 1]!)) {
while (
end + 1 < lines.length &&
!insideFence[end + 1] &&
isTableRow(lines[end + 1]!)
) {
end++
rows.push(splitMarkdownRow(lines[end]!))
}

View File

@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseTeachingDesign } from './markdownParser'
import { writeTeachingDesignMarkdown } from './markdownWriter'
describe('writeTeachingDesignMarkdown', () => {
it('writes canonical sections that can be parsed again', () => {
const source = readFileSync(resolve(process.cwd(), 'data/Web/1.md'), 'utf8')
const parsed = parseTeachingDesign('1.md', source)
const output = writeTeachingDesignMarkdown(parsed)
const reparsed = parseTeachingDesign('1.md', output)
expect(output).toContain('## 板书设计')
expect(reparsed.topic).toBe(parsed.topic)
expect(reparsed.processSteps).toHaveLength(parsed.processSteps.length)
expect(reparsed.reflection).toBe(parsed.reflection)
})
it('escapes table-breaking pipes but preserves inline markdown', () => {
const source = parseTeachingDesign('1.md', readFileSync(
resolve(process.cwd(), 'data/Web/1.md'),
'utf8',
))
source.resources = '终端 | 浏览器与 `index.html`'
expect(writeTeachingDesignMarkdown(source)).toContain(
'终端 \\| 浏览器与 `index.html`',
)
})
it('omits decorative parentheses for steps without a duration', () => {
const source = parseTeachingDesign('1.md', readFileSync(
resolve(process.cwd(), 'data/Web/1.md'),
'utf8',
))
source.processSteps[0]!.duration = ''
source.processSteps[0]!.name = '1. 导入'
const output = writeTeachingDesignMarkdown(source)
expect(output).toContain('**1. 导入**')
expect(output).not.toContain('**1. 导入**<br>')
expect(output).not.toContain('**1. 导入**')
})
})

View File

@@ -0,0 +1,76 @@
import type { TeachingDesign } from '../domain/teachingDesign'
function escapeCell(value: string): string {
return value
.replace(/\r?\n/g, '<br>')
.replace(/(?<!\\)\|/g, '\\|')
.trim()
}
function objectiveCell(design: TeachingDesign): string {
return [
`**知识目标**${design.knowledgeObjective}`,
`**技能目标**${design.skillObjective}`,
`**素养目标**${design.literacyObjective}`,
].join('\n')
}
function keyPointCell(design: TeachingDesign): string {
return [`**重点**${design.keyPoint}`, `**难点**${design.difficultPoint}`].join('\n')
}
function processNameCell(step: TeachingDesign['processSteps'][number]): string {
return step.duration ? `**${step.name}**\n${step.duration}` : `**${step.name}**`
}
export function writeTeachingDesignMarkdown(design: TeachingDesign): string {
const title = design.title || `${design.topic} 教学设计`
const processRows = design.processSteps.map((step) =>
[
escapeCell(processNameCell(step)),
escapeCell(step.content),
escapeCell(step.teacherActivity),
escapeCell(step.studentActivity),
escapeCell(step.intention),
]
.map((cell) => `| ${cell}`)
.join(' ') + ' |',
)
const sections = [
`# ${title}`,
'',
`| **课题** | **${escapeCell(design.topic)}** |`,
'|:---|:---|',
`| **课时** | ${escapeCell(design.duration)} |`,
`| **教学目标** | ${escapeCell(objectiveCell(design))} |`,
`| **教学重难点** | ${escapeCell(keyPointCell(design))} |`,
`| **教学资源准备** | ${escapeCell(design.resources)} |`,
'',
'## 教学过程',
'',
'| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |',
'|:---|:---|:---|:---|:---|',
...processRows,
'',
'## 板书设计',
'',
'```text',
design.boardDesign.trim(),
'```',
'',
'## 教学成效与反思',
'',
'| | |',
'|:---|:---|',
`| **教学成效** | ${escapeCell(design.effectiveness)} |`,
`| **教学反思** | ${escapeCell(design.reflection)} |`,
]
if (design.additionalContent.trim()) {
sections.push('', '## 附加内容', '', design.additionalContent.trim())
}
return `${sections.join('\n')}\n`
}

View File

@@ -0,0 +1,35 @@
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
import { createBookZip } from './zipExporter'
describe('createBookZip', () => {
it('keeps original lesson filenames and adds an order manifest', async () => {
const second = createEmptyTeachingDesign('2.md')
second.topic = '第二课'
const first = createEmptyTeachingDesign('1.md')
first.topic = '第一课'
const blob = await createBookZip([second, first])
const zip = await JSZip.loadAsync(blob)
expect(Object.keys(zip.files)).toEqual(
expect.arrayContaining(['2.md', '1.md', '课程顺序.txt']),
)
await expect(zip.file('课程顺序.txt')?.async('text')).resolves.toContain('1. 2.md')
})
it('disambiguates duplicate filenames', async () => {
const first = createEmptyTeachingDesign('1.md')
first.topic = '第一课甲'
const duplicate = createEmptyTeachingDesign('1.md')
duplicate.topic = '第一课乙'
const blob = await createBookZip([first, duplicate])
const zip = await JSZip.loadAsync(blob)
expect(Object.keys(zip.files)).toEqual(
expect.arrayContaining(['1.md', '1-2.md', '课程顺序.txt']),
)
})
})

View File

@@ -0,0 +1,32 @@
import JSZip from 'jszip'
import type { TeachingDesign } from '../domain/teachingDesign'
import { writeTeachingDesignMarkdown } from './markdownWriter'
export async function createBookZip(designs: readonly TeachingDesign[]): Promise<Blob> {
const zip = new JSZip()
const usedNames = new Set<string>()
const order: string[] = []
designs.forEach((design, index) => {
let filename = design.originalFilename || `${index + 1}.md`
if (usedNames.has(filename)) {
const stem = filename.replace(/\.md$/i, '')
filename = `${stem}-${index + 1}.md`
}
usedNames.add(filename)
order.push(`${index + 1}. ${filename}${design.topic}`)
zip.file(filename, writeTeachingDesignMarkdown(design))
})
zip.file('课程顺序.txt', `${order.join('\n')}\n`)
return zip.generateAsync({ type: 'blob' })
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}

View File

@@ -1,296 +1,594 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #202a33;
background: #edf0f2;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
--green-700: #216447;
--green-600: #2d7a58;
--green-100: #dceee5;
--line: #cfd5da;
--muted: #68747f;
--paper-width: 210mm;
--paper-min-height: 297mm;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
button,
textarea,
input {
font: inherit;
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-height: 100vh;
}
#center {
.app-shell {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
min-height: 100vh;
}
#next-steps {
/* Toolbar */
.workspace-toolbar {
display: flex;
border-top: 1px solid var(--border);
align-items: center;
gap: 16px;
height: 56px;
flex: 0 0 56px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--line);
}
.workspace-toolbar button {
border: 1px solid var(--line);
background: #fff;
border-radius: 6px;
padding: 6px 14px;
color: var(--green-700);
cursor: pointer;
}
.workspace-toolbar button:hover:not(:disabled) {
background: var(--green-100);
border-color: var(--green-600);
}
.workspace-toolbar button:disabled {
color: var(--muted);
border-color: var(--line);
cursor: not-allowed;
opacity: 0.6;
}
.workspace-toolbar-count,
.workspace-toolbar-warning,
.workspace-toolbar-status {
font-size: 14px;
color: var(--muted);
}
.workspace-toolbar-warning {
color: #b65c00;
}
.workspace-toolbar-status--error {
color: #c0392b;
}
.workspace-toolbar-status--saved {
color: var(--green-700);
}
/* Layout */
.workspace-layout {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
/* Sidebar */
.lesson-sidebar {
width: 260px;
flex: 0 0 260px;
background: #fff;
border-right: 1px solid var(--line);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.lesson-sidebar-cover {
border: none;
border-bottom: 1px solid var(--line);
background: none;
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
padding: 12px 16px;
font-weight: 600;
color: var(--green-700);
cursor: pointer;
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
.lesson-sidebar-list {
list-style: none;
margin: 0;
padding: 0;
}
.lesson-sidebar-item {
display: flex;
align-items: stretch;
border-bottom: 1px solid var(--line);
}
.lesson-sidebar-item--active {
background: var(--green-100);
box-shadow: inset 3px 0 0 var(--green-600);
}
.lesson-sidebar-select {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
border: none;
background: none;
text-align: left;
padding: 10px 12px;
cursor: pointer;
min-width: 0;
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
.lesson-sidebar-number {
flex: 0 0 auto;
color: var(--muted);
font-size: 13px;
}
.ticks {
position: relative;
.lesson-sidebar-topic {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-sidebar-badge {
flex: 0 0 auto;
background: #e67e22;
color: #fff;
border-radius: 999px;
font-size: 12px;
line-height: 1.6;
min-width: 1.6em;
text-align: center;
padding: 0 4px;
}
.lesson-sidebar-remove {
flex: 0 0 auto;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
padding: 0 12px;
font-size: 16px;
}
.lesson-sidebar-remove:hover {
color: #c0392b;
}
/* Paper canvas */
.a4-workspace {
flex: 1 1 auto;
overflow: auto;
padding: 16mm;
display: flex;
justify-content: center;
}
.a4-paper {
flex: 0 0 auto;
}
/* A4 page */
.page {
display: block;
width: var(--paper-width);
min-height: var(--paper-min-height);
margin: 0 auto;
padding: 16mm;
background: #fff;
box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12);
}
.print-book .page {
box-shadow: none;
}
/* Cover page */
.cover-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 24px;
}
.cover-title {
font-size: 40px;
font-weight: 700;
letter-spacing: 0.2em;
color: var(--green-700);
margin: 0;
}
.cover-field {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
}
.cover-field-label {
color: var(--muted);
}
.cover-field-value {
min-width: 12em;
text-align: left;
border-bottom: 1px solid var(--line);
}
/* Teaching design page */
.teaching-design-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.design-title {
text-align: center;
font-size: 22px;
font-weight: 700;
color: var(--green-700);
}
.section-heading {
margin: 12px 0 0;
padding-left: 10px;
border-left: 4px solid var(--green-600);
font-size: 16px;
color: var(--green-700);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
.basic-info-table th,
.basic-info-table td,
.process-table th,
.process-table td,
.reflection-table th,
.reflection-table td {
border: 1px solid var(--line);
padding: 6px 8px;
vertical-align: top;
text-align: left;
}
.basic-info-table th,
.process-table th,
.reflection-table th {
background: var(--green-100);
color: var(--green-700);
font-weight: 600;
width: 8em;
}
.process-table th {
width: auto;
}
.objectives-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.objective-row {
display: flex;
align-items: baseline;
gap: 6px;
}
.objective-label {
flex: 0 0 auto;
color: var(--muted);
font-size: 13px;
}
.process-step-name {
display: flex;
flex-direction: column;
gap: 4px;
}
.process-step-actions {
width: 6em;
text-align: center;
}
.process-step-actions button {
border: 1px solid var(--line);
background: #fff;
border-radius: 4px;
padding: 2px 6px;
font-size: 12px;
cursor: pointer;
color: #c0392b;
}
.process-step-actions button:disabled {
color: var(--muted);
cursor: not-allowed;
}
.board-design {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
white-space: pre-wrap;
border: 1px solid var(--line);
border-radius: 4px;
padding: 8px;
min-height: 6em;
}
.warning-summary {
margin: 8px 0 0;
padding: 8px 12px;
border: 1px solid #e6c98b;
background: #fbf3e1;
border-radius: 4px;
color: #8a6116;
font-size: 13px;
}
/* Editable fields */
.editable-field {
font: inherit;
color: inherit;
line-height: 1.6;
}
.editable-text {
display: block;
width: 100%;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 4px;
background: transparent;
resize: none;
overflow: hidden;
}
.editable-text--multiline {
white-space: pre-wrap;
}
.editable-text--static {
display: block;
width: 100%;
white-space: pre-wrap;
padding: 2px 4px;
}
.editable-text:hover {
background: var(--green-100);
}
.editable-text:focus {
background: #fff;
border-color: var(--green-600);
outline: none;
}
.editable-markdown {
width: 100%;
}
.markdown-preview {
min-height: 1.6em;
padding: 2px 4px;
border-radius: 4px;
border: 1px solid transparent;
cursor: text;
}
.markdown-preview--empty {
color: var(--muted);
}
.markdown-preview :first-child {
margin-top: 0;
}
.markdown-preview :last-child {
margin-bottom: 0;
}
.markdown-preview:hover {
background: var(--green-100);
}
.markdown-source {
display: block;
width: 100%;
border: 1px solid var(--green-600);
border-radius: 4px;
padding: 2px 4px;
background: #fff;
resize: none;
overflow: hidden;
outline: none;
}
/* Upload dropzone */
.upload-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
margin: 16mm auto;
max-width: 480px;
min-height: 200px;
border: 2px dashed var(--line);
border-radius: 12px;
background: #fff;
color: var(--muted);
text-align: center;
padding: 24px;
cursor: pointer;
}
.upload-dropzone--drag-over {
border-color: var(--green-600);
background: var(--green-100);
}
.upload-dropzone--compact {
flex-direction: row;
min-height: 0;
margin: 0;
padding: 6px 14px;
border-radius: 6px;
}
.upload-dropzone-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.upload-dropzone-title {
font-size: 16px;
color: #202a33;
margin: 0;
}
.upload-dropzone-hint {
font-size: 13px;
margin: 0;
}
/* Dialogs and notices */
.dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(32, 42, 51, 0.4);
z-index: 10;
}
.dialog {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 420px;
box-shadow: 0 12px 32px rgba(32, 42, 51, 0.25);
}
.dialog-filenames {
margin: 8px 0;
padding-left: 20px;
color: var(--muted);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.dialog-actions button {
border: 1px solid var(--line);
background: #fff;
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
}
.app-notice {
margin: 0;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.app-notice--error {
background: #fdecea;
color: #c0392b;
border-bottom: 1px solid #f5c6c0;
}
.app-notice button {
border: 1px solid currentcolor;
background: none;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
color: inherit;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
/* Responsive */
@media (max-width: 900px) {
.workspace-layout {
flex-direction: column;
}
&::before {
left: 0;
border-left-color: var(--border);
.lesson-sidebar {
width: auto;
flex: 0 0 auto;
max-height: 180px;
border-right: none;
border-bottom: 1px solid var(--line);
}
&::after {
right: 0;
border-right-color: var(--border);
.a4-workspace {
padding: 6mm;
}
.page {
width: 100%;
min-height: auto;
}
}

View File

@@ -2,7 +2,7 @@
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "vitest/globals"],
"types": ["vite/client", "vitest/globals", "node"],
/* Linting */
"noUnusedLocals": true,