Files
gxwebsoft 2b69686795 feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
2026-09-08 12:13:44 +08:00

435 lines
18 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>
<div class="bg-white">
<HeroSection />
<!-- 数据统计栏三组数字 -->
<section v-if="statsItems.length" class="bg-white pt-8">
<div class="t9-container">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 sm:gap-4">
<div
v-for="(s, i) in statsItems"
:key="'stat-' + i"
class="flex items-center justify-center gap-3"
>
<span
class="w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0"
style="background-color: var(--t9-primary-soft)"
>
<!-- 作家 -->
<svg v-if="i === 0" class="w-7 h-7" style="color: var(--t9-primary)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<!-- 书籍 -->
<svg v-else-if="i === 1" class="w-7 h-7" style="color: var(--t9-primary)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<!-- 读者 -->
<svg v-else class="w-7 h-7" style="color: var(--t9-primary)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</span>
<div class="leading-tight">
<div class="text-2xl font-bold" style="color: var(--t9-primary)">{{ s.value }}</div>
<div class="text-sm mt-1" style="color: var(--t9-muted)">{{ s.label }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- 出版社简介 -->
<section v-if="introText" class="bg-white pt-8 pb-2">
<div class="t9-container">
<p class="text-[15px] leading-8 text-justify indent-8" style="color: var(--t9-text)">
{{ introText }}
</p>
</div>
</section>
<!-- 新闻中心左大图 + 右列表 -->
<section v-if="newsList.length" class="bg-white">
<div class="t9-container">
<h2 class="t9-section-title">{{ newsNav?.title || '新闻中心' }}</h2>
<div class="grid lg:grid-cols-2 gap-8 lg:gap-10 pb-8">
<!-- 头条大图 -->
<div v-if="featuredNews">
<NuxtLink :to="detailLink(newsNav?.model, featuredNews)" class="block group">
<div class="overflow-hidden bg-[var(--t9-bg-gray)] h-[240px] sm:h-[280px]">
<img
:src="itemImage(featuredNews)"
:alt="featuredNews.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
@error="onImgError"
>
</div>
<div class="pt-4">
<div class="flex items-start justify-between gap-4">
<h3
class="text-base t9-ellipsis flex-1 transition-colors group-hover:text-[var(--t9-primary)]"
style="color: var(--t9-muted)"
>{{ featuredNews.title }}</h3>
<span class="text-sm flex-shrink-0" style="color: var(--t9-muted)">
{{ formatDate(featuredNews.publishTime || featuredNews.createTime) }}
</span>
</div>
<p class="mt-3 text-sm leading-7 t9-clamp-3" style="color: var(--t9-muted)">
{{ summaryOf(featuredNews) }}
</p>
</div>
</NuxtLink>
</div>
<!-- 列表 -->
<ul class="flex flex-col">
<li
v-for="item in restNews"
:key="item.id || item.articleId"
class="border-b"
style="border-color: var(--t9-border-2)"
>
<NuxtLink
:to="detailLink(newsNav?.model, item)"
class="flex items-center justify-between gap-4 py-[14px] group"
>
<span class="flex items-center gap-2 min-w-0 flex-1">
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0" style="background-color: var(--t9-primary)"></span>
<span
class="t9-ellipsis text-[15px] transition-colors group-hover:text-[var(--t9-primary)]"
style="color: var(--t9-text)"
>{{ item.title }}</span>
</span>
<span class="text-sm flex-shrink-0" style="color: var(--t9-muted)">
{{ formatDate(item.publishTime || item.createTime) }}
</span>
</NuxtLink>
</li>
</ul>
</div>
</div>
</section>
<!-- 图书速递横向滚动封面 -->
<section v-if="carouselBooks.length" class="bg-white">
<div class="t9-container">
<h2 class="t9-section-title">{{ bookNav?.title || '图书速递' }}</h2>
<div class="relative pb-8">
<div
ref="scrollerRef"
class="t9-scroll-x flex gap-5 overflow-x-auto pb-3 scroll-smooth"
>
<NuxtLink
v-for="item in carouselBooks"
:key="'book-' + (item.id || item.articleId)"
:to="detailLink(bookNav?.model, item)"
class="flex-shrink-0 w-[150px] group"
>
<div class="w-[150px] h-[210px] overflow-hidden bg-[var(--t9-bg-gray)]">
<img
:src="itemImage(item)"
:alt="item.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
@error="onImgError"
>
</div>
<p
class="mt-2 text-sm text-center t9-ellipsis transition-colors group-hover:text-[var(--t9-primary)]"
style="color: var(--t9-muted-2)"
>{{ item.title }}</p>
</NuxtLink>
</div>
<!-- 左右滚动按钮 -->
<button
v-if="carouselBooks.length > 6"
type="button" aria-label="向左滚动"
class="hidden lg:flex absolute -left-4 top-[85px] w-9 h-9 rounded-full items-center justify-center text-white shadow-md transition-opacity hover:opacity-90"
style="background-color: var(--t9-primary)"
@click="scrollBy(-600)"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<button
v-if="carouselBooks.length > 6"
type="button" aria-label="向右滚动"
class="hidden lg:flex absolute -right-4 top-[85px] w-9 h-9 rounded-full items-center justify-center text-white shadow-md transition-opacity hover:opacity-90"
style="background-color: var(--t9-primary)"
@click="scrollBy(600)"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</div>
</section>
<!-- 名家经典圆形头像网格 -->
<section v-if="masterList.length" class="bg-white">
<div class="t9-container">
<h2 class="t9-section-title">{{ masterNav?.title || '名家经典' }}</h2>
<div class="flex flex-wrap justify-center gap-x-6 gap-y-7 pb-8">
<NuxtLink
v-for="item in masterList"
:key="'master-' + (item.id || item.productId)"
:to="detailLink(masterNav?.model, item)"
class="w-[104px] sm:w-[120px] group"
>
<div
class="w-[104px] h-[104px] sm:w-[120px] sm:h-[120px] rounded-full overflow-hidden bg-[var(--t9-bg-gray)] border-2 border-transparent transition-colors group-hover:border-[var(--t9-primary)]"
>
<img
:src="itemImage(item)"
:alt="masterName(item)"
class="w-full h-full object-cover"
loading="lazy"
@error="onImgError"
>
</div>
<p
class="mt-2 text-[13px] text-center leading-snug t9-clamp-2 transition-colors group-hover:text-[var(--t9-primary)]"
style="color: var(--t9-text)"
>{{ masterName(item) }}</p>
</NuxtLink>
</div>
</div>
</section>
<!-- 作品集书籍封面墙 + 查看更多 -->
<section v-if="worksBooks.length" class="bg-white">
<div class="t9-container">
<h2 class="t9-section-title">作品集</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-5 lg:gap-6">
<NuxtLink
v-for="item in worksBooks"
:key="'work-' + (item.id || item.articleId)"
:to="detailLink(bookNav?.model, item)"
class="group"
>
<div class="w-full aspect-[19/23] overflow-hidden bg-[var(--t9-bg-gray)]">
<img
:src="itemImage(item)"
:alt="item.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
@error="onImgError"
>
</div>
<p
class="mt-2 text-sm text-center t9-ellipsis transition-colors group-hover:text-[var(--t9-primary)]"
style="color: var(--t9-muted-2)"
>{{ item.title }}</p>
</NuxtLink>
</div>
<div class="text-center py-10">
<NuxtLink
:to="bookNav ? getNavLink(bookNav) : '/article'"
class="inline-block px-8 py-2 text-sm border transition-colors hover:text-white"
style="color: var(--t9-text); border-color: var(--t9-border-2)"
@mouseenter="onMoreEnter"
@mouseleave="onMoreLeave"
>
查看更多 More
</NuxtLink>
</div>
</div>
</section>
<!-- 兜底全部板块无数据时展示关于我们 -->
<AboutSection
v-if="!newsList.length && !carouselBooks.length && !masterList.length"
title-color="var(--t9-text-strong)"
accent-color="var(--t9-primary)"
summary-color="var(--t9-text-secondary)"
link-color="var(--t9-primary)"
container-class="t9-container grid lg:grid-cols-2 gap-10 items-center"
image-bg-color="var(--t9-bg-gray)"
image-object-fit="object-cover"
/>
</div>
</template>
<script setup lang="ts">
/**
* 模板 9 - 首页(墨绿文化出版风,对齐漓江出版社官网)
*
* 板块顺序:Banner → 数据统计 → 简介 → 新闻中心(左大图右列表)
* → 图书速递(横向滚动封面)→ 名家经典(圆形头像)→ 作品集(封面墙)
*
* 数据来源自适应导航:
* - newsNav :第 1 个 article 模型顶层栏目
* - bookNav case 模型栏目,缺省回退到第 2 个 article 栏目
* - masterNav product 模型顶层栏目(渲染为圆形头像)
*/
import dayjs from 'dayjs'
import HeroSection from '../components/HeroSection.vue'
import type { CmsNavigation, Article, Product, CaseItem, PageResult, ApiEnvelope } from '~/types'
import { collectDescendantNavIds } from '~/utils/nav-tree'
import { getNavLink, stripHtml } from '~/utils'
import { getCompressedImageUrl } from '~/utils/image'
type ContentItem = Article | Product | CaseItem
const { siteInfo, navigations, allNavigations, fetchSiteInfo } = useSite()
await fetchSiteInfo()
/** 顶层栏目:
* 优先取顶部主菜单(useSite.navigations,即 top===1 的权威顶部菜单),
* 避免把页脚链接(如「核销业务」「资质荣誉」)误当成首页内容板块的数据源;
* 顶部菜单为空时才回退到全部导航的顶层节点。 */
const topNavs = computed<CmsNavigation[]>(() => {
const primary = (navigations.value || []).filter((n) => (n.parentId || 0) === 0)
if (primary.length) return primary
return (allNavigations.value || []).filter((n) => (n.parentId || 0) === 0)
})
/** 新闻栏目:第 1 个 article 模型 */
const newsNav = computed<CmsNavigation | undefined>(() =>
topNavs.value.filter((n) => n.model === 'article')[0]
)
/** 图书栏目:case 模型优先,其次第 2 个 article 栏目 */
const bookNav = computed<CmsNavigation | undefined>(() =>
topNavs.value.find((n) => n.model === 'case')
|| topNavs.value.filter((n) => n.model === 'article')[1]
)
/** 名家栏目:product 模型 */
const masterNav = computed<CmsNavigation | undefined>(() =>
topNavs.value.find((n) => n.model === 'product')
)
/** 通用列表拉取
* 先按栏目(含所有后代栏目)过滤;若该栏目下暂无内容,回退为不限栏目再取一次,
* 兼容后台内容尚未归类到具体栏目的站点,避免首页板块整块空白。 */
async function fetchList(nav: CmsNavigation | undefined, limit: number): Promise<ContentItem[]> {
if (!nav) return []
const api = nav.model === 'product'
? '/api/product/list'
: nav.model === 'case'
? '/api/case/list'
: '/api/article/list'
const ids = collectDescendantNavIds(nav.navigationId, allNavigations.value || [])
const load = async (categoryIds?: string): Promise<ContentItem[]> => {
try {
const res = await $fetch<ApiEnvelope<PageResult<ContentItem>> | PageResult<ContentItem>>(api, {
query: { categoryIds, page: 1, limit }
})
return ((res as ApiEnvelope<PageResult<ContentItem>>)?.data?.list
?? (res as PageResult<ContentItem>)?.list
?? []) as ContentItem[]
} catch {
return []
}
}
const scoped = ids.length ? ids.join(',') : undefined
const list = await load(scoped)
if (list.length || !scoped) return list
return load(undefined)
}
/** SSR 并发拉取三类数据 */
const [newsList, bookList, masterList] = await Promise.all([
fetchList(newsNav.value, 7),
fetchList(bookNav.value, 27),
fetchList(masterNav.value, 21)
])
/** 新闻头条与列表 */
const featuredNews = computed<ContentItem | undefined>(() => newsList[0])
const restNews = computed<ContentItem[]>(() => newsList.slice(1, 7))
/** 图书速递(前 12 条横滚)与作品集(后续 15 条封面墙) */
const carouselBooks = computed<ContentItem[]>(() => bookList.slice(0, 12))
const worksBooks = computed<ContentItem[]>(() => bookList.slice(12, 27))
/** 数据统计栏:优先后台 config.stats(JSON 数组),否则用实际内容数量兜底 */
const statsItems = computed<{ value: string, label: string }[]>(() => {
const raw = (siteInfo.value?.config as Record<string, unknown> | undefined)?.stats
let parsed: unknown = raw
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw) } catch { parsed = null }
}
if (Array.isArray(parsed) && parsed.length) {
return (parsed as { value?: string | number, label?: string }[])
.filter((s) => s && (s.value !== undefined || s.label))
.slice(0, 3)
.map((s) => ({ value: String(s.value ?? ''), label: String(s.label ?? '') }))
}
const items: { value: string, label: string }[] = []
if (masterList.length) items.push({ value: `${masterList.length}+`, label: `位${masterNav.value?.title || '名家'}` })
if (bookList.length) items.push({ value: `${bookList.length}+`, label: `部${bookNav.value?.title || '作品'}` })
if (newsList.length) items.push({ value: `${newsList.length}+`, label: `条${newsNav.value?.title || '资讯'}` })
return items.length === 3 ? items : []
})
/** 简介文案 */
const introText = computed(() =>
stripHtml(String(siteInfo.value?.comments || (siteInfo.value as Record<string, unknown> | null)?.content || '')) || ''
)
/** 默认封面图(文章/图书无图时使用) */
const FALLBACK_IMAGE = '/images/template-09/news-default.jpg'
/** 内容封面图:兼容 image / cover / photo / images[0] */
function itemImage(item: ContentItem): string {
const anyItem = item as Record<string, unknown>
const raw = (anyItem.image || anyItem.cover || anyItem.photo
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
if (!raw) return FALLBACK_IMAGE
return getCompressedImageUrl(raw, { width: 800 })
}
/** 图片加载失败回退 */
function onImgError(e: Event) {
const el = e.target as HTMLImageElement
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
}
/** 名家/产品名称:兼容 productName / title */
function masterName(item: ContentItem): string {
const anyItem = item as Record<string, unknown>
return String(anyItem.productName || anyItem.title || '')
}
/** 摘要 */
function summaryOf(item: ContentItem): string {
const anyItem = item as Record<string, unknown>
const s = (anyItem.summary || anyItem.description || anyItem.content || '') as string
return stripHtml(s).slice(0, 120)
}
/** 详情链接 */
function detailLink(model: string | undefined, item: ContentItem): string {
const anyItem = item as Record<string, unknown>
const id = anyItem.id || anyItem.articleId || anyItem.productId
if (model === 'product') return `/product/${id}`
if (model === 'case') return `/case/${id}`
return `/article/${id}`
}
function formatDate(date?: unknown) {
if (!date) return ''
return dayjs(String(date)).format('YYYY-MM-DD')
}
/** 图书速递横向滚动 */
const scrollerRef = ref<HTMLElement | null>(null)
function scrollBy(delta: number) {
scrollerRef.value?.scrollBy({ left: delta, behavior: 'smooth' })
}
/** 「查看更多」按钮 hover 主色填充 */
function onMoreEnter(e: Event) {
const el = e.target as HTMLElement
el.style.backgroundColor = 'var(--t9-primary)'
el.style.borderColor = 'var(--t9-primary)'
el.style.color = '#ffffff'
}
function onMoreLeave(e: Event) {
const el = e.target as HTMLElement
el.style.backgroundColor = ''
el.style.borderColor = 'var(--t9-border-2)'
el.style.color = 'var(--t9-text)'
}
</script>