官网开发 0805
This commit is contained in:
@@ -52,6 +52,8 @@
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.20",
|
||||
"defu": "6.1.7",
|
||||
|
||||
Generated
+688
-165
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ packages:
|
||||
- 'packages/*'
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
es5-ext: false
|
||||
esbuild: false
|
||||
simple-git-hooks: false
|
||||
vue-demi: false
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||
import type { IDomEditor, IToolbarConfig } from '@wangeditor/editor';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
|
||||
/**
|
||||
* 富文本编辑器(wangEditor)
|
||||
*
|
||||
* 用于官网区块正文编辑:支持加粗/字号/颜色/缩进/列表/对齐等,输出 HTML,
|
||||
* 与 www 端 v-html 渲染配合使用(样式还原见 www main.css 的 .rich-text)。
|
||||
*/
|
||||
defineOptions({ name: 'RichTextEditor' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
/** 编辑区高度,默认 180px */
|
||||
height?: number;
|
||||
}>(),
|
||||
{ placeholder: '请输入正文', height: 180 }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:value': [value: string] }>();
|
||||
|
||||
const editorRef = shallowRef<IDomEditor>();
|
||||
const valueHtml = ref(props.value || '');
|
||||
|
||||
// 外部值变化(切换区块/重置表单)时同步进编辑器,避免覆盖用户正在编辑的内容
|
||||
watch(
|
||||
() => props.value,
|
||||
v => {
|
||||
const next = v || '';
|
||||
if (next !== valueHtml.value) {
|
||||
valueHtml.value = next;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleChange(editor: IDomEditor) {
|
||||
const html = editor.getHtml();
|
||||
// 空内容时 wangEditor 输出 '<p><br></p>',归一化为空串
|
||||
const normalized = html === '<p><br></p>' ? '' : html;
|
||||
valueHtml.value = normalized;
|
||||
emit('update:value', normalized);
|
||||
}
|
||||
|
||||
function handleCreated(editor: IDomEditor) {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
|
||||
// 工具栏:剔除视频/上传图/表格/代码等重功能,保留文字排版相关
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
excludeKeys: ['group-video', 'uploadImage', 'insertTable', 'code', 'codeBlock', 'emotion', 'todo', 'fullScreen']
|
||||
};
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder,
|
||||
autoFocus: false,
|
||||
scroll: false
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rich-editor w-full border-1px border-solid rounded-8px overflow-hidden">
|
||||
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default" class="rich-editor-toolbar" />
|
||||
<Editor
|
||||
v-model="valueHtml"
|
||||
:default-config="editorConfig"
|
||||
mode="default"
|
||||
class="rich-editor-body"
|
||||
:style="{ height: `${height}px`, overflowY: 'auto' }"
|
||||
@on-created="handleCreated"
|
||||
@on-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rich-editor {
|
||||
border-color: var(--n-border-color, #e0e0e6);
|
||||
}
|
||||
|
||||
.rich-editor-body {
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, type SelectOption, type SelectRenderOption } from 'naive-ui';
|
||||
import ColorInput from '../page/home/components/ColorInput.vue';
|
||||
import MediaSelectInput from './MediaSelectInput.vue';
|
||||
import RichTextEditor from './RichTextEditor.vue';
|
||||
import {
|
||||
fetchCreateSection,
|
||||
fetchDeleteSection,
|
||||
@@ -46,6 +47,8 @@ const layoutOptions: LayoutOption[] = [
|
||||
{ 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: 'spec_table', desc: '产品技术参数规格表格' }
|
||||
@@ -107,7 +110,7 @@ const formData = reactive({
|
||||
media_url_en: '',
|
||||
video_url: '',
|
||||
is_visible: true,
|
||||
config: { text_position: 'middle_left', width_percent: 100, bg_fit: 'cover', media_position: 'left' } as Record<string, unknown>
|
||||
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null } as Record<string, unknown>
|
||||
});
|
||||
|
||||
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
|
||||
@@ -117,6 +120,71 @@ const bgFitOptions = [
|
||||
{ label: '原尺寸', value: 'auto' }
|
||||
];
|
||||
|
||||
// 内容区最大宽度选项(区块内容容器上限,默认 1200px)
|
||||
const containerWidthOptions = [
|
||||
{ label: '1200px(默认)', value: '1200' },
|
||||
{ label: '1400px', value: '1400' },
|
||||
{ label: '1600px', value: '1600' },
|
||||
{ label: '全宽', value: 'full' }
|
||||
];
|
||||
|
||||
// ===== 马赛克拼贴(layout=mosaic):固定版式 + 槽位素材,存 config.mosaic_pattern / config.mosaic_items =====
|
||||
interface MosaicItemForm {
|
||||
type: 'image' | 'video';
|
||||
media_zh: string;
|
||||
media_en: string;
|
||||
}
|
||||
|
||||
const mosaicPatternOptions = [
|
||||
{ label: '1 大 + 3 小', value: 'big_3', count: 4 },
|
||||
{ label: '1 大 + 1 大(左右等分)', value: 'half_2', count: 2 },
|
||||
{ label: '1 大 + 4 小', value: 'big_4', count: 5 },
|
||||
{ label: '1 大 + 2 小(上下等分)', value: 'big_2', count: 3 }
|
||||
];
|
||||
|
||||
function mosaicSlotCount(pattern: string) {
|
||||
return mosaicPatternOptions.find(o => o.value === pattern)?.count ?? 4;
|
||||
}
|
||||
|
||||
// 各版式槽位说明(按顺序对应素材编辑槽位与 www 端网格位置)
|
||||
function mosaicSlotLabels(pattern: string): string[] {
|
||||
return (
|
||||
{
|
||||
big_3: ['大图', '小图 1', '小图 2', '宽图'],
|
||||
half_2: ['左侧', '右侧'],
|
||||
big_4: ['大图', '小图 1', '小图 2', '小图 3', '小图 4'],
|
||||
big_2: ['大图', '上图', '下图']
|
||||
} as Record<string, string[]>
|
||||
)[pattern] || [];
|
||||
}
|
||||
|
||||
function emptyMosaicItems(count: number): MosaicItemForm[] {
|
||||
return Array.from({ length: count }, () => ({ type: 'image', media_zh: '', media_en: '' }));
|
||||
}
|
||||
|
||||
// 归一化回填:类型/字段容错,数量与版式槽位数对齐(保留已填素材)
|
||||
function normalizeMosaicItems(raw: unknown, pattern: string): MosaicItemForm[] {
|
||||
const count = mosaicSlotCount(pattern);
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const it = (arr[i] ?? {}) as Partial<MosaicItemForm>;
|
||||
return {
|
||||
type: it.type === 'video' ? 'video' : 'image',
|
||||
media_zh: it.media_zh || '',
|
||||
media_en: it.media_en || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 切换版式时同步槽位数量(保留已有素材)
|
||||
function handleMosaicPatternChange(value: string) {
|
||||
formData.config.mosaic_pattern = value;
|
||||
formData.config.mosaic_items = normalizeMosaicItems(formData.config.mosaic_items, value);
|
||||
}
|
||||
|
||||
const mosaicSlotItems = computed<MosaicItemForm[]>(() => (formData.config.mosaic_items as MosaicItemForm[]) || []);
|
||||
const mosaicSlotLabelList = computed(() => mosaicSlotLabels(String(formData.config.mosaic_pattern ?? 'big_3')));
|
||||
|
||||
// 九宫格文案位置(hero_split 专用)
|
||||
const textPositionOptions = [
|
||||
{ label: '左上', value: 'top_left' },
|
||||
@@ -179,11 +247,12 @@ const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
render(row) {
|
||||
return h('div', { class: 'flex-y-center gap-8px' }, [
|
||||
h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', onClick: () => handleCopy(row) }, { default: () => '复制' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' })
|
||||
]);
|
||||
}
|
||||
@@ -246,13 +315,12 @@ function handleAdd() {
|
||||
formData.media_url_en = '';
|
||||
formData.video_url = '';
|
||||
formData.is_visible = true;
|
||||
formData.config = { text_position: 'middle_left', width_percent: 100, bg_fit: 'cover', media_position: 'left' };
|
||||
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: Api.Www.SectionBlock) {
|
||||
dialogTitle.value = '编辑 Section';
|
||||
formData.id = row.id;
|
||||
// 用指定行的数据填充表单(编辑/复制共用)
|
||||
function fillFormFromRow(row: Api.Www.SectionBlock) {
|
||||
formData.layout = row.layout;
|
||||
formData.theme = row.theme;
|
||||
formData.overline_color = row.overline_color || '';
|
||||
@@ -285,9 +353,30 @@ function handleEdit(row: Api.Www.SectionBlock) {
|
||||
...cfg,
|
||||
text_position: cfg.text_position || 'middle_left',
|
||||
width_percent: typeof cfg.width_percent === 'number' ? cfg.width_percent : 100,
|
||||
container_width: ['1400', '1600', 'full'].includes(String(cfg.container_width)) ? String(cfg.container_width) : '1200',
|
||||
border_radius: typeof cfg.border_radius === 'number' && cfg.border_radius > 0 ? cfg.border_radius : 0,
|
||||
padding_y: typeof cfg.padding_y === 'number' && cfg.padding_y >= 0 ? cfg.padding_y : null,
|
||||
title_font_size: typeof cfg.title_font_size === 'number' && cfg.title_font_size > 0 ? cfg.title_font_size : null,
|
||||
bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover',
|
||||
media_position: cfg.media_position === 'right' ? 'right' : 'left'
|
||||
media_position: cfg.media_position === 'right' ? 'right' : 'left',
|
||||
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'
|
||||
};
|
||||
formData.config.mosaic_items = normalizeMosaicItems(cfg.mosaic_items, String(formData.config.mosaic_pattern));
|
||||
}
|
||||
|
||||
function handleEdit(row: Api.Www.SectionBlock) {
|
||||
dialogTitle.value = '编辑 Section';
|
||||
formData.id = row.id;
|
||||
fillFormFromRow(row);
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 复制:以该行数据预填新增表单(不带 id,保存后创建新区块)
|
||||
function handleCopy(row: Api.Www.SectionBlock) {
|
||||
dialogTitle.value = `复制 Section(原:${row.title_zh || layoutLabel(row.layout)})`;
|
||||
formData.id = null;
|
||||
fillFormFromRow(row);
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -356,7 +445,7 @@ onMounted(loadData);
|
||||
v-model:show="dialogVisible"
|
||||
preset="card"
|
||||
:title="dialogTitle"
|
||||
class="w-800px"
|
||||
class="w-1000px"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
|
||||
@@ -384,6 +473,65 @@ onMounted(loadData);
|
||||
<span class="text-12px opacity-60">占视口宽度百分比,居中显示;100 = 全宽。移动端(小屏)始终全宽</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="内容区宽度">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NSelect
|
||||
:value="(formData.config.container_width as string) ?? '1200'"
|
||||
:options="containerWidthOptions"
|
||||
@update:value="v => (formData.config.container_width = v)"
|
||||
/>
|
||||
<span class="text-12px opacity-60">区块内容容器的最大宽度,居中于区块内;全宽 = 内容占满整个区块</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="区块圆角">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
:value="(formData.config.border_radius as number) ?? 0"
|
||||
:min="0"
|
||||
:max="60"
|
||||
:step="4"
|
||||
class="w-full"
|
||||
@update:value="v => (formData.config.border_radius = v ?? 0)"
|
||||
>
|
||||
<template #suffix>px</template>
|
||||
</NInputNumber>
|
||||
<span class="text-12px opacity-60">区块四角圆角大小;0 = 直角(默认)。建议与「区块宽度」小于 100 搭配使用,全宽时圆角不可见</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="上下内边距">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
:value="(formData.config.padding_y as number | null) ?? null"
|
||||
:min="0"
|
||||
:max="240"
|
||||
:step="8"
|
||||
class="w-full"
|
||||
clearable
|
||||
placeholder="留空 = 布局默认值"
|
||||
@update:value="v => (formData.config.padding_y = v)"
|
||||
>
|
||||
<template #suffix>px</template>
|
||||
</NInputNumber>
|
||||
<span class="text-12px opacity-60">内容区与区块上下边缘的间距。留空 = 布局默认(多数 96px / 全幅横幅 120px / 主视觉分屏 64px);0 = 完全贴边</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="标题字号">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
:value="(formData.config.title_font_size as number | null) ?? null"
|
||||
:min="16"
|
||||
:max="96"
|
||||
:step="2"
|
||||
class="w-full"
|
||||
clearable
|
||||
placeholder="留空 = 36px(兜底)"
|
||||
@update:value="v => (formData.config.title_font_size = v)"
|
||||
>
|
||||
<template #suffix>px</template>
|
||||
</NInputNumber>
|
||||
<span class="text-12px opacity-60">桌面端标题字号;移动端自动按约 62.5% 缩小(如填 48 → 移动端 30px)。留空 = 兜底 36px;引言布局无标题字段不受影响</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NDivider>文案颜色(留空=跟随主题默认色)</NDivider>
|
||||
<NFormItem label="Overline 颜色">
|
||||
<ColorInput v-model:value="formData.overline_color" />
|
||||
@@ -410,17 +558,40 @@ 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'" label="文案位置">
|
||||
<div class="grid grid-cols-3 gap-4px">
|
||||
<NButton
|
||||
v-for="pos in textPositionOptions"
|
||||
:key="pos.value"
|
||||
size="tiny"
|
||||
:type="formData.config.text_position === pos.value ? 'primary' : 'default'"
|
||||
@click="formData.config.text_position = pos.value"
|
||||
<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)"
|
||||
>
|
||||
{{ pos.label }}
|
||||
</NButton>
|
||||
<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">
|
||||
<NButton
|
||||
v-for="pos in textPositionOptions"
|
||||
:key="pos.value"
|
||||
size="tiny"
|
||||
:type="formData.config.text_position === pos.value ? 'primary' : 'default'"
|
||||
@click="formData.config.text_position = pos.value"
|
||||
>
|
||||
{{ pos.label }}
|
||||
</NButton>
|
||||
</div>
|
||||
<span v-if="formData.layout === 'title_media'" class="text-12px opacity-60">标题+媒体布局仅水平方向生效(左对齐/居中/右对齐),垂直位置忽略</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="formData.layout === 'split_content'" label="媒体位置">
|
||||
@@ -429,6 +600,14 @@ onMounted(loadData);
|
||||
<NRadio value="right">图片/视频在右</NRadio>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="formData.layout === 'mosaic'" label="拼贴版式">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NRadioGroup :value="(formData.config.mosaic_pattern as string) ?? 'big_3'" @update:value="handleMosaicPatternChange">
|
||||
<NRadio v-for="opt in mosaicPatternOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
|
||||
</NRadioGroup>
|
||||
<span class="text-12px opacity-60">固定版式;切换版式会同步下方槽位数量(已填素材按顺序保留)</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="遮罩">
|
||||
<NSpace align="center">
|
||||
<NSwitch v-model:value="formData.overlay_enabled" />
|
||||
@@ -458,10 +637,10 @@ onMounted(loadData);
|
||||
<NInput v-model:value="formData.subtitle_en" type="textarea" :autosize="{ minRows: 1 }" />
|
||||
</NFormItem>
|
||||
<NFormItem label="正文(中)">
|
||||
<NInput v-model:value="formData.content_zh" type="textarea" :autosize="{ minRows: 2 }" />
|
||||
<RichTextEditor v-model:value="formData.content_zh" />
|
||||
</NFormItem>
|
||||
<NFormItem label="正文(英)">
|
||||
<NInput v-model:value="formData.content_en" type="textarea" :autosize="{ minRows: 2 }" />
|
||||
<RichTextEditor v-model:value="formData.content_en" />
|
||||
</NFormItem>
|
||||
|
||||
<NDivider>按钮</NDivider>
|
||||
@@ -485,15 +664,33 @@ onMounted(loadData);
|
||||
</NFormItem>
|
||||
|
||||
<NDivider>素材</NDivider>
|
||||
<NFormItem label="图片(中)">
|
||||
<MediaSelectInput v-model:value="formData.media_url_zh" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
|
||||
</NFormItem>
|
||||
<NFormItem label="图片(英)">
|
||||
<MediaSelectInput v-model:value="formData.media_url_en" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
|
||||
</NFormItem>
|
||||
<NFormItem label="视频地址">
|
||||
<MediaSelectInput v-model:value="formData.video_url" file-type="video" :maxlength="500" placeholder="视频路径或 URL(背景为视频或图文分栏媒体位使用)" />
|
||||
</NFormItem>
|
||||
<template v-if="formData.layout === 'mosaic'">
|
||||
<NFormItem
|
||||
v-for="(item, idx) in mosaicSlotItems"
|
||||
:key="`${formData.config.mosaic_pattern}-${idx}`"
|
||||
:label="`槽位 ${idx + 1}:${mosaicSlotLabelList[idx] || ''}`"
|
||||
>
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NRadioGroup v-model:value="item.type">
|
||||
<NRadio value="image">图片</NRadio>
|
||||
<NRadio value="video">视频</NRadio>
|
||||
</NRadioGroup>
|
||||
<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="素材(英,留空回退中文素材)" />
|
||||
</div>
|
||||
</NFormItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NFormItem label="图片(中)">
|
||||
<MediaSelectInput v-model:value="formData.media_url_zh" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
|
||||
</NFormItem>
|
||||
<NFormItem label="图片(英)">
|
||||
<MediaSelectInput v-model:value="formData.media_url_en" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
|
||||
</NFormItem>
|
||||
<NFormItem label="视频地址">
|
||||
<MediaSelectInput v-model:value="formData.video_url" file-type="video" :maxlength="500" placeholder="视频路径或 URL(背景为视频或图文分栏媒体位使用)" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<NDivider>显示</NDivider>
|
||||
<NFormItem label="是否可见">
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
"~/*": ["./*"],
|
||||
// @wangeditor/editor-for-vue 的 exports 缺 types 条件,手动指向其类型声明
|
||||
"@wangeditor/editor-for-vue": ["./node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client", "node", "unplugin-icons/types/vue", "naive-ui/volar"],
|
||||
|
||||
@@ -23,3 +23,65 @@ body {
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 区块标题字号统一由此处控制:config.title_font_size 经 SectionRenderer 注入 --sec-title-fs(桌面)
|
||||
/ --sec-title-fs-m(移动端),留空兜底桌面 36px / 移动端 22.5px(62.5%);
|
||||
富文本内的 h2 由 .rich-text 标题规则覆盖(class 选择器优先级更高) */
|
||||
section h2 {
|
||||
font-size: var(--sec-title-fs-m, 22.5px);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
section h2 {
|
||||
font-size: var(--sec-title-fs, 36px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 富文本(wangEditor 输出 HTML)样式还原:UnoCSS preflight 重置了 p/列表/标题等默认样式 */
|
||||
.rich-text p {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
.rich-text > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.rich-text ul,
|
||||
.rich-text ol {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.rich-text ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.rich-text ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.rich-text h1,
|
||||
.rich-text h2,
|
||||
.rich-text h3,
|
||||
.rich-text h4,
|
||||
.rich-text h5 {
|
||||
margin: 0.6em 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.rich-text h1 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
.rich-text h2 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.rich-text h3 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.rich-text h4,
|
||||
.rich-text h5 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.rich-text blockquote {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid currentColor;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.rich-text a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 LayoutSpecTable from './layouts/LayoutSpecTable.vue';
|
||||
@@ -24,6 +26,8 @@ const layoutComponents: Record<SectionLayout, unknown> = {
|
||||
gallery: LayoutGallery,
|
||||
stats: LayoutStats,
|
||||
video_showcase: LayoutVideoShowcase,
|
||||
title_media: LayoutTitleMedia,
|
||||
mosaic: LayoutMosaic,
|
||||
scroll_narrative: LayoutScrollNarrative,
|
||||
quote: LayoutQuote,
|
||||
spec_table: LayoutSpecTable
|
||||
@@ -53,9 +57,40 @@ const bgMediaUrl = computed(() => resolveAssetUrl(props.section.bg_value));
|
||||
|
||||
// 区块宽度百分比(config.width_percent):占视口宽度比例并居中,
|
||||
// 未配置或 100 时全宽;md 以下始终全宽(见模板 class)
|
||||
const sectionWidthStyle = computed<CSSProperties>(() => {
|
||||
// 内容区最大宽度(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.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);
|
||||
return { '--sec-w': w > 0 && w < 100 ? `${w}%` : '100%' } as CSSProperties;
|
||||
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 } : {}),
|
||||
...(titleFontSize.value ? { '--sec-title-fs': titleFontSize.value.desktop, '--sec-title-fs-m': titleFontSize.value.mobile } : {})
|
||||
} as CSSProperties;
|
||||
});
|
||||
|
||||
// 背景图/视频显示模式(config.bg_fit):cover 铺满(默认)/ contain 完整显示 / auto 原尺寸
|
||||
@@ -85,7 +120,7 @@ const bgVideoClass = computed(() =>
|
||||
<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, sectionWidthStyle]"
|
||||
:style="[bgStyle, sectionStyleVars]"
|
||||
>
|
||||
<!-- 背景图:显示模式由 config.bg_fit 控制(默认铺满) -->
|
||||
<div
|
||||
|
||||
@@ -9,13 +9,13 @@ const features = computed(() => config.value.features ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<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 }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<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">
|
||||
|
||||
@@ -7,17 +7,17 @@ const { overline, title, subtitle, content, overlineStyle, titleStyle, bodyStyle
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-120px flex-col-center text-center max-w-800px">
|
||||
<div class="section-container py-[var(--sec-py,120px)] flex-col-center text-center max-w-800px">
|
||||
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="section.theme === 'dark' ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
|
||||
{{ 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'" :style="titleStyle">
|
||||
<h2 class="mt-16px font-bold leading-tight" :class="section.theme === 'dark' ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ 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'" :style="bodyStyle">
|
||||
{{ 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'" :style="bodyStyle" v-html="content" />
|
||||
<div v-if="content" class="rich-text mt-16px text-15px leading-relaxed" :class="section.theme === 'dark' ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
|
||||
<SectionCta :section="section" class="mt-32px justify-center" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,13 +9,13 @@ const images = computed(() => config.value.images ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<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 }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<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">
|
||||
|
||||
@@ -28,32 +28,61 @@ const hPad = computed(() => ({
|
||||
center: 'md:px-64px',
|
||||
right: 'md:pl-96px md:pr-128px'
|
||||
} as Record<string, string>)[textPosition.value.split('_')[1] ?? ''] ?? 'md:px-64px');
|
||||
|
||||
// 区块最小高度(config.min_height):未设置 = 沿用首屏高度规约(移动端 2/3 比例 + 桌面视口公式);
|
||||
// 0 = 完全自适应内容(去上下内边距,高度由媒体素材擑开);>0 = 自定义最小高度 px
|
||||
const minHeight = computed<number | null>(() => {
|
||||
const v = (props.section.config as Record<string, unknown> | null)?.min_height;
|
||||
return typeof v === 'number' && v >= 0 ? v : null;
|
||||
});
|
||||
const isAutoFit = computed(() => minHeight.value === 0);
|
||||
|
||||
const wrapClass = computed(() => {
|
||||
const base = 'relative grid gap-48px px-16px md:px-0';
|
||||
// 默认首屏高度:完整字面量供 UnoCSS 提取
|
||||
return minHeight.value === null ? `aspect-[2/3] md:aspect-auto md:min-h-[max(680px,min(calc(92vh-64px),54vw))] ${base}` : base;
|
||||
});
|
||||
const wrapStyle = computed(() => (!isAutoFit.value && minHeight.value ? { minHeight: `${minHeight.value}px` } : {}));
|
||||
|
||||
// 自适应模式下文案区去掉上下内边距,区块高度严格等于内容高度;
|
||||
// 其余模式支持 config.padding_y 覆盖(默认 64px)
|
||||
const textPadClass = computed(() => (isAutoFit.value ? 'py-0' : 'py-[var(--sec-py,64px)]'));
|
||||
|
||||
// 文案块定位:默认桌面端绝对定位覆盖;自适应模式无前景媒体时改为文档流擑高(否则区块会塔缩),有媒体时保持覆盖
|
||||
const textBlockClass = computed(() =>
|
||||
isAutoFit.value && !mediaUrl.value ? 'py-0' : `${textPadClass.value} md:absolute md:inset-0`
|
||||
);
|
||||
|
||||
// 自适应模式下媒体改为文档流(擑开容器高度,桌面端右半对齐);其余模式保持绝对定位铺右半
|
||||
const mediaClass = computed(() =>
|
||||
isAutoFit.value ? 'flex-center md:w-1/2 md:ml-auto' : 'flex-center md:absolute md:top-0 md:right-0 md:bottom-0 md:w-1/2'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative aspect-[2/3] md:aspect-auto md:min-h-[max(680px,min(calc(92vh-64px),54vw))] grid gap-48px px-16px md:px-0">
|
||||
<!-- 文案块:移动端文档流;桌面端全宽绝对定位 + 九宫格对齐 -->
|
||||
<div :class="wrapClass" :style="wrapStyle">
|
||||
<!-- 文案块:移动端文档流;桌面端全宽绝对定位 + 九宫格对齐(自适应无媒体时文档流擑高) -->
|
||||
<div
|
||||
class="relative z-10 flex flex-col gap-20px py-64px md:py-64px md:absolute md:inset-0"
|
||||
:class="[vAlign, hAlign, hPad]"
|
||||
class="relative z-10 flex flex-col gap-20px"
|
||||
:class="[textBlockClass, vAlign, hAlign, hPad]"
|
||||
>
|
||||
<div class="flex flex-col gap-20px max-w-560px">
|
||||
<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="text-3xl md:text-5xl font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<h2 class="font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="text-lg md:text-xl" :class="isDark ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'" :style="bodyStyle">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="content" class="text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
|
||||
<div v-if="content" class="rich-text text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
|
||||
<SectionCta :section="section" class="mt-8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片:移动端文档流;桌面端占右侧一半 -->
|
||||
<div v-if="mediaUrl" class="flex-center md:absolute md:top-0 md:right-0 md:bottom-0 md:w-1/2">
|
||||
<!-- 图片:移动端文档流;桌面端占右侧一半(自适应模式为文档流擑高) -->
|
||||
<div v-if="mediaUrl" :class="mediaClass">
|
||||
<img :src="mediaUrl" :alt="title" class="w-full max-w-560px rounded-16px object-cover shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<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);
|
||||
|
||||
type MosaicPattern = 'big_3' | 'half_2' | 'big_4' | 'big_2';
|
||||
|
||||
// 拼贴版式(config.mosaic_pattern):big_3 一大三小(默认)/ half_2 左右等分 / big_4 一大四小 / big_2 一大二小上下
|
||||
const pattern = computed<MosaicPattern>(() => {
|
||||
const p = config.value.mosaic_pattern;
|
||||
return p === 'half_2' || p === 'big_4' || p === 'big_2' ? p : 'big_3';
|
||||
});
|
||||
|
||||
// 各版式槽位数量
|
||||
const slotCount: Record<MosaicPattern, number> = { big_3: 4, half_2: 2, big_4: 5, big_2: 3 };
|
||||
|
||||
// 槽位素材(config.mosaic_items):media_zh/_en 以语言后缀结尾,不命中数据层 resolveAssetUrls,需手动解析相对路径
|
||||
const items = computed(() => {
|
||||
const raw = (config.value.mosaic_items ?? []) as Array<{ type?: string; media_zh?: string; media_en?: string }>;
|
||||
return Array.from({ length: slotCount[pattern.value] }, (_, i) => {
|
||||
const it = raw[i] ?? {};
|
||||
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) };
|
||||
});
|
||||
});
|
||||
|
||||
// 网格高度(config.min_height,与主视觉分屏共用字段):仅桌面端生效;
|
||||
// 未设置 = 默认 16/9 宽高比;>0 = 固定高度 px(格子媒体 object-cover 裁剪填充),移动端保持单列 4/3 堆叠
|
||||
const gridHeight = computed(() => {
|
||||
const v = (props.section.config as Record<string, unknown> | null)?.min_height;
|
||||
return typeof v === 'number' && v > 0 ? `${v}px` : '';
|
||||
});
|
||||
|
||||
// 容器网格 class(完整字面量供 UnoCSS 提取;容器定高后行轨道均分,格子内媒体 object-cover 填充)
|
||||
const gridClass = computed(() => {
|
||||
const base = (
|
||||
{
|
||||
big_3: 'md:grid-cols-4 md:grid-rows-2',
|
||||
half_2: 'md:grid-cols-2',
|
||||
big_4: 'md:grid-cols-4 md:grid-rows-2',
|
||||
big_2: 'md:grid-cols-2 md:grid-rows-2'
|
||||
} as Record<MosaicPattern, string>
|
||||
)[pattern.value];
|
||||
// 自定义高度时改用固定高(md:h-[var(--mosaic-h)]),否则保持默认宽高比
|
||||
const sizing = gridHeight.value
|
||||
? 'md:h-[var(--mosaic-h)]'
|
||||
: (
|
||||
{
|
||||
big_3: 'md:aspect-16/9',
|
||||
half_2: '',
|
||||
big_4: 'md:aspect-16/9',
|
||||
big_2: 'md:aspect-16/9'
|
||||
} as Record<MosaicPattern, string>
|
||||
)[pattern.value];
|
||||
return `${base} ${sizing}`;
|
||||
});
|
||||
|
||||
// 格子跨行跨列 class(按槽位下标;移动端统一单列 4/3 堆叠)
|
||||
function cellClass(idx: number) {
|
||||
switch (pattern.value) {
|
||||
case 'big_3':
|
||||
if (idx === 0) return 'aspect-4/3 md:aspect-auto md:col-span-2 md:row-span-2';
|
||||
if (idx === 3) return 'aspect-4/3 md:aspect-auto md:col-span-2';
|
||||
return 'aspect-4/3 md:aspect-auto';
|
||||
case 'half_2':
|
||||
return 'aspect-4/3';
|
||||
case 'big_4':
|
||||
if (idx === 0) return 'aspect-4/3 md:aspect-auto md:col-span-2 md:row-span-2';
|
||||
return 'aspect-4/3 md:aspect-auto';
|
||||
case 'big_2':
|
||||
if (idx === 0) return 'aspect-4/3 md:aspect-auto md:row-span-2';
|
||||
return 'aspect-4/3 md:aspect-auto';
|
||||
default:
|
||||
return 'aspect-4/3';
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- 拼贴网格:自定义高度时通过 --mosaic-h 下发(仅桌面端) -->
|
||||
<div class="grid gap-16px" :class="gridClass" :style="gridHeight ? { '--mosaic-h': gridHeight } : {}">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="overflow-hidden rounded-16px bg-gray-100 dark:bg-gray-800"
|
||||
:class="cellClass(idx)"
|
||||
>
|
||||
<video v-if="item.type === 'video' && item.url" :src="item.url" class="w-full h-full object-cover" autoplay muted loop playsinline preload="metadata" />
|
||||
<img v-else-if="item.url" :src="item.url" :alt="title" class="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,10 +8,10 @@ const { content, subtitle, isDark, titleStyle, bodyStyle } = useSectionContent(p
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px flex-col-center text-center max-w-800px">
|
||||
<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="mt-8px text-2xl md:text-3xl font-medium leading-relaxed"
|
||||
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"
|
||||
|
||||
@@ -9,13 +9,13 @@ const steps = computed(() => config.value.steps ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<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 text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<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">
|
||||
|
||||
@@ -32,13 +32,13 @@ function needToggle(group: SpecGroup): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<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 }}
|
||||
</p>
|
||||
<h2 class="mt-12px text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<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">
|
||||
|
||||
@@ -12,10 +12,11 @@ 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');
|
||||
|
||||
// 媒体填满所在栏(去掉 520px 上限与栏内居中),高度按原素材比例自适应
|
||||
const mediaWrapClass = computed(() =>
|
||||
mediaPosition.value === 'right'
|
||||
? 'flex-center order-2'
|
||||
: 'flex-center order-2 md:order-1'
|
||||
? 'order-2'
|
||||
: 'order-2 md:order-1'
|
||||
);
|
||||
|
||||
const textWrapClass = computed(() =>
|
||||
@@ -26,19 +27,19 @@ const textWrapClass = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px grid md:grid-cols-2 gap-48px items-center">
|
||||
<div class="section-container py-[var(--sec-py,96px)] grid md:grid-cols-2 gap-48px items-center">
|
||||
<!-- 媒体:视频优先,其次图片 -->
|
||||
<div v-if="videoUrl || mediaUrl" :class="mediaWrapClass">
|
||||
<video
|
||||
v-if="videoUrl"
|
||||
:src="videoUrl"
|
||||
class="w-full max-w-520px rounded-16px object-cover shadow-lg"
|
||||
class="w-full rounded-16px object-cover shadow-lg"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<img v-else :src="mediaUrl" :alt="title" class="w-full max-w-520px rounded-16px object-cover shadow-lg" />
|
||||
<img v-else :src="mediaUrl" :alt="title" class="w-full rounded-16px object-cover shadow-lg" />
|
||||
</div>
|
||||
|
||||
<!-- 文案 -->
|
||||
@@ -46,13 +47,13 @@ const textWrapClass = computed(() =>
|
||||
<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="text-3xl md:text-4xl font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<h2 class="font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="text-lg" :class="isDark ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'" :style="bodyStyle">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="content" class="text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
|
||||
<div v-if="content" class="rich-text text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
|
||||
<SectionCta :section="section" class="mt-8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,13 @@ const stats = computed(() => config.value.stats ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<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 text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
import { resolveAssetUrl } from '~/utils/resolveAssetUrl';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, content, mediaUrl, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
|
||||
|
||||
// 媒体视频(video_url 有值时优先渲染视频,支持 /uploads/... 相对路径)
|
||||
const videoUrl = computed(() => resolveAssetUrl(props.section.video_url));
|
||||
|
||||
// 文案水平对齐(config.text_position 九宫格仅取水平方向):left 左对齐(默认)/ center 居中 / right 右对齐
|
||||
const hAlign = computed(() => {
|
||||
const col = (props.section.config as Record<string, string> | null)?.text_position?.split('_')[1] ?? 'left';
|
||||
return ({
|
||||
left: { wrap: 'items-start', text: 'text-left' },
|
||||
center: { wrap: 'items-center', text: 'text-center' },
|
||||
right: { wrap: 'items-end', text: 'text-right' }
|
||||
} as Record<string, { wrap: string; text: string }>)[col] ?? { wrap: 'items-start', text: 'text-left' };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-[var(--sec-py,96px)] flex flex-col gap-48px" :class="hAlign.wrap">
|
||||
<!-- 标题区 -->
|
||||
<div class="flex flex-col gap-16px max-w-800px" :class="hAlign.text">
|
||||
<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="font-bold leading-tight" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="text-lg md:text-xl" :class="isDark ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'" :style="bodyStyle">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="content" class="rich-text text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
|
||||
</div>
|
||||
|
||||
<!-- 媒体:视频优先,其次图片;填满内容容器(宽度跟随「内容区宽度」配置),高度按素材比例自适应 -->
|
||||
<div v-if="videoUrl || mediaUrl" class="w-full">
|
||||
<video
|
||||
v-if="videoUrl"
|
||||
:src="videoUrl"
|
||||
class="w-full rounded-16px object-cover shadow-lg"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<img v-else :src="mediaUrl" :alt="title" class="w-full rounded-16px object-cover shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,13 +10,13 @@ const isEmbed = computed(() => /youtube\.com|youtu\.be|bilibili\.com|player\.bil
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-96px">
|
||||
<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 text-3xl md:text-4xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
<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">
|
||||
|
||||
@@ -30,6 +30,8 @@ export function useSectionContent(section: SectionBlock) {
|
||||
// 前景文案自定义颜色(空 = 跟随 theme 默认配色,由布局组件的 class 控制)
|
||||
// 内联 style 优先级高于 class,非空时直接覆盖默认色
|
||||
const overlineStyle = computed(() => (section.overline_color ? { color: section.overline_color } : undefined));
|
||||
// 标题字号由 main.css 的 section h2 规则统一控制(config.title_font_size 经 SectionRenderer
|
||||
// 注入 --sec-title-fs/--sec-title-fs-m 变量,留空兜底 36px,移动端 22.5px)
|
||||
const titleStyle = computed(() => (section.title_color ? { color: section.title_color } : undefined));
|
||||
const bodyStyle = computed(() => (section.body_color ? { color: section.body_color } : undefined));
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ export type SectionLayout =
|
||||
| 'gallery'
|
||||
| 'stats'
|
||||
| 'video_showcase'
|
||||
| 'title_media'
|
||||
| 'mosaic'
|
||||
| 'scroll_narrative'
|
||||
| 'quote'
|
||||
| 'spec_table';
|
||||
@@ -136,11 +138,19 @@ export interface GalleryImage {
|
||||
caption_en: string;
|
||||
}
|
||||
|
||||
// 马赛克拼贴布局槽位素材(config.mosaic_items)
|
||||
export interface MosaicItem {
|
||||
type: 'image' | 'video';
|
||||
media_zh: string;
|
||||
media_en: string;
|
||||
}
|
||||
|
||||
export interface SectionConfig {
|
||||
features?: FeatureItem[];
|
||||
stats?: StatItem[];
|
||||
steps?: NarrativeStep[];
|
||||
images?: GalleryImage[];
|
||||
mosaic_items?: MosaicItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ export default defineConfig({
|
||||
shortcuts: {
|
||||
'flex-center': 'flex justify-center items-center',
|
||||
'flex-col-center': 'flex flex-col justify-center items-center',
|
||||
'section-container': 'mx-auto max-w-1200px px-16px'
|
||||
// 内容容器最大宽度由区块 config.container_width 通过 --sec-cw 覆盖,未设置时回退 1200px
|
||||
'section-container': 'mx-auto max-w-[var(--sec-cw,1200px)] px-16px'
|
||||
},
|
||||
transformers: [transformerDirectives(), transformerVariantGroup()],
|
||||
presets: [presetWind3({ dark: 'class' })]
|
||||
|
||||
Reference in New Issue
Block a user