feat(gxmu): 文稿工作台新增「一键排版」,按原文排版进富文本编辑器
生成的 markdown 正文现在能一键转成带格式的富文本: - 正文区改为「Markdown 预览 / 富文本排版」两个 tab,一键排版后自动切到编辑器 - 转换统一走 utils/manuscript-content 的 markdownToHtml,新增 plugins 参数, 传 gfm 插件还原表格、删除线(不挂 gfm 时表格会漏成一堆 | 管道符) - 生成标题不拼进正文:标题有独立字段,导出 Word 时才拼进导出件 - 编辑器已有内容且文本不一致时才二次确认覆盖,同一份内容重复排版不打扰 - 富文本下方新增「保存为稿件」,入库写 contentFormat: 'html'; 存过之后按钮置灰,避免连点存出重复草稿 - oa/项目资料 里指向外部排版站的同名按钮改成「公众号排版」,避开同名不同义
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(/ /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>
|
||||
|
||||
Reference in New Issue
Block a user