This commit is contained in:
2026-06-16 08:18:10 -06:00
parent 19cc1ffdfa
commit 660039b3cf
9 changed files with 639 additions and 103 deletions

View File

@@ -1,22 +1,39 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App.vue'
import { createEmptyBook } from './domain/teachingDesign'
import * as booksApi from './services/booksApi'
vi.mock('./services/booksApi')
const authState = vi.hoisted(() => ({
authedFetch: vi.fn(),
fetchMe: vi.fn(),
loggedIn: true,
login: vi.fn(),
logout: vi.fn(),
user: null as { id: string; username: string; role: 'admin' | 'user' } | null,
}))
vi.mock('./composables/useAuth', () => ({
authedFetch: authState.authedFetch,
useAuth: () => ({
isLoggedIn: computed(() => true),
fetchMe: vi.fn(),
user: ref(null),
fetchMe: authState.fetchMe,
isLoggedIn: computed(() => authState.loggedIn),
login: authState.login,
logout: authState.logout,
user: computed(() => authState.user),
}),
}))
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
authState.authedFetch.mockResolvedValue([])
authState.loggedIn = true
authState.user = null
window.history.replaceState(null, '', '/books')
})
it('starts with the book list entry page', async () => {
@@ -29,7 +46,7 @@ describe('App', () => {
expect(wrapper.text()).toContain('新建整本')
})
it('switches to the workspace view when a book is opened', async () => {
it('opens a book route when a book is selected', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
@@ -46,10 +63,11 @@ describe('App', () => {
await wrapper.get('[data-testid="open-b1"]').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/books/b1')
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
})
it('returns to the book list when back is emitted', async () => {
it('returns to the books route from the workspace', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
@@ -69,6 +87,35 @@ describe('App', () => {
await wrapper.get('[data-testid="back"]').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/books')
expect(wrapper.text()).toContain('教学设计')
})
it('opens the admin route from the book list', async () => {
authState.user = { id: 'u1', username: 'admin', role: 'admin' }
vi.mocked(booksApi.listBooks).mockResolvedValue([])
const wrapper = mount(App)
await flushPromises()
const adminButton = wrapper.findAll('button').find((button) => button.text() === '用户管理')
expect(adminButton).toBeDefined()
await adminButton!.trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/admin')
expect(wrapper.text()).toContain('用户管理')
})
it('routes logged-out users to login', async () => {
authState.loggedIn = false
window.history.replaceState(null, '', '/books/b1')
const wrapper = mount(App)
await flushPromises()
expect(window.location.pathname).toBe('/login')
expect(wrapper.text()).toContain('登录')
})
})

View File

@@ -1,52 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AdminPage from './components/AdminPage.vue'
import BookListPage from './components/BookListPage.vue'
import LoginPage from './components/LoginPage.vue'
import WorkspaceView from './components/WorkspaceView.vue'
import { useAuth } from './composables/useAuth'
type AppRoute =
| { name: 'login' }
| { name: 'books' }
| { name: 'book'; bookId: string }
| { name: 'admin' }
const { isLoggedIn, fetchMe } = useAuth()
const currentBookId = ref<string | null>(null)
const showAdmin = ref(false)
const route = ref<AppRoute>(getInitialRoute())
function parseRoute(pathname: string): AppRoute {
if (pathname === '/login') return { name: 'login' }
if (pathname === '/admin') return { name: 'admin' }
if (pathname === '/books') return { name: 'books' }
const bookMatch = pathname.match(/^\/books\/([^/]+)$/)
if (bookMatch?.[1]) {
try {
return { name: 'book', bookId: decodeURIComponent(bookMatch[1]) }
} catch {
return { name: 'books' }
}
}
return { name: 'books' }
}
function getInitialRoute(): AppRoute {
const parsed = parseRoute(window.location.pathname)
return isLoggedIn.value ? parsed : { name: 'login' }
}
function routeToPath(nextRoute: AppRoute): string {
if (nextRoute.name === 'login') return '/login'
if (nextRoute.name === 'admin') return '/admin'
if (nextRoute.name === 'book') return `/books/${encodeURIComponent(nextRoute.bookId)}`
return '/books'
}
function replaceRoute(nextRoute: AppRoute): void {
const path = routeToPath(nextRoute)
route.value = nextRoute
if (window.location.pathname !== path) {
window.history.replaceState(null, '', path)
}
}
function pushRoute(nextRoute: AppRoute): void {
const path = routeToPath(nextRoute)
route.value = nextRoute
if (window.location.pathname !== path) {
window.history.pushState(null, '', path)
}
}
function syncRouteForAuth(): void {
if (!isLoggedIn.value) {
replaceRoute({ name: 'login' })
return
}
if (route.value.name === 'login') {
replaceRoute({ name: 'books' })
return
}
replaceRoute(route.value)
}
function handlePopState(): void {
route.value = parseRoute(window.location.pathname)
syncRouteForAuth()
}
onMounted(async () => {
window.addEventListener('popstate', handlePopState)
await fetchMe()
syncRouteForAuth()
})
onBeforeUnmount(() => {
window.removeEventListener('popstate', handlePopState)
})
async function handleLoginSuccess(): Promise<void> {
showAdmin.value = false
currentBookId.value = null
await fetchMe()
if (isLoggedIn.value) {
pushRoute({ name: 'books' })
} else {
replaceRoute({ name: 'login' })
}
}
function openBook(id: string): void {
currentBookId.value = id
showAdmin.value = false
pushRoute({ name: 'book', bookId: id })
}
function backToList(): void {
currentBookId.value = null
pushRoute({ name: 'books' })
}
function openAdmin(): void {
showAdmin.value = true
currentBookId.value = null
pushRoute({ name: 'admin' })
}
function closeAdmin(): void {
showAdmin.value = false
}
watch(isLoggedIn, syncRouteForAuth)
</script>
<template>
<LoginPage v-if="!isLoggedIn" @success="handleLoginSuccess" />
<LoginPage v-if="route.name === 'login'" @success="handleLoginSuccess" />
<template v-else>
<AdminPage v-if="showAdmin" @back="closeAdmin" />
<AdminPage v-if="route.name === 'admin'" @back="backToList" />
<WorkspaceView
v-else-if="currentBookId"
:key="currentBookId"
:book-id="currentBookId"
v-else-if="route.name === 'book'"
:key="route.bookId"
:book-id="route.bookId"
@back="backToList"
/>
<BookListPage v-else @open="openBook" @admin="openAdmin" />

View File

@@ -2,11 +2,13 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
import * as booksApi from '../services/booksApi'
import * as zipExporter from '../services/zipExporter'
import BatchGenerateDialog from './BatchGenerateDialog.vue'
import GenerateLessonDialog from './GenerateLessonDialog.vue'
import WorkspaceView from './WorkspaceView.vue'
vi.mock('../services/booksApi')
vi.mock('../services/zipExporter')
function mockBook(data = createEmptyBook()): void {
vi.mocked(booksApi.getBook).mockResolvedValue({
@@ -189,4 +191,20 @@ describe('WorkspaceView', () => {
expect(wrapper.text()).toContain('点击或拖拽上传')
})
it('downloads the exported zip with the book name', async () => {
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))
const blob = new Blob(['zip'])
mockBook(data)
vi.mocked(zipExporter.createBookZip).mockResolvedValue(blob)
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
await flushPromises()
await wrapper.get('[data-testid="export"]').trigger('click')
await flushPromises()
expect(zipExporter.downloadBlob).toHaveBeenCalledWith(blob, '示例整本.zip')
})
})

View File

@@ -14,6 +14,7 @@ import UploadDropzone from './UploadDropzone.vue'
import WorkspaceToolbar from './WorkspaceToolbar.vue'
const BATCH_GENERATE_CONCURRENCY = 3
const DEFAULT_EXPORT_ZIP_NAME = 'teaching-design-book'
const props = defineProps<{ bookId: string }>()
@@ -104,10 +105,15 @@ function handlePrint(): void {
document.title = prev
}
function createExportZipFilename(name: string): string {
const stem = name.trim().replace(/[\\/:*?"<>|]/g, '_')
return `${stem || DEFAULT_EXPORT_ZIP_NAME}.zip`
}
async function handleExport(): Promise<void> {
try {
const blob = await createBookZip(book.value.designs)
downloadBlob(blob, 'teaching-design-book.zip')
downloadBlob(blob, createExportZipFilename(bookName.value))
} catch {
errorMessage.value = '导出失败,请重试。'
}

View File

@@ -82,10 +82,10 @@ input {
}
.ui-button:disabled {
color: var(--muted);
background: #f4f6f7;
color: #43515c;
border-color: var(--line);
cursor: not-allowed;
opacity: 0.6;
}
.ui-button--primary {
@@ -195,10 +195,10 @@ input {
}
.workspace-toolbar button:disabled {
color: var(--muted);
background: #f4f6f7;
color: #43515c;
border-color: var(--line);
cursor: not-allowed;
opacity: 0.6;
}
.workspace-toolbar-count,

31
src/style.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import './style.css'
describe('shared button styles', () => {
it('keeps disabled shared button text readable without lowering opacity', () => {
const button = document.createElement('button')
button.className = 'ui-button ui-button--primary'
button.disabled = true
button.textContent = 'Create'
document.body.append(button)
expect(getComputedStyle(button).opacity).toBe('1')
button.remove()
})
it('keeps disabled toolbar button text readable without lowering opacity', () => {
const toolbar = document.createElement('div')
toolbar.className = 'workspace-toolbar'
const button = document.createElement('button')
button.disabled = true
button.textContent = 'Export'
toolbar.append(button)
document.body.append(toolbar)
expect(getComputedStyle(button).opacity).toBe('1')
toolbar.remove()
})
})