feat(h5): 种类详情页(子类 Tab + 材料列表)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-04-27 08:56:34 +08:00
parent a136c3db85
commit 5ff8874139
3 changed files with 150 additions and 2 deletions

View File

@ -0,0 +1,34 @@
<script setup>
import Chip from './Chip.vue'
import StarLevel from './StarLevel.vue'
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 cursor-pointer">
<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>

View File

@ -0,0 +1,12 @@
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())
}

View File

@ -1,2 +1,104 @@
<script setup>defineOptions({ name: 'CategoryDetail' })</script>
<template><div class="p-4">CategoryDetail 占位</div></template>
<script setup>
import { ref, onMounted, 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>