feat(pages): 添加文章和商品详情页面并重构页面结构
- 新增 app/pages/article/[id].vue 页面实现文章列表功能 - 新增 app/pages/goods-item/[id].vue 页面实现商品详情功能 - 重构 app/pages/page/[id].vue 页面样式和SEO配置 - 重命名 app/pages/product/[id].vue 为 app/pages/goods/[navigationId].vue - 新增 app/pages/product/[navigationId].vue 保留产品分类页面路由 - 新增 app/components/shop/GoodsCategoryPage.vue 商品分类组件 - 更新 API 类型定义修复 shopGoods 接口响应类型 - 实现商品分类页面的商品网格布局和分页功能 - 添加面包屑导航、搜索功能和图片懒加载支持 - 优化页面SEO元数据和链接规范化配置 - 统一页面错误处理和加载状态显示样式
This commit is contained in:
@@ -106,7 +106,7 @@ export async function getShopGoods(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCount(params: ShopGoodsParam) {
|
export async function getCount(params: ShopGoodsParam) {
|
||||||
const res = await request.get(MODULES_API_URL + '/shop/shop-goods/data', {
|
const res = await request.get<ApiResult<unknown>>(MODULES_API_URL + '/shop/shop-goods/data', {
|
||||||
params
|
params
|
||||||
});
|
});
|
||||||
if (res.data.code === 0) {
|
if (res.data.code === 0) {
|
||||||
|
|||||||
441
app/components/shop/GoodsCategoryPage.vue
Normal file
441
app/components/shop/GoodsCategoryPage.vue
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<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 { getShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||||
|
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/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: category } = await useAsyncData<ShopGoodsCategory | null>(
|
||||||
|
() => `shop-goods-category-${String(route.params.navigationId)}`,
|
||||||
|
async () => {
|
||||||
|
if (!isValidCategoryId.value) return null
|
||||||
|
return await getShopGoodsCategory(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 name = category.value?.title?.trim()
|
||||||
|
if (name) return name
|
||||||
|
if (isValidCategoryId.value) return `分类 ${categoryId.value}`
|
||||||
|
return '商品列表'
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroStyle = computed(() => {
|
||||||
|
const banner = pickString(category.value, '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 any
|
||||||
|
const url = typeof first?.url === 'string' ? first.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>
|
||||||
382
app/pages/article/[id].vue
Normal file
382
app/pages/article/[id].vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<main class="article-page">
|
||||||
|
<section class="article-hero" :style="heroStyle">
|
||||||
|
<div class="article-hero-mask">
|
||||||
|
<div class="mx-auto max-w-screen-xl px-4 py-8">
|
||||||
|
<a-breadcrumb class="article-breadcrumb">
|
||||||
|
<a-breadcrumb-item>
|
||||||
|
<NuxtLink to="/">首页</NuxtLink>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item v-if="parentName">{{ parentName }}</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
|
||||||
|
<div class="article-hero-title">{{ pageTitle }}</div>
|
||||||
|
|
||||||
|
<div class="article-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="article-card" :bordered="false">
|
||||||
|
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
|
||||||
|
|
||||||
|
<a-result
|
||||||
|
v-else-if="!isValidNavigationId"
|
||||||
|
status="404"
|
||||||
|
title="栏目不存在"
|
||||||
|
sub-title="navigationId 无效或缺失。"
|
||||||
|
>
|
||||||
|
<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="!articles.length" description="暂无文章" />
|
||||||
|
|
||||||
|
<a-list v-else item-layout="vertical" size="large" :data-source="articles">
|
||||||
|
<template #renderItem="{ item, index }">
|
||||||
|
<a-list-item
|
||||||
|
:key="String(item.articleId ?? item.code ?? `${String(route.params.id)}-${index}`)"
|
||||||
|
class="article-item"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<img
|
||||||
|
v-if="resolveArticleImage(item)"
|
||||||
|
class="article-cover"
|
||||||
|
:src="resolveArticleImage(item)"
|
||||||
|
:alt="resolveArticleTitle(item)"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onImgError"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-list-item-meta :description="resolveArticleOverview(item)">
|
||||||
|
<template #title>
|
||||||
|
<span class="article-title">{{ resolveArticleTitle(item) }}</span>
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
|
||||||
|
<div class="article-meta">
|
||||||
|
<a-tag v-if="item.categoryName" color="default">{{ item.categoryName }}</a-tag>
|
||||||
|
<a-tag v-if="item.author" color="blue">{{ item.author }}</a-tag>
|
||||||
|
<a-tag v-if="item.createTime" color="green">{{ item.createTime }}</a-tag>
|
||||||
|
<a-tag v-if="typeof item.actualViews === 'number'" color="default">
|
||||||
|
阅读 {{ item.actualViews }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="typeof item.virtualViews === 'number'" color="default">
|
||||||
|
热度 {{ item.virtualViews }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
|
||||||
|
<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="['10', '20', '50', '100']"
|
||||||
|
@change="onPageChange"
|
||||||
|
@show-size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LocationQueryRaw } from 'vue-router'
|
||||||
|
import { pageCmsArticle } from '@/api/cms/cmsArticle'
|
||||||
|
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
|
||||||
|
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
|
||||||
|
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||||
|
|
||||||
|
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 navigationId = computed(() => parseNumberParam(route.params.id))
|
||||||
|
const isValidNavigationId = computed(() => Number.isFinite(navigationId.value) && navigationId.value > 0)
|
||||||
|
|
||||||
|
const page = computed(() => parsePositiveInt(route.query.page, 1))
|
||||||
|
const limit = computed(() => parsePositiveInt(route.query.limit, 10))
|
||||||
|
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.id)}`,
|
||||||
|
async () => {
|
||||||
|
if (!isValidNavigationId.value) return null
|
||||||
|
return await getCmsNavigation(navigationId.value).catch(() => null)
|
||||||
|
},
|
||||||
|
{ watch: [navigationId] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: articlePage,
|
||||||
|
pending,
|
||||||
|
error: loadError,
|
||||||
|
refresh
|
||||||
|
} = await useAsyncData<{ list: CmsArticle[]; count: number } | null>(
|
||||||
|
() => `cms-article-${String(route.params.id)}-${page.value}-${limit.value}-${keywords.value}`,
|
||||||
|
async () => {
|
||||||
|
if (!isValidNavigationId.value) return null
|
||||||
|
return await pageCmsArticle({
|
||||||
|
navigationId: navigationId.value,
|
||||||
|
page: page.value,
|
||||||
|
limit: limit.value,
|
||||||
|
keywords: keywords.value || undefined
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ watch: [navigationId, page, limit, keywords] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const articles = computed(() => articlePage.value?.list ?? [])
|
||||||
|
const total = computed(() => articlePage.value?.count ?? 0)
|
||||||
|
|
||||||
|
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 name = pickString(navigation.value, 'title') || pickString(navigation.value, 'label')
|
||||||
|
if (name) return name
|
||||||
|
if (isValidNavigationId.value) return `栏目 ${navigationId.value}`
|
||||||
|
return '文章列表'
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentName = computed(() => pickString(navigation.value, 'parentName'))
|
||||||
|
|
||||||
|
const heroStyle = computed(() => {
|
||||||
|
const banner = pickString(navigation.value, 'banner')
|
||||||
|
if (banner) return { backgroundImage: `url(${banner})` }
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveArticleTitle(a: CmsArticle) {
|
||||||
|
return String(a.title || a.code || '未命名文章').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveArticleImage(a: CmsArticle) {
|
||||||
|
const img = String(a.image || '').trim()
|
||||||
|
return img || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveArticleOverview(a: CmsArticle) {
|
||||||
|
const text = String(a.overview || '').trim()
|
||||||
|
if (text) return text
|
||||||
|
const fallback = String(a.detail || '').trim()
|
||||||
|
return fallback.length > 120 ? `${fallback.slice(0, 120)}...` : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImgError(e: Event) {
|
||||||
|
const el = e.target as HTMLImageElement | null
|
||||||
|
if (!el) return
|
||||||
|
el.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoTitle = computed(() => `${pageTitle.value} - 文章列表`)
|
||||||
|
const seoDescription = computed(() => {
|
||||||
|
if (keywords.value) return `${pageTitle.value} - 关键词「${keywords.value}」的文章列表`
|
||||||
|
return `${pageTitle.value} - 文章列表`
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
.article-page {
|
||||||
|
background: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-hero-mask {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-breadcrumb {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-hero-title {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-hero-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item :deep(.ant-list-item-extra) {
|
||||||
|
margin-inline-start: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-cover {
|
||||||
|
width: 220px;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.article-item :deep(.ant-list-item-extra) {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
399
app/pages/goods-item/[id].vue
Normal file
399
app/pages/goods-item/[id].vue
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<template>
|
||||||
|
<main class="detail">
|
||||||
|
<section class="mx-auto max-w-screen-xl px-4 py-8">
|
||||||
|
<a-breadcrumb class="detail-breadcrumb">
|
||||||
|
<a-breadcrumb-item>
|
||||||
|
<NuxtLink to="/">首页</NuxtLink>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>
|
||||||
|
<span>商品</span>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-screen-xl px-4 pb-12">
|
||||||
|
<a-card class="detail-card" :bordered="false">
|
||||||
|
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
|
||||||
|
|
||||||
|
<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="goBack">返回</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
|
||||||
|
<a-result
|
||||||
|
v-else-if="!goods"
|
||||||
|
status="404"
|
||||||
|
title="商品不存在"
|
||||||
|
sub-title="未找到对应的商品信息。"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
|
||||||
|
<a-button @click="goBack">返回</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<a-row :gutter="[24, 24]">
|
||||||
|
<a-col :xs="24" :lg="10">
|
||||||
|
<div class="detail-cover-wrap">
|
||||||
|
<img
|
||||||
|
class="detail-cover"
|
||||||
|
:src="coverUrl"
|
||||||
|
:alt="title"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onImgError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="galleryUrls.length" class="mt-4 grid grid-cols-4 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="u in galleryUrls"
|
||||||
|
:key="u"
|
||||||
|
type="button"
|
||||||
|
class="thumb"
|
||||||
|
@click="coverUrl = u"
|
||||||
|
>
|
||||||
|
<img class="thumb-img" :src="u" :alt="title" loading="lazy" @error="onImgError" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="14">
|
||||||
|
<div class="detail-title-row">
|
||||||
|
<a-typography-title :level="2" class="!mb-2">{{ title }}</a-typography-title>
|
||||||
|
<div class="detail-tags">
|
||||||
|
<a-tag v-if="unitName" color="blue">{{ unitName }}</a-tag>
|
||||||
|
<a-tag v-if="typeof sales === 'number'" color="default">销量 {{ sales }}</a-tag>
|
||||||
|
<a-tag v-if="typeof stock === 'number'" color="default">库存 {{ stock }}</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-price">
|
||||||
|
<span class="detail-price-main">{{ formatMoney(price) }}</span>
|
||||||
|
<span v-if="unitName" class="detail-price-unit">/ {{ unitName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a-descriptions bordered size="small" :column="2">
|
||||||
|
<a-descriptions-item label="商品ID">{{ goodsId }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="分类ID">{{ categoryIdText }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="创建时间">{{ createTime }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="更新时间">{{ updateTime }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a-space>
|
||||||
|
<a-button @click="goBack">返回</a-button>
|
||||||
|
<a-button type="primary" @click="navigateTo('/')">首页</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-divider class="!my-6" />
|
||||||
|
|
||||||
|
<a-typography-title :level="4" class="!mb-3">商品详情</a-typography-title>
|
||||||
|
<a-alert v-if="!content" type="info" show-icon message="暂无详情内容" class="mb-4" />
|
||||||
|
<RichText v-else :content="content" />
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||||
|
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const id = computed(() => {
|
||||||
|
const raw = route.params.id
|
||||||
|
const text = Array.isArray(raw) ? raw[0] : raw
|
||||||
|
const n = Number(text)
|
||||||
|
return Number.isFinite(n) ? n : NaN
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: goods,
|
||||||
|
pending,
|
||||||
|
error: loadError,
|
||||||
|
refresh
|
||||||
|
} = await useAsyncData<ShopGoods | null>(
|
||||||
|
() => `shop-goods-${String(route.params.id)}`,
|
||||||
|
async () => {
|
||||||
|
if (!Number.isFinite(id.value)) return null
|
||||||
|
return await getShopGoods(id.value)
|
||||||
|
},
|
||||||
|
{ watch: [id] }
|
||||||
|
)
|
||||||
|
|
||||||
|
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() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNumber(obj: unknown, key: string): number | undefined {
|
||||||
|
if (!obj || typeof obj !== 'object') return undefined
|
||||||
|
const record = obj as Record<string, unknown>
|
||||||
|
const value = record[key]
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!g) return '商品详情'
|
||||||
|
return (
|
||||||
|
pickString(g, 'goodsName') ||
|
||||||
|
pickString(g, 'name') ||
|
||||||
|
pickString(g, 'code') ||
|
||||||
|
`商品 ${String(route.params.id)}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unitName = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
return g ? pickString(g, 'unitName') : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const price = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!g) return undefined
|
||||||
|
return (
|
||||||
|
pickNumber(g, 'salePrice') ??
|
||||||
|
pickNumber(g, 'price') ??
|
||||||
|
pickNumber(g, 'chainStorePrice') ??
|
||||||
|
pickNumber(g, 'originPrice') ??
|
||||||
|
pickNumber(g, 'buyingPrice')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sales = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
return g ? pickNumber(g, 'sales') : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const stock = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
return g ? pickNumber(g, 'stock') : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const goodsId = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
return g ? pickNumber(g, 'goodsId') : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoryIdText = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
const n = g ? pickNumber(g, 'categoryId') : undefined
|
||||||
|
return typeof n === 'number' ? String(n) : '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTime = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
return g ? pickString(g, 'createTime') || '-' : '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateTime = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
return g ? pickString(g, 'updateTime') || '-' : '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = computed(() => {
|
||||||
|
const g = goods.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!g) return ''
|
||||||
|
return pickString(g, 'content')
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseGalleryUrls(g: ShopGoods | null | undefined) {
|
||||||
|
if (!g) return []
|
||||||
|
const anyG = g as unknown as Record<string, unknown>
|
||||||
|
const files = typeof anyG.files === 'string' ? anyG.files.trim() : ''
|
||||||
|
const image = typeof anyG.image === 'string' ? anyG.image.trim() : ''
|
||||||
|
const urls: string[] = []
|
||||||
|
if (image) urls.push(image)
|
||||||
|
|
||||||
|
if (files) {
|
||||||
|
if (files.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(files) as unknown
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
for (const it of parsed) {
|
||||||
|
const url = typeof (it as any)?.url === 'string' ? String((it as any).url).trim() : ''
|
||||||
|
if (url) urls.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const part of files.split(',')) {
|
||||||
|
const u = part.trim()
|
||||||
|
if (u) urls.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(urls)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryUrls = computed(() => parseGalleryUrls(goods.value))
|
||||||
|
const coverUrl = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => galleryUrls.value,
|
||||||
|
(list) => {
|
||||||
|
coverUrl.value =
|
||||||
|
list[0] ||
|
||||||
|
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (import.meta.client && window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoTitle = computed(() => title.value)
|
||||||
|
const seoDescription = computed(() => `${title.value},价格 ${formatMoney(price.value)}${unitName.value ? '/' + unitName.value : ''}`)
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: seoTitle,
|
||||||
|
description: seoDescription,
|
||||||
|
ogTitle: seoTitle,
|
||||||
|
ogDescription: seoDescription,
|
||||||
|
ogType: 'product'
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
.detail {
|
||||||
|
background: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-breadcrumb {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cover-wrap {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 360px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price-main {
|
||||||
|
color: #16a34a;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price-unit {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
10
app/pages/goods/[navigationId].vue
Normal file
10
app/pages/goods/[navigationId].vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GoodsCategoryPage from '@/components/shop/GoodsCategoryPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GoodsCategoryPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-screen-md px-4 py-12">
|
<main class="page">
|
||||||
<a-spin v-if="pending" />
|
<section class="page-hero" :style="heroStyle">
|
||||||
|
<div class="page-hero-mask">
|
||||||
|
<div class="mx-auto max-w-screen-xl px-4 py-8">
|
||||||
|
<a-breadcrumb class="page-breadcrumb">
|
||||||
|
<a-breadcrumb-item>
|
||||||
|
<NuxtLink to="/">首页</NuxtLink>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item v-if="parentName">{{ parentName }}</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
|
||||||
|
<div class="page-hero-title">{{ pageTitle }}</div>
|
||||||
|
|
||||||
|
<div class="page-hero-meta">
|
||||||
|
<a-tag v-if="modelName" color="green">{{ modelName }}</a-tag>
|
||||||
|
<a-tag v-if="createTime" color="blue">{{ createTime }}</a-tag>
|
||||||
|
<a-tag v-if="typeof readNum === 'number'" color="default">阅读 {{ readNum }}</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-screen-xl px-4 py-10">
|
||||||
|
<a-card class="page-card" :bordered="false">
|
||||||
|
<div class="page-card-head">
|
||||||
|
<a-space>
|
||||||
|
<a-button @click="goBack">返回</a-button>
|
||||||
|
<a-button type="primary" @click="navigateTo('/')">首页</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider class="!my-4" />
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
|
||||||
|
|
||||||
<a-result
|
<a-result
|
||||||
v-else-if="loadError"
|
v-else-if="loadError"
|
||||||
@@ -30,19 +64,21 @@
|
|||||||
</a-result>
|
</a-result>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-typography-title :level="1" class="!mb-6">{{ pageTitle }}</a-typography-title>
|
|
||||||
<a-alert v-if="!pageContent" class="mb-6" type="info" show-icon message="暂无内容" />
|
<a-alert v-if="!pageContent" class="mb-6" type="info" show-icon message="暂无内容" />
|
||||||
<RichText v-else :content="pageContent" />
|
<RichText v-else :content="pageContent" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
|
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
|
||||||
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||||
import { usePageSeo } from '@/composables/usePageSeo'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const id = computed(() => {
|
const id = computed(() => {
|
||||||
const raw = route.params.id
|
const raw = route.params.id
|
||||||
@@ -88,6 +124,44 @@ const pageTitle = computed(() => {
|
|||||||
return pickString(nav, 'title') || pickString(nav, 'label') || `页面 ${String(route.params.id)}`
|
return pickString(nav, 'title') || pickString(nav, 'label') || `页面 ${String(route.params.id)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const heroStyle = computed(() => {
|
||||||
|
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||||
|
const banner = nav ? pickString(nav, 'banner') : ''
|
||||||
|
if (banner) {
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${banner})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentName = computed(() => {
|
||||||
|
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!nav) return ''
|
||||||
|
return pickString(nav, 'parentName')
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelName = computed(() => {
|
||||||
|
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!nav) return ''
|
||||||
|
return pickString(nav, 'modelName')
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTime = computed(() => {
|
||||||
|
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!nav) return ''
|
||||||
|
return pickString(nav, 'createTime')
|
||||||
|
})
|
||||||
|
|
||||||
|
const readNum = computed(() => {
|
||||||
|
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||||
|
if (!nav) return undefined
|
||||||
|
const n = nav.readNum
|
||||||
|
if (typeof n === 'number' && Number.isFinite(n)) return n
|
||||||
|
if (typeof n === 'string' && n.trim() && Number.isFinite(Number(n))) return Number(n)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const pageContent = computed(() => {
|
const pageContent = computed(() => {
|
||||||
const nav = navigation.value as unknown as Record<string, unknown> | null
|
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||||
if (!nav) return ''
|
if (!nav) return ''
|
||||||
@@ -115,10 +189,94 @@ const pageContent = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
usePageSeo({
|
const seoTitle = computed(() => pageTitle.value)
|
||||||
title: pageTitle.value,
|
const seoDescription = computed(() =>
|
||||||
description: pageContent.value ? pageContent.value.slice(0, 120) : `${pageTitle.value} - 页面内容`,
|
pageContent.value ? pageContent.value.slice(0, 120) : `${pageTitle.value} - 页面内容`
|
||||||
path: route.path
|
)
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: seoTitle,
|
||||||
|
description: seoDescription,
|
||||||
|
ogTitle: seoTitle,
|
||||||
|
ogDescription: seoDescription,
|
||||||
|
ogType: 'article'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 }] : []
|
||||||
|
}))
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (import.meta.client && window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
background: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero-mask {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero-title {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
8
app/pages/product/[navigationId].vue
Normal file
8
app/pages/product/[navigationId].vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GoodsCategoryPage from '@/components/shop/GoodsCategoryPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GoodsCategoryPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
Reference in New Issue
Block a user