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'`。
|
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
|
||||||
*/
|
*/
|
||||||
import { getProcessor } from 'bytemd';
|
import { getProcessor } from 'bytemd';
|
||||||
|
import type { BytemdPlugin } from 'bytemd';
|
||||||
|
|
||||||
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
|
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
|
||||||
const MARKDOWN_LINE_RE =
|
const MARKDOWN_LINE_RE =
|
||||||
@@ -57,11 +58,20 @@ export function detectFormat(content?: string | null): ContentFormat {
|
|||||||
return looksLikeMarkdown(text) ? 'markdown' : 'text';
|
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 {
|
try {
|
||||||
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
|
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
|
||||||
const result = getProcessor({}).processSync(String(markdown ?? ''));
|
const result = getProcessor({ plugins }).processSync(String(markdown ?? ''));
|
||||||
return String(result);
|
return String(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('markdown 转换失败', e);
|
console.error('markdown 转换失败', e);
|
||||||
|
|||||||
@@ -152,6 +152,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<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="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
|
||||||
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
|
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
|
||||||
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
|
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
|
||||||
@@ -171,17 +178,55 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="正文内容">
|
<a-form-item label="正文内容">
|
||||||
<div class="markdown-shell">
|
<a-tabs
|
||||||
<div v-if="loading" class="markdown-streaming">
|
v-model:activeKey="contentTab"
|
||||||
{{ markdownContent || 'AI 正在生成内容...' }}
|
size="small"
|
||||||
</div>
|
class="content-tabs"
|
||||||
<div
|
>
|
||||||
v-else-if="markdownContent"
|
<a-tab-pane key="markdown" tab="Markdown 预览">
|
||||||
class="markdown-body markdown-viewer"
|
<div class="markdown-shell">
|
||||||
v-html="renderedMarkdownHtml"
|
<div v-if="loading" class="markdown-streaming">
|
||||||
></div>
|
{{ markdownContent || 'AI 正在生成内容...' }}
|
||||||
<div v-else class="markdown-empty">生成结果将显示在这里</div>
|
</div>
|
||||||
</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-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</template>
|
</template>
|
||||||
@@ -198,10 +243,10 @@
|
|||||||
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
||||||
import highlight from '@bytemd/plugin-highlight-ssr';
|
import highlight from '@bytemd/plugin-highlight-ssr';
|
||||||
import 'highlight.js/styles/default.css';
|
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 { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 { messageLoading } from 'ele-admin-pro';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { asBlob } from 'html-docx-js-typescript';
|
import { asBlob } from 'html-docx-js-typescript';
|
||||||
@@ -215,6 +260,16 @@
|
|||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
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 {
|
interface FormState {
|
||||||
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
|
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
|
||||||
@@ -248,6 +303,46 @@
|
|||||||
highlight()
|
highlight()
|
||||||
]);
|
]);
|
||||||
const loading = ref(false);
|
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 generatedTitle = ref('');
|
||||||
const generatedContent = ref('');
|
const generatedContent = ref('');
|
||||||
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
||||||
@@ -569,6 +664,10 @@
|
|||||||
const clearGenerated = () => {
|
const clearGenerated = () => {
|
||||||
generatedTitle.value = '';
|
generatedTitle.value = '';
|
||||||
generatedContent.value = '';
|
generatedContent.value = '';
|
||||||
|
// 排版结果是这份 markdown 的产物,正文换了就不能留着旧的
|
||||||
|
richContentHtml.value = '';
|
||||||
|
savedSnapshot.value = '';
|
||||||
|
contentTab.value = 'markdown';
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -627,6 +726,127 @@
|
|||||||
form.number = 600;
|
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) => {
|
const removeUploadedFile = (index: number) => {
|
||||||
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
|
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
|
||||||
};
|
};
|
||||||
@@ -852,6 +1072,7 @@
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
|
editorRef.value?.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1047,6 +1268,46 @@
|
|||||||
color: #1f2d3d;
|
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 {
|
.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
class="ele-btn-icon"
|
class="ele-btn-icon"
|
||||||
@click="openUrl('https://p.haoii123.com/')"
|
@click="openUrl('https://p.haoii123.com/')"
|
||||||
>
|
>
|
||||||
一键排版
|
公众号排版
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user