ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

成为全栈·Next.js 网站前台篇·会员投稿工作流:草稿、预览、投稿、撤回与重新送审

成为全栈·Next.js 网站前台篇·会员投稿工作流:草稿、预览、投稿、撤回与重新送审 成为全栈·Next.js 网站前台篇·会员投稿工作流草稿、预览、投稿、撤回与重新送审投稿编辑器不是一个带 Markdown 的表单。它还要遵守稿件状态机、区分保存与提交、保护未保存内容、串行上传图片并在服务端状态变化时避免静默覆盖。前言会员投稿一开始看起来很像管理后台文章编辑标题、正文、摘要、分类、标签、封面再放一个保存按钮。但会员不是编辑他不能直接发布已发布文章再次修改也不能绕过审核。编辑器因此必须理解业务状态草稿可以继续编辑和投稿待审核可以撤回已发布修改后进入重新审核。页面按钮、请求顺序和错误提示都要围绕这条状态机工作。先画清稿件状态机新建 → draft ──提交──→ pending ──后台通过──→ published ↑ │ │ └────撤回──────┘ │ 修改并重新送审 └────────────────── pending ←─────────┘当前状态会员可执行保存后的状态新稿保存草稿、提交审核draft 或 pendingdraft编辑、预览、提交、删除draft / pendingpending编辑、预览、撤回pending / draftpublished查看、修改并重新送审pending前端没有“直接发布”按钮管理员从会员投稿页进入也遵循同一规则。角色权限不能从另一个产品界面意外穿透。草稿校验与投稿校验不完全相同草稿允许暂时没有分类提交审核则必须完整exportconstvalidateDraft(fields:DraftFields,submit:boolean){if(!fields.title.trim())return请输入文章标题if(fields.title.length200)return标题最多 200 字符if(!fields.content.trim())return请输入文章正文if(fields.content.length65535)return正文最多 65,535 字符if((fields.summary?.length??0)500)return摘要最多 500 字符if(submit!fields.categoryId)return提交审核前请选择分类return}已发布文章保存也按投稿标准校验因为它将进入 pendingconsterrorvalidateDraft(fields,submit||article?.statuspublished,)客户端校验提供即时反馈后端仍要重复验证。绕过页面构造请求不能获得更宽松规则。保存与提交是两个有先后关系的动作“提交审核”必须先保存当前编辑内容再调用 submitletsavedawaitsaveManuscript(article?.id,{...fields,title:fields.title.trim()},saveStatus(article?.status),)if(submitsaved.statusdraft){try{savedawaitsubmitManuscript(saved.id)}catch(error){setNotice(草稿已保存提交审核失败${message(error)})return}}第二步失败时不能显示笼统的“操作失败”。事实是草稿已经成功保存用户只需重试提交不应重新创建文章。两阶段动作必须告诉用户部分成功发生在哪里。首次保存后要固定稿件身份if(idundefined){window.history.replaceState(window.history.state,,/member/articles/${saved.id}/edit,)}新建页面保存成功后改为具体 id 地址后续保存更新同一篇稿件。若仍停留在/new刷新或重复点击很容易再次创建。replaceState不触发整页重载当前编辑上下文得以保留服务端返回的 id 才是稿件真实身份。已发布文章修改必须明确进入重新审核exportconstsaveStatus(status?:Manuscript[status])statuspublished?pending:(status??draft)保存前还要求确认if ( article?.status published !window.confirm(保存后文章将转为待审核确认修改并重新送审) ) return当前后端没有独立文章版本。已发布稿件改为 pending 后公开缓存可能短暂保留旧内容这不是“公开版本与新草稿并存”的完整版本系统文章不能虚构这种能力。保存前重新读取减少静默覆盖constlatestawaitreadManuscript(article.id)if(latest.status!article.status||latest.updatedAt!article.updatedAt){thrownewError(服务器稿件已被修改或审核请保留本机副本后刷新页面核对。,)}这能发现编辑期间后台审核或另一处修改但它不是原子并发控制。检查与 PUT 之间仍有窗口后端没有版本号或If-Match就不能宣称彻底解决覆盖。更完整的方案应由后端接受 revision并在不匹配时返回 409。当前前端只提供冲突提示和内容保留。本机恢复副本按账号和稿件隔离constrecoveryKeycodex-writing:${user?.id}:${id??new}useEffect(() { if (!ready || !dirty || recovery) return try { localStorage.setItem(recoveryKey, JSON.stringify(fields)) } catch { setStorageError(本机空间不足或存储不可用请及时保存到服务器。) } }, [recoveryKey, fields, ready, dirty, recovery])恢复副本不会自动覆盖服务器内容。检测到差异后让用户选择“恢复内容”或“使用服务器内容”。key 带账号 id避免同一浏览器切换用户时看到别人的未保存稿件。localStorage 只是本机容灾不是服务器草稿、跨设备同步或永久备份。界面明确提示“本机恢复副本不等于服务器保存”。离开保护需要覆盖刷新和普通链接useEffect(() { if (!dirty !busy) return const unload (event: BeforeUnloadEvent) { event.preventDefault() event.returnValue } const click (event: MouseEvent) { const link (event.target as Element).closest(a) if (link !window.confirm(message)) { event.preventDefault() event.stopPropagation() } } window.addEventListener(beforeunload, unload) document.addEventListener(click, click, true) return () { window.removeEventListener(beforeunload, unload) document.removeEventListener(click, click, true) } }, [dirty, busy])浏览器历史后退并非所有环境都允许阻止所以本机恢复副本仍是兜底。上传进行中也属于 busy离开提示要比普通未保存状态更明确。连续图片上传要排队不能互相覆盖光标constqueueuseRef(Promise.resolve())constpendinguseRef(0)functionaddFiles(files:File[]){pending.current1onBusy(true)queue.currentqueue.current.then(async(){for(constfileoffiles){try{consturlawaituploadImage(file,articleId)insertAtCursor(\n![图片](${url})\n)}catch(error){onError(message(error))}}}).finally((){pending.current-1onBusy(pending.current0)})}选择、粘贴和拖拽都进入同一队列。单图失败不终止后续文件上传未结束时禁止保存和投稿避免正文引用尚未得到的地址。客户端先检查类型与大小if(!allowedTypes.includes(file.type))thrownewError(图片类型不支持)if(file.size10*1024*1024)thrownewError(图片不能超过 10MB)后端仍需再次验证 MIME、文件内容、大小和权限。SVG 文件还需要下载隔离、nosniff与 CSP不能因为它是“图片”就允许执行站点脚本。私人预览必须走私有稿件接口const query useQuery({ queryKey: [manuscript, user?.id, id], queryFn: () readManuscript(id), enabled: valid !!user, retry: false, }) if (query.data.authorId ! user?.id) { return p rolealert只能预览自己的稿件。/p }草稿不能调用公开 ISR 文章接口也不进入 sitemap。页面的 authorId 检查提供清晰反馈真正防止越权的仍是后端。编辑器预览与公开详情复用renderMarkdownarticle classNamewriting-preview h1{fields.title || 未命名文章}/h1 {fields.summary p classNameintro{fields.summary}/p} {renderMarkdown(fields.content).body} /article这样 GFM、代码高亮、表格滚动、标题锚点和原始 HTML 安全策略保持一致。投稿工作流验收矩阵场景预期新稿首次保存创建一次并切换到具体 id 地址保存成功、submit 失败提示草稿已保存可重试同一稿件待审核撤回状态回到 draft已发布修改明确确认并进入 pending后台在编辑期间审核保存前发现状态或 updatedAt 变化断网保存输入保留本机副本仍在连续上传两图串行插入单图失败不阻断下一张其他会员读稿件 id后端拒绝页面不泄露内容手机 375px单栏切换且无横向溢出项目还分别在 Next.js Node 生产运行和 OpenNext Worker 环境验证了创建、保存、投稿、撤回、重投、上传与私人预览。构建通过只能证明产物生成真实接口流程才证明状态机连通。适用边界当前系统没有文章版本、驳回历史和多人协作。需要保留公开版本、同时编辑下一版时应在后端建立 revision 模型而不是让前端复制一份 content 字段假装版本管理。长篇协作写作还需要服务端自动保存、冲突合并和编辑锁本机恢复副本只能解决单浏览器意外离开。小结投稿编辑器的复杂度主要来自状态和失败顺序不来自 Markdown 输入框。保存与提交是两阶段动作已发布修改必须重新审核本机副本要按账号隔离上传要串行服务端变化不能被静默覆盖。把这些规则写进代码和提示以后用户才知道内容现在保存在哪里、处于什么状态、下一步会发生什么。延伸阅读评论系统叠楼、回复、删除与内容审核Markdown、代码高亮与目录如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer
返回列表