居然能找到这里来嘛,这里只是用来放笔记的图书馆哦~
4843 字
24 分钟
【笔记】Fuwari主题下的DecapCMS编辑器替换
前言:
还是老样子前言开篇,先展示一下成果

我们看图说话吧。当然幻梦在编辑这篇文档时是看不到图片的,你们都能看到,而这就是第一个问题。Fuwari导致的我们必须相对文档“./”来选择图片,这东西只有Fuwari看得懂其他没有任何一个工具能看懂,现在也解决不掉预览的问题。当然,其实有一个缺德一点的办法,直接用jsdelivr这种cdn去加载github仓库里的图片,不再使用相对路径,不过这其实就是滥用了,jsdelivr本体就是被滥用导致备案挂掉的。属于理论上可行,但是过于缺德,不推荐。
然后就是右上角的这个提示了,目前看起来很好,实际上代码的实现很不优雅。DecapCMS是有一个提示功能的,但是官方没有把方法暴露出来,无法调用(Decap基于React框架,为了安全没有暴露很多方法,其中提示功能是基于Toastify库完成的)。我们就只能模拟一下Toastify了
接下来是编辑器,这个编辑器的超链接按钮是一个残废的状态(快捷键好使但是按钮残废,是否触发全看运气),编辑器使用的是vditor。
工作代码:
其实也没什么好看的了,最后偷懒让Ai改的错,已经变成Qwen模型的样子了。不过也不考虑后期维护了,就这样吧。
(function () { 'use strict'; // 防止重复加载 if (window.decapCmsVditorPlugin) return;
// 创建一个模拟Toastify的通知函数 function showToast(message, type = 'info', duration = 5000) { // 检查是否已有Toastify容器,如果没有则创建 let container = document.querySelector('.Toastify'); if (!container) { container = document.createElement('div'); container.className = 'Toastify'; document.body.appendChild(container); }
// 创建toast元素 const toast = document.createElement('div'); toast.className = `vditor-toast Toastify__toast Toastify__toast--${type}`;
// 添加内容 const body = document.createElement('div'); body.className = 'Toastify__toast-body'; body.innerHTML = `<div>${message.replace(/\n/g, '<br>')}</div>`;
toast.appendChild(body);
// 添加关闭按钮 const closeButton = document.createElement('button'); closeButton.className = 'Toastify__close-button'; closeButton.innerHTML = '×';
closeButton.onclick = () => { if (container.contains(toast)) { toast.classList.add('fade-out'); setTimeout(() => { if (container.contains(toast)) { container.removeChild(toast); } }, 300); } };
toast.appendChild(closeButton);
container.appendChild(toast);
// 自动移除toast const timer = setTimeout(() => { if (container.contains(toast)) { toast.classList.add('fade-out'); setTimeout(() => { if (container.contains(toast)) { container.removeChild(toast); } }, 300); } }, duration); }
// 测试通知功能的方法 function testNotifications() { showToast('测试成功通知', 'success', 10000); showToast('测试错误通知', 'error', 10000); showToast('测试警告通知', 'warning', 10000); showToast('测试信息通知', 'info', 10000); }
// 将测试函数暴露到全局,方便调试 window.testVditorNotifications = testNotifications;
function init() { if (!window.createClass || !window.h) { setTimeout(init, 100); return; } registerPlugin(); }
// 合并媒体文件到主分支的函数 async function mergeMediaToMain() { // 检查媒体分支是否存在 const token = ImageUploadManager.getToken(); const { repoOwner, repoName, branch: mainBranch, mediaBranch } = ImageUploadManager.config;
try { // 检查媒体分支是否存在 const branchCheckUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/branches/${mediaBranch}`; const branchCheckRes = await fetch(branchCheckUrl, { headers: { Authorization: `token ${token}` } });
if (branchCheckRes.status === 404) { // 媒体分支不存在,无需合并 console.log('媒体分支不存在,无需合并'); return; } else if (!branchCheckRes.ok) { const errorData = await branchCheckRes.text(); console.error(`检查分支状态失败: ${branchCheckRes.status}, ${errorData}`); return; }
// 如果媒体分支存在,则执行合并 console.log('开始合并媒体分支到主分支...');
const mergeRes = await fetch(`https://api.github.com/repos/${repoOwner}/${repoName}/merges`, { method: 'POST', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ base: mainBranch, head: mediaBranch, commit_message: '[Auto] Merge media assets to main' }) });
if (mergeRes.ok) { console.log('成功将媒体分支合并到主分支'); showToast('✅ 成功将媒体分支合并到主分支', 'success');
// 合并成功后删除媒体分支 try { const deleteUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/git/refs/heads/${mediaBranch}`;
const deleteRes = await fetch(deleteUrl, { method: 'DELETE', headers: { Authorization: `token ${token}` } });
if (deleteRes.ok) { console.log('成功删除媒体分支'); showToast('🗑️ 成功删除媒体分支', 'info'); } else { const errorData = await deleteRes.text(); console.error('删除分支失败:', deleteRes.status, errorData); } } catch (error) { console.error('删除媒体分支失败:', error); // 不抛出错误,因为合并已经成功 }
// 清空已上传但未提交的记录 ImageUploadManager.uploadedButUncommitted.clear(); } else if (mergeRes.status === 409) { // 合并冲突,可能需要手动处理 console.warn('媒体分支与主分支存在冲突,无法自动合并'); showToast('⚠️ 媒体分支与主分支存在冲突,无法自动合并', 'warning', 5000); } else { const errorText = await mergeRes.text(); console.error(`合并失败: ${mergeRes.status}`, errorText); showToast(`❌ 合并失败: ${mergeRes.status}`, 'error', 5000); } } catch (error) { console.error('合并媒体分支时出错:', error); showToast(`❌ 合并媒体分支时出错: ${error.message}`, 'error', 5000); } }
// Slug工具类,用于处理slug相关的功能 const SlugUtils = { // 从URL中提取slug extractSlugFromUrl() { const url = window.location.href; try { // 提取URL中的slug部分,例如从 https://www.yumehinata.com/admin#/collections/terminal/entries/slug const match = url.match(/\/entries\/([^\/\?#]+)/); if (match && match[1]) { const decodedSlug = decodeURIComponent(match[1]); // 解码URL编码的slug // 检查是否是有效的slug格式(不是特殊占位符或无法解码的内容) if (decodedSlug && decodedSlug !== 'new' && decodedSlug !== 'create' && decodedSlug !== 'default') { return decodedSlug; } } } catch (e) { console.error('无法从URL中提取slug:', e); } return null; // 返回null表示无法获取有效slug },
// 获取当前文档的slug,如果无法获取则抛出错误 getCurrentSlug() { const slug = this.extractSlugFromUrl();
if (!slug) { // 使用自定义通知而不是直接抛出错误(虽然仍需抛出以中断流程) // 但在抛出前显示通知 showToast('无法确定文章标识符,请先保存文章标题后再上传图片', 'error', 4000); throw new Error('无法确定文章标识符,请先保存文章标题后再上传图片'); } // 保留字母、数字、中文、连字符、下划线,替换其他特殊字符为下划线 return slug.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_'); } };
// 全局变量用于存储待提交的图片 let pendingMediaFiles = [];
const ImageUploadManager = { pendingImages: [], isUploading: false, uploadedButUncommitted: new Set(), // 初始化为Set对象
config: { repoOwner: 'YumeHinata', repoName: 'AstroBlog', branch: 'main', mediaFolder: 'src/content/posts', mediaBranch: 'cms/media-assets' // 添加媒体分支配置 },
commitConfig: { authorName: 'Decap CMS Editor', authorEmail: 'editor@yumehinata.com', commitPrefix: '[Media] Upload: ' },
getToken() { try { const userData = JSON.parse(localStorage.getItem('decap-cms-user')); if (!userData?.token) throw new Error('请先登录Decap CMS'); return userData.token; } catch (e) { throw new Error('认证失败: ' + e.message); } },
// 使用SlugUtils来获取slug extractSlugFromUrl: SlugUtils.extractSlugFromUrl,
getCurrentSlug: SlugUtils.getCurrentSlug,
calculatePaths(filename) { const mediaFolder = this.config.mediaFolder.replace(/^\//, '');
// 获取当前文档的slug const slug = this.getCurrentSlug();
// 保留字母、数字、中文、连字符、下划线和点,替换其他特殊字符为下划线 const safeFilename = filename.replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]/g, '_');
// 在post目录下的images子目录中创建以slug命名的子目录存放图片 const pathInRepo = `${mediaFolder}/images/${slug}/${safeFilename}`; const markdownPath = `./images/${slug}/${safeFilename}`; return { pathInRepo, markdownPath }; },
addImages(files) { const newImages = Array.from(files).map(file => ({ file, previewUrl: URL.createObjectURL(file), name: file.name, size: file.size, id: Date.now() + Math.random() }));
this.pendingImages.push(...newImages); return newImages; },
cleanupPreviews() { this.pendingImages.forEach(img => URL.revokeObjectURL(img.previewUrl)); this.pendingImages = []; },
// 上传所有图片并返回markdown字符串 async uploadAll(vditorInstance) { if (this.pendingImages.length === 0) throw new Error('没有图片需要上传'); if (this.isUploading) throw new Error('上传正在进行中');
this.isUploading = true;
// 在开始上传前验证能否获取到slug try { this.calculatePaths('test.jpg'); // 只是为了验证能否成功计算路径 } catch (error) { console.error("路径计算失败:", error); throw new Error(error.message); }
const token = this.getToken(); const { repoOwner, repoName, mediaBranch = 'cms/media-assets' } = this.config; // 设置默认值 const commitCfg = this.commitConfig;
const results = { success: 0, errors: [], markdowns: [] };
try { // 确保媒体分支存在 await this.ensureMediaBranchExists(token, repoOwner, repoName, mediaBranch);
for (const img of this.pendingImages) { try { const { pathInRepo, markdownPath } = this.calculatePaths(img.name);
// 检查文件是否已存在 const checkUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${pathInRepo}?ref=${mediaBranch}`; const checkRes = await fetch(checkUrl, { headers: { Authorization: `token ${token}` } });
if (checkRes.ok) { // 文件已存在,直接使用现有文件 results.success++; results.markdowns.push(``);
// 添加到已上传集合,但不实际上传 this.uploadedButUncommitted.add(pathInRepo);
// 释放预览URL URL.revokeObjectURL(img.previewUrl); continue; // 跳过上传步骤 }
// 文件不存在,执行上传 const content = await this.fileToBase64(img.file);
// 上传到媒体分支 - 现在commitMediaFile会自己处理文件存在性检查 await this.commitMediaFile(token, repoOwner, repoName, pathInRepo, content, mediaBranch, img.name, commitCfg);
results.success++; results.markdowns.push(``);
this.uploadedButUncommitted.add(pathInRepo);
// 释放预览URL URL.revokeObjectURL(img.previewUrl); } catch (error) { console.error("处理图片出错:", img.name, error); results.errors.push(`${img.name}: ${error.message}`); } }
// 即使有部分失败,也将成功上传的图片插入到编辑器中 if (results.success > 0 && results.markdowns.length > 0 && vditorInstance) { vditorInstance.insertValue('\n' + results.markdowns.join('\n') + '\n'); }
if (results.errors.length > 0) { console.error('以下图片上传失败:', results.errors);
// 使用自定义的Toastify样式通知 showToast(`部分图片上传失败:\n${results.errors.join('\n')}\n\n但已成功上传的图片已插入编辑器。`, 'error', 5000); } else if (results.success > 0) { // 使用自定义的Toastify样式通知 showToast(`✅ 成功处理 ${results.success} 张图片到媒体库`, 'success'); }
this.pendingImages = []; return results; } finally { this.isUploading = false; } },
// 提交单个媒体文件到GitHub async commitMediaFile(token, owner, repo, path, content, branch, filename, commitCfg) { // 首先检查文件是否已存在 const checkUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`; let sha = null;
try { const checkRes = await fetch(checkUrl, { headers: { Authorization: `token ${token}` } });
if (checkRes.ok) { // 文件已存在,获取SHA const fileInfo = await checkRes.json(); sha = fileInfo.sha; } // 如果文件不存在,checkRes.status 会是 404,我们将继续创建新文件 } catch (error) { console.error(`检查文件是否存在时出错: ${error.message}`); // 如果检查失败,继续上传新文件 }
// 构建API URL和请求体 const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; const body = { message: `${commitCfg.commitPrefix}${filename}`, content: content, // 确保内容是base64编码的 branch: branch, committer: { name: commitCfg.authorName, email: commitCfg.authorEmail }, author: { name: commitCfg.authorName, email: commitCfg.authorEmail } };
// 只有当文件确实存在(有SHA)时才添加sha字段 if (sha) { body.sha = sha; }
const res = await fetch(url, { method: 'PUT', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) { const errorData = await res.text(); console.error(`GitHub API错误详情: ${errorData}`); console.error(`请求URL: ${url}`); console.error(`路径原始值: ${path}`); console.error(`请求体: ${JSON.stringify({ message: body.message, contentLength: content.length, branch: body.branch }, null, 2)}`); throw new Error(`GitHub API错误: ${res.status}, ${errorData}`); }
// 成功上传后返回数据 return await res.json(); },
// 确保媒体分支存在,如果不存在则从主分支创建 async ensureMediaBranchExists(token, repoOwner, repoName, mediaBranch) { // 检查媒体分支是否存在 const branchCheckUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/branches/${mediaBranch}`; const branchCheckRes = await fetch(branchCheckUrl, { headers: { Authorization: `token ${token}` } });
if (branchCheckRes.ok) { // 分支已存在 return; } else if (branchCheckRes.status === 404) { // 分支不存在,需要创建
// 获取主分支信息 const mainBranchName = this.config.branch; const mainBranchUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/git/refs/heads/${mainBranchName}`; const mainBranchRes = await fetch(mainBranchUrl, { headers: { Authorization: `token ${token}` } });
if (!mainBranchRes.ok) { throw new Error(`无法获取主分支信息: ${mainBranchRes.status}`); }
const mainBranchData = await mainBranchRes.json(); const mainBranchSha = mainBranchData.object.sha;
// 创建媒体分支 const createBranchUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/git/refs`; const createRes = await fetch(createBranchUrl, { method: 'POST', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ref: `refs/heads/${mediaBranch}`, sha: mainBranchSha }) });
if (!createRes.ok) { const errorData = await createRes.text(); throw new Error(`创建分支失败: ${createRes.status}, ${errorData}`); } } else { const errorData = await branchCheckRes.text(); throw new Error(`检查分支状态失败: ${branchCheckRes.status}, ${errorData}`); } },
// 获取当前内容所在分支 getCurrentContentBranch() { if (window.CMS?.localBackend) { return this.config.branch; }
if (window.CMS?.activeEntry) { return this.config.branch; }
return this.config.branch; },
// 恢复checkFileExists函数,尽管目前在上传流程中不需要使用它,但保留该函数以备将来使用 async checkFileExists(token, owner, repo, path, branch = null) { // 对路径进行URL编码以用于API请求 const encodedPath = encodeURIComponent(path).replace(/\//g, '%2F'); let url = `https://api.github.com/repos/${owner}/${repo}/contents/${encodedPath}`; if (branch) { url += `?ref=${branch}`; }
const res = await fetch(url, { headers: { Authorization: `token ${token}` } }); return res.status === 200 ? await res.json() : null; },
async fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(file); }); } };
const VditorControl = createClass({ getInitialState() { return { value: this.props.value || '', showUploadPanel: false, uploadStatus: null }; },
componentDidMount() { this.initVditor(); this.pathCheckInterval = setInterval(() => this.checkDocPath(), 2000); },
// 在props中提供的控件方法中处理提交前逻辑 control: { // 这个方法将在外部调用,当需要提交内容时 async persist(entry) { if (pendingMediaFiles.length > 0) { try { // 获取token const token = ImageUploadManager.getToken(); const { repoOwner, repoName, mediaBranch } = ImageUploadManager.config; // 使用媒体分支 const contentBranch = ImageUploadManager.getCurrentContentBranch();
// 提交所有待处理的媒体文件到媒体分支 for (const mediaFile of pendingMediaFiles) { try { await this.commitMediaFile( token, repoOwner, repoName, mediaFile.path, mediaFile.content, mediaBranch, // 使用媒体分支 mediaFile.filename, ImageUploadManager.commitConfig, mediaFile.sha ); } catch (error) { console.error('提交媒体文件失败:', error); } }
// 清空待提交列表 pendingMediaFiles = []; } catch (error) { console.error('处理媒体文件时出错:', error); } } } },
componentWillUnmount() { // 清除定时器 clearInterval(this.pathCheckInterval);
// 由于我们使用全局hashchange监听器,不需要在组件中移除 if (this.vditor) this.vditor.destroy(); ImageUploadManager.cleanupPreviews(); },
checkDocPath() { const newPath = this.getCurrentDocPath(); if (newPath !== this.state.currentDocPath) { this.setState({ currentDocPath: newPath }); } },
getCurrentDocPath() { if (window.CMS?.activeEntry?.path) { // 解码路径中的URL编码字符 return decodeURIComponent(window.CMS.activeEntry.path); } if (this.props.entry?.path) { return decodeURIComponent(this.props.entry.path); }
// 尝试从URL中获取当前文档路径 try { const slug = SlugUtils.extractSlugFromUrl(); if (slug) { return `src/content/posts/${slug}.md`; } } catch (e) { console.error('无法从URL中确定文档路径:', e); }
return '无法确定当前文档路径'; },
initVditor() { try { this.vditor = new Vditor(this.props.forID, { height: 500, value: this.state.value, mode: 'ir', cache: { enable: false }, toolbar: this.getToolbarConfig(), input: (value) => { this.setState({ value }); this.props.onChange(value); } });
window.vditorInstance = this.vditor; } catch (e) { console.error('Vditor初始化失败:', e); } },
getToolbarConfig() { const baseTools = [ 'emoji', 'headings', 'bold', 'italic', 'strike', 'link', 'quote', 'code', 'inline-code', 'insert-before', 'insert-after', '|', 'list', 'ordered-list', 'check', 'outdent', 'indent', '|', 'table', '|', 'undo', 'redo', '|' ];
const uploadButton = { name: 'image-upload', tip: '上传图片到GitHub', className: 'toolbar__image-upload', icon: '<svg viewBox="0 0 1024 1024" width="16" height="16"><path d="M959.9 774.4c0 70.4-57.6 128-128 128H192c-70.4 0-128-57.6-128-128V249.6c0-70.4 57.6-128 128-128h640c70.4 0 128 57.6 128 128v524.8z" fill="#FF8A00"></path><path d="M825.6 300.8c0 57.6-44.8 102.4-102.4 102.4s-102.4-44.8-102.4-102.4 44.8-102.4 102.4-102.4 102.4 44.8 102.4 102.4zM710.4 556.8l-108.8-108.8-185.6 185.6-108.8-108.8L128 697.6v76.8c0 70.4 57.6 128 128 128h640c70.4 0 128-57.6 128-128v-76.8L710.4 556.8z" fill="#FFFFFF"></path></svg>', click: () => this.setState({ showUploadPanel: true }) };
const remainingTools = [ 'edit-mode', 'content-theme', 'code-theme', 'export', 'outline', 'preview', 'devtools', 'info', 'help', 'br' ];
return [...baseTools, uploadButton, '|', ...remainingTools]; },
handleFileSelect(event) { const files = event.target.files; if (!files.length) return;
ImageUploadManager.addImages(files); this.setState({ uploadStatus: `已暂存 ${files.length} 张图片,共 ${ImageUploadManager.pendingImages.length} 张待上传` });
event.target.value = ''; },
async handleUpload() { const pendingCount = ImageUploadManager.pendingImages.length;
if (pendingCount === 0) { this.setState({ uploadStatus: '请先选择图片' }); return; }
this.setState({ uploadStatus: '上传中...', showUploadPanel: false });
try { const result = await ImageUploadManager.uploadAll(this.vditor);
if (result.success > 0) { this.setState({ uploadStatus: `✅ 上传完成!成功 ${result.success}/${pendingCount} 张` }); setTimeout(() => this.setState({ uploadStatus: null }), 5000);
// 显示成功通知 showToast(`✅ 成功上传 ${result.success} 张图片`, 'success'); } else { this.setState({ uploadStatus: '上传失败,请查看控制台', showUploadPanel: true });
// 显示失败通知 showToast('❌ 图片上传失败,请重试', 'error'); }
if (result.errors.length > 0) { console.error('上传错误:', result.errors); } } catch (error) { console.error('上传过程出错:', error); // 保留错误信息到控制台 this.setState({ uploadStatus: `错误: ${error.message}`, showUploadPanel: true });
// 显示错误通知 showToast(`❌ 上传出错: ${error.message}`, 'error', 5000); } },
handleClear() { ImageUploadManager.cleanupPreviews(); this.setState({ uploadStatus: '已清空暂存图片', showUploadPanel: false });
// 显示清空成功的通知 showToast('🗑️ 已清空暂存图片', 'info'); },
render() { const h = window.h; const { showUploadPanel, uploadStatus } = this.state; const pendingImages = ImageUploadManager.pendingImages;
return h('div', { className: 'vditor-full-container' }, [ h('div', { key: 'editor', id: this.props.forID, className: 'vditor-editor' }),
showUploadPanel && this.renderUploadPanel(h, pendingImages, uploadStatus),
!showUploadPanel && pendingImages.length > 0 && h('div', { key: 'upload-hint', className: 'upload-hint' }, `📷 ${pendingImages.length} 张图片待上传,点击管理`) ]); },
renderUploadPanel(h, pendingImages, uploadStatus) { const currentDocPath = this.getCurrentDocPath();
return h('div', { key: 'upload-panel', className: 'vditor-upload-panel' }, [ h('h4', {}, '📁 图片上传到GitHub'),
currentDocPath && h('div', { className: 'doc-path' }, `文档路径: ${currentDocPath}`),
h('div', { className: 'file-input-container' }, [ h('input', { type: 'file', accept: 'image/*', multiple: true, onChange: this.handleFileSelect, className: 'file-input' }), h('div', { className: 'file-hint' }, '支持多选,图片将暂存在浏览器中') ]),
pendingImages.length > 0 && this.renderPreviewArea(h, pendingImages),
uploadStatus && h('div', { className: `upload-status ${this.getStatusClass(uploadStatus)}` }, uploadStatus),
this.renderActionButtons(h, pendingImages) ]); },
renderPreviewArea(h, pendingImages) { return h('div', { key: 'preview-area', className: 'preview-area' }, [ h('div', { className: 'preview-label' }, `已选择 ${pendingImages.length} 张图片:`), ...pendingImages.map((img, idx) => h('div', { key: idx, className: 'preview-item' }, [ h('img', { src: img.previewUrl, className: 'preview-image' }), h('div', { className: 'preview-name' }, img.name) ])) ]); },
renderActionButtons(h, pendingImages) { const isUploading = ImageUploadManager.isUploading;
return h('div', { className: 'button-container' }, [ h('button', { onClick: this.handleUpload, disabled: pendingImages.length === 0 || isUploading, className: `primary-button ${pendingImages.length === 0 ? 'disabled' : ''}` }, isUploading ? '上传中...' : '🚀 开始上传'),
h('button', { onClick: this.handleClear, className: 'secondary-button' }, '清空'),
h('button', { onClick: () => this.setState({ showUploadPanel: false }), className: 'secondary-button' }, '关闭') ]); },
getStatusClass(status) { if (status.includes('✅')) return 'success'; if (status.includes('❌') || status.includes('错误')) return 'error'; return 'warning'; } });
const VditorPreview = createClass({ render() { const h = window.h; const value = this.props.value || '';
return h('div', { className: 'vditor-preview' }, value || '(无内容)'); } });
function registerPlugin() { if (!window.CMS?.registerWidget || typeof Vditor === 'undefined') { setTimeout(registerPlugin, 100); return; }
try { // 注册widget,添加数据持久化方法 const widget = { control: VditorControl, preview: VditorPreview, // 添加一个方法用于在提交前处理媒体文件 beforeSubmit: async function (entry) { if (window.vditorInstance && window.vditorInstance.getValue) { // 更新entry中的内容 entry.set(window.vditorInstance.getValue()); }
// 处理待提交的媒体文件 if (pendingMediaFiles.length > 0) { try { // 获取token const token = ImageUploadManager.getToken(); const { repoOwner, repoName, mediaBranch } = ImageUploadManager.config; // 使用媒体分支 const contentBranch = ImageUploadManager.getCurrentContentBranch();
// 提交所有待处理的媒体文件到媒体分支 for (const mediaFile of pendingMediaFiles) { try { await VditorControl.prototype.commitMediaFile.call( { commitMediaFile: VditorControl.prototype.commitMediaFile }, // 为调用提供上下文 token, repoOwner, repoName, mediaFile.path, mediaFile.content, mediaBranch, // 使用媒体分支 mediaFile.filename, ImageUploadManager.commitConfig, mediaFile.sha ); } catch (error) { console.error('提交媒体文件失败:', error); } }
// 清空待提交列表 pendingMediaFiles = []; } catch (error) { console.error('处理媒体文件时出错:', error); } } } };
window.CMS.registerWidget('vditor', widget.control, widget.preview);
// 添加beforeSubmit处理器到全局,供CMS调用 if (window.CMS_EVENTS) { window.CMS_EVENTS.beforeSubmit = widget.beforeSubmit; } else { window.CMS_EVENTS = { beforeSubmit: widget.beforeSubmit }; }
// 使用正确的事件注册方式注册发布事件监听器 if (window.CMS && typeof window.CMS.registerEventListener === 'function') { window.CMS.registerEventListener({ name: 'prePublish', handler: async (obj) => { console.log('检测到prePublish事件,即将发布文章'); // 发布前合并媒体分支到主分支 await mergeMediaToMain(); } }); }
window.decapCmsVditorPlugin = { version: '4.7', hasUpload: true, manager: ImageUploadManager };
console.log('✅ Vditor插件已注册'); } catch (e) { console.error('插件注册失败:', e); } }
if (typeof Vditor !== 'undefined') { init(); } else { const checkVditor = () => { typeof Vditor !== 'undefined' ? init() : setTimeout(checkVditor, 100); }; checkVditor(); }})();/* Vditor 编辑器样式 */.toolbar__image-upload { cursor: pointer;}
.toolbar__image-upload:hover { opacity: 0.8;}
/* 上传面板样式 */.vditor-upload-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10000; padding: 20px; min-width: 400px; max-width: 600px;}
.upload-hint { margin: 15px 0; color: #666; line-height: 1.5;}
.file-input-container { margin: 15px 0;}
.file-hint { margin: 10px 0; color: #888; font-size: 0.9em;}
.doc-path { margin: 10px 0; padding: 8px; background-color: #f5f5f5; border-radius: 4px; font-family: monospace; word-break: break-all;}
.preview-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #f0f0f0;}
.preview-item:last-child { border-bottom: none;}
.preview-info { display: flex; align-items: center;}
.preview-image { width: 40px; height: 40px; object-fit: cover; margin-right: 10px; border-radius: 4px;}
.preview-name { font-size: 14px; color: #333;}
.preview-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #f0f0f0;}
.preview-item:last-child { border-bottom: none;}
.preview-image { width: 40px; height: 40px; object-fit: cover; margin-right: 10px; border-radius: 4px;}
.preview-name { font-size: 14px; color: #333;}
.preview-area { margin-top: 20px; max-height: 300px; overflow-y: auto; border: 1px solid #eee; border-radius: 4px; padding: 10px;}
.button-container { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end;}
.primary-button { background-color: #007cba; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px;}
.primary-button:hover { background-color: #005a87;}
.primary-button.disabled { background-color: #cccccc; cursor: not-allowed;}
.secondary-button { background-color: #f0f0f0; color: #333; border: 1px solid #ccc; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px;}
.secondary-button:hover { background-color: #e0e0e0;}
.upload-status { margin-top: 15px; padding: 10px; border-radius: 4px; text-align: center;}
.upload-status.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
.upload-status.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
.upload-status.loading { background-color: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb;}
/* 加载指示器 */.loading-indicator { display: inline-block; width: 20px; height: 20px; border: 3px solid rgba(255,255,255,.3); border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;}
@keyframes spin { to { transform: rotate(360deg); }}
/* Toast通知样式 */.Toastify { position: fixed; top: 80px; right: 20px; z-index: 10000; min-width: 300px; max-width: 400px;}
.vditor-toast { position: relative; min-height: 64px; box-sizing: border-box; margin-bottom: 10px; padding: 12px 16px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; justify-content: space-between; align-items: center; max-height: 120px; overflow: hidden; font-family: sans-serif; cursor: default; direction: ltr; background: #fff; color: #fff; animation: slideInRight 0.3s ease-out;}
.vditor-toast.fade-out { animation: fadeOut 0.3s ease-out;}
.Toastify__toast--success { background: #07bc0c;}
.Toastify__toast--error { background: #e74c3c;}
.Toastify__toast--warning { background: #f1c40f; color: #333;}
.Toastify__toast--info { background: #3498db;}
.Toastify__toast-body { flex: 1; word-break: break-word;}
.Toastify__close-button { background: transparent; border: none; color: inherit; font-size: 20px; cursor: pointer; padding: 0; margin-left: 10px; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;}
@keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; }}
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; }}使用说明:
这种垃圾代码就不传github污染copilot了,不过姑且写个使用说明给自己看吧。
{ label: "正文", name: "body", widget: "vditor" }config.yml里做出修改widget不再是markdown,应该为vditor
在admin.html head中引入以下样式表:
<link rel="stylesheet" href="/admin/vditor/index.css" /> <link rel="stylesheet" href="/admin/vditor-plugin/style.css" />body中按顺序引入以下脚本:
<script src="/admin/vditor/index.min.js"></script> <script src="https://unpkg.com/decap-cms@^3.1.2/dist/decap-cms.js"></script> <script src="/admin/vditor-plugin/index.js"></script>需要注意目前幻梦是固定了vditor编辑器的版本在本地,其使用中也可以替换为vditor官方的cdn版本。
【笔记】Fuwari主题下的DecapCMS编辑器替换
https://www.yumehinata.com/posts/笔记fuwari主题下的decapcms编辑器替换/
