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.viewsets import ModelViewSet
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from django.core.cache import cache
from .models import Material, MaterialCategory, MaterialSubcategory from .models import Material, MaterialCategory, MaterialSubcategory
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
from .importers import import_materials_plan_excel from .importers import import_materials_plan_excel
from .signals import CATEGORY_TREE_CACHE_KEY
from apps.factory.models import COOPERATION_MODE_CHOICES from apps.factory.models import COOPERATION_MODE_CHOICES
@ -484,6 +486,59 @@ class MaterialViewSet(ModelViewSet):
return Response(result) 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') @action(detail=False, methods=['get'], url_path='categories-by-major')
def categories_by_major(self, request): def categories_by_major(self, request):
major = request.query_params.get('major_category') 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 fetchMaterials = async (params) => (await api.get('/material/', { params })).data
export const fetchMaterialDetail = async (id) => (await api.get(`/material/${id}/`)).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) => export const fetchCategoriesByMajor = async (major_category) =>
(await api.get('/material/categories-by-major/', { params: { major_category } })).data (await api.get('/material/categories-by-major/', { params: { major_category } })).data
export const fetchSubcategoriesByCategory = async (major_category, material_category) => export const fetchSubcategoriesByCategory = async (major_category, material_category) =>

View File

@ -1,60 +1,51 @@
<script setup> <script setup>
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import MajorCategoryCard from '@/components/MajorCategoryCard.vue' import MajorCategoryCard from '@/components/MajorCategoryCard.vue'
import CategoryCard from '@/components/CategoryCard.vue' import CategoryCard from '@/components/CategoryCard.vue'
import Skeleton from '@/components/Skeleton.vue' import Skeleton from '@/components/Skeleton.vue'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useUiStore } from '@/store/ui' import { useUiStore } from '@/store/ui'
import { fetchCategoriesByMajor } from '@/api/material' import { fetchCategoryTree } from '@/api/material'
defineOptions({ name: 'Home' }) defineOptions({ name: 'Home' })
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const majors = [ const fallbackMajors = [
{ value: 'architecture', label: '建筑' }, { value: 'architecture', label: '建筑' },
{ value: 'landscape', label: '景观' }, { value: 'landscape', label: '景观' },
{ value: 'equipment', label: '设备' }, { value: 'equipment', label: '设备' },
{ value: 'decoration', label: '装修' }, { value: 'decoration', label: '装修' },
] ]
const tree = ref([])
const loading = ref(true)
const selected = ref(ui.selectedMajor) const selected = ref(ui.selectedMajor)
const categoriesByMajor = ref({})
const loading = ref(false)
const loadAll = async () => { const majors = computed(() => {
loading.value = true if (!tree.value.length) return fallbackMajors
try { const order = ['architecture', 'landscape', 'equipment', 'decoration']
const results = await Promise.all(majors.map((m) => fetchCategoriesByMajor(m.value))) const map = Object.fromEntries(tree.value.map((n) => [n.value, n]))
const map = {} return order.filter((v) => map[v]).map((v) => ({ value: v, label: map[v].label }))
majors.forEach((m, i) => { map[m.value] = results[i] }) })
categoriesByMajor.value = map
} finally { loading.value = false } 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) => { const onSelect = (v) => {
selected.value = selected.value === v ? '' : v selected.value = selected.value === v ? '' : v
ui.setMajor(selected.value) 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 () => { onMounted(async () => {
if (!auth.user) { try { await auth.loadUser() } catch {} } 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 } }) 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>
<section class="px-4 pb-6 space-y-5"> <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" /> <Skeleton v-for="n in 6" :key="n" class="h-14" />
</div> </div>
<template v-else> <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="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> <span class="text-[11px] text-muted tnum">{{ g.categories.length }} </span>
</div> </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" <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>
<div v-else class="py-6 text-center text-xs text-muted bg-white rounded-card">暂无已审核材料</div>
</div> </div>
</template> </template>
</section> </section>
</div> </div>
</template> </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>