Files
admin/dashboard/frontend/src/views/www/components/SectionManager.vue
T

521 lines
19 KiB
Vue
Raw Normal View History

2026-08-04 19:18:35 +08:00
<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 {
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: '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, bg_fit: 'cover', media_position: 'left' } as Record<string, unknown>
});
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
const bgFitOptions = [
{ label: '铺满', value: 'cover' },
{ label: '完整显示', value: 'contain' },
{ label: '原尺寸', value: 'auto' }
];
// 九宫格文案位置(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: 180,
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', 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, bg_fit: 'cover', media_position: 'left' };
dialogVisible.value = true;
}
function handleEdit(row: Api.Www.SectionBlock) {
dialogTitle.value = '编辑 Section';
formData.id = row.id;
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,
bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover',
media_position: cfg.media_position === 'right' ? 'right' : 'left'
};
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-800px"
: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>
<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'" 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"
>
{{ pos.label }}
</NButton>
</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 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="正文(中)">
<NInput v-model:value="formData.content_zh" type="textarea" :autosize="{ minRows: 2 }" />
</NFormItem>
<NFormItem label="正文(英)">
<NInput v-model:value="formData.content_en" type="textarea" :autosize="{ minRows: 2 }" />
</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>
<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>
<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>