官网开发中 0731
This commit is contained in:
+8
-3
@@ -4,6 +4,10 @@ import enUS from '~~/i18n/locales/en.json';
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
// 应用启动时拉取全局站点配置(导航/页脚/社交/i18n 词条/产品)
|
||||
const siteStore = useSiteStore();
|
||||
siteStore.init();
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: () => (locale.value === 'zh' ? 'zh-CN' : 'en-US')
|
||||
@@ -11,10 +15,11 @@ useHead({
|
||||
});
|
||||
|
||||
// 根据当前语言动态设置页面标题
|
||||
// i18n v10:useLocaleHead 选项为 { lang, dir, seo }(均默认 true)
|
||||
const head = useLocaleHead({
|
||||
addDirAttribute: true,
|
||||
identifierAttribute: 'id',
|
||||
addSeoAttributes: true
|
||||
lang: true,
|
||||
dir: true,
|
||||
seo: true
|
||||
});
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -1,14 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const siteStore = useSiteStore();
|
||||
const { footer, social, site } = storeToRefs(siteStore);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 社交链接图标(系统内置,admin 只填链接/二维码)
|
||||
const socialIcons: Record<string, string> = {
|
||||
wechat: '💬',
|
||||
weibo: '🌐',
|
||||
bilibili: '📺',
|
||||
youtube: '▶️',
|
||||
facebook: '📘',
|
||||
instagram: '📷'
|
||||
};
|
||||
|
||||
// 仅显示已填写链接或二维码的社交入口
|
||||
const visibleSocial = computed(() =>
|
||||
(social.value || []).filter((s) => s.url || s.qrcode_url)
|
||||
);
|
||||
|
||||
// 悬浮显示微信二维码
|
||||
const wechatHover = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-gray-100 bg-gray-50 dark:bg-dark-900 dark:border-dark-700">
|
||||
<div class="section-container py-32px flex flex-col items-center gap-12px">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('footer.copyright', { year: currentYear }) }}
|
||||
</p>
|
||||
<div class="section-container py-48px">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-32px">
|
||||
<!-- 品牌与联系信息 -->
|
||||
<div class="flex flex-col gap-12px">
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ site?.site_title || t('app.name') }}
|
||||
</p>
|
||||
<p v-if="footer?.company_name" class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ footer.company_name }}
|
||||
</p>
|
||||
<p v-if="footer?.company_address" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ footer.company_address }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-16px text-sm text-gray-500 dark:text-gray-400">
|
||||
<a v-if="footer?.contact_phone" :href="`tel:${footer.contact_phone}`" class="hover:text-primary transition-colors">
|
||||
{{ footer.contact_phone }}
|
||||
</a>
|
||||
<a v-if="footer?.contact_email" :href="`mailto:${footer.contact_email}`" class="hover:text-primary transition-colors">
|
||||
{{ footer.contact_email }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 社交媒体 -->
|
||||
<div v-if="visibleSocial.length" class="flex items-center gap-16px">
|
||||
<template v-for="item in visibleSocial" :key="item.platform">
|
||||
<!-- 微信:悬浮显示二维码 -->
|
||||
<div
|
||||
v-if="item.platform === 'wechat' && item.qrcode_url"
|
||||
class="relative"
|
||||
@mouseenter="wechatHover = true"
|
||||
@mouseleave="wechatHover = false"
|
||||
>
|
||||
<span class="w-40px h-40px flex-center rounded-full bg-white border border-gray-200 text-lg cursor-pointer hover:border-primary transition-colors dark:bg-dark-800 dark:border-dark-600">
|
||||
{{ socialIcons[item.platform] || '🔗' }}
|
||||
</span>
|
||||
<transition name="qr">
|
||||
<img
|
||||
v-if="wechatHover"
|
||||
:src="item.qrcode_url"
|
||||
alt="WeChat QR"
|
||||
class="absolute bottom-48px right-0 w-140px h-140px object-contain rounded-8px shadow-xl border border-gray-100 bg-white"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 其他平台:外链 -->
|
||||
<a
|
||||
v-else-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-40px h-40px flex-center rounded-full bg-white border border-gray-200 text-lg hover:border-primary transition-colors dark:bg-dark-800 dark:border-dark-600"
|
||||
>
|
||||
{{ socialIcons[item.platform] || '🔗' }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版权与备案 -->
|
||||
<div class="mt-32px pt-24px border-t border-gray-200 dark:border-dark-700 flex flex-col md:flex-row md:items-center md:justify-between gap-8px">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ footer?.copyright_text || t('footer.copyright', { year: currentYear }) }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-16px text-xs text-gray-400 dark:text-gray-500">
|
||||
<span v-if="footer?.icp_number">{{ footer.icp_number }}</span>
|
||||
<span v-if="footer?.police_number">{{ footer.police_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-enter-active,
|
||||
.qr-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.qr-enter-from,
|
||||
.qr-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
const { t, locale, locales } = useI18n();
|
||||
const switchLocalePath = useSwitchLocalePath();
|
||||
import type { NavItem } from '~/types/site';
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ label: t('nav.home'), path: '/' },
|
||||
{ label: t('nav.products'), path: '/products' },
|
||||
{ label: t('nav.news'), path: '/news' },
|
||||
{ label: t('nav.about'), path: '/about' }
|
||||
]);
|
||||
const { t, locale, locales } = useI18n();
|
||||
const localePath = useLocalePath();
|
||||
const switchLocalePath = useSwitchLocalePath();
|
||||
const route = useRoute();
|
||||
|
||||
const siteStore = useSiteStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const { navItems, navAppearance, productSeries, site } = storeToRefs(siteStore);
|
||||
|
||||
// 滚动状态:滚动后强制毛玻璃背景 + 深色文字
|
||||
const { y: scrollY } = useWindowScroll();
|
||||
const isScrolled = computed(() => scrollY.value > 24);
|
||||
|
||||
// 是否首页
|
||||
const isHome = computed(() => route.path === localePath('/') || route.path === '/');
|
||||
|
||||
// 导航栏是否使用透明风格(未滚动时)
|
||||
const useTransparent = computed(() => {
|
||||
if (isScrolled.value) return false;
|
||||
const style = isHome.value ? navAppearance.value?.homepage_style : navAppearance.value?.non_homepage_style;
|
||||
return style === 'transparent';
|
||||
});
|
||||
|
||||
// 文字/Logo 颜色:透明风格下用浅色(适配深色首屏),否则用深色
|
||||
const lightText = computed(() => useTransparent.value);
|
||||
|
||||
const logoUrl = computed(() => {
|
||||
if (!site.value) return '';
|
||||
return lightText.value ? site.value.logo_light_url : site.value.logo_dark_url;
|
||||
});
|
||||
|
||||
// 判断是否为「产品」入口(悬浮展开 Mega Menu)
|
||||
function isProductsEntry(item: NavItem): boolean {
|
||||
return item.link.includes('/products');
|
||||
}
|
||||
|
||||
// 解析导航链接:外部 URL 原样返回,内部路由加语言前缀
|
||||
function resolveLink(link: string): string {
|
||||
if (/^https?:\/\//.test(link)) return link;
|
||||
return localePath(link);
|
||||
}
|
||||
|
||||
function isExternal(link: string): boolean {
|
||||
return /^https?:\/\//.test(link);
|
||||
}
|
||||
|
||||
// Mega Menu 悬浮状态
|
||||
const megaOpen = ref(false);
|
||||
let megaTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function openMega() {
|
||||
if (megaTimer) clearTimeout(megaTimer);
|
||||
megaOpen.value = true;
|
||||
}
|
||||
|
||||
function closeMega() {
|
||||
megaTimer = setTimeout(() => {
|
||||
megaOpen.value = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// 移动端:关闭抽屉
|
||||
function closeMobile() {
|
||||
appStore.toggleMobileMenu(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100 dark:bg-dark-900/80 dark:border-dark-700">
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300"
|
||||
:class="useTransparent ? 'bg-transparent' : 'bg-white/80 backdrop-blur-md border-b border-gray-100 dark:bg-dark-900/80 dark:border-dark-700'"
|
||||
>
|
||||
<div class="section-container h-64px flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<NuxtLink :to="switchLocalePath(locale)" class="flex items-center gap-8px text-xl font-bold text-primary">
|
||||
{{ t('app.name') }}
|
||||
<NuxtLink :to="switchLocalePath(locale)" class="flex items-center gap-8px shrink-0" @click="closeMobile">
|
||||
<img v-if="logoUrl" :src="logoUrl" :alt="site?.site_title || 'Luxsin'" class="h-32px w-auto" />
|
||||
<span
|
||||
v-else
|
||||
class="text-xl font-bold transition-colors"
|
||||
:class="lightText ? 'text-white' : 'text-primary'"
|
||||
>
|
||||
{{ site?.site_title || t('app.name') }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- 导航 -->
|
||||
<nav class="hidden md:flex items-center gap-24px">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="switchLocalePath(locale) === '/' ? item.path : switchLocalePath(locale) + item.path"
|
||||
class="text-sm text-gray-600 hover:text-primary transition-colors dark:text-gray-300"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
<!-- 桌面端导航 -->
|
||||
<nav class="hidden md:flex items-center gap-32px">
|
||||
<template v-for="item in navItems" :key="item.id">
|
||||
<!-- 产品入口:悬浮 Mega Menu -->
|
||||
<div
|
||||
v-if="isProductsEntry(item)"
|
||||
class="relative h-64px flex items-center"
|
||||
@mouseenter="openMega"
|
||||
@mouseleave="closeMega"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="resolveLink(item.link)"
|
||||
class="text-sm font-medium transition-colors flex items-center gap-4px"
|
||||
:class="lightText ? 'text-white/90 hover:text-white' : 'text-gray-700 hover:text-primary dark:text-gray-200'"
|
||||
>
|
||||
{{ siteStore.navLabel(item) }}
|
||||
<span class="text-10px opacity-60">▾</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Mega Menu 面板 -->
|
||||
<transition name="mega">
|
||||
<div
|
||||
v-if="megaOpen"
|
||||
class="absolute top-64px left-50% -translate-x-50% min-w-640px bg-white/95 backdrop-blur-xl rounded-12px shadow-xl border border-gray-100 p-24px dark:bg-dark-900/95 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex gap-40px">
|
||||
<div v-for="series in productSeries" :key="series.id" class="min-w-200px">
|
||||
<p class="text-12px font-semibold text-gray-400 mb-12px uppercase tracking-wide">
|
||||
{{ locale === 'en' ? series.name_en : series.name_zh }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-8px">
|
||||
<NuxtLink
|
||||
v-for="product in series.products"
|
||||
:key="product.id"
|
||||
:to="localePath(`/products/${product.slug}`)"
|
||||
class="flex items-center gap-12px p-8px rounded-8px hover:bg-gray-50 transition-colors dark:hover:bg-dark-800"
|
||||
@click="megaOpen = false"
|
||||
>
|
||||
<img
|
||||
v-if="product.cover_url"
|
||||
:src="product.cover_url"
|
||||
:alt="product.name_zh"
|
||||
class="w-48px h-36px object-cover rounded-6px bg-gray-100"
|
||||
/>
|
||||
<span class="text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ locale === 'en' ? product.name_en : product.name_zh }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 普通入口:外部链接 -->
|
||||
<a
|
||||
v-else-if="isExternal(item.link)"
|
||||
:href="item.link"
|
||||
:target="item.open_new_tab ? '_blank' : undefined"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="lightText ? 'text-white/90 hover:text-white' : 'text-gray-700 hover:text-primary dark:text-gray-200'"
|
||||
>
|
||||
{{ siteStore.navLabel(item) }}
|
||||
</a>
|
||||
|
||||
<!-- 普通入口:站内路由 -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="resolveLink(item.link)"
|
||||
:target="item.open_new_tab ? '_blank' : undefined"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="lightText ? 'text-white/90 hover:text-white' : 'text-gray-700 hover:text-primary dark:text-gray-200'"
|
||||
>
|
||||
{{ siteStore.navLabel(item) }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<!-- 右侧:语言切换 + 移动端汉堡 -->
|
||||
<div class="flex items-center gap-12px">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="px-8px py-4px rounded text-sm transition-colors"
|
||||
:class="locale === loc.code ? 'bg-primary text-white' : 'text-gray-500 hover:text-primary'"
|
||||
<div class="hidden md:flex items-center gap-4px">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="px-8px py-4px rounded text-sm transition-colors"
|
||||
:class="
|
||||
locale === loc.code
|
||||
? lightText ? 'bg-white/20 text-white' : 'bg-primary text-white'
|
||||
: lightText ? 'text-white/70 hover:text-white' : 'text-gray-500 hover:text-primary'
|
||||
"
|
||||
>
|
||||
{{ loc.code === 'zh' ? '中' : 'EN' }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 汉堡按钮 -->
|
||||
<button
|
||||
class="md:hidden w-40px h-40px flex-center rounded-8px transition-colors"
|
||||
:class="lightText ? 'text-white hover:bg-white/10' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200'"
|
||||
aria-label="menu"
|
||||
@click="appStore.toggleMobileMenu()"
|
||||
>
|
||||
{{ loc.code === 'zh' ? '中' : 'EN' }}
|
||||
</NuxtLink>
|
||||
<span class="text-xl">{{ appStore.mobileMenuOpen ? '✕' : '☰' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉 -->
|
||||
<transition name="drawer">
|
||||
<div
|
||||
v-if="appStore.mobileMenuOpen"
|
||||
class="md:hidden bg-white dark:bg-dark-900 border-t border-gray-100 dark:border-dark-700 px-16px py-16px flex flex-col gap-4px"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
:to="resolveLink(item.link)"
|
||||
class="px-12px py-12px rounded-8px text-base text-gray-800 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-dark-800"
|
||||
@click="closeMobile"
|
||||
>
|
||||
{{ siteStore.navLabel(item) }}
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex items-center gap-8px mt-12px px-12px">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="px-12px py-6px rounded text-sm transition-colors"
|
||||
:class="locale === loc.code ? 'bg-primary text-white' : 'text-gray-500 hover:text-primary'"
|
||||
@click="closeMobile"
|
||||
>
|
||||
{{ loc.code === 'zh' ? '中文' : 'English' }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mega-enter-active,
|
||||
.mega-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.mega-enter-from,
|
||||
.mega-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -8px);
|
||||
}
|
||||
.mega-enter-to,
|
||||
.mega-leave-from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { ctaPrimaryText, ctaSecondaryText, isDark } = useSectionContent(props.section);
|
||||
const { isExternal, resolveLink } = useLinkResolver();
|
||||
|
||||
const primaryLink = computed(() => resolveLink(props.section.cta_primary_url));
|
||||
const secondaryLink = computed(() => resolveLink(props.section.cta_secondary_url));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="primaryLink || secondaryLink" class="flex flex-wrap items-center gap-16px">
|
||||
<!-- 主 CTA -->
|
||||
<template v-if="primaryLink && ctaPrimaryText">
|
||||
<a
|
||||
v-if="isExternal(section.cta_primary_url)"
|
||||
:href="section.cta_primary_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-28px py-12px rounded-full text-sm font-medium transition-all"
|
||||
:class="isDark ? 'bg-white text-gray-900 hover:bg-gray-200' : 'bg-primary text-white hover:opacity-90'"
|
||||
>
|
||||
{{ ctaPrimaryText }}
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="primaryLink"
|
||||
class="px-28px py-12px rounded-full text-sm font-medium transition-all"
|
||||
:class="isDark ? 'bg-white text-gray-900 hover:bg-gray-200' : 'bg-primary text-white hover:opacity-90'"
|
||||
>
|
||||
{{ ctaPrimaryText }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<!-- 次 CTA -->
|
||||
<template v-if="secondaryLink && ctaSecondaryText">
|
||||
<a
|
||||
v-if="isExternal(section.cta_secondary_url)"
|
||||
:href="section.cta_secondary_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-28px py-12px rounded-full text-sm font-medium border transition-all"
|
||||
:class="isDark ? 'border-white/40 text-white hover:bg-white/10' : 'border-gray-300 text-gray-700 hover:border-primary hover:text-primary dark:border-dark-600 dark:text-gray-200'"
|
||||
>
|
||||
{{ ctaSecondaryText }}
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="secondaryLink"
|
||||
class="px-28px py-12px rounded-full text-sm font-medium border transition-all"
|
||||
:class="isDark ? 'border-white/40 text-white hover:bg-white/10' : 'border-gray-300 text-gray-700 hover:border-primary hover:text-primary dark:border-dark-600 dark:text-gray-200'"
|
||||
>
|
||||
{{ ctaSecondaryText }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock, SectionLayout } from '~/types/site';
|
||||
import LayoutHeroSplit from './layouts/LayoutHeroSplit.vue';
|
||||
import LayoutFullBanner from './layouts/LayoutFullBanner.vue';
|
||||
import LayoutFeatureGrid from './layouts/LayoutFeatureGrid.vue';
|
||||
import LayoutSplitContent from './layouts/LayoutSplitContent.vue';
|
||||
import LayoutGallery from './layouts/LayoutGallery.vue';
|
||||
import LayoutStats from './layouts/LayoutStats.vue';
|
||||
import LayoutVideoShowcase from './layouts/LayoutVideoShowcase.vue';
|
||||
import LayoutScrollNarrative from './layouts/LayoutScrollNarrative.vue';
|
||||
import LayoutQuote from './layouts/LayoutQuote.vue';
|
||||
import LayoutSpecTable from './layouts/LayoutSpecTable.vue';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
// 布局类型 -> 组件映射
|
||||
const layoutComponents: Record<SectionLayout, unknown> = {
|
||||
hero_split: LayoutHeroSplit,
|
||||
full_banner: LayoutFullBanner,
|
||||
feature_grid: LayoutFeatureGrid,
|
||||
split_content: LayoutSplitContent,
|
||||
gallery: LayoutGallery,
|
||||
stats: LayoutStats,
|
||||
video_showcase: LayoutVideoShowcase,
|
||||
scroll_narrative: LayoutScrollNarrative,
|
||||
quote: LayoutQuote,
|
||||
spec_table: LayoutSpecTable
|
||||
};
|
||||
|
||||
const layoutComponent = computed(() => layoutComponents[props.section.layout] ?? null);
|
||||
|
||||
const isDark = computed(() => props.section.theme === 'dark');
|
||||
|
||||
// 遮罩透明度(decimal 字符串 -> 数值)
|
||||
const overlayOpacity = computed(() => Number(props.section.overlay_opacity) || 0);
|
||||
const showOverlay = computed(() => !!props.section.overlay_enabled && overlayOpacity.value > 0);
|
||||
|
||||
// 背景样式
|
||||
const bgStyle = computed(() => {
|
||||
const { bg_type, bg_value } = props.section;
|
||||
if (bg_type === 'color' && bg_value) {
|
||||
return { backgroundColor: bg_value };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const hasBgImage = computed(() => props.section.bg_type === 'image' && !!props.section.bg_value);
|
||||
const hasBgVideo = computed(() => props.section.bg_type === 'video' && !!props.section.bg_value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative overflow-hidden"
|
||||
:class="isDark ? 'bg-gray-950 text-white' : 'bg-white text-gray-900 dark:bg-dark-900 dark:text-white'"
|
||||
:style="bgStyle"
|
||||
>
|
||||
<!-- 背景图 -->
|
||||
<div
|
||||
v-if="hasBgImage"
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${section.bg_value})` }"
|
||||
/>
|
||||
|
||||
<!-- 背景视频 -->
|
||||
<video
|
||||
v-if="hasBgVideo"
|
||||
:src="section.bg_value"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
|
||||
<!-- 遮罩层 -->
|
||||
<div
|
||||
v-if="showOverlay"
|
||||
class="absolute inset-0 bg-black"
|
||||
:style="{ opacity: overlayOpacity }"
|
||||
/>
|
||||
|
||||
<!-- 布局内容 -->
|
||||
<div class="relative z-10">
|
||||
<component :is="layoutComponent" v-if="layoutComponent" :section="section" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isEn, isDark } = useSectionContent(props.section);
|
||||
|
||||
const features = computed(() => config.value.features ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-56px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 特性网格 -->
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-24px">
|
||||
<div
|
||||
v-for="(feature, idx) in features"
|
||||
:key="idx"
|
||||
class="p-32px rounded-16px transition-all hover:-translate-y-4px"
|
||||
:class="isDark ? 'bg-white/5 border border-white/10' : 'bg-white border border-gray-100 shadow-sm hover:shadow-md dark:bg-dark-800 dark:border-dark-700'"
|
||||
>
|
||||
<span v-if="feature.icon" class="text-3xl">{{ feature.icon }}</span>
|
||||
<h3 class="mt-16px text-lg font-semibold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ isEn ? feature.title_en : feature.title_zh }}
|
||||
</h3>
|
||||
<p class="mt-8px text-sm leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ isEn ? feature.desc_en : feature.desc_zh }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, content } = useSectionContent(props.section);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-120px flex-col-center text-center max-w-800px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="section.theme === 'dark' ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-16px text-3xl md:text-5xl font-bold leading-tight" :class="section.theme === 'dark' ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-20px text-lg md:text-xl" :class="section.theme === 'dark' ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="content" class="mt-16px text-15px leading-relaxed" :class="section.theme === 'dark' ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" v-html="content" />
|
||||
<SectionCta :section="section" class="mt-32px justify-center" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isEn, isDark } = useSectionContent(props.section);
|
||||
|
||||
const images = computed(() => config.value.images ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-56px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片网格 -->
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-16px">
|
||||
<figure v-for="(img, idx) in images" :key="idx" class="group relative overflow-hidden rounded-12px">
|
||||
<img :src="img.url" :alt="isEn ? img.caption_en : img.caption_zh" class="w-full aspect-4/3 object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<figcaption
|
||||
v-if="(isEn ? img.caption_en : img.caption_zh)"
|
||||
class="absolute bottom-0 left-0 right-0 px-16px py-12px text-sm text-white bg-gradient-to-t from-black/60 to-transparent"
|
||||
>
|
||||
{{ isEn ? img.caption_en : img.caption_zh }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, content, mediaUrl, isDark } = useSectionContent(props.section);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container aspect-[2/3] md:aspect-auto md:min-h-[min(calc(92vh-64px),54vw)] grid md:grid-cols-2 gap-48px items-center">
|
||||
<!-- 左侧文案 -->
|
||||
<div class="flex flex-col gap-20px self-center md:min-h-[680px]">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="text-3xl md:text-5xl font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="text-lg md:text-xl" :class="isDark ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="content" class="text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" v-html="content" />
|
||||
<SectionCta :section="section" class="mt-8px" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧图片 -->
|
||||
<div v-if="mediaUrl" class="flex-center">
|
||||
<img :src="mediaUrl" :alt="title" class="w-full max-w-560px rounded-16px object-cover shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
// 引言评价:正文 content 作为引用语,subtitle 作为作者/出处
|
||||
const { content, subtitle, isDark } = useSectionContent(props.section);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px flex-col-center text-center max-w-800px">
|
||||
<span class="text-6xl leading-none font-serif" :class="isDark ? 'text-primary-300' : 'text-primary'">“</span>
|
||||
<blockquote
|
||||
class="mt-8px text-2xl md:text-3xl font-medium leading-relaxed"
|
||||
:class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'"
|
||||
v-html="content"
|
||||
/>
|
||||
<p v-if="subtitle" class="mt-24px text-sm tracking-wide" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
—— {{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isEn, isDark } = useSectionContent(props.section);
|
||||
|
||||
const steps = computed(() => config.value.steps ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-64px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 步骤叙事 -->
|
||||
<div class="flex flex-col gap-64px max-w-960px mx-auto">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="grid md:grid-cols-2 gap-32px items-center"
|
||||
>
|
||||
<!-- 文案 -->
|
||||
<div class="flex flex-col gap-12px" :class="idx % 2 === 1 ? 'md:order-2' : ''">
|
||||
<span class="text-4xl font-bold opacity-20" :class="isDark ? 'text-white' : 'text-gray-900'">
|
||||
{{ String(idx + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ isEn ? step.title_en : step.title_zh }}
|
||||
</h3>
|
||||
<p class="text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ isEn ? step.desc_en : step.desc_zh }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 配图 -->
|
||||
<div v-if="step.image_url" :class="idx % 2 === 1 ? 'md:order-1' : ''">
|
||||
<img :src="step.image_url" :alt="isEn ? step.title_en : step.title_zh" class="w-full rounded-16px object-cover shadow-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock, SpecGroup } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, isEn, isDark } = useSectionContent(props.section);
|
||||
|
||||
const specGroups = computed<SpecGroup[]>(() => props.section.spec_groups ?? []);
|
||||
|
||||
// 每个分组的展开状态(默认收起,仅显示前 default_visible 项)
|
||||
const expandedMap = ref<Record<number, boolean>>({});
|
||||
|
||||
function isExpanded(group: SpecGroup): boolean {
|
||||
return !!expandedMap.value[group.id];
|
||||
}
|
||||
|
||||
function toggle(group: SpecGroup) {
|
||||
expandedMap.value[group.id] = !expandedMap.value[group.id];
|
||||
}
|
||||
|
||||
// 分组内可见的参数项:展开时全部,收起时前 default_visible 项
|
||||
function visibleItems(group: SpecGroup) {
|
||||
const items = group.items ?? [];
|
||||
if (isExpanded(group)) return items;
|
||||
return items.slice(0, group.default_visible || 5);
|
||||
}
|
||||
|
||||
// 是否需要显示展开按钮(参数项 > 默认显示数量)
|
||||
function needToggle(group: SpecGroup): boolean {
|
||||
return (group.items?.length ?? 0) > (group.default_visible || 5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-56px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 参数分组卡片 -->
|
||||
<div class="flex flex-col gap-24px max-w-960px mx-auto">
|
||||
<div
|
||||
v-for="group in specGroups"
|
||||
:key="group.id"
|
||||
class="rounded-16px overflow-hidden"
|
||||
:class="isDark ? 'bg-white/5 border border-white/10' : 'bg-white border border-gray-100 shadow-sm dark:bg-dark-800 dark:border-dark-700'"
|
||||
>
|
||||
<!-- 分组标题 -->
|
||||
<div class="px-32px py-20px border-b" :class="isDark ? 'border-white/10' : 'border-gray-100 dark:border-dark-700'">
|
||||
<h3 class="text-lg font-semibold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ isEn ? group.title_en : group.title_zh }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 参数项列表 -->
|
||||
<div class="px-32px">
|
||||
<div
|
||||
v-for="item in visibleItems(group)"
|
||||
:key="item.id"
|
||||
class="flex items-start justify-between gap-24px py-14px border-b last:border-b-0"
|
||||
:class="isDark ? 'border-white/5' : 'border-gray-50 dark:border-dark-700/50'"
|
||||
>
|
||||
<span class="text-sm shrink-0" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ isEn ? item.name_en : item.name_zh }}
|
||||
</span>
|
||||
<span class="text-sm text-right font-medium" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
|
||||
{{ isEn ? item.value_en : item.value_zh }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开/收起按钮 -->
|
||||
<div v-if="needToggle(group)" class="px-32px py-16px">
|
||||
<button
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="isDark ? 'text-primary-300 hover:text-primary-200' : 'text-primary hover:opacity-80'"
|
||||
@click="toggle(group)"
|
||||
>
|
||||
{{ isExpanded(group) ? '收起参数 ∧' : `查看全部参数(${group.items?.length ?? 0} 项)∨` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, content, mediaUrl, isDark } = useSectionContent(props.section);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px grid md:grid-cols-2 gap-48px items-center">
|
||||
<!-- 图片 -->
|
||||
<div v-if="mediaUrl" class="flex-center order-2 md:order-1">
|
||||
<img :src="mediaUrl" :alt="title" class="w-full max-w-520px rounded-16px object-cover shadow-lg" />
|
||||
</div>
|
||||
|
||||
<!-- 文案 -->
|
||||
<div class="flex flex-col gap-20px order-1 md:order-2">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="text-3xl md:text-4xl font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="text-lg" :class="isDark ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="content" class="text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" v-html="content" />
|
||||
<SectionCta :section="section" class="mt-8px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isEn, isDark } = useSectionContent(props.section);
|
||||
|
||||
const stats = computed(() => config.value.stats ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-64px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据统计 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-32px">
|
||||
<div v-for="(stat, idx) in stats" :key="idx" class="flex-col-center text-center">
|
||||
<p class="text-4xl md:text-5xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ stat.value }}<span class="text-2xl md:text-3xl ml-4px opacity-70">{{ stat.unit }}</span>
|
||||
</p>
|
||||
<p class="mt-12px text-sm" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ isEn ? stat.label_en : stat.label_zh }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, isDark } = useSectionContent(props.section);
|
||||
|
||||
// 判断是否为可嵌入的 iframe 视频(YouTube / Bilibili)
|
||||
const isEmbed = computed(() => /youtube\.com|youtu\.be|bilibili\.com|player\.bilibili\.com/.test(props.section.video_url));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-48px">
|
||||
<p v-if="overline" class="text-13px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 视频 -->
|
||||
<div class="max-w-960px mx-auto rounded-16px overflow-hidden shadow-xl bg-black aspect-16/9">
|
||||
<iframe
|
||||
v-if="isEmbed"
|
||||
:src="section.video_url"
|
||||
class="w-full h-full"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
scrolling="no"
|
||||
/>
|
||||
<video
|
||||
v-else-if="section.video_url"
|
||||
:src="section.video_url"
|
||||
class="w-full h-full object-cover"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 链接解析:区分外部 URL 与站内路由
|
||||
*
|
||||
* 站内路由自动加当前语言前缀(中文无前缀,英文 /en 前缀)。
|
||||
*/
|
||||
export function useLinkResolver() {
|
||||
const localePath = useLocalePath();
|
||||
|
||||
function isExternal(link: string): boolean {
|
||||
return /^https?:\/\//.test(link);
|
||||
}
|
||||
|
||||
function resolveLink(link: string): string {
|
||||
if (!link) return '';
|
||||
if (isExternal(link)) return link;
|
||||
return localePath(link);
|
||||
}
|
||||
|
||||
return { isExternal, resolveLink };
|
||||
}
|
||||
@@ -1,28 +1,41 @@
|
||||
/**
|
||||
* 统一请求封装(基于 Nuxt 内置 ofetch)
|
||||
* 响应格式约定:{ code, data, message },成功码 '0000'
|
||||
*
|
||||
* 与 dashboard 后端约定响应格式:{ code, msg, data }
|
||||
* - code = 1:成功
|
||||
* - code = 0:业务错误
|
||||
* - code = 2:无数据
|
||||
*/
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
code: string;
|
||||
data: T;
|
||||
message: string;
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: number;
|
||||
constructor(msg: string, code: number) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRequest() {
|
||||
const config = useRuntimeConfig();
|
||||
const baseURL = config.public.apiBaseUrl as string;
|
||||
|
||||
async function request<T = unknown>(url: string, options?: Parameters<typeof $fetch>[1]): Promise<T> {
|
||||
async function request<T = unknown>(url: string, options?: Parameters<typeof $fetch>[1]): Promise<T | null> {
|
||||
const res = await $fetch<ApiResponse<T>>(url, {
|
||||
baseURL,
|
||||
...options
|
||||
});
|
||||
|
||||
if (res.code !== '0000') {
|
||||
throw new Error(res.message || 'Request failed');
|
||||
if (res.code === 0) {
|
||||
throw new ApiError(res.msg || '请求失败', res.code);
|
||||
}
|
||||
|
||||
// code = 2(无数据)时 data 为 null,正常返回
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SectionBlock, SectionConfig } from '~/types/site';
|
||||
|
||||
/**
|
||||
* 区块内容多语言解析
|
||||
*
|
||||
* Section 的各文本字段均有 _zh / _en 两个版本,
|
||||
* 该 composable 根据当前语言返回对应的值,避免各布局组件重复判断。
|
||||
*/
|
||||
export function useSectionContent(section: SectionBlock) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = computed(() => locale.value === 'en');
|
||||
|
||||
const overline = computed(() => (isEn.value ? section.overline_en : section.overline_zh) || '');
|
||||
const title = computed(() => (isEn.value ? section.title_en : section.title_zh) || '');
|
||||
const subtitle = computed(() => (isEn.value ? section.subtitle_en : section.subtitle_zh) || '');
|
||||
const content = computed(() => (isEn.value ? section.content_en : section.content_zh) || '');
|
||||
const ctaPrimaryText = computed(() => (isEn.value ? section.cta_primary_en : section.cta_primary_zh) || '');
|
||||
const ctaSecondaryText = computed(() => (isEn.value ? section.cta_secondary_en : section.cta_secondary_zh) || '');
|
||||
const mediaUrl = computed(() => (isEn.value ? section.media_url_en : section.media_url_zh) || section.media_url_zh || '');
|
||||
|
||||
const config = computed<SectionConfig>(() => section.config ?? {});
|
||||
|
||||
/** 深色主题区块 */
|
||||
const isDark = computed(() => section.theme === 'dark');
|
||||
|
||||
return {
|
||||
isEn,
|
||||
overline,
|
||||
title,
|
||||
subtitle,
|
||||
content,
|
||||
ctaPrimaryText,
|
||||
ctaSecondaryText,
|
||||
mediaUrl,
|
||||
config,
|
||||
isDark
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { GlobalConfig, NavConfig, SectionBlock, I18nEntry, ProductsData } from '~/types/site';
|
||||
|
||||
/**
|
||||
* 官网前台公开接口服务层
|
||||
*
|
||||
* 消费 dashboard 后端的 /api/site/* 只读接口(免鉴权)。
|
||||
*/
|
||||
export function useSiteApi() {
|
||||
const { get } = useRequest();
|
||||
|
||||
/** 全局配置:站点信息 + 页脚 + 社交链接 + SEO 默认值 */
|
||||
function getGlobal() {
|
||||
return get<GlobalConfig>('/site/global');
|
||||
}
|
||||
|
||||
/** 导航:可见入口 + 外观 */
|
||||
function getNav() {
|
||||
return get<NavConfig>('/site/nav');
|
||||
}
|
||||
|
||||
/** Section 区块列表(按页面类型,仅可见) */
|
||||
function getSections(pageType: string, productId?: number) {
|
||||
const params: Record<string, unknown> = { page_type: pageType };
|
||||
if (productId) params.product_id = productId;
|
||||
return get<{ items: SectionBlock[]; total: number }>('/site/sections', params);
|
||||
}
|
||||
|
||||
/** i18n 词条列表 */
|
||||
function getI18n() {
|
||||
return get<{ items: I18nEntry[]; total: number }>('/site/i18n');
|
||||
}
|
||||
|
||||
/** 产品:可见系列及其下可见产品 */
|
||||
function getProducts() {
|
||||
return get<ProductsData>('/site/products');
|
||||
}
|
||||
|
||||
return { getGlobal, getNav, getSections, getI18n, getProducts };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader />
|
||||
<main class="flex-1">
|
||||
<main class="flex-1 pt-64px">
|
||||
<slot />
|
||||
</main>
|
||||
<AppFooter />
|
||||
|
||||
+33
-10
@@ -1,23 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { getSections } = useSiteApi();
|
||||
|
||||
useHead({
|
||||
title: () => `${t('app.name')} - ${t('nav.home')}`
|
||||
});
|
||||
|
||||
// 拉取首页区块列表(仅可见,按 sort_order 排序)
|
||||
const sections = ref<SectionBlock[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getSections('home');
|
||||
sections.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error('[home] 加载区块失败', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero 区块 -->
|
||||
<section class="section-container py-96px flex-col-center text-center">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('app.name') }}
|
||||
</h1>
|
||||
<p class="mt-16px text-lg text-gray-500 dark:text-gray-400">
|
||||
{{ t('app.slogan') }}
|
||||
</p>
|
||||
</section>
|
||||
<!-- 加载中占位 -->
|
||||
<div v-if="loading" class="min-h-screen flex-col-center">
|
||||
<p class="text-sm text-gray-400">加载中…</p>
|
||||
</div>
|
||||
|
||||
<!-- 页面内容由后台 CMS 配置的区块(Section)组合渲染 -->
|
||||
<!-- 区块列表:由后台 CMS 配置的 Section 组合渲染 -->
|
||||
<template v-else>
|
||||
<SectionRenderer v-for="section in sections" :key="section.id" :section="section" />
|
||||
|
||||
<!-- 空状态:尚未配置任何区块 -->
|
||||
<div v-if="!sections.length" class="min-h-screen flex-col-center px-16px text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ t('app.name') }}</h1>
|
||||
<p class="mt-12px text-gray-500 dark:text-gray-400">{{ t('app.slogan') }}</p>
|
||||
<p class="mt-32px text-sm text-gray-400">首页区块内容待配置,请前往管理后台「官网管理 - 页面管理 - 首页区块」添加。</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type {
|
||||
SiteSettings,
|
||||
FooterSettings,
|
||||
SocialLink,
|
||||
SeoDefaults,
|
||||
NavItem,
|
||||
NavAppearance,
|
||||
ProductSeries,
|
||||
I18nEntry
|
||||
} from '~/types/site';
|
||||
|
||||
/**
|
||||
* 官网全局站点状态
|
||||
*
|
||||
* 在应用启动时一次性拉取全局配置、导航、产品(Mega Menu)与 i18n 词条,
|
||||
* 供 AppHeader / AppFooter 及各页面共享使用。
|
||||
*/
|
||||
export const useSiteStore = defineStore('site', () => {
|
||||
const { getGlobal, getNav, getI18n, getProducts } = useSiteApi();
|
||||
const { locale } = useI18n();
|
||||
|
||||
// ===== 全局配置 =====
|
||||
const site = ref<SiteSettings | null>(null);
|
||||
const footer = ref<FooterSettings | null>(null);
|
||||
const social = ref<SocialLink[]>([]);
|
||||
const seo = ref<SeoDefaults | null>(null);
|
||||
|
||||
// ===== 导航 =====
|
||||
const navItems = ref<NavItem[]>([]);
|
||||
const navAppearance = ref<NavAppearance | null>(null);
|
||||
|
||||
// ===== 产品(导航 Mega Menu) =====
|
||||
const productSeries = ref<ProductSeries[]>([]);
|
||||
|
||||
// ===== i18n 词条(dict_key -> entry) =====
|
||||
const i18nMap = ref<Record<string, I18nEntry>>({});
|
||||
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
/** 应用启动时调用,并行拉取全局数据 */
|
||||
async function init() {
|
||||
if (loaded.value || loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [globalRes, navRes, i18nRes, productsRes] = await Promise.all([
|
||||
getGlobal(),
|
||||
getNav(),
|
||||
getI18n(),
|
||||
getProducts()
|
||||
]);
|
||||
|
||||
if (globalRes) {
|
||||
site.value = globalRes.site;
|
||||
footer.value = globalRes.footer;
|
||||
social.value = globalRes.social ?? [];
|
||||
seo.value = globalRes.seo;
|
||||
}
|
||||
if (navRes) {
|
||||
navItems.value = navRes.items ?? [];
|
||||
navAppearance.value = navRes.appearance;
|
||||
}
|
||||
if (i18nRes) {
|
||||
const map: Record<string, I18nEntry> = {};
|
||||
for (const entry of i18nRes.items ?? []) {
|
||||
map[entry.dict_key] = entry;
|
||||
}
|
||||
i18nMap.value = map;
|
||||
}
|
||||
if (productsRes) {
|
||||
productSeries.value = productsRes.series ?? [];
|
||||
}
|
||||
|
||||
loaded.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译 CMS 动态词条(www_i18n_entries)
|
||||
* @param key 词条 key,如 'btn.learn_more'
|
||||
* @param fallback 词条缺失时的回退文案
|
||||
*/
|
||||
function td(key: string, fallback = ''): string {
|
||||
const entry = i18nMap.value[key];
|
||||
if (!entry) return fallback || key;
|
||||
const value = locale.value === 'en' ? entry.value_en : entry.value_zh;
|
||||
return value || fallback || key;
|
||||
}
|
||||
|
||||
/** 根据当前语言取导航入口名称 */
|
||||
function navLabel(item: NavItem): string {
|
||||
return locale.value === 'en' ? item.name_en : item.name_zh;
|
||||
}
|
||||
|
||||
return {
|
||||
site,
|
||||
footer,
|
||||
social,
|
||||
seo,
|
||||
navItems,
|
||||
navAppearance,
|
||||
productSeries,
|
||||
i18nMap,
|
||||
loaded,
|
||||
loading,
|
||||
init,
|
||||
td,
|
||||
navLabel
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 官网前台数据类型定义
|
||||
*
|
||||
* 字段名与 dashboard 后端 /api/site/* 响应的 snake_case 保持一致。
|
||||
*/
|
||||
|
||||
// ===== 全局配置 =====
|
||||
export interface SiteSettings {
|
||||
id: number;
|
||||
site_title: string;
|
||||
favicon_url: string;
|
||||
logo_dark_url: string;
|
||||
logo_light_url: string;
|
||||
}
|
||||
|
||||
export interface FooterSettings {
|
||||
id: number;
|
||||
company_name: string;
|
||||
company_address: string;
|
||||
contact_phone: string;
|
||||
contact_email: string;
|
||||
copyright_text: string;
|
||||
icp_number: string;
|
||||
police_number: string;
|
||||
}
|
||||
|
||||
export interface SocialLink {
|
||||
id: number;
|
||||
platform: string;
|
||||
url: string;
|
||||
qrcode_url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface SeoDefaults {
|
||||
id: number;
|
||||
meta_description: string;
|
||||
meta_keywords: string;
|
||||
og_image_url: string;
|
||||
}
|
||||
|
||||
export interface GlobalConfig {
|
||||
site: SiteSettings | null;
|
||||
footer: FooterSettings | null;
|
||||
social: SocialLink[];
|
||||
seo: SeoDefaults | null;
|
||||
}
|
||||
|
||||
// ===== 导航 =====
|
||||
export type NavDisplayMode = 'text' | 'icon_text' | 'image' | 'image_text';
|
||||
|
||||
export interface NavItem {
|
||||
id: number;
|
||||
name_zh: string;
|
||||
name_en: string;
|
||||
link: string;
|
||||
is_visible: number;
|
||||
sort_order: number;
|
||||
display_mode: NavDisplayMode;
|
||||
icon_name: string;
|
||||
image_url: string;
|
||||
open_new_tab: number;
|
||||
}
|
||||
|
||||
export interface NavAppearance {
|
||||
id: number;
|
||||
homepage_style: 'transparent' | 'glass';
|
||||
non_homepage_style: 'transparent' | 'glass';
|
||||
}
|
||||
|
||||
export interface NavConfig {
|
||||
items: NavItem[];
|
||||
appearance: NavAppearance | null;
|
||||
}
|
||||
|
||||
// ===== Section 区块 =====
|
||||
export type SectionLayout =
|
||||
| 'hero_split'
|
||||
| 'full_banner'
|
||||
| 'feature_grid'
|
||||
| 'split_content'
|
||||
| 'gallery'
|
||||
| 'stats'
|
||||
| 'video_showcase'
|
||||
| 'scroll_narrative'
|
||||
| 'quote'
|
||||
| 'spec_table';
|
||||
|
||||
export interface SpecItem {
|
||||
id: number;
|
||||
spec_group_id: number;
|
||||
name_zh: string;
|
||||
name_en: string;
|
||||
value_zh: string;
|
||||
value_en: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface SpecGroup {
|
||||
id: number;
|
||||
section_block_id: number;
|
||||
title_zh: string;
|
||||
title_en: string;
|
||||
sort_order: number;
|
||||
default_visible: number;
|
||||
items: SpecItem[];
|
||||
}
|
||||
|
||||
// ===== Section config JSON 子结构(特性网格/数据统计/滚动叙事/图片画廊) =====
|
||||
export interface FeatureItem {
|
||||
icon: string;
|
||||
title_zh: string;
|
||||
title_en: string;
|
||||
desc_zh: string;
|
||||
desc_en: string;
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
value: string;
|
||||
unit: string;
|
||||
label_zh: string;
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
export interface NarrativeStep {
|
||||
title_zh: string;
|
||||
title_en: string;
|
||||
desc_zh: string;
|
||||
desc_en: string;
|
||||
image_url: string;
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
url: string;
|
||||
caption_zh: string;
|
||||
caption_en: string;
|
||||
}
|
||||
|
||||
export interface SectionConfig {
|
||||
features?: FeatureItem[];
|
||||
stats?: StatItem[];
|
||||
steps?: NarrativeStep[];
|
||||
images?: GalleryImage[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SectionBlock {
|
||||
id: number;
|
||||
page_type: string;
|
||||
product_id: number | null;
|
||||
layout: SectionLayout;
|
||||
theme: 'light' | 'dark';
|
||||
bg_type: 'color' | 'image' | 'video';
|
||||
bg_value: string;
|
||||
overlay_enabled: number;
|
||||
overlay_opacity: string;
|
||||
overline_zh: string;
|
||||
overline_en: string;
|
||||
title_zh: string;
|
||||
title_en: string;
|
||||
subtitle_zh: string;
|
||||
subtitle_en: string;
|
||||
content_zh: string | null;
|
||||
content_en: string | null;
|
||||
cta_primary_zh: string;
|
||||
cta_primary_en: string;
|
||||
cta_primary_url: string;
|
||||
cta_secondary_zh: string;
|
||||
cta_secondary_en: string;
|
||||
cta_secondary_url: string;
|
||||
media_url_zh: string;
|
||||
media_url_en: string;
|
||||
video_url: string;
|
||||
config: SectionConfig | null;
|
||||
is_visible: number;
|
||||
sort_order: number;
|
||||
spec_groups?: SpecGroup[];
|
||||
}
|
||||
|
||||
// ===== i18n 词条 =====
|
||||
export interface I18nEntry {
|
||||
id: number;
|
||||
dict_key: string;
|
||||
value_zh: string;
|
||||
value_en: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ===== 产品 =====
|
||||
export interface Product {
|
||||
id: number;
|
||||
series_id: number;
|
||||
name_zh: string;
|
||||
name_en: string;
|
||||
slug: string;
|
||||
intro_zh: string;
|
||||
intro_en: string;
|
||||
cover_url: string;
|
||||
sort_order: number;
|
||||
is_visible: number;
|
||||
}
|
||||
|
||||
export interface ProductSeries {
|
||||
id: number;
|
||||
name_zh: string;
|
||||
name_en: string;
|
||||
overline: string;
|
||||
subtitle_zh: string;
|
||||
subtitle_en: string;
|
||||
cover_url: string;
|
||||
sort_order: number;
|
||||
is_visible: number;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export interface ProductsData {
|
||||
series: ProductSeries[];
|
||||
total: number;
|
||||
}
|
||||
Reference in New Issue
Block a user