Files
pc-10584/app/components/shop/GoodsCategoryPage.vue
赵忠林 682e264a6f feat(router): 更新路由结构并优化页面组件
- 移除经营范围按钮,精简导航栏
- 实现文章标题链接功能,提升用户体验
- 添加商品详情页面包屑导航,支持分类跳转
- 引入配送管理相关页面(区域、接单台、配送员、派单)
- 替换控制台布局为站点头部和底部组件
- 重构商品分类页面,集成CMS导航功能
- 新增文章详情页面,支持多种访问方式
- 删除已迁移的创建应用和空应用页面
- 优化样式和组件导入,提升代码质量
2026-01-29 16:21:22 +08:00

445 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<main class="goods-page">
<section class="goods-hero" :style="heroStyle">
<div class="goods-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="goods-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item>商品</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="goods-hero-title">{{ pageTitle }}</div>
<div class="goods-hero-meta">
<a-tag v-if="typeof total === 'number'" color="green"> {{ total }} </a-tag>
<a-tag v-if="keywords" color="blue">关键词{{ keywords }}</a-tag>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<a-input
v-model:value="keywordInput"
allow-clear
class="w-full sm:w-80"
placeholder="搜索商品名称/关键词"
@press-enter="applySearch"
/>
<a-button type="primary" @click="applySearch">搜索</a-button>
<a-button v-if="keywords" @click="clearSearch">清除</a-button>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="goods-card" :bordered="false">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="!isValidCategoryId"
status="404"
title="分类不存在"
sub-title="分类ID无效或缺失"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="loadError"
status="error"
title="商品加载失败"
:sub-title="loadError.message"
>
<template #extra>
<a-space>
<a-button type="primary" @click="refresh()">重试</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<a-empty v-if="!goodsList.length" description="暂无商品" />
<div v-else class="grid grid-cols-12 gap-6">
<a-card
v-for="(g, idx) in goodsList"
:key="String(g.goodsId ?? g.code ?? `${String(route.params.navigationId)}-${idx}`)"
hoverable
class="col-span-12 sm:col-span-6 lg:col-span-3 goods-item"
@click="goDetail(g)"
>
<template #cover>
<img
class="goods-cover"
:src="resolveGoodsImage(g)"
:alt="resolveGoodsTitle(g)"
loading="lazy"
@error="onImgError"
/>
</template>
<a-card-meta :title="resolveGoodsTitle(g)">
<template #description>
<div class="goods-desc">
<div class="goods-price-row">
<span class="goods-price">
{{ formatMoney(resolveGoodsPrice(g)) }}
<span v-if="resolveGoodsUnit(g)" class="goods-unit">/ {{ resolveGoodsUnit(g) }}</span>
</span>
<a-tag v-if="Number(g.isNew) === 1" color="blue">新品</a-tag>
<a-tag v-if="Number(g.recommend) === 1" color="green">推荐</a-tag>
</div>
<div class="goods-sub">
<span v-if="typeof g.sales === 'number'">销量 {{ g.sales }}</span>
<span v-if="typeof g.stock === 'number'">库存 {{ g.stock }}</span>
</div>
</div>
</template>
</a-card-meta>
</a-card>
</div>
<div v-if="total > 0" class="mt-6 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['12', '24', '48', '96']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</template>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import { pageShopGoods } from '@/api/shop/shopGoods'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import type { LocationQueryRaw } from 'vue-router'
const route = useRoute()
const router = useRouter()
function parseNumberParam(raw: unknown): number {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
}
function parsePositiveInt(raw: unknown, fallback: number) {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
if (!Number.isFinite(n)) return fallback
const i = Math.floor(n)
return i > 0 ? i : fallback
}
function parseQueryString(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
return typeof text === 'string' ? text.trim() : ''
}
const categoryId = computed(() => parseNumberParam(route.params.navigationId))
const isValidCategoryId = computed(() => Number.isFinite(categoryId.value) && categoryId.value > 0)
const page = computed(() => parsePositiveInt(route.query.page, 1))
const limit = computed(() => parsePositiveInt(route.query.limit, 12))
const keywords = computed(() => parseQueryString(route.query.q))
const keywordInput = ref(keywords.value)
watch(
() => keywords.value,
(v) => {
keywordInput.value = v
}
)
function updateQuery(next: LocationQueryRaw) {
// Keep list state shareable via URL.
router.replace({
path: route.path,
query: {
...route.query,
...next
}
})
}
function applySearch() {
updateQuery({ q: keywordInput.value?.trim() || undefined, page: 1 })
}
function clearSearch() {
keywordInput.value = ''
updateQuery({ q: undefined, page: 1 })
}
function onPageChange(nextPage: number) {
updateQuery({ page: nextPage })
}
function onPageSizeChange(_current: number, nextSize: number) {
updateQuery({ limit: nextSize, page: 1 })
}
const { data: navigation } = await useAsyncData<CmsNavigation | null>(
() => `cms-navigation-${String(route.params.navigationId)}`,
async () => {
if (!isValidCategoryId.value) return null
return await getCmsNavigation(categoryId.value).catch(() => null)
},
{ watch: [categoryId] }
)
const {
data: goodsPage,
pending,
error: loadError,
refresh
} = await useAsyncData<{ list: ShopGoods[]; count: number } | null>(
() =>
`shop-goods-${String(route.params.navigationId)}-${page.value}-${limit.value}-${keywords.value}`,
async () => {
if (!isValidCategoryId.value) return null
return await pageShopGoods({
categoryId: categoryId.value,
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
})
},
{ watch: [categoryId, page, limit, keywords] }
)
const goodsList = computed(() => goodsPage.value?.list ?? [])
const total = computed(() => goodsPage.value?.count ?? 0)
function goDetail(g: ShopGoods) {
const id = (g as unknown as { goodsId?: unknown }).goodsId
const n = typeof id === 'number' ? id : Number(id)
if (!Number.isFinite(n)) return
navigateTo(`/goods-item/${n}`)
}
function pickString(obj: unknown, key: string) {
if (!obj || typeof obj !== 'object') return ''
const record = obj as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
const pageTitle = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
const name = nav ? pickString(nav, 'title') || pickString(nav, 'label') : ''
if (name) return name
if (isValidCategoryId.value) return `分类 ${categoryId.value}`
return '商品列表'
})
const heroStyle = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
const banner = nav ? (pickString(nav, 'banner') || pickString(nav, 'image')) : ''
if (banner) {
return {
backgroundImage: `url(${banner})`
}
}
return {}
})
function resolveGoodsTitle(g: ShopGoods) {
return String(g.goodsName || g.name || g.code || '未命名商品').trim()
}
function resolveGoodsPrice(g: ShopGoods) {
const anyG = g as unknown as Record<string, unknown>
const candidates = [
anyG.salePrice,
anyG.price,
anyG.chainStorePrice,
anyG.originPrice,
anyG.buyingPrice
]
for (const v of candidates) {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (typeof v === 'string' && v.trim()) return v.trim()
}
return undefined
}
function resolveGoodsUnit(g: ShopGoods) {
const unit = (g as unknown as { unitName?: unknown }).unitName
if (typeof unit === 'string' && unit.trim()) return unit.trim()
return ''
}
function resolveGoodsImage(g: ShopGoods) {
const img = typeof g.image === 'string' ? g.image.trim() : ''
if (img) return img
const files = typeof g.files === 'string' ? g.files.trim() : ''
if (files) {
// Some APIs store files as JSON array string: [{ url: "..." }, ...]
if (files.startsWith('[')) {
try {
const parsed = JSON.parse(files) as unknown
if (Array.isArray(parsed)) {
const first = parsed[0] as unknown
const rec = first && typeof first === 'object' ? (first as Record<string, unknown>) : null
const url = rec && typeof rec.url === 'string' ? rec.url.trim() : ''
if (url) return url
}
} catch {
// ignore JSON parse errors
}
}
return files.split(',')[0]?.trim() || ''
}
// Keep it empty -> browser will trigger @error and swap to placeholder.
return ''
}
function onImgError(e: Event) {
const img = e.target as HTMLImageElement | null
if (!img) return
img.onerror = null
img.src =
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
}
function formatMoney(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return `¥${value.toFixed(2)}`
const v = typeof value === 'string' ? value.trim() : ''
if (!v) return '-'
const n = Number(v)
if (!Number.isFinite(n)) return `¥${v}`
return `¥${n.toFixed(2)}`
}
const seoTitle = computed(() => `${pageTitle.value} - 商品`)
const seoDescription = computed(() => {
if (!isValidCategoryId.value) return '商品分类列表'
const suffix = keywords.value ? `,关键词:${keywords.value}` : ''
return `分类 ${pageTitle.value} 商品列表${suffix}`
})
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'website'
})
const canonicalUrl = computed(() => {
if (import.meta.client) return window.location.href
try {
return useRequestURL().href
} catch {
return ''
}
})
useHead(() => ({
link: canonicalUrl.value ? [{ rel: 'canonical', href: canonicalUrl.value }] : []
}))
</script>
<style scoped>
.goods-page {
background: #f4f6f8;
}
.goods-hero {
background:
radial-gradient(circle at 15% 20%, rgba(22, 163, 74, 0.22), transparent 60%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 55%),
linear-gradient(180deg, #ffffff, #f8fafc);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.goods-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.goods-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.goods-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.goods-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.goods-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.goods-cover {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
background: #f3f4f6;
}
.goods-desc {
display: flex;
flex-direction: column;
gap: 6px;
}
.goods-price-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.goods-price {
color: #16a34a;
font-weight: 800;
}
.goods-unit {
color: rgba(0, 0, 0, 0.6);
font-weight: 600;
margin-left: 4px;
}
.goods-sub {
display: flex;
gap: 12px;
flex-wrap: wrap;
color: rgba(0, 0, 0, 0.55);
font-size: 12px;
}
</style>