This commit is contained in:
2026-06-15 23:14:16 -06:00
parent 4660d10829
commit 6e1263feac
12 changed files with 719 additions and 25 deletions

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import * as booksApi from '../services/booksApi'
type Phase = 'theme' | 'outline-loading' | 'outline' | 'running' | 'done' | 'error'
const props = defineProps<{
running: boolean
done: number
total: number
currentTopic: string
error: string | null
}>()
const emit = defineEmits<{
start: [topics: string[]]
cancel: []
close: []
}>()
const phase = ref<Phase>('theme')
const theme = ref('')
const outlineText = ref('')
const outlineError = ref<string | null>(null)
const parsedTopics = computed(() =>
outlineText.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
)
watch(
() => props.running,
(val) => {
if (!val && phase.value === 'running') {
phase.value = props.error ? 'error' : 'done'
}
},
)
async function handleGenerateOutline(): Promise<void> {
if (!theme.value.trim()) return
phase.value = 'outline-loading'
outlineError.value = null
try {
const result = await booksApi.generateOutline(theme.value.trim())
outlineText.value = result.titles.join('\n')
phase.value = 'outline'
} catch (error) {
outlineError.value = error instanceof Error ? error.message : '生成失败。'
phase.value = 'theme'
}
}
function handleStart(): void {
if (parsedTopics.value.length === 0) return
phase.value = 'running'
emit('start', parsedTopics.value)
}
function handleClose(): void {
phase.value = 'theme'
theme.value = ''
outlineText.value = ''
outlineError.value = null
emit('close')
}
</script>
<template>
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="batch-generate-title">
<div class="dialog batch-dialog">
<h2 id="batch-generate-title">批量生成教案</h2>
<!-- 第一步输入主题 -->
<template v-if="phase === 'theme'">
<p>输入课程主题AI 先生成大纲再依次生成每篇教案</p>
<p v-if="outlineError" class="app-notice app-notice--error" role="alert">{{ outlineError }}</p>
<input
v-model="theme"
type="text"
placeholder="例如Web 前端开发项目式教学"
@keydown.enter="handleGenerateOutline"
/>
<div class="dialog-actions">
<button type="button" :disabled="!theme.trim()" @click="handleGenerateOutline">生成大纲</button>
<button type="button" @click="handleClose">取消</button>
</div>
</template>
<!-- 大纲生成中 -->
<template v-else-if="phase === 'outline-loading'">
<p>正在生成大纲</p>
</template>
<!-- 第二步确认/编辑大纲 -->
<template v-else-if="phase === 'outline'">
<p>AI 已生成以下大纲可直接编辑后开始生成</p>
<textarea v-model="outlineText" class="batch-topics-input" rows="12" />
<p class="batch-topics-count"> {{ parsedTopics.length }} 个课题</p>
<div class="dialog-actions">
<button type="button" :disabled="parsedTopics.length === 0" @click="handleStart">开始生成</button>
<button type="button" @click="phase = 'theme'">重新输入</button>
</div>
</template>
<!-- 生成中 -->
<template v-else-if="phase === 'running'">
<p class="batch-progress-label">
正在生成第 <strong>{{ done + 1 }}</strong> / {{ total }}
</p>
<p class="batch-current-topic">{{ currentTopic }}</p>
<div class="batch-progress-bar">
<div class="batch-progress-fill" :style="{ width: `${(done / total) * 100}%` }" />
</div>
<div class="dialog-actions">
<button type="button" @click="emit('cancel')">停止</button>
</div>
</template>
<!-- 出错 -->
<template v-else-if="phase === 'error'">
<p class="app-notice app-notice--error" role="alert">{{ error }}</p>
<p>已生成 {{ done }} / {{ total }} 生成中止</p>
<div class="dialog-actions">
<button type="button" @click="handleClose">关闭</button>
</div>
</template>
<!-- 完成含主动停止 -->
<template v-else-if="phase === 'done'">
<p>已生成 <strong>{{ done }}</strong> / {{ total }} 篇教案</p>
<div class="dialog-actions">
<button type="button" @click="handleClose">关闭</button>
</div>
</template>
</div>
</div>
</template>

View File

@@ -22,9 +22,6 @@ const cstDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
function formatCstUpdatedAt(value: string): string {
@@ -38,7 +35,7 @@ function formatCstUpdatedAt(value: string): string {
.map((part) => [part.type, part.value]),
)
return `${parts.year}/${parts.month}/${parts.day} ${parts.hour}:${parts.minute} CST`
return `${parts.year}/${parts.month}/${parts.day}`
}
async function loadBooks(): Promise<void> {
@@ -105,7 +102,7 @@ async function removeBook(book: BookSummary): Promise<void> {
<template>
<div class="book-list-page">
<h1>教学设计整本</h1>
<h1>教学设计</h1>
<form class="book-list-create" @submit.prevent="createBook">
<input v-model="newBookName" type="text" placeholder="新整本名称" aria-label="新整本名称" />

View File

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

View File

@@ -1,23 +1,14 @@
<script setup lang="ts">
import type { BookCover, TeachingDesign } from '../domain/teachingDesign'
import CoverPage from './CoverPage.vue'
import type { TeachingDesign } from '../domain/teachingDesign'
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>

View File

@@ -70,3 +70,7 @@ export function deleteBook(id: string): Promise<{ ok: true }> {
export function generateLesson(topic: string): Promise<GenerateResult> {
return request('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) })
}
export function generateOutline(theme: string): Promise<{ titles: string[] }> {
return request('/api/generate/outline', { method: 'POST', body: JSON.stringify({ theme }) })
}

View File

@@ -247,7 +247,7 @@ export function parseTeachingDesign(filename: string, markdown: string): Teachin
if (!reflectionTable) {
warnings.push({ code: 'missing-reflection', message: '教学成效与反思表格格式不正确。' })
} else {
for (const row of reflectionTable.rows) {
for (const row of [reflectionTable.header, ...reflectionTable.rows]) {
const label = cleanLabel(row[0] ?? '')
const value = normalizeMultiline(row[1] ?? '')
if (label === '教学成效') design.effectiveness = value

View File

@@ -166,6 +166,24 @@ export function extractMarkdownTable(
const header = splitMarkdownRow(headerLine)
const divider = splitMarkdownRow(dividerLine)
// Handle separator-first tables (no header row: starts with |:---|:---|)
if (header.length > 0 && header.every((cell) => dividerCellPattern.test(cell))) {
const rows: string[][] = []
let end = start
while (end + 1 < lines.length && !insideFence[end + 1] && isTableRow(lines[end + 1]!)) {
end++
const row = splitMarkdownRow(lines[end]!)
if (!row.every((cell) => dividerCellPattern.test(cell))) {
rows.push(row)
}
}
if (rows.length > 0) {
return { start, end, header: [], rows }
}
}
if (
header.length === 0 ||
divider.length !== header.length ||

View File

@@ -604,6 +604,7 @@ table {
margin-bottom: 16px;
}
.dialog input,
.book-list-create input,
.book-list-item input {
flex: 1 1 auto;
@@ -652,3 +653,58 @@ table {
color: var(--muted);
font-size: 14px;
}
/* Batch generate dialog */
.batch-dialog {
width: 480px;
}
.batch-topics-input {
display: block;
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 12px;
resize: vertical;
margin-top: 8px;
}
.batch-topics-input:focus {
outline: none;
border-color: var(--green-600);
}
.batch-topics-count {
font-size: 13px;
color: var(--muted);
margin: 6px 0 0;
}
.batch-progress-label {
font-size: 16px;
margin: 8px 0 4px;
}
.batch-current-topic {
color: var(--muted);
font-size: 14px;
margin: 0 0 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-progress-bar {
height: 6px;
background: var(--line);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.batch-progress-fill {
height: 100%;
background: var(--green-600);
border-radius: 3px;
transition: width 0.3s ease;
}