feat: Tiptap替换为Vditor + MD存储重构 + Shortcode卡片扩展

- 编辑器:Tiptap 替换为 Vditor 3.11.2(wysiwyg 模式,本地托管 5.8MB,去 CDN)
- 存储:Body 字段从 HTML 改为 Markdown,去除 BodyHTML,新增 Excerpt 列表摘要
- 安全:去除 bluemonday 依赖(MD 纯文本无 XSS 风险),go.mod 已清理
- Shortcode:新增 [zone:type:params] 扩展语法,支持活动/游戏/投票/资源卡片
- 图片上传:POST /api/posts/upload-image 直接返回 Vditor 原生响应格式
- 草稿:localStorage 键名改为 draft_post_body_md,与旧 HTML 草稿隔离
- 详情页:Vditor.method.min.js 客户端 MD → HTML 渲染,去 highlight.js CDN
- 样式:去 Tiptap 样式 ~190 行,精简工具栏 CSS,新增卡片样式
- 文档:vditor-migration-plan.md 完整记录迁移决策、架构、用法
This commit is contained in:
2026-05-30 15:56:22 +08:00
parent c9808ab9b2
commit 6ec12010f3
121 changed files with 8267 additions and 613 deletions

View File

@ -0,0 +1,135 @@
/**
* Shortcode 卡片渲染器 — 配合 [zone:type:params] Markdown 扩展语法
*
* 工作流程:
* 1. 后端 ShortcodeService 将 MD 中的 [zone:type:params] 替换为占位 <div class="zone-card">
* 2. Vditor.preview() 渲染 MD占位 div 原样保留
* 3. 本脚本扫描所有 .zone-card根据 data-zone-* 属性渲染对应卡片 UI
*
* 添加新卡片类型:
* 1. 在 cardRenderers 中注册类型标识和渲染函数
* 2. 渲染函数接收 id 参数,返回 HTML 字符串
* 3. 如果后端 API 已可用,在渲染函数中 fetch 数据;否则展示静态占位卡片
*
* 后端 shortcode 语法参考: /docs/vditor-migration-plan.md
*/
(function () {
'use strict';
// =========================================================================
// 卡片渲染函数注册表
//
// 每个渲染函数签名为: function(id: string) → html: string
// id 来自 [zone:type:id] 中的 id 部分
// =========================================================================
var cardRenderers = {
/**
* 活动卡片 — [zone:event:活动ID]
* TODO: 接入活动 API 后改为 fetch 动态数据
*/
event: function (id) {
return '' +
'<div class="zone-card-event">' +
' <div class="zone-card-event-icon">🎪</div>' +
' <div class="zone-card-event-body">' +
' <div class="zone-card-event-title">官方活动</div>' +
' <div class="zone-card-event-id">活动 ID: ' + escapeHtml(id) + '</div>' +
' </div>' +
' <a class="zone-card-event-link" href="/events/' + escapeHtml(id) + '" target="_blank">查看详情 →</a>' +
'</div>';
},
/**
* 游戏卡片 — [zone:game:游戏slug]
* TODO: 接入游戏数据库 API 后改为 fetch 动态数据
*/
game: function (id) {
return '' +
'<div class="zone-card-game">' +
' <div class="zone-card-game-icon">🎮</div>' +
' <div class="zone-card-game-body">' +
' <div class="zone-card-game-title">相关游戏</div>' +
' <div class="zone-card-game-slug">' + escapeHtml(id) + '</div>' +
' </div>' +
' <a class="zone-card-game-link" href="/games/' + escapeHtml(id) + '" target="_blank">查看 →</a>' +
'</div>';
},
/**
* 投票组件 — [zone:poll:投票ID]
* ※预留:后端投票 API 未实现,当前展示占位卡片
*/
poll: function (id) {
return '' +
'<div class="zone-card-poll">' +
' <div class="zone-card-poll-icon">📊</div>' +
' <div class="zone-card-poll-body">' +
' <div class="zone-card-poll-title">社区投票</div>' +
' <div class="zone-card-poll-id">投票 ID: ' + escapeHtml(id) + '</div>' +
' </div>' +
' <span class="zone-card-poll-pending">即将开放</span>' +
'</div>';
},
/**
* 资源卡片 — [zone:resource:资源ID]
* ※预留:后端资源 API 未实现,当前展示占位卡片
*/
resource: function (id) {
return '' +
'<div class="zone-card-resource">' +
' <div class="zone-card-resource-icon">📦</div>' +
' <div class="zone-card-resource-body">' +
' <div class="zone-card-resource-title">推荐资源</div>' +
' <div class="zone-card-resource-id">资源 ID: ' + escapeHtml(id) + '</div>' +
' </div>' +
' <a class="zone-card-resource-link" href="/resources/' + escapeHtml(id) + '" target="_blank">查看 →</a>' +
'</div>';
}
};
// =========================================================================
// 工具函数
// =========================================================================
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// =========================================================================
// 主渲染逻辑
// =========================================================================
/**
* renderShortcodes — 扫描容器内所有 .zone-card 占位并渲染
* @param {HTMLElement} container — 包含 shortcode 占位的 DOM 容器
*/
window.renderShortcodes = function (container) {
if (!container) return;
var placeholders = container.querySelectorAll('.zone-card');
for (var i = 0; i < placeholders.length; i++) {
var el = placeholders[i];
var type = el.getAttribute('data-zone-type');
var id = el.getAttribute('data-zone-id');
if (!type || !id) continue;
var renderer = cardRenderers[type];
if (!renderer) continue;
// 渲染卡片 HTML 并替换占位 div
try {
el.outerHTML = renderer(id);
} catch (e) {
// 渲染失败时保留占位文本
console.warn('[Shortcode] render failed for type=' + type + ' id=' + id, e);
}
}
};
})();