From 807e7de4432216d022dc4daba80d0a3aae0f8453 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 24 Apr 2026 15:41:42 +0800 Subject: [PATCH] =?UTF-8?q?docs(h5):=20=E6=96=B0=E5=A2=9E=20H5=20=E6=9D=90?= =?UTF-8?q?=E6=96=99=E6=B5=8F=E8=A7=88=E7=AB=AF=E5=AE=9E=E6=96=BD=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-24-h5-material-browser.md | 1385 +++++++++++++++++ 1 file changed, 1385 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-h5-material-browser.md diff --git a/docs/superpowers/plans/2026-04-24-h5-material-browser.md b/docs/superpowers/plans/2026-04-24-h5-material-browser.md new file mode 100644 index 0000000..e5bbed0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-h5-material-browser.md @@ -0,0 +1,1385 @@ +# H5 材料浏览端 实施计划 + +> **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:** 在现有 mat3 项目上新增一个独立的 H5 站点,用户在手机浏览器登录后可按 "大类 → 种类 → 子类 → 材料 → 详情" 层级浏览材料。 + +**Architecture:** 新建独立 Vite/Vue 3 项目 `frontend-h5/`(平级于 `frontend/`),基于 Tailwind 自研轻量组件;部署到 Nginx `/m/` 子路径;复用后端已有接口,另在 `MaterialViewSet` 增加两个聚合查询 action。 + +**Tech Stack:** Vue 3 · vue-router 4 · Pinia · Axios · Tailwind CSS · Vite 5;后端 Django / DRF。 + +**Spec:** `docs/superpowers/specs/2026-04-24-h5-material-browser-design.md` + +--- + +## 文件结构概览 + +### 后端 +- Modify: `backend/apps/material/views.py` — `MaterialViewSet` 新增两个 `@action` + +### 前端(全部新建) + +``` +frontend-h5/ +├── index.html +├── vite.config.js +├── package.json +├── postcss.config.js +├── tailwind.config.js +├── .gitignore +└── src/ + ├── main.js + ├── App.vue + ├── styles/ + │ └── tailwind.css + ├── router/ + │ └── index.js + ├── store/ + │ ├── auth.js + │ └── ui.js + ├── api/ + │ ├── client.js + │ ├── auth.js + │ └── material.js + ├── composables/ + │ ├── useToast.js + │ └── useInfiniteScroll.js + ├── components/ + │ ├── NavBar.vue + │ ├── Toast.vue + │ ├── Chip.vue + │ ├── StarLevel.vue + │ ├── Skeleton.vue + │ ├── MajorCategoryCard.vue + │ ├── CategoryCard.vue + │ └── MaterialCard.vue + └── views/ + ├── Login.vue + ├── Home.vue + ├── CategoryDetail.vue + └── MaterialDetail.vue +``` + +--- + +## Task 1:后端——新增两个聚合查询接口 + +**Files:** +- Modify: `backend/apps/material/views.py`(`MaterialViewSet` 类内部) + +- [ ] **Step 1: 在 `MaterialViewSet` 中新增 `categories_by_major` action** + +在 `MaterialViewSet` 类末尾追加: + +```python +from django.db.models import Count + +@action(detail=False, methods=['get'], url_path='categories-by-major') +def categories_by_major(self, request): + major = request.query_params.get('major_category') + if not major: + return Response({"detail": "major_category 参数必填"}, status=status.HTTP_400_BAD_REQUEST) + qs = (Material.objects + .filter(major_category=major, status='approved') + .exclude(material_category__isnull=True) + .exclude(material_category__exact='') + .values('material_category') + .annotate(count=Count('id')) + .order_by('material_category')) + data = [{"value": row['material_category'], "count": row['count']} for row in qs] + return Response(data) +``` + +- [ ] **Step 2: 新增 `subcategories_by_category` action** + +紧接着追加: + +```python +@action(detail=False, methods=['get'], url_path='subcategories-by-category') +def subcategories_by_category(self, request): + major = request.query_params.get('major_category') + category = request.query_params.get('material_category') + if not major or not category: + return Response({"detail": "major_category 和 material_category 均必填"}, status=status.HTTP_400_BAD_REQUEST) + qs = (Material.objects + .filter(major_category=major, material_category=category, status='approved') + .exclude(material_subcategory__isnull=True) + .exclude(material_subcategory__exact='') + .values('material_subcategory') + .annotate(count=Count('id')) + .order_by('material_subcategory')) + data = [{"value": row['material_subcategory'], "count": row['count']} for row in qs] + return Response(data) +``` + +> 注:只统计 `status='approved'` 的材料;H5 只展示已审核材料。 + +- [ ] **Step 3: 启动后端并手工验证** + +```bash +D:/projects/mat3/backend/.venv/Scripts/python.exe D:/projects/mat3/backend/manage.py runserver +``` + +另开终端,拿 token 后请求: + +```bash +curl -H "Authorization: Bearer " \ + "http://localhost:8000/api/material/categories-by-major/?major_category=architecture" +``` + +Expected: 返回 `[{"value": "...", "count": N}, ...]`(或 `[]`)。 + +- [ ] **Step 4: Commit** + +```bash +git add backend/apps/material/views.py +git commit -m "feat(material): 新增 H5 大类/种类聚合查询接口" +``` + +--- + +## Task 2:前端——`frontend-h5` 项目脚手架 + +**Files:** +- Create: `frontend-h5/package.json` +- Create: `frontend-h5/vite.config.js` +- Create: `frontend-h5/index.html` +- Create: `frontend-h5/.gitignore` +- Create: `frontend-h5/src/main.js` +- Create: `frontend-h5/src/App.vue` + +- [ ] **Step 1: 新建 `frontend-h5/package.json`** + +```json +{ + "name": "mat3-h5", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.8", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "vite": "^5.2.10" + } +} +``` + +- [ ] **Step 2: 新建 `frontend-h5/vite.config.js`** + +```js +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + base: '/m/', + plugins: [vue()], + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) +``` + +- [ ] **Step 3: 新建 `frontend-h5/index.html`** + +```html + + + + + + 材料浏览 + + +
+ + + +``` + +- [ ] **Step 4: 新建 `frontend-h5/.gitignore`** + +``` +node_modules +dist +.DS_Store +*.local +``` + +- [ ] **Step 5: 新建 `frontend-h5/src/App.vue`** + +```vue + + + +``` + +- [ ] **Step 6: 新建 `frontend-h5/src/main.js`**(占位,后面 Task 逐步填充 router/store/tailwind) + +```js +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') +``` + +- [ ] **Step 7: 安装依赖并启动 dev server 验证可运行** + +```bash +cd D:/projects/mat3/frontend-h5 && npm install +npm run dev +``` + +Expected: 终端显示 `Local: http://localhost:5174/m/`,浏览器打开显示空白(无报错)。Ctrl-C 停止。 + +- [ ] **Step 8: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 初始化 frontend-h5 项目脚手架" +``` + +--- + +## Task 3:Tailwind 配置与设计令牌 + +**Files:** +- Create: `frontend-h5/postcss.config.js` +- Create: `frontend-h5/tailwind.config.js` +- Create: `frontend-h5/src/styles/tailwind.css` +- Modify: `frontend-h5/src/main.js` + +- [ ] **Step 1: 新建 `frontend-h5/postcss.config.js`** + +```js +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} +``` + +- [ ] **Step 2: 新建 `frontend-h5/tailwind.config.js`** + +```js +export default { + content: ['./index.html', './src/**/*.{vue,js}'], + theme: { + extend: { + colors: { + brand: { DEFAULT: '#2F4F3F', dark: '#233C2F', light: '#6B8878' }, + surface: { DEFAULT: '#FFFFFF', alt: '#FAFAFA', warm: '#F5F4F2' }, + danger: '#D2584A', + info: '#5A7FB8', + muted: '#8A8A8A', + line: '#EEEEEE', + }, + borderRadius: { card: '18px' }, + boxShadow: { card: '0 1px 2px rgba(0,0,0,0.04)' }, + fontFamily: { + sans: ['-apple-system', 'BlinkMacSystemFont', '"PingFang SC"', '"Microsoft YaHei"', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} +``` + +- [ ] **Step 3: 新建 `frontend-h5/src/styles/tailwind.css`** + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, body, #app { + @apply h-full bg-surface-alt text-neutral-900 font-sans antialiased; + } + body { -webkit-tap-highlight-color: transparent; } + .tnum { font-feature-settings: 'tnum'; font-variant-numeric: tabular-nums; } +} +``` + +- [ ] **Step 4: 在 `main.js` 导入样式** + +```js +import { createApp } from 'vue' +import App from './App.vue' +import './styles/tailwind.css' + +createApp(App).mount('#app') +``` + +- [ ] **Step 5: 验证 Tailwind 生效** + +把 `App.vue` 的 `` 暂时替换为 `
Tailwind OK
` 运行 `npm run dev`,看到绿色块即通过;验证后恢复原内容。 + +- [ ] **Step 6: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 配置 Tailwind 与设计令牌" +``` + +--- + +## Task 4:Axios 客户端 + API 模块 + +**Files:** +- Create: `frontend-h5/src/api/client.js` +- Create: `frontend-h5/src/api/auth.js` +- Create: `frontend-h5/src/api/material.js` + +- [ ] **Step 1: 新建 `src/api/client.js`** + +```js +import axios from 'axios' + +const api = axios.create({ baseURL: '/api', timeout: 15000 }) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('h5_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('h5_token') + const current = window.location.pathname + window.location.search + if (!current.startsWith('/login')) { + window.location.href = `/m/login?redirect=${encodeURIComponent(current)}` + } + } + return Promise.reject(err) + }, +) + +export default api +``` + +- [ ] **Step 2: 新建 `src/api/auth.js`** + +```js +import api from './client' + +export const login = async (payload) => (await api.post('/auth/login/', payload)).data +export const fetchCurrentUser = async () => (await api.get('/auth/user/')).data +``` + +- [ ] **Step 3: 新建 `src/api/material.js`** + +```js +import api from './client' + +export const fetchMaterials = async (params) => (await api.get('/material/', { params })).data +export const fetchMaterialDetail = async (id) => (await api.get(`/material/${id}/`)).data +export const fetchCategoriesByMajor = async (major_category) => + (await api.get('/material/categories-by-major/', { params: { major_category } })).data +export const fetchSubcategoriesByCategory = async (major_category, material_category) => + (await api.get('/material/subcategories-by-category/', { params: { major_category, material_category } })).data +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend-h5/src/api +git commit -m "feat(h5): 新增 axios 客户端与 API 模块" +``` + +--- + +## Task 5:Pinia Store(auth / ui) + +**Files:** +- Create: `frontend-h5/src/store/auth.js` +- Create: `frontend-h5/src/store/ui.js` +- Modify: `frontend-h5/src/main.js` + +- [ ] **Step 1: 新建 `src/store/auth.js`** + +```js +import { defineStore } from 'pinia' +import * as authApi from '@/api/auth' +import api from '@/api/client' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('h5_token') || '', + user: null, + }), + getters: { + isAuthed: (s) => !!s.token, + }, + actions: { + async login(payload) { + const data = await authApi.login(payload) + this.token = data.token || data.access || data.access_token + localStorage.setItem('h5_token', this.token) + await this.loadUser() + }, + async loadUser() { + this.user = await authApi.fetchCurrentUser() + }, + logout() { + this.token = '' + this.user = null + localStorage.removeItem('h5_token') + }, + }, +}) +``` + +> 说明:`login` 返回字段以现有后端为准;启动时如果字段名不匹配会在登录 task 手工验证后再调整。 + +- [ ] **Step 2: 新建 `src/store/ui.js`** + +```js +import { defineStore } from 'pinia' + +export const useUiStore = defineStore('ui', { + state: () => ({ + selectedMajor: '', // Home 上次选中的大类 + categorySubTab: {}, // { 'architecture::地砖': '釉面砖' | '' } + scrollCache: {}, // { routeKey: scrollTop } + }), + actions: { + setMajor(v) { this.selectedMajor = v }, + setSubTab(key, v) { this.categorySubTab[key] = v }, + saveScroll(key, top) { this.scrollCache[key] = top }, + }, +}) +``` + +- [ ] **Step 3: 在 `main.js` 注册 pinia 与 `@` 别名** + +先改 `vite.config.js`,增加 resolve alias: + +```js +import { fileURLToPath, URL } from 'node:url' +// ... +resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, +``` + +再改 `main.js`: + +```js +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import './styles/tailwind.css' + +const app = createApp(App) +app.use(createPinia()) +app.mount('#app') +``` + +- [ ] **Step 4: 启动 `npm run dev` 确认无报错** + +- [ ] **Step 5: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 新增 pinia auth/ui store 与 @ 别名" +``` + +--- + +## Task 6:路由与守卫 + +**Files:** +- Create: `frontend-h5/src/router/index.js` +- Modify: `frontend-h5/src/main.js` +- Create: `frontend-h5/src/views/Login.vue`(占位) +- Create: `frontend-h5/src/views/Home.vue`(占位) +- Create: `frontend-h5/src/views/CategoryDetail.vue`(占位) +- Create: `frontend-h5/src/views/MaterialDetail.vue`(占位) + +- [ ] **Step 1: 新建 4 个视图占位文件** + +内容均为: +```vue + + +``` +把 `XXX` 替换为 `Login` / `Home` / `CategoryDetail` / `MaterialDetail`。`name` 用于 ``。 + +- [ ] **Step 2: 新建 `src/router/index.js`** + +```js +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/store/auth' + +const routes = [ + { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { public: true } }, + { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, + { path: '/category/:major/:category', name: 'CategoryDetail', component: () => import('@/views/CategoryDetail.vue'), props: true }, + { path: '/material/:id', name: 'MaterialDetail', component: () => import('@/views/MaterialDetail.vue'), props: true }, +] + +const router = createRouter({ + history: createWebHistory('/m/'), + routes, + scrollBehavior(to, from, saved) { return saved || { top: 0 } }, +}) + +router.beforeEach((to) => { + if (to.meta.public) return true + const auth = useAuthStore() + if (!auth.isAuthed) { + return { name: 'Login', query: { redirect: to.fullPath } } + } + return true +}) + +export default router +``` + +- [ ] **Step 3: 在 `main.js` 注册 router** + +```js +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './styles/tailwind.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') +``` + +- [ ] **Step 4: 手工验证** + +`npm run dev` 后访问: +- `http://localhost:5174/m/` → 应自动跳 `/m/login?redirect=%2F` +- 手动改地址栏到 `/m/login` → 显示 "Login 占位" + +- [ ] **Step 5: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 新增路由与认证守卫" +``` + +--- + +## Task 7:通用组件(NavBar / Toast / Chip / StarLevel / Skeleton) + +**Files:** +- Create: `frontend-h5/src/components/NavBar.vue` +- Create: `frontend-h5/src/components/Toast.vue` +- Create: `frontend-h5/src/composables/useToast.js` +- Create: `frontend-h5/src/components/Chip.vue` +- Create: `frontend-h5/src/components/StarLevel.vue` +- Create: `frontend-h5/src/components/Skeleton.vue` +- Modify: `frontend-h5/src/App.vue` + +- [ ] **Step 1: NavBar.vue** + +```vue + + +``` + +- [ ] **Step 2: Toast.vue + useToast.js** + +`useToast.js`: +```js +import { reactive } from 'vue' +const state = reactive({ list: [] }) +let seq = 0 +export function useToast() { + const show = (msg, type = 'info', duration = 2000) => { + const id = ++seq + state.list.push({ id, msg, type }) + setTimeout(() => { state.list = state.list.filter(x => x.id !== id) }, duration) + } + return { state, show } +} +``` + +`Toast.vue`: +```vue + + + +``` + +在 `App.vue` 模板最外层加入 `` 并 `import Toast from '@/components/Toast.vue'`。 + +- [ ] **Step 3: Chip.vue** + +```vue + + +``` + +- [ ] **Step 4: StarLevel.vue** + +```vue + + +``` + +- [ ] **Step 5: Skeleton.vue** + +```vue + +``` + +- [ ] **Step 6: 手工验证组件** + +在 `Home.vue` 占位里临时引入一组组件看看视觉;确认后清理。 + +- [ ] **Step 7: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 新增通用组件 NavBar/Toast/Chip/StarLevel/Skeleton" +``` + +--- + +## Task 8:Login 页 + +**Files:** +- Modify: `frontend-h5/src/views/Login.vue` + +- [ ] **Step 1: 替换 `Login.vue` 为完整实现** + +```vue + + + +``` + +- [ ] **Step 2: 验证登录成功字段** + +`npm run dev`,用真实账号登录;**打开浏览器 Network 面板看 `/auth/login/` 响应体**,确认 token 字段名(`token` / `access` / `access_token`)与 `store/auth.js` 中读取一致;不一致则调整。 + +Expected: 登录成功后跳转到 `/`。 + +- [ ] **Step 3: Commit** + +```bash +git add frontend-h5/src +git commit -m "feat(h5): 登录页" +``` + +--- + +## Task 9:Home 页(大类 + 种类) + +**Files:** +- Create: `frontend-h5/src/components/MajorCategoryCard.vue` +- Create: `frontend-h5/src/components/CategoryCard.vue` +- Modify: `frontend-h5/src/views/Home.vue` + +- [ ] **Step 1: `MajorCategoryCard.vue`** + +```vue + + +``` + +- [ ] **Step 2: `CategoryCard.vue`** + +```vue + + +``` + +- [ ] **Step 3: 实现 `Home.vue`** + +```vue + + + + + +``` + +- [ ] **Step 4: 手工验证** + +登录 → 看到 4 个大类;点一个大类 → 下方出现种类卡片;点种类 → 路由进入 CategoryDetail 占位。 + +- [ ] **Step 5: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 首页大类+种类浏览" +``` + +--- + +## Task 10:CategoryDetail 页(子类 Tab + 材料列表) + +**Files:** +- Create: `frontend-h5/src/components/MaterialCard.vue` +- Create: `frontend-h5/src/composables/useInfiniteScroll.js` +- Modify: `frontend-h5/src/views/CategoryDetail.vue` + +- [ ] **Step 1: `MaterialCard.vue`** + +```vue + + + +``` + +- [ ] **Step 2: `useInfiniteScroll.js`** + +```js +import { onMounted, onBeforeUnmount, ref } from 'vue' + +export function useInfiniteScroll(target, onLoad) { + const observer = ref(null) + onMounted(() => { + observer.value = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) onLoad() + }) + if (target.value) observer.value.observe(target.value) + }) + onBeforeUnmount(() => observer.value?.disconnect()) +} +``` + +- [ ] **Step 3: 实现 `CategoryDetail.vue`** + +```vue + + + + + +``` + +- [ ] **Step 4: 手工验证** + +进入某种类 → 能看到"全部"及各子类 Tab;切换 Tab 列表刷新;滚到底继续加载;点卡片进入材料详情占位。 + +- [ ] **Step 5: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 种类详情页(子类 Tab + 材料列表)" +``` + +--- + +## Task 11:MaterialDetail 页(三块展示) + +**Files:** +- Modify: `frontend-h5/src/views/MaterialDetail.vue` + +- [ ] **Step 1: 实现 `MaterialDetail.vue`** + +```vue + + + +``` + +- [ ] **Step 2: 手工验证** + +从列表进入详情,确认三块布局;含/不含 brochure 两种情况都能正常显示;电话字段可点拨。 + +> 若 `factory_province_name` / `factory_city_name` 字段名与后端序列化器不一致,按实际字段调整。 + +- [ ] **Step 3: Commit** + +```bash +git add frontend-h5 +git commit -m "feat(h5): 材料详情页(三块展示)" +``` + +--- + +## Task 12:生产构建 & Nginx 部署说明 + +**Files:** +- Create: `frontend-h5/README.md` + +- [ ] **Step 1: 在 `frontend-h5/` 根执行构建** + +```bash +cd D:/projects/mat3/frontend-h5 && npm run build +``` + +Expected: 产出 `frontend-h5/dist/`;文件包含 `index.html`、`assets/*.js`、`assets/*.css`。 + +- [ ] **Step 2: 新建 `frontend-h5/README.md` 记录部署** + +内容(简短): + +````markdown +# frontend-h5 + +H5 材料浏览端。 + +## 开发 + +``` +npm install +npm run dev # http://localhost:5174/m/ +``` + +## 生产 + +``` +npm run build # 输出到 dist/ +``` + +Nginx 挂载示例: + +``` +location /m/ { + alias /path/to/frontend-h5/dist/; + try_files $uri $uri/ /m/index.html; +} +``` + +API 仍走主站 `/api/`。 +```` + +- [ ] **Step 3: Commit** + +```bash +git add frontend-h5/README.md +git commit -m "docs(h5): 添加 README 部署说明" +``` + +--- + +## Task 13:端到端验收 + +- [ ] **Step 1: 启动后端 + H5 dev server** + +```bash +# Terminal A +D:/projects/mat3/backend/.venv/Scripts/python.exe D:/projects/mat3/backend/manage.py runserver + +# Terminal B +cd D:/projects/mat3/frontend-h5 && npm run dev +``` + +- [ ] **Step 2: 手机浏览器或 Chrome DevTools 移动端模拟访问 `http://localhost:5174/m/`** + +按 spec §7 验收标准走一遍: +- 未登录 → 跳 `/login` +- 登录成功 → 跳 `/` 并显示 4 个大类 +- 点大类 → 展开种类;点种类 → 进入 CategoryDetail +- 切换子类 Tab,列表正确刷新;滚到底自动加载下一页 +- 点材料 → 详情三块正确;电话可拨 +- 返回一级,选中状态与滚动位置保留 +- 401 模拟(手动清 `h5_token` 并刷新)→ 跳登录并带 redirect +- PC 端 `/` 仍正常 + +- [ ] **Step 3: 若全部通过,整理最终 commit 即可(前序已按 task 提交)** + +--- + +## 规范与复用要点 + +- **DRY**:`d()` / `costLabel()` / `importanceTone()` 等工具函数在多处重复,完成 Task 11 后若感到重复明显,可统一抽到 `src/utils/format.js`(一个小重构 commit 即可)。 +- **YAGNI**:不做收藏、搜索、下载、分享、离线等 spec 非目标功能。 +- **TDD 说明**:H5 全是 UI/交互,没有现成的测试基建;每个 Task 的"手工验证"即验证步骤。后端两个接口若要加单测,可在 Task 1 后追加 `backend/apps/material/tests/test_h5_actions.py`,但非必需。 +- **Commit 粒度**:每 Task 至少 1 次 commit,便于回滚。