Compare commits
13 Commits
d44f0e0c7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9aeebdaf | |||
| c44e8174c4 | |||
| a427bba8c8 | |||
| b18bde8d2a | |||
| 12acaae38b | |||
| c7e4dd02bc | |||
| 6d54750b37 | |||
| 7da3f15ecd | |||
| 5723470f65 | |||
| 78a082b273 | |||
| 462714f45e | |||
| 1fb8fe6680 | |||
| 4e23d3b163 |
98
docs/book-import-spec.md
Normal file
98
docs/book-import-spec.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 从书籍生成教案 — 功能规格(Spec)
|
||||||
|
|
||||||
|
## 背景与目标
|
||||||
|
|
||||||
|
当前 FakeTeachingDesign(Vue 3 + Hono/Bun + SQLite,DeepSeek `deepseek-v4-flash`)只能:
|
||||||
|
- **生成一篇**:手输一个主题 → `/api/generate` 生成单篇教案。
|
||||||
|
- **批量生成**:输入主题 → `/api/generate/outline` 生成标题列表 → 逐篇生成。
|
||||||
|
|
||||||
|
缺口:无法基于**真实教材内容**生成教案。本功能让用户提供一本教材的**目录**与**正文**,由 AI 据此划分课时,用户确认后批量生成,且生成每篇时把对应章节正文作为上下文喂给 AI,产出更贴合教材的教案。
|
||||||
|
|
||||||
|
## 输入(用户提供)
|
||||||
|
|
||||||
|
1. **目录**:多行文本,用户**粘贴**到文本框。每行一个标题,给出课程的**顺序**与**完整标题清单**。
|
||||||
|
2. **正文压缩包**:用户**上传**一个 ZIP,内含多份 `.md`,**文件名 = 目录中的标题**,文件内容 = 该标题对应的正文。
|
||||||
|
|
||||||
|
## 核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
粘贴目录(多行) + 上传 ZIP
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
前端解压(JSZip) → {标题 → 正文} 映射 → 与目录每行按文件名匹配
|
||||||
|
│ 得到 entries: [{index, title, content, charCount, matched}]
|
||||||
|
▼
|
||||||
|
POST /api/generate/lessons-from-book { entries: [{index,title,charCount}] }
|
||||||
|
│ AI 据目录与各条体量「再划分课时」(可合并/拆分)
|
||||||
|
▼
|
||||||
|
返回 lessons: [{title, sourceIndexes:[...]}]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
预览/编辑课时列表(改标题、删行)
|
||||||
|
│ 前端按 sourceIndexes 拼出每课时的正文上下文
|
||||||
|
▼
|
||||||
|
批量生成: 每课时 POST /api/generate { topic, content }
|
||||||
|
│ content = 该课时所辖章节正文(拼接并截断 ~6000 字)
|
||||||
|
▼
|
||||||
|
逐篇 parseTeachingDesign → 追加进 book.designs(复用现有有序追加/进度/取消)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键决策
|
||||||
|
|
||||||
|
- **解压与匹配全在前端**:`JSZip` 已是项目依赖(`src/services/zipExporter.ts` 在用)。浏览器内 `JSZip.loadAsync` 解压,无需 PDF 解析、OCR、服务端缓存、multipart 上传——发给后端只是 JSON。
|
||||||
|
- **AI 再划分课时**:目录条目(章/节)不一定 1:1 对应课时。AI 据标题 + 各条字数(`charCount`,廉价信号,不传全文)决定合并小节、拆分大章。
|
||||||
|
- **正文随生成请求直传**:每课时生成时把拼好的正文作为 `content` 字段直接 POST,无服务端缓存、无 `parseId`。
|
||||||
|
- **纯增量扩展 `/api/generate`**:新增可选 `content`,老调用方仍只传 `{topic}`,现有测试不变。
|
||||||
|
|
||||||
|
## 标题↔文件名 匹配规则
|
||||||
|
|
||||||
|
ZIP 内文件名可能带 `.md` 后缀、含被替换的非法字符(现有 `sanitizeFilename` 把 `[\\/:*?"<>|]` 换成 `_`)。匹配时对**两侧都归一化**后比对:
|
||||||
|
1. 去掉 `.md` 后缀;
|
||||||
|
2. `trim`,把连续空白折叠为单空格;
|
||||||
|
3. 把 `[\\/:*?"<>|]` 替换为 `_`(与服务端 `sanitizeFilename` 一致)。
|
||||||
|
归一化后字符串相等即匹配。匹配不到的目录行 `matched=false`、`content=''`(仍可生成,但只靠标题)。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
### `POST /api/generate/lessons-from-book`(新增,挂 `/api/generate/*`,复用现有鉴权)
|
||||||
|
请求:
|
||||||
|
```json
|
||||||
|
{ "entries": [ { "index": 0, "title": "第1章 C# 入门", "charCount": 5821 } ] }
|
||||||
|
```
|
||||||
|
- 系统提示要求 AI 据目录把全书拆为单课时课题,每条对应一个或多个目录条目,难度由浅入深、覆盖全部条目,复杂条目可拆多课时;**仅输出 JSON**,形如 `{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}`。
|
||||||
|
- 复用现有 `fenceMatch`(`generate.ts:86-87`)剥代码块围栏;`JSON.parse` 后校验每行 `title:string` + `sourceIndexes:number[]`(索引在 entries 范围内),丢弃非法行。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{ "lessons": [ { "title": "C# 入门——搭建环境运行首个程序", "sourceIndexes": [0] } ] }
|
||||||
|
```
|
||||||
|
错误:400 缺 `entries`;500 无 key;502 DeepSeek 失败/空。
|
||||||
|
|
||||||
|
### `POST /api/generate`(扩展,向后兼容)
|
||||||
|
- 读可选 `content:string`。有则把它作为编写依据拼进 user 消息(提炼要点、不照抄),系统提示不变,返回 `{filename, markdown}` 不变。
|
||||||
|
- 无 `content` 时请求体与现状完全一致。
|
||||||
|
|
||||||
|
## 前端
|
||||||
|
|
||||||
|
- **菜单**:`GenerateMenuButton.vue` 加「从书籍生成」(`data-testid="book-import"`),经 `WorkspaceToolbar` → `WorkspaceView`。
|
||||||
|
- **对话框** `BookImportDialog.vue`(仿 `BatchGenerateDialog.vue` 多阶段):
|
||||||
|
- `input`:目录粘贴 `<textarea>` + `UploadDropzone`(accept `.zip`,单文件)。校验:目录非空 + 已传 ZIP。点「解析」→ 前端解压匹配,展示匹配概况(如「20 行目录,18 行匹配到正文,2 行缺正文」)。
|
||||||
|
- `division-loading`:调 `divideLessonsFromBook(entries)`,提示「AI 正在划分课时…」。
|
||||||
|
- `preview`:可编辑课时列表(改标题、删行);显示每课时所辖章节标题。「开始生成」emit `start:[lessons]`(含拼好的 content)。
|
||||||
|
- `running / done / error`:与 batch 相同(进度条 + 停止)。
|
||||||
|
- **解压匹配工具** `src/services/bookImport.ts`:`parseZip(file)` 用 JSZip 读所有 `.md` → `Map<归一化标题, 正文>`;`matchToc(tocLines, map)` → `entries[]`。
|
||||||
|
- **API** `src/services/booksApi.ts`:`generateLesson` 增加 `content` 选项(兼容旧 `AbortSignal` 第二参);新增 `divideLessonsFromBook(entries)`;新增类型 `BookEntry`、`ProposedLesson`。
|
||||||
|
- **Store** `useTeachingBook.ts`:新增 `generateLessonsFromBook(lessons:{title,content}[], options)`,复用 `generateLessons`(`:199-265`)的 worker 池(并发 3、有序追加、可取消、进度回调),每个 worker 调 `generateLesson(title,{content,signal})`。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不支持 PDF / Word / 扫描件 / OCR(仅 ZIP-of-md + 粘贴目录)。
|
||||||
|
- 不做章节↔课时映射的高级可视化编辑(预览仅改标题、删行)。
|
||||||
|
- 不持久化上传的原始 ZIP/目录(仅用于一次生成)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- **服务端 `bun test server`**:`/lessons-from-book` 从 mock JSON 解出 `lessons`、畸形 JSON 优雅处理、400 缺 entries;`POST /` 带 `content` 时 `content` 出现在捕获的 DeepSeek 请求体,且不带时请求体不变(向后兼容)。
|
||||||
|
- **前端 `vitest run`**:`bookImport.ts` 解压+匹配(含归一化、缺正文)单测;`booksApi` 的 `generateLesson('x')` 仍发 `{topic:'x'}`、带 content 时附加;`divideLessonsFromBook` 发 `{entries}`;`BookImportDialog` 走 input→preview→start 流程,断言 `start` 载荷;`useTeachingBook.generateLessonsFromBook` 有序追加 + 取消。
|
||||||
|
- **类型检查**:`npx vue-tsc -b`。
|
||||||
|
- **手动 E2E**:粘贴一份目录 + 传一个 md 的 ZIP → 看匹配概况 → 确认 AI 课时 → 改标题/删行 → 开始生成 → 教案追加、内容贴合章节;负向:目录空 / 传非 ZIP / 全部不匹配 → 友好提示。
|
||||||
662
docs/superpowers/plans/2026-06-22-merge-print-export-buttons.md
Normal file
662
docs/superpowers/plans/2026-06-22-merge-print-export-buttons.md
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
# Merge Print/Export Buttons Into Dropdown 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:** Replace the two separate "打印整册" / "导出 MD" toolbar buttons with a single "导出 ▾" button that opens a dropdown menu offering both actions, and extract the dropdown logic that's now used by two buttons into a shared `ToolbarMenuButton.vue` component.
|
||||||
|
|
||||||
|
**Architecture:** Extract `ToolbarMenuButton.vue` — a generic dropdown wrapper that owns open/close state, outside-click/Escape dismissal, and `disabled` handling, exposing menu items via a scoped default slot (`{ close }`). Refactor the existing `GenerateMenuButton.vue` to be a thin wrapper around it (no behavior change, same public DOM contract). Add a new `ExportMenuButton.vue`, also a thin wrapper, for "打印整册"/"导出 MD". `WorkspaceToolbar.vue` swaps its two standalone buttons for `ExportMenuButton`; `WorkspaceView.vue` requires no changes.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 (`<script setup lang="ts">`), Vitest + `@vue/test-utils`, plain CSS in `src/style.css` (no UI component library).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Button label for the merged print/export button is exactly "导出 ▾".
|
||||||
|
- Clicking the main button only toggles the dropdown; it never directly fires `print` or `export`.
|
||||||
|
- The two menu item buttons keep `data-testid="print"` and `data-testid="export"`.
|
||||||
|
- The new toggle button uses `data-testid="export-menu-toggle"`.
|
||||||
|
- The merged button is disabled as a whole (native `disabled` attribute) when `lessonCount === 0` — matching the existing per-button disabled condition on "打印整册"/"导出 MD".
|
||||||
|
- `GenerateMenuButton.vue`'s public DOM contract (testids `generate-menu-toggle`, `generate`, `batch-generate`, label "生成教案 ▾", events `generate`/`batchGenerate`) must NOT change as an observable behavior — internal implementation may change.
|
||||||
|
- `WorkspaceToolbar.vue`'s `defineEmits` block and `WorkspaceView.vue`'s event listeners must NOT change.
|
||||||
|
- Reuse existing CSS design tokens only (`var(--line)`, `var(--radius-md)`, `var(--green-100)`, `var(--green-700)`) — no new color/radius values.
|
||||||
|
- No changes to generation/print/export business logic, `BatchGenerateDialog.vue`, or `GenerateLessonDialog.vue`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create `ToolbarMenuButton.vue` with tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/ToolbarMenuButton.vue`
|
||||||
|
- Create: `src/components/ToolbarMenuButton.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ToolbarMenuButton` component with:
|
||||||
|
```ts
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
toggleTestid: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
```
|
||||||
|
No emits — it has no domain knowledge of what actions exist. Default slot receives scope `{ close: () => void }`. Consumers render their own `<li>` menu items inside the slot and call `close()` after emitting their own event.
|
||||||
|
- DOM contract later tasks rely on:
|
||||||
|
- Toggle button: `button[:data-testid="toggleTestid"]` (the literal value passed via the `toggleTestid` prop)
|
||||||
|
- Root wrapper: `div.toolbar-menu`
|
||||||
|
- Menu list (only in DOM while open): `ul.toolbar-menu-list`, rendered via `<slot :close="close" />` inside it
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `src/components/ToolbarMenuButton.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
|
function mountMenu(props: { label: string; toggleTestid: string; disabled?: boolean }) {
|
||||||
|
return mount(ToolbarMenuButton, {
|
||||||
|
props,
|
||||||
|
attachTo: document.body,
|
||||||
|
slots: {
|
||||||
|
default: `<template #default="{ close }">
|
||||||
|
<li role="menuitem"><button data-testid="item-a" @click="close">Item A</button></li>
|
||||||
|
<li role="menuitem"><button data-testid="item-b" @click="close">Item B</button></li>
|
||||||
|
</template>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ToolbarMenuButton', () => {
|
||||||
|
it('renders the toggle button with the given label and closed menu by default', () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').text()).toBe('导出 ▾')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the menu when the toggle button is clicked', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-testid="item-a"]').isVisible()).toBe(true)
|
||||||
|
expect(wrapper.get('[data-testid="item-b"]').isVisible()).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when a slot item calls close', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="item-a"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when clicking outside the component', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
document.body.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when Escape is pressed', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('div.toolbar-menu').trigger('keydown', { key: 'Escape' })
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the toggle button and never opens the menu when disabled is true', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle', disabled: true })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/ToolbarMenuButton.test.ts`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./ToolbarMenuButton.vue"` (file doesn't exist yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the component implementation**
|
||||||
|
|
||||||
|
Create `src/components/ToolbarMenuButton.vue`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
toggleTestid: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!rootRef.value) return
|
||||||
|
if (!rootRef.value.contains(event.target as Node)) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="toolbar-menu" @keydown="handleKeydown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:data-testid="toggleTestid"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
<ul v-if="open" class="toolbar-menu-list" role="menu">
|
||||||
|
<slot :close="close" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: a native `disabled` button never dispatches `click` events, so `toggle()` cannot run while `disabled` is true — no extra guard needed in `toggle()` itself.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/ToolbarMenuButton.test.ts`
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/ToolbarMenuButton.vue src/components/ToolbarMenuButton.test.ts
|
||||||
|
git commit -m "feat: add generic ToolbarMenuButton dropdown component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Refactor `GenerateMenuButton.vue` to use `ToolbarMenuButton`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/GenerateMenuButton.vue` (full rewrite, ~21 lines)
|
||||||
|
- Modify: `src/components/GenerateMenuButton.test.ts:54` (one assertion)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ToolbarMenuButton` from Task 1 — props `label`, `toggleTestid`, `disabled?`; default slot scope `{ close }`.
|
||||||
|
- Produces: `GenerateMenuButton` keeps emitting `generate` / `batchGenerate` exactly as before, with identical DOM contract (`generate-menu-toggle`, `generate`, `batch-generate`, label "生成教案 ▾"). No prior consumer of `GenerateMenuButton` (i.e. `WorkspaceToolbar.vue`) needs to change.
|
||||||
|
|
||||||
|
This task is a pure refactor: the existing `GenerateMenuButton.test.ts` (6 tests, unchanged behavior asserted through testids) must still pass except for the one assertion that inspects the internal root class name.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the one test assertion that touches internal implementation**
|
||||||
|
|
||||||
|
In `src/components/GenerateMenuButton.test.ts`, line 54 currently reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await wrapper.get('div.generate-menu').trigger('keydown', { key: 'Escape' })
|
||||||
|
```
|
||||||
|
|
||||||
|
Change it to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await wrapper.get('div.toolbar-menu').trigger('keydown', { key: 'Escape' })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the existing test to verify it fails for the expected reason**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/GenerateMenuButton.test.ts`
|
||||||
|
Expected: FAIL on `closes the menu when Escape is pressed` — `div.toolbar-menu` does not exist yet (component still renders `div.generate-menu`). The other 5 tests still pass at this point since the component hasn't changed yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite the component to wrap `ToolbarMenuButton`**
|
||||||
|
|
||||||
|
Replace the full contents of `src/components/GenerateMenuButton.vue` with:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
generate: []
|
||||||
|
batchGenerate: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToolbarMenuButton label="生成教案 ▾" toggle-testid="generate-menu-toggle">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="batch-generate"
|
||||||
|
@click="
|
||||||
|
emit('batchGenerate')
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
批量生成
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="generate"
|
||||||
|
@click="
|
||||||
|
emit('generate')
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
生成一篇
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ToolbarMenuButton>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/GenerateMenuButton.test.ts`
|
||||||
|
Expected: PASS (6 tests) — identical behavior, now backed by `ToolbarMenuButton`.
|
||||||
|
|
||||||
|
Also run the consumers to confirm no regression:
|
||||||
|
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts src/components/WorkspaceView.test.ts`
|
||||||
|
Expected: same pass/fail counts as before this task (the 3 pre-existing unrelated `WorkspaceView.test.ts` failures about file-upload placeholder text and batch-generate concurrency ordering are unaffected; `WorkspaceToolbar.test.ts` fully passes).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/GenerateMenuButton.vue src/components/GenerateMenuButton.test.ts
|
||||||
|
git commit -m "refactor: rebuild GenerateMenuButton on top of ToolbarMenuButton"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `ExportMenuButton.vue` with tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/ExportMenuButton.vue`
|
||||||
|
- Create: `src/components/ExportMenuButton.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ToolbarMenuButton` from Task 1.
|
||||||
|
- Produces: `ExportMenuButton` component:
|
||||||
|
```ts
|
||||||
|
defineProps<{ disabled?: boolean }>()
|
||||||
|
defineEmits<{ print: []; export: [] }>()
|
||||||
|
```
|
||||||
|
DOM contract: toggle `button[data-testid="export-menu-toggle"]` with label "导出 ▾"; menu items `button[data-testid="print"]` ("打印整册") and `button[data-testid="export"]` ("导出 MD"), only present while the dropdown is open.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `src/components/ExportMenuButton.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ExportMenuButton from './ExportMenuButton.vue'
|
||||||
|
|
||||||
|
describe('ExportMenuButton', () => {
|
||||||
|
it('renders the toggle button with the menu closed by default', () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, { attachTo: document.body })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').text()).toContain('导出')
|
||||||
|
expect(wrapper.find('[data-testid="print"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="export"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits print and closes the menu when "打印整册" is clicked', async () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, { attachTo: document.body })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="print"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('print')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('[data-testid="print"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits export and closes the menu when "导出 MD" is clicked', async () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, { attachTo: document.body })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="export"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('export')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('[data-testid="export"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the toggle button when disabled prop is true', () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, {
|
||||||
|
props: { disabled: true },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'),
|
||||||
|
).toBeDefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the toggle button enabled when disabled prop is false', () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, {
|
||||||
|
props: { disabled: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'),
|
||||||
|
).toBeUndefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/ExportMenuButton.test.ts`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./ExportMenuButton.vue"` (file doesn't exist yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the component implementation**
|
||||||
|
|
||||||
|
Create `src/components/ExportMenuButton.vue`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
|
defineProps<{ disabled?: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
print: []
|
||||||
|
export: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToolbarMenuButton label="导出 ▾" toggle-testid="export-menu-toggle" :disabled="disabled">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="print"
|
||||||
|
@click="
|
||||||
|
emit('print')
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
打印整册
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="export"
|
||||||
|
@click="
|
||||||
|
emit('export')
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
导出 MD
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ToolbarMenuButton>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/ExportMenuButton.test.ts`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/ExportMenuButton.vue src/components/ExportMenuButton.test.ts
|
||||||
|
git commit -m "feat: add ExportMenuButton dropdown component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Wire `ExportMenuButton` into `WorkspaceToolbar.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/WorkspaceToolbar.vue`
|
||||||
|
- Modify: `src/components/WorkspaceToolbar.test.ts`
|
||||||
|
- Modify: `src/components/WorkspaceView.test.ts:207`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ExportMenuButton` from Task 3 — props `disabled?`, events `print`/`export`, testids `export-menu-toggle`/`print`/`export`.
|
||||||
|
- Produces: `WorkspaceToolbar` keeps emitting `print` and `export` exactly as before — no change to its own `defineEmits` block or to how `WorkspaceView.vue` listens to it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
In `src/components/WorkspaceToolbar.test.ts`, replace the `disables print, export and clear when there are no lessons` test (currently the last test in the file, asserting on `data-testid="print"` / `"export"` directly) with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('disables the export menu toggle and clear button when there are no lessons', () => {
|
||||||
|
const wrapper = mountToolbar(0)
|
||||||
|
expect(
|
||||||
|
wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'),
|
||||||
|
).toBeDefined()
|
||||||
|
expect(wrapper.get('button[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits print when the print menu item is clicked', async () => {
|
||||||
|
const wrapper = mountToolbar(3)
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="print"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('print')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits export when the export menu item is clicked', async () => {
|
||||||
|
const wrapper = mountToolbar(3)
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="export"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('export')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep every other existing test in the file unchanged.)
|
||||||
|
|
||||||
|
In `src/components/WorkspaceView.test.ts`, line 207 currently reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await wrapper.get('[data-testid="export"]').trigger('click')
|
||||||
|
```
|
||||||
|
|
||||||
|
Change it to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await wrapper.get('[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('[data-testid="export"]').trigger('click')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts src/components/WorkspaceView.test.ts`
|
||||||
|
Expected: FAIL — `export-menu-toggle` testid not found (toolbar still has the old two standalone buttons).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the toolbar template**
|
||||||
|
|
||||||
|
In `src/components/WorkspaceToolbar.vue`, add the import in `<script setup>` alongside the existing `GenerateMenuButton` import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import ExportMenuButton from './ExportMenuButton.vue'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the two lines:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
||||||
|
<button type="button" data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 MD</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ExportMenuButton :disabled="lessonCount === 0" @print="$emit('print')" @export="$emit('export')" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts src/components/WorkspaceView.test.ts`
|
||||||
|
Expected: PASS for `WorkspaceToolbar.test.ts` (all tests); `WorkspaceView.test.ts` shows the same pre-existing 3 unrelated failures as before this task (file-upload placeholder text, batch-generate concurrency ordering) and no new failures.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/WorkspaceToolbar.vue src/components/WorkspaceToolbar.test.ts src/components/WorkspaceView.test.ts
|
||||||
|
git commit -m "feat: merge print/export buttons into a single dropdown in WorkspaceToolbar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Rename CSS classes from `generate-menu` to generic `toolbar-menu`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/style.css:273-307` (the three `.generate-menu*` rules)
|
||||||
|
- Modify: `src/style.css:778-780` (the mobile media-query rule)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: class names `toolbar-menu` / `toolbar-menu-list` already rendered by `ToolbarMenuButton.vue` (Task 1).
|
||||||
|
- Produces: a single shared style block used by both `GenerateMenuButton` and `ExportMenuButton` — no per-button duplication.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rename the dropdown style block**
|
||||||
|
|
||||||
|
In `src/style.css`, replace lines 273-307:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.generate-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-menu-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 120px;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-menu-list button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--green-700);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-menu-list button:hover {
|
||||||
|
background: var(--green-100);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.toolbar-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-menu-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 120px;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-menu-list button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--green-700);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-menu-list button:hover {
|
||||||
|
background: var(--green-100);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rename the mobile media-query rule**
|
||||||
|
|
||||||
|
In `src/style.css`, inside the `@media (max-width: 600px)` block, replace:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.workspace-toolbar .generate-menu {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.workspace-toolbar .toolbar-menu {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite to confirm no regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: PASS for all suites except the 5 pre-existing, unrelated failures (3 in `WorkspaceView.test.ts` about file-upload placeholder text and batch-generate concurrency ordering, 2 in `useTeachingBook.test.ts` about `store.importFiles`) — CSS changes don't affect Vitest/jsdom assertions; this is a safety check that nothing else broke.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/style.css
|
||||||
|
git commit -m "style: rename generate-menu CSS classes to generic toolbar-menu"
|
||||||
|
```
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 合并「打印整册」与「导出 MD」按钮设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`WorkspaceToolbar.vue` 当前并排放置两个独立按钮,均在 `lessonCount === 0` 时禁用:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
||||||
|
<button data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 MD</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
此前已经把「批量生成」「生成一篇」合并为一个下拉菜单按钮(`GenerateMenuButton.vue`,2026-06-22 提交)。本次需求是用同样的交互模式合并「打印整册」「导出 MD」,并借此机会把两个下拉菜单共用的逻辑抽取成通用组件,避免重复。
|
||||||
|
|
||||||
|
## 交互
|
||||||
|
|
||||||
|
- 合并后的主按钮文案为「导出 ▾」。
|
||||||
|
- 点击主按钮只展开/收起下拉菜单,不直接触发任何操作。
|
||||||
|
- 下拉菜单包含「打印整册」「导出 MD」两项,点击任意一项后触发对应事件并收起菜单。
|
||||||
|
- 点击外部区域或按 `Escape` 收起菜单(与生成菜单一致)。
|
||||||
|
- 主按钮在 `lessonCount === 0` 时整体禁用(原生 `disabled`),此时无法展开菜单——与现有两个按钮各自禁用的行为等价。
|
||||||
|
|
||||||
|
## 组件设计
|
||||||
|
|
||||||
|
### 新增通用组件 `src/components/ToolbarMenuButton.vue`
|
||||||
|
|
||||||
|
把下拉菜单的通用逻辑(展开状态、点击外部关闭、Escape 关闭、disabled 处理)收进这一个组件,具体菜单项通过默认 slot 传入,slot 透出 `close` 方法供菜单项点击后调用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
toggleTestid: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
```
|
||||||
|
|
||||||
|
行为:
|
||||||
|
- 内部 `open = ref(false)`;`toggle()` 切换 `open`(已经走过原生 `disabled` 拦截,无需在 JS 里再判断一次)。
|
||||||
|
- 根元素 `ref`,`onMounted` 注册 `document` 的 `click` 监听判断点击是否在组件外部,`onUnmounted` 移除;`keydown` 监听 `Escape` 关闭。
|
||||||
|
- 主按钮:`<button :data-testid="toggleTestid" :disabled="disabled" :aria-expanded="open" @click.stop="toggle">{{ label }}</button>`。
|
||||||
|
- 菜单:`<ul v-if="open" class="toolbar-menu-list" role="menu"><slot :close="close" /></ul>`——因为主按钮 disabled 时浏览器不会触发其 click,`open` 永远不会在 disabled 状态下变为 true,不需要在 `v-if` 里重复判断 `!disabled`。
|
||||||
|
- 根元素 class 由 `generate-menu` 改名为通用的 `toolbar-menu`。
|
||||||
|
|
||||||
|
### `GenerateMenuButton.vue` 改为薄封装
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ToolbarMenuButton label="生成教案 ▾" toggle-testid="generate-menu-toggle">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<li role="menuitem">
|
||||||
|
<button data-testid="batch-generate" @click="emit('batchGenerate'); close()">批量生成</button>
|
||||||
|
</li>
|
||||||
|
<li role="menuitem">
|
||||||
|
<button data-testid="generate" @click="emit('generate'); close()">生成一篇</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ToolbarMenuButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
对外接口(`data-testid` 值、按钮文案、`generate` / `batchGenerate` 事件)完全不变,`WorkspaceToolbar.vue` 和 `WorkspaceView.vue` 不需要任何改动。
|
||||||
|
|
||||||
|
### 新增 `src/components/ExportMenuButton.vue`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ disabled?: boolean }>()
|
||||||
|
const emit = defineEmits<{ print: []; export: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToolbarMenuButton label="导出 ▾" toggle-testid="export-menu-toggle" :disabled="disabled">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<li role="menuitem">
|
||||||
|
<button data-testid="print" @click="emit('print'); close()">打印整册</button>
|
||||||
|
</li>
|
||||||
|
<li role="menuitem">
|
||||||
|
<button data-testid="export" @click="emit('export'); close()">导出 MD</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ToolbarMenuButton>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WorkspaceToolbar.vue` 改动
|
||||||
|
|
||||||
|
将原来的 `<button data-testid="print">` / `<button data-testid="export">` 替换为:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ExportMenuButton :disabled="lessonCount === 0" @print="$emit('print')" @export="$emit('export')" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`defineEmits` 块(`print`、`export` 等)和 `WorkspaceView.vue` 的监听逻辑不变。
|
||||||
|
|
||||||
|
### 样式 (`src/style.css`)
|
||||||
|
|
||||||
|
- 把现有 `.generate-menu` / `.generate-menu-list` / `.generate-menu-list button` 三条规则改名为通用的 `.toolbar-menu` / `.toolbar-menu-list` / `.toolbar-menu-list button`,两个下拉按钮共用,不重复定义。
|
||||||
|
- 移动端媒体查询里的 `.workspace-toolbar .generate-menu { flex: 0 0 auto; }` 同样改名为 `.workspace-toolbar .toolbar-menu { flex: 0 0 auto; }`。
|
||||||
|
- 不引入任何新的颜色/圆角取值,继续复用 `var(--line)`、`var(--radius-md)`、`var(--green-100)`、`var(--green-700)` 等既有变量。
|
||||||
|
|
||||||
|
## 测试改动
|
||||||
|
|
||||||
|
- 新增 `ToolbarMenuButton.test.ts`:覆盖默认收起、点击展开、点击菜单项后通过 slot 的 `close()` 收起、点击外部收起、`Escape` 收起、`disabled` 时主按钮不可点击(因此菜单永远不会展开)。
|
||||||
|
- `GenerateMenuButton.test.ts`:现有用例保持不变,唯一改动是把断言根元素 class 的那一行从 `div.generate-menu` 改成 `div.toolbar-menu`(实现细节变化,行为不变)。
|
||||||
|
- 新增 `ExportMenuButton.test.ts`:覆盖点击「打印整册」触发 `print` 并收起菜单、点击「导出 MD」触发 `export` 并收起菜单、`disabled` 为 `true` 时主按钮 `disabled` 属性存在。
|
||||||
|
- `WorkspaceToolbar.test.ts`:
|
||||||
|
- `disables print, export and clear when there are no lessons` 用例改写——`lessonCount === 0` 时菜单项不在 DOM 里(菜单不会展开),改为断言 `export-menu-toggle` 的 `disabled` 属性存在,以及 `clear` 的 `disabled` 属性存在。
|
||||||
|
- 如需要保留对菜单项可点击性的验证,新增一条用例:在 `lessonCount` 大于 0 时展开菜单点击 `print` / `export`,确认事件被触发。
|
||||||
|
- `WorkspaceView.test.ts`:第 207 行 `await wrapper.get('[data-testid="export"]').trigger('click')` 之前补一步 `await wrapper.get('[data-testid="export-menu-toggle"]').trigger('click')`。
|
||||||
|
|
||||||
|
## 范围说明
|
||||||
|
|
||||||
|
本次改动仅涉及 UI 层的按钮合并、通用下拉逻辑抽取,不涉及:
|
||||||
|
- 打印逻辑(`$emit('print')` 之后 `WorkspaceView.vue` 内部如何打印)或导出逻辑(`zipExporter` 相关代码)。
|
||||||
|
- 移动端窄屏样式的额外适配(沿用现有响应式规则,新按钮作为 flex item 自然换行)。
|
||||||
|
- 对 `GenerateMenuButton.vue` 公开接口或文案的任何改动。
|
||||||
@@ -86,6 +86,68 @@ describe('generate route', () => {
|
|||||||
expect(res.status).toBe(502)
|
expect(res.status).toBe(502)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns 400 when theme is missing on outline', async () => {
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
|
||||||
|
const res = await app.request('/api/generate/outline', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the requested count in the outline prompt', async () => {
|
||||||
|
let sentBody = ''
|
||||||
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
||||||
|
sentBody = init.body ?? ''
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ choices: [{ message: { content: 'A\nB\nC' } }] }),
|
||||||
|
{ status: 200 },
|
||||||
|
)
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
const res = await app.request('/api/generate/outline', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: 'Web 前端', count: 25 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(sentBody).toContain('共约25条')
|
||||||
|
const body = (await res.json()) as { titles: string[] }
|
||||||
|
expect(body.titles).toEqual(['A', 'B', 'C'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults the outline count to 18 and clamps out-of-range values', async () => {
|
||||||
|
const captured: string[] = []
|
||||||
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
||||||
|
captured.push(init.body ?? '')
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ choices: [{ message: { content: 'A' } }] }),
|
||||||
|
{ status: 200 },
|
||||||
|
)
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
|
||||||
|
await app.request('/api/generate/outline', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: 'Web 前端' }),
|
||||||
|
})
|
||||||
|
await app.request('/api/generate/outline', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: 'Web 前端', count: 999 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(captured[0]).toContain('共约18条')
|
||||||
|
expect(captured[1]).toContain('共约50条')
|
||||||
|
})
|
||||||
|
|
||||||
it('returns 502 when Deepseek response has no content', async () => {
|
it('returns 502 when Deepseek response has no content', async () => {
|
||||||
globalThis.fetch = mock(async () => new Response(JSON.stringify({ choices: [] }), { status: 200 })) as unknown as typeof fetch
|
globalThis.fetch = mock(async () => new Response(JSON.stringify({ choices: [] }), { status: 200 })) as unknown as typeof fetch
|
||||||
|
|
||||||
@@ -98,4 +160,149 @@ describe('generate route', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(502)
|
expect(res.status).toBe(502)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not add content to the prompt when topic is sent alone', async () => {
|
||||||
|
let sentBody = ''
|
||||||
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
||||||
|
sentBody = init.body ?? ''
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
|
||||||
|
{ status: 200 },
|
||||||
|
)
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
await app.request('/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sentBody).not.toContain('作为编写依据')
|
||||||
|
expect(sentBody).toContain('请围绕主题')
|
||||||
|
expect(sentBody).toContain('生成一份教案。')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('injects chapter content into the prompt when content is provided', async () => {
|
||||||
|
let sentBody = ''
|
||||||
|
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
|
||||||
|
sentBody = init.body ?? ''
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
|
||||||
|
{ status: 200 },
|
||||||
|
)
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
const res = await app.request('/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic: 'CSS 弹性布局', content: '弹性盒模型的正文内容片段' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(sentBody).toContain('作为编写依据')
|
||||||
|
expect(sentBody).toContain('弹性盒模型的正文内容片段')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when entries are missing on lessons-from-book', async () => {
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
|
||||||
|
const res = await app.request('/api/generate/lessons-from-book', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses lessons with sourceIndexes from lessons-from-book', async () => {
|
||||||
|
globalThis.fetch = mock(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content:
|
||||||
|
'{"lessons":[{"title":"C# 入门——搭建环境","sourceIndexes":[0]},{"title":"变量与类型","sourceIndexes":[1,2]}]}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
const res = await app.request('/api/generate/lessons-from-book', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
entries: [
|
||||||
|
{ index: 0, title: '第1章 C# 入门', charCount: 5000 },
|
||||||
|
{ index: 1, title: '第2章 变量', charCount: 3000 },
|
||||||
|
{ index: 2, title: '第3章 类型', charCount: 2000 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
lessons: { title: string; sourceIndexes: number[] }[]
|
||||||
|
}
|
||||||
|
expect(body.lessons).toEqual([
|
||||||
|
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
|
||||||
|
{ title: '变量与类型', sourceIndexes: [1, 2] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips code fences and drops out-of-range sourceIndexes on lessons-from-book', async () => {
|
||||||
|
globalThis.fetch = mock(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content:
|
||||||
|
'```json\n{"lessons":[{"title":"课时一","sourceIndexes":[0,9]}]}\n```',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
const res = await app.request('/api/generate/lessons-from-book', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
lessons: { title: string; sourceIndexes: number[] }[]
|
||||||
|
}
|
||||||
|
expect(body.lessons).toEqual([{ title: '课时一', sourceIndexes: [0] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 502 when lessons-from-book JSON is malformed', async () => {
|
||||||
|
globalThis.fetch = mock(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ choices: [{ message: { content: '这不是 JSON' } }] }),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||||||
|
const res = await app.request('/api/generate/lessons-from-book', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(502)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
app.post('/', async (c) => {
|
app.post('/', async (c) => {
|
||||||
const body = (await c.req.json().catch(() => null)) as { topic?: unknown } | null
|
const body = (await c.req.json().catch(() => null)) as
|
||||||
|
| { topic?: unknown; content?: unknown }
|
||||||
|
| null
|
||||||
const topic = body?.topic
|
const topic = body?.topic
|
||||||
|
|
||||||
if (typeof topic !== 'string' || topic.trim() === '') {
|
if (typeof topic !== 'string' || topic.trim() === '') {
|
||||||
@@ -48,6 +50,11 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = typeof body?.content === 'string' ? body.content.trim() : ''
|
||||||
|
const userContent = content
|
||||||
|
? `请围绕主题"${topic.trim()}"生成一份教案。\n\n以下是教材相关章节内容,作为编写依据(请提炼要点,不要照抄):\n"""\n${content.slice(0, 6000)}\n"""`
|
||||||
|
: `请围绕主题"${topic.trim()}"生成一份教案。`
|
||||||
|
|
||||||
const systemPrompt = loadSystemPrompt()
|
const systemPrompt = loadSystemPrompt()
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
@@ -62,7 +69,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
model: 'deepseek-v4-flash',
|
model: 'deepseek-v4-flash',
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` },
|
{ role: 'user', content: userContent },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -90,13 +97,18 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post('/outline', async (c) => {
|
app.post('/outline', async (c) => {
|
||||||
const body = (await c.req.json().catch(() => null)) as { theme?: unknown } | null
|
const body = (await c.req.json().catch(() => null)) as
|
||||||
|
| { theme?: unknown; count?: unknown }
|
||||||
|
| null
|
||||||
const theme = body?.theme
|
const theme = body?.theme
|
||||||
|
|
||||||
if (typeof theme !== 'string' || theme.trim() === '') {
|
if (typeof theme !== 'string' || theme.trim() === '') {
|
||||||
return c.json({ error: '请提供课程主题。' }, 400)
|
return c.json({ error: '请提供课程主题。' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawCount = typeof body?.count === 'number' ? body.count : 18
|
||||||
|
const count = Math.min(50, Math.max(1, Math.round(rawCount)))
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||||||
}
|
}
|
||||||
@@ -115,7 +127,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
content:
|
||||||
'你是教学设计专家。根据用户提供的课程主题,生成一份完整的课时大纲标题列表,共约18条。' +
|
`你是教学设计专家。根据用户提供的课程主题,生成一份完整的课时大纲标题列表,共约${count}条。` +
|
||||||
'每个标题格式为"项目名——课时任务",一行一个,不加序号、不加任何说明,直接输出标题列表。',
|
'每个标题格式为"项目名——课时任务",一行一个,不加序号、不加任何说明,直接输出标题列表。',
|
||||||
},
|
},
|
||||||
{ role: 'user', content: `课程主题:${theme.trim()}` },
|
{ role: 'user', content: `课程主题:${theme.trim()}` },
|
||||||
@@ -147,5 +159,119 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
return c.json({ titles })
|
return c.json({ titles })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/lessons-from-book', async (c) => {
|
||||||
|
const body = (await c.req.json().catch(() => null)) as { entries?: unknown } | null
|
||||||
|
const rawEntries = Array.isArray(body?.entries) ? body!.entries : null
|
||||||
|
|
||||||
|
if (!rawEntries || rawEntries.length === 0) {
|
||||||
|
return c.json({ error: '请提供教材目录。' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = rawEntries
|
||||||
|
.map((entry, index) => {
|
||||||
|
const e = entry as {
|
||||||
|
index?: unknown
|
||||||
|
title?: unknown
|
||||||
|
charCount?: unknown
|
||||||
|
depth?: unknown
|
||||||
|
}
|
||||||
|
const title = typeof e?.title === 'string' ? e.title.trim() : ''
|
||||||
|
const entryIndex = typeof e?.index === 'number' ? e.index : index
|
||||||
|
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
|
||||||
|
const depth = typeof e?.depth === 'number' && e.depth > 0 ? e.depth : 0
|
||||||
|
return { index: entryIndex, title, charCount, depth }
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.title !== '')
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return c.json({ error: '目录为空或格式无效。' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tocText = entries
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
`${entry.index}\t${' '.repeat(entry.depth)}${entry.title}\t约${entry.charCount}字`,
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
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:
|
||||||
|
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」,标题前的缩进表示层级(章 → 节 → 小节),同一上级标题下的条目应优先归到相邻课时。' +
|
||||||
|
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
|
||||||
|
'每个课题对应一个或多个目录条目(用其序号表示)。' +
|
||||||
|
'仅输出 JSON,不要解释、不要代码块围栏,形如:' +
|
||||||
|
'{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}。',
|
||||||
|
},
|
||||||
|
{ role: 'user', content: tocText },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} 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 fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
|
||||||
|
const jsonText = fenceMatch ? fenceMatch[1]! : raw
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonText)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Deepseek 返回的课时格式无法解析。' }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxIndex = entries.reduce((max, entry) => Math.max(max, entry.index), 0)
|
||||||
|
const rawLessons = (parsed as { lessons?: unknown })?.lessons
|
||||||
|
const lessons = Array.isArray(rawLessons)
|
||||||
|
? rawLessons
|
||||||
|
.map((lesson) => {
|
||||||
|
const l = lesson as { title?: unknown; sourceIndexes?: unknown }
|
||||||
|
const title = typeof l?.title === 'string' ? l.title.trim() : ''
|
||||||
|
const sourceIndexes = Array.isArray(l?.sourceIndexes)
|
||||||
|
? l.sourceIndexes.filter(
|
||||||
|
(n): n is number => typeof n === 'number' && n >= 0 && n <= maxIndex,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
return { title, sourceIndexes }
|
||||||
|
})
|
||||||
|
.filter((lesson) => lesson.title !== '')
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (lessons.length === 0) {
|
||||||
|
return c.json({ error: 'Deepseek 未返回有效课时。' }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ lessons })
|
||||||
|
})
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const phase = ref<Phase>('theme')
|
const phase = ref<Phase>('theme')
|
||||||
const theme = ref(props.defaultTheme ?? '')
|
const theme = ref(props.defaultTheme ?? '')
|
||||||
|
const count = ref(18)
|
||||||
const outlineText = ref('')
|
const outlineText = ref('')
|
||||||
const outlineError = ref<string | null>(null)
|
const outlineError = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ async function handleGenerateOutline(): Promise<void> {
|
|||||||
phase.value = 'outline-loading'
|
phase.value = 'outline-loading'
|
||||||
outlineError.value = null
|
outlineError.value = null
|
||||||
try {
|
try {
|
||||||
const result = await booksApi.generateOutline(theme.value.trim())
|
const result = await booksApi.generateOutline(theme.value.trim(), count.value)
|
||||||
outlineText.value = result.titles.join('\n')
|
outlineText.value = result.titles.join('\n')
|
||||||
phase.value = 'outline'
|
phase.value = 'outline'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -63,6 +64,7 @@ function handleStart(): void {
|
|||||||
function handleClose(): void {
|
function handleClose(): void {
|
||||||
phase.value = 'theme'
|
phase.value = 'theme'
|
||||||
theme.value = props.defaultTheme ?? ''
|
theme.value = props.defaultTheme ?? ''
|
||||||
|
count.value = 18
|
||||||
outlineText.value = ''
|
outlineText.value = ''
|
||||||
outlineError.value = null
|
outlineError.value = null
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -84,6 +86,10 @@ function handleClose(): void {
|
|||||||
placeholder="例如:Web 前端开发项目式教学"
|
placeholder="例如:Web 前端开发项目式教学"
|
||||||
@keydown.enter="handleGenerateOutline"
|
@keydown.enter="handleGenerateOutline"
|
||||||
/>
|
/>
|
||||||
|
<label class="batch-count-field">
|
||||||
|
生成数量
|
||||||
|
<input v-model.number="count" type="number" min="1" max="50" />
|
||||||
|
</label>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" :disabled="!theme.trim()" @click="handleGenerateOutline">生成大纲</button>
|
<button type="button" :disabled="!theme.trim()" @click="handleGenerateOutline">生成大纲</button>
|
||||||
<button type="button" @click="handleClose">取消</button>
|
<button type="button" @click="handleClose">取消</button>
|
||||||
|
|||||||
86
src/components/BookImportDialog.test.ts
Normal file
86
src/components/BookImportDialog.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as booksApi from '../services/booksApi'
|
||||||
|
import BookImportDialog from './BookImportDialog.vue'
|
||||||
|
|
||||||
|
vi.mock('../services/booksApi')
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
running: false,
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
currentTopic: '',
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMdFiles(files: Record<string, string>): File[] {
|
||||||
|
return Object.entries(files).map(
|
||||||
|
([name, content]) => new File([content], name, { type: 'text/markdown' }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(wrapper: ReturnType<typeof mount>, files: File[]): Promise<void> {
|
||||||
|
const input = wrapper.get('input[type="file"]')
|
||||||
|
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
|
||||||
|
await input.trigger('change')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookImportDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses toc + md files, divides lessons, and emits start with assembled content', async () => {
|
||||||
|
vi.mocked(booksApi.divideLessonsFromBook).mockResolvedValue({
|
||||||
|
lessons: [
|
||||||
|
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
|
||||||
|
{ title: '变量与类型', sourceIndexes: [1] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(BookImportDialog, { props: baseProps })
|
||||||
|
|
||||||
|
await wrapper.get('textarea').setValue('第1章 入门\n第2章 变量')
|
||||||
|
const files = makeMdFiles({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' })
|
||||||
|
await uploadFiles(wrapper, files)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 解析并划分(File.text() 为异步,故用 waitFor 等待预览出现)
|
||||||
|
await wrapper.get('.dialog-actions button').trigger('click')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.findAll('.book-import-lesson-title')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(booksApi.divideLessonsFromBook).toHaveBeenCalledWith([
|
||||||
|
{ index: 0, title: '第1章 入门', charCount: 4, depth: 0 },
|
||||||
|
{ index: 1, title: '第2章 变量', charCount: 4, depth: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// 预览阶段:编辑第一个课时标题
|
||||||
|
const titleInputs = wrapper.findAll('.book-import-lesson-title')
|
||||||
|
await titleInputs[0]!.setValue('改后的标题')
|
||||||
|
|
||||||
|
// 开始生成
|
||||||
|
await wrapper.get('.dialog-actions button').trigger('click')
|
||||||
|
|
||||||
|
const startEvents = wrapper.emitted('start')
|
||||||
|
expect(startEvents).toHaveLength(1)
|
||||||
|
expect(startEvents?.[0]?.[0]).toEqual([
|
||||||
|
{ title: '改后的标题', content: '入门正文' },
|
||||||
|
{ title: '变量与类型', content: '变量正文' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an upload with no md files', async () => {
|
||||||
|
const wrapper = mount(BookImportDialog, { props: baseProps })
|
||||||
|
const txt = new File(['x'], 'notes.txt', { type: 'text/plain' })
|
||||||
|
await uploadFiles(wrapper, [txt])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('.app-notice--error').text()).toContain('.md')
|
||||||
|
})
|
||||||
|
})
|
||||||
218
src/components/BookImportDialog.vue
Normal file
218
src/components/BookImportDialog.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import * as booksApi from '../services/booksApi'
|
||||||
|
import {
|
||||||
|
assembleContent,
|
||||||
|
matchToc,
|
||||||
|
parseMdFiles,
|
||||||
|
type BookEntry,
|
||||||
|
type ProposedLesson,
|
||||||
|
} from '../services/bookImport'
|
||||||
|
import type { BookLessonInput } from '../composables/useTeachingBook'
|
||||||
|
import UploadDropzone from './UploadDropzone.vue'
|
||||||
|
|
||||||
|
type Phase = 'input' | 'division-loading' | 'preview' | 'running' | 'done' | 'error'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
running: boolean
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
currentTopic: string
|
||||||
|
error: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
start: [lessons: BookLessonInput[]]
|
||||||
|
cancel: []
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const phase = ref<Phase>('input')
|
||||||
|
const tocText = ref('')
|
||||||
|
const mdFiles = ref<File[]>([])
|
||||||
|
const inputError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const entries = ref<BookEntry[]>([])
|
||||||
|
const lessons = ref<ProposedLesson[]>([])
|
||||||
|
|
||||||
|
const matchedCount = computed(() => entries.value.filter((e) => e.matched).length)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.running,
|
||||||
|
(val) => {
|
||||||
|
if (!val && phase.value === 'running') {
|
||||||
|
phase.value = props.error ? 'error' : 'done'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function onFiles(files: File[]): void {
|
||||||
|
const mds = files.filter((file) => /\.md$/i.test(file.name))
|
||||||
|
if (mds.length === 0) {
|
||||||
|
inputError.value = '请上传 .md 文件。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputError.value = null
|
||||||
|
// 去重(同名覆盖)并追加到已选列表。
|
||||||
|
const byName = new Map(mdFiles.value.map((f) => [f.name, f]))
|
||||||
|
for (const file of mds) byName.set(file.name, file)
|
||||||
|
mdFiles.value = [...byName.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapterTitlesOf(lesson: ProposedLesson): string {
|
||||||
|
return lesson.sourceIndexes
|
||||||
|
.map((i) => entries.value.find((e) => e.index === i)?.title ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAnalyze(): Promise<void> {
|
||||||
|
if (!tocText.value.trim() || mdFiles.value.length === 0) return
|
||||||
|
inputError.value = null
|
||||||
|
phase.value = 'division-loading'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const map = await parseMdFiles(mdFiles.value)
|
||||||
|
entries.value = matchToc(tocText.value, map)
|
||||||
|
} catch {
|
||||||
|
inputError.value = 'Markdown 文件读取失败,请重试。'
|
||||||
|
phase.value = 'input'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.value.length === 0) {
|
||||||
|
inputError.value = '目录为空。'
|
||||||
|
phase.value = 'input'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await booksApi.divideLessonsFromBook(
|
||||||
|
entries.value.map((e) => ({
|
||||||
|
index: e.index,
|
||||||
|
title: e.title,
|
||||||
|
charCount: e.charCount,
|
||||||
|
depth: e.depth,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
lessons.value = result.lessons
|
||||||
|
phase.value = 'preview'
|
||||||
|
} catch (error) {
|
||||||
|
inputError.value = error instanceof Error ? error.message : 'AI 划分课时失败。'
|
||||||
|
phase.value = 'input'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLesson(index: number): void {
|
||||||
|
lessons.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStart(): void {
|
||||||
|
const payload: BookLessonInput[] = lessons.value
|
||||||
|
.filter((lesson) => lesson.title.trim() !== '')
|
||||||
|
.map((lesson) => ({
|
||||||
|
title: lesson.title.trim(),
|
||||||
|
content: assembleContent(lesson, entries.value),
|
||||||
|
}))
|
||||||
|
if (payload.length === 0) return
|
||||||
|
phase.value = 'running'
|
||||||
|
emit('start', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(): void {
|
||||||
|
phase.value = 'input'
|
||||||
|
tocText.value = ''
|
||||||
|
mdFiles.value = []
|
||||||
|
inputError.value = null
|
||||||
|
entries.value = []
|
||||||
|
lessons.value = []
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="book-import-title">
|
||||||
|
<div class="dialog batch-dialog">
|
||||||
|
<h2 id="book-import-title">从书籍生成教案</h2>
|
||||||
|
|
||||||
|
<!-- 第一步:粘贴目录 + 上传 ZIP -->
|
||||||
|
<template v-if="phase === 'input'">
|
||||||
|
<p>粘贴教材目录(每行一个标题,可用缩进表示章/节层级),并上传各章 .md 文件(文件名为目录标题,可多选)。AI 将据此划分课时。</p>
|
||||||
|
<p v-if="inputError" class="app-notice app-notice--error" role="alert">{{ inputError }}</p>
|
||||||
|
<textarea
|
||||||
|
v-model="tocText"
|
||||||
|
class="batch-topics-input"
|
||||||
|
rows="12"
|
||||||
|
placeholder="第1章 C# 入门 第2章 变量与数据类型 第3章 流程控制"
|
||||||
|
/>
|
||||||
|
<UploadDropzone accept=".md,text/markdown" :multiple="true" @files="onFiles" />
|
||||||
|
<p v-if="mdFiles.length" class="book-import-file">已选 {{ mdFiles.length }} 个文件</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!tocText.trim() || mdFiles.length === 0"
|
||||||
|
@click="handleAnalyze"
|
||||||
|
>
|
||||||
|
解析并划分课时
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="handleClose">取消</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- AI 划分中 -->
|
||||||
|
<template v-else-if="phase === 'division-loading'">
|
||||||
|
<p>AI 正在划分课时…</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 第二步:预览/编辑课时 -->
|
||||||
|
<template v-else-if="phase === 'preview'">
|
||||||
|
<p>
|
||||||
|
目录共 {{ entries.length }} 行,匹配到正文 {{ matchedCount }} 行。AI 划分出以下课时,可编辑标题或删除后开始生成:
|
||||||
|
</p>
|
||||||
|
<ul class="book-import-lessons">
|
||||||
|
<li v-for="(lesson, index) in lessons" :key="index" class="book-import-lesson">
|
||||||
|
<input v-model="lesson.title" type="text" class="book-import-lesson-title" />
|
||||||
|
<span class="book-import-lesson-source">{{ chapterTitlesOf(lesson) || '(无匹配正文)' }}</span>
|
||||||
|
<button type="button" class="book-import-lesson-remove" @click="removeLesson(index)">删除</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="batch-topics-count">共 {{ lessons.length }} 个课时</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" :disabled="lessons.length === 0" @click="handleStart">开始生成</button>
|
||||||
|
<button type="button" @click="phase = 'input'">重新选择</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>
|
||||||
53
src/components/ExportMenuButton.test.ts
Normal file
53
src/components/ExportMenuButton.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ExportMenuButton from './ExportMenuButton.vue'
|
||||||
|
|
||||||
|
describe('ExportMenuButton', () => {
|
||||||
|
it('renders the toggle button with the menu closed by default', () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, { attachTo: document.body })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').text()).toContain('导出')
|
||||||
|
expect(wrapper.find('[data-testid="print"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="export"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits print and closes the menu when "打印整册" is clicked', async () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, { attachTo: document.body })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="print"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('print')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('[data-testid="print"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits export and closes the menu when "导出 MD" is clicked', async () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, { attachTo: document.body })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="export"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('export')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('[data-testid="export"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the toggle button when disabled prop is true', () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, {
|
||||||
|
props: { disabled: true },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'),
|
||||||
|
).toBeDefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the toggle button enabled when disabled prop is false', () => {
|
||||||
|
const wrapper = mount(ExportMenuButton, {
|
||||||
|
props: { disabled: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'),
|
||||||
|
).toBeUndefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/components/ExportMenuButton.vue
Normal file
41
src/components/ExportMenuButton.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
|
defineProps<{ disabled?: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
print: []
|
||||||
|
export: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToolbarMenuButton label="导出 ▾" toggle-testid="export-menu-toggle" :disabled="disabled">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="print"
|
||||||
|
@click="
|
||||||
|
emit('print');
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
打印整册
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="menuitem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="export"
|
||||||
|
@click="
|
||||||
|
emit('export');
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
导出 MD
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ToolbarMenuButton>
|
||||||
|
</template>
|
||||||
@@ -5,7 +5,7 @@ import GenerateMenuButton from './GenerateMenuButton.vue'
|
|||||||
describe('GenerateMenuButton', () => {
|
describe('GenerateMenuButton', () => {
|
||||||
it('renders the toggle button with the menu closed by default', () => {
|
it('renders the toggle button with the menu closed by default', () => {
|
||||||
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
||||||
expect(wrapper.get('button[data-testid="generate-menu-toggle"]').text()).toContain('生成教案')
|
expect(wrapper.get('button[data-testid="generate-menu-toggle"]').text()).toContain('生成')
|
||||||
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
||||||
expect(wrapper.find('[data-testid="batch-generate"]').exists()).toBe(false)
|
expect(wrapper.find('[data-testid="batch-generate"]').exists()).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -37,6 +37,15 @@ describe('GenerateMenuButton', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits bookImport and closes the menu when "从书籍生成" is clicked', async () => {
|
||||||
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
||||||
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="book-import"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('bookImport')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('[data-testid="book-import"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('closes the menu when clicking outside the component', async () => {
|
it('closes the menu when clicking outside the component', async () => {
|
||||||
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
||||||
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||||
@@ -54,7 +63,7 @@ describe('GenerateMenuButton', () => {
|
|||||||
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||||
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(true)
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(true)
|
||||||
|
|
||||||
await wrapper.get('div.generate-menu').trigger('keydown', { key: 'Escape' })
|
await wrapper.get('div.toolbar-menu').trigger('keydown', { key: 'Escape' })
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|||||||
@@ -1,68 +1,52 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
generate: []
|
generate: []
|
||||||
batchGenerate: []
|
batchGenerate: []
|
||||||
|
bookImport: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const open = ref(false)
|
|
||||||
const rootRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
function toggle(): void {
|
|
||||||
open.value = !open.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function close(): void {
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function select(action: 'generate' | 'batchGenerate'): void {
|
|
||||||
emit(action)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocumentClick(event: MouseEvent): void {
|
|
||||||
if (!rootRef.value) return
|
|
||||||
if (!rootRef.value.contains(event.target as Node)) {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handleDocumentClick)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('click', handleDocumentClick)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="rootRef" class="generate-menu" @keydown="handleKeydown">
|
<ToolbarMenuButton label="生成 ▾" toggle-testid="generate-menu-toggle">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<li role="menuitem">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="generate-menu-toggle"
|
data-testid="batch-generate"
|
||||||
:aria-expanded="open"
|
@click="
|
||||||
@click.stop="toggle"
|
emit('batchGenerate');
|
||||||
|
close()
|
||||||
|
"
|
||||||
>
|
>
|
||||||
生成教案 ▾
|
|
||||||
</button>
|
|
||||||
<ul v-if="open" class="generate-menu-list" role="menu">
|
|
||||||
<li role="menuitem">
|
|
||||||
<button type="button" data-testid="batch-generate" @click="select('batchGenerate')">
|
|
||||||
批量生成
|
批量生成
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li role="menuitem">
|
<li role="menuitem">
|
||||||
<button type="button" data-testid="generate" @click="select('generate')">生成一篇</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="generate"
|
||||||
|
@click="
|
||||||
|
emit('generate');
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
生成一篇
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<li role="menuitem">
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="book-import"
|
||||||
|
@click="
|
||||||
|
emit('bookImport');
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
从书籍生成
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ToolbarMenuButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
89
src/components/ToolbarMenuButton.test.ts
Normal file
89
src/components/ToolbarMenuButton.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
|
function mountMenu(props: { label: string; toggleTestid: string; disabled?: boolean }) {
|
||||||
|
return mount(ToolbarMenuButton, {
|
||||||
|
props,
|
||||||
|
attachTo: document.body,
|
||||||
|
slots: {
|
||||||
|
default: `<template #default="{ close }">
|
||||||
|
<li role="menuitem"><button data-testid="item-a" @click="close">Item A</button></li>
|
||||||
|
<li role="menuitem"><button data-testid="item-b" @click="close">Item B</button></li>
|
||||||
|
</template>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ToolbarMenuButton', () => {
|
||||||
|
it('renders the toggle button with the given label and closed menu by default', () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').text()).toBe('导出 ▾')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the menu when the toggle button is clicked', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-testid="item-a"]').isVisible()).toBe(true)
|
||||||
|
expect(wrapper.get('[data-testid="item-b"]').isVisible()).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when a slot item calls close', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="item-a"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when clicking outside the component', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
document.body.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when Escape is pressed', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('div.toolbar-menu').trigger('keydown', { key: 'Escape' })
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the toggle button and never opens the menu when disabled is true', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle', disabled: true })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes an open menu when another menu button is clicked', async () => {
|
||||||
|
const first = mountMenu({ label: '生成 ▾', toggleTestid: 'generate-menu-toggle' })
|
||||||
|
const second = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
|
||||||
|
await first.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||||
|
expect(first.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await second.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await first.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(second.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
expect(first.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
|
||||||
|
first.unmount()
|
||||||
|
second.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
58
src/components/ToolbarMenuButton.vue
Normal file
58
src/components/ToolbarMenuButton.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
toggleTestid: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!rootRef.value) return
|
||||||
|
if (!rootRef.value.contains(event.target as Node)) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="toolbar-menu" @keydown="handleKeydown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:data-testid="toggleTestid"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
<ul v-if="open" class="toolbar-menu-list" role="menu">
|
||||||
|
<slot :close="close" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
withDefaults(defineProps<{ compact?: boolean }>(), {
|
withDefaults(defineProps<{ compact?: boolean; accept?: string; multiple?: boolean }>(), {
|
||||||
compact: false,
|
compact: false,
|
||||||
|
accept: '.md,text/markdown,text/plain',
|
||||||
|
multiple: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{ files: [files: File[]] }>()
|
const emit = defineEmits<{ files: [files: File[]] }>()
|
||||||
@@ -60,8 +62,8 @@ defineExpose({ openPicker })
|
|||||||
<input
|
<input
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
:multiple="multiple"
|
||||||
accept=".md,text/markdown,text/plain"
|
:accept="accept"
|
||||||
class="upload-dropzone-input"
|
class="upload-dropzone-input"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@click.stop
|
@click.stop
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ describe('WorkspaceToolbar', () => {
|
|||||||
expect(wrapper.emitted('batchGenerate')).toHaveLength(1)
|
expect(wrapper.emitted('batchGenerate')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits bookImport when the book-import menu item is clicked', async () => {
|
||||||
|
const wrapper = mountToolbar(3)
|
||||||
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="book-import"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('bookImport')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('emits back when the back button is clicked', async () => {
|
it('emits back when the back button is clicked', async () => {
|
||||||
const wrapper = mountToolbar(0)
|
const wrapper = mountToolbar(0)
|
||||||
await wrapper.get('button[data-testid="back"]').trigger('click')
|
await wrapper.get('button[data-testid="back"]').trigger('click')
|
||||||
@@ -42,10 +49,25 @@ describe('WorkspaceToolbar', () => {
|
|||||||
expect(wrapper.get('button[data-testid="back"]').attributes('disabled')).toBeUndefined()
|
expect(wrapper.get('button[data-testid="back"]').attributes('disabled')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables print, export and clear when there are no lessons', () => {
|
it('disables the export menu toggle and clear button when there are no lessons', () => {
|
||||||
const wrapper = mountToolbar(0)
|
const wrapper = mountToolbar(0)
|
||||||
expect(wrapper.get('button[data-testid="print"]').attributes('disabled')).toBeDefined()
|
expect(
|
||||||
expect(wrapper.get('button[data-testid="export"]').attributes('disabled')).toBeDefined()
|
wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'),
|
||||||
|
).toBeDefined()
|
||||||
expect(wrapper.get('button[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
expect(wrapper.get('button[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits print when the print menu item is clicked', async () => {
|
||||||
|
const wrapper = mountToolbar(3)
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="print"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('print')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits export when the export menu item is clicked', async () => {
|
||||||
|
const wrapper = mountToolbar(3)
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="export"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('export')).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SaveStatus } from '../composables/useTeachingBook'
|
import type { SaveStatus } from '../composables/useTeachingBook'
|
||||||
|
import ExportMenuButton from './ExportMenuButton.vue'
|
||||||
import GenerateMenuButton from './GenerateMenuButton.vue'
|
import GenerateMenuButton from './GenerateMenuButton.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -14,6 +15,7 @@ defineEmits<{
|
|||||||
clear: []
|
clear: []
|
||||||
generate: []
|
generate: []
|
||||||
batchGenerate: []
|
batchGenerate: []
|
||||||
|
bookImport: []
|
||||||
fixBroken: []
|
fixBroken: []
|
||||||
back: []
|
back: []
|
||||||
}>()
|
}>()
|
||||||
@@ -28,10 +30,13 @@ const saveStatusLabel: Record<SaveStatus, string> = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="workspace-toolbar">
|
<header class="workspace-toolbar">
|
||||||
<button type="button" data-testid="back" @click="$emit('back')">返回列表</button>
|
<button type="button" data-testid="back" @click="$emit('back')">返回</button>
|
||||||
<GenerateMenuButton @generate="$emit('generate')" @batch-generate="$emit('batchGenerate')" />
|
<GenerateMenuButton
|
||||||
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
@generate="$emit('generate')"
|
||||||
<button type="button" data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 MD</button>
|
@batch-generate="$emit('batchGenerate')"
|
||||||
|
@book-import="$emit('bookImport')"
|
||||||
|
/>
|
||||||
|
<ExportMenuButton :disabled="lessonCount === 0" @print="$emit('print')" @export="$emit('export')" />
|
||||||
<button type="button" data-testid="clear" :disabled="lessonCount === 0" @click="$emit('clear')">清空</button>
|
<button type="button" data-testid="clear" :disabled="lessonCount === 0" @click="$emit('clear')">清空</button>
|
||||||
|
|
||||||
<span class="workspace-toolbar-count">共 {{ lessonCount }} 课</span>
|
<span class="workspace-toolbar-count">共 {{ lessonCount }} 课</span>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('WorkspaceView', () => {
|
|||||||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
expect(wrapper.find('[data-testid="generate-menu-toggle"]').exists()).toBe(true)
|
||||||
|
|
||||||
await wrapper.get('[data-testid="back"]').trigger('click')
|
await wrapper.get('[data-testid="back"]').trigger('click')
|
||||||
expect(wrapper.emitted('back')).toHaveLength(1)
|
expect(wrapper.emitted('back')).toHaveLength(1)
|
||||||
@@ -191,7 +191,8 @@ describe('WorkspaceView', () => {
|
|||||||
|
|
||||||
await wrapper.get('[data-testid="clear"]').trigger('click')
|
await wrapper.get('[data-testid="clear"]').trigger('click')
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
expect(wrapper.find('.workspace-layout').exists()).toBe(false)
|
||||||
|
expect(wrapper.get('[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('downloads the exported zip with the book name', async () => {
|
it('downloads the exported zip with the book name', async () => {
|
||||||
@@ -204,6 +205,7 @@ describe('WorkspaceView', () => {
|
|||||||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
await wrapper.get('[data-testid="export"]').trigger('click')
|
await wrapper.get('[data-testid="export"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useTeachingBook } from '../composables/useTeachingBook'
|
import { useTeachingBook, type BookLessonInput } from '../composables/useTeachingBook'
|
||||||
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
||||||
import A4Workspace from './A4Workspace.vue'
|
import A4Workspace from './A4Workspace.vue'
|
||||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||||
|
import BookImportDialog from './BookImportDialog.vue'
|
||||||
import FixBrokenDialog from './FixBrokenDialog.vue'
|
import FixBrokenDialog from './FixBrokenDialog.vue'
|
||||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||||
import LessonSidebar from './LessonSidebar.vue'
|
import LessonSidebar from './LessonSidebar.vue'
|
||||||
@@ -35,6 +36,7 @@ const {
|
|||||||
clearBook,
|
clearBook,
|
||||||
generateLesson,
|
generateLesson,
|
||||||
generateLessons,
|
generateLessons,
|
||||||
|
generateLessonsFromBook,
|
||||||
regenerateLesson,
|
regenerateLesson,
|
||||||
} = useTeachingBook(props.bookId)
|
} = useTeachingBook(props.bookId)
|
||||||
|
|
||||||
@@ -52,6 +54,14 @@ const batchCurrentTopic = ref('')
|
|||||||
const batchError = ref<string | null>(null)
|
const batchError = ref<string | null>(null)
|
||||||
const batchCancelled = ref(false)
|
const batchCancelled = ref(false)
|
||||||
|
|
||||||
|
const showBookImportDialog = ref(false)
|
||||||
|
const bookImportRunning = ref(false)
|
||||||
|
const bookImportDone = ref(0)
|
||||||
|
const bookImportTotal = ref(0)
|
||||||
|
const bookImportCurrentTopic = ref('')
|
||||||
|
const bookImportError = ref<string | null>(null)
|
||||||
|
const bookImportCancelled = ref(false)
|
||||||
|
|
||||||
const showFixDialog = ref(false)
|
const showFixDialog = ref(false)
|
||||||
const fixRunning = ref(false)
|
const fixRunning = ref(false)
|
||||||
const fixDone = ref(0)
|
const fixDone = ref(0)
|
||||||
@@ -153,6 +163,42 @@ function closeBatchDialog(): void {
|
|||||||
batchError.value = null
|
batchError.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBookImportStart(lessons: BookLessonInput[]): Promise<void> {
|
||||||
|
bookImportRunning.value = true
|
||||||
|
bookImportCancelled.value = false
|
||||||
|
bookImportDone.value = 0
|
||||||
|
bookImportTotal.value = lessons.length
|
||||||
|
bookImportError.value = null
|
||||||
|
|
||||||
|
const result = await generateLessonsFromBook(lessons, {
|
||||||
|
concurrency: BATCH_GENERATE_CONCURRENCY,
|
||||||
|
isCancelled: () => bookImportCancelled.value,
|
||||||
|
onTopicStart: (topic) => {
|
||||||
|
bookImportCurrentTopic.value = topic
|
||||||
|
},
|
||||||
|
onLessonComplete: (count) => {
|
||||||
|
bookImportDone.value += count
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
bookImportError.value = result.message
|
||||||
|
}
|
||||||
|
|
||||||
|
bookImportRunning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBookImportCancel(): void {
|
||||||
|
bookImportCancelled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBookImportDialog(): void {
|
||||||
|
showBookImportDialog.value = false
|
||||||
|
bookImportDone.value = 0
|
||||||
|
bookImportTotal.value = 0
|
||||||
|
bookImportError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function openFixDialog(): void {
|
function openFixDialog(): void {
|
||||||
fixTotal.value = book.value.designs.filter((d) => d.warnings.length > 0).length
|
fixTotal.value = book.value.designs.filter((d) => d.warnings.length > 0).length
|
||||||
fixDone.value = 0
|
fixDone.value = 0
|
||||||
@@ -197,7 +243,7 @@ function closeFixDialog(): void {
|
|||||||
|
|
||||||
<div v-else-if="loadStatus === 'error'" class="app-notice app-notice--error" role="alert">
|
<div v-else-if="loadStatus === 'error'" class="app-notice app-notice--error" role="alert">
|
||||||
<span>{{ loadError }}</span>
|
<span>{{ loadError }}</span>
|
||||||
<button type="button" @click="$emit('back')">返回列表</button>
|
<button type="button" @click="$emit('back')">返回</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -220,6 +266,17 @@ function closeFixDialog(): void {
|
|||||||
@cancel="handleBatchCancel"
|
@cancel="handleBatchCancel"
|
||||||
@close="closeBatchDialog"
|
@close="closeBatchDialog"
|
||||||
/>
|
/>
|
||||||
|
<BookImportDialog
|
||||||
|
v-if="showBookImportDialog"
|
||||||
|
:running="bookImportRunning"
|
||||||
|
:done="bookImportDone"
|
||||||
|
:total="bookImportTotal"
|
||||||
|
:current-topic="bookImportCurrentTopic"
|
||||||
|
:error="bookImportError"
|
||||||
|
@start="handleBookImportStart"
|
||||||
|
@cancel="handleBookImportCancel"
|
||||||
|
@close="closeBookImportDialog"
|
||||||
|
/>
|
||||||
<FixBrokenDialog
|
<FixBrokenDialog
|
||||||
v-if="showFixDialog"
|
v-if="showFixDialog"
|
||||||
:running="fixRunning"
|
:running="fixRunning"
|
||||||
@@ -247,6 +304,7 @@ function closeFixDialog(): void {
|
|||||||
@back="$emit('back')"
|
@back="$emit('back')"
|
||||||
@generate="openGenerateDialog"
|
@generate="openGenerateDialog"
|
||||||
@batch-generate="showBatchDialog = true"
|
@batch-generate="showBatchDialog = true"
|
||||||
|
@book-import="showBookImportDialog = true"
|
||||||
@fix-broken="openFixDialog"
|
@fix-broken="openFixDialog"
|
||||||
@print="handlePrint"
|
@print="handlePrint"
|
||||||
@export="handleExport"
|
@export="handleExport"
|
||||||
|
|||||||
@@ -83,36 +83,19 @@ describe('useTeachingBook', () => {
|
|||||||
expect(store.loadError.value).toBe('网络错误。')
|
expect(store.loadError.value).toBe('网络错误。')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('imports files in natural order and selects the first lesson', async () => {
|
|
||||||
mockGetBook(createEmptyBook())
|
|
||||||
const store = useTeachingBook('b1')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }),
|
|
||||||
new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }),
|
|
||||||
]
|
|
||||||
|
|
||||||
await store.importFiles(files, 'keep')
|
|
||||||
|
|
||||||
expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual(['2.md', '10.md'])
|
|
||||||
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reorders lessons without changing their identities', async () => {
|
it('reorders lessons without changing their identities', async () => {
|
||||||
mockGetBook(createEmptyBook())
|
const data = createEmptyBook()
|
||||||
|
data.designs.push(createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md'))
|
||||||
|
data.selectedId = data.designs[0]!.id
|
||||||
|
mockGetBook(data)
|
||||||
|
|
||||||
const store = useTeachingBook('b1')
|
const store = useTeachingBook('b1')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await store.importFiles(
|
|
||||||
[new File(['# One 教学设计'], '1.md'), new File(['# Two 教学设计'], '2.md')],
|
|
||||||
'keep',
|
|
||||||
)
|
|
||||||
|
|
||||||
const ids = store.book.value.designs.map((design) => design.id)
|
const ids = store.book.value.designs.map((design) => design.id)
|
||||||
store.moveDesign(0, 1)
|
store.moveDesign(0, 1)
|
||||||
|
|
||||||
expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse())
|
expect(store.book.value.designs.map((design) => design.id)).toEqual([ids[1], ids[0]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not autosave immediately after the initial load', async () => {
|
it('does not autosave immediately after the initial load', async () => {
|
||||||
@@ -208,6 +191,51 @@ describe('useTeachingBook', () => {
|
|||||||
expect(store.book.value.designs).toHaveLength(0)
|
expect(store.book.value.designs).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('generateLessonsFromBook appends lessons in order and feeds chapter content', async () => {
|
||||||
|
mockGetBook(createEmptyBook())
|
||||||
|
vi.mocked(booksApi.generateLesson).mockImplementation(async (topic: string) => ({
|
||||||
|
filename: `${topic}.md`,
|
||||||
|
markdown: `# ${topic} 教学设计`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const store = useTeachingBook('b1')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const result = await store.generateLessonsFromBook([
|
||||||
|
{ title: '课时一', content: '第一章正文' },
|
||||||
|
{ title: '课时二', content: '第二章正文' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, completed: 2 })
|
||||||
|
expect(store.book.value.designs).toHaveLength(2)
|
||||||
|
const calls = vi.mocked(booksApi.generateLesson).mock.calls
|
||||||
|
expect(calls.map((c) => c[0])).toEqual(['课时一', '课时二'])
|
||||||
|
expect(calls[0]?.[1]).toEqual(expect.objectContaining({ content: '第一章正文' }))
|
||||||
|
expect(calls[1]?.[1]).toEqual(expect.objectContaining({ content: '第二章正文' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateLessonsFromBook stops early when cancelled', async () => {
|
||||||
|
mockGetBook(createEmptyBook())
|
||||||
|
vi.mocked(booksApi.generateLesson).mockImplementation(async (topic: string) => ({
|
||||||
|
filename: `${topic}.md`,
|
||||||
|
markdown: `# ${topic} 教学设计`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const store = useTeachingBook('b1')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const result = await store.generateLessonsFromBook(
|
||||||
|
[
|
||||||
|
{ title: '课时一', content: 'a' },
|
||||||
|
{ title: '课时二', content: 'b' },
|
||||||
|
],
|
||||||
|
{ concurrency: 1, isCancelled: () => true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.completed).toBe(0)
|
||||||
|
expect(store.book.value.designs).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('clearBook empties designs and clears selection', async () => {
|
it('clearBook empties designs and clears selection', async () => {
|
||||||
const { data } = createBookWithDesign()
|
const { data } = createBookWithDesign()
|
||||||
mockGetBook(data)
|
mockGetBook(data)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface BatchGenerateLessonOptions {
|
|||||||
onLessonComplete?: (count: number) => void
|
onLessonComplete?: (count: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookLessonInput {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeachingBookStore {
|
export interface TeachingBookStore {
|
||||||
book: Ref<TeachingBook>
|
book: Ref<TeachingBook>
|
||||||
bookName: Ref<string>
|
bookName: Ref<string>
|
||||||
@@ -47,6 +52,10 @@ export interface TeachingBookStore {
|
|||||||
topics: readonly string[],
|
topics: readonly string[],
|
||||||
options?: BatchGenerateLessonOptions,
|
options?: BatchGenerateLessonOptions,
|
||||||
) => Promise<BatchGenerateLessonResult>
|
) => Promise<BatchGenerateLessonResult>
|
||||||
|
generateLessonsFromBook: (
|
||||||
|
lessons: readonly BookLessonInput[],
|
||||||
|
options?: BatchGenerateLessonOptions,
|
||||||
|
) => Promise<BatchGenerateLessonResult>
|
||||||
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
|
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,13 +205,15 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateLessons(
|
async function runGenerationPool<T>(
|
||||||
topics: readonly string[],
|
items: readonly T[],
|
||||||
|
labelOf: (item: T) => string,
|
||||||
|
requestOf: (item: T) => Promise<booksApi.GenerateResult>,
|
||||||
options: BatchGenerateLessonOptions = {},
|
options: BatchGenerateLessonOptions = {},
|
||||||
): Promise<BatchGenerateLessonResult> {
|
): Promise<BatchGenerateLessonResult> {
|
||||||
const concurrency = Math.max(1, options.concurrency ?? 3)
|
const concurrency = Math.max(1, options.concurrency ?? 3)
|
||||||
const workerCount = Math.min(concurrency, topics.length)
|
const workerCount = Math.min(concurrency, items.length)
|
||||||
const results = new Array<TeachingDesign | undefined>(topics.length)
|
const results = new Array<TeachingDesign | undefined>(items.length)
|
||||||
let nextStartIndex = 0
|
let nextStartIndex = 0
|
||||||
let nextAppendIndex = 0
|
let nextAppendIndex = 0
|
||||||
let appendedCount = 0
|
let appendedCount = 0
|
||||||
@@ -227,24 +238,19 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
async function runWorker(): Promise<void> {
|
async function runWorker(): Promise<void> {
|
||||||
while (!firstError) {
|
while (!firstError) {
|
||||||
if (options.isCancelled?.()) {
|
if (options.isCancelled?.()) return
|
||||||
abortController.abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = nextStartIndex
|
const index = nextStartIndex
|
||||||
if (index >= topics.length) return
|
if (index >= items.length) return
|
||||||
|
|
||||||
nextStartIndex++
|
nextStartIndex++
|
||||||
const topic = topics[index]!
|
const item = items[index]!
|
||||||
options.onTopicStart?.(topic)
|
options.onTopicStart?.(labelOf(item))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await booksApi.generateLesson(topic, abortController.signal)
|
const result = await requestOf(item)
|
||||||
results[index] = removeGeneratedAdditionalContent(
|
results[index] = removeGeneratedAdditionalContent(
|
||||||
parseTeachingDesign(result.filename, result.markdown),
|
parseTeachingDesign(result.filename, result.markdown),
|
||||||
)
|
)
|
||||||
@@ -264,6 +270,25 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
: { ok: true, completed: appendedCount }
|
: { ok: true, completed: appendedCount }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateLessons(
|
||||||
|
topics: readonly string[],
|
||||||
|
options: BatchGenerateLessonOptions = {},
|
||||||
|
): Promise<BatchGenerateLessonResult> {
|
||||||
|
return runGenerationPool(topics, (topic) => topic, (topic) => booksApi.generateLesson(topic), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateLessonsFromBook(
|
||||||
|
lessons: readonly BookLessonInput[],
|
||||||
|
options: BatchGenerateLessonOptions = {},
|
||||||
|
): Promise<BatchGenerateLessonResult> {
|
||||||
|
return runGenerationPool(
|
||||||
|
lessons,
|
||||||
|
(lesson) => lesson.title,
|
||||||
|
(lesson) => booksApi.generateLesson(lesson.title, { content: lesson.content }),
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
|
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
|
||||||
const existing = book.value.designs.find((d) => d.id === id)
|
const existing = book.value.designs.find((d) => d.id === id)
|
||||||
if (!existing) return { ok: false, message: '找不到该教案。' }
|
if (!existing) return { ok: false, message: '找不到该教案。' }
|
||||||
@@ -305,6 +330,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
clearBook,
|
clearBook,
|
||||||
generateLesson,
|
generateLesson,
|
||||||
generateLessons,
|
generateLessons,
|
||||||
|
generateLessonsFromBook,
|
||||||
regenerateLesson,
|
regenerateLesson,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/services/bookImport.test.ts
Normal file
73
src/services/bookImport.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
assembleContent,
|
||||||
|
matchToc,
|
||||||
|
normalizeTitle,
|
||||||
|
parseMdFiles,
|
||||||
|
type BookEntry,
|
||||||
|
} from './bookImport'
|
||||||
|
|
||||||
|
function makeMdFiles(files: Record<string, string>): File[] {
|
||||||
|
return Object.entries(files).map(([name, content]) => new File([content], name))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeTitle', () => {
|
||||||
|
it('strips .md, illegal chars and collapses whitespace', () => {
|
||||||
|
expect(normalizeTitle('第1章:入门.md')).toBe('第1章_入门')
|
||||||
|
expect(normalizeTitle(' 变量 与 类型 ')).toBe('变量 与 类型')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseMdFiles + matchToc', () => {
|
||||||
|
it('matches toc lines to md files by normalized filename', async () => {
|
||||||
|
const map = await parseMdFiles(
|
||||||
|
makeMdFiles({
|
||||||
|
'第1章 入门.md': '入门正文',
|
||||||
|
'第2章 变量.md': '变量正文',
|
||||||
|
'readme.txt': '忽略非 md',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const entries = matchToc('第1章 入门\n第2章 变量\n第3章 缺失', map)
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(3)
|
||||||
|
expect(entries[0]).toMatchObject({ index: 0, title: '第1章 入门', content: '入门正文', matched: true })
|
||||||
|
expect(entries[1]).toMatchObject({ index: 1, content: '变量正文', matched: true })
|
||||||
|
expect(entries[2]).toMatchObject({ index: 2, content: '', matched: false, charCount: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('infers depth from leading indentation, ranking distinct widths', async () => {
|
||||||
|
const map = await parseMdFiles([])
|
||||||
|
const entries = matchToc(
|
||||||
|
['第1章 入门', ' 1.1 安装', ' 1.2 第一个程序', '第2章 变量'].join('\n'),
|
||||||
|
map,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entries.map((e) => e.depth)).toEqual([0, 1, 1, 0])
|
||||||
|
expect(entries[1]?.title).toBe('1.1 安装')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats tab and space indentation as the same depth unit per document', async () => {
|
||||||
|
const map = await parseMdFiles([])
|
||||||
|
const spaces = matchToc(['章', ' 节'].join('\n'), map)
|
||||||
|
const tabs = matchToc(['章', '\t节'].join('\n'), map)
|
||||||
|
expect(spaces.map((e) => e.depth)).toEqual([0, 1])
|
||||||
|
expect(tabs.map((e) => e.depth)).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores directory prefixes in file names', async () => {
|
||||||
|
const map = await parseMdFiles(makeMdFiles({ 'book/第1章 入门.md': '正文' }))
|
||||||
|
const entries = matchToc('第1章 入门', map)
|
||||||
|
expect(entries[0]?.matched).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('assembleContent', () => {
|
||||||
|
it('concatenates content of all source entries in order', () => {
|
||||||
|
const entries: BookEntry[] = [
|
||||||
|
{ index: 0, title: 'a', depth: 0, content: 'AAA', charCount: 3, matched: true },
|
||||||
|
{ index: 1, title: 'b', depth: 0, content: 'BBB', charCount: 3, matched: true },
|
||||||
|
{ index: 2, title: 'c', depth: 0, content: '', charCount: 0, matched: false },
|
||||||
|
]
|
||||||
|
expect(assembleContent({ title: 'x', sourceIndexes: [0, 1, 2] }, entries)).toBe('AAA\n\nBBB')
|
||||||
|
})
|
||||||
|
})
|
||||||
81
src/services/bookImport.ts
Normal file
81
src/services/bookImport.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/** 目录中的一行,匹配到正文后携带内容与体量。 */
|
||||||
|
export interface BookEntry {
|
||||||
|
index: number
|
||||||
|
title: string
|
||||||
|
/** 由前导缩进推断的层级,0 为顶层(章),数字越大越深(节)。 */
|
||||||
|
depth: number
|
||||||
|
content: string
|
||||||
|
charCount: number
|
||||||
|
matched: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 划分出的课时,引用其所辖的目录条目下标。 */
|
||||||
|
export interface ProposedLesson {
|
||||||
|
title: string
|
||||||
|
sourceIndexes: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化标题/文件名以便匹配:去 .md 后缀、trim、折叠空白、
|
||||||
|
* 把 sanitizeFilename 会替换的非法字符统一成下划线(与服务端一致)。
|
||||||
|
*/
|
||||||
|
export function normalizeTitle(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\.md$/i, '')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取多个 .md 文件,按归一化文件名建立 { 标题 → 正文 } 映射(忽略非 .md)。 */
|
||||||
|
export async function parseMdFiles(files: readonly File[]): Promise<Map<string, string>> {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!/\.md$/i.test(file.name)) continue
|
||||||
|
// 文件名可能带目录前缀(拖拽文件夹时),只取最后一段。
|
||||||
|
const base = file.name.split('/').pop() ?? file.name
|
||||||
|
const key = normalizeTitle(base)
|
||||||
|
if (key) {
|
||||||
|
map.set(key, await file.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行前导空白的宽度(tab 记 4 格),用作缩进度量。 */
|
||||||
|
function indentWidth(line: string): number {
|
||||||
|
const lead = line.match(/^[ \t]*/)?.[0] ?? ''
|
||||||
|
return lead.replace(/\t/g, ' ').length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把目录文本逐行与正文映射匹配,得到带内容与体量的条目列表。
|
||||||
|
* 缩进层级按全文出现的不同缩进宽度排名得出,兼容空格 / tab、2 格 / 4 格等不同风格。
|
||||||
|
*/
|
||||||
|
export function matchToc(tocText: string, contentMap: Map<string, string>): BookEntry[] {
|
||||||
|
const lines = tocText.split('\n').filter((line) => line.trim() !== '')
|
||||||
|
const widths = [...new Set(lines.map(indentWidth))].sort((a, b) => a - b)
|
||||||
|
|
||||||
|
return lines.map((line, index) => {
|
||||||
|
const title = line.trim()
|
||||||
|
const content = contentMap.get(normalizeTitle(title)) ?? ''
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
depth: widths.indexOf(indentWidth(line)),
|
||||||
|
content,
|
||||||
|
charCount: content.length,
|
||||||
|
matched: content !== '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按课时的 sourceIndexes 拼接其所辖目录条目的正文。 */
|
||||||
|
export function assembleContent(lesson: ProposedLesson, entries: readonly BookEntry[]): string {
|
||||||
|
return lesson.sourceIndexes
|
||||||
|
.map((i) => entries.find((entry) => entry.index === i)?.content ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
@@ -80,6 +80,43 @@ describe('booksApi', () => {
|
|||||||
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'CSS 弹性布局' })
|
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'CSS 弹性布局' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('generates a lesson with chapter content as context', async () => {
|
||||||
|
const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' }
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||||||
|
|
||||||
|
await booksApi.generateLesson('CSS 弹性布局', { content: '弹性盒模型正文' })
|
||||||
|
|
||||||
|
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||||||
|
expect(JSON.parse(init?.body as string)).toEqual({
|
||||||
|
topic: 'CSS 弹性布局',
|
||||||
|
content: '弹性盒模型正文',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still accepts an AbortSignal as the second argument', async () => {
|
||||||
|
const result = { filename: 'x.md', markdown: '# x' }
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
await booksApi.generateLesson('x', controller.signal)
|
||||||
|
|
||||||
|
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||||||
|
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'x' })
|
||||||
|
expect(init?.signal).toBe(controller.signal)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('divides lessons from a book table of contents', async () => {
|
||||||
|
const lessons = [{ title: 'C# 入门——搭建环境', sourceIndexes: [0] }]
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ lessons }), { status: 200 }))
|
||||||
|
|
||||||
|
const entries = [{ index: 0, title: '第1章 C# 入门', charCount: 5000 }]
|
||||||
|
await expect(booksApi.divideLessonsFromBook(entries)).resolves.toEqual({ lessons })
|
||||||
|
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0]!
|
||||||
|
expect(url).toBe('/api/generate/lessons-from-book')
|
||||||
|
expect(JSON.parse(init?.body as string)).toEqual({ entries })
|
||||||
|
})
|
||||||
|
|
||||||
it('throws the server error message on failure', async () => {
|
it('throws the server error message on failure', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ error: '整本不存在。' }), { status: 404 }))
|
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ error: '整本不存在。' }), { status: 404 }))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TeachingBook } from '../../shared/domain/teachingDesign'
|
import type { TeachingBook } from '../../shared/domain/teachingDesign'
|
||||||
import { authedFetch } from '../composables/useAuth'
|
import { authedFetch } from '../composables/useAuth'
|
||||||
|
import type { ProposedLesson } from './bookImport'
|
||||||
|
|
||||||
export interface BookSummary {
|
export interface BookSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -51,10 +52,44 @@ export function deleteBook(id: string): Promise<{ ok: true }> {
|
|||||||
return authedFetch(`/api/books/${id}`, { method: 'DELETE' })
|
return authedFetch(`/api/books/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateLesson(topic: string, signal?: AbortSignal): Promise<GenerateResult> {
|
export interface GenerateLessonOptions {
|
||||||
return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }), signal })
|
content?: string
|
||||||
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateOutline(theme: string): Promise<{ titles: string[] }> {
|
export function generateLesson(
|
||||||
return authedFetch('/api/generate/outline', { method: 'POST', body: JSON.stringify({ theme }) })
|
topic: string,
|
||||||
|
optionsOrSignal?: GenerateLessonOptions | AbortSignal,
|
||||||
|
): Promise<GenerateResult> {
|
||||||
|
const options: GenerateLessonOptions =
|
||||||
|
optionsOrSignal instanceof AbortSignal
|
||||||
|
? { signal: optionsOrSignal }
|
||||||
|
: optionsOrSignal ?? {}
|
||||||
|
|
||||||
|
const payload: { topic: string; content?: string } = { topic }
|
||||||
|
if (options.content && options.content.trim() !== '') {
|
||||||
|
payload.content = options.content
|
||||||
|
}
|
||||||
|
|
||||||
|
return authedFetch('/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: options.signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateOutline(theme: string, count = 18): Promise<{ titles: string[] }> {
|
||||||
|
return authedFetch('/api/generate/outline', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ theme, count }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function divideLessonsFromBook(
|
||||||
|
entries: readonly { index: number; title: string; charCount: number; depth: number }[],
|
||||||
|
): Promise<{ lessons: ProposedLesson[] }> {
|
||||||
|
return authedFetch('/api/generate/lessons-from-book', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ entries }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,12 +270,12 @@ table {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-menu {
|
.toolbar-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-menu-list {
|
.toolbar-menu-list {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -290,7 +290,7 @@ table {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-menu-list button {
|
.toolbar-menu-list button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -302,7 +302,7 @@ table {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-menu-list button:hover {
|
.toolbar-menu-list button:hover {
|
||||||
background: var(--green-100);
|
background: var(--green-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,7 +775,7 @@ table {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-toolbar .generate-menu {
|
.workspace-toolbar .toolbar-menu {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,6 +849,20 @@ table {
|
|||||||
border-color: var(--green-600);
|
border-color: var(--green-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-count-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-count-field input {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-topics-count {
|
.batch-topics-count {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -883,3 +897,51 @@ table {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-import-file {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-import-lessons {
|
||||||
|
list-style: none;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-import-lesson {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-import-lesson-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-import-lesson-title:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-import-lesson-source {
|
||||||
|
flex: 0 0 30%;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-import-lesson-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user