DjangoBlog项目实战:从数据模型到部署排错全流程
2026/9/15 3:33:14 网站建设 项目流程

前阵子有朋友问我:只做一个个人博客,有必要上 Django 吗?我几乎是秒回的——如果你不想把半年时间耗在造轮子上,那就很有必要。DjangoBlog 这类项目乍看就是一套简单的 CRUD:发文章、改文章、删文章。可真等你把分类、标签、后台发布、阅读量统计、资源下载、部署上线全部串在一起,框架选型就决定了后面要踩多少雷。我最早用 Flask 手写过一个小博客,跑了大半年后整体迁到了 Django,核心原因就两个:自带的后台管理太好用了,ORM 对查询和删除的抽象也足够省心。这篇文章就结合我自己做 DjangoBlog 的完整过程,把从项目搭建、数据模型、admin 后台、视图模板,到 MySQL 接入和上线排错的经验一次讲透。适合已经有 Python 基础、想拿一个完整项目练手的人看,也适合那些 Djangoo 入门后发现教程太碎、想系统梳理一遍的读者。

1. 技术选型:内容型站点用 Django 到底赢在哪

1.1 和 Flask、Spring Boot 的直观对比

做技术选型时,很多人喜欢纠结框架排名,我说句实在话:选型要看项目类型。博客是典型的“读多写少”内容型站点,核心诉求不是并发有多高,而是开发效率、内容维护体验、SEO 亲和力这三件事。

框架后台管理ORM 成熟度学习成本适合方向
Django自带 Admin,开箱即用高,多表关联和迁移很顺手中,概念集中内容型站点、管理后台、运营系统
Flask无,需自己拼中,常配 SQLAlchemy低,微框架自由度高轻接口、原型、小工具
Spring Boot需整合 Spring Security 等企业级中后台、大型分布式

这里必须强调一点:后台管理对内容型产品不是“锦上添花”,而是生命线。写博客的人最核心的重复劳动就是发布和修改内容,Django 自带 Admin 等于项目才起步就把这套运营工具白送给你了。Flask 不是不能用,但你要自己写表单、自己写登录、自己处理权限,这些时间足够你把博客的核心业务逻辑再打磨两遍了。

1.2 MVT 模型在博客里的真实映射

Django 说的 MVT,也就是 Model、View、Template,它在博客项目里的映射非常清晰:

  • Model 对应数据库表,比如 Post 表、Category 表、Tag 表;
  • View 是业务逻辑层,负责从数据库取数据、判断状态、把数据交给模板;
  • Template 负责渲染 HTML,模板语言里的{{ object }}这类语法就是面向这个场景设计出来的。

很多初学者容易被“前后端分离”带偏,一上来就想 Django 只做 API,前端再用 Vue 或 React 重写一套。对于博客这种对 SEO 敏感的项目,我劝你不要这么做。服务端直接渲染 HTML,搜索引擎爬虫拿到的是完整页面,文章详情页、分类页都能直接被收录。这套 DjangoBlog 我坚持用服务端渲染,原因就在这里。你当然可以在某个子模块里用 Django 写接口给移动端调,但主体页面走 Template 渲染是内容型项目的最佳路径。

2. 项目骨架和数据模型:写代码之前先决策

2.1 创建 Django 项目和 app

动手第一步不是写代码,而是把项目结构想清楚。通常一个博客系统会有两个核心模块:用户认证模块和博客内容模块。用户认证 Django 已经内置了,所以我们只需要额外创建一个 blog app。

# 安装 Django,建议创建虚拟环境后装 pip install django # 创建项目,DjangoBlog 这里的名字你可以改成自己想要的 django-admin startproject DjangoBlog cd DjangoBlog # 创建博客应用 python3 manage.py startapp blog

创建完后的目录大概是这样的:

DjangoBlog/ ├── DjangoBlog/ │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── blog/ │ ├── migrations/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ ├── tests.py │ └── views.py └── manage.py

关键一步是把 blog 注册进INSTALLED_APPS。很多人漏了这一步,结果后面执行迁移时发现表建不出来,还以为是代码写错了。

# DjangoBlog/settings.py INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "blog", # 新加的 app ]

关于中文路径和 Python 环境,这里多说一句:项目目录别带中文,Python 解释器尽量用 3.8 以上版本。我见过不少新人因为虚拟环境里装了多个 Python,导致命令行pythonpython3指向不同解释器,最后迁移时数据库版本对不上,排查半天才发现问题出在环境上。

2.2 文章、分类、标签的模型设计

模型是整个博客系统的地基。Post 表里不是塞一个 title 和 content 就完了,我建议把核心扩展字段提前设计好。下面这个模型是我在 DjangoBlog 里的实际写法,可以直接参考:

# blog/models.py from django.db import models from django.utils import timezone from django.urls import reverse class Category(models.Model): name = models.CharField("分类名", max_length=50) slug = models.SlugField("别名", unique=True) class Meta: verbose_name = "分类" verbose_name_plural = "分类" def __str__(self): return self.name class Tag(models.Model): name = models.CharField("标签名", max_length=30) slug = models.SlugField("别名", unique=True) class Meta: verbose_name = "标签" verbose_name_plural = "标签" def __str__(self): return self.name class Post(models.Model): STATUS_CHOICES = ( ("draft", "草稿"), ("published", "已发布"), ) title = models.CharField("标题", max_length=200) slug = models.SlugField("链接别名", unique=True) summary = models.TextField("摘要", max_length=500, blank=True) category = models.ForeignKey( Category, verbose_name="分类", on_delete=models.SET_NULL, null=True, blank=True, ) tags = models.ManyToManyField(Tag, verbose_name="标签", blank=True) author = models.ForeignKey( "auth.User", verbose_name="作者", on_delete=models.CASCADE, ) content = models.TextField("正文") status = models.CharField( "状态", max_length=10, choices=STATUS_CHOICES, default="draft" ) views = models.PositiveIntegerField("阅读量", default=0) created_at = models.DateTimeField("创建时间", default=timezone.now) updated_at = models.DateTimeField("更新时间", auto_now=True) class Meta: ordering = ["-created_at"] verbose_name = "文章" verbose_name_plural = "文章" def __str__(self): return self.title def get_absolute_url(self): return reverse("blog:post_detail", args=[self.slug])

这里有几个设计决策要解释清楚:

第一个是categorytags分别用了外键和多对多。分类是树形结构,一篇文章属于一个分类,用 ForeignKey 最自然;标签是一篇文章可以打多个标签,反过来一个标签也能挂在多篇文章下,这是典型的多对多关系,直接用 ManyToManyField 就好。

第二个是Categoryon_delete=models.SET_NULL。这是我从坑里爬出来后的选择。早期我把外键写成默认的CASCADE,结果运营人员在后台删掉了一个分类,连带这个分类下二十多篇文章全部被级联删除了。文章内容是创作者最宝贵的资产,分类删错了可以新建,文章没了就是灾难。所以内容类模型我建议优先用SET_NULL,让关联关系断裂而不是把数据吞掉。

第三个是slug字段。很多简单教程会用文章 ID 做详情页 URL,比如/post/123/。这样做短期没问题,但对 SEO 不友好。Slug 是文章标题转成的 URL 别名,比如“DjangoBlog 博客系统实战”会变成djangoblog-blog-system-guide,阅读者看到链接大概就知道内容是什么。Django 里SlugField会自动帮你处理非法字符,再配合 admin 里的prepopulated_fields就能自动填充。

2.3 迁移流程:数据库表结构变更的流水账

写完 models.py 之后,接下来是 Django 不同于其他框架的一个关键动作:迁移。

# 在项目根目录执行 python3 manage.py makemigrations blog python3 manage.py migrate

makemigrations会根据模型改动生成一个迁移文件,它就像数据库的“流水账”,记录了哪张表新增了字段、哪个字段改了类型。真正执行migrate时才把变更同步到数据库。

很多初学者搞不清 migrations 文件夹里那些文件是干嘛的,于是直接删掉重来。我建议不要这样做。迁移文件一旦被其他同事同步过,很可能造成历史记录不一致,导致后面 migrate 各种报错。遇到想调整模型的情况,正确姿势是:改 models.py,然后删掉出错的那个迁移文件里你不想要的历史是可以的,但一旦上了生产环境,优先新增迁移文件而不是回滚删除。

另外,启动项目前记得创建一个超级用户,否则你连后台都进不去:

python3 manage.py createsuperuser

3. 管理后台:从“能用”到“好用”的 admin 调优

3.1 注册模型时的字段清单

Django admin 不是摆设。如果你只是注册一下模型就开用,那它只能算“能用”;真正让它“好用”,需要动一点配置。先看我在admin.py里是怎么写的:

# blog/admin.py from django.contrib import admin from .models import Category, Tag, Post @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display = ("name", "slug") search_fields = ("name",) @admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display = ("name", "slug") search_fields = ("name",) @admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display = ("title", "category", "status", "views", "created_at") list_filter = ("status", "category", "tags") search_fields = ("title", "summary", "content") prepopulated_fields = {"slug": ("title",)} date_hierarchy = "created_at" list_editable = ("status",) list_per_page = 30

这段配置每行都有讲究:

  • list_display决定列表页显示哪些列。文章标题、分类、状态、阅读量、创建时间,一眼扫过去就知道内容运营的全貌。
  • list_filter按状态、分类、标签做筛选,在文章量上了千篇之后尤其好用。运营想看“所有草稿”或“某个分类下已发布的内容”,点一下就能筛出来。
  • search_fields设置搜索范围,标题和正文都算上,方便快速定位。
  • date_hierarchy会在列表页顶部生成一个时间层级导航,可以按年、按月筛选文章,对内容库特别实用。
  • list_editable允许直接在列表页改状态,发布、撤回都不要再点进详情页。

一个小提醒:list_editable的字段不能出现在list_display的第一列,因为第一列默认是链接入口,两个功能会冲突。如果你想让某个字段既能点进详情又能在列表页编辑,布局上要注意避开第一列。

3.2 定制按钮、搜索与批量操作

Admin 里的方法可以做更多事情。比如我给文章列表加了一个“复制文章”的功能,运营想写同类型内容时可以直接复制草稿再改,省去重复搭建框架的时间:

# blog/admin.py from django.http import HttpResponseRedirect @admin.register(Post) class PostAdmin(admin.ModelAdmin): # ... 前面的配置省略 ... actions = ["copy_post"] def copy_post(self, request, queryset): count = 0 for post in queryset: post.pk = None post.status = "draft" post.title = post.title + "_副本" post.views = 0 post.save() count += 1 self.message_user(request, f"已复制 {count} 篇文章") copy_post.short_description = "复制所选文章" def save_model(self, request, obj, form, change): if not change: obj.author = request.user super().save_model(request, obj, form, change)

save_model这个重写很有价值。通常我们不想让发布者在创建文章时手动选作者,而是默认取当前登录用户,这样既省事,又避免权限错乱。另外,如果你管理的文章表已经涨到几万条,建议在PostAdmin里加一个get_queryset方法,用select_related把关联的分类、作者查出来,不然列表页每显示一行就会多一次 SQL 查询,页面会明显变慢。

3.3 后台美化的常用思路

后台美化是热搜词里被问得很多的点。说直接点:admin 原版界面确实比较朴素,但它最大的价值是稳定和可预期。美化可以从几个方向考虑:

  • 给 admin 整体换模板,比如覆盖admin/base_site.html,替换站点标题和 CSS 变量;
  • 用第三方主题,如 django-simpleui、django-grappelli,但要注意版本兼容性;
  • settings.py里给admin.site.site_header设置成自己的品牌名,这是最低成本的美化。

我给 DjangoBlog 用的是自定义 base_site 模板方案。因为博客站点的后台只需要改个标题、换个配色、调整一下侧边栏文字,就能让运营同事觉得“这系统是我们自己做的”,没必要为了外观引入整套组件库从而提高升级维护成本。这里的原则是:能改样式变量解决的问题,就别引入新的依赖。

4. 从 URL 到页面:路由、视图、模板的完整串联

4.1 URL 设计:别把所有逻辑堆在项目级 urls.py

项目级urls.py应该只做分发,具体业务路由放到 app 下的urls.py,这样模块边界清晰,以后扩展新功能不用改全局文件。

# DjangoBlog/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), path("", include("blog.urls")), ]
# blog/urls.py from django.urls import path from . import views app_name = "blog" urlpatterns = [ path("", views.post_list, name="post_list"), path("post/<slug:slug>/", views.post_detail, name="post_detail"), ]

这里我用的是<slug:slug>而不是<str:slug>。Path 转换器里,slug只匹配字母、数字、连字符和下划线,天然过滤掉 URL 里的非法字符,安全性更好。如果用str,你大概率要自己再做一遍清洗。

4.2 列表页视图与分页

列表页我用的不是函数式视图写死一切,而是直接操作 QuerySet 加 Django 内置的 Paginator。

# blog/views.py from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .models import Post def post_list(request): posts = ( Post.objects .filter(status="published") .select_related("category") ) paginator = Paginator(posts, 10) page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) return render(request, "blog/post_list.html", {"page_obj": page_obj})

分页这里有两个关键知识点:Paginator.get_page(page_number)page(page_number)更抗造,它遇到非法页码时返回第一页,而不是直接抛 404;select_related可以避免列表页每篇文章都查询一次分类表,这就是N+1问题的典型解法。

对应的模板片段大概是这样的:

<!-- blog/templates/blog/post_list.html --> {% for post in page_obj %} <article> <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2> <p>{{ post.summary }}</p> <span>{{ post.category.name }} | {{ post.created_at|date:"Y-m-d" }}</span> </article> {% empty %} <p>还没有发布文章</p> {% endfor %} <div class="pagination"> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}">上一页</a> {% endif %} <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span> {% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}">下一页</a> {% endif %} </div>

4.3 详情页、跳转和数据传递

详情页直接用get_object_or_404,顺着 slug 找文章,找不到就让 Django 自动返回 404 页面:

def post_detail(request, slug): post = get_object_or_404(Post, slug=slug, status="published") return render(request, "blog/post_detail.html", {"post": post})

说到跳转和数据传递,微信群里常有人问“Django 重定向怎么带参数”。这个要分场景:

  • 如果是一次性提示,比如“保存成功”,用 Django 内置的 messages 框架,后台self.message_user就是它的封装;
  • 如果是临时状态数据,可以放 session;
  • 如果希望刷新后 URL 里还能看到参数,那就拼查询字符串:reverse("blog:post_detail", args=[post.slug]) + "?from=admin"

我之前见过有人把提示信息拼在 URL 查询参数里,然后一路从视图传到模板再手动读request.GET.get("status"),这完全是重复造轮子。消息提示用官方 messages 就够了:视图里写messages.success(request, "文章已发布"),模板里{% for message in messages %}依次渲染。

5. ORM 查询与删除:这些行为边界必须清楚

5.1 QuerySet 是惰性的,不等于列表

热搜词里有一条是“django 执行查询-删除对象”,这其实对应了一个高频误区:QuerySet 是惰性的。很多初学者以为posts = Post.objects.filter(status="published")这一行一执行,SQL 就发到数据库了。其实不是。QuerySet 在你真正用到结果时才去查库,比如遍历它,或者强转 list。

# 这行不会立刻查库 posts = Post.objects.filter(status="published") # 遍历时才真正执行 SQL for post in posts: print(post.title)

延迟加载本身是为了提高效率,但也带来两个坑:

第一个是“重复查询”。同一个 QuerySet 被遍历两次,Django 会执行两次 SQL。如果数据量不大倒还好,但列表页一旦做了复杂过滤,效率会很难看。需要多次使用时,请先list()一下,或者直接复用同一个 QuerySet 对象,Django 有结果缓存。

第二个是我踩过的:切片会强制 QuerySet 执行 SQL。posts[:10]这一步会发出带 LIMIT 的查询,如果你没注意好顺序,后续再对切片结果做操作可能拿到的是旧数据。这也是为什么分页逻辑最好放在视图层统一处理,而不是在模板里反复切片。

5.2 删除对象时最容易踩的级联坑

删除操作的坑比查询更隐蔽。Django 的delete()方法是会收集关联对象的。比如我们删除一篇文章:

post = Post.objects.get(pk=1) post.delete()

你以为只删了这一行,其实 Django 会先检查需要一起删除的相关对象。如果某个外键的on_deleteCASCADE,那么关联它的对象也会被一并删除,并且返回值会告诉你删了多少对象。

result = post.delete() print(result) # (6, {'blog.Post': 1, 'blog.Post_tags': 3, ...})

这个返回值堪称“清除菜单”,它把这次删除操作波及到的所有表和记录数都列出来了。我强烈建议你在执行删除前后都打印一下这个结果,尤其在对分类、标签这种被多篇文章引用的对象动手之前。

再回到分类的外键设计。我前面把Categoryon_delete设置成了SET_NULL,本质就是为了防止误删分类时把文章一起带走。如果你已经写成了CASCADE,同时想临时做一次批量删除,至少要先看清会连带删掉什么:

category = Category.objects.get(pk=3) # 看看这个分类下有多少文章,再决定删不删 print(category.post_set.count())

5.3 批量操作与性能

Django 的批量删除有两种姿势:对单个 QuerySet 调用 delete,或者循环中逐个删。两者差别很大:

# 推荐:一条 DELETE SQL Post.objects.filter(status="draft").delete() # 不推荐:产生 N 条 SQL for post in Post.objects.filter(status="draft"): post.delete()

批量 delete 的难点在于,如果模型存在关联对象,Django 会先把受影响的主键收集齐,再逐张表去删,这个过程中可能产生大量查询。所以对大型数据表做批量删除时,最好放事务里,同时先统计好数量。我自己在处理脏数据时就遇到过一次性删几千条草稿、数据库锁了十几秒的情况,后来改成凌晨低峰期执行,并且把删除条件加索引,速度立刻不一样了。

6. 接入 MySQL 与文件下载:上线前的两个硬骨头

6.1 mysqlclient 安装与数据库配置

博客数据量大了之后,SQLite 一般撑不住并发写,多数项目会切到 MySQL。这里最大的坎经常出现在安装mysqlclient这个依赖上。

pip install mysqlclient

如果你运气不好,可能会碰到mysql_config not found或各种编译错误。原因是 mysqlclient 依赖 MySQL 的 C 客户端库。根据不同系统,先装系统依赖再重试:

# Debian / Ubuntu sudo apt-get install python3-dev default-libmysqlclient-dev build-essential # macOS brew install mysql-client echo 'export PATH="/usr/local/opt/mysql-client/bin:$PATH"' >> ~/.zshrc

如果编译环境一直搞不定,也可以退而求其次用 PyMySQL,然后在项目__init__.py里做兼容。但我的建议是:生产环境最好还是解决 mysqlclient,它的性能和稳定性更靠谱,PyMySQL 作为备用方案没问题,别长期依赖。

配置好依赖后,数据库连接信息在settings.py里要改成这样:

DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "djangoblog", "USER": "root", "PASSWORD": "your_password", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": {"charset": "utf8mb4"}, } }

utf8mb4这个字符集必须显式指定,否则 emoji 和一些生僻汉字会存不进去,报错让你完全摸不着头脑。字符集问题属于那种“看数据库全表都是正常的数据,但写入就报错”的隐藏地雷。

6.2 FileResponse 和 StreamingHttpResponse 的选择

博客系统里经常有附件下载需求,比如上传 PDF、压缩包、代码示例。热搜里提到的StreamingHttpResponse和它的content_typeContent-Disposition参数,这里一并说清楚。

先明确一个原则:下载文件优先用FileResponse,它是 Django 为文件下载专门封装的响应类,内部会用分块方式把文件流式写入客户端,不会把整个文件一次性读进内存。StreamingHttpResponse适合的是你手头有一个生成器或迭代器、动态产生内容的情况。

# blog/views.py from django.http import FileResponse from django.utils.http import urlquote def download_file(request, file_path): response = FileResponse( open(file_path, "rb"), content_type="application/octet-stream", ) # 关键:Content-Disposition 控制浏览器以下载方式处理 response["Content-Disposition"] = "attachment; filename*=UTF-8''" + urlquote(file_name) return response

这里有两个容易踩的细节。

第一个是content_typeapplication/octet-stream这个类型告诉浏览器“别尝试解析,当成二进制流下载”。如果你设成text/html,浏览器很可能直接把它在页面上打开,而不是触发下载。

第二个是中文文件名。Content-Disposition直接写attachment; filename="攻略.pdf"在老旧浏览器里会乱码。用filename*=UTF-8''加 URL 编码,是目前兼容性最好的写法。另外,文件路径一定要校验,防止用户传入..穿越目录读取服务器上其他敏感文件。基础的防护就是先把路径规范化,再判断它是否在允许的下载目录内。

7. 部署联调与一次真实排错复盘

7.1 静态文件收集与 host 配置

本地开发时,Django 会自动处理静态文件;但到了生产环境,通常不会再用 Django 进程直接服务静态资源。常规做法是:settings.py里配置好STATIC_ROOT,然后执行python3 manage.py collectstatic,把散落在各个 app 里的静态文件收集到一个统一目录。

部署后常见的 400 错误,多半是ALLOWED_HOSTS没有把域名加进去:

ALLOWED_HOSTS = ["your-domain.com", "www.your-domain.com"]

如果你用了 Nginx 反代,还要保证X-Forwarded-ForX-Forwarded-Proto这两个头被正确传递,否则后台的 HTTPS 跳转会无限循环。

7.2 一次 500 错误的完整排查链路

分享一次我实际处理过的 500 问题。现象是:DjangoBlog 后台列表页能打开,但点进某篇具体文章就 500。我第一反应是模板报错,但本地跑同样代码又是好的,这说明大概率不是语法问题,而是环境和数据差异。

排查链路按这个顺序走:

  1. 看日志。打开DEBUG=False之后,Django 的 500 页面不再显示具体异常,必须去服务日志里找关键报错。当时日志里写的是database is locked
  2. 定位锁来源。SQLite 在并发写时很容易锁库,后台自动保存、日志写入、定时任务同时执行,就可能触发。
  3. 确认场景。那篇文章恰好是运营正在编辑保存时被访问,写锁阻塞了读请求。
  4. 解决方向。我把数据库切到了 MySQL,锁粒度更细,并发性能也更好。之后同类问题就没再出现过。

这次排查给我最深的印象是:不要一上来就怀疑代码逻辑,先把DEBUG=FalseALLOWED_HOSTS、数据库权限、静态文件路径这些部署四件套检查完,它们才是线上出问题的最大来源。如果日志里只给了一行很隐晦的异常,就去 settings 里临时打开LOGGING配置,把 SQL 也打出来,很多时候 500 的答案就藏在最后一条 SQL 里。

另外再补充一个常用的定位技巧:生产环境把django-debug-toolbar挂上去并不合适,你可以在本地复制线上的一行数据,用manage.py shell手工执行一遍视图函数,这样能快速把环境差异和数据差异分开。数据差异导致的 500 很容易被忽略,比如某篇文章的摘要超过了max_length对应的数据库字段长度,MySQL 在严格模式下会直接报错,而在本地 SQLite 上可能没这么敏感。遇到这种问题,用同一个数据样本在本地重现是最快的办法。

做完 DjangoBlog 这个项目后,我自己最大的体会是:博客系统的难度不在某单个功能,而在瞬间把 Django 的各个零件同时拼起来的能力。路由怎么分、模型外键怎么设、admin 怎么配、ORM 查询何时执行、响应头怎么写,每一个环节单独看都有教程,但它们连在一起时才会真实地爆发问题。如果你也正卡在某个细节上,可以参考我上面这些处理思路,先用最小步骤跑通,再逐步把条件加上去。后面我还计划给这个博客加上全文搜索、RSS 订阅、Markdown 编辑器支持,这些扩展点也都是在现有模型结构上继续生长。先把地基打扎实,后面加功能会顺畅非常多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询