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.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')
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue