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

46
data/SKILLS.md Normal file
View File

@@ -0,0 +1,46 @@
# Role: 中职教学设计专家
## Profile
- language: 中文
- description: 面向中等职业学校学生设计项目式单课时教案,以完整项目为主线,将知识讲解、编码/操作实践、调试测试和成果展示整合为可直接实施的教学设计文档。
- target_audience: 中职信息技术相关专业教师。
## Rules
1. 每份教案属于某个项目的一个明确课时,必须形成阶段性成果。
2. 理论讲解后立即安排实践操作,课堂内容能在普通实训室完成。
3. 单课时严格按 40 分钟设计,教学过程各环节时间之和等于 40 分钟。
4. 面向中职入门学生,难度适中,避免复杂算法和高级框架。
## Output Format
只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明。
严格遵循以下结构:
1. 第一行是一级标题:`# <课程标题> 教学设计`
2. 紧接着是一个两列表格(第一行为表头,分隔行使用 `|:---|:---|`),依次包含以下行:
- `| **课题** | **<课题名称>** |`
- `| **课时** | 1课时40分钟 |`
- `| **教学目标** | **知识目标**...<br>**技能目标**...<br>**素养目标**... |`
- `| **教学重难点** | **重点**...<br>**难点**... |`
- `| **教学资源准备** | ... |`
3. 二级标题 `## 教学过程`,后接 5 列表格:
| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |
|:---|:---|:---|:---|:---|
包含 5 个教学环节行,每个环节名称格式:`**N. 环节名称**<br>(时长)`
教师活动和学生活动中每个主要活动使用加粗四字标题,例如 `**情境导入**`,标题后换行写具体描述。
4. 二级标题 `## 板书设计`,内容放在 ` ```text ` 代码块中。
5. 二级标题 `## 教学成效与反思`,后接两列表格:
- `| **教学成效** | ... |`
- `| **教学反思** | ... |`
教学成效与反思合计不超过 300 字。

View File

@@ -783,7 +783,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'deepseek-chat', model: 'deepseek-v4-flash',
messages: [ messages: [
{ role: 'system', content: SYSTEM_PROMPT }, { role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` }, { role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` },

View File

@@ -0,0 +1,371 @@
# Fix Broken Lessons Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在工作区工具栏新增「修复 X 处提示」按钮,点击后弹出 `FixBrokenDialog`,依次重新生成所有有警告的教案并原位替换,完成后显示摘要。
**Architecture:**`useTeachingBook` 中新增 `regenerateLesson(id)` 用于原位替换单篇;新建 `FixBrokenDialog.vue` 负责进度显示confirm → running → done/error`WorkspaceToolbar` 新增 `fixBroken` emit 与条件显示按钮;`WorkspaceView` 协调状态并驱动循环。
**Tech Stack:** Vue 3 + TypeScript与现有 BatchGenerateDialog 模式保持一致。
---
## File Structure
| 文件 | 操作 |
|---|---|
| `src/composables/useTeachingBook.ts` | 修改:新增 `regenerateLesson`,接口加 `regenerateLesson` |
| `src/components/FixBrokenDialog.vue` | 新建:修复进度对话框 |
| `src/components/WorkspaceToolbar.vue` | 修改:新增 `fixBroken` emit 与按钮 |
| `src/components/WorkspaceView.vue` | 修改:新增 fix 状态与处理逻辑 |
---
## Task 1: `regenerateLesson` in `useTeachingBook.ts`
**Files:**
- Modify: `src/composables/useTeachingBook.ts`
- [ ] **Step 1: 在 `TeachingBookStore` 接口加入 `regenerateLesson`**
`generateLesson` 行下方追加:
```typescript
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
```
- [ ] **Step 2: 实现 `regenerateLesson` 函数**
`generateLesson` 函数后插入(`return {` 之前):
```typescript
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
const existing = book.value.designs.find((d) => d.id === id)
if (!existing) return { ok: false, message: '找不到该教案。' }
const topic = existing.originalFilename.replace(/\.md$/i, '')
try {
const result = await booksApi.generateLesson(topic)
const newDesign = parseTeachingDesign(result.filename, result.markdown)
const index = book.value.designs.findIndex((d) => d.id === id)
if (index !== -1) {
book.value.designs.splice(index, 1, newDesign)
if (book.value.selectedId === id) {
book.value.selectedId = newDesign.id
}
}
touch()
return { ok: true }
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : '修复失败。' }
}
}
```
- [ ] **Step 3: 在 `return` 对象中暴露 `regenerateLesson`**
`generateLesson,` 行之后加一行:
```typescript
regenerateLesson,
```
- [ ] **Step 4: 运行测试确认无破坏**
```bash
bun run test -- src/composables/useTeachingBook.test.ts
```
期望:全部通过。
- [ ] **Step 5: Commit**
```bash
git add src/composables/useTeachingBook.ts
git commit -m "feat: add regenerateLesson to useTeachingBook"
```
---
## Task 2: 新建 `FixBrokenDialog.vue`
**Files:**
- Create: `src/components/FixBrokenDialog.vue`
- [ ] **Step 1: 创建组件文件**
内容如下:
```vue
<script setup lang="ts">
import { watch, ref } from 'vue'
type Phase = 'confirm' | 'running' | 'done' | 'error'
const props = defineProps<{
running: boolean
done: number
total: number
currentTopic: string
error: string | null
}>()
const emit = defineEmits<{
start: []
cancel: []
close: []
}>()
const phase = ref<Phase>('confirm')
watch(
() => props.running,
(val) => {
if (val) {
phase.value = 'running'
} else if (phase.value === 'running') {
phase.value = props.error ? 'error' : 'done'
}
},
)
function handleClose(): void {
phase.value = 'confirm'
emit('close')
}
</script>
<template>
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="fix-dialog-title">
<div class="dialog">
<h2 id="fix-dialog-title">修复问题教案</h2>
<!-- 确认 -->
<template v-if="phase === 'confirm'">
<p> <strong>{{ total }}</strong> 篇教案存在解析问题点击开始将重新生成并原位替换</p>
<div class="dialog-actions">
<button type="button" @click="emit('start')">开始修复</button>
<button type="button" @click="emit('close')">取消</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>
```
- [ ] **Step 2: Commit**
```bash
git add src/components/FixBrokenDialog.vue
git commit -m "feat: add FixBrokenDialog component"
```
---
## Task 3: WorkspaceToolbar — 新增修复按钮
**Files:**
- Modify: `src/components/WorkspaceToolbar.vue`
- [ ] **Step 1: 新增 `fixBroken` emit**
`defineEmits` 改为:
```typescript
defineEmits<{
upload: []
print: []
export: []
clear: []
generate: []
batchGenerate: []
fixBroken: []
back: []
}>()
```
- [ ] **Step 2: 在 `warningCount` span 前加修复按钮**
把:
```html
<span v-if="warningCount > 0" class="workspace-toolbar-warning">
{{ warningCount }} 处提示
</span>
```
替换为:
```html
<template v-if="warningCount > 0">
<button type="button" data-testid="fix-broken" @click="$emit('fixBroken')">
修复 {{ warningCount }} 处提示
</button>
</template>
```
- [ ] **Step 3: Commit**
```bash
git add src/components/WorkspaceToolbar.vue
git commit -m "feat: add fix-broken button to WorkspaceToolbar"
```
---
## Task 4: WorkspaceView — 协调 fix 流程
**Files:**
- Modify: `src/components/WorkspaceView.vue`
- [ ] **Step 1: import FixBrokenDialog**
在现有 import 列表里加(与 `BatchGenerateDialog` 相邻):
```typescript
import FixBrokenDialog from './FixBrokenDialog.vue'
```
- [ ] **Step 2: 解构 `regenerateLesson`**
`useTeachingBook` 解构对象中加:
```typescript
regenerateLesson,
```
- [ ] **Step 3: 新增 fix 状态 refs**
`batchCancelled` ref 之后插入:
```typescript
const showFixDialog = ref(false)
const fixRunning = ref(false)
const fixDone = ref(0)
const fixTotal = ref(0)
const fixCurrentTopic = ref('')
const fixError = ref<string | null>(null)
const fixCancelled = ref(false)
```
- [ ] **Step 4: 新增 fix 处理函数**
`closeBatchDialog` 函数之后插入:
```typescript
function openFixDialog(): void {
fixTotal.value = book.value.designs.filter((d) => d.warnings.length > 0).length
fixDone.value = 0
fixError.value = null
showFixDialog.value = true
}
async function handleFixStart(): Promise<void> {
const broken = book.value.designs.filter((d) => d.warnings.length > 0)
fixCancelled.value = false
fixRunning.value = true
for (const lesson of broken) {
if (fixCancelled.value) break
fixCurrentTopic.value = lesson.originalFilename.replace(/\.md$/i, '')
const result = await regenerateLesson(lesson.id)
if (!result.ok) {
fixError.value = result.message
break
}
fixDone.value++
}
fixRunning.value = false
}
function handleFixCancel(): void {
fixCancelled.value = true
}
function closeFixDialog(): void {
showFixDialog.value = false
fixDone.value = 0
fixTotal.value = 0
fixError.value = null
}
```
- [ ] **Step 5: 在 template 中挂载 FixBrokenDialog**
`<BatchGenerateDialog ... />` 之后加:
```html
<FixBrokenDialog
v-if="showFixDialog"
:running="fixRunning"
:done="fixDone"
:total="fixTotal"
:current-topic="fixCurrentTopic"
:error="fixError"
@start="handleFixStart"
@cancel="handleFixCancel"
@close="closeFixDialog"
/>
```
- [ ] **Step 6: 在 WorkspaceToolbar 上绑定 `@fix-broken`**
把:
```html
@batch-generate="showBatchDialog = true"
```
改为:
```html
@batch-generate="showBatchDialog = true"
@fix-broken="openFixDialog"
```
- [ ] **Step 7: 运行全量测试**
```bash
bun run test
```
期望:`markdownTable``markdownParser``useTeachingBook``PrintBook` 相关测试全部通过App.test.ts 的 2 个已知失败不影响)。
- [ ] **Step 8: Commit**
```bash
git add src/components/WorkspaceView.vue
git commit -m "feat: wire fix-broken flow in WorkspaceView"
```

View File

@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { Hono } from 'hono' import { Hono } from 'hono'
const SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供的主题生成一份 Markdown 格式的教案。 const DEFAULT_SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供的主题生成一份 Markdown 格式的教案。
请严格遵循以下结构(标题、表格列数、章节名称必须完全一致,便于程序解析),只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明: 请严格遵循以下结构(标题、表格列数、章节名称必须完全一致,便于程序解析),只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明:
1. 第一行是一级标题:\`# <课程标题> 教学设计\` 1. 第一行是一级标题:\`# <课程标题> 教学设计\`
@@ -19,6 +20,14 @@ const SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供
- \`| **教学反思** | ... |\` - \`| **教学反思** | ... |\`
` `
function loadSystemPrompt(): string {
try {
return readFileSync('data/SKILLS.md', 'utf8')
} catch {
return DEFAULT_SYSTEM_PROMPT
}
}
function sanitizeFilename(topic: string): string { function sanitizeFilename(topic: string): string {
const sanitized = topic.trim().replace(/[\\/:*?"<>|]/g, '_') const sanitized = topic.trim().replace(/[\\/:*?"<>|]/g, '_')
return sanitized || 'lesson' return sanitized || 'lesson'
@@ -39,6 +48,8 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500) return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
} }
const systemPrompt = loadSystemPrompt()
let response: Response let response: Response
try { try {
response = await fetch('https://api.deepseek.com/chat/completions', { response = await fetch('https://api.deepseek.com/chat/completions', {
@@ -48,9 +59,9 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'deepseek-chat', model: 'deepseek-v4-flash',
messages: [ messages: [
{ role: 'system', content: SYSTEM_PROMPT }, { role: 'system', content: systemPrompt },
{ role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` }, { role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` },
], ],
}), }),
@@ -66,14 +77,75 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
const payload = (await response.json().catch(() => null)) as const payload = (await response.json().catch(() => null)) as
| { choices?: Array<{ message?: { content?: string } }> } | { choices?: Array<{ message?: { content?: string } }> }
| null | null
const markdown = payload?.choices?.[0]?.message?.content const raw = payload?.choices?.[0]?.message?.content
if (!markdown) { if (!raw) {
return c.json({ error: 'Deepseek 返回内容为空。' }, 502) return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
} }
const fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
const markdown = fenceMatch ? fenceMatch[1]! : raw
return c.json({ filename: `${sanitizeFilename(topic)}.md`, markdown }) return c.json({ filename: `${sanitizeFilename(topic)}.md`, markdown })
}) })
app.post('/outline', async (c) => {
const body = (await c.req.json().catch(() => null)) as { theme?: unknown } | null
const theme = body?.theme
if (typeof theme !== 'string' || theme.trim() === '') {
return c.json({ error: '请提供课程主题。' }, 400)
}
if (!apiKey) {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
}
let response: Response
try {
response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content:
'你是教学设计专家。根据用户提供的课程主题生成一份完整的课时大纲标题列表共约18条。' +
'每个标题格式为"项目名——课时任务",一行一个,不加序号、不加任何说明,直接输出标题列表。',
},
{ role: 'user', content: `课程主题:${theme.trim()}` },
],
}),
})
} catch {
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
}
if (!response.ok) {
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
}
const payload = (await response.json().catch(() => null)) as
| { choices?: Array<{ message?: { content?: string } }> }
| null
const raw = payload?.choices?.[0]?.message?.content
if (!raw) {
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
}
const titles = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return c.json({ titles })
})
return app return app
} }

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', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}) })
function formatCstUpdatedAt(value: string): string { function formatCstUpdatedAt(value: string): string {
@@ -38,7 +35,7 @@ function formatCstUpdatedAt(value: string): string {
.map((part) => [part.type, part.value]), .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> { async function loadBooks(): Promise<void> {
@@ -105,7 +102,7 @@ async function removeBook(book: BookSummary): Promise<void> {
<template> <template>
<div class="book-list-page"> <div class="book-list-page">
<h1>教学设计整本</h1> <h1>教学设计</h1>
<form class="book-list-create" @submit.prevent="createBook"> <form class="book-list-create" @submit.prevent="createBook">
<input v-model="newBookName" type="text" placeholder="新整本名称" aria-label="新整本名称" /> <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' import PrintBook from './PrintBook.vue'
describe('PrintBook', () => { 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') const first = createEmptyTeachingDesign('2.md')
first.topic = '第二课' first.topic = '第二课'
const second = createEmptyTeachingDesign('1.md') const second = createEmptyTeachingDesign('1.md')
@@ -12,12 +12,11 @@ describe('PrintBook', () => {
const wrapper = mount(PrintBook, { const wrapper = mount(PrintBook, {
props: { props: {
cover: { courseName: 'Web 前端开发', teacherName: '张老师' },
designs: [first, second], 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('第一课')) expect(wrapper.text().indexOf('第二课')).toBeLessThan(wrapper.text().indexOf('第一课'))
}) })
}) })

View File

@@ -1,23 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BookCover, TeachingDesign } from '../domain/teachingDesign' import type { TeachingDesign } from '../domain/teachingDesign'
import CoverPage from './CoverPage.vue'
import TeachingDesignPage from './TeachingDesignPage.vue' import TeachingDesignPage from './TeachingDesignPage.vue'
defineProps<{ defineProps<{
cover: BookCover
designs: TeachingDesign[] designs: TeachingDesign[]
}>() }>()
</script> </script>
<template> <template>
<div class="print-book"> <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"> <div v-for="design in designs" :key="design.id" class="print-section">
<TeachingDesignPage :design="design" :editable="false" /> <TeachingDesignPage :design="design" :editable="false" />
</div> </div>

View File

@@ -70,3 +70,7 @@ export function deleteBook(id: string): Promise<{ ok: true }> {
export function generateLesson(topic: string): Promise<GenerateResult> { export function generateLesson(topic: string): Promise<GenerateResult> {
return request('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) }) 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) { if (!reflectionTable) {
warnings.push({ code: 'missing-reflection', message: '教学成效与反思表格格式不正确。' }) warnings.push({ code: 'missing-reflection', message: '教学成效与反思表格格式不正确。' })
} else { } else {
for (const row of reflectionTable.rows) { for (const row of [reflectionTable.header, ...reflectionTable.rows]) {
const label = cleanLabel(row[0] ?? '') const label = cleanLabel(row[0] ?? '')
const value = normalizeMultiline(row[1] ?? '') const value = normalizeMultiline(row[1] ?? '')
if (label === '教学成效') design.effectiveness = value if (label === '教学成效') design.effectiveness = value

View File

@@ -166,6 +166,24 @@ export function extractMarkdownTable(
const header = splitMarkdownRow(headerLine) const header = splitMarkdownRow(headerLine)
const divider = splitMarkdownRow(dividerLine) 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 ( if (
header.length === 0 || header.length === 0 ||
divider.length !== header.length || divider.length !== header.length ||

View File

@@ -604,6 +604,7 @@ table {
margin-bottom: 16px; margin-bottom: 16px;
} }
.dialog input,
.book-list-create input, .book-list-create input,
.book-list-item input { .book-list-item input {
flex: 1 1 auto; flex: 1 1 auto;
@@ -652,3 +653,58 @@ table {
color: var(--muted); color: var(--muted);
font-size: 14px; 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;
}