feat(gxmu): 文稿工作台新增「一键排版」,按原文排版进富文本编辑器

生成的 markdown 正文现在能一键转成带格式的富文本:
- 正文区改为「Markdown 预览 / 富文本排版」两个 tab,一键排版后自动切到编辑器
- 转换统一走 utils/manuscript-content 的 markdownToHtml,新增 plugins 参数,
  传 gfm 插件还原表格、删除线(不挂 gfm 时表格会漏成一堆 | 管道符)
- 生成标题不拼进正文:标题有独立字段,导出 Word 时才拼进导出件
- 编辑器已有内容且文本不一致时才二次确认覆盖,同一份内容重复排版不打扰
- 富文本下方新增「保存为稿件」,入库写 contentFormat: 'html';
  存过之后按钮置灰,避免连点存出重复草稿
- oa/项目资料 里指向外部排版站的同名按钮改成「公众号排版」,避开同名不同义
This commit is contained in:
2026-11-01 19:27:46 +08:00
parent 4b810a731c
commit 6d19cb5a05
3 changed files with 288 additions and 17 deletions
+13 -3
View File
@@ -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);
+274 -13
View File
@@ -152,6 +152,13 @@
</template>
<template #extra>
<a-space>
<a-button
type="primary"
:disabled="loading || !markdownContent"
@click="formatRichText"
>
一键排版
</a-button>
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
@@ -171,17 +178,55 @@
/>
</a-form-item>
<a-form-item label="正文内容">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
<a-tabs
v-model:activeKey="contentTab"
size="small"
class="content-tabs"
>
<a-tab-pane key="markdown" tab="Markdown 预览">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
</a-tab-pane>
<a-tab-pane key="rich" tab="富文本排版">
<div class="rich-shell">
<div class="rich-bar">
<span class="rich-bar-tip">
一键排版按原文的标题层级、段落、列表、表格还原,可直接在编辑器里继续改
</span>
<a-button
size="small"
type="primary"
:disabled="saveDisabled"
@click="saveAsManuscript"
>
{{ savedUpToDate ? '已保存为稿件' : '保存为稿件' }}
</a-button>
</div>
<Toolbar
class="rich-toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
mode="default"
/>
<Editor
v-model="richContentHtml"
class="rich-editor"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleEditorCreated"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-form-item>
</a-form>
</template>
@@ -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<IDomEditor>();
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video']
};
const editorConfig: Partial<IEditorConfig> = {
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<Partial<FileRecord>[]>([]);
@@ -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 空内容是一段 `<p><br></p>`,不能只看字符串长度 */
const hasRichText = (html: string) => {
const text = String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !!text || /<img\b/i.test(String(html || ''));
};
/**
* 一键排版:把生成结果按原文的标题层级、段落、列表、表格转成 HTML,
* 灌进富文本编辑器。
* - 用 gfm 插件,和页面上 Markdown 预览同一套解析(表格不会漏成管道符);
* - 不把生成标题拼进正文:标题有独立字段,导出 Word 时也只在导出件里拼;
* - 不落库:保存是用户在编辑器里确认过的第二次动作(见 saveAsManuscript)。
*/
const formatRichText = () => {
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();
});
</script>
@@ -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%;
}
@@ -134,7 +134,7 @@
class="ele-btn-icon"
@click="openUrl('https://p.haoii123.com/')"
>
一键排版
公众号排版
</a-button>
</a-space>
</div>