feat: switch between book list and workspace view in App
This commit is contained in:
@@ -1,13 +1,66 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { createEmptyBook } from './domain/teachingDesign'
|
||||||
|
import * as booksApi from './services/booksApi'
|
||||||
|
|
||||||
|
vi.mock('./services/booksApi')
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(() => localStorage.clear())
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with the book list entry page', async () => {
|
||||||
|
vi.mocked(booksApi.listBooks).mockResolvedValue([])
|
||||||
|
|
||||||
it('starts with the multi-file upload screen', () => {
|
|
||||||
const wrapper = mount(App)
|
const wrapper = mount(App)
|
||||||
expect(wrapper.get('input[type="file"]').attributes('multiple')).toBeDefined()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('上传 Markdown')
|
|
||||||
|
expect(wrapper.text()).toContain('教学设计整本')
|
||||||
|
expect(wrapper.text()).toContain('新建整本')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to the workspace view when a book is opened', async () => {
|
||||||
|
vi.mocked(booksApi.listBooks).mockResolvedValue([
|
||||||
|
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
|
||||||
|
])
|
||||||
|
vi.mocked(booksApi.getBook).mockResolvedValue({
|
||||||
|
id: 'b1',
|
||||||
|
name: '示例整本',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: createEmptyBook(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(App)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="open-b1"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns to the book list when back is emitted', async () => {
|
||||||
|
vi.mocked(booksApi.listBooks).mockResolvedValue([
|
||||||
|
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
|
||||||
|
])
|
||||||
|
vi.mocked(booksApi.getBook).mockResolvedValue({
|
||||||
|
id: 'b1',
|
||||||
|
name: '示例整本',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: createEmptyBook(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(App)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="open-b1"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="back"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('教学设计整本')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
174
src/App.vue
174
src/App.vue
@@ -1,174 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import A4Workspace from './components/A4Workspace.vue'
|
import BookListPage from './components/BookListPage.vue'
|
||||||
import ImportConflictDialog from './components/ImportConflictDialog.vue'
|
import WorkspaceView from './components/WorkspaceView.vue'
|
||||||
import LessonSidebar from './components/LessonSidebar.vue'
|
|
||||||
import PrintBook from './components/PrintBook.vue'
|
|
||||||
import RestoreDraftDialog from './components/RestoreDraftDialog.vue'
|
|
||||||
import UploadDropzone from './components/UploadDropzone.vue'
|
|
||||||
import WorkspaceToolbar from './components/WorkspaceToolbar.vue'
|
|
||||||
import { type DuplicateStrategy, useTeachingBook } from './composables/useTeachingBook'
|
|
||||||
import { clearStoredBook, loadStoredBook } from './services/bookStorage'
|
|
||||||
import type { TeachingBook, TeachingDesign } from './domain/teachingDesign'
|
|
||||||
import { createBookZip, downloadBlob } from './services/zipExporter'
|
|
||||||
|
|
||||||
const {
|
const currentBookId = ref<string | null>(null)
|
||||||
book,
|
|
||||||
saveStatus,
|
|
||||||
lastError,
|
|
||||||
selectedDesign,
|
|
||||||
hasDesigns,
|
|
||||||
warningCount,
|
|
||||||
importFiles,
|
|
||||||
detectDuplicates,
|
|
||||||
selectPage,
|
|
||||||
moveDesign,
|
|
||||||
removeDesign,
|
|
||||||
updateCover,
|
|
||||||
updateDesign,
|
|
||||||
restore,
|
|
||||||
clearBook,
|
|
||||||
} = useTeachingBook()
|
|
||||||
|
|
||||||
const restoreCandidate = ref<TeachingBook | null>(null)
|
function openBook(id: string): void {
|
||||||
const pendingFiles = ref<File[]>([])
|
currentBookId.value = id
|
||||||
const duplicateNames = ref<string[]>([])
|
|
||||||
const errorMessage = ref<string | null>(null)
|
|
||||||
const uploadRef = ref<InstanceType<typeof UploadDropzone> | null>(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const stored = loadStoredBook()
|
|
||||||
if (stored && stored.designs.length > 0) {
|
|
||||||
restoreCandidate.value = stored
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function restoreDraft(): void {
|
|
||||||
if (restoreCandidate.value) {
|
|
||||||
restore(restoreCandidate.value)
|
|
||||||
}
|
|
||||||
restoreCandidate.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardDraft(): void {
|
function backToList(): void {
|
||||||
clearStoredBook()
|
currentBookId.value = null
|
||||||
restoreCandidate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runImport(files: File[], strategy: DuplicateStrategy): Promise<void> {
|
|
||||||
const result = await importFiles(files, strategy)
|
|
||||||
if (result.failed.length > 0) {
|
|
||||||
errorMessage.value = `${result.failed.length} 个文件导入失败:${result.failed
|
|
||||||
.map((entry) => `${entry.filename}(${entry.message})`)
|
|
||||||
.join('、')}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFiles(files: File[]): Promise<void> {
|
|
||||||
const duplicates = detectDuplicates(files)
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
pendingFiles.value = files
|
|
||||||
duplicateNames.value = duplicates
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await runImport(files, 'keep')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveConflict(strategy: DuplicateStrategy | 'cancel'): Promise<void> {
|
|
||||||
const files = pendingFiles.value
|
|
||||||
pendingFiles.value = []
|
|
||||||
duplicateNames.value = []
|
|
||||||
if (strategy === 'cancel') return
|
|
||||||
await runImport(files, strategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerUpload(): void {
|
|
||||||
uploadRef.value?.openPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePrint(): void {
|
|
||||||
window.print()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExport(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const blob = await createBookZip(book.value.designs)
|
|
||||||
downloadBlob(blob, 'teaching-design-book.zip')
|
|
||||||
} catch {
|
|
||||||
errorMessage.value = '导出失败,请重试。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear(): void {
|
|
||||||
if (book.value.designs.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (window.confirm('确定要清空当前所有教案吗?此操作无法撤销。')) {
|
|
||||||
clearBook()
|
|
||||||
clearStoredBook()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDesignUpdate(design: TeachingDesign): void {
|
|
||||||
updateDesign(design.id, (target) => Object.assign(target, design))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<BookListPage v-if="!currentBookId" @open="openBook" />
|
||||||
<RestoreDraftDialog
|
<WorkspaceView v-else :key="currentBookId" :book-id="currentBookId" @back="backToList" />
|
||||||
v-if="restoreCandidate"
|
|
||||||
:updated-at="restoreCandidate.updatedAt"
|
|
||||||
@restore="restoreDraft"
|
|
||||||
@discard="discardDraft"
|
|
||||||
/>
|
|
||||||
<ImportConflictDialog
|
|
||||||
v-if="duplicateNames.length > 0"
|
|
||||||
:duplicates="duplicateNames"
|
|
||||||
@replace="resolveConflict('replace')"
|
|
||||||
@keep="resolveConflict('keep')"
|
|
||||||
@cancel="resolveConflict('cancel')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p v-if="errorMessage" class="app-notice app-notice--error" role="alert">
|
|
||||||
{{ errorMessage }}
|
|
||||||
<button type="button" @click="errorMessage = null">关闭</button>
|
|
||||||
</p>
|
|
||||||
<p v-if="saveStatus === 'error' && lastError" class="app-notice app-notice--error" role="alert">
|
|
||||||
{{ lastError }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<UploadDropzone v-if="!hasDesigns" @files="handleFiles" />
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<WorkspaceToolbar
|
|
||||||
:lesson-count="book.designs.length"
|
|
||||||
:warning-count="warningCount"
|
|
||||||
:save-status="saveStatus"
|
|
||||||
@upload="triggerUpload"
|
|
||||||
@print="handlePrint"
|
|
||||||
@export="handleExport"
|
|
||||||
@clear="handleClear"
|
|
||||||
/>
|
|
||||||
<div class="workspace-layout">
|
|
||||||
<LessonSidebar
|
|
||||||
:designs="book.designs"
|
|
||||||
:selected-id="book.selectedId"
|
|
||||||
@select="selectPage"
|
|
||||||
@remove="removeDesign"
|
|
||||||
@move="moveDesign"
|
|
||||||
/>
|
|
||||||
<A4Workspace
|
|
||||||
:cover="book.cover"
|
|
||||||
:selected-id="book.selectedId"
|
|
||||||
:selected-design="selectedDesign"
|
|
||||||
@update:cover="updateCover"
|
|
||||||
@update:design="handleDesignUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<UploadDropzone ref="uploadRef" compact class="visually-hidden" @files="handleFiles" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<PrintBook :cover="book.cover" :designs="book.designs" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user