718 lines
30 KiB
Vue
718 lines
30 KiB
Vue
<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 MediaSelectInput from './MediaSelectInput.vue';
|
||
import RichTextEditor from './RichTextEditor.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: '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: '产品技术参数规格表格' }
|
||
];
|
||
|
||
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: '',
|
||
overlay_enabled: false,
|
||
overlay_opacity: 0,
|
||
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, 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 为图片/视频时生效)
|
||
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' }
|
||
];
|
||
|
||
// ===== 马赛克拼贴(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' },
|
||
{ 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: 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: () => '删除' })
|
||
]);
|
||
}
|
||
}
|
||
]);
|
||
|
||
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.overlay_enabled = false;
|
||
formData.overlay_opacity = 0;
|
||
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, 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 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.overlay_enabled = Boolean(row.overlay_enabled);
|
||
formData.overlay_opacity = Number(row.overlay_opacity);
|
||
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,
|
||
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',
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
submitLoading.value = true;
|
||
const { id, ...payload } = formData;
|
||
const reqData = {
|
||
...payload,
|
||
page_type: props.pageType,
|
||
product_id: props.productId || null
|
||
};
|
||
const { error } = id
|
||
? await fetchUpdateSection(id, reqData)
|
||
: await fetchCreateSection(reqData);
|
||
submitLoading.value = false;
|
||
if (!error) {
|
||
window.$message?.success(id ? '更新成功' : '创建成功');
|
||
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"
|
||
>
|
||
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
|
||
<NDivider>基础配置</NDivider>
|
||
<NFormItem label="布局类型">
|
||
<NSelect v-model:value="formData.layout" :options="layoutOptions" :render-label="renderLayoutLabel" :render-option="renderLayoutOption" />
|
||
</NFormItem>
|
||
<NFormItem label="主题">
|
||
<NRadioGroup v-model:value="formData.theme">
|
||
<NRadio v-for="opt in themeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
|
||
</NRadioGroup>
|
||
</NFormItem>
|
||
<NFormItem label="区块宽度">
|
||
<div class="w-full flex flex-col gap-8px">
|
||
<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>
|
||
<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" />
|
||
</NFormItem>
|
||
<NFormItem label="标题颜色">
|
||
<ColorInput v-model:value="formData.title_color" />
|
||
</NFormItem>
|
||
<NFormItem label="副标题/正文颜色">
|
||
<ColorInput v-model:value="formData.body_color" />
|
||
</NFormItem>
|
||
<NFormItem label="背景类型">
|
||
<NRadioGroup v-model:value="formData.bg_type">
|
||
<NRadioButton value="color">纯色</NRadioButton>
|
||
<NRadioButton value="image">图片</NRadioButton>
|
||
<NRadioButton value="gradient">渐变</NRadioButton>
|
||
<NRadioButton value="video">视频</NRadioButton>
|
||
</NRadioGroup>
|
||
</NFormItem>
|
||
<NFormItem label="背景值">
|
||
<MediaSelectInput v-model:value="formData.bg_value" placeholder="颜色值或图片/视频路径(支持 /uploads/... 相对路径)" />
|
||
</NFormItem>
|
||
<NFormItem v-if="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 === '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">
|
||
<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="媒体位置">
|
||
<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 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" />
|
||
<span>透明度</span>
|
||
<NSlider v-model:value="formData.overlay_opacity" :min="0" :max="1" :step="0.05" class="w-200px" />
|
||
<span>{{ formData.overlay_opacity.toFixed(2) }}</span>
|
||
</NSpace>
|
||
</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>
|
||
<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="primary" :loading="submitLoading" @click="handleSubmit">确定</NButton>
|
||
</NSpace>
|
||
</template>
|
||
</NModal>
|
||
</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>
|