feat(pages): 添加文章和商品详情页及API代理配置

- 添加了.dockerignore、.env.example和.gitignore配置文件
- 实现了文件服务器、模块API和服务器API的代理功能
- 创建了动态路由页面用于展示文章列表和详情
- 实现了商品详情页面包括图片展示和价格信息
- 添加了静态页面展示功能支持富文本内容渲染
- 配置了SEO元数据和面包屑导航组件
This commit is contained in:
2026-02-12 13:52:55 +08:00
commit 91e9a8c20f
322 changed files with 48203 additions and 0 deletions

510
app/pages/index.vue Normal file
View File

@@ -0,0 +1,510 @@
<template>
<main class="home">
<Carousel arrows class="mx-auto" :items="flashSlides" :height="flashHeight">
<template #prevArrow>
<div class="custom-slick-arrow" style="left: 10px; z-index: 1">
<left-circle-outlined />
</div>
</template>
<template #nextArrow>
<div class="custom-slick-arrow" style="right: 10px">
<right-circle-outlined />
</div>
</template>
</Carousel>
<!-- <section class="banner">-->
<!-- <div class="mx-auto max-w-screen-xl px-4 py-6">-->
<!-- <div class="banner-title">以合规经营与品质服务为核心</div>-->
<!-- </div>-->
<!-- </section>-->
<section class="mx-auto max-w-screen-xl px-4 py-6">
<div class="section-title">
<div class="section-title-main">资讯与公告</div>
<div class="section-title-sub">NEWS & UPDATES</div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div v-for="c in columns" :key="c.key" class="col-span-12 lg:col-span-4">
<div class="panel">
<div class="column-head">
<div class="column-title">{{ c.title }}</div>
<NuxtLink v-if="c.moreTo" :to="c.moreTo" class="column-more">更多 +</NuxtLink>
<span v-else class="column-more opacity-60">更多 +</span>
</div>
<div class="column-list">
<template v-if="newsPending">
<div class="column-empty">加载中...</div>
</template>
<template v-else-if="!c.items.length">
<div class="column-empty">暂无文章</div>
</template>
<template v-else>
<NuxtLink
v-for="(it, idx) in c.items"
:key="String(it.articleId ?? it.code ?? `${c.key}-${idx}`)"
class="column-item"
:to="resolveArticleLink(it, c.navId)"
>
{{ resolveArticleTitle(it) }}
</NuxtLink>
</template>
</div>
</div>
</div>
</div>
<div class="mt-10">
<div class="section-title">
<div class="section-title-main">资质与合作</div>
<div class="section-title-sub">COMPLIANCE</div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<a-card
v-for="c in compliance"
:key="c.title"
hoverable
class="case-card col-span-12 sm:col-span-6 lg:col-span-3"
@click="navigateTo(c.to)"
>
<a-typography-title :level="5" class="!mb-2 case-title">{{ c.title }}</a-typography-title>
<div class="case-desc">{{ c.desc }}</div>
</a-card>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import {
AppstoreOutlined,
FileTextOutlined,
LeftCircleOutlined,
RightCircleOutlined,
SafetyCertificateOutlined,
ShopOutlined
} from '@ant-design/icons-vue'
import { usePageSeo } from '@/composables/usePageSeo'
import { getAdByCode } from '@/api/cms/cmsAd'
import type { CmsAd } from '@/api/cms/cmsAd/model'
import { COMPANY } from '@/config/company'
import { listCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import { pageCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
usePageSeo({
title: '首页',
description:
'桂乐淘:生物基材料技术研发、技术服务与食品/农产品流通服务。提供经营范围与资质信息查询及合作咨询入口。',
path: '/'
})
function parsePx(value?: string) {
if (!value) return undefined
const m = String(value).match(/(\d+)/)
if (!m) return undefined
const n = Number(m[1])
return Number.isFinite(n) ? n : undefined
}
type FlashImage = {
url?: string
path?: string
title?: string
}
const { data: flashAd } = await useAsyncData<CmsAd | null>('cms-ad-flash', () =>
getAdByCode('flash').catch(() => null)
)
const flashSlides = computed(() => {
const list = (flashAd.value?.imageList || []) as FlashImage[]
const slides = list
.map((it) => {
if (!it?.url) return null
return {
src: it.url,
href: it.path || undefined,
alt: it.title || undefined
}
})
.filter(Boolean) as Array<{ src: string; href?: string; alt?: string }>
return slides.length
? slides
: [{ src: 'https://file-cloud.yst.com.cn/photo/website/2024/11/28/5a5cc07336224e54a84561c80899bcac.jpg' }]
})
const flashHeight = computed(() => parsePx(flashAd.value?.height) ?? 360)
function splitScope(text: string) {
return text
.split(/[;]+/g)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.replace(/[。.]$/, '').trim())
}
const generalScopeItems = computed(() => splitScope(COMPANY.scope.general))
const licensedScopeItems = computed(() => splitScope(COMPANY.scope.licensed))
const services = [
{
title: '生物基材料技术研发',
desc: '面向应用场景开展研发与技术支持',
icon: SafetyCertificateOutlined
},
{
title: '技术服务与技术推广',
desc: '技术开发/咨询/交流/转让/推广',
icon: AppstoreOutlined
},
{
title: '食品销售(预包装)',
desc: '含保健食品(预包装)销售等',
icon: ShopOutlined
},
{
title: '农产品与生鲜流通',
desc: '鲜肉/水产/蔬果/蛋类等零售与批发',
icon: FileTextOutlined
}
]
const NEWS_PARENT_ID = 4548
type HomeNewsColumn = {
key: string
navId?: number
title: string
moreTo: string | null
items: CmsArticle[]
}
function resolveNavTitle(nav: CmsNavigation) {
return String(nav.title || nav.label || nav.code || '').trim()
}
function resolveArticleTitle(a: CmsArticle) {
return String(a.title || a.code || '未命名文章').trim()
}
function resolveArticleLink(a: CmsArticle, navId?: number) {
const articleId = typeof a.articleId === 'number' && Number.isFinite(a.articleId) ? a.articleId : NaN
const code = String(a.code || '').trim()
return {
path: '/article-item',
query: {
id: Number.isFinite(articleId) ? String(articleId) : undefined,
code: !Number.isFinite(articleId) && code ? code : undefined,
navId: typeof navId === 'number' && Number.isFinite(navId) ? String(navId) : undefined
}
}
}
const { data: newsRaw, pending: newsPending } = await useAsyncData<HomeNewsColumn[]>(
`home-news-${NEWS_PARENT_ID}`,
async () => {
const navs = await listCmsNavigation({ parentId: NEWS_PARENT_ID }).catch(() => [])
const top3 = [...navs]
.filter((it) => typeof it?.navigationId === 'number')
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
.slice(0, 3)
const bundles = await Promise.all(
top3.map(async (nav) => {
const navId = nav.navigationId
const title = resolveNavTitle(nav) || (typeof navId === 'number' ? `栏目 ${navId}` : '栏目')
const page = typeof navId === 'number'
? await pageCmsArticle({ categoryId: navId, page: 1, limit: 10 }).catch(() => null)
: null
return {
key: String(navId ?? title),
navId,
title,
moreTo: typeof navId === 'number' ? `/article/${navId}` : null,
items: page?.list ?? []
} satisfies HomeNewsColumn
})
)
return bundles
}
)
const columns = computed<HomeNewsColumn[]>(() => {
if (newsRaw.value?.length) return newsRaw.value
if (!newsPending.value) return []
// Keep layout stable while SSR/client is loading.
return [
{ key: 'news-loading-1', navId: undefined, title: '加载中', moreTo: null, items: [] },
{ key: 'news-loading-2', navId: undefined, title: '加载中', moreTo: null, items: [] },
{ key: 'news-loading-3', navId: undefined, title: '加载中', moreTo: null, items: [] }
]
})
const compliance = [
{
title: '产品展示',
desc: '一般项目/许可项目明细与说明',
to: '/goods/4476'
},
{
title: '注册地址',
desc: '南宁市江南区国凯大道东13号神冠胶原智库项目加工厂房',
to: '/contact'
},
{
title: '合作咨询',
desc: '提交需求与合作意向,我们尽快联系',
to: '/contact'
},
{
title: '更多内容',
desc: '后续可接入资讯、产品与资质展示',
to: '/contact'
}
]
function scrollToCompany() {
if (!import.meta.client) return
const el = document.querySelector('#company')
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<style scoped>
.home {
background: #f4f6f8;
}
.panel {
background: #fff;
height: 360px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: linear-gradient(90deg, #16a34a, #22c55e);
color: #fff;
}
.panel-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 800;
}
.panel-more {
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
text-decoration: none;
}
.scope-item {
padding: 6px 0;
color: rgba(0, 0, 0, 0.75);
line-height: 1.6;
}
.section-pill {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 9999px;
background: linear-gradient(90deg, #16a34a, #22c55e);
color: #fff;
font-weight: 800;
}
.pill-left {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pill-right {
font-weight: 700;
font-size: 12px;
opacity: 0.9;
}
.guide-card {
border-radius: 10px;
}
.guide-icon {
background: rgba(22, 163, 74, 0.12);
color: #16a34a;
}
.service-icon {
background: rgba(22, 163, 74, 0.12);
color: #16a34a;
}
.guide-title {
font-size: 14px;
font-weight: 800;
color: rgba(0, 0, 0, 0.88);
}
.guide-desc {
margin-top: 3px;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
}
.login-hero {
padding: 18px 16px;
background:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.22), transparent 55%),
linear-gradient(90deg, #16a34a, #22c55e);
color: #fff;
}
.login-hero-title {
font-size: 18px;
font-weight: 900;
}
.login-hero-sub {
margin-top: 6px;
font-size: 12px;
letter-spacing: 0.12em;
opacity: 0.9;
}
.banner {
background:
radial-gradient(circle at 20% 30%, rgba(22, 163, 74, 0.18), transparent 55%),
linear-gradient(180deg, #ffffff, #f8fafc);
border-top: 1px solid rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.banner-title {
text-align: center;
font-size: 28px;
font-weight: 900;
color: #15803d;
}
.section-title {
text-align: center;
}
.section-title-main {
font-size: 18px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
}
.section-title-sub {
margin-top: 4px;
font-size: 12px;
letter-spacing: 0.12em;
color: rgba(0, 0, 0, 0.45);
}
.column-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.column-title {
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
}
.column-more {
font-size: 12px;
color: rgba(21, 128, 61, 0.95);
text-decoration: none;
}
.column-list {
padding: 10px 14px 14px;
}
.column-empty {
padding: 12px 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.column-item {
display: block;
padding: 8px 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.78);
text-decoration: none;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
.column-item:last-child {
border-bottom: 0;
}
.case-card {
border-radius: 10px;
overflow: hidden;
}
.case-title {
color: rgba(21, 128, 61, 0.95);
}
.case-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
line-height: 1.6;
}
:deep(.slick-slide) {
text-align: center;
height: 160px;
line-height: 160px;
background: #364d79;
overflow: hidden;
}
:deep(.slick-arrow.custom-slick-arrow) {
width: 25px;
height: 25px;
font-size: 25px;
color: #fff;
background-color: rgba(31, 45, 61, 0.11);
transition: ease all 0.3s;
opacity: 0.3;
z-index: 1;
}
:deep(.slick-arrow.custom-slick-arrow:before) {
display: none;
}
:deep(.slick-arrow.custom-slick-arrow:hover) {
color: #fff;
opacity: 0.5;
}
:deep(.slick-slide h3) {
color: #fff;
}
</style>