Files
shop-pc/app/components/shop/GoodsCategoryPage.vue
赵忠林 91e9a8c20f feat(pages): 添加文章和商品详情页及API代理配置
- 添加了.dockerignore、.env.example和.gitignore配置文件
- 实现了文件服务器、模块API和服务器API的代理功能
- 创建了动态路由页面用于展示文章列表和详情
- 实现了商品详情页面包括图片展示和价格信息
- 添加了静态页面展示功能支持富文本内容渲染
- 配置了SEO元数据和面包屑导航组件
2026-02-12 13:52:55 +08:00

445 lines
13 KiB
Vue
Raw Permalink 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>