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:
parent
1a0af18457
commit
25587dce21
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue