update
This commit is contained in:
340
docs/superpowers/plans/2026-06-16-frontend-routing.md
Normal file
340
docs/superpowers/plans/2026-06-16-frontend-routing.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Frontend Routing 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:** Add URL-backed frontend routes for login, book list, book workspace, and admin.
|
||||||
|
|
||||||
|
**Architecture:** Keep route ownership in `src/App.vue` and leave page components event-driven. Use `window.history.pushState`, `window.history.replaceState`, and `popstate` to maintain one small local route state without adding dependencies.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 Composition API, Vite, Vitest, Vue Test Utils, browser History API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/App.test.ts` for TDD coverage of URL-backed navigation and logged-out redirects.
|
||||||
|
- Modify `src/App.vue` to replace local `currentBookId` / `showAdmin` view state with parsed route state.
|
||||||
|
- Do not modify `package.json` or `package-lock.json`; existing user changes there are unrelated.
|
||||||
|
|
||||||
|
## Task 1: Add failing route tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/App.test.ts`
|
||||||
|
- Test: `src/App.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the auth mock with mutable test state**
|
||||||
|
|
||||||
|
Use a hoisted auth state so each test can switch between logged-in and logged-out behavior:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const authState = vi.hoisted(() => {
|
||||||
|
const { computed, ref } = require('vue') as typeof import('vue')
|
||||||
|
return {
|
||||||
|
loggedIn: ref(true),
|
||||||
|
user: ref(null),
|
||||||
|
fetchMe: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('./composables/useAuth', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
isLoggedIn: computed(() => authState.loggedIn.value),
|
||||||
|
fetchMe: authState.fetchMe,
|
||||||
|
user: authState.user,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reset URL and auth state before each test**
|
||||||
|
|
||||||
|
Add this to the existing `beforeEach`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
authState.loggedIn.value = true
|
||||||
|
authState.user.value = null
|
||||||
|
authState.fetchMe.mockReset()
|
||||||
|
window.history.replaceState(null, '', '/books')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add tests for the route behaviors**
|
||||||
|
|
||||||
|
Add these tests:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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 },
|
||||||
|
])
|
||||||
|
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(window.location.pathname).toBe('/books/b1')
|
||||||
|
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 },
|
||||||
|
])
|
||||||
|
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(window.location.pathname).toBe('/books')
|
||||||
|
expect(wrapper.text()).toContain('教学设计')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the admin route from the book list', async () => {
|
||||||
|
authState.user.value = { id: 'u1', username: 'admin', role: 'admin' }
|
||||||
|
vi.mocked(booksApi.listBooks).mockResolvedValue([])
|
||||||
|
|
||||||
|
const wrapper = mount(App)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/admin')
|
||||||
|
expect(wrapper.text()).toContain('用户管理')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes logged-out users to login', async () => {
|
||||||
|
authState.loggedIn.value = false
|
||||||
|
window.history.replaceState(null, '', '/books/b1')
|
||||||
|
|
||||||
|
const wrapper = mount(App)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/login')
|
||||||
|
expect(wrapper.text()).toContain('登录')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk npm test -- src/App.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `App.vue` does not update `window.location.pathname` for book/admin navigation and does not redirect logged-out users.
|
||||||
|
|
||||||
|
## Task 2: Implement route state in App.vue
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/App.vue`
|
||||||
|
- Test: `src/App.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace local page flags with route state**
|
||||||
|
|
||||||
|
In `src/App.vue`, replace `currentBookId` and `showAdmin` with this route model:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AppRoute =
|
||||||
|
| { name: 'login' }
|
||||||
|
| { name: 'books' }
|
||||||
|
| { name: 'book'; bookId: string }
|
||||||
|
| { name: 'admin' }
|
||||||
|
|
||||||
|
const route = ref<AppRoute>(parseRoute(window.location.pathname))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add route parsing and navigation helpers**
|
||||||
|
|
||||||
|
Add helpers in `src/App.vue`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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]) {
|
||||||
|
return { name: 'book', bookId: decodeURIComponent(bookMatch[1]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: 'books' }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire mount, popstate, and auth redirects**
|
||||||
|
|
||||||
|
Update imports and lifecycle:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
```
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function syncRouteForAuth(): void {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
replaceRoute({ name: 'login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.value.name === 'login') {
|
||||||
|
replaceRoute({ name: 'books' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopState(): void {
|
||||||
|
route.value = parseRoute(window.location.pathname)
|
||||||
|
syncRouteForAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
await fetchMe()
|
||||||
|
syncRouteForAuth()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('popstate', handlePopState)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isLoggedIn, syncRouteForAuth)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Map existing component events to route navigation**
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function handleLoginSuccess(): Promise<void> {
|
||||||
|
await fetchMe()
|
||||||
|
pushRoute({ name: 'books' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBook(id: string): void {
|
||||||
|
pushRoute({ name: 'book', bookId: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToList(): void {
|
||||||
|
pushRoute({ name: 'books' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdmin(): void {
|
||||||
|
pushRoute({ name: 'admin' })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update template route conditions**
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<LoginPage v-if="route.name === 'login'" @success="handleLoginSuccess" />
|
||||||
|
<template v-else>
|
||||||
|
<AdminPage v-if="route.name === 'admin'" @back="backToList" />
|
||||||
|
<WorkspaceView
|
||||||
|
v-else-if="route.name === 'book'"
|
||||||
|
:key="route.bookId"
|
||||||
|
:book-id="route.bookId"
|
||||||
|
@back="backToList"
|
||||||
|
/>
|
||||||
|
<BookListPage v-else @open="openBook" @admin="openAdmin" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run test to verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk npm test -- src/App.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS for all `App.test.ts` cases.
|
||||||
|
|
||||||
|
## Task 3: Final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `src/App.vue`
|
||||||
|
- Verify: `src/App.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk npm test -- src/App.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full frontend test suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run production build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Review git diff**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk git diff -- src/App.vue src/App.test.ts docs/superpowers/plans/2026-06-16-frontend-routing.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: diff only contains routing implementation, routing tests, and this plan.
|
||||||
152
package-lock.json
generated
152
package-lock.json
generated
@@ -11,21 +11,22 @@
|
|||||||
"hono": "^4.12.25",
|
"hono": "^4.12.25",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "^14.2.0",
|
"markdown-it": "^14.2.0",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^25.9.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.8",
|
"vitest": "^4.1.9",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.3.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -754,6 +755,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bun": {
|
||||||
|
"version": "1.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz",
|
||||||
|
"integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bun-types": "1.3.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -805,14 +816,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "25.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
@@ -833,15 +843,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/coverage-v8": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||||
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
|
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bcoe/v8-coverage": "^1.0.2",
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"ast-v8-to-istanbul": "^1.0.0",
|
"ast-v8-to-istanbul": "^1.0.0",
|
||||||
"istanbul-lib-coverage": "^3.2.2",
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
"istanbul-lib-report": "^3.0.1",
|
"istanbul-lib-report": "^3.0.1",
|
||||||
@@ -855,8 +865,8 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vitest/browser": "4.1.8",
|
"@vitest/browser": "4.1.9",
|
||||||
"vitest": "4.1.8"
|
"vitest": "4.1.9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@vitest/browser": {
|
"@vitest/browser": {
|
||||||
@@ -865,16 +875,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.1.8",
|
"@vitest/spy": "4.1.9",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"chai": "^6.2.2",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -883,13 +893,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.8",
|
"@vitest/spy": "4.1.9",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
@@ -920,9 +930,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -933,13 +943,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -947,14 +957,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.8",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -963,9 +973,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -973,13 +983,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.8",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -1292,6 +1302,16 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bun-types": {
|
||||||
|
"version": "1.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz",
|
||||||
|
"integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
@@ -2918,9 +2938,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -3010,20 +3030,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.8",
|
"@vitest/expect": "4.1.9",
|
||||||
"@vitest/mocker": "4.1.8",
|
"@vitest/mocker": "4.1.9",
|
||||||
"@vitest/pretty-format": "4.1.8",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"@vitest/runner": "4.1.8",
|
"@vitest/runner": "4.1.9",
|
||||||
"@vitest/snapshot": "4.1.8",
|
"@vitest/snapshot": "4.1.9",
|
||||||
"@vitest/spy": "4.1.8",
|
"@vitest/spy": "4.1.9",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"es-module-lexer": "^2.0.0",
|
"es-module-lexer": "^2.0.0",
|
||||||
"expect-type": "^1.3.0",
|
"expect-type": "^1.3.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -3051,12 +3071,12 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.1.8",
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
"@vitest/browser-preview": "4.1.8",
|
"@vitest/browser-preview": "4.1.9",
|
||||||
"@vitest/browser-webdriverio": "4.1.8",
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
"@vitest/coverage-istanbul": "4.1.8",
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
"@vitest/coverage-v8": "4.1.8",
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
"@vitest/ui": "4.1.8",
|
"@vitest/ui": "4.1.9",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*",
|
"jsdom": "*",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -18,21 +18,21 @@
|
|||||||
"hono": "^4.12.25",
|
"hono": "^4.12.25",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "^14.2.0",
|
"markdown-it": "^14.2.0",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^25.9.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.8",
|
"vitest": "^4.1.9",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { createEmptyBook } from './domain/teachingDesign'
|
import { createEmptyBook } from './domain/teachingDesign'
|
||||||
import * as booksApi from './services/booksApi'
|
import * as booksApi from './services/booksApi'
|
||||||
|
|
||||||
vi.mock('./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', () => ({
|
vi.mock('./composables/useAuth', () => ({
|
||||||
|
authedFetch: authState.authedFetch,
|
||||||
useAuth: () => ({
|
useAuth: () => ({
|
||||||
isLoggedIn: computed(() => true),
|
fetchMe: authState.fetchMe,
|
||||||
fetchMe: vi.fn(),
|
isLoggedIn: computed(() => authState.loggedIn),
|
||||||
user: ref(null),
|
login: authState.login,
|
||||||
|
logout: authState.logout,
|
||||||
|
user: computed(() => authState.user),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
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 () => {
|
it('starts with the book list entry page', async () => {
|
||||||
@@ -29,7 +46,7 @@ describe('App', () => {
|
|||||||
expect(wrapper.text()).toContain('新建整本')
|
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([
|
vi.mocked(booksApi.listBooks).mockResolvedValue([
|
||||||
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
|
{ 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 wrapper.get('[data-testid="open-b1"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/books/b1')
|
||||||
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
|
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([
|
vi.mocked(booksApi.listBooks).mockResolvedValue([
|
||||||
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
|
{ 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 wrapper.get('[data-testid="back"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/books')
|
||||||
expect(wrapper.text()).toContain('教学设计')
|
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('登录')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
110
src/App.vue
110
src/App.vue
@@ -1,52 +1,126 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import AdminPage from './components/AdminPage.vue'
|
import AdminPage from './components/AdminPage.vue'
|
||||||
import BookListPage from './components/BookListPage.vue'
|
import BookListPage from './components/BookListPage.vue'
|
||||||
import LoginPage from './components/LoginPage.vue'
|
import LoginPage from './components/LoginPage.vue'
|
||||||
import WorkspaceView from './components/WorkspaceView.vue'
|
import WorkspaceView from './components/WorkspaceView.vue'
|
||||||
import { useAuth } from './composables/useAuth'
|
import { useAuth } from './composables/useAuth'
|
||||||
|
|
||||||
|
type AppRoute =
|
||||||
|
| { name: 'login' }
|
||||||
|
| { name: 'books' }
|
||||||
|
| { name: 'book'; bookId: string }
|
||||||
|
| { name: 'admin' }
|
||||||
|
|
||||||
const { isLoggedIn, fetchMe } = useAuth()
|
const { isLoggedIn, fetchMe } = useAuth()
|
||||||
const currentBookId = ref<string | null>(null)
|
const route = ref<AppRoute>(getInitialRoute())
|
||||||
const showAdmin = ref(false)
|
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
await fetchMe()
|
await fetchMe()
|
||||||
|
syncRouteForAuth()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('popstate', handlePopState)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleLoginSuccess(): Promise<void> {
|
async function handleLoginSuccess(): Promise<void> {
|
||||||
showAdmin.value = false
|
|
||||||
currentBookId.value = null
|
|
||||||
await fetchMe()
|
await fetchMe()
|
||||||
|
if (isLoggedIn.value) {
|
||||||
|
pushRoute({ name: 'books' })
|
||||||
|
} else {
|
||||||
|
replaceRoute({ name: 'login' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBook(id: string): void {
|
function openBook(id: string): void {
|
||||||
currentBookId.value = id
|
pushRoute({ name: 'book', bookId: id })
|
||||||
showAdmin.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToList(): void {
|
function backToList(): void {
|
||||||
currentBookId.value = null
|
pushRoute({ name: 'books' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAdmin(): void {
|
function openAdmin(): void {
|
||||||
showAdmin.value = true
|
pushRoute({ name: 'admin' })
|
||||||
currentBookId.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAdmin(): void {
|
watch(isLoggedIn, syncRouteForAuth)
|
||||||
showAdmin.value = false
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoginPage v-if="!isLoggedIn" @success="handleLoginSuccess" />
|
<LoginPage v-if="route.name === 'login'" @success="handleLoginSuccess" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<AdminPage v-if="showAdmin" @back="closeAdmin" />
|
<AdminPage v-if="route.name === 'admin'" @back="backToList" />
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
v-else-if="currentBookId"
|
v-else-if="route.name === 'book'"
|
||||||
:key="currentBookId"
|
:key="route.bookId"
|
||||||
:book-id="currentBookId"
|
:book-id="route.bookId"
|
||||||
@back="backToList"
|
@back="backToList"
|
||||||
/>
|
/>
|
||||||
<BookListPage v-else @open="openBook" @admin="openAdmin" />
|
<BookListPage v-else @open="openBook" @admin="openAdmin" />
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { flushPromises, mount } from '@vue/test-utils'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||||
import * as booksApi from '../services/booksApi'
|
import * as booksApi from '../services/booksApi'
|
||||||
|
import * as zipExporter from '../services/zipExporter'
|
||||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||||
import WorkspaceView from './WorkspaceView.vue'
|
import WorkspaceView from './WorkspaceView.vue'
|
||||||
|
|
||||||
vi.mock('../services/booksApi')
|
vi.mock('../services/booksApi')
|
||||||
|
vi.mock('../services/zipExporter')
|
||||||
|
|
||||||
function mockBook(data = createEmptyBook()): void {
|
function mockBook(data = createEmptyBook()): void {
|
||||||
vi.mocked(booksApi.getBook).mockResolvedValue({
|
vi.mocked(booksApi.getBook).mockResolvedValue({
|
||||||
@@ -189,4 +191,20 @@ describe('WorkspaceView', () => {
|
|||||||
|
|
||||||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import UploadDropzone from './UploadDropzone.vue'
|
|||||||
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
||||||
|
|
||||||
const BATCH_GENERATE_CONCURRENCY = 3
|
const BATCH_GENERATE_CONCURRENCY = 3
|
||||||
|
const DEFAULT_EXPORT_ZIP_NAME = 'teaching-design-book'
|
||||||
|
|
||||||
const props = defineProps<{ bookId: string }>()
|
const props = defineProps<{ bookId: string }>()
|
||||||
|
|
||||||
@@ -104,10 +105,15 @@ function handlePrint(): void {
|
|||||||
document.title = prev
|
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> {
|
async function handleExport(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const blob = await createBookZip(book.value.designs)
|
const blob = await createBookZip(book.value.designs)
|
||||||
downloadBlob(blob, 'teaching-design-book.zip')
|
downloadBlob(blob, createExportZipFilename(bookName.value))
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage.value = '导出失败,请重试。'
|
errorMessage.value = '导出失败,请重试。'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-button:disabled {
|
.ui-button:disabled {
|
||||||
color: var(--muted);
|
background: #f4f6f7;
|
||||||
|
color: #43515c;
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-button--primary {
|
.ui-button--primary {
|
||||||
@@ -195,10 +195,10 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace-toolbar button:disabled {
|
.workspace-toolbar button:disabled {
|
||||||
color: var(--muted);
|
background: #f4f6f7;
|
||||||
|
color: #43515c;
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-toolbar-count,
|
.workspace-toolbar-count,
|
||||||
|
|||||||
31
src/style.test.ts
Normal file
31
src/style.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user