update
This commit is contained in:
13
src/App.test.ts
Normal file
13
src/App.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
171
src/App.vue
171
src/App.vue
@@ -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 |
@@ -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 |
37
src/components/A4Workspace.vue
Normal file
37
src/components/A4Workspace.vue
Normal 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>
|
||||
40
src/components/CoverPage.vue
Normal file
40
src/components/CoverPage.vue
Normal 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>
|
||||
15
src/components/EditableMarkdown.test.ts
Normal file
15
src/components/EditableMarkdown.test.ts
Normal 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('**重点**内容')
|
||||
})
|
||||
})
|
||||
79
src/components/EditableMarkdown.vue
Normal file
79
src/components/EditableMarkdown.vue
Normal 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) : ' '"
|
||||
></div>
|
||||
<textarea
|
||||
v-else
|
||||
ref="textareaRef"
|
||||
class="markdown-source"
|
||||
:aria-label="label"
|
||||
:value="modelValue"
|
||||
rows="1"
|
||||
@input="onInput"
|
||||
@blur="deactivate"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
16
src/components/EditableText.test.ts
Normal file
16
src/components/EditableText.test.ts
Normal 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('课题')
|
||||
})
|
||||
})
|
||||
59
src/components/EditableText.vue
Normal file
59
src/components/EditableText.vue
Normal 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>
|
||||
@@ -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>
|
||||
29
src/components/ImportConflictDialog.vue
Normal file
29
src/components/ImportConflictDialog.vue
Normal 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>
|
||||
18
src/components/LessonSidebar.test.ts
Normal file
18
src/components/LessonSidebar.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
75
src/components/LessonSidebar.vue
Normal file
75
src/components/LessonSidebar.vue
Normal 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>
|
||||
23
src/components/PrintBook.test.ts
Normal file
23
src/components/PrintBook.test.ts
Normal 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('第一课'))
|
||||
})
|
||||
})
|
||||
25
src/components/PrintBook.vue
Normal file
25
src/components/PrintBook.vue
Normal 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>
|
||||
24
src/components/RestoreDraftDialog.vue
Normal file
24
src/components/RestoreDraftDialog.vue
Normal 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>
|
||||
18
src/components/TeachingDesignPage.test.ts
Normal file
18
src/components/TeachingDesignPage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
286
src/components/TeachingDesignPage.vue
Normal file
286
src/components/TeachingDesignPage.vue
Normal 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>
|
||||
16
src/components/UploadDropzone.test.ts
Normal file
16
src/components/UploadDropzone.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
77
src/components/UploadDropzone.vue
Normal file
77
src/components/UploadDropzone.vue
Normal 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>
|
||||
40
src/components/WorkspaceToolbar.vue
Normal file
40
src/components/WorkspaceToolbar.vue
Normal 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>
|
||||
38
src/composables/useTeachingBook.test.ts
Normal file
38
src/composables/useTeachingBook.test.ts
Normal 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())
|
||||
})
|
||||
})
|
||||
226
src/composables/useTeachingBook.ts
Normal file
226
src/composables/useTeachingBook.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
85
src/print.css
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/services/bookStorage.test.ts
Normal file
26
src/services/bookStorage.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
29
src/services/bookStorage.ts
Normal file
29
src/services/bookStorage.ts
Normal 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)
|
||||
}
|
||||
35
src/services/markdownParser.corpus.test.ts
Normal file
35
src/services/markdownParser.corpus.test.ts
Normal 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$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
82
src/services/markdownParser.test.ts
Normal file
82
src/services/markdownParser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
282
src/services/markdownParser.ts
Normal file
282
src/services/markdownParser.ts
Normal 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
|
||||
}
|
||||
12
src/services/markdownRenderer.ts
Normal file
12
src/services/markdownRenderer.ts
Normal 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 || '')
|
||||
}
|
||||
@@ -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]!))
|
||||
}
|
||||
|
||||
46
src/services/markdownWriter.test.ts
Normal file
46
src/services/markdownWriter.test.ts
Normal 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. 导入**()')
|
||||
})
|
||||
})
|
||||
76
src/services/markdownWriter.ts
Normal file
76
src/services/markdownWriter.ts
Normal 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`
|
||||
}
|
||||
35
src/services/zipExporter.test.ts
Normal file
35
src/services/zipExporter.test.ts
Normal 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']),
|
||||
)
|
||||
})
|
||||
})
|
||||
32
src/services/zipExporter.ts
Normal file
32
src/services/zipExporter.ts
Normal 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)
|
||||
}
|
||||
812
src/style.css
812
src/style.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user