Files
admin/www/app/components/section/SectionRenderer.vue
T
2026-08-17 14:58:02 +08:00

155 lines
6.2 KiB
Vue
Raw 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.
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import type { SectionBlock, SectionLayout } from '~/types/site';
import { resolveAssetUrl } from '~/utils/resolveAssetUrl';
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 LayoutTitleMedia from './layouts/LayoutTitleMedia.vue';
import LayoutMosaic from './layouts/LayoutMosaic.vue';
import LayoutMarquee from './layouts/LayoutMarquee.vue';
import LayoutSpecTable from './layouts/LayoutSpecTable.vue';
import LayoutModel3d from './layouts/LayoutModel3d.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,
title_media: LayoutTitleMedia,
mosaic: LayoutMosaic,
marquee: LayoutMarquee,
spec_table: LayoutSpecTable,
model3d: LayoutModel3d
};
const layoutComponent = computed(() => layoutComponents[props.section.layout] ?? null);
const isDark = computed(() => props.section.theme === 'dark');
// 背景样式
const bgStyle = computed(() => {
const { bg_type, bg_value } = props.section;
if (bg_type === 'color' && bg_value) {
return { backgroundColor: bg_value };
}
// 渐变:bg_value 为完整 CSS 渐变函数(如 linear-gradient(...));仅在形如渐变函数时应用,避免把普通字符串当背景
if (bg_type === 'gradient' && bg_value && bg_value.includes('gradient(')) {
return { background: 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);
// 背景图/视频资源地址(支持 /uploads/... 相对路径)
const bgMediaUrl = computed(() => resolveAssetUrl(props.section.bg_value));
// 区块宽度百分比(config.width_percent):占视口宽度比例并居中,
// 未配置或 100 时全宽;md 以下始终全宽(见模板 class)
// 内容区最大宽度(config.container_width):'1200'(默认)/ '1400' / '1600' / 'full'
// 通过 --sec-cw 下发给 section-container shortcut(见 uno.config.ts
// 区块圆角(config.border_radius):单位 px,未配置或 0 为直角(默认);
// 外层 section 已有 overflow-hidden,背景图/视频自动被圆角裁剪
const borderRadius = computed(() => {
const r = Number(props.section.config?.border_radius);
return r > 0 ? `${r}px` : '0px';
});
// 区块垂直内边距(config.padding_y):单位 px,未设置时不下发变量(各布局用 var 回退自身默认值),0 = 完全贴边
const paddingY = computed(() => {
const v = props.section.config?.padding_y;
return typeof v === 'number' && v >= 0 ? `${v}px` : '';
});
// 区块上下外边距(config.margin_y):单位 px,区块与相邻区块的间隙(露出页面背景);
// 未设置或 0 = 紧贴相邻区块(默认,全宽无缝拼接设计)
const marginY = computed(() => {
const v = props.section.config?.margin_y;
return typeof v === 'number' && v > 0 ? `${v}px` : '';
});
// 标题字号(config.title_font_size):桌面端 px,未设置时不下发变量(main.css 的 section h2 规则兜底 36px);
// 移动端按桌面值 62.5% 等比缩小(--sec-title-fs-m
const titleFontSize = computed(() => {
const v = props.section.config?.title_font_size;
if (typeof v !== 'number' || v <= 0) return null;
return { desktop: `${v}px`, mobile: `${Math.round(v * 0.625)}px` };
});
const sectionStyleVars = computed<CSSProperties>(() => {
const w = Number(props.section.config?.width_percent);
const cw = props.section.config?.container_width;
const containerWidth = cw === 'full' ? '100%' : Number(cw) > 0 ? `${cw}px` : '1200px';
return {
'--sec-w': w > 0 && w < 100 ? `${w}%` : '100%',
'--sec-cw': containerWidth,
borderRadius: borderRadius.value,
...(paddingY.value ? { '--sec-py': paddingY.value } : {}),
...(marginY.value ? { marginTop: marginY.value, marginBottom: marginY.value } : {}),
...(titleFontSize.value ? { '--sec-title-fs': titleFontSize.value.desktop, '--sec-title-fs-m': titleFontSize.value.mobile } : {})
} as CSSProperties;
});
// 背景图/视频显示模式(config.bg_fit):cover 铺满(默认)/ contain 完整显示 / auto 原尺寸
const bgFit = computed<'cover' | 'contain' | 'auto'>(() => {
const f = props.section.config?.bg_fit;
return f === 'contain' || f === 'auto' ? f : 'cover';
});
const bgImageClass = computed(() =>
({
cover: 'absolute inset-0 bg-cover bg-center',
contain: 'absolute inset-0 bg-contain bg-center bg-no-repeat',
auto: 'absolute inset-0 bg-auto bg-center bg-no-repeat'
})[bgFit.value]
);
const bgVideoClass = computed(() =>
({
cover: 'absolute inset-0 w-full h-full object-cover',
contain: 'absolute inset-0 w-full h-full object-contain',
auto: 'absolute inset-0 w-full h-full object-none'
})[bgFit.value]
);
</script>
<template>
<section
class="relative overflow-hidden md:w-[var(--sec-w)] md:mx-auto"
:class="isDark ? 'bg-gray-950 text-white' : 'bg-white text-gray-900 dark:bg-dark-900 dark:text-white'"
:style="[bgStyle, sectionStyleVars]"
>
<!-- 背景图显示模式由 config.bg_fit 控制默认铺满 -->
<div
v-if="hasBgImage"
:class="bgImageClass"
:style="{ backgroundImage: `url(${bgMediaUrl})` }"
/>
<!-- 背景视频显示模式由 config.bg_fit 控制默认铺满 -->
<video
v-if="hasBgVideo"
:src="bgMediaUrl"
:class="bgVideoClass"
autoplay
muted
loop
playsinline
/>
<!-- 布局内容 -->
<div class="relative z-10">
<component :is="layoutComponent" v-if="layoutComponent" :section="section" />
</div>
</section>
</template>