42 KiB
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_majoraction
在 MaterialViewSet 类末尾追加:
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_categoryaction
紧接着追加:
@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: 启动后端并手工验证
D:/projects/mat3/backend/.venv/Scripts/python.exe D:/projects/mat3/backend/manage.py runserver
另开终端,拿 token 后请求:
curl -H "Authorization: Bearer <token>" \
"http://localhost:8000/api/material/categories-by-major/?major_category=architecture"
Expected: 返回 [{"value": "...", "count": N}, ...](或 [])。
- Step 4: Commit
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
{
"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
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
<!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
<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)
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
- Step 7: 安装依赖并启动 dev server 验证可运行
cd D:/projects/mat3/frontend-h5 && npm install
npm run dev
Expected: 终端显示 Local: http://localhost:5174/m/,浏览器打开显示空白(无报错)。Ctrl-C 停止。
- Step 8: Commit
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
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
- Step 2: 新建
frontend-h5/tailwind.config.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
@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导入样式
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
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
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
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
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
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
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
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:
import { fileURLToPath, URL } from 'node:url'
// ...
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
再改 main.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
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 个视图占位文件
内容均为:
<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
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
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
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
<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:
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:
<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
<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
<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
<template>
<div class="animate-pulse bg-neutral-200/60 rounded" />
</template>
- Step 6: 手工验证组件
在 Home.vue 占位里临时引入一组组件看看视觉;确认后清理。
- Step 7: Commit
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为完整实现
<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
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
<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
<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
<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
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
<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
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
<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
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
<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
git add frontend-h5
git commit -m "feat(h5): 材料详情页(三块展示)"
Task 12:生产构建 & Nginx 部署说明
Files:
-
Create:
frontend-h5/README.md -
Step 1: 在
frontend-h5/根执行构建
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记录部署
内容(简短):
# 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
git add frontend-h5/README.md
git commit -m "docs(h5): 添加 README 部署说明"
Task 13:端到端验收
- Step 1: 启动后端 + H5 dev server
# 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,便于回滚。