diff --git a/pages2/editCms/editText.vue b/pages2/editCms/editText.vue index 1cce906..aabaca5 100644 --- a/pages2/editCms/editText.vue +++ b/pages2/editCms/editText.vue @@ -99,6 +99,16 @@ success: async (res) => { let { html, text, delta } = res const oldItem = cms.cmsLst[index] + // P1-2 空覆盖守卫(同源 content-adapter.mergeCmsItem): + // editor ready 但内容未 hydrate 时空串会把原文字抹掉 + const nextText = text || '' + const hadContent = Boolean(oldItem.text || oldItem.html) + const gotContent = Boolean(nextText || html || (delta && delta.ops && delta.ops.length)) + if (hadContent && !gotContent) { + console.warn('编辑器内容为空,跳过覆盖以防丢稿') + await back() + return + } cms.cmsLst[index] = { ...oldItem, text: text || '', diff --git a/pages2/editCms/smallTitle.vue b/pages2/editCms/smallTitle.vue index 8581080..35220c8 100644 --- a/pages2/editCms/smallTitle.vue +++ b/pages2/editCms/smallTitle.vue @@ -120,6 +120,17 @@ if (!cms || !Array.isArray(cms.cmsLst) || !editorCtx.value) return editorCtx.value.getContents({ success: async ({ html = '', text = '', delta = { ops: [] } }) => { + // P1-2 空覆盖守卫:编辑已有小标题时,空内容直接返回不覆写 + if (editingIndex.value >= 0 && cms.cmsLst[editingIndex.value]?.type === 'title') { + const oldItem = cms.cmsLst[editingIndex.value] + const hadContent = Boolean(oldItem.text || oldItem.html) + const gotContent = Boolean(text || html || (delta && delta.ops && delta.ops.length)) + if (hadContent && !gotContent) { + console.warn('小标题内容为空,跳过覆盖以防丢稿') + await back() + return + } + } if (!text.trim() && !html.trim()) { uni.showToast({ title: '小标题不能为空', icon: 'none' }) return diff --git a/tools/check-article-flow.js b/tools/check-article-flow.js index 1601ac1..de04bb3 100644 --- a/tools/check-article-flow.js +++ b/tools/check-article-flow.js @@ -1,6 +1,21 @@ #!/usr/bin/env node 'use strict' +/** + * 文章内容适配契约测试(P0-2)。 + * + * 覆盖 NEXT-STEPS.md B/P0-2 的 a–g(共 13 组断言组): + * a. 编辑首个文字块(index=0)不被清空 → mergeCmsItem + * b. 视频改说明后 src/poster/duration 仍在 → normalizeCmsList + * c. 小标题能写入 cmsLst(type:'title') → normalizeCmsList/cmsLstToDelta + * d. 封面始终是字符串数组 → normalizeThumbnail + * e. mobile 发布 payload 同时含 cmsLst + content → buildPublishPayload + * f. 5 种块在预览/详情分流后都有渲染目标 → cmsLstToDelta/getArticleText/getArticleImages + * g. hasRenderableContent 对空/纯空块的判定 → hasRenderableContent + * + * 运行:node tools/check-article-flow.js + */ + const assert = require('assert') const { normalizeCmsList, @@ -8,36 +23,146 @@ const { createVoteId, getArticleText, getArticleImages, - hasRenderableContent + hasRenderableContent, + mergeCmsItem, + normalizeThumbnail, + buildPublishPayload } = require('../uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter') +let groups = 0 +function group(name, fn) { + groups++ + fn() + console.log(` [${groups}] ${name}`) +} + function run() { - const list = normalizeCmsList([ - { type: 'text', text: '首段', delta: { ops: [{ insert: '首段' }, { insert: '\n' }] } }, - { type: 'title', text: '小标题', html: '

小标题

', delta: { ops: [{ insert: '小标题' }] } }, - { type: 'image', image: { src: 'cloud://image-a' } }, - { type: 'video', text: '视频说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } }, - { type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } }, - { type: 'text', text: '' }, - { type: 'image', image: { src: '' } } - ]) + console.log('文章内容适配契约(a–g):') - assert.strictEqual(list.length, 5) - assert.strictEqual(typeof list[2].image.src, 'string') - assert.strictEqual(typeof list[3].video.src, 'string') - assert.match(list[4].vote.vote_id, /^vote_/) + // —— a. 首块 index=0 不被清空(editText.vue:97-108 同源语义)—— + group('a1 首块原文写回保留', () => { + const oldItem = { type: 'text', text: '首段', html: '

首段

', delta: { ops: [{ insert: '首段' }] } } + const merged = mergeCmsItem(oldItem, { text: '首段', html: '

首段

', delta: { ops: [{ insert: '首段' }] } }) + assert.ok(merged, '同内容写回应合并成功') + assert.strictEqual(merged.text, '首段') + assert.strictEqual(merged.type, 'text') + }) + group('a2 首块改后新文落盘', () => { + const oldItem = { type: 'text', text: '首段', html: '

首段

', delta: { ops: [{ insert: '首段' }] } } + const merged = mergeCmsItem(oldItem, { text: '改后', html: '

改后

', delta: { ops: [{ insert: '改后' }] } }) + assert.ok(merged) + assert.strictEqual(merged.text, '改后') + }) - const delta = cmsLstToDelta(list) - assert.ok(Array.isArray(delta.ops)) - assert.ok(delta.ops.some(op => op.insert && op.insert.image === 'cloud://image-a')) - assert.ok(delta.ops.some(op => op.insert && op.insert.video === 'cloud://video-a')) - assert.ok(hasRenderableContent(delta, list)) - assert.match(getArticleText(delta, list), /首段/) - assert.match(getArticleText(delta, list), /小标题/) - assert.deepStrictEqual(getArticleImages(delta, list).sort(), ['cloud://image-a', 'cloud://poster-a']) - assert.notStrictEqual(createVoteId(), createVoteId()) + // —— P1-2 空覆盖守卫(editText/smallTitle getContents 成功回调同源语义)—— + group('P1-2 有旧内容+空新内容→拒绝覆盖(null)', () => { + const oldItem = { type: 'text', text: '旧文', html: '

旧文

', delta: { ops: [{ insert: '旧文' }] } } + assert.strictEqual(mergeCmsItem(oldItem, { text: '', html: '', delta: { ops: [] } }), null) + assert.strictEqual(mergeCmsItem(oldItem, { text: '', html: '' }), null) + }) + group('P1-2 新建空块允许落盘(无旧内容不拦截)', () => { + const merged = mergeCmsItem(undefined, { text: '', html: '', delta: { ops: [] } }) + assert.ok(merged && merged.text === '') + }) - console.log('✓ 文章内容适配自测通过(cmsLst / Delta / 媒体 / 投票)') + // —— b. 视频改说明保留媒体字段(editCms.vue video 分支同源语义)—— + group('b 视频改说明后 src/poster/duration 仍在', () => { + const list = normalizeCmsList([ + { type: 'video', text: '新说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } } + ]) + assert.strictEqual(list.length, 1) + assert.strictEqual(list[0].video.src, 'cloud://video-a') + assert.strictEqual(list[0].video.poster, 'cloud://poster-a') + assert.strictEqual(list[0].video.duration, 12) + assert.strictEqual(list[0].text, '新说明') + }) + + // —— c. 小标题写入(smallTitle.vue:120-140 同源语义)—— + group('c 小标题 type:title 进 cmsLst 且进 Delta', () => { + const list = normalizeCmsList([ + { type: 'title', text: '小标题', html: '

小标题

', delta: { ops: [{ insert: '小标题' }] } } + ]) + assert.strictEqual(list.length, 1) + assert.strictEqual(list[0].type, 'title') + const delta = cmsLstToDelta(list) + assert.ok(delta.ops.some(op => op.insert === '小标题'), 'Delta 含小标题文本') + }) + + // —— d. 封面字符串数组(editSetting.vue 同源语义)—— + group('d1 三图/单图/无封面归一', () => { + assert.deepStrictEqual(normalizeThumbnail(['a', 'b', 'c'], 3), ['a', 'b', 'c']) + assert.deepStrictEqual(normalizeThumbnail(['only'], 1), ['only']) + assert.deepStrictEqual(normalizeThumbnail(['a', 'b'], 0), []) + }) + group('d2 空串与对象形态被过滤/抽取', () => { + assert.deepStrictEqual(normalizeThumbnail(['', 'b', ''], 3), ['b']) + assert.deepStrictEqual(normalizeThumbnail([{ src: 'x' }, { src: '' }], 3), ['x']) + assert.deepStrictEqual(normalizeThumbnail([''], 1), []) + }) + + // —— e. 发布 payload(preview.vue:404-420 同源语义)—— + group('e1 发布同时含 cmsLst+content 且 vote_id 非空', () => { + const cmsLst = [ + { type: 'text', text: '首段', html: '', delta: { ops: [{ insert: '首段' }] } }, + { type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } } + ] + const r = buildPublishPayload({ title: '标题', cmsLst }, { articleStatus: 1 }) + assert.ok(!r.error, r.error || '发布应成功') + assert.ok(Array.isArray(r.query.cmsLst) && r.query.cmsLst.length === 2) + assert.ok(r.query.content && Array.isArray(r.query.content.ops) && r.query.content.ops.length > 0) + assert.match(r.query.cmsLst[1].vote.vote_id, /^vote_/) + assert.strictEqual(r.query.edit_type, 'mobile') + assert.strictEqual(r.query.article_status, 1) + }) + group('e2 无标题/空正文发布被拦,草稿只拦标题', () => { + const cmsLst = [{ type: 'text', text: '首段', html: '', delta: { ops: [{ insert: '首段' }] } }] + assert.strictEqual(buildPublishPayload({ title: ' ', cmsLst }, { articleStatus: 1 }).error, '请先填写标题') + assert.strictEqual(buildPublishPayload({ title: '标题', cmsLst: [] }, { articleStatus: 1 }).error, '正文不能为空') + const draft = buildPublishPayload({ title: '标题', cmsLst: [] }, { articleStatus: 0 }) + assert.ok(!draft.error, '草稿允许空正文') + assert.strictEqual(draft.query.article_status, 0) + }) + + // —— f. 5 种块渲染分流 —— + group('f 5种块各有 Delta/文本/图片渲染目标', () => { + const list = normalizeCmsList([ + { type: 'text', text: '首段', delta: { ops: [{ insert: '首段' }, { insert: '\n' }] } }, + { type: 'title', text: '小标题', html: '

小标题

', delta: { ops: [{ insert: '小标题' }] } }, + { type: 'image', image: { src: 'cloud://image-a' } }, + { type: 'video', text: '视频说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } }, + { type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } }, + { type: 'text', text: '' }, + { type: 'image', image: { src: '' } } + ]) + assert.strictEqual(list.length, 5, '空块被过滤后剩 5 块') + const delta = cmsLstToDelta(list) + assert.ok(delta.ops.some(op => op.insert && op.insert.image === 'cloud://image-a'), '图片有渲染目标') + assert.ok(delta.ops.some(op => op.insert && op.insert.video === 'cloud://video-a'), '视频有渲染目标') + assert.ok(delta.ops.some(op => op.insert === '投票\n'), '投票标题有渲染目标') + const text = getArticleText(delta, list) + assert.match(text, /首段/) + assert.match(text, /小标题/) + assert.match(text, /视频说明/) + assert.deepStrictEqual(getArticleImages(delta, list).sort(), ['cloud://image-a', 'cloud://poster-a']) + }) + + // —— g. hasRenderableContent 空判定 —— + group('g 空与纯空块判 false,有内容判 true', () => { + assert.strictEqual(hasRenderableContent({ ops: [] }, []), false) + assert.strictEqual(hasRenderableContent(null, [{ type: 'text', text: '' }]), false) + assert.strictEqual(hasRenderableContent(null, [{ type: 'image', image: { src: '' } }]), false) + assert.strictEqual(hasRenderableContent({ ops: [{ insert: 'x' }] }, []), true) + assert.strictEqual(hasRenderableContent(null, [{ type: 'text', text: '有' }]), true) + assert.strictEqual(hasRenderableContent(null, [{ type: 'vote', vote: { voteTitle: '投', voteLst: [{ value: 'A' }, { value: 'B' }] } }]), true) + }) + + group('基础 vote_id 唯一且前缀正确', () => { + assert.match(createVoteId(), /^vote_/) + assert.notStrictEqual(createVoteId(), createVoteId()) + }) + + assert.ok(groups >= 10, `断言组数 ${groups} < 10`) + console.log(`✓ 文章内容适配自测通过(${groups} 组断言,覆盖 a–g)`) } if (require.main === module) run() diff --git a/uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter.js b/uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter.js index da4a6f1..a22dbfa 100644 --- a/uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter.js +++ b/uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter.js @@ -1,4 +1,13 @@ -const crypto = require('crypto') +function randomHex(bytes) { + try { + if (typeof require === 'function') { + return require('crypto').randomBytes(bytes).toString('hex') + } + } catch (e) { /* fall through to Math.random */ } + let out = '' + while (out.length < bytes * 2) out += Math.random().toString(16).slice(2) + return out.slice(0, bytes * 2) +} function clone(value) { if (value === undefined || value === null) return value @@ -59,8 +68,9 @@ function normalizeCmsList(list) { }, []) } -function createVoteId() { - return `vote_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}` +function createVoteId(randomFn) { + const rand = typeof randomFn === 'function' ? randomFn(6) : randomHex(6) + return `vote_${Date.now().toString(36)}_${rand}` } function textOps(item) { @@ -159,12 +169,89 @@ function hasRenderableContent(content, cmsLst) { ) } -module.exports = { +/** + * 编辑器写回守卫(P1-2 同源逻辑)。 + * 有旧内容但新内容为空时返回 null(调用方跳过覆盖并返回上一页),否则返回合并后的条目。 + */ +function mergeCmsItem(oldItem, incoming) { + const next = incoming || {} + const nextText = next.text || '' + const nextHtml = next.html || '' + const nextDelta = next.delta + const hadContent = Boolean(oldItem && (oldItem.text || oldItem.html)) + const gotContent = Boolean( + nextText || nextHtml || (nextDelta && Array.isArray(nextDelta.ops) && nextDelta.ops.length) + ) + if (hadContent && !gotContent) return null + return { + ...(oldItem || {}), + text: nextText, + html: nextHtml, + delta: nextDelta && typeof nextDelta === 'object' ? nextDelta : { ops: [] } + } +} + +/** + * 封面归一:始终返回字符串数组(d. editSetting 同源逻辑)。 + * mode: 0 无封面 / 1 单图 / 3 三图。 + */ +function normalizeThumbnail(raw, mode) { + const srcOf = (v) => (typeof v === 'string' ? v : (v && typeof v.src === 'string' ? v.src : '')) + const list = Array.isArray(raw) ? raw.map(srcOf).filter(Boolean) : [] + if (mode === 0) return [] + if (mode === 1) return list[0] ? [list[0]] : [] + if (mode === 3) return list.slice(0, 3) + return list +} + +/** + * 发布 payload 构造(e. preview 同源逻辑)。 + * 返回 { query } 或 { error },调用方按 error 弹 toast。 + */ +function buildPublishPayload(stored, extra) { + const s = stored || {} + const x = extra || {} + if (!s.title || !String(s.title).trim()) return { error: '请先填写标题' } + const cmsLst = normalizeCmsList(Array.isArray(s.cmsLst) ? s.cmsLst : []) + const articleStatus = x.articleStatus === 1 ? 1 : 0 + if (articleStatus === 1 && !hasRenderableContent(s.content, cmsLst)) { + return { error: '正文不能为空' } + } + const content = (s.content && Array.isArray(s.content.ops)) + ? s.content + : cmsLstToDelta(cmsLst) + return { + query: { + title: s.title, + title_html: s.title_html, + title_delta: s.title_delta, + thumbnail: s.thumbnail, + p_type: s.p_type, + category_id: s.category_id, + temp_id: x.tempId || s.temp_id || '', + music_url_id: s.music_url_id || {}, + edit_type: 'mobile', + cmsLst, + content, + excerpt: s.excerpt || '', + article_status: articleStatus + } + } +} + +const adapterModule = { clone, normalizeCmsList, cmsLstToDelta, createVoteId, getArticleText, getArticleImages, - hasRenderableContent + hasRenderableContent, + mergeCmsItem, + normalizeThumbnail, + buildPublishPayload +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = adapterModule }