# 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,便于回滚。