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:
135
templates/MetaLab-2026/static/js/shortcode.js
Normal file
135
templates/MetaLab-2026/static/js/shortcode.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user