Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d54750b37 | ||
|
|
7da3f15ecd | ||
|
|
5723470f65 | ||
|
|
78a082b273 | ||
|
|
462714f45e | ||
|
|
1fb8fe6680 | ||
|
|
4e23d3b163 |
@@ -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` 公开接口或文案的任何改动。
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -54,7 +54,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,39 @@
|
|||||||
<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: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
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">
|
||||||
<button
|
<template #default="{ close }">
|
||||||
type="button"
|
|
||||||
data-testid="generate-menu-toggle"
|
|
||||||
:aria-expanded="open"
|
|
||||||
@click.stop="toggle"
|
|
||||||
>
|
|
||||||
生成教案 ▾
|
|
||||||
</button>
|
|
||||||
<ul v-if="open" class="generate-menu-list" role="menu">
|
|
||||||
<li role="menuitem">
|
<li role="menuitem">
|
||||||
<button type="button" data-testid="batch-generate" @click="select('batchGenerate')">
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="batch-generate"
|
||||||
|
@click="
|
||||||
|
emit('batchGenerate');
|
||||||
|
close()
|
||||||
|
"
|
||||||
|
>
|
||||||
批量生成
|
批量生成
|
||||||
</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>
|
</template>
|
||||||
</div>
|
</ToolbarMenuButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.stop="toggle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
<ul v-if="open" class="toolbar-menu-list" role="menu">
|
||||||
|
<slot :close="close" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -42,10 +42,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<{
|
||||||
@@ -30,8 +31,7 @@ const saveStatusLabel: Record<SaveStatus, string> = {
|
|||||||
<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 @generate="$emit('generate')" @batch-generate="$emit('batchGenerate')" />
|
||||||
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
<ExportMenuButton :disabled="lessonCount === 0" @print="$emit('print')" @export="$emit('export')" />
|
||||||
<button type="button" data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 MD</button>
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -204,6 +204,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()
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user