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>
|
||||
|
||||
Reference in New Issue
Block a user