官网开发 0805

This commit is contained in:
eafonyang
2026-08-05 18:36:01 +08:00
parent 816281bf2d
commit c9a1d9c576
23 changed files with 1367 additions and 231 deletions
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type { IDomEditor, IToolbarConfig } from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
/**
* 富文本编辑器(wangEditor
*
* 用于官网区块正文编辑:支持加粗/字号/颜色/缩进/列表/对齐等,输出 HTML,
* 与 www 端 v-html 渲染配合使用(样式还原见 www main.css 的 .rich-text)。
*/
defineOptions({ name: 'RichTextEditor' });
const props = withDefaults(
defineProps<{
value: string;
placeholder?: string;
/** 编辑区高度,默认 180px */
height?: number;
}>(),
{ placeholder: '请输入正文', height: 180 }
);
const emit = defineEmits<{ 'update:value': [value: string] }>();
const editorRef = shallowRef<IDomEditor>();
const valueHtml = ref(props.value || '');
// 外部值变化(切换区块/重置表单)时同步进编辑器,避免覆盖用户正在编辑的内容
watch(
() => props.value,
v => {
const next = v || '';
if (next !== valueHtml.value) {
valueHtml.value = next;
}
}
);
function handleChange(editor: IDomEditor) {
const html = editor.getHtml();
// 空内容时 wangEditor 输出 '<p><br></p>',归一化为空串
const normalized = html === '<p><br></p>' ? '' : html;
valueHtml.value = normalized;
emit('update:value', normalized);
}
function handleCreated(editor: IDomEditor) {
editorRef.value = editor;
}
onBeforeUnmount(() => {
editorRef.value?.destroy();
});
// 工具栏:剔除视频/上传图/表格/代码等重功能,保留文字排版相关
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video', 'uploadImage', 'insertTable', 'code', 'codeBlock', 'emotion', 'todo', 'fullScreen']
};
const editorConfig = {
placeholder: props.placeholder,
autoFocus: false,
scroll: false
};
</script>
<template>
<div class="rich-editor w-full border-1px border-solid rounded-8px overflow-hidden">
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default" class="rich-editor-toolbar" />
<Editor
v-model="valueHtml"
:default-config="editorConfig"
mode="default"
class="rich-editor-body"
:style="{ height: `${height}px`, overflowY: 'auto' }"
@on-created="handleCreated"
@on-change="handleChange"
/>
</div>
</template>
<style scoped>
.rich-editor {
border-color: var(--n-border-color, #e0e0e6);
}
.rich-editor-body {
padding: 0 12px;
}
/* 深色主题适配(wangEditor 默认浅色) */
:global(html.dark) .rich-editor,
:global(html.dark) .rich-editor :deep(.w-e-text-container),
:global(html.dark) .rich-editor :deep(.w-e-toolbar) {
background-color: transparent;
color: rgba(255, 255, 255, 0.82);
}
:global(html.dark) .rich-editor :deep(.w-e-bar-item button) {
color: rgba(255, 255, 255, 0.72);
}
:global(html.dark) .rich-editor :deep(.w-e-text-container [data-slate-editor]) {
color: rgba(255, 255, 255, 0.82);
}
</style>
@@ -3,6 +3,7 @@ import { computed, h, onMounted, reactive, ref } from 'vue';
import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, type SelectOption, type SelectRenderOption } from 'naive-ui';
import ColorInput from '../page/home/components/ColorInput.vue';
import MediaSelectInput from './MediaSelectInput.vue';
import RichTextEditor from './RichTextEditor.vue';
import {
fetchCreateSection,
fetchDeleteSection,
@@ -46,6 +47,8 @@ const layoutOptions: LayoutOption[] = [
{ label: '图片画廊', value: 'gallery', desc: '多张图片网格展示' },
{ label: '数据统计', value: 'stats', desc: '大数字展示关键指标数据' },
{ label: '视频展示', value: 'video_showcase', desc: '以视频为主的视觉区块' },
{ label: '标题+媒体', value: 'title_media', desc: '顶部标题文案 + 下方图片/视频,对齐方向可配置' },
{ label: '马赛克拼贴', value: 'mosaic', desc: '一大带多小的不规则网格,每个槽位支持图片/视频、中英双素材' },
{ label: '滚动叙事', value: 'scroll_narrative', desc: '随滚动逐步展开的叙事内容' },
{ label: '引言评价', value: 'quote', desc: '引用语 / 用户评价展示' },
{ label: '参数规格表', value: 'spec_table', desc: '产品技术参数规格表格' }
@@ -107,7 +110,7 @@ const formData = reactive({
media_url_en: '',
video_url: '',
is_visible: true,
config: { text_position: 'middle_left', width_percent: 100, bg_fit: 'cover', media_position: 'left' } as Record<string, unknown>
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null } as Record<string, unknown>
});
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
@@ -117,6 +120,71 @@ const bgFitOptions = [
{ label: '原尺寸', value: 'auto' }
];
// 内容区最大宽度选项(区块内容容器上限,默认 1200px)
const containerWidthOptions = [
{ label: '1200px(默认)', value: '1200' },
{ label: '1400px', value: '1400' },
{ label: '1600px', value: '1600' },
{ label: '全宽', value: 'full' }
];
// ===== 马赛克拼贴(layout=mosaic):固定版式 + 槽位素材,存 config.mosaic_pattern / config.mosaic_items =====
interface MosaicItemForm {
type: 'image' | 'video';
media_zh: string;
media_en: string;
}
const mosaicPatternOptions = [
{ label: '1 大 + 3 小', value: 'big_3', count: 4 },
{ label: '1 大 + 1 大(左右等分)', value: 'half_2', count: 2 },
{ label: '1 大 + 4 小', value: 'big_4', count: 5 },
{ label: '1 大 + 2 小(上下等分)', value: 'big_2', count: 3 }
];
function mosaicSlotCount(pattern: string) {
return mosaicPatternOptions.find(o => o.value === pattern)?.count ?? 4;
}
// 各版式槽位说明(按顺序对应素材编辑槽位与 www 端网格位置)
function mosaicSlotLabels(pattern: string): string[] {
return (
{
big_3: ['大图', '小图 1', '小图 2', '宽图'],
half_2: ['左侧', '右侧'],
big_4: ['大图', '小图 1', '小图 2', '小图 3', '小图 4'],
big_2: ['大图', '上图', '下图']
} as Record<string, string[]>
)[pattern] || [];
}
function emptyMosaicItems(count: number): MosaicItemForm[] {
return Array.from({ length: count }, () => ({ type: 'image', media_zh: '', media_en: '' }));
}
// 归一化回填:类型/字段容错,数量与版式槽位数对齐(保留已填素材)
function normalizeMosaicItems(raw: unknown, pattern: string): MosaicItemForm[] {
const count = mosaicSlotCount(pattern);
const arr = Array.isArray(raw) ? raw : [];
return Array.from({ length: count }, (_, i) => {
const it = (arr[i] ?? {}) as Partial<MosaicItemForm>;
return {
type: it.type === 'video' ? 'video' : 'image',
media_zh: it.media_zh || '',
media_en: it.media_en || ''
};
});
}
// 切换版式时同步槽位数量(保留已有素材)
function handleMosaicPatternChange(value: string) {
formData.config.mosaic_pattern = value;
formData.config.mosaic_items = normalizeMosaicItems(formData.config.mosaic_items, value);
}
const mosaicSlotItems = computed<MosaicItemForm[]>(() => (formData.config.mosaic_items as MosaicItemForm[]) || []);
const mosaicSlotLabelList = computed(() => mosaicSlotLabels(String(formData.config.mosaic_pattern ?? 'big_3')));
// 九宫格文案位置(hero_split 专用)
const textPositionOptions = [
{ label: '左上', value: 'top_left' },
@@ -179,11 +247,12 @@ const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
{
title: '操作',
key: 'actions',
width: 180,
width: 240,
fixed: 'right',
render(row) {
return h('div', { class: 'flex-y-center gap-8px' }, [
h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'small', onClick: () => handleCopy(row) }, { default: () => '复制' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' })
]);
}
@@ -246,13 +315,12 @@ function handleAdd() {
formData.media_url_en = '';
formData.video_url = '';
formData.is_visible = true;
formData.config = { text_position: 'middle_left', width_percent: 100, bg_fit: 'cover', media_position: 'left' };
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null };
dialogVisible.value = true;
}
function handleEdit(row: Api.Www.SectionBlock) {
dialogTitle.value = '编辑 Section';
formData.id = row.id;
// 用指定行的数据填充表单(编辑/复制共用)
function fillFormFromRow(row: Api.Www.SectionBlock) {
formData.layout = row.layout;
formData.theme = row.theme;
formData.overline_color = row.overline_color || '';
@@ -285,9 +353,30 @@ function handleEdit(row: Api.Www.SectionBlock) {
...cfg,
text_position: cfg.text_position || 'middle_left',
width_percent: typeof cfg.width_percent === 'number' ? cfg.width_percent : 100,
container_width: ['1400', '1600', 'full'].includes(String(cfg.container_width)) ? String(cfg.container_width) : '1200',
border_radius: typeof cfg.border_radius === 'number' && cfg.border_radius > 0 ? cfg.border_radius : 0,
padding_y: typeof cfg.padding_y === 'number' && cfg.padding_y >= 0 ? cfg.padding_y : null,
title_font_size: typeof cfg.title_font_size === 'number' && cfg.title_font_size > 0 ? cfg.title_font_size : null,
bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover',
media_position: cfg.media_position === 'right' ? 'right' : 'left'
media_position: cfg.media_position === 'right' ? 'right' : 'left',
min_height: typeof cfg.min_height === 'number' && cfg.min_height >= 0 ? cfg.min_height : null,
mosaic_pattern: ['big_3', 'half_2', 'big_4', 'big_2'].includes(String(cfg.mosaic_pattern)) ? String(cfg.mosaic_pattern) : 'big_3'
};
formData.config.mosaic_items = normalizeMosaicItems(cfg.mosaic_items, String(formData.config.mosaic_pattern));
}
function handleEdit(row: Api.Www.SectionBlock) {
dialogTitle.value = '编辑 Section';
formData.id = row.id;
fillFormFromRow(row);
dialogVisible.value = true;
}
// 复制:以该行数据预填新增表单(不带 id,保存后创建新区块)
function handleCopy(row: Api.Www.SectionBlock) {
dialogTitle.value = `复制 Section(原:${row.title_zh || layoutLabel(row.layout)}`;
formData.id = null;
fillFormFromRow(row);
dialogVisible.value = true;
}
@@ -356,7 +445,7 @@ onMounted(loadData);
v-model:show="dialogVisible"
preset="card"
:title="dialogTitle"
class="w-800px"
class="w-1000px"
:mask-closable="false"
>
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
@@ -384,6 +473,65 @@ onMounted(loadData);
<span class="text-12px opacity-60">占视口宽度百分比居中显示100 = 全宽移动端小屏始终全宽</span>
</div>
</NFormItem>
<NFormItem label="内容区宽度">
<div class="w-full flex flex-col gap-8px">
<NSelect
:value="(formData.config.container_width as string) ?? '1200'"
:options="containerWidthOptions"
@update:value="v => (formData.config.container_width = v)"
/>
<span class="text-12px opacity-60">区块内容容器的最大宽度居中于区块内全宽 = 内容占满整个区块</span>
</div>
</NFormItem>
<NFormItem label="区块圆角">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.border_radius as number) ?? 0"
:min="0"
:max="60"
:step="4"
class="w-full"
@update:value="v => (formData.config.border_radius = v ?? 0)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">区块四角圆角大小0 = 直角默认建议与区块宽度小于 100 搭配使用全宽时圆角不可见</span>
</div>
</NFormItem>
<NFormItem label="上下内边距">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.padding_y as number | null) ?? null"
:min="0"
:max="240"
:step="8"
class="w-full"
clearable
placeholder="留空 = 布局默认值"
@update:value="v => (formData.config.padding_y = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px0 = 完全贴边</span>
</div>
</NFormItem>
<NFormItem label="标题字号">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.title_font_size as number | null) ?? null"
:min="16"
:max="96"
:step="2"
class="w-full"
clearable
placeholder="留空 = 36px(兜底)"
@update:value="v => (formData.config.title_font_size = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">桌面端标题字号移动端自动按约 62.5% 缩小如填 48 移动端 30px留空 = 兜底 36px引言布局无标题字段不受影响</span>
</div>
</NFormItem>
<NDivider>文案颜色留空=跟随主题默认色</NDivider>
<NFormItem label="Overline 颜色">
<ColorInput v-model:value="formData.overline_color" />
@@ -410,17 +558,40 @@ onMounted(loadData);
<NRadio v-for="opt in bgFitOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem v-if="formData.layout === 'hero_split'" label="文案位置">
<div class="grid grid-cols-3 gap-4px">
<NButton
v-for="pos in textPositionOptions"
:key="pos.value"
size="tiny"
:type="formData.config.text_position === pos.value ? 'primary' : 'default'"
@click="formData.config.text_position = pos.value"
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'mosaic'" :label="formData.layout === 'mosaic' ? '网格高度' : '最小高度'">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.min_height as number | null) ?? null"
:min="0"
:max="1200"
:step="20"
class="w-full"
clearable
:placeholder="formData.layout === 'mosaic' ? '留空 = 默认 16:9 宽高比' : '留空 = 首屏高度(默认)'"
@update:value="v => (formData.config.min_height = v)"
>
{{ pos.label }}
</NButton>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">
<template v-if="formData.layout === 'mosaic'">仅桌面端生效留空 = 默认 16:9 宽高比填数值 = 网格固定高度px素材自动裁剪填充可调低区块高度移动端保持单列堆叠不变</template>
<template v-else>仅主视觉分屏布局生效留空 = 沿用首屏高度至少 680px 约一屏0 = 完全自适应内容去上下内边距区块高度严格等于内容高度填数值 = 自定义最小高度</template>
</span>
</div>
</NFormItem>
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'title_media'" label="文案位置">
<div class="w-full flex flex-col gap-8px">
<div class="grid grid-cols-3 gap-4px">
<NButton
v-for="pos in textPositionOptions"
:key="pos.value"
size="tiny"
:type="formData.config.text_position === pos.value ? 'primary' : 'default'"
@click="formData.config.text_position = pos.value"
>
{{ pos.label }}
</NButton>
</div>
<span v-if="formData.layout === 'title_media'" class="text-12px opacity-60">标题+媒体布局仅水平方向生效左对齐/居中/右对齐垂直位置忽略</span>
</div>
</NFormItem>
<NFormItem v-if="formData.layout === 'split_content'" label="媒体位置">
@@ -429,6 +600,14 @@ onMounted(loadData);
<NRadio value="right">图片/视频在右</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem v-if="formData.layout === 'mosaic'" label="拼贴版式">
<div class="w-full flex flex-col gap-8px">
<NRadioGroup :value="(formData.config.mosaic_pattern as string) ?? 'big_3'" @update:value="handleMosaicPatternChange">
<NRadio v-for="opt in mosaicPatternOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
<span class="text-12px opacity-60">固定版式切换版式会同步下方槽位数量已填素材按顺序保留</span>
</div>
</NFormItem>
<NFormItem label="遮罩">
<NSpace align="center">
<NSwitch v-model:value="formData.overlay_enabled" />
@@ -458,10 +637,10 @@ onMounted(loadData);
<NInput v-model:value="formData.subtitle_en" type="textarea" :autosize="{ minRows: 1 }" />
</NFormItem>
<NFormItem label="正文(中)">
<NInput v-model:value="formData.content_zh" type="textarea" :autosize="{ minRows: 2 }" />
<RichTextEditor v-model:value="formData.content_zh" />
</NFormItem>
<NFormItem label="正文(英)">
<NInput v-model:value="formData.content_en" type="textarea" :autosize="{ minRows: 2 }" />
<RichTextEditor v-model:value="formData.content_en" />
</NFormItem>
<NDivider>按钮</NDivider>
@@ -485,15 +664,33 @@ onMounted(loadData);
</NFormItem>
<NDivider>素材</NDivider>
<NFormItem label="图片(中)">
<MediaSelectInput v-model:value="formData.media_url_zh" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
</NFormItem>
<NFormItem label="图片(英)">
<MediaSelectInput v-model:value="formData.media_url_en" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
</NFormItem>
<NFormItem label="视频地址">
<MediaSelectInput v-model:value="formData.video_url" file-type="video" :maxlength="500" placeholder="视频路径或 URL(背景为视频或图文分栏媒体位使用)" />
</NFormItem>
<template v-if="formData.layout === 'mosaic'">
<NFormItem
v-for="(item, idx) in mosaicSlotItems"
:key="`${formData.config.mosaic_pattern}-${idx}`"
:label="`槽位 ${idx + 1}${mosaicSlotLabelList[idx] || ''}`"
>
<div class="w-full flex flex-col gap-8px">
<NRadioGroup v-model:value="item.type">
<NRadio value="image">图片</NRadio>
<NRadio value="video">视频</NRadio>
</NRadioGroup>
<MediaSelectInput v-model:value="item.media_zh" :file-type="item.type === 'video' ? 'video' : 'image'" :maxlength="500" placeholder="素材(中)" />
<MediaSelectInput v-model:value="item.media_en" :file-type="item.type === 'video' ? 'video' : 'image'" :maxlength="500" placeholder="素材(英,留空回退中文素材)" />
</div>
</NFormItem>
</template>
<template v-else>
<NFormItem label="图片(中)">
<MediaSelectInput v-model:value="formData.media_url_zh" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
</NFormItem>
<NFormItem label="图片(英)">
<MediaSelectInput v-model:value="formData.media_url_en" file-type="image" :maxlength="500" placeholder="素材路径(支持 /uploads/... 相对路径)" />
</NFormItem>
<NFormItem label="视频地址">
<MediaSelectInput v-model:value="formData.video_url" file-type="video" :maxlength="500" placeholder="视频路径或 URL(背景为视频或图文分栏媒体位使用)" />
</NFormItem>
</template>
<NDivider>显示</NDivider>
<NFormItem label="是否可见">