update
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user