update
This commit is contained in:
140
src/components/BatchGenerateDialog.vue
Normal file
140
src/components/BatchGenerateDialog.vue
Normal 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>
|
||||
@@ -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="新整本名称" />
|
||||
|
||||
@@ -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('第一课'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user