perf(h5): 合并为单次 category-tree 接口 + 后端缓存

- 后端新增 /material/category-tree/ 一次性聚合 (大类→种类→子类),使用 Django cache 全局缓存(30 分钟)
- Material post_save / post_delete 信号自动失效缓存,保证一致性
- H5 首页从 4 次并行请求降为 1 次;命中缓存时不打 DB

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-04-27 09:34:29 +08:00
parent 1a0af18457
commit 25587dce21
5 changed files with 111 additions and 39 deletions

View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class MaterialConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.material'
def ready(self):
from . import signals # noqa: F401

View File

@ -0,0 +1,21 @@
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Material
CATEGORY_TREE_CACHE_KEY = 'material:category_tree:approved'
def invalidate_category_tree_cache():
cache.delete(CATEGORY_TREE_CACHE_KEY)
@receiver(post_save, sender=Material)
def _on_material_saved(sender, instance, **kwargs):
invalidate_category_tree_cache()
@receiver(post_delete, sender=Material)
def _on_material_deleted(sender, instance, **kwargs):
invalidate_category_tree_cache()

View File

@ -15,9 +15,11 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import MultiPartParser
from django.core.cache import cache
from .models import Material, MaterialCategory, MaterialSubcategory
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
from .importers import import_materials_plan_excel
from .signals import CATEGORY_TREE_CACHE_KEY
from apps.factory.models import COOPERATION_MODE_CHOICES
@ -484,6 +486,59 @@ class MaterialViewSet(ModelViewSet):
return Response(result)
@action(detail=False, methods=['get'], url_path='category-tree', permission_classes=[IsAuthenticated])
def category_tree(self, request):
"""
H5 浏览端的全量种类树一次返回所有大类 材料种类 子类的聚合结果
与用户无关仅依赖 status='approved'故走全局缓存Material 变更时由 signals 失效
"""
cached = cache.get(CATEGORY_TREE_CACHE_KEY)
if cached is not None:
return Response(cached)
rows = (Material.objects
.filter(status='approved')
.exclude(major_category__isnull=True).exclude(major_category__exact='')
.exclude(material_category__isnull=True).exclude(material_category__exact='')
.values('major_category', 'material_category', 'material_subcategory')
.annotate(count=Count('id'))
.order_by('major_category', 'material_category', 'material_subcategory'))
majors = {}
for row in rows:
mj = row['major_category']
cat = row['material_category']
sub = row['material_subcategory'] or ''
cnt = row['count']
major_node = majors.setdefault(mj, {})
cat_node = major_node.setdefault(cat, {'count': 0, 'subs': {}})
cat_node['count'] += cnt
if sub:
cat_node['subs'][sub] = cat_node['subs'].get(sub, 0) + cnt
major_display = dict(Material.MAJOR_CATEGORY_CHOICES)
data = []
for mj_value, cats in majors.items():
data.append({
'value': mj_value,
'label': major_display.get(mj_value, mj_value),
'categories': [
{
'value': cat,
'count': node['count'],
'subcategories': [
{'value': s, 'count': c}
for s, c in sorted(node['subs'].items())
],
}
for cat, node in sorted(cats.items())
],
})
cache.set(CATEGORY_TREE_CACHE_KEY, data, timeout=60 * 30)
return Response(data)
@action(detail=False, methods=['get'], url_path='categories-by-major')
def categories_by_major(self, request):
major = request.query_params.get('major_category')

View File

@ -2,6 +2,7 @@ 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 fetchCategoryTree = async () => (await api.get('/material/category-tree/')).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) =>

View File

@ -1,60 +1,51 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, computed } 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'
import { fetchCategoryTree } from '@/api/material'
defineOptions({ name: 'Home' })
const router = useRouter()
const auth = useAuthStore()
const ui = useUiStore()
const majors = [
const fallbackMajors = [
{ value: 'architecture', label: '建筑' },
{ value: 'landscape', label: '景观' },
{ value: 'equipment', label: '设备' },
{ value: 'decoration', label: '装修' },
]
const tree = ref([])
const loading = ref(true)
const selected = ref(ui.selectedMajor)
const categoriesByMajor = ref({})
const loading = ref(false)
const loadAll = async () => {
loading.value = true
try {
const results = await Promise.all(majors.map((m) => fetchCategoriesByMajor(m.value)))
const map = {}
majors.forEach((m, i) => { map[m.value] = results[i] })
categoriesByMajor.value = map
} finally { loading.value = false }
}
const majors = computed(() => {
if (!tree.value.length) return fallbackMajors
const order = ['architecture', 'landscape', 'equipment', 'decoration']
const map = Object.fromEntries(tree.value.map((n) => [n.value, n]))
return order.filter((v) => map[v]).map((v) => ({ value: v, label: map[v].label }))
})
const visibleGroups = computed(() => {
const list = tree.value.length ? tree.value : []
if (selected.value) return list.filter((g) => g.value === selected.value)
return list
})
const onSelect = (v) => {
selected.value = selected.value === v ? '' : v
ui.setMajor(selected.value)
}
const visibleGroups = computed(() => {
if (selected.value) {
return [{ major: majors.find((m) => m.value === selected.value), categories: categoriesByMajor.value[selected.value] || [] }]
}
return majors.map((m) => ({ major: m, categories: categoriesByMajor.value[m.value] || [] }))
})
watch(selected, (v) => {
if (v && !categoriesByMajor.value[v]) {
fetchCategoriesByMajor(v).then((r) => { categoriesByMajor.value = { ...categoriesByMajor.value, [v]: r } })
}
})
onMounted(async () => {
if (!auth.user) { try { await auth.loadUser() } catch {} }
await loadAll()
try { tree.value = await fetchCategoryTree() }
finally { loading.value = false }
})
const goCategory = (major, c) => router.push({ name: 'CategoryDetail', params: { major, category: c.value } })
@ -75,28 +66,23 @@ const onLogout = () => { auth.logout(); router.replace('/login') }
</section>
<section class="px-4 pb-6 space-y-5">
<div v-if="loading && !Object.keys(categoriesByMajor).length" class="grid grid-cols-2 gap-3">
<div v-if="loading" class="grid grid-cols-2 gap-3">
<Skeleton v-for="n in 6" :key="n" class="h-14" />
</div>
<template v-else>
<div v-for="g in visibleGroups" :key="g.major.value">
<div v-if="!visibleGroups.length" class="py-10 text-center text-sm text-muted">暂无已审核材料</div>
<div v-for="g in visibleGroups" :key="g.value">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-muted">{{ g.major.label }} · 材料种类</div>
<div class="text-xs text-muted">{{ g.label }} · 材料种类</div>
<span class="text-[11px] text-muted tnum">{{ g.categories.length }} </span>
</div>
<div v-if="g.categories.length" class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-2 gap-3">
<CategoryCard v-for="c in g.categories" :key="c.value" :value="c.value" :count="c.count"
@click="goCategory(g.major.value, c)" />
@click="goCategory(g.value, c)" />
</div>
<div v-else class="py-6 text-center text-xs text-muted bg-white rounded-card">暂无已审核材料</div>
</div>
</template>
</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>