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>