1386 lines
42 KiB
Markdown
1386 lines
42 KiB
Markdown
# 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 3:Tailwind 配置与设计令牌
|
||
|
||
**Files:**
|
||
- Create: `frontend-h5/postcss.config.js`
|
||
- Create: `frontend-h5/tailwind.config.js`
|
||
- Create: `frontend-h5/src/styles/tailwind.css`
|
||
- Modify: `frontend-h5/src/main.js`
|
||
|
||
- [ ] **Step 1: 新建 `frontend-h5/postcss.config.js`**
|
||
|
||
```js
|
||
export default {
|
||
plugins: {
|
||
tailwindcss: {},
|
||
autoprefixer: {},
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 新建 `frontend-h5/tailwind.config.js`**
|
||
|
||
```js
|
||
export default {
|
||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
brand: { DEFAULT: '#2F4F3F', dark: '#233C2F', light: '#6B8878' },
|
||
surface: { DEFAULT: '#FFFFFF', alt: '#FAFAFA', warm: '#F5F4F2' },
|
||
danger: '#D2584A',
|
||
info: '#5A7FB8',
|
||
muted: '#8A8A8A',
|
||
line: '#EEEEEE',
|
||
},
|
||
borderRadius: { card: '18px' },
|
||
boxShadow: { card: '0 1px 2px rgba(0,0,0,0.04)' },
|
||
fontFamily: {
|
||
sans: ['-apple-system', 'BlinkMacSystemFont', '"PingFang SC"', '"Microsoft YaHei"', 'system-ui', 'sans-serif'],
|
||
},
|
||
},
|
||
},
|
||
plugins: [],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 新建 `frontend-h5/src/styles/tailwind.css`**
|
||
|
||
```css
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
@layer base {
|
||
html, body, #app {
|
||
@apply h-full bg-surface-alt text-neutral-900 font-sans antialiased;
|
||
}
|
||
body { -webkit-tap-highlight-color: transparent; }
|
||
.tnum { font-feature-settings: 'tnum'; font-variant-numeric: tabular-nums; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 在 `main.js` 导入样式**
|
||
|
||
```js
|
||
import { createApp } from 'vue'
|
||
import App from './App.vue'
|
||
import './styles/tailwind.css'
|
||
|
||
createApp(App).mount('#app')
|
||
```
|
||
|
||
- [ ] **Step 5: 验证 Tailwind 生效**
|
||
|
||
把 `App.vue` 的 `<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 4:Axios 客户端 + API 模块
|
||
|
||
**Files:**
|
||
- Create: `frontend-h5/src/api/client.js`
|
||
- Create: `frontend-h5/src/api/auth.js`
|
||
- Create: `frontend-h5/src/api/material.js`
|
||
|
||
- [ ] **Step 1: 新建 `src/api/client.js`**
|
||
|
||
```js
|
||
import axios from 'axios'
|
||
|
||
const api = axios.create({ baseURL: '/api', timeout: 15000 })
|
||
|
||
api.interceptors.request.use((config) => {
|
||
const token = localStorage.getItem('h5_token')
|
||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||
return config
|
||
})
|
||
|
||
api.interceptors.response.use(
|
||
(res) => res,
|
||
(err) => {
|
||
if (err.response?.status === 401) {
|
||
localStorage.removeItem('h5_token')
|
||
const current = window.location.pathname + window.location.search
|
||
if (!current.startsWith('/login')) {
|
||
window.location.href = `/m/login?redirect=${encodeURIComponent(current)}`
|
||
}
|
||
}
|
||
return Promise.reject(err)
|
||
},
|
||
)
|
||
|
||
export default api
|
||
```
|
||
|
||
- [ ] **Step 2: 新建 `src/api/auth.js`**
|
||
|
||
```js
|
||
import api from './client'
|
||
|
||
export const login = async (payload) => (await api.post('/auth/login/', payload)).data
|
||
export const fetchCurrentUser = async () => (await api.get('/auth/user/')).data
|
||
```
|
||
|
||
- [ ] **Step 3: 新建 `src/api/material.js`**
|
||
|
||
```js
|
||
import api from './client'
|
||
|
||
export const fetchMaterials = async (params) => (await api.get('/material/', { params })).data
|
||
export const fetchMaterialDetail = async (id) => (await api.get(`/material/${id}/`)).data
|
||
export const fetchCategoriesByMajor = async (major_category) =>
|
||
(await api.get('/material/categories-by-major/', { params: { major_category } })).data
|
||
export const fetchSubcategoriesByCategory = async (major_category, material_category) =>
|
||
(await api.get('/material/subcategories-by-category/', { params: { major_category, material_category } })).data
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend-h5/src/api
|
||
git commit -m "feat(h5): 新增 axios 客户端与 API 模块"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5:Pinia Store(auth / ui)
|
||
|
||
**Files:**
|
||
- Create: `frontend-h5/src/store/auth.js`
|
||
- Create: `frontend-h5/src/store/ui.js`
|
||
- Modify: `frontend-h5/src/main.js`
|
||
|
||
- [ ] **Step 1: 新建 `src/store/auth.js`**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import * as authApi from '@/api/auth'
|
||
import api from '@/api/client'
|
||
|
||
export const useAuthStore = defineStore('auth', {
|
||
state: () => ({
|
||
token: localStorage.getItem('h5_token') || '',
|
||
user: null,
|
||
}),
|
||
getters: {
|
||
isAuthed: (s) => !!s.token,
|
||
},
|
||
actions: {
|
||
async login(payload) {
|
||
const data = await authApi.login(payload)
|
||
this.token = data.token || data.access || data.access_token
|
||
localStorage.setItem('h5_token', this.token)
|
||
await this.loadUser()
|
||
},
|
||
async loadUser() {
|
||
this.user = await authApi.fetchCurrentUser()
|
||
},
|
||
logout() {
|
||
this.token = ''
|
||
this.user = null
|
||
localStorage.removeItem('h5_token')
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
> 说明:`login` 返回字段以现有后端为准;启动时如果字段名不匹配会在登录 task 手工验证后再调整。
|
||
|
||
- [ ] **Step 2: 新建 `src/store/ui.js`**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
|
||
export const useUiStore = defineStore('ui', {
|
||
state: () => ({
|
||
selectedMajor: '', // Home 上次选中的大类
|
||
categorySubTab: {}, // { 'architecture::地砖': '釉面砖' | '' }
|
||
scrollCache: {}, // { routeKey: scrollTop }
|
||
}),
|
||
actions: {
|
||
setMajor(v) { this.selectedMajor = v },
|
||
setSubTab(key, v) { this.categorySubTab[key] = v },
|
||
saveScroll(key, top) { this.scrollCache[key] = top },
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 在 `main.js` 注册 pinia 与 `@` 别名**
|
||
|
||
先改 `vite.config.js`,增加 resolve alias:
|
||
|
||
```js
|
||
import { fileURLToPath, URL } from 'node:url'
|
||
// ...
|
||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||
```
|
||
|
||
再改 `main.js`:
|
||
|
||
```js
|
||
import { createApp } from 'vue'
|
||
import { createPinia } from 'pinia'
|
||
import App from './App.vue'
|
||
import './styles/tailwind.css'
|
||
|
||
const app = createApp(App)
|
||
app.use(createPinia())
|
||
app.mount('#app')
|
||
```
|
||
|
||
- [ ] **Step 4: 启动 `npm run dev` 确认无报错**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend-h5
|
||
git commit -m "feat(h5): 新增 pinia auth/ui store 与 @ 别名"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6:路由与守卫
|
||
|
||
**Files:**
|
||
- Create: `frontend-h5/src/router/index.js`
|
||
- Modify: `frontend-h5/src/main.js`
|
||
- Create: `frontend-h5/src/views/Login.vue`(占位)
|
||
- Create: `frontend-h5/src/views/Home.vue`(占位)
|
||
- Create: `frontend-h5/src/views/CategoryDetail.vue`(占位)
|
||
- Create: `frontend-h5/src/views/MaterialDetail.vue`(占位)
|
||
|
||
- [ ] **Step 1: 新建 4 个视图占位文件**
|
||
|
||
内容均为:
|
||
```vue
|
||
<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 8:Login 页
|
||
|
||
**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 9:Home 页(大类 + 种类)
|
||
|
||
**Files:**
|
||
- Create: `frontend-h5/src/components/MajorCategoryCard.vue`
|
||
- Create: `frontend-h5/src/components/CategoryCard.vue`
|
||
- Modify: `frontend-h5/src/views/Home.vue`
|
||
|
||
- [ ] **Step 1: `MajorCategoryCard.vue`**
|
||
|
||
```vue
|
||
<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 10:CategoryDetail 页(子类 Tab + 材料列表)
|
||
|
||
**Files:**
|
||
- Create: `frontend-h5/src/components/MaterialCard.vue`
|
||
- Create: `frontend-h5/src/composables/useInfiniteScroll.js`
|
||
- Modify: `frontend-h5/src/views/CategoryDetail.vue`
|
||
|
||
- [ ] **Step 1: `MaterialCard.vue`**
|
||
|
||
```vue
|
||
<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 11:MaterialDetail 页(三块展示)
|
||
|
||
**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,便于回滚。
|