mat/docs/superpowers/plans/2026-04-24-h5-material-brow...

1386 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <token>" \
"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
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<title>材料浏览</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
```
- [ ] **Step 4: 新建 `frontend-h5/.gitignore`**
```
node_modules
dist
.DS_Store
*.local
```
- [ ] **Step 5: 新建 `frontend-h5/src/App.vue`**
```vue
<template>
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<keep-alive :include="['Home', 'CategoryDetail']">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</template>
<style>
.page-enter-active,
.page-leave-active { transition: all 0.2s ease; }
.page-enter-from { transform: translateX(20px); opacity: 0; }
.page-leave-to { transform: translateX(-20px); opacity: 0; }
</style>
```
- [ ] **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 3Tailwind 配置与设计令牌
**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``<router-view>` 暂时替换为 `<div class="p-4 bg-brand text-white">Tailwind OK</div>` 运行 `npm run dev`,看到绿色块即通过;验证后恢复原内容。
- [ ] **Step 6: Commit**
```bash
git add frontend-h5
git commit -m "feat(h5): 配置 Tailwind 与设计令牌"
```
---
## Task 4Axios 客户端 + 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 5Pinia Storeauth / 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
<script setup>defineOptions({ name: 'XXX' })</script>
<template><div class="p-4">XXX 占位</div></template>
```
`XXX` 替换为 `Login` / `Home` / `CategoryDetail` / `MaterialDetail`。`name` 用于 `<keep-alive include>`
- [ ] **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
<script setup>
import { useRouter } from 'vue-router'
defineProps({ title: String, showBack: { type: Boolean, default: true } })
const router = useRouter()
</script>
<template>
<header class="sticky top-0 z-20 h-12 flex items-center bg-white/90 backdrop-blur border-b border-line">
<button v-if="showBack" class="w-12 h-12 flex items-center justify-center active:bg-line" @click="router.back()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M13 4l-6 6 6 6" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<h1 class="flex-1 text-center text-base font-semibold truncate px-4">{{ title }}</h1>
<div class="w-12 h-12"><slot name="right" /></div>
</header>
</template>
```
- [ ] **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
<script setup>
import { useToast } from '@/composables/useToast'
const { state } = useToast()
</script>
<template>
<teleport to="body">
<div class="fixed top-2 left-0 right-0 z-50 flex flex-col items-center gap-2 pointer-events-none">
<transition-group name="toast">
<div v-for="t in state.list" :key="t.id"
class="px-4 py-2 rounded-full text-sm shadow-card"
:class="t.type==='error' ? 'bg-danger text-white' : 'bg-neutral-800/90 text-white'">
{{ t.msg }}
</div>
</transition-group>
</div>
</teleport>
</template>
<style>
.toast-enter-active,.toast-leave-active{transition:all .25s ease}
.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(-8px)}
</style>
```
`App.vue` 模板最外层加入 `<Toast />``import Toast from '@/components/Toast.vue'`
- [ ] **Step 3: Chip.vue**
```vue
<script setup>
defineProps({ tone: { type: String, default: 'neutral' } })
const map = {
brand: 'bg-brand/10 text-brand',
danger: 'bg-danger/10 text-danger',
info: 'bg-info/10 text-info',
neutral: 'bg-neutral-100 text-neutral-600',
}
</script>
<template>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full whitespace-nowrap" :class="map[tone]">
<slot />
</span>
</template>
```
- [ ] **Step 4: StarLevel.vue**
```vue
<script setup>
defineProps({ value: { type: Number, default: 0 }, max: { type: Number, default: 3 } })
</script>
<template>
<span class="inline-flex items-center gap-0.5">
<svg v-for="i in max" :key="i" width="14" height="14" viewBox="0 0 20 20"
:fill="i <= value ? '#2F4F3F' : '#E5E5E5'">
<path d="M10 1.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L10 14.9 4.8 17.6l1-5.8L1.5 7.7l5.9-.9z"/>
</svg>
</span>
</template>
```
- [ ] **Step 5: Skeleton.vue**
```vue
<template>
<div class="animate-pulse bg-neutral-200/60 rounded" />
</template>
```
- [ ] **Step 6: 手工验证组件**
`Home.vue` 占位里临时引入一组组件看看视觉;确认后清理。
- [ ] **Step 7: Commit**
```bash
git add frontend-h5
git commit -m "feat(h5): 新增通用组件 NavBar/Toast/Chip/StarLevel/Skeleton"
```
---
## Task 8Login 页
**Files:**
- Modify: `frontend-h5/src/views/Login.vue`
- [ ] **Step 1: 替换 `Login.vue` 为完整实现**
```vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/composables/useToast'
defineOptions({ name: 'Login' })
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const { show } = useToast()
const username = ref('')
const password = ref('')
const loading = ref(false)
const submit = async () => {
if (!username.value || !password.value) {
show('请输入账号和密码', 'error')
return
}
loading.value = true
try {
await auth.login({ username: username.value, password: password.value })
const redirect = route.query.redirect || '/'
router.replace(redirect)
} catch (e) {
show(e?.response?.data?.detail || '登录失败', 'error')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex flex-col bg-surface">
<div class="flex-[0.55] flex items-end justify-center pb-10">
<div class="text-center">
<div class="w-16 h-16 mx-auto rounded-2xl bg-brand text-white text-3xl font-bold flex items-center justify-center"></div>
<h1 class="mt-4 text-xl font-semibold">材料浏览</h1>
<p class="mt-1 text-sm text-muted">登录后查看材料库</p>
</div>
</div>
<div class="flex-[0.45] px-6 space-y-3">
<input v-model="username" placeholder="账号"
class="w-full h-12 px-4 rounded-card bg-surface-alt border border-line focus:border-brand outline-none" />
<input v-model="password" type="password" placeholder="密码"
class="w-full h-12 px-4 rounded-card bg-surface-alt border border-line focus:border-brand outline-none" />
<button :disabled="loading" @click="submit"
class="w-full h-12 rounded-card bg-brand text-white text-base font-medium active:bg-brand-dark disabled:opacity-60">
{{ loading ? '登录中…' : '登录' }}
</button>
</div>
</div>
</template>
```
- [ ] **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 9Home 页(大类 + 种类)
**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
<script setup>
defineProps({ label: String, value: String, active: Boolean })
</script>
<template>
<div class="aspect-square rounded-card shadow-card flex flex-col items-center justify-center transition active:scale-[0.98]"
:class="active ? 'bg-brand text-white' : 'bg-white text-neutral-800'">
<div class="text-3xl font-semibold">{{ label.slice(0,1) }}</div>
<div class="mt-2 text-sm">{{ label }}</div>
</div>
</template>
```
- [ ] **Step 2: `CategoryCard.vue`**
```vue
<script setup>
defineProps({ value: String, count: Number })
</script>
<template>
<div class="relative px-4 py-3 rounded-card bg-white shadow-card active:bg-surface-warm">
<div class="font-medium truncate">{{ value }}</div>
<div class="absolute top-2 right-3 text-xs text-muted tnum">{{ count }}</div>
</div>
</template>
```
- [ ] **Step 3: 实现 `Home.vue`**
```vue
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import MajorCategoryCard from '@/components/MajorCategoryCard.vue'
import CategoryCard from '@/components/CategoryCard.vue'
import Skeleton from '@/components/Skeleton.vue'
import { useAuthStore } from '@/store/auth'
import { useUiStore } from '@/store/ui'
import { fetchCategoriesByMajor } from '@/api/material'
defineOptions({ name: 'Home' })
const router = useRouter()
const auth = useAuthStore()
const ui = useUiStore()
const majors = [
{ value: 'architecture', label: '建筑' },
{ value: 'landscape', label: '景观' },
{ value: 'equipment', label: '设备' },
{ value: 'decoration', label: '装修' },
]
const selected = ref(ui.selectedMajor)
const categories = ref([])
const loading = ref(false)
const loadCategories = async (major) => {
if (!major) { categories.value = []; return }
loading.value = true
try { categories.value = await fetchCategoriesByMajor(major) }
finally { loading.value = false }
}
const onSelect = (v) => {
selected.value = selected.value === v ? '' : v
ui.setMajor(selected.value)
}
watch(selected, loadCategories, { immediate: true })
onMounted(async () => {
if (!auth.user) { try { await auth.loadUser() } catch {} }
})
const goCategory = (c) => router.push({ name: 'CategoryDetail', params: { major: selected.value, category: c.value } })
const onLogout = () => { auth.logout(); router.replace('/login') }
</script>
<template>
<div class="min-h-screen">
<header class="h-12 px-4 flex items-center justify-between bg-white border-b border-line">
<div class="text-sm text-muted">你好<span class="text-neutral-800 font-medium">{{ auth.user?.username || '' }}</span></div>
<button class="text-sm text-muted active:text-danger" @click="onLogout">退出</button>
</header>
<section class="p-4 grid grid-cols-2 gap-3">
<MajorCategoryCard v-for="m in majors" :key="m.value"
:label="m.label" :value="m.value" :active="selected === m.value"
@click="onSelect(m.value)" />
</section>
<section class="px-4 pb-6">
<transition name="fade">
<div v-if="selected">
<div class="text-xs text-muted mb-2">细分种类</div>
<div v-if="loading" class="grid grid-cols-2 gap-3">
<Skeleton v-for="n in 4" :key="n" class="h-14" />
</div>
<div v-else-if="categories.length" class="grid grid-cols-2 gap-3">
<CategoryCard v-for="c in categories" :key="c.value" :value="c.value" :count="c.count" @click="goCategory(c)" />
</div>
<div v-else class="py-10 text-center text-sm text-muted">该大类暂无已审核材料</div>
</div>
<div v-else class="py-10 text-center text-sm text-muted">点击上方分类查看细分种类</div>
</transition>
</section>
</div>
</template>
<style scoped>
.fade-enter-active,.fade-leave-active{transition:all .2s ease}
.fade-enter-from,.fade-leave-to{opacity:0;transform:translateY(-4px)}
</style>
```
- [ ] **Step 4: 手工验证**
登录 → 看到 4 个大类;点一个大类 → 下方出现种类卡片;点种类 → 路由进入 CategoryDetail 占位。
- [ ] **Step 5: Commit**
```bash
git add frontend-h5
git commit -m "feat(h5): 首页大类+种类浏览"
```
---
## Task 10CategoryDetail 页(子类 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
<script setup>
import Chip from './Chip.vue'
import StarLevel from './StarLevel.vue'
const props = defineProps({ item: Object })
const importanceTone = (lv) => {
if (lv === '核心') return 'danger'
if (lv === '优先') return 'info'
return 'neutral'
}
const costLabel = (v) => (v == null ? '—' : `${v > 0 ? '+' : ''}${Number(v)}%`)
</script>
<template>
<div class="bg-white rounded-card shadow-card p-4 active:bg-surface-warm">
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="text-base font-semibold truncate">{{ item.name }}</div>
<div class="mt-1 text-xs text-muted truncate">{{ item.factory_short_name || '—' }}</div>
<div class="mt-2 flex flex-wrap gap-1">
<Chip v-if="item.importance_level" :tone="importanceTone(item.importance_level)">{{ item.importance_level }}</Chip>
<Chip v-for="a in (item.advantage_display || []).slice(0,2)" :key="a" tone="brand">{{ a }}</Chip>
</div>
</div>
<div class="text-right shrink-0">
<div class="tnum text-lg font-semibold" :class="item.cost_compare < 0 ? 'text-brand' : 'text-neutral-800'">
{{ costLabel(item.cost_compare) }}
</div>
<div class="text-[11px] text-muted mt-0.5">成本对比</div>
<div class="mt-2"><StarLevel :value="item.score_level || 0" /></div>
</div>
</div>
</div>
</template>
```
- [ ] **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
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import NavBar from '@/components/NavBar.vue'
import MaterialCard from '@/components/MaterialCard.vue'
import Skeleton from '@/components/Skeleton.vue'
import { useUiStore } from '@/store/ui'
import { fetchSubcategoriesByCategory, fetchMaterials } from '@/api/material'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
defineOptions({ name: 'CategoryDetail' })
const props = defineProps({ major: String, category: String })
const router = useRouter()
const ui = useUiStore()
const stateKey = computed(() => `${props.major}::${props.category}`)
const subs = ref([])
const activeSub = ref(ui.categorySubTab[stateKey.value] || '')
const items = ref([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const initialLoading = ref(true)
const sentinel = ref(null)
const loadSubs = async () => {
subs.value = await fetchSubcategoriesByCategory(props.major, props.category)
}
const resetAndLoad = async () => {
page.value = 1; items.value = []; hasMore.value = true
await loadMore()
}
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const params = {
major_category: props.major,
material_category: props.category,
status: 'approved',
page: page.value,
page_size: 20,
}
if (activeSub.value) params.material_subcategory = activeSub.value
const res = await fetchMaterials(params)
const list = res.results || res
items.value.push(...list)
hasMore.value = !!res.next
page.value += 1
} finally { loading.value = false; initialLoading.value = false }
}
const pickSub = (v) => {
activeSub.value = v
ui.setSubTab(stateKey.value, v)
initialLoading.value = true
resetAndLoad()
}
onMounted(async () => {
await loadSubs()
await resetAndLoad()
})
useInfiniteScroll(sentinel, loadMore)
const goDetail = (id) => router.push({ name: 'MaterialDetail', params: { id } })
</script>
<template>
<div class="min-h-screen pb-6">
<NavBar :title="category" />
<div class="sticky top-12 z-10 bg-white border-b border-line">
<div class="flex gap-1 overflow-x-auto px-2 py-2 no-scrollbar">
<button class="px-3 py-1 rounded-full text-sm whitespace-nowrap"
:class="activeSub === '' ? 'bg-brand text-white' : 'bg-surface-warm text-neutral-700'"
@click="pickSub('')">全部</button>
<button v-for="s in subs" :key="s.value"
class="px-3 py-1 rounded-full text-sm whitespace-nowrap"
:class="activeSub === s.value ? 'bg-brand text-white' : 'bg-surface-warm text-neutral-700'"
@click="pickSub(s.value)">{{ s.value }} ({{ s.count }})</button>
</div>
</div>
<section class="p-4 space-y-3">
<template v-if="initialLoading">
<Skeleton v-for="n in 4" :key="n" class="h-24" />
</template>
<template v-else-if="items.length">
<MaterialCard v-for="it in items" :key="it.id" :item="it" @click="goDetail(it.id)" />
<div ref="sentinel" class="py-4 text-center text-xs text-muted">
{{ hasMore ? (loading ? '加载中…' : '下拉加载') : '没有更多了' }}
</div>
</template>
<div v-else class="py-16 text-center text-sm text-muted">暂无材料</div>
</section>
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar { display: none; }
</style>
```
- [ ] **Step 4: 手工验证**
进入某种类 → 能看到"全部"及各子类 Tab切换 Tab 列表刷新;滚到底继续加载;点卡片进入材料详情占位。
- [ ] **Step 5: Commit**
```bash
git add frontend-h5
git commit -m "feat(h5): 种类详情页(子类 Tab + 材料列表)"
```
---
## Task 11MaterialDetail 页(三块展示)
**Files:**
- Modify: `frontend-h5/src/views/MaterialDetail.vue`
- [ ] **Step 1: 实现 `MaterialDetail.vue`**
```vue
<script setup>
import { ref, onMounted, computed } from 'vue'
import NavBar from '@/components/NavBar.vue'
import Chip from '@/components/Chip.vue'
import StarLevel from '@/components/StarLevel.vue'
import Skeleton from '@/components/Skeleton.vue'
import { fetchMaterialDetail } from '@/api/material'
defineOptions({ name: 'MaterialDetail' })
const props = defineProps({ id: [String, Number] })
const data = ref(null)
const loading = ref(true)
onMounted(async () => {
try { data.value = await fetchMaterialDetail(props.id) }
finally { loading.value = false }
})
const importanceTone = (lv) => lv === '核心' ? 'danger' : lv === '优先' ? 'info' : 'neutral'
const d = (v) => (v == null || v === '') ? '—' : v
const costLabel = (v) => v == null ? '—' : `${v > 0 ? '+' : ''}${Number(v)}%`
const location = computed(() => {
const x = data.value
if (!x) return ''
return [x.factory_province_name, x.factory_city_name].filter(Boolean).join(' · ')
})
</script>
<template>
<div class="min-h-screen pb-8">
<NavBar :title="data?.name || '材料详情'" />
<template v-if="loading">
<div class="p-4 space-y-3">
<Skeleton class="h-40" />
<Skeleton class="h-60" />
<Skeleton class="h-40" />
</div>
</template>
<template v-else-if="data">
<img v-if="data.brochure" :src="data.brochure" class="w-full aspect-video object-cover bg-surface-warm" />
<!-- § 材料信息 -->
<section class="mx-4 mt-4 p-4 bg-white rounded-card shadow-card">
<h2 class="text-sm font-semibold text-neutral-500 mb-3">材料信息</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span class="text-muted">材料名称</span><span>{{ d(data.name) }}</span>
<span class="text-muted">材料大类</span><span>{{ d(data.major_category_display) }}</span>
<span class="text-muted">细分种类</span><span>{{ d(data.material_category) }}</span>
<span class="text-muted">材料子类</span><span>{{ d(data.material_subcategory) }}</span>
<span class="text-muted">阶段</span><span>{{ d(data.stage_display) }}</span>
<span class="text-muted">重要等级</span>
<span>
<Chip v-if="data.importance_level" :tone="importanceTone(data.importance_level)">{{ data.importance_level }}</Chip>
<span v-else>—</span>
</span>
<span class="text-muted">规格型号</span><span>{{ d(data.spec) }}</span>
<span class="text-muted">符合标准</span><span>{{ d(data.standard) }}</span>
<span class="text-muted">应用场景</span>
<span class="flex flex-wrap gap-1">
<Chip v-for="a in (data.application_scene_display || [])" :key="a" tone="brand">{{ a }}</Chip>
<span v-if="!(data.application_scene_display||[]).length">—</span>
</span>
<span class="text-muted">替代类型</span><span>{{ d(data.replace_type_display) }}</span>
<span class="text-muted">连接方式</span><span>{{ d(data.connection_method) }}</span>
<span class="text-muted">施工工艺</span><span>{{ d(data.construction_method) }}</span>
<span class="text-muted">竞争优势</span>
<span class="flex flex-wrap gap-1">
<Chip v-for="a in (data.advantage_display || [])" :key="a" tone="brand">{{ a }}</Chip>
<span v-if="!(data.advantage_display||[]).length">—</span>
</span>
<span class="text-muted">成本对比</span><span class="tnum" :class="data.cost_compare < 0 ? 'text-brand font-medium' : ''">{{ costLabel(data.cost_compare) }}</span>
</div>
<div v-if="data.application_desc" class="mt-4 pt-3 border-t border-line">
<div class="text-muted text-xs mb-1">应用说明</div>
<div class="text-sm whitespace-pre-wrap">{{ data.application_desc }}</div>
</div>
<div v-if="data.advantage_desc" class="mt-3">
<div class="text-muted text-xs mb-1">优势说明</div>
<div class="text-sm whitespace-pre-wrap">{{ data.advantage_desc }}</div>
</div>
<div v-if="data.cost_desc" class="mt-3">
<div class="text-muted text-xs mb-1">成本说明</div>
<div class="text-sm whitespace-pre-wrap">{{ data.cost_desc }}</div>
</div>
<div v-if="data.limit_condition" class="mt-3">
<div class="text-muted text-xs mb-1">限制条件</div>
<div class="text-sm whitespace-pre-wrap">{{ data.limit_condition }}</div>
</div>
<div class="mt-4 pt-3 border-t border-line grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm items-center">
<span class="text-muted">质量等级</span><StarLevel :value="data.quality_level || 0" />
<span class="text-muted">耐久等级</span><StarLevel :value="data.durability_level || 0" />
<span class="text-muted">环保等级</span><StarLevel :value="data.eco_level || 0" />
<span class="text-muted">低碳等级</span><StarLevel :value="data.carbon_level || 0" />
<span class="text-muted font-medium">综合评分</span><StarLevel :value="data.score_level || 0" />
</div>
</section>
<!-- § 品牌与供应商 -->
<section class="mx-4 mt-4 p-4 bg-white rounded-card shadow-card">
<h2 class="text-sm font-semibold text-neutral-500 mb-3">品牌与供应商</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span class="text-muted">品牌</span><span>{{ d(data.brand_name) }}</span>
<span class="text-muted">供应商简称</span><span>{{ d(data.factory_short_name) }}</span>
<span class="text-muted">供应商全称</span><span>{{ d(data.factory_name) }}</span>
<span class="text-muted">合作模式</span><span>{{ d(data.factory_cooperation_mode_display) }}</span>
<span class="text-muted">省-市</span><span>{{ d(location) }}</span>
<span class="text-muted">对接人</span><span>{{ d(data.contact_person) }}</span>
<span class="text-muted">联系方式</span>
<span>
<a v-if="data.contact_phone" :href="`tel:${data.contact_phone}`" class="text-brand">{{ data.contact_phone }}</a>
<span v-else>—</span>
</span>
</div>
</section>
<!-- § 案例信息 -->
<section class="mx-4 mt-4 p-4 bg-white rounded-card shadow-card">
<h2 class="text-sm font-semibold text-neutral-500 mb-3">案例信息</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span class="text-muted">落地项目</span><span>{{ d(data.landing_project) }}</span>
<span class="text-muted">经办人</span><span>{{ d(data.handler) }}</span>
<span class="text-muted">备注</span><span>{{ d(data.remark) }}</span>
</div>
<div v-if="data.cases" class="mt-3">
<div class="text-muted text-xs mb-1">案例</div>
<div class="text-sm whitespace-pre-wrap">{{ data.cases }}</div>
</div>
</section>
</template>
</div>
</template>
```
- [ ] **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便于回滚。