提交 979bea76 authored 作者: 王鹏飞's avatar 王鹏飞

docs: add stage zero monorepo implementation plan

上级 a33c2ebd
# Stage 0 Monorepo Foundation 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:** Convert the existing `center-book` repository into a pnpm Monorepo with the legacy admin app under `apps/admin`, a TypeScript learning app foundation under `apps/learning`, URL-restorable page state, and working local `/admin` proxy and production build outputs.
**Architecture:** Keep admin and learning as separate Vite applications with separate routers, state, API clients, dependencies, and outputs. The learning app follows the `saas-bi` module structure, uses TanStack React Query for server state, nuqs for recoverable URL state, and Zustand only for transient cross-component client state.
**Tech Stack:** pnpm 10.33.0, React 18.3.1, Vite 7.3.1, TypeScript, React Router 7, TanStack React Query 5, nuqs 2, Zustand 5, Axios, Vitest, Testing Library.
## Global Constraints
- Work only on branch `next` in `/Users/max/code/book/admin/center-book`.
- Preserve the repository name `center-book`.
- Do not modify `com-ebook-app-api`, `com-ebook-pc-api`, `book-app`, or `book-app-h5` in Stage 0.
- Do not refactor existing admin business pages while moving them.
- The admin app remains JavaScript/JSX and may retain its existing Redux usage.
- The learning app must use TypeScript and must not depend on Redux, Redux Toolkit, React Redux, or Redux Persist.
- The learning app uses TanStack React Query for all server state.
- The learning app uses nuqs for recoverable and shareable page operation state.
- The learning app uses Zustand only for transient cross-component client state that is neither URL state nor server state.
- Admin builds to `dist/admin`; learning builds to `dist/learning`.
- Admin is served at `/admin/`; learning is served at `/`.
- Do not push or deploy. Local commits are required after each task.
- Preserve user changes. Stop if the worktree contains unexpected modifications before a task.
---
## File Map
### Workspace root
- `package.json`: workspace commands and pinned pnpm version.
- `pnpm-workspace.yaml`: workspace package discovery.
- `pnpm-lock.yaml`: single dependency lockfile.
- `.gitignore`: root build and package-manager exclusions.
- `scripts/verify-build-layout.mjs`: verifies both production entry files exist.
- `AGENTS.md`: root boundaries and commands for future AI work.
### Admin application
- `apps/admin/*`: mechanically moved legacy application.
- `apps/admin/vite.config.js`: `/admin/` base, port 5174, admin API proxy, `dist/admin` output.
- `apps/admin/src/utils/adminPaths.js`: central admin browser and API path helpers.
- `apps/admin/src/utils/adminPaths.test.js`: path behavior characterization tests.
- `apps/admin/src/main.jsx`: BrowserRouter basename.
- `apps/admin/src/utils/request.js`: normalized `/api/admin` gateway use.
- `apps/admin/src/utils/axios.js`: normalized `/api/admin` gateway use.
- `apps/admin/index.html`: formula editor path under `/admin/`.
### Learning application
- `apps/learning/package.json`: learning-only runtime and test dependencies.
- `apps/learning/tsconfig*.json`: strict TypeScript configuration.
- `apps/learning/vite.config.ts`: root base, port 5173, local `/admin` and API proxies, `dist/learning` output.
- `apps/learning/src/main.tsx`: QueryClient and Router providers.
- `apps/learning/src/router/RootLayout.tsx`: NuqsAdapter inside React Router context.
- `apps/learning/src/router/routes.tsx`: module route aggregation and `/library` default.
- `apps/learning/src/utils/http.ts`: browser learning API client.
- `apps/learning/src/modules/library/api.ts`: typed library list API contract.
- `apps/learning/src/modules/library/query.ts`: query key and queryOptions factory.
- `apps/learning/src/modules/library/query-state.ts`: typed nuqs URL protocol.
- `apps/learning/src/modules/library/routes.tsx`: module route export.
- `apps/learning/src/modules/library/views/LibraryView.tsx`: Stage 0 state restoration proof page.
- `apps/learning/src/modules/library/query-state.test.tsx`: nuqs state and history tests.
- `apps/learning/src/modules/library/query.test.ts`: query-key tests.
---
### Task 1: Establish the pnpm workspace and move the legacy admin app
**Files:**
- Create: `package.json`
- Create: `pnpm-workspace.yaml`
- Create: `apps/admin/package.json`
- Move: `.env` -> `apps/admin/.env`
- Move: `.env.development` -> `apps/admin/.env.development`
- Move: `.eslintignore` -> `apps/admin/.eslintignore`
- Move: `.eslintrc.cjs` -> `apps/admin/.eslintrc.cjs`
- Move: `index.html` -> `apps/admin/index.html`
- Move: `jsconfig.json` -> `apps/admin/jsconfig.json`
- Move: `public` -> `apps/admin/public`
- Move: `src` -> `apps/admin/src`
- Move: `vite.config.js` -> `apps/admin/vite.config.js`
- Delete: `package-lock.json`
- Create: `pnpm-lock.yaml` through `pnpm install`
**Interfaces:**
- Produces: workspace package `@ebook/admin` and root command `pnpm build:admin`.
- Preserves: every legacy admin source file and public asset without business edits.
- [ ] **Step 1: Verify the clean starting point and capture the baseline build**
Run:
```bash
git branch --show-current
git status --short
npm run build
```
Expected:
```text
next
```
`git status --short` prints nothing and `npm run build` exits 0 with a generated `dist/index.html`.
- [ ] **Step 2: Move the legacy application mechanically**
Run:
```bash
mkdir -p apps/admin
git mv .env apps/admin/.env
git mv .env.development apps/admin/.env.development
git mv .eslintignore apps/admin/.eslintignore
git mv .eslintrc.cjs apps/admin/.eslintrc.cjs
git mv index.html apps/admin/index.html
git mv jsconfig.json apps/admin/jsconfig.json
git mv public apps/admin/public
git mv src apps/admin/src
git mv vite.config.js apps/admin/vite.config.js
git mv package.json apps/admin/package.json
git rm package-lock.json
```
Do not move `AGENTS.md`, `docs`, `.gitignore`, `.agents`, `.claude`, `readme.md`, or `skills-lock.json`.
- [ ] **Step 3: Rename the admin package without changing its dependencies**
Modify the first fields of `apps/admin/package.json`:
```json
{
"name": "@ebook/admin",
"private": true,
"version": "1.0.0",
"type": "module"
}
```
Keep all existing dependencies and scripts in that file unchanged during this task.
- [ ] **Step 4: Create the workspace root package**
Create `package.json`:
```json
{
"name": "center-book-workspace",
"private": true,
"version": "1.0.0",
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev:admin": "pnpm --filter @ebook/admin dev",
"build:admin": "pnpm --filter @ebook/admin build",
"lint:admin": "pnpm --filter @ebook/admin lint"
}
}
```
Create `pnpm-workspace.yaml`:
```yaml
packages:
- apps/*
- packages/*
```
- [ ] **Step 5: Install from the workspace root and generate the single lockfile**
Run:
```bash
pnpm install
```
Expected: exit 0, root `pnpm-lock.yaml` exists, and `apps/admin/node_modules` is linked by pnpm.
- [ ] **Step 6: Verify the mechanically moved admin still builds**
Run:
```bash
pnpm build:admin
```
Expected: exit 0. At this task boundary the output may still be `apps/admin/dist`; the output is changed in Task 2.
- [ ] **Step 7: Commit the workspace migration**
```bash
git add package.json pnpm-workspace.yaml pnpm-lock.yaml apps/admin .gitignore
git commit -m "refactor: migrate admin app into pnpm workspace"
```
---
### Task 2: Make the admin app safe under `/admin/`
**Files:**
- Create: `apps/admin/src/utils/adminPaths.js`
- Create: `apps/admin/src/utils/adminPaths.test.js`
- Modify: `apps/admin/package.json`
- Modify: `apps/admin/vite.config.js`
- Modify: `apps/admin/.env`
- Modify: `apps/admin/.env.development`
- Modify: `apps/admin/index.html`
- Modify: `apps/admin/src/main.jsx`
- Modify: `apps/admin/src/utils/request.js`
- Modify: `apps/admin/src/utils/axios.js`
- Modify: `apps/admin/src/pages/user-module/login/index.jsx`
**Interfaces:**
- Produces: `ADMIN_BASE_PATH`, `ADMIN_LOGIN_PATH`, `normalizeAdminApiPath(url)`, and `stripAdminGatewayPrefix(url)`.
- Produces: admin output `dist/admin` and local server `https://dev.ezijing.com:5174/admin/`.
- Consumes: existing admin API routes such as `/user/login` and `/book/book/getList`.
- [ ] **Step 1: Write failing path helper tests**
Create `apps/admin/src/utils/adminPaths.test.js`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import {
ADMIN_LOGIN_PATH,
normalizeAdminApiPath,
stripAdminGatewayPrefix,
} from './adminPaths.js'
test('admin login stays under the admin base path', () => {
assert.equal(ADMIN_LOGIN_PATH, '/admin/login')
})
test('legacy /api requests are normalized for the admin gateway', () => {
assert.equal(normalizeAdminApiPath('/api/user/login'), '/user/login')
assert.equal(normalizeAdminApiPath('/user/login'), '/user/login')
})
test('gateway prefix is removed before backend signature calculation', () => {
assert.equal(
stripAdminGatewayPrefix('https://zijingebook.ezijing.com/api/admin/user/login'),
'https://zijingebook.ezijing.com/user/login'
)
})
```
Add to `apps/admin/package.json` scripts:
```json
"test": "node --test src/utils/*.test.js"
```
- [ ] **Step 2: Run the tests and verify they fail**
Run:
```bash
pnpm --filter @ebook/admin test
```
Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `adminPaths.js`.
- [ ] **Step 3: Implement the path helpers**
Create `apps/admin/src/utils/adminPaths.js`:
```js
export const ADMIN_BASE_PATH = '/admin'
export const ADMIN_LOGIN_PATH = `${ADMIN_BASE_PATH}/login`
export function normalizeAdminApiPath(url = '') {
if (url === '/api') return '/'
if (url.startsWith('/api/')) return url.slice('/api'.length)
return url
}
export function stripAdminGatewayPrefix(url = '') {
return url.replace('/api/admin', '')
}
```
- [ ] **Step 4: Run the path tests and verify they pass**
Run:
```bash
pnpm --filter @ebook/admin test
```
Expected: 3 tests pass.
- [ ] **Step 5: Configure Vite for the admin subpath and output**
Replace `apps/admin/vite.config.js` with a config retaining the existing plugins, aliases, and Less option while applying these exact values:
```js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react-swc'
import mkcert from 'vite-plugin-mkcert'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const adminApiTarget = env.VITE_ADMIN_API_TARGET || 'http://127.0.0.1:7419'
return {
base: '/admin/',
plugins: [react(), mkcert()],
server: {
open: '/admin/',
host: 'dev.ezijing.com',
port: 5174,
strictPort: true,
proxy: {
'/api/admin': {
target: adminApiTarget,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/api\/admin/, ''),
},
},
},
build: {
outDir: '../../dist/admin',
emptyOutDir: true,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
css: {
preprocessorOptions: {
less: { javascriptEnabled: true },
},
},
}
})
```
- [ ] **Step 6: Put the router and static assets under `/admin/`**
Change `apps/admin/src/main.jsx`:
```jsx
<BrowserRouter basename="/admin">
<MyApp />
</BrowserRouter>
```
Change the formula editor script in `apps/admin/index.html`:
```html
<script src="%BASE_URL%formula-editor/dist/formula-editor.min.js"></script>
```
The module entry remains:
```html
<script type="module" src="/src/main.jsx"></script>
```
- [ ] **Step 7: Normalize both legacy Axios clients at the gateway boundary**
Set the following in both env files:
```dotenv
VITE_API_URL=https://zijingebook.ezijing.com/api/admin
VITE_API_PREFIX=/api/admin
VITE_ADMIN_API_TARGET=http://127.0.0.1:7419
VITE_API_WEBSOCKET_URL=wss://zijingebook.ezijing.com/ws
```
In `apps/admin/src/utils/request.js`, import the helpers:
```js
import { ADMIN_LOGIN_PATH, normalizeAdminApiPath, stripAdminGatewayPrefix } from '@/utils/adminPaths.js'
```
At the start of its request interceptor, before signature data is built:
```js
config.url = normalizeAdminApiPath(config.url)
```
Replace the existing signature URL prefix removal with:
```js
s = stripAdminGatewayPrefix(import.meta.env.VITE_API_URL + config.url)
```
Replace direct login-path comparisons and redirects in this file with `ADMIN_LOGIN_PATH`.
In `apps/admin/src/utils/axios.js`, set:
```js
const httpRequest = axios.create({
baseURL: import.meta.env.VITE_API_PREFIX,
withCredentials: true,
})
```
Import `ADMIN_LOGIN_PATH` and `normalizeAdminApiPath`, normalize `config.url` before signing, and replace direct `/login` redirects with `ADMIN_LOGIN_PATH`.
- [ ] **Step 8: Fix login widget URLs that bypass the shared Axios clients**
Change the captcha props in `apps/admin/src/pages/user-module/login/index.jsx`:
```jsx
initUrl="/api/admin/user/login/initData"
verifyUrl="/api/admin/user/login/checkVerify"
```
Run this audit:
```bash
rg -n "location\.(href|pathname) = '/login'|initUrl: '/user|verifyUrl: '/user" apps/admin/src
```
Expected: no matches.
- [ ] **Step 9: Verify admin tests and production build**
Run:
```bash
pnpm --filter @ebook/admin test
pnpm build:admin
test -f dist/admin/index.html
test -f dist/admin/formula-editor/dist/formula-editor.min.js
rg -n '/admin/' dist/admin/index.html
```
Expected: all commands exit 0 and built HTML contains `/admin/` asset URLs.
- [ ] **Step 10: Commit the admin subpath work**
```bash
git add apps/admin
git commit -m "refactor: serve admin app from admin subpath"
```
Do not add generated `dist` contents.
---
### Task 3: Create the TypeScript learning application foundation
**Files:**
- Create: `apps/learning/package.json`
- Create: `apps/learning/index.html`
- Create: `apps/learning/tsconfig.json`
- Create: `apps/learning/tsconfig.app.json`
- Create: `apps/learning/tsconfig.node.json`
- Create: `apps/learning/.eslintrc.cjs`
- Create: `apps/learning/vite.config.ts`
- Create: `apps/learning/src/vite-env.d.ts`
- Create: `apps/learning/src/main.tsx`
- Create: `apps/learning/src/router/RootLayout.tsx`
- Create: `apps/learning/src/router/routes.tsx`
- Create: `apps/learning/src/utils/http.ts`
- Create: `apps/learning/src/modules/library/routes.tsx`
- Create: `apps/learning/src/modules/library/views/LibraryView.tsx`
- Modify: root `package.json`
**Interfaces:**
- Produces: package `@ebook/learning`, port 5173, output `dist/learning`.
- Produces: `queryClient`, module route aggregation, `NuqsAdapter` inside Router context, and learning Axios client at `/api/web`.
- [ ] **Step 1: Create the learning package manifest**
Create `apps/learning/package.json`:
```json
{
"name": "@ebook/learning",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.76.2",
"axios": "^1.8.4",
"nuqs": "^2.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.4.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.9",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react-swc": "^4.2.3",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"jsdom": "^26.1.0",
"typescript": "^5.7.3",
"vite": "^7.3.1",
"vite-plugin-mkcert": "^1.17.10",
"vitest": "^3.2.4"
}
}
```
- [ ] **Step 2: Create strict TypeScript configs**
Create `apps/learning/tsconfig.json`:
```json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
```
Create `apps/learning/tsconfig.app.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"composite": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
```
Create `apps/learning/tsconfig.node.json`:
```json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
```
- [ ] **Step 3: Create Vite and HTML entry files**
Create `apps/learning/index.html`:
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>清控紫荆数智学堂</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
Create `apps/learning/src/vite-env.d.ts`:
```ts
/// <reference types="vite/client" />
```
Create `apps/learning/.eslintrc.cjs`:
```js
module.exports = {
root: true,
env: { browser: true, es2022: true },
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'react-hooks', 'react-refresh'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
ignorePatterns: ['dist'],
}
```
Create `apps/learning/vite.config.ts`:
```ts
import { fileURLToPath, URL } from 'node:url'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react-swc'
import mkcert from 'vite-plugin-mkcert'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const learningApiTarget = env.VITE_LEARNING_API_TARGET || 'http://127.0.0.1:7421'
const adminApiTarget = env.VITE_ADMIN_API_TARGET || 'http://127.0.0.1:7419'
return {
base: '/',
plugins: [react(), mkcert()],
server: {
open: '/library',
host: 'dev.ezijing.com',
port: 5173,
strictPort: true,
proxy: {
'/admin': {
target: 'https://dev.ezijing.com:5174',
changeOrigin: true,
secure: false,
ws: true,
},
'/api/web': {
target: learningApiTarget,
changeOrigin: true,
rewrite: path => path.replace(/^\/api\/web/, '/web'),
},
'/api/admin': {
target: adminApiTarget,
changeOrigin: true,
rewrite: path => path.replace(/^\/api\/admin/, ''),
},
},
},
build: {
outDir: '../../dist/learning',
emptyOutDir: true,
},
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
}
})
```
- [ ] **Step 4: Create providers, router aggregation, and the HTTP boundary**
Create `apps/learning/src/router/RootLayout.tsx`:
```tsx
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'
import { Outlet } from 'react-router'
export function RootLayout() {
return (
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
)
}
```
Create `apps/learning/src/router/routes.tsx`:
```tsx
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
import { RootLayout } from './RootLayout'
type RouteModule = { routes: RouteObject[] }
const modules = import.meta.glob<RouteModule>('../modules/**/routes.tsx', { eager: true })
const moduleRoutes = Object.values(modules).flatMap(module => module.routes)
export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <Navigate to="/library" replace /> },
...moduleRoutes,
],
},
{ path: '*', element: <Navigate to="/library" replace /> },
])
```
Create `apps/learning/src/utils/http.ts`:
```ts
import axios from 'axios'
export const http = axios.create({
baseURL: '/api/web',
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
})
```
Create `apps/learning/src/main.tsx`:
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router'
import { router } from '@/router/routes'
export const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 60 * 1000, retry: 1 },
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
)
```
Do not add a Redux provider.
Create `apps/learning/src/modules/library/views/LibraryView.tsx`:
```tsx
export function LibraryView() {
return (
<main>
<h1>图书馆</h1>
<p>学习端架构初始化完成。</p>
</main>
)
}
```
Create `apps/learning/src/modules/library/routes.tsx`:
```tsx
import type { RouteObject } from 'react-router'
import { LibraryView } from './views/LibraryView'
export const routes: RouteObject[] = [
{ path: 'library', element: <LibraryView /> },
]
```
- [ ] **Step 5: Add root workspace commands and install dependencies**
Replace root scripts with:
```json
"scripts": {
"dev": "pnpm --parallel --filter @ebook/admin --filter @ebook/learning dev",
"dev:admin": "pnpm --filter @ebook/admin dev",
"dev:learning": "pnpm --filter @ebook/learning dev",
"build": "pnpm run build:learning && pnpm run build:admin",
"build:admin": "pnpm --filter @ebook/admin build",
"build:learning": "pnpm --filter @ebook/learning build",
"test": "pnpm --recursive test",
"test:admin": "pnpm --filter @ebook/admin test",
"test:learning": "pnpm --filter @ebook/learning test",
"lint:admin": "pnpm --filter @ebook/admin lint",
"lint:learning": "pnpm --filter @ebook/learning lint"
}
```
Run:
```bash
pnpm install
pnpm build:learning
```
Expected: dependency installation and the placeholder learning build both exit 0.
- [ ] **Step 6: Commit the learning toolchain foundation**
```bash
git add package.json pnpm-lock.yaml apps/learning
git commit -m "feat: scaffold learning application foundation"
```
---
### Task 4: Prove URL-restorable state and React Query boundaries
**Files:**
- Create: `apps/learning/src/test/setup.ts`
- Create: `apps/learning/src/modules/library/api.ts`
- Create: `apps/learning/src/modules/library/query.ts`
- Create: `apps/learning/src/modules/library/query-state.ts`
- Create: `apps/learning/src/modules/library/query-state.test.tsx`
- Create: `apps/learning/src/modules/library/query.test.ts`
- Modify: `apps/learning/src/modules/library/routes.tsx`
- Modify: `apps/learning/src/modules/library/views/LibraryView.tsx`
- Create: `apps/learning/src/modules/library/views/LibraryView.css`
**Interfaces:**
- Produces: `LibrarySearchState`, `librarySearchParams`, `useLibraryQueryState`, `libraryKeys`, and `libraryListOptions`.
- URL contract: `q`, `category`, `sort`, `page`, `tab`, `dialog`, and `bookId`.
- Query key contract: `['library', 'list', normalizedState]`.
- [ ] **Step 1: Write failing nuqs state tests**
Create `apps/learning/src/test/setup.ts`:
```ts
import '@testing-library/jest-dom/vitest'
```
Create `apps/learning/src/modules/library/query-state.test.tsx` using `renderHook`, `act`, and `withNuqsTestingAdapter` to assert:
```tsx
import { act, renderHook } from '@testing-library/react'
import { withNuqsTestingAdapter, type OnUrlUpdateFunction } from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vitest'
import { useLibraryQueryState } from './query-state'
describe('useLibraryQueryState', () => {
it('restores typed state from the URL', () => {
const { result } = renderHook(() => useLibraryQueryState(), {
wrapper: withNuqsTestingAdapter({
searchParams: '?q=AI&category=12&sort=latest&page=2&tab=all',
}),
})
expect(result.current[0]).toMatchObject({
q: 'AI',
category: '12',
sort: 'latest',
page: 2,
tab: 'all',
})
})
it('pushes pagination as a navigation action', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
const { result } = renderHook(() => useLibraryQueryState(), {
wrapper: withNuqsTestingAdapter({ searchParams: '', onUrlUpdate }),
})
await act(async () => result.current[1]({ page: 2 }, { history: 'push' }))
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('page')).toBe('2')
expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push')
})
})
```
- [ ] **Step 2: Write the failing query-key test**
Create `apps/learning/src/modules/library/query.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { libraryKeys } from './query'
describe('libraryKeys', () => {
it('includes server inputs and excludes dialog state', () => {
const state = {
q: 'AI', category: '12', sort: 'latest', page: 2,
tab: 'all' as const, dialog: 'book' as const, bookId: '100',
}
expect(libraryKeys.list(state)).toEqual([
'library',
'list',
{ q: 'AI', category: '12', sort: 'latest', page: 2, tab: 'all' },
])
})
})
```
- [ ] **Step 3: Run the tests and verify they fail**
Run:
```bash
pnpm test:learning
```
Expected: FAIL because `query-state.ts` and `query.ts` do not exist.
- [ ] **Step 4: Implement the typed URL protocol**
Create `apps/learning/src/modules/library/query-state.ts`:
```ts
import {
parseAsInteger,
parseAsString,
parseAsStringLiteral,
useQueryStates,
} from 'nuqs'
const tabs = ['all', 'course'] as const
const sorts = ['latest', 'popular'] as const
const dialogs = ['book'] as const
export const librarySearchParams = {
q: parseAsString.withDefault(''),
category: parseAsString.withDefault(''),
sort: parseAsStringLiteral(sorts).withDefault('latest'),
page: parseAsInteger.withDefault(1),
tab: parseAsStringLiteral(tabs).withDefault('all'),
dialog: parseAsStringLiteral(dialogs),
bookId: parseAsString,
}
export interface LibrarySearchState {
q: string
category: string
sort: 'latest' | 'popular'
page: number
tab: 'all' | 'course'
dialog: 'book' | null
bookId: string | null
}
export function useLibraryQueryState() {
return useQueryStates(librarySearchParams, {
clearOnDefault: true,
history: 'replace',
})
}
```
- [ ] **Step 5: Implement the API and Query boundary without duplicating server data in Zustand**
Create `apps/learning/src/modules/library/api.ts`:
```ts
import { http } from '@/utils/http'
import type { LibrarySearchState } from './query-state'
export type LibraryListParams = Pick<
LibrarySearchState,
'q' | 'category' | 'sort' | 'page' | 'tab'
>
export interface LibraryBook {
book_id: string
book_name: string
img: string
introduction: string
}
export interface LibraryListResult {
list: LibraryBook[]
total: number
}
export async function getLibraryBooks(params: LibraryListParams) {
const response = await http.post<{ data: LibraryListResult }>(
'/v1/book/category/getBookList',
params
)
return response.data.data
}
```
Create `apps/learning/src/modules/library/query.ts`:
```ts
import { queryOptions } from '@tanstack/react-query'
import { getLibraryBooks, type LibraryListParams } from './api'
import type { LibrarySearchState } from './query-state'
export function toLibraryListParams(state: LibrarySearchState): LibraryListParams {
const { q, category, sort, page, tab } = state
return { q, category, sort, page, tab }
}
export const libraryKeys = {
all: ['library'] as const,
list: (state: LibrarySearchState) => ['library', 'list', toLibraryListParams(state)] as const,
}
export function libraryListOptions(state: LibrarySearchState) {
const params = toLibraryListParams(state)
return queryOptions({
queryKey: libraryKeys.list(state),
queryFn: () => getLibraryBooks(params),
})
}
```
Do not call `libraryListOptions` from the Stage 0 page because the `/web` backend route is implemented in Stage 1. Its types and query key are testable now.
- [ ] **Step 6: Create the state proof page and module route**
Replace `apps/learning/src/modules/library/views/LibraryView.tsx`:
```tsx
import { useLibraryQueryState } from '../query-state'
import './LibraryView.css'
export function LibraryView() {
const [state, setState] = useLibraryQueryState()
return (
<main className="library-proof">
<h1>图书馆</h1>
<p>当前页面用于验证 URL 状态恢复和模块边界。</p>
<label>
搜索
<input
aria-label="搜索"
value={state.q}
onChange={event => void setState(
{ q: event.target.value, page: 1 },
{ history: 'replace' }
)}
/>
</label>
<label>
分类
<select
aria-label="分类"
value={state.category}
onChange={event => void setState(
{ category: event.target.value, page: 1 },
{ history: 'push' }
)}
>
<option value="">全部</option>
<option value="12">人工智能</option>
<option value="13">数字教材</option>
</select>
</label>
<label>
排序
<select
aria-label="排序"
value={state.sort}
onChange={event => void setState(
{ sort: event.target.value as 'latest' | 'popular', page: 1 },
{ history: 'push' }
)}
>
<option value="latest">最新</option>
<option value="popular">热门</option>
</select>
</label>
<nav aria-label="图书馆视图">
<button type="button" onClick={() => void setState({ tab: 'all', page: 1 }, { history: 'push' })}>
全部图书
</button>
<button type="button" onClick={() => void setState({ tab: 'course', page: 1 }, { history: 'push' })}>
课程图书
</button>
</nav>
<div className="library-proof__actions">
<button
type="button"
disabled={state.page <= 1}
onClick={() => void setState({ page: state.page - 1 }, { history: 'push' })}
>
上一页
</button>
<span>第 {state.page} 页</span>
<button type="button" onClick={() => void setState({ page: state.page + 1 }, { history: 'push' })}>
下一页
</button>
<button
type="button"
onClick={() => void setState({ dialog: 'book', bookId: '100' }, { history: 'push' })}
>
打开图书详情
</button>
</div>
{state.dialog === 'book' && (
<section role="dialog" aria-label="图书详情">
<p>图书编号:{state.bookId}</p>
<button
type="button"
onClick={() => void setState({ dialog: null, bookId: null }, { history: 'push' })}
>
关闭
</button>
</section>
)}
<pre aria-label="当前URL状态">{JSON.stringify(state, null, 2)}</pre>
</main>
)
}
```
Create `apps/learning/src/modules/library/views/LibraryView.css`:
```css
.library-proof {
box-sizing: border-box;
width: min(960px, 100%);
margin: 0 auto;
padding: 32px;
display: grid;
gap: 16px;
}
.library-proof label,
.library-proof__actions,
.library-proof nav {
display: flex;
align-items: center;
gap: 12px;
}
.library-proof pre {
overflow: auto;
padding: 16px;
background: #f5f5f5;
}
```
Create `routes.tsx`:
```tsx
import type { RouteObject } from 'react-router'
import { LibraryView } from './views/LibraryView'
export const routes: RouteObject[] = [
{ path: 'library', element: <LibraryView /> },
]
```
The page is an architecture proof, not the final visual design.
- [ ] **Step 7: Run tests and build**
Run:
```bash
pnpm test:learning
pnpm build:learning
test -f dist/learning/index.html
```
Expected: all tests pass and the build exits 0.
- [ ] **Step 8: Verify forbidden state dependencies are absent**
Run:
```bash
pnpm --filter @ebook/learning why redux
pnpm --filter @ebook/learning why @reduxjs/toolkit
rg -n "useSearchParams|new URLSearchParams|window\.location\.search" apps/learning/src
```
Expected: pnpm reports no learning dependency on Redux packages and ripgrep returns no matches.
- [ ] **Step 9: Commit the learning state architecture**
```bash
git add apps/learning pnpm-lock.yaml
git commit -m "feat: add URL-restorable learning module architecture"
```
---
### Task 5: Add build-layout verification and AI maintenance boundaries
**Files:**
- Create: `scripts/verify-build-layout.mjs`
- Modify: root `package.json`
- Modify: root `AGENTS.md`
- Create: `apps/admin/AGENTS.md`
- Create: `apps/learning/AGENTS.md`
**Interfaces:**
- Produces: `pnpm verify:build-layout`.
- Documents: separate app responsibilities and the URL/Query/Zustand state decision tree.
- [ ] **Step 1: Write the failing build-layout verifier first**
Create `scripts/verify-build-layout.mjs`:
```js
import { access } from 'node:fs/promises'
import { constants } from 'node:fs'
const buildRoot = process.env.BUILD_ROOT || 'dist'
const requiredFiles = [
'learning/index.html',
'admin/index.html',
'admin/formula-editor/dist/formula-editor.min.js',
].map(file => `${buildRoot}/${file}`)
const missing = []
for (const file of requiredFiles) {
try {
await access(file, constants.R_OK)
} catch {
missing.push(file)
}
}
if (missing.length > 0) {
console.error(`Missing build outputs:\n${missing.join('\n')}`)
process.exit(1)
}
console.log('Build layout verified')
```
Add root script:
```json
"verify:build-layout": "node scripts/verify-build-layout.mjs"
```
- [ ] **Step 2: Verify the checker fails on a clean output directory**
Run:
```bash
BUILD_ROOT=/tmp/center-book-stage0-missing-build-root pnpm verify:build-layout
```
Expected: exit 1 listing all three missing files. Do not delete or rename the real `dist` directory for this check.
- [ ] **Step 3: Build both apps and verify the assembled layout**
Run:
```bash
pnpm build
pnpm verify:build-layout
```
Expected: both commands exit 0 and print `Build layout verified`.
- [ ] **Step 4: Write app-specific AI instructions**
Update root `AGENTS.md` to describe the Monorepo layout, root commands, and the rule that `apps/admin` and `apps/learning` do not import each other's `src`.
Create `apps/admin/AGENTS.md` stating:
```text
- This is the legacy React 18 admin application served at /admin/.
- Preserve existing JavaScript/JSX and Redux behavior unless a task explicitly changes it.
- Use /api/admin for backend requests.
- Direct browser redirects must remain under /admin.
```
Create `apps/learning/AGENTS.md` stating:
```text
- This is a TypeScript React 18 application served at /.
- Server state belongs in TanStack React Query.
- Recoverable page operation state belongs in nuqs and the URL.
- Zustand is allowed only for transient cross-component client state.
- Redux packages are forbidden.
- Each business module owns its API, Query, URL-state, route, type, component, and view files; files that are not used are not created.
- Pages must not call Axios directly.
```
- [ ] **Step 5: Run the complete automated verification**
Run:
```bash
pnpm test
pnpm lint:learning
pnpm build
pnpm verify:build-layout
git diff --check
```
Expected: all commands exit 0.
- [ ] **Step 6: Commit the verification and documentation**
```bash
git add package.json scripts AGENTS.md apps/admin/AGENTS.md apps/learning/AGENTS.md
git commit -m "chore: document and verify monorepo boundaries"
```
---
### Task 6: Run local `/admin` proxy and refresh smoke tests
**Files:**
- Modify only if verification exposes a defect: `apps/learning/vite.config.ts`, `apps/admin/vite.config.js`, or route entry files.
**Interfaces:**
- Verifies: `https://dev.ezijing.com:5173/` serves learning and `/admin/` serves the proxied admin Vite app with HMR routing.
- [ ] **Step 1: Start both applications from the workspace root**
Run in a persistent terminal:
```bash
pnpm dev
```
Expected logs include learning on port 5173 and admin on port 5174 without port fallback.
- [ ] **Step 2: Verify HTTP responses through the single local entry**
Run in another terminal:
```bash
curl -k -I https://dev.ezijing.com:5173/library?q=AI&page=2
curl -k -I https://dev.ezijing.com:5173/admin/
curl -k -I https://dev.ezijing.com:5173/admin/login
```
Expected: all return HTTP 200. `/admin` without a trailing slash may return one redirect before 200.
- [ ] **Step 3: Manually verify URL restoration**
Open:
```text
https://dev.ezijing.com:5173/library?q=AI&category=12&sort=latest&page=2&tab=all
```
Expected:
- Controls display the URL values.
- Changing the search input updates the URL with replace behavior.
- Changing page, tab, or dialog updates the URL with push behavior.
- Refresh restores all controls.
- Browser Back and Forward restore navigation-like operations.
- [ ] **Step 4: Manually verify the admin subpath**
Open:
```text
https://dev.ezijing.com:5173/admin/login
```
Expected:
- Admin login page renders.
- Admin assets load from `/admin/assets` or `/admin/formula-editor`.
- Refresh remains on `/admin/login`.
- Failed authentication never redirects to the learning `/login` route.
- [ ] **Step 5: Run final status and commit only smoke-fix changes**
Run:
```bash
pnpm test
pnpm build
pnpm verify:build-layout
git status --short
```
If smoke testing required fixes, commit only those files:
```bash
git add apps/learning/vite.config.ts apps/admin/vite.config.js apps/learning/src/router apps/admin/src
git commit -m "fix: stabilize local admin subpath proxy"
```
If there are no fixes, do not create an empty commit.
---
## Stage 0 Completion Gate
Stage 0 is complete only when all of the following are true:
- The worktree is clean after the planned commits.
- `pnpm test` passes.
- `pnpm build` passes.
- `pnpm verify:build-layout` passes.
- Admin output is under `dist/admin` and learning output is under `dist/learning`.
- The learning package has no Redux dependency.
- Learning server state is represented by React Query contracts, not Zustand.
- The library proof page restores state from a copied URL and browser refresh.
- Local learning port 5173 proxies `/admin/` to admin port 5174.
- Existing admin login and primary navigation render under `/admin/`.
- No backend repository, remote branch, Jenkins job, Nginx server, or production environment was changed.
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论