Files
admin/dashboard/frontend/src/views/www/components/SectionManager.vue
T
2026-08-05 18:36:01 +08:00

718 lines
30 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 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>