官网开发 0810
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user