Files
admin/dashboard/frontend/src/views/www/components/SectionManager.vue
T
2026-08-17 14:58:02 +08:00

903 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { 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 GradientInput from './GradientInput.vue';
import MediaSelectInput from './MediaSelectInput.vue';
import RichTextEditor from './RichTextEditor.vue';
import SpecManager from './SpecManager.vue';
import {
fetchCreateSection,
fetchDeleteSection,
fetchGetSections,
fetchSortSections,
fetchUpdateSection
} from '@/service/api';
/**
* Section 区块管理(可复用)
*
* 通过 pageType / productId 管理任意页面的区块列表:
* - 首页:pageType='home'
* - 产品详情:pageType='product_detail' + productId
*/
defineOptions({ name: 'SectionManager' });
const props = withDefaults(
defineProps<{
pageType: string;
productId?: number | null;
title?: string;
}>(),
{ productId: null, title: '区块管理' }
);
const loading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('');
const formRef = ref<FormInst | null>(null);
const tableData = ref<Api.Www.SectionBlock[]>([]);
type LayoutOption = SelectOption & { desc: string };
const layoutOptions: LayoutOption[] = [
{ label: '主视觉分屏', value: 'hero_split', desc: '左文案右图片,首页首屏大图' },
{ label: '通栏大横幅', value: 'full_banner', desc: '全宽背景图,标题文案居中' },
{ label: '特性网格', value: 'feature_grid', desc: '多列卡片,展示产品特性卖点' },
{ label: '图文分栏', value: 'split_content', desc: '图片与文案左右并排介绍' },
{ label: '图片画廊', value: 'gallery', desc: '多张图片网格展示' },
{ label: '数据统计', value: 'stats', desc: '大数字展示关键指标数据' },
{ label: '标题+媒体', value: 'title_media', desc: '顶部标题文案 + 下方图片/视频,对齐方向可配置' },
{ label: '马赛克拼贴', value: 'mosaic', desc: '一大带多小的不规则网格,每个槽位支持图片/视频、中英双素材' },
{ label: '跑马灯', value: 'marquee', desc: '图片/视频轮播,可自定义切换间隔,最多 5 个素材' },
{ label: '参数规格表', value: 'spec_table', desc: '产品技术参数规格表格' },
{ label: '3D 模型展示', value: 'model3d', desc: 'three.js 交互式 3D 模型(素材库上传 .glb),支持拖拽旋转/缩放/自动旋转' }
];
function layoutLabel(value: string) {
return (layoutOptions.find(o => o.value === value)?.label as string) || value;
}
function renderLayoutLabel(option: SelectOption, _selected: boolean) {
const opt = option as LayoutOption;
// 始终渲染 label + desc(收起态的 desc 由 CSS 隐藏,见底部 <style>
return h('div', { class: 'flex flex-col py-2px' }, [
h('span', { class: 'text-14px' }, opt.label as string),
h('span', { class: 'text-12px opacity-60 layout-desc' }, opt.desc)
]);
}
const renderLayoutOption: SelectRenderOption = ({ node, option }) => {
const opt = option as LayoutOption;
return h('div', {
style: opt.value === layoutOptions[layoutOptions.length - 1].value
? ''
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06);'
}, [node]);
};
const themeOptions = [
{ label: '浅色', value: 'light' },
{ label: '深色', value: 'dark' }
];
const formData = reactive({
id: null as number | null,
layout: 'hero_split',
theme: 'light',
overline_color: '',
title_color: '',
body_color: '',
bg_type: 'color',
bg_value: '',
overline_zh: '',
overline_en: '',
title_zh: '',
title_en: '',
subtitle_zh: '',
subtitle_en: '',
content_zh: '',
content_en: '',
cta_primary_zh: '',
cta_primary_en: '',
cta_primary_url: '',
cta_secondary_zh: '',
cta_secondary_en: '',
cta_secondary_url: '',
media_url_zh: '',
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), marquee_items: emptyMarqueeItems(1), marquee_interval: 3, min_height: null } as Record<string, unknown>
});
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
const bgFitOptions = [
{ label: '铺满', value: 'cover' },
{ label: '完整显示', value: 'contain' },
{ label: '原尺寸', value: 'auto' }
];
// 内容区最大宽度选项(区块内容容器上限,默认 1200px)
const containerWidthOptions = [
{ label: '1200px(默认)', value: '1200' },
{ label: '1400px', value: '1400' },
{ label: '1600px', value: '1600' },
{ label: '全宽', value: 'full' }
];
// 图文分栏比例选项(值为素材栏宽占比);标签顺序随「媒体位置」联动:
// 素材在左 → 「素材 : 文案」,素材在右 → 「文案 : 素材」(与视觉左右顺序一致,底层存值不变)
const splitRatioOptions = computed(() => {
const mediaRight = formData.config.media_position === 'right';
const label = (media: number) => (mediaRight ? `${100 - media} : ${media}` : `${media} : ${100 - media}`);
return [
{ label: `${label(30)}`, value: 30 },
{ label: `${label(40)}`, value: 40 },
{ label: `${label(50)}(默认)`, value: 50 },
{ label: `${label(60)}`, value: 60 },
{ label: `${label(70)}`, value: 70 }
];
});
// ===== 马赛克拼贴(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')));
// ===== 跑马灯(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' },
{ label: '中上', value: 'top_center' },
{ label: '右上', value: 'top_right' },
{ label: '左中', value: 'middle_left' },
{ label: '正中', value: 'middle_center' },
{ label: '右中', value: 'middle_right' },
{ label: '左下', value: 'bottom_left' },
{ label: '中下', value: 'bottom_center' },
{ label: '右下', value: 'bottom_right' }
];
const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
{
title: '排序',
key: 'sort_order',
width: 110,
render(row) {
const idx = tableData.value.findIndex(item => item.id === row.id);
const isFirst = idx === 0;
const isLast = idx === tableData.value.length - 1;
return h('div', { class: 'flex-y-center gap-4px' }, [
h('span', { class: 'text-12px text-gray-400 w-20px text-center' }, { default: () => String(row.sort_order) }),
h(NButton, {
text: true, size: 'tiny', type: 'primary', disabled: isFirst,
onClick: () => handleMove(idx, -1)
}, { default: () => '↑' }),
h(NButton, {
text: true, size: 'tiny', type: 'primary', disabled: isLast,
onClick: () => handleMove(idx, 1)
}, { default: () => '↓' })
]);
}
},
{ title: '布局', key: 'layout', width: 130, render: row => layoutLabel(row.layout) },
{
title: '主题',
key: 'theme',
width: 80,
render(row) {
return h(NTag, { type: row.theme === 'dark' ? 'default' : 'success', size: 'small' }, {
default: () => (row.theme === 'dark' ? '深色' : '浅色')
});
}
},
{ title: '标题(中)', key: 'title_zh', ellipsis: { tooltip: true } },
{ title: '标题(英)', key: 'title_en', ellipsis: { tooltip: true } },
{
title: '可见',
key: 'is_visible',
width: 80,
render(row) {
return h(NSwitch, {
value: Boolean(row.is_visible),
onUpdateValue: (val: boolean) => handleToggleVisible(row, val)
});
}
},
{
title: '操作',
key: 'actions',
width: 290,
fixed: 'right',
render(row) {
return h('div', { class: 'flex-y-center gap-8px' }, [
h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
// spec_table 布局:管理参数分组与参数项
row.layout === 'spec_table'
? h(NButton, { size: 'small', type: 'info', onClick: () => handleSpecManage(row) }, { default: () => '参数' })
: null,
h(NButton, { size: 'small', onClick: () => handleCopy(row) }, { default: () => '复制' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' })
]);
}
}
]);
async function handleMove(index: number, direction: -1 | 1) {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= tableData.value.length) return;
const newData = [...tableData.value];
[newData[index], newData[swapIndex]] = [newData[swapIndex], newData[index]];
const sortPayload = newData.map((item, i) => ({ id: item.id, sort_order: i }));
const { error } = await fetchSortSections(sortPayload);
if (!error) {
window.$message?.success('排序已更新');
loadData();
}
}
async function loadData() {
loading.value = true;
const params: { page_type: string; product_id?: number } = { page_type: props.pageType };
if (props.productId) params.product_id = props.productId;
const { data, error } = await fetchGetSections(params);
loading.value = false;
if (!error && data) {
tableData.value = data.items || [];
}
}
function handleAdd() {
dialogTitle.value = '新增 Section';
formData.id = null;
formData.layout = 'hero_split';
formData.theme = 'light';
formData.overline_color = '';
formData.title_color = '';
formData.body_color = '';
formData.bg_type = 'color';
formData.bg_value = '';
formData.overline_zh = '';
formData.overline_en = '';
formData.title_zh = '';
formData.title_en = '';
formData.subtitle_zh = '';
formData.subtitle_en = '';
formData.content_zh = '';
formData.content_en = '';
formData.cta_primary_zh = '';
formData.cta_primary_en = '';
formData.cta_primary_url = '';
formData.cta_secondary_zh = '';
formData.cta_secondary_en = '';
formData.cta_secondary_url = '';
formData.media_url_zh = '';
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), marquee_items: emptyMarqueeItems(1), marquee_interval: 3, min_height: null, model_url: '', model_height: 480, auto_rotate: true };
dialogVisible.value = true;
}
// 用指定行的数据填充表单(编辑/复制共用)
function fillFormFromRow(row: Api.Www.SectionBlock) {
formData.layout = row.layout;
formData.theme = row.theme;
formData.overline_color = row.overline_color || '';
formData.title_color = row.title_color || '';
formData.body_color = row.body_color || '';
formData.bg_type = row.bg_type;
formData.bg_value = row.bg_value;
formData.overline_zh = row.overline_zh;
formData.overline_en = row.overline_en;
formData.title_zh = row.title_zh;
formData.title_en = row.title_en;
formData.subtitle_zh = row.subtitle_zh;
formData.subtitle_en = row.subtitle_en;
formData.content_zh = row.content_zh;
formData.content_en = row.content_en;
formData.cta_primary_zh = row.cta_primary_zh;
formData.cta_primary_en = row.cta_primary_en;
formData.cta_primary_url = row.cta_primary_url;
formData.cta_secondary_zh = row.cta_secondary_zh;
formData.cta_secondary_en = row.cta_secondary_en;
formData.cta_secondary_url = row.cta_secondary_url;
formData.media_url_zh = row.media_url_zh;
formData.media_url_en = row.media_url_en;
formData.video_url = row.video_url;
formData.is_visible = Boolean(row.is_visible);
const cfg = (row.config || {}) as Record<string, unknown>;
formData.config = {
...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,
margin_y: typeof cfg.margin_y === 'number' && cfg.margin_y >= 0 ? cfg.margin_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',
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',
marquee_interval: typeof cfg.marquee_interval === 'number' && cfg.marquee_interval >= 1 ? Math.min(cfg.marquee_interval, 60) : 3,
model_url: typeof cfg.model_url === 'string' ? cfg.model_url : '',
model_height: typeof cfg.model_height === 'number' && cfg.model_height >= 200 ? cfg.model_height : 480,
auto_rotate: cfg.auto_rotate !== false
};
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) {
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;
}
async function handleToggleVisible(row: Api.Www.SectionBlock, val: boolean) {
const { error } = await fetchUpdateSection(row.id, { is_visible: val });
if (!error) {
row.is_visible = val;
window.$message?.success('已更新');
}
}
function handleDelete(row: Api.Www.SectionBlock) {
window.$dialog?.warning({
title: '警告',
content: `确定要删除 Section「${row.title_zh || row.layout}」吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchDeleteSection(row.id);
if (!error) {
window.$message?.success('删除成功');
loadData();
}
}
});
}
// 参数管理(spec_table 布局):唤起 SpecManager
const specManagerRef = ref<InstanceType<typeof SpecManager> | null>(null);
function handleSpecManage(row: Api.Www.SectionBlock) {
specManagerRef.value?.open(row);
}
async function handleSubmit(closeAfter = true) {
submitLoading.value = true;
const { id, ...payload } = formData;
const reqData = {
...payload,
page_type: props.pageType,
product_id: props.productId || null
};
const { data, error } = id
? await fetchUpdateSection(id, reqData)
: await fetchCreateSection(reqData);
submitLoading.value = false;
if (!error) {
// 「应用」新建后回填新 id,后续保存走更新接口,避免重复创建
if (!id && data?.id) formData.id = data.id;
window.$message?.success(id ? '更新成功' : '创建成功');
if (closeAfter) dialogVisible.value = false;
loadData();
}
}
onMounted(loadData);
</script>
<template>
<div>
<NCard :bordered="false" class="card-wrapper" :title="title" size="small">
<template #header-extra>
<NButton type="primary" @click="handleAdd">
<template #icon>
<SvgIcon icon="mdi:plus" />
</template>
新增区块
</NButton>
</template>
<NDataTable :loading="loading" :columns="columns" :data="tableData" :bordered="false" size="small" />
</NCard>
<NModal
v-model:show="dialogVisible"
preset="card"
:title="dialogTitle"
class="w-1000px"
:mask-closable="false"
:segmented="{ content: true, footer: 'soft' }"
content-style="max-height: calc(85vh - 130px); overflow-y: auto;"
>
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
<NDivider>基础配置</NDivider>
<div class="grid grid-cols-2 gap-x-16px">
<NFormItem label="布局类型" label-placement="top">
<NSelect v-model:value="formData.layout" :options="layoutOptions" :render-label="renderLayoutLabel" :render-option="renderLayoutOption" />
</NFormItem>
<NFormItem label="主题" label-placement="top">
<NRadioGroup v-model:value="formData.theme">
<NRadio v-for="opt in themeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
</div>
<div class="grid grid-cols-3 gap-x-16px">
<NFormItem label="区块宽度" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.width_percent as number) ?? 100"
:min="30"
:max="100"
:step="5"
class="w-full"
@update:value="v => (formData.config.width_percent = v ?? 100)"
>
<template #suffix>%</template>
</NInputNumber>
</template>
占视口宽度百分比居中显示100 = 全宽移动端小屏始终全宽
</NTooltip>
</NFormItem>
<NFormItem label="内容区宽度" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NSelect
:value="(formData.config.container_width as string) ?? '1200'"
:options="containerWidthOptions"
@update:value="v => (formData.config.container_width = v)"
/>
</template>
区块内容容器的最大宽度居中于区块内全宽 = 内容占满整个区块
</NTooltip>
</NFormItem>
<NFormItem label="区块圆角" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<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>
</template>
区块四角圆角大小0 = 直角默认)。建议与区块宽度小于 100 搭配使用全宽时圆角不可见
</NTooltip>
</NFormItem>
</div>
<div class="grid grid-cols-3 gap-x-16px">
<NFormItem label="上下内边距" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<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>
</template>
内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px);0 = 完全贴边
</NTooltip>
</NFormItem>
<NFormItem label="上下外边距" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.margin_y as number | null) ?? null"
:min="0"
:max="240"
:step="8"
class="w-full"
clearable
placeholder="留空 = 紧贴相邻区块"
@update:value="v => (formData.config.margin_y = v)"
>
<template #suffix>px</template>
</NInputNumber>
</template>
区块与上下相邻区块之间的间隙露出页面背景色适合区块宽度 &lt; 100 + 圆角的卡片式区块留空/0 = 紧贴默认
</NTooltip>
</NFormItem>
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'mosaic'" :label="formData.layout === 'mosaic' ? '网格高度' : '最小高度'" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<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>
</template>
<template v-if="formData.layout === 'mosaic'">仅桌面端生效留空 = 默认 16:9 宽高比填数值 = 网格固定高度px),素材自动裁剪填充可调低区块高度移动端保持单列堆叠不变</template>
<template v-else>仅主视觉分屏布局生效留空 = 沿用首屏高度至少 680px 约一屏);0 = 完全自适应内容去上下内边距区块高度严格等于内容高度);填数值 = 自定义最小高度</template>
</NTooltip>
</NFormItem>
</div>
<div class="flex items-start gap-x-16px">
<NFormItem label="背景类型" label-placement="top" class="shrink-0">
<NRadioGroup v-model:value="formData.bg_type">
<NRadioButton value="color">纯色</NRadioButton>
<NRadioButton value="gradient">渐变</NRadioButton>
<NRadioButton value="image">图片</NRadioButton>
<NRadioButton value="video">视频</NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem label="背景值" label-placement="top" class="min-w-0 flex-1">
<ColorInput v-if="formData.bg_type === 'color'" v-model:value="formData.bg_value" placeholder="留空则无背景色" />
<GradientInput v-else-if="formData.bg_type === 'gradient'" v-model:value="formData.bg_value" />
<MediaSelectInput v-else v-model:value="formData.bg_value" placeholder="图片/视频路径(支持 /uploads/... 相对路径)" />
</NFormItem>
</div>
<NDivider>文案样式</NDivider>
<div class="grid grid-cols-4 gap-x-16px">
<NFormItem label="标题字号" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<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>
</template>
桌面端标题字号移动端自动按约 62.5% 缩小如填 48 移动端 30px)。留空 = 兜底 36px引言布局无标题字段不受影响
</NTooltip>
</NFormItem>
<NFormItem label="Overline 颜色" label-placement="top">
<ColorInput v-model:value="formData.overline_color" />
</NFormItem>
<NFormItem label="标题颜色" label-placement="top">
<ColorInput v-model:value="formData.title_color" />
</NFormItem>
<NFormItem label="副标题/正文颜色" label-placement="top">
<ColorInput v-model:value="formData.body_color" />
</NFormItem>
</div>
<NFormItem v-if="formData.layout !== 'split_content' && (formData.bg_type === 'image' || formData.bg_type === 'video')" label="背景显示模式">
<NRadioGroup :value="(formData.config.bg_fit as string) ?? 'cover'" @update:value="v => (formData.config.bg_fit = v)">
<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 === '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>
<!-- 图文分栏背景显示模式仅图片/视频背景/ 媒体位置 / 分栏比例 同一行 -->
<div
v-if="formData.layout === 'split_content'"
class="grid gap-x-16px"
:class="formData.bg_type === 'image' || formData.bg_type === 'video' ? 'grid-cols-3' : 'grid-cols-2'"
>
<NFormItem v-if="formData.bg_type === 'image' || formData.bg_type === 'video'" label="背景显示模式" label-placement="top">
<NRadioGroup :value="(formData.config.bg_fit as string) ?? 'cover'" @update:value="v => (formData.config.bg_fit = v)">
<NRadio v-for="opt in bgFitOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="媒体位置" label-placement="top">
<NRadioGroup :value="(formData.config.media_position as string) ?? 'left'" @update:value="v => (formData.config.media_position = v)">
<NRadio value="left">素材文案</NRadio>
<NRadio value="right">文案素材</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="分栏比例" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NSelect
:value="(formData.config.split_ratio as number) ?? 50"
:options="splitRatioOptions"
@update:value="v => (formData.config.split_ratio = v)"
/>
</template>
按页面视觉左右顺序标注{{ formData.config.media_position === 'right' ? '文案 : 素材' : '素材 : 文案' }}),默认五五等分移动端小屏始终单列堆叠不受影响
</NTooltip>
</NFormItem>
</div>
<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>
<NDivider>文案内容</NDivider>
<NFormItem label="Overline(中)">
<NInput v-model:value="formData.overline_zh" maxlength="100" />
</NFormItem>
<NFormItem label="Overline(英)">
<NInput v-model:value="formData.overline_en" maxlength="100" />
</NFormItem>
<NFormItem label="标题(中)">
<NInput v-model:value="formData.title_zh" maxlength="200" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="formData.title_en" maxlength="200" />
</NFormItem>
<NFormItem label="副标题(中)">
<NInput v-model:value="formData.subtitle_zh" type="textarea" :autosize="{ minRows: 1 }" />
</NFormItem>
<NFormItem label="副标题(英)">
<NInput v-model:value="formData.subtitle_en" type="textarea" :autosize="{ minRows: 1 }" />
</NFormItem>
<NFormItem label="正文(中)">
<RichTextEditor v-model:value="formData.content_zh" />
</NFormItem>
<NFormItem label="正文(英)">
<RichTextEditor v-model:value="formData.content_en" />
</NFormItem>
<NDivider>按钮</NDivider>
<NFormItem label="主按钮(中)">
<NInput v-model:value="formData.cta_primary_zh" maxlength="50" />
</NFormItem>
<NFormItem label="主按钮(英)">
<NInput v-model:value="formData.cta_primary_en" maxlength="50" />
</NFormItem>
<NFormItem label="主按钮链接">
<NInput v-model:value="formData.cta_primary_url" maxlength="500" placeholder="站内路由(如 /products/luxsin-x8)或完整 URL" />
</NFormItem>
<NFormItem label="次按钮(中)">
<NInput v-model:value="formData.cta_secondary_zh" maxlength="50" />
</NFormItem>
<NFormItem label="次按钮(英)">
<NInput v-model:value="formData.cta_secondary_en" maxlength="50" />
</NFormItem>
<NFormItem label="次按钮链接">
<NInput v-model:value="formData.cta_secondary_url" maxlength="500" placeholder="站内路由(如 /products/luxsin-x8)或完整 URL" />
</NFormItem>
<NDivider>素材</NDivider>
<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-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-if="formData.layout === 'model3d'">
<NFormItem label="3D 模型">
<MediaSelectInput v-model:value="(formData.config.model_url as string)" file-type="file" :maxlength="500" placeholder="模型文件(素材库上传 .glb/.gltf,建议 .glb" />
</NFormItem>
<div class="grid grid-cols-2 gap-x-16px">
<NFormItem label="展示区高度" label-placement="top">
<NInputNumber
:value="(formData.config.model_height as number) ?? 480"
:min="200"
:max="1200"
:step="20"
class="w-full"
@update:value="v => (formData.config.model_height = v ?? 480)"
>
<template #suffix>px</template>
</NInputNumber>
</NFormItem>
<NFormItem label="自动旋转" label-placement="top">
<NSwitch :value="(formData.config.auto_rotate as boolean) !== false" @update:value="v => (formData.config.auto_rotate = v)" />
</NFormItem>
</div>
</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="是否可见">
<NSwitch v-model:value="formData.is_visible" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="dialogVisible = false">取消</NButton>
<NButton type="success" secondary :loading="submitLoading" @click="handleSubmit(false)">应用</NButton>
<NButton type="primary" :loading="submitLoading" @click="handleSubmit()">确定</NButton>
</NSpace>
</template>
</NModal>
<!-- 参数规格管理spec_table 布局专用 -->
<SpecManager ref="specManagerRef" />
</div>
</template>
<style>
/* 布局类型下拉:收起态(输入框内)隐藏 desc,下拉展开列表里始终显示 desc */
.n-base-selection-label .layout-desc,
.n-base-selection-tag__content .layout-desc,
.n-base-selection-tags .layout-desc {
display: none;
}
</style>