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">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</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 { createApp } from 'vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import './print.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
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,}:?$/
|
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('|')
|
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(
|
export function extractMarkdownTable(
|
||||||
lines: readonly string[],
|
lines: readonly string[],
|
||||||
fromIndex = 0,
|
fromIndex = 0,
|
||||||
): MarkdownTable | null {
|
): MarkdownTable | null {
|
||||||
|
const insideFence = computeFenceMask(lines)
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let start = Math.max(0, fromIndex);
|
let start = Math.max(0, fromIndex);
|
||||||
start < lines.length - 1;
|
start < lines.length - 1;
|
||||||
start++
|
start++
|
||||||
) {
|
) {
|
||||||
|
if (insideFence[start] || insideFence[start + 1]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const headerLine = lines[start]!
|
const headerLine = lines[start]!
|
||||||
const dividerLine = lines[start + 1]!
|
const dividerLine = lines[start + 1]!
|
||||||
|
|
||||||
if (!startsWithPipe(headerLine) || !startsWithPipe(dividerLine)) {
|
if (!isTableRow(headerLine) || !isTableRow(dividerLine)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +177,11 @@ export function extractMarkdownTable(
|
|||||||
const rows: string[][] = []
|
const rows: string[][] = []
|
||||||
let end = start + 1
|
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++
|
end++
|
||||||
rows.push(splitMarkdownRow(lines[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 {
|
:root {
|
||||||
--text: #6b6375;
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
--text-h: #08060d;
|
color: #202a33;
|
||||||
--bg: #fff;
|
background: #edf0f2;
|
||||||
--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-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
--green-700: #216447;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
--green-600: #2d7a58;
|
||||||
|
--green-100: #dceee5;
|
||||||
@media (max-width: 1024px) {
|
--line: #cfd5da;
|
||||||
font-size: 16px;
|
--muted: #68747f;
|
||||||
}
|
--paper-width: 210mm;
|
||||||
|
--paper-min-height: 297mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
box-sizing: border-box;
|
||||||
--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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
button,
|
||||||
h2 {
|
textarea,
|
||||||
font-family: var(--heading);
|
input {
|
||||||
font-weight: 500;
|
font: inherit;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 1126px;
|
min-height: 100vh;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#center {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 25px;
|
min-height: 100vh;
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps {
|
/* Toolbar */
|
||||||
|
.workspace-toolbar {
|
||||||
display: flex;
|
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;
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
& > div {
|
font-weight: 600;
|
||||||
flex: 1 1 0;
|
color: var(--green-700);
|
||||||
padding: 32px;
|
cursor: pointer;
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#docs {
|
.lesson-sidebar-list {
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-sidebar-item {
|
||||||
display: flex;
|
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;
|
gap: 8px;
|
||||||
margin: 32px 0 0;
|
border: none;
|
||||||
|
background: none;
|
||||||
.logo {
|
text-align: left;
|
||||||
height: 18px;
|
padding: 10px 12px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#spacer {
|
.lesson-sidebar-number {
|
||||||
height: 88px;
|
flex: 0 0 auto;
|
||||||
border-top: 1px solid var(--border);
|
color: var(--muted);
|
||||||
@media (max-width: 1024px) {
|
font-size: 13px;
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticks {
|
.lesson-sidebar-topic {
|
||||||
position: relative;
|
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%;
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
&::before,
|
.basic-info-table th,
|
||||||
&::after {
|
.basic-info-table td,
|
||||||
content: '';
|
.process-table th,
|
||||||
position: absolute;
|
.process-table td,
|
||||||
top: -4.5px;
|
.reflection-table th,
|
||||||
border: 5px solid transparent;
|
.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 {
|
.lesson-sidebar {
|
||||||
left: 0;
|
width: auto;
|
||||||
border-left-color: var(--border);
|
flex: 0 0 auto;
|
||||||
|
max-height: 180px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
&::after {
|
|
||||||
right: 0;
|
.a4-workspace {
|
||||||
border-right-color: var(--border);
|
padding: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client", "vitest/globals"],
|
"types": ["vite/client", "vitest/globals", "node"],
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user