官网开发 0811
This commit is contained in:
@@ -90,19 +90,27 @@ const editorConfig = {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 深色主题适配(wangEditor 默认浅色) */
|
||||
:global(html.dark) .rich-editor,
|
||||
:global(html.dark) .rich-editor :deep(.w-e-text-container),
|
||||
:global(html.dark) .rich-editor :deep(.w-e-toolbar) {
|
||||
background-color: transparent;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
:global(html.dark) .rich-editor :deep(.w-e-bar-item button) {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
:global(html.dark) .rich-editor :deep(.w-e-text-container [data-slate-editor]) {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
/*
|
||||
* 深色主题适配:wangEditor 通过 CSS 变量(--w-e-*)控制全部内部元素颜色,
|
||||
* 覆盖变量即可一次性适配工具栏、编辑区、下拉菜单、模态框等所有子组件,
|
||||
* 无需逐元素覆写 background-color / color(旧方案漏掉了下拉菜单等内部元素)。
|
||||
*/
|
||||
:global(html.dark) .rich-editor {
|
||||
--w-e-textarea-bg-color: #18181c;
|
||||
--w-e-textarea-color: rgba(255, 255, 255, 0.82);
|
||||
--w-e-textarea-border-color: rgba(255, 255, 255, 0.12);
|
||||
--w-e-textarea-slight-border-color: rgba(255, 255, 255, 0.08);
|
||||
--w-e-textarea-slight-color: rgba(255, 255, 255, 0.4);
|
||||
--w-e-textarea-slight-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--w-e-textarea-selected-border-color: #1668dc;
|
||||
--w-e-textarea-handler-bg-color: #1668dc;
|
||||
--w-e-toolbar-color: rgba(255, 255, 255, 0.72);
|
||||
--w-e-toolbar-bg-color: #18181c;
|
||||
--w-e-toolbar-active-color: rgba(255, 255, 255, 0.95);
|
||||
--w-e-toolbar-active-bg-color: rgba(255, 255, 255, 0.1);
|
||||
--w-e-toolbar-disabled-color: rgba(255, 255, 255, 0.3);
|
||||
--w-e-toolbar-border-color: rgba(255, 255, 255, 0.1);
|
||||
--w-e-modal-button-bg-color: rgba(255, 255, 255, 0.08);
|
||||
--w-e-modal-button-border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,11 +47,9 @@ const layoutOptions: LayoutOption[] = [
|
||||
{ label: '图文分栏', value: 'split_content', desc: '图片与文案左右并排介绍' },
|
||||
{ label: '图片画廊', value: 'gallery', desc: '多张图片网格展示' },
|
||||
{ label: '数据统计', value: 'stats', desc: '大数字展示关键指标数据' },
|
||||
{ label: '视频展示', value: 'video_showcase', desc: '以视频为主的视觉区块' },
|
||||
{ label: '标题+媒体', value: 'title_media', desc: '顶部标题文案 + 下方图片/视频,对齐方向可配置' },
|
||||
{ label: '马赛克拼贴', value: 'mosaic', desc: '一大带多小的不规则网格,每个槽位支持图片/视频、中英双素材' },
|
||||
{ label: '滚动叙事', value: 'scroll_narrative', desc: '随滚动逐步展开的叙事内容' },
|
||||
{ label: '引言评价', value: 'quote', desc: '引用语 / 用户评价展示' },
|
||||
{ label: '跑马灯', value: 'marquee', desc: '图片/视频轮播,可自定义切换间隔,最多 5 个素材' },
|
||||
{ label: '参数规格表', value: 'spec_table', desc: '产品技术参数规格表格' }
|
||||
];
|
||||
|
||||
@@ -111,7 +109,7 @@ const formData = reactive({
|
||||
media_url_en: '',
|
||||
video_url: '',
|
||||
is_visible: true,
|
||||
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null } as Record<string, unknown>
|
||||
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), marquee_items: emptyMarqueeItems(1), marquee_interval: 3, min_height: null } as Record<string, unknown>
|
||||
});
|
||||
|
||||
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
|
||||
@@ -200,6 +198,43 @@ function handleMosaicPatternChange(value: string) {
|
||||
const mosaicSlotItems = computed<MosaicItemForm[]>(() => (formData.config.mosaic_items as MosaicItemForm[]) || []);
|
||||
const mosaicSlotLabelList = computed(() => mosaicSlotLabels(String(formData.config.mosaic_pattern ?? 'big_3')));
|
||||
|
||||
// ===== 跑马灯(layout=marquee):图片/视频轮播,存 config.marquee_items / config.marquee_interval =====
|
||||
interface MarqueeItemForm {
|
||||
type: 'image' | 'video';
|
||||
media_zh: string;
|
||||
media_en: string;
|
||||
/** 可选跳转链接 */
|
||||
link: string;
|
||||
}
|
||||
const MARQUEE_MAX = 5;
|
||||
|
||||
function emptyMarqueeItem(): MarqueeItemForm {
|
||||
return { type: 'image', media_zh: '', media_en: '', link: '' };
|
||||
}
|
||||
function emptyMarqueeItems(count: number): MarqueeItemForm[] {
|
||||
return Array.from({ length: count }, () => emptyMarqueeItem());
|
||||
}
|
||||
// 归一化回填:类型/字段容错,数量限制 1~5(空则给 1 个空槽位)
|
||||
function normalizeMarqueeItems(raw: unknown): MarqueeItemForm[] {
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
const list = arr.slice(0, MARQUEE_MAX).map((it): MarqueeItemForm => {
|
||||
const o = (it ?? {}) as Partial<MarqueeItemForm>;
|
||||
return { type: o.type === 'video' ? 'video' : 'image', media_zh: o.media_zh || '', media_en: o.media_en || '', link: o.link || '' };
|
||||
});
|
||||
return list.length ? list : emptyMarqueeItems(1);
|
||||
}
|
||||
function handleAddMarqueeItem() {
|
||||
const items = (formData.config.marquee_items as MarqueeItemForm[]) || [];
|
||||
if (items.length >= MARQUEE_MAX) return;
|
||||
formData.config.marquee_items = [...items, emptyMarqueeItem()];
|
||||
}
|
||||
function handleRemoveMarqueeItem(idx: number) {
|
||||
const items = (formData.config.marquee_items as MarqueeItemForm[]) || [];
|
||||
const next = items.filter((_, i) => i !== idx);
|
||||
formData.config.marquee_items = next.length ? next : emptyMarqueeItems(1);
|
||||
}
|
||||
const marqueeItems = computed<MarqueeItemForm[]>(() => (formData.config.marquee_items as MarqueeItemForm[]) || []);
|
||||
|
||||
// 九宫格文案位置(hero_split 专用)
|
||||
const textPositionOptions = [
|
||||
{ label: '左上', value: 'top_left' },
|
||||
@@ -334,7 +369,7 @@ function handleAdd() {
|
||||
formData.media_url_en = '';
|
||||
formData.video_url = '';
|
||||
formData.is_visible = true;
|
||||
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null };
|
||||
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), marquee_items: emptyMarqueeItems(1), marquee_interval: 3, min_height: null };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -381,9 +416,11 @@ function fillFormFromRow(row: Api.Www.SectionBlock) {
|
||||
media_position: cfg.media_position === 'right' ? 'right' : 'left',
|
||||
split_ratio: [30, 40, 50, 60, 70].includes(Number(cfg.split_ratio)) ? Number(cfg.split_ratio) : 50,
|
||||
min_height: typeof cfg.min_height === 'number' && cfg.min_height >= 0 ? cfg.min_height : null,
|
||||
mosaic_pattern: ['big_3', 'half_2', 'big_4', 'big_2'].includes(String(cfg.mosaic_pattern)) ? String(cfg.mosaic_pattern) : 'big_3'
|
||||
mosaic_pattern: ['big_3', 'half_2', 'big_4', 'big_2'].includes(String(cfg.mosaic_pattern)) ? String(cfg.mosaic_pattern) : 'big_3',
|
||||
marquee_interval: typeof cfg.marquee_interval === 'number' && cfg.marquee_interval >= 1 ? Math.min(cfg.marquee_interval, 60) : 3
|
||||
};
|
||||
formData.config.mosaic_items = normalizeMosaicItems(cfg.mosaic_items, String(formData.config.mosaic_pattern));
|
||||
formData.config.marquee_items = normalizeMarqueeItems(cfg.marquee_items);
|
||||
}
|
||||
|
||||
function handleEdit(row: Api.Www.SectionBlock) {
|
||||
@@ -581,6 +618,26 @@ onMounted(loadData);
|
||||
<span class="text-12px opacity-60">桌面端标题字号;移动端自动按约 62.5% 缩小(如填 48 → 移动端 30px)。留空 = 兜底 36px;引言布局无标题字段不受影响</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'mosaic'" :label="formData.layout === 'mosaic' ? '网格高度' : '最小高度'">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
:value="(formData.config.min_height as number | null) ?? null"
|
||||
:min="0"
|
||||
:max="1200"
|
||||
:step="20"
|
||||
class="w-full"
|
||||
clearable
|
||||
:placeholder="formData.layout === 'mosaic' ? '留空 = 默认 16:9 宽高比' : '留空 = 首屏高度(默认)'"
|
||||
@update:value="v => (formData.config.min_height = v)"
|
||||
>
|
||||
<template #suffix>px</template>
|
||||
</NInputNumber>
|
||||
<span class="text-12px opacity-60">
|
||||
<template v-if="formData.layout === 'mosaic'">仅桌面端生效。留空 = 默认 16:9 宽高比;填数值 = 网格固定高度(px),素材自动裁剪填充,可调低区块高度;移动端保持单列堆叠不变</template>
|
||||
<template v-else>仅主视觉分屏布局生效。留空 = 沿用首屏高度(至少 680px 约一屏);0 = 完全自适应内容(去上下内边距,区块高度严格等于内容高度);填数值 = 自定义最小高度</template>
|
||||
</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NDivider>文案颜色(留空=跟随主题默认色)</NDivider>
|
||||
<NFormItem label="Overline 颜色">
|
||||
<ColorInput v-model:value="formData.overline_color" />
|
||||
@@ -607,26 +664,6 @@ onMounted(loadData);
|
||||
<NRadio v-for="opt in bgFitOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'mosaic'" :label="formData.layout === 'mosaic' ? '网格高度' : '最小高度'">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
:value="(formData.config.min_height as number | null) ?? null"
|
||||
:min="0"
|
||||
:max="1200"
|
||||
:step="20"
|
||||
class="w-full"
|
||||
clearable
|
||||
:placeholder="formData.layout === 'mosaic' ? '留空 = 默认 16:9 宽高比' : '留空 = 首屏高度(默认)'"
|
||||
@update:value="v => (formData.config.min_height = v)"
|
||||
>
|
||||
<template #suffix>px</template>
|
||||
</NInputNumber>
|
||||
<span class="text-12px opacity-60">
|
||||
<template v-if="formData.layout === 'mosaic'">仅桌面端生效。留空 = 默认 16:9 宽高比;填数值 = 网格固定高度(px),素材自动裁剪填充,可调低区块高度;移动端保持单列堆叠不变</template>
|
||||
<template v-else>仅主视觉分屏布局生效。留空 = 沿用首屏高度(至少 680px 约一屏);0 = 完全自适应内容(去上下内边距,区块高度严格等于内容高度);填数值 = 自定义最小高度</template>
|
||||
</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'title_media'" label="文案位置">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<div class="grid grid-cols-3 gap-4px">
|
||||
@@ -739,6 +776,40 @@ onMounted(loadData);
|
||||
</div>
|
||||
</NFormItem>
|
||||
</template>
|
||||
<template v-else-if="formData.layout === 'marquee'">
|
||||
<NFormItem label="切换间隔">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
:value="(formData.config.marquee_interval as number) ?? 3"
|
||||
:min="1"
|
||||
:max="60"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:value="v => (formData.config.marquee_interval = v ?? 3)"
|
||||
>
|
||||
<template #suffix>秒</template>
|
||||
</NInputNumber>
|
||||
<span class="text-12px opacity-60">相邻素材自动切换的时间间隔(1~60 秒),默认 3 秒</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem v-for="(item, idx) in marqueeItems" :key="idx" :label="`素材 ${idx + 1}`">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<div class="flex items-center justify-between">
|
||||
<NRadioGroup v-model:value="item.type">
|
||||
<NRadio value="image">图片</NRadio>
|
||||
<NRadio value="video">视频</NRadio>
|
||||
</NRadioGroup>
|
||||
<NButton v-if="marqueeItems.length > 1" text type="error" size="tiny" @click="handleRemoveMarqueeItem(idx)">删除</NButton>
|
||||
</div>
|
||||
<MediaSelectInput v-model:value="item.media_zh" :file-type="item.type === 'video' ? 'video' : 'image'" :maxlength="500" placeholder="素材(中)" />
|
||||
<MediaSelectInput v-model:value="item.media_en" :file-type="item.type === 'video' ? 'video' : 'image'" :maxlength="500" placeholder="素材(英,留空回退中文素材)" />
|
||||
<NInput v-model:value="item.link" :maxlength="500" placeholder="跳转链接(可选,点击素材后新窗口打开)" />
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="操作">
|
||||
<NButton :disabled="marqueeItems.length >= 5" @click="handleAddMarqueeItem">+ 添加素材({{ marqueeItems.length }}/5)</NButton>
|
||||
</NFormItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NFormItem label="图片(中)">
|
||||
<MediaSelectInput v-model:value="formData.media_url_zh" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
|
||||
|
||||
@@ -17,7 +17,11 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* 正文/UI:英文走 Inter,中文走 Noto Sans SC(已按 unicode-range 拆分按需下载),
|
||||
未加载完成时回退系统字体(macOS PingFang / Windows 微软雅黑),font-display: swap 不阻塞渲染 */
|
||||
font-family:
|
||||
'Inter',
|
||||
'Noto Sans SC',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
@@ -30,6 +34,13 @@ body {
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* 品牌大标题字体:英文走 Space Grotesk,中文走 Noto Sans SC(标题元素 font-weight 多为 700,
|
||||
复用正文已加载的 700 字重 CJK 子集,零额外体积)。富文本内标题由 .rich-text 规则还原为正文字体,保证可读性。 */
|
||||
h1,
|
||||
section h2 {
|
||||
font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
|
||||
/* 链接统一下划线重置,避免浏览器默认样式 */
|
||||
a {
|
||||
text-decoration: none;
|
||||
@@ -73,6 +84,8 @@ section h2 {
|
||||
margin: 0.6em 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
/* 还原为正文字体,覆盖 h1/section h2 的标题字体(选择器优先级更高) */
|
||||
font-family: 'Inter', 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
.rich-text h1 {
|
||||
font-size: 1.6em;
|
||||
|
||||
@@ -8,11 +8,9 @@ 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 LayoutTitleMedia from './layouts/LayoutTitleMedia.vue';
|
||||
import LayoutMosaic from './layouts/LayoutMosaic.vue';
|
||||
import LayoutScrollNarrative from './layouts/LayoutScrollNarrative.vue';
|
||||
import LayoutQuote from './layouts/LayoutQuote.vue';
|
||||
import LayoutMarquee from './layouts/LayoutMarquee.vue';
|
||||
import LayoutSpecTable from './layouts/LayoutSpecTable.vue';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
@@ -25,11 +23,9 @@ const layoutComponents: Record<SectionLayout, unknown> = {
|
||||
split_content: LayoutSplitContent,
|
||||
gallery: LayoutGallery,
|
||||
stats: LayoutStats,
|
||||
video_showcase: LayoutVideoShowcase,
|
||||
title_media: LayoutTitleMedia,
|
||||
mosaic: LayoutMosaic,
|
||||
scroll_narrative: LayoutScrollNarrative,
|
||||
quote: LayoutQuote,
|
||||
marquee: LayoutMarquee,
|
||||
spec_table: LayoutSpecTable
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
import { resolveAssetUrl } from '~/utils/resolveAssetUrl';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
|
||||
|
||||
// 轮播素材(config.marquee_items):media_zh/_en 以语言后缀结尾,不命中数据层 resolveAssetUrls,需手动解析相对路径;最多 5 个
|
||||
const items = computed(() => {
|
||||
const raw = (config.value.marquee_items ?? []) as Array<{ type?: string; media_zh?: string; media_en?: string; link?: string }>;
|
||||
return raw
|
||||
.slice(0, 5)
|
||||
.map(it => {
|
||||
const src = (isEn.value ? it.media_en || it.media_zh : it.media_zh || it.media_en) || '';
|
||||
return { type: it.type === 'video' ? 'video' : 'image', url: resolveAssetUrl(src), link: (it.link || '').trim() };
|
||||
})
|
||||
.filter(it => !!it.url);
|
||||
});
|
||||
|
||||
// 点击素材:若配置了跳转链接则新窗口打开
|
||||
function handleItemClick(item: { link?: string }) {
|
||||
if (item.link) window.open(item.link, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
// 切换间隔(config.marquee_interval,秒):默认 3s,限制 1-60
|
||||
const interval = computed(() => {
|
||||
const v = Number(config.value.marquee_interval);
|
||||
return Number.isFinite(v) && v >= 1 ? Math.min(v, 60) : 3;
|
||||
});
|
||||
|
||||
const current = ref(0);
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
// 素材/间隔变化时重启计时器,并收敛下标防越界
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
if (current.value >= items.value.length) current.value = 0;
|
||||
if (items.value.length < 2) return;
|
||||
timer = setInterval(() => {
|
||||
current.value = (current.value + 1) % items.value.length;
|
||||
}, interval.value * 1000);
|
||||
}
|
||||
watch([items, interval], startTimer, { immediate: true });
|
||||
onBeforeUnmount(stopTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-[var(--sec-py,96px)]">
|
||||
<!-- 标题区 -->
|
||||
<div v-if="overline || title || subtitle" 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 }}
|
||||
</p>
|
||||
<h2 class="mt-12px font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<!-- 轮播容器:淡入淡出切换 -->
|
||||
<div v-if="items.length" class="relative overflow-hidden rounded-16px bg-gray-100 dark:bg-gray-800 aspect-16/9">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="absolute inset-0 transition-opacity duration-500"
|
||||
:class="[idx === current ? 'opacity-100' : 'opacity-0 pointer-events-none', item.link ? 'cursor-pointer' : '']"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<video v-if="item.type === 'video'" :src="item.url" class="w-full h-full object-cover" autoplay muted loop playsinline preload="metadata" />
|
||||
<img v-else :src="item.url" :alt="title" class="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- 指示点(可点击切换) -->
|
||||
<div v-if="items.length > 1" class="absolute bottom-16px left-0 right-0 flex justify-center gap-8px">
|
||||
<button
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="h-8px rounded-full transition-all"
|
||||
:class="idx === current ? 'w-24px bg-white' : 'w-8px bg-white/50'"
|
||||
@click="current = idx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
// 引言评价:正文 content 作为引用语,subtitle 作为作者/出处
|
||||
const { content, subtitle, isDark, titleStyle, bodyStyle } = useSectionContent(props.section);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-[var(--sec-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="rich-text mt-8px text-2xl md:text-3xl font-medium leading-relaxed"
|
||||
:class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'"
|
||||
:style="titleStyle"
|
||||
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'" :style="bodyStyle">
|
||||
—— {{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
|
||||
|
||||
const steps = computed(() => config.value.steps ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-[var(--sec-py,96px)]">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-64px">
|
||||
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<!-- 步骤叙事 -->
|
||||
<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'" :style="titleStyle">
|
||||
{{ 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'" :style="bodyStyle">
|
||||
{{ 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>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, isDark, overlineStyle, titleStyle, bodyStyle } = 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-[var(--sec-py,96px)]">
|
||||
<!-- 标题区 -->
|
||||
<div class="text-center max-w-640px mx-auto mb-48px">
|
||||
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<!-- 视频 -->
|
||||
<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>
|
||||
+13
-3
@@ -83,11 +83,9 @@ export type SectionLayout =
|
||||
| 'split_content'
|
||||
| 'gallery'
|
||||
| 'stats'
|
||||
| 'video_showcase'
|
||||
| 'title_media'
|
||||
| 'mosaic'
|
||||
| 'scroll_narrative'
|
||||
| 'quote'
|
||||
| 'marquee'
|
||||
| 'spec_table';
|
||||
|
||||
export interface SpecItem {
|
||||
@@ -147,12 +145,24 @@ export interface MosaicItem {
|
||||
media_en: string;
|
||||
}
|
||||
|
||||
// 跑马灯布局轮播素材(config.marquee_items):图片/视频,最多 5 个
|
||||
export interface MarqueeItem {
|
||||
type: 'image' | 'video';
|
||||
media_zh: string;
|
||||
media_en: string;
|
||||
/** 可选跳转链接 */
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface SectionConfig {
|
||||
features?: FeatureItem[];
|
||||
stats?: StatItem[];
|
||||
steps?: NarrativeStep[];
|
||||
images?: GalleryImage[];
|
||||
mosaic_items?: MosaicItem[];
|
||||
marquee_items?: MarqueeItem[];
|
||||
/** 跑马灯切换间隔(秒) */
|
||||
marquee_interval?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -4,7 +4,17 @@ export default defineNuxtConfig({
|
||||
|
||||
modules: ['@unocss/nuxt', '@nuxtjs/i18n', '@pinia/nuxt', '@vueuse/nuxt'],
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
css: [
|
||||
// Web 字体(fontsource,font-display: swap;CJK 已按 unicode-range 拆分,浏览器按页面字形按需下载)
|
||||
// 正文/UI:Inter(英)+ Noto Sans SC(中);品牌大标题:Space Grotesk(英)+ Noto Sans SC 700(中)
|
||||
'@fontsource/inter/400.css',
|
||||
'@fontsource/inter/500.css',
|
||||
'@fontsource/inter/700.css',
|
||||
'@fontsource/noto-sans-sc/400.css',
|
||||
'@fontsource/noto-sans-sc/700.css',
|
||||
'@fontsource/space-grotesk/700.css',
|
||||
'~/assets/css/main.css',
|
||||
],
|
||||
|
||||
// 生产构建(nuxt build/generate,NODE_ENV=production)走同源相对路径 /api,由 nginx 反代到 backend;
|
||||
// 开发(pnpm dev)直连本地后端。显式传入的 NUXT_PUBLIC_* 环境变量仍可覆盖。
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/noto-sans-sc": "^5.3.0",
|
||||
"@fontsource/space-grotesk": "^5.3.0",
|
||||
"@iconify/vue": "^5.0.1",
|
||||
"@nuxtjs/i18n": "^10.5.0",
|
||||
"@pinia/nuxt": "^1.0.1",
|
||||
|
||||
Generated
+24
@@ -8,6 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fontsource/inter':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@fontsource/noto-sans-sc':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@fontsource/space-grotesk':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@iconify/vue':
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(vue@3.5.40(typescript@5.8.2))
|
||||
@@ -599,6 +608,15 @@ packages:
|
||||
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@fontsource/inter@5.3.0':
|
||||
resolution: {integrity: sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==}
|
||||
|
||||
'@fontsource/noto-sans-sc@5.3.0':
|
||||
resolution: {integrity: sha512-HeqIlGm0+ohOKxZLuHj1qW6r6avHH0OWdKERAcSDI0RQ+MXrteuLKA+M+5eOA8rYy0MFvOR5AT0fQo2rUkye0Q==}
|
||||
|
||||
'@fontsource/space-grotesk@5.3.0':
|
||||
resolution: {integrity: sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -5205,6 +5223,12 @@ snapshots:
|
||||
'@eslint/core': 1.2.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@fontsource/inter@5.3.0': {}
|
||||
|
||||
'@fontsource/noto-sans-sc@5.3.0': {}
|
||||
|
||||
'@fontsource/space-grotesk@5.3.0': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
|
||||
Reference in New Issue
Block a user