feat(h5): 种类详情页(子类 Tab + 材料列表)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a136c3db85
commit
5ff8874139
|
|
@ -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>
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue