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

42 KiB
Raw Blame History

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.pyMaterialViewSet 新增两个 @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.pyMaterialViewSet 类内部)

  • Step 1: 在 MaterialViewSet 中新增 categories_by_major action

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_category action

紧接着追加:

@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 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

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 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

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 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

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 / MaterialDetailname 用于 <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 8Login 页

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 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

<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 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

<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 11MaterialDetail 页(三块展示)

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.htmlassets/*.jsassets/*.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 提交)


规范与复用要点

  • DRYd() / 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便于回滚。