GoWindGoWind
Home
Framework
GoWind Toolkit
  • Open Source

    • GoWind Admin (Admin Platform)
    • GoWind CMS (Content Management)
    • GoWind UBA (Behavior Analytics)
    • GoWind Shop (E-commerce)
    • GoWind Kanban (Kanban Boards)
    • GoWind Ledger (Personal Finance)
    • GoWind ShortPlay (Short Drama)
    • GoWind Ops (Operations Platform)
    • RushWind (Rust Ecosystem)
  • Open Core (Community + Enterprise)

    • GoWind OA (Office Collaboration)
    • GoWind ERP (Procure-to-Pay)
    • GoWind WMS (Warehousing)
  • Commercial

    • GoWind IM (Instant Messaging)
    • GoWind IoT (IoT Platform)
    • GoWind Edu (AI Education)
    • GoWind MES (Manufacturing Execution)
    • GoWind CDC (Data Pipeline)
    • GoWind SDP (Zero-Trust Gateway)
    • GoWind Quant (Quant Research)
    • GoWind Trade Radar (Export Sourcing)
    • GoWind Game Radar (Game Greenlight)
  • 简体中文
  • English
GitHub
Home
Framework
GoWind Toolkit
  • Open Source

    • GoWind Admin (Admin Platform)
    • GoWind CMS (Content Management)
    • GoWind UBA (Behavior Analytics)
    • GoWind Shop (E-commerce)
    • GoWind Kanban (Kanban Boards)
    • GoWind Ledger (Personal Finance)
    • GoWind ShortPlay (Short Drama)
    • GoWind Ops (Operations Platform)
    • RushWind (Rust Ecosystem)
  • Open Core (Community + Enterprise)

    • GoWind OA (Office Collaboration)
    • GoWind ERP (Procure-to-Pay)
    • GoWind WMS (Warehousing)
  • Commercial

    • GoWind IM (Instant Messaging)
    • GoWind IoT (IoT Platform)
    • GoWind Edu (AI Education)
    • GoWind MES (Manufacturing Execution)
    • GoWind CDC (Data Pipeline)
    • GoWind SDP (Zero-Trust Gateway)
    • GoWind Quant (Quant Research)
    • GoWind Trade Radar (Export Sourcing)
    • GoWind Game Radar (Game Greenlight)
  • 简体中文
  • English
GitHub
  • Introduction

    • GoWind CMS Product Introduction
    • GoWind CMS Installation Guide
  • Backend

    • CMS Backend Architecture Overview
    • CMS Backend Modules Overview
    • CMS Protobuf API Definitions
    • CMS Configuration and Deployment Guide
    • Backend Extension Mechanisms Guide
  • Frontend

    • CMS Frontend Architecture
    • CMS Frontend Modules Overview
  • Getting Started

    • Adding a Content Type, Full Stack in Practice
    • Multi-Client API Client Code Generation Tutorial
  • Core Tutorials

    • Content Multilingual Translation Tutorial
    • Content Publishing Workflow Tutorial
    • Section Editor in Practice
    • Headless API Multi-Client Integration in Practice
    • Frontend Application Development in Practice
  • Intermediate Tutorials

    • Multi-Site Management in Practice
    • Media Asset Management in Practice
    • Comment System Tutorial
    • Event Bus Architecture in Practice
    • Lua Script Extensions in Practice
  • Advanced Topics

    • Permission System in Practice
    • Dual-Client Login Security in Practice
    • Encryption and Security Toolkit Tutorial
    • Full-Text Search in Practice
    • Task Scheduling in Practice
    • Real-Time Message Push (SSE) in Practice
    • Performance Monitoring in Practice
    • Dictionary Management System Tutorial
  • Full-Stack Guides

    • Full-Stack Integration in Practice
    • Three-Service Deployment Tutorial

Section Editor in Practice

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.

Prerequisites

  • You have read CMS Frontend Architecture
  • Understand basic concepts of TipTap / rich text editors

1. Section Editor Architecture

1.1 Traditional Rich Text vs. Section Editor

ComparisonTraditional Rich TextSection Editor
Data structureHTML stringJSON section array
Structured dataHard to extractExtractable by section type
ExtensibilityDepends on editor pluginsCustom section types possible
Multi-client renderingRequires HTML parsingEach client renders sections independently
Data migrationDifficultSection-level migration

1.2 Section Types

SectionSectionTypeDescription
Text paragraphSECTION_TYPE_TEXTRich text paragraph (Markdown supported)
HeadingSECTION_TYPE_HEADINGH1-H6 headings
ImageSECTION_TYPE_IMAGESingle/multiple images
Code blockSECTION_TYPE_CODECode highlighting
VideoSECTION_TYPE_VIDEOVideo embedding
QuoteSECTION_TYPE_QUOTEQuoted text
DividerSECTION_TYPE_DIVIDERHorizontal rule
ListSECTION_TYPE_LISTOrdered/unordered lists
TableSECTION_TYPE_TABLEData tables
CarouselSECTION_TYPE_CAROUSELImage carousels
ButtonSECTION_TYPE_BUTTONCTA buttons
EmbedSECTION_TYPE_EMBEDThird-party embeds (YouTube/Twitter)

2. Data Model

2.1 The Section Message Definition

// content/service/v1/section.proto

message Section {
  optional string id = 1;              // 区块 UUID
  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;     // 1-6
  optional string text = 2;
}

message TextData {
  optional string content = 1;   // Markdown 或 HTML
}

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;  // go, javascript, python...
  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;
}

2.2 Sections in the Post

// content/service/v1/post.proto
message Post {
  // ...
  // 内容以区块数组存储
  repeated Section sections = 14 [json_name = "sections"];

  // 兼容字段:纯文本摘要(从区块自动生成)
  optional string summary = 15;
}

2.3 Storage Format

{
  "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 三服务架构"
        }
      }
    }
  ]
}

3. Admin Console Editor

3.1 The TipTap-Based Section Editor

<!-- components/editor/SectionEditor.vue -->
<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 || []);

// TipTap 编辑器配置
const editor = useEditor({
  extensions: [StarterKit, Image, CodeBlockLowlight],
  content: sectionsToHTML(sections.value),
  onUpdate: ({ editor }) => {
    // 将 TipTap JSON 转换为 Section 数组
    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>

3.2 Section Renderer Component

<!-- components/editor/SectionRenderer.vue -->
<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>

4. Frontend Section Rendering

4.1 React Frontend Rendering

// src/components/post/SectionRenderer.tsx
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: // HEADING
              const Tag = `h${section.data.heading?.level || 2}` as keyof JSX.IntrinsicElements;
              return <Tag key={section.id}>{section.data.heading?.text}</Tag>;

            case 2: // TEXT
              return (
                <div
                  key={section.id}
                  className="prose"
                  dangerouslySetInnerHTML={{ __html: section.data.text?.content }}
                />
              );

            case 3: // IMAGE
              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: // CODE
              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: // VIDEO
              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: // QUOTE
              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: // DIVIDER
              return <hr key={section.id} className="my-8" />;

            case 11: // BUTTON
              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>
  );
}

4.2 Flutter Frontend Rendering

// features/post_detail/presentation/widgets/section_list.dart
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)),
        ],
      ),
    );
  }
}

5. Automatic Summary Generation

A text summary is automatically generated from the section data:

// app/core/service/internal/service/post_service.go
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(" ")
        }
        // 截取前 200 字符
        if sb.Len() > 200 {
            break
        }
    }
    summary := sb.String()
    if len(summary) > 200 {
        summary = summary[:200] + "..."
    }
    return summary
}

6. Custom Section Types

6.1 Extending SectionType

// 新增自定义区块类型
enum SectionType {
  // ... 标准类型
  SECTION_TYPE_CALLOUT = 100;     // 提示框
  SECTION_TYPE_GALLERY = 101;     // 图库
  SECTION_TYPE_TIMELINE = 102;    // 时间线
  SECTION_TYPE_PRICING = 103;     // 价格表
}

6.2 Registering Custom Sections on the Frontend

// 注册自定义区块渲染器
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>;
}

7. Checklist

Check ItemDescription
Section message definitionSection type enum + data messages
Post section associationsections array field
Admin console editorTipTap + drag-and-drop sorting + section operations
Section renderer componentIndependent renderer per type
Frontend multi-client renderingReact / Flutter / Taro
Automatic summary generationText extracted from sections
Custom sectionsExtensible types

Related Documents

  • CMS Frontend Architecture
  • Content Publishing Workflow in Practice
  • Media Asset Management in Practice
  • Frontend Application Development in Practice
Edit this page
Last Updated:: 11/3/26, 2:14 PM
Contributors: Bobo
Prev
Content Publishing Workflow Tutorial
Next
Headless API Multi-Client Integration in Practice