官网开发 0810

This commit is contained in:
eafonyang
2026-08-10 14:56:44 +08:00
parent c9a1d9c576
commit 48510739a7
20 changed files with 636 additions and 53 deletions
@@ -72,6 +72,13 @@ const paddingY = computed(() => {
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(() => {
@@ -89,6 +96,7 @@ const sectionStyleVars = computed<CSSProperties>(() => {
'--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;
});
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import type { SectionBlock, SpecGroup } from '~/types/site';
const props = defineProps<{ section: SectionBlock }>();
const { overline, title, subtitle, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
const { overline, title, subtitle, content, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
const specGroups = computed<SpecGroup[]>(() => props.section.spec_groups ?? []);
// 每个分组的展开状态(默认收起,仅显示前 default_visible
// 每个分组的展开状态(默认收起,仅显示前 default_visible
const expandedMap = ref<Record<number, boolean>>({});
function isExpanded(group: SpecGroup): boolean {
@@ -18,10 +19,10 @@ function toggle(group: SpecGroup) {
expandedMap.value[group.id] = !expandedMap.value[group.id];
}
// 分组内可见的参数项:展开时全部,收起时前 default_visible 项
function visibleItems(group: SpecGroup) {
// 固定显示的参数项:始终为前 default_visible 项(默认 5 行),
// 其余项由下方 Transition 区域渲染,避免展开时重复
function fixedItems(group: SpecGroup) {
const items = group.items ?? [];
if (isExpanded(group)) return items;
return items.slice(0, group.default_visible || 5);
}
@@ -29,11 +30,44 @@ function visibleItems(group: SpecGroup) {
function needToggle(group: SpecGroup): boolean {
return (group.items?.length ?? 0) > (group.default_visible || 5);
}
// 展开/收起高度过渡:从 0 动画到内容自然高度
// 注意:钩子必须声明并调用 done 回调(Vue 按形参个数判断是否显式接管完成时机),
// 否则 leave 结束后元素不会被移除,导致再次展开失效
const SPEC_TOGGLE_MS = 300;
function onEnter(el: Element, done: () => void) {
const html = el as HTMLElement;
html.style.overflow = 'hidden';
html.style.height = '0';
// 强制 reflow 后再切到目标高度,确保过渡生效
void html.offsetHeight;
html.style.transition = `height ${SPEC_TOGGLE_MS}ms ease`;
html.style.height = `${html.scrollHeight}px`;
setTimeout(done, SPEC_TOGGLE_MS);
}
function onAfterEnter(el: Element) {
const html = el as HTMLElement;
html.style.overflow = '';
html.style.height = '';
html.style.transition = '';
}
function onLeave(el: Element, done: () => void) {
const html = el as HTMLElement;
html.style.overflow = 'hidden';
html.style.height = `${html.scrollHeight}px`;
void html.offsetHeight;
html.style.transition = `height ${SPEC_TOGGLE_MS}ms ease`;
html.style.height = '0';
setTimeout(done, SPEC_TOGGLE_MS);
}
</script>
<template>
<div class="section-container py-[var(--sec-py,96px)]">
<!-- 标题 -->
<!-- 块标题整个区块顶部居中 -->
<div class="text-center max-w-640px mx-auto mb-56px">
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
{{ overline }}
@@ -44,49 +78,84 @@ function needToggle(group: SpecGroup): boolean {
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle">
{{ subtitle }}
</p>
<div v-if="content" class="rich-text mt-20px text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
</div>
<!-- 参数分组卡片 -->
<div class="flex flex-col gap-24px max-w-960px mx-auto">
<!-- 参数分组左分组标题 + 右参数卡片 -->
<div class="flex flex-col gap-64px">
<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'"
class="md:grid md:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-64px items-start"
>
<!-- 分组标题 -->
<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'" :style="titleStyle">
<!-- 左栏分组标题桌面端吸顶 -->
<div class="mb-24px md:sticky md:top-120px md:mb-0">
<h3 class="text-xl md:text-24px font-bold" :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>
<!-- 右栏参数卡片2 参数名 / 参数值 -->
<div
class="rounded-16px overflow-hidden"
:class="isDark ? 'bg-white/5 border border-white/10' : 'bg-gray-100 border border-gray-200 dark:bg-dark-800 dark:border-dark-700'"
>
<div class="px-24px md:px-32px py-16px">
<div
v-for="(item, idx) in fixedItems(group)"
:key="item.id"
class="flex items-baseline justify-between gap-24px py-16px border-b"
:class="[isDark ? 'border-white/10' : 'border-gray-300 dark:border-dark-600', idx === fixedItems(group).length - 1 && (!needToggle(group) || !isExpanded(group)) ? 'border-b-0' : '']"
>
<span class="text-sm font-semibold w-20% shrink-0" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
{{ isEn ? item.name_en : item.name_zh }}
</span>
<span class="flex-1 text-sm text-left" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
{{ isEn ? item.value_en : item.value_zh }}
</span>
</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>
<!-- 收起时隐藏的参数项高度过渡动画 -->
<Transition @enter="onEnter" @after-enter="onAfterEnter" @leave="onLeave">
<div v-if="isExpanded(group)">
<div
v-for="item in group.items?.slice(group.default_visible || 5)"
:key="item.id"
class="flex items-baseline justify-between gap-24px py-16px border-b"
:class="[isDark ? 'border-white/10' : 'border-gray-300 dark:border-dark-600', item.id === group.items?.at(-1)?.id ? 'border-b-0' : '']"
>
<span class="text-sm font-semibold w-20% shrink-0" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
{{ isEn ? item.name_en : item.name_zh }}
</span>
<span class="flex-1 text-sm text-left" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
{{ isEn ? item.value_en : item.value_zh }}
</span>
</div>
</div>
</Transition>
</div>
<!-- 展开/收起按钮 -->
<div v-if="needToggle(group)" class="px-24px md:px-32px py-16px flex justify-center">
<button
class="text-sm font-medium inline-flex items-center gap-8px bg-transparent cursor-pointer transition-colors"
:class="isDark ? 'text-white' : 'text-gray-600 dark:text-white'"
@click="toggle(group)"
>
<template v-if="isExpanded(group)">
{{ isEn ? 'View less' : '收起参数' }}
<span class="w-24px h-24px rounded-full flex items-center justify-center text-14px" :class="isDark ? 'bg-white/10 text-white' : 'bg-gray-200 text-gray-700'" aria-hidden="true">
<Icon icon="mingcute:up-line" />
</span>
</template>
<template v-else>
{{ isEn ? `View all (${group.items?.length ?? 0})` : `查看全部参数${group.items?.length ?? 0} ` }}
<span class="w-24px h-24px rounded-full flex items-center justify-center text-14px" :class="isDark ? 'bg-white/10 text-white' : 'bg-gray-200 text-gray-700'" aria-hidden="true">
<Icon icon="mingcute:down-line" />
</span>
</template>
</button>
</div>
</div>
</div>
</div>
@@ -12,6 +12,19 @@ const videoUrl = computed(() => resolveAssetUrl(props.section.video_url));
// 媒体位置(config.media_position):left 图/视频在左(默认)/ right 在右;移动端始终文案在上
const mediaPosition = computed(() => (props.section.config as Record<string, string> | null)?.media_position === 'right' ? 'right' : 'left');
// 分栏比例(config.split_ratio):媒体栏宽占比,档位 30/40/50/60/70,默认 50(五五等分);移动端单列不受影响
const splitRatio = computed(() => {
const v = Number((props.section.config as Record<string, unknown> | null)?.split_ratio);
return [30, 40, 50, 60, 70].includes(v) ? v : 50;
});
// 栅格轨道随媒体位置:媒体在左 = 首轨为媒体栏,媒体在右 = 次轨为媒体栏(完整字面量供 UnoCSS 提取)
const gridClass = computed(() =>
mediaPosition.value === 'right'
? 'md:grid-cols-[minmax(0,1fr)_var(--split-media,50%)]'
: 'md:grid-cols-[var(--split-media,50%)_minmax(0,1fr)]'
);
// 媒体填满所在栏(去掉 520px 上限与栏内居中),高度按原素材比例自适应
const mediaWrapClass = computed(() =>
mediaPosition.value === 'right'
@@ -27,7 +40,11 @@ const textWrapClass = computed(() =>
</script>
<template>
<div class="section-container py-[var(--sec-py,96px)] grid md:grid-cols-2 gap-48px items-center">
<div
class="section-container py-[var(--sec-py,96px)] grid gap-48px items-center"
:class="gridClass"
:style="{ '--split-media': `${splitRatio}%` }"
>
<!-- 媒体视频优先其次图片 -->
<div v-if="videoUrl || mediaUrl" :class="mediaWrapClass">
<video