GoWind CMS pioneered a section-based content editor built on SectionType, allowing content editors to build rich content like building blocks. Each section has its own type and configuration, with support for text, image, video, code block, carousel, and many other section types. This tutorial explains the architecture, data model, and front-/back-end implementation of the section editor.
| Comparison | Traditional Rich Text | Section Editor |
|---|
| Data structure | HTML string | JSON section array |
| Structured data | Hard to extract | Extractable by section type |
| Extensibility | Depends on editor plugins | Custom section types possible |
| Multi-client rendering | Requires HTML parsing | Each client renders sections independently |
| Data migration | Difficult | Section-level migration |
| Section | SectionType | Description |
|---|
| Text paragraph | SECTION_TYPE_TEXT | Rich text paragraph (Markdown supported) |
| Heading | SECTION_TYPE_HEADING | H1-H6 headings |
| Image | SECTION_TYPE_IMAGE | Single/multiple images |
| Code block | SECTION_TYPE_CODE | Code highlighting |
| Video | SECTION_TYPE_VIDEO | Video embedding |
| Quote | SECTION_TYPE_QUOTE | Quoted text |
| Divider | SECTION_TYPE_DIVIDER | Horizontal rule |
| List | SECTION_TYPE_LIST | Ordered/unordered lists |
| Table | SECTION_TYPE_TABLE | Data tables |
| Carousel | SECTION_TYPE_CAROUSEL | Image carousels |
| Button | SECTION_TYPE_BUTTON | CTA buttons |
| Embed | SECTION_TYPE_EMBED | Third-party embeds (YouTube/Twitter) |
message Section {
optional string id = 1;
optional SectionType type = 2;
optional uint32 sort_order = 3 [json_name = "sortOrder"];
optional SectionData data = 4;
}
enum SectionType {
SECTION_TYPE_UNSPECIFIED = 0;
SECTION_TYPE_HEADING = 1;
SECTION_TYPE_TEXT = 2;
SECTION_TYPE_IMAGE = 3;
SECTION_TYPE_CODE = 4;
SECTION_TYPE_VIDEO = 5;
SECTION_TYPE_QUOTE = 6;
SECTION_TYPE_DIVIDER = 7;
SECTION_TYPE_LIST = 8;
SECTION_TYPE_TABLE = 9;
SECTION_TYPE_CAROUSEL = 10;
SECTION_TYPE_BUTTON = 11;
SECTION_TYPE_EMBED = 12;
}
message SectionData {
oneof data {
HeadingData heading = 1;
TextData text = 2;
ImageData image = 3;
CodeData code = 4;
VideoData video = 5;
QuoteData quote = 6;
ListData list = 8;
TableData table = 9;
CarouselData carousel = 10;
ButtonData button = 11;
EmbedData embed = 12;
}
}
message HeadingData {
optional uint32 level = 1;
optional string text = 2;
}
message TextData {
optional string content = 1;
}
message ImageData {
optional string url = 1;
optional string alt = 2;
optional string caption = 3;
optional uint32 width = 4;
optional uint32 height = 5;
}
message CodeData {
optional string language = 1;
optional string code = 2;
optional bool show_line_numbers = 3 [json_name = "showLineNumbers"];
}
message VideoData {
optional string url = 1;
optional string poster = 2;
optional uint32 duration = 3;
}
message Post {
repeated Section sections = 14 [json_name = "sections"];
optional string summary = 15;
}
{
"id": 42,
"title": "GoWind 架构解析",
"sections": [
{
"id": "sec-001",
"type": "SECTION_TYPE_HEADING",
"sortOrder": 1,
"data": {
"heading": { "level": 2, "text": "架构概述" }
}
},
{
"id": "sec-002",
"type": "SECTION_TYPE_TEXT",
"sortOrder": 2,
"data": {
"text": { "content": "GoWind CMS 采用三服务架构..." }
}
},
{
"id": "sec-003",
"type": "SECTION_TYPE_CODE",
"sortOrder": 3,
"data": {
"code": {
"language": "go",
"code": "func main() {\n fmt.Println(\"Hello\")\n}",
"showLineNumbers": true
}
}
},
{
"id": "sec-004",
"type": "SECTION_TYPE_IMAGE",
"sortOrder": 4,
"data": {
"image": {
"url": "https://oss.example.com/architecture.png",
"alt": "架构图",
"caption": "图1:CMS 三服务架构"
}
}
}
]
}
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
const props = defineProps<{ modelValue: Section[] }>();
const emit = defineEmits<{ 'update:modelValue': [value: Section[]] }>();
const sections = ref<Section[]>(props.modelValue || []);
const editor = useEditor({
extensions: [StarterKit, Image, CodeBlockLowlight],
content: sectionsToHTML(sections.value),
onUpdate: ({ editor }) => {
sections.value = htmlToSections(editor.getHTML());
emit('update:modelValue', sections.value);
},
});
watch(() => props.modelValue, (val) => {
if (val && JSON.stringify(val) !== JSON.stringify(sections.value)) {
sections.value = val;
editor.value?.commands.setContent(sectionsToHTML(val));
}
});
const addSection = (type: SectionType) => {
const newSection: Section = {
id: generateUUID(),
type,
sortOrder: sections.value.length + 1,
data: createDefaultData(type),
};
sections.value.push(newSection);
emit('update:modelValue', sections.value);
};
const moveSection = (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= sections.value.length) return;
[sections.value[index], sections.value[targetIndex]] =
[sections.value[targetIndex], sections.value[index]];
sections.value.forEach((s, i) => (s.sortOrder = i + 1));
emit('update:modelValue', sections.value);
};
const deleteSection = (id: string) => {
sections.value = sections.value.filter((s) => s.id !== id);
emit('update:modelValue', sections.value);
};
const sectionTypes = [
{ type: 1, label: '标题', icon: 'HeadingOutlined' },
{ type: 2, label: '文本', icon: 'FontSizeOutlined' },
{ type: 3, label: '图片', icon: 'PictureOutlined' },
{ type: 4, label: '代码', icon: 'CodeOutlined' },
{ type: 5, label: '视频', icon: 'VideoCameraOutlined' },
{ type: 6, label: '引用', icon: 'MessageOutlined' },
{ type: 9, label: '表格', icon: 'TableOutlined' },
{ type: 11, label: '按钮', icon: 'LinkOutlined' },
];
</script>
<template>
<div class="section-editor">
<div class="toolbar">
<Tooltip v-for="st in sectionTypes" :key="st.type" :title="st.label">
<Button @click="addSection(st.type)">
<component :is="st.icon" />
</Button>
</Tooltip>
</div>
<draggable
v-model="sections"
item-key="id"
handle=".drag-handle"
@end="$emit('update:modelValue', sections)"
>
<template #item="{ element, index }">
<div class="section-item">
<div class="drag-handle">⋮⋮</div>
<SectionRenderer
:section="element"
@update="(data) => element.data = data"
/>
<div class="section-actions">
<Button size="small" @click="moveSection(index, 'up')">上移</Button>
<Button size="small" @click="moveSection(index, 'down')">下移</Button>
<Button size="small" danger @click="deleteSection(element.id)">删除</Button>
</div>
</div>
</template>
</draggable>
<div v-if="sections.length === 0" class="empty-state">
<p>点击上方工具栏按钮添加内容区块</p>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ section: Section }>();
const emit = defineEmits<{ update: [data: SectionData] }>();
const isEditing = ref(false);
</script>
<template>
<div class="section-renderer" @dblclick="isEditing = true">
<component
:is="`h${section.data.heading?.level || 2}`"
v-if="section.type === 1"
>
<EditableText
v-model="section.data.heading.text"
:editing="isEditing"
@blur="emit('update', section.data)"
/>
</component>
<div v-else-if="section.type === 2" class="text-section">
<TipTapEditor
v-model="section.data.text.content"
:editing="isEditing"
@blur="emit('update', section.data)"
/>
</div>
<div v-else-if="section.type === 3" class="image-section">
<MediaPicker
v-if="isEditing"
v-model="section.data.image.url"
@select="emit('update', section.data)"
/>
<figure v-else>
<img
:src="section.data.image.url"
:alt="section.data.image.alt"
:style="{ maxWidth: section.data.image.width + 'px' }"
/>
<figcaption v-if="section.data.image.caption">
{{ section.data.image.caption }}
</figcaption>
</figure>
</div>
<div v-else-if="section.type === 4" class="code-section">
<select v-if="isEditing" v-model="section.data.code.language">
<option value="go">Go</option>
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="java">Java</option>
</select>
<pre><code :class="`language-${section.data.code.language}`">{{
section.data.code.code
}}</code></pre>
</div>
<div v-else-if="section.type === 5" class="video-section">
<video
:src="section.data.video.url"
:poster="section.data.video.poster"
controls
/>
</div>
</div>
</template>
import Image from 'next/image';
interface SectionRendererProps {
sections: Section[];
}
export function SectionRenderer({ sections }: SectionRendererProps) {
return (
<div className="post-content">
{sections
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((section) => {
switch (section.type) {
case 1:
const Tag = `h${section.data.heading?.level || 2}` as keyof JSX.IntrinsicElements;
return <Tag key={section.id}>{section.data.heading?.text}</Tag>;
case 2:
return (
<div
key={section.id}
className="prose"
dangerouslySetInnerHTML={{ __html: section.data.text?.content }}
/>
);
case 3:
return (
<figure key={section.id} className="my-8">
<Image
src={section.data.image?.url}
alt={section.data.image?.alt || ''}
width={section.data.image?.width || 800}
height={section.data.image?.height || 400}
className="rounded-lg"
/>
{section.data.image?.caption && (
<figcaption className="text-center text-gray-500 text-sm mt-2">
{section.data.image.caption}
</figcaption>
)}
</figure>
);
case 4:
return (
<pre key={section.id} className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<code className={`language-${section.data.code?.language}`}>
{section.data.code?.code}
</code>
</pre>
);
case 5:
return (
<video
key={section.id}
src={section.data.video?.url}
poster={section.data.video?.poster}
controls
className="w-full rounded-lg my-8"
/>
);
case 6:
return (
<blockquote
key={section.id}
className="border-l-4 border-blue-500 pl-4 italic text-gray-700 my-6"
>
{section.data.quote?.text}
</blockquote>
);
case 7:
return <hr key={section.id} className="my-8" />;
case 11:
return (
<div key={section.id} className="text-center my-6">
<a
href={section.data.button?.url}
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{section.data.button?.text}
</a>
</div>
);
default:
return null;
}
})}
</div>
);
}
class SectionList extends StatelessWidget {
final List<Section> sections;
@override
Widget build(BuildContext context) {
final sorted = sections..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return Column(
children: sorted.map((section) => _buildSection(section)).toList(),
);
}
Widget _buildSection(Section section) {
switch (section.type) {
case SectionType.heading:
return _buildHeading(section.data.heading);
case SectionType.text:
return _buildText(section.data.text);
case SectionType.image:
return _buildImage(section.data.image);
case SectionType.code:
return _buildCode(section.data.code);
case SectionType.video:
return _buildVideo(section.data.video);
default:
return SizedBox.shrink();
}
}
Widget _buildImage(ImageData data) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
Image.network(data.url),
if (data.caption != null)
Text(data.caption!, style: TextStyle(color: Colors.grey)),
],
),
);
}
}
A text summary is automatically generated from the section data:
func (s *PostService) generateSummary(sections []*contentV1.Section) string {
var sb strings.Builder
for _, sec := range sections {
switch sec.GetType() {
case contentV1.SectionType_SECTION_TYPE_TEXT:
content := sec.GetData().GetText().GetContent()
sb.WriteString(stripHTML(content))
sb.WriteString(" ")
case contentV1.SectionType_SECTION_TYPE_HEADING:
sb.WriteString(sec.GetData().GetHeading().GetText())
sb.WriteString(" ")
}
if sb.Len() > 200 {
break
}
}
summary := sb.String()
if len(summary) > 200 {
summary = summary[:200] + "..."
}
return summary
}
enum SectionType {
SECTION_TYPE_CALLOUT = 100;
SECTION_TYPE_GALLERY = 101;
SECTION_TYPE_TIMELINE = 102;
SECTION_TYPE_PRICING = 103;
}
const sectionRenderers: Record<number, React.FC<{ data: any }>> = {
100: CalloutSection,
101: GallerySection,
102: TimelineSection,
103: PricingSection,
};
export function SectionRenderer({ section }: { section: Section }) {
const Renderer = sectionRenderers[section.type];
if (Renderer) return <Renderer data={section.data} />;
return <div>未知区块类型</div>;
}
| Check Item | Description |
|---|
| Section message definition | Section type enum + data messages |
| Post section association | sections array field |
| Admin console editor | TipTap + drag-and-drop sorting + section operations |
| Section renderer component | Independent renderer per type |
| Frontend multi-client rendering | React / Flutter / Taro |
| Automatic summary generation | Text extracted from sections |
| Custom sections | Extensible types |