update
This commit is contained in:
46
data/SKILLS.md
Normal file
46
data/SKILLS.md
Normal 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 字。
|
||||||
@@ -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()}"生成一份教案。` },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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',
|
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="新整本名称" />
|
||||||
|
|||||||
@@ -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('第一课'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user