From 6d19cb5a05ae3b03193e926f326dd4c40f02197a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Sun, 1 Nov 2026 19:27:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(gxmu):=20=E6=96=87=E7=A8=BF=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E6=96=B0=E5=A2=9E=E3=80=8C=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=8E=92=E7=89=88=E3=80=8D=EF=BC=8C=E6=8C=89=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E6=8E=92=E7=89=88=E8=BF=9B=E5=AF=8C=E6=96=87=E6=9C=AC=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成的 markdown 正文现在能一键转成带格式的富文本: - 正文区改为「Markdown 预览 / 富文本排版」两个 tab,一键排版后自动切到编辑器 - 转换统一走 utils/manuscript-content 的 markdownToHtml,新增 plugins 参数, 传 gfm 插件还原表格、删除线(不挂 gfm 时表格会漏成一堆 | 管道符) - 生成标题不拼进正文:标题有独立字段,导出 Word 时才拼进导出件 - 编辑器已有内容且文本不一致时才二次确认覆盖,同一份内容重复排版不打扰 - 富文本下方新增「保存为稿件」,入库写 contentFormat: 'html'; 存过之后按钮置灰,避免连点存出重复草稿 - oa/项目资料 里指向外部排版站的同名按钮改成「公众号排版」,避开同名不同义 --- src/utils/manuscript-content.ts | 16 +- src/views/gxmu/manuscriptGen/index.vue | 287 +++++++++++++++++- .../oa/project/components/project-edit.vue | 2 +- 3 files changed, 288 insertions(+), 17 deletions(-) diff --git a/src/utils/manuscript-content.ts b/src/utils/manuscript-content.ts index fd53d42..5ebadb0 100644 --- a/src/utils/manuscript-content.ts +++ b/src/utils/manuscript-content.ts @@ -10,6 +10,7 @@ * 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。 */ import { getProcessor } from 'bytemd'; +import type { BytemdPlugin } from 'bytemd'; const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i; const MARKDOWN_LINE_RE = @@ -57,11 +58,20 @@ export function detectFormat(content?: string | null): ContentFormat { return looksLikeMarkdown(text) ? 'markdown' : 'text'; } -/** markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。 */ -export function markdownToHtml(markdown: string): string { +/** + * markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。 + * + * `plugins` 决定润色到什么程度:默认不挂 GFM,表格会原样漏成一堆 `|` 管道符、 + * 删除线也会丢。要让「表格/删除线」和预览一致,调用方传 gfm 插件进来 + * (generate 页的一键排版就是这么做的)。转换口径只留这一处,别再各写一套。 + */ +export function markdownToHtml( + markdown: string, + plugins: BytemdPlugin[] = [] +): string { try { // processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串 - const result = getProcessor({}).processSync(String(markdown ?? '')); + const result = getProcessor({ plugins }).processSync(String(markdown ?? '')); return String(result); } catch (e) { console.error('markdown 转换失败', e); diff --git a/src/views/gxmu/manuscriptGen/index.vue b/src/views/gxmu/manuscriptGen/index.vue index 9afcac9..62ce974 100644 --- a/src/views/gxmu/manuscriptGen/index.vue +++ b/src/views/gxmu/manuscriptGen/index.vue @@ -152,6 +152,13 @@ @@ -198,10 +243,10 @@ import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json'; import highlight from '@bytemd/plugin-highlight-ssr'; import 'highlight.js/styles/default.css'; - import { computed, onBeforeUnmount, reactive, ref } from 'vue'; + import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'; import { UploadOutlined } from '@ant-design/icons-vue'; import { useRouter } from 'vue-router'; - import { message } from 'ant-design-vue'; + import { Modal, message } from 'ant-design-vue'; import { messageLoading } from 'ele-admin-pro'; import { saveAs } from 'file-saver'; import { asBlob } from 'html-docx-js-typescript'; @@ -215,6 +260,16 @@ import type { FileRecord } from '@/api/system/file/model'; import { copyText } from '@/utils/common'; import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result'; + import { markdownToHtml } from '@/utils/manuscript-content'; + import { addManuscript } from '@/api/cms/manuscript'; + import type { Manuscript } from '@/api/cms/manuscript/model'; + import { Editor, Toolbar } from '@wangeditor/editor-for-vue'; + import type { + IDomEditor, + IEditorConfig, + IToolbarConfig + } from '@wangeditor/editor'; + import '@wangeditor/editor/dist/css/style.css'; interface FormState { /** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */ @@ -248,6 +303,46 @@ highlight() ]); const loading = ref(false); + /** 正文区域展示方式:默认看 Markdown 预览,一键排版后切到富文本 */ + const contentTab = ref<'markdown' | 'rich'>('markdown'); + /** 富文本编辑器里的 HTML(一键排版灌入,人工可继续改) */ + const richContentHtml = ref(''); + const savingManuscript = ref(false); + /** 上次保存时的正文文本快照:内容没变就不再让点「保存为稿件」,避免存出重复草稿 */ + const savedSnapshot = ref(''); + const editorRef = shallowRef(); + + const toolbarConfig: Partial = { + excludeKeys: ['group-video'] + }; + + const editorConfig: Partial = { + placeholder: '点上方「一键排版」,把生成结果按原文排版放进这里', + MENU_CONF: { + uploadImage: { + allowedFileTypes: ['image/*'], + maxFileSize: 2 * 1024 * 1024, + base64LimitSize: 0, + async customUpload(file: File, insertFn: (url: string) => void) { + if (!file.type.startsWith('image/')) { + message.error('只能上传图片'); + return; + } + try { + const result = await uploadFile(file); + insertFn(result.url || ''); + } catch (e: any) { + message.error(e.message || '图片上传失败'); + } + } + } + } + }; + + const handleEditorCreated = (editor: IDomEditor) => { + editorRef.value = editor; + }; + const generatedTitle = ref(''); const generatedContent = ref(''); const uploadedFiles = ref[]>([]); @@ -569,6 +664,10 @@ const clearGenerated = () => { generatedTitle.value = ''; generatedContent.value = ''; + // 排版结果是这份 markdown 的产物,正文换了就不能留着旧的 + richContentHtml.value = ''; + savedSnapshot.value = ''; + contentTab.value = 'markdown'; }; const resetForm = () => { @@ -627,6 +726,127 @@ form.number = 600; }; + /** 只留文本、丢掉标签与空白,用来判断两份富文本是不是同一份内容 */ + const normalizeForCompare = (html: string) => + String(html || '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ''); + + /** 编辑器里的内容是否已经存成稿件(按文本比,编辑器规整过标签属性也算同一份) */ + const savedUpToDate = computed( + () => + !!savedSnapshot.value && + savedSnapshot.value === normalizeForCompare(richContentHtml.value) + ); + + /** 保存按钮的可用状态:没内容、正在存、这份已经存过,都不给点 */ + const saveDisabled = computed( + () => + savingManuscript.value || + !hasRichText(richContentHtml.value) || + savedUpToDate.value + ); + + /** 编辑器里的“空”:wangEditor 空内容是一段 `


`,不能只看字符串长度 */ + const hasRichText = (html: string) => { + const text = String(html || '') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, '') + .trim(); + return !!text || / { + if (loading.value) { + message.warning('正在生成中,暂不能排版'); + return; + } + if (!markdownContent.value) { + message.warning('暂无可排版的正文内容'); + return; + } + if (markdownContent.value === FAILED_TEXT) { + message.error('生成失败的内容不能排版,请重新生成'); + return; + } + const nextHtml = markdownToHtml(markdownContent.value, [ + gfm({ locale: zh_HansGfm }) + ]); + if (!nextHtml.trim()) { + message.warning('当前内容转换不出富文本,请检查正文'); + return; + } + const apply = () => { + richContentHtml.value = nextHtml; + // 排版换了内容,之前那次保存的状态就作废 + savedSnapshot.value = ''; + contentTab.value = 'rich'; + message.success('已按原文排版放进富文本编辑器,请检查后保存'); + }; + // 编辑器里有内容且不是同一份排版(用户改过),才需要确认覆盖。 + // 比的是「文本」而不是 HTML:wangEditor 会把 setHtml 进来的标签属性、 + // 空白重新规整一遍,按 HTML 比会把「原样再排一次」也判成要覆盖。 + if ( + hasRichText(richContentHtml.value) && + normalizeForCompare(richContentHtml.value) !== normalizeForCompare(nextHtml) + ) { + Modal.confirm({ + title: '覆盖编辑器里的内容?', + content: '富文本编辑器里已有内容,重新排版会覆盖掉当前改动。', + okText: '覆盖', + cancelText: '取消', + onOk: apply + }); + return; + } + apply(); + }; + + /** + * 保存为稿件:入库一律写 html,和「编辑稿件」弹窗的口径一致。 + * add 接口不回 id,所以这里只负责“存成一条新稿件”,不冒充“再编辑同一篇”: + * 存过之后按钮置灰,改过正文才会重新可点(避免连点存出多条草稿)。 + */ + const saveAsManuscript = async () => { + const title = (generatedTitle.value || form.theme).trim(); + if (!title) { + message.warning('请先填写生成标题,稿件需要标题'); + return; + } + if (!hasRichText(richContentHtml.value)) { + message.warning('富文本里还没有内容,请先点「一键排版」'); + return; + } + if (savedUpToDate.value) { + message.info('这份排版已经保存过了,改完再点可以另存一条'); + return; + } + savingManuscript.value = true; + try { + const msg = await addManuscript({ + title, + content: richContentHtml.value, + contentFormat: 'html', + // 后端 status 是 Integer(列表里 0 待审核 / 1 显示),前端类型写成 string 是历史遗留 + status: 1 as unknown as Manuscript['status'], + sortNumber: 0 + }); + savedSnapshot.value = normalizeForCompare(richContentHtml.value); + message.success(msg || '已保存为稿件'); + } catch (e: any) { + message.error(e.message || '保存稿件失败'); + } finally { + savingManuscript.value = false; + } + }; + const removeUploadedFile = (index: number) => { uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index); }; @@ -852,6 +1072,7 @@ onBeforeUnmount(() => { clearStreamResolver(); + editorRef.value?.destroy(); }); @@ -1047,6 +1268,46 @@ color: #1f2d3d; } + .content-tabs :deep(.ant-tabs-nav) { + margin-bottom: 10px; + } + + .rich-shell { + border: 1px solid #c7e8ff; + border-radius: 16px; + overflow: hidden; + background: #fffdfb; + } + + .rich-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #eef4f9; + background: #f5fbff; + } + + .rich-bar-tip { + color: #5f7f9f; + font-size: 12px; + line-height: 1.6; + } + + .rich-toolbar { + border-bottom: 1px solid #f0f0f0; + } + + .rich-editor { + min-height: 360px; + } + + .rich-editor :deep(.w-e-text-container) { + min-height: 360px; + } + + .full-width { width: 100%; } diff --git a/src/views/oa/project/components/project-edit.vue b/src/views/oa/project/components/project-edit.vue index d5fd69c..c786160 100644 --- a/src/views/oa/project/components/project-edit.vue +++ b/src/views/oa/project/components/project-edit.vue @@ -134,7 +134,7 @@ class="ele-btn-icon" @click="openUrl('https://p.haoii123.com/')" > - 一键排版 + 公众号排版