feat(manuscript): 后台按正文格式标记渲染与保存
- 新增 utils/manuscript-content.ts:格式判定、bytemd 转 HTML、纯文本转段落 - 编辑弹窗打开时把 markdown / 纯文本转成富文本,否则 wangEditor 会把 # 和 ** 当成正文, 保存一次就把原稿改坏 - 保存统一写 contentFormat: 'html' - 列表加「正文格式」列,非 HTML 的行显式标出来
This commit is contained in:
@@ -9,8 +9,10 @@ export interface Manuscript {
|
|||||||
id?: number;
|
id?: number;
|
||||||
//
|
//
|
||||||
title?: string;
|
title?: string;
|
||||||
//
|
// 正文(HTML,历史上可能是 markdown / 纯文本)
|
||||||
content?: string;
|
content?: string;
|
||||||
|
// 正文格式:html / markdown / text
|
||||||
|
contentFormat?: string;
|
||||||
//
|
//
|
||||||
cover?: string;
|
cover?: string;
|
||||||
// 是否删除, 0否, 1是
|
// 是否删除, 0否, 1是
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 稿件正文的格式判定与转换(后台)。
|
||||||
|
*
|
||||||
|
* `cms_manuscript.content` 历史上混着两种来源:
|
||||||
|
* 1) wangEditor 保存的 HTML;
|
||||||
|
* 2) 从 AI 结果直接粘进后台的 markdown(`## 标题`、`**加粗**`、`- 列表`)。
|
||||||
|
* 后台编辑器只认 HTML:把 markdown 原样塞进 wangEditor,它会当成普通文字,
|
||||||
|
* 保存时再包一层 `<p>`,于是 `## 标题` 变成正文里的一行字——打开编辑即损坏。
|
||||||
|
*
|
||||||
|
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
|
||||||
|
*/
|
||||||
|
import { getProcessor } from 'bytemd';
|
||||||
|
|
||||||
|
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
|
||||||
|
const MARKDOWN_LINE_RE =
|
||||||
|
/(^|\n)[ \t]{0,3}(#{1,6}[ \t]|[-*+][ \t]|\d+\.[ \t]|>[ \t])/;
|
||||||
|
const MARKDOWN_EMPHASIS_RE =
|
||||||
|
/(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(`[^`\n]+`)|(!\[[^\]]*\]\()/;
|
||||||
|
|
||||||
|
export type ContentFormat = 'html' | 'markdown' | 'text';
|
||||||
|
|
||||||
|
/** 后端 `contentFormat` 的规范值;认不出来返回空串,交给内容判断兜底。 */
|
||||||
|
export function normalizeFormat(format?: string | null): ContentFormat | '' {
|
||||||
|
const value = String(format ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (value === 'html' || value === 'text') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === 'markdown' || value === 'md') {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeHtml(content?: string | null): boolean {
|
||||||
|
return HTML_TAG_RE.test(String(content ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeMarkdown(content?: string | null): boolean {
|
||||||
|
const text = String(content ?? '');
|
||||||
|
if (!text.trim() || looksLikeHtml(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return MARKDOWN_LINE_RE.test(text) || MARKDOWN_EMPHASIS_RE.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 没有格式标记时的兜底判断(老接口、或写库前漏填的行)。 */
|
||||||
|
export function detectFormat(content?: string | null): ContentFormat {
|
||||||
|
const text = String(content ?? '');
|
||||||
|
if (!text.trim()) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
if (looksLikeHtml(text)) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
return looksLikeMarkdown(text) ? 'markdown' : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。 */
|
||||||
|
export function markdownToHtml(markdown: string): string {
|
||||||
|
try {
|
||||||
|
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
|
||||||
|
const result = getProcessor({}).processSync(String(markdown ?? ''));
|
||||||
|
return String(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('markdown 转换失败', e);
|
||||||
|
return plainTextToHtml(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯文本 → HTML:空行分段,段内换行转 `<br>`,否则编辑器里会挤成一段。 */
|
||||||
|
export function plainTextToHtml(text: string): string {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter((paragraph) => paragraph !== '')
|
||||||
|
.map(
|
||||||
|
(paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@
|
|||||||
import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
|
import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
|
||||||
import { Form, message } from 'ant-design-vue';
|
import { Form, message } from 'ant-design-vue';
|
||||||
import { assignObject } from 'ele-admin-pro';
|
import { assignObject } from 'ele-admin-pro';
|
||||||
|
import {
|
||||||
|
detectFormat,
|
||||||
|
markdownToHtml,
|
||||||
|
normalizeFormat,
|
||||||
|
plainTextToHtml
|
||||||
|
} from '@/utils/manuscript-content';
|
||||||
import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
|
import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
|
||||||
import { Manuscript } from '@/api/cms/manuscript/model';
|
import { Manuscript } from '@/api/cms/manuscript/model';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
@@ -147,6 +153,7 @@
|
|||||||
id: undefined,
|
id: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
content: undefined,
|
content: undefined,
|
||||||
|
contentFormat: 'html',
|
||||||
cover: undefined,
|
cover: undefined,
|
||||||
deleted: undefined,
|
deleted: undefined,
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
@@ -161,6 +168,27 @@
|
|||||||
emit('update:visible', value);
|
emit('update:visible', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入库格式 → 编辑器 HTML。
|
||||||
|
* 库里可能是 markdown 或纯文本(早期从 AI 结果粘进来的),wangEditor 只认 HTML,
|
||||||
|
* 原样塞进去会在保存时被当成普通文字包进 <p>,等于把正文改坏。
|
||||||
|
*/
|
||||||
|
const toEditorHtml = (data: Manuscript): string => {
|
||||||
|
const content = data.content || '';
|
||||||
|
const format = normalizeFormat(data.contentFormat) || detectFormat(content);
|
||||||
|
if (format === 'markdown') {
|
||||||
|
message.info('这篇稿件存的是 Markdown,已转换为富文本,请确认排版后保存');
|
||||||
|
return markdownToHtml(content);
|
||||||
|
}
|
||||||
|
if (format === 'text') {
|
||||||
|
message.info(
|
||||||
|
'这篇稿件存的是纯文本,已按段落转换为富文本,请确认排版后保存'
|
||||||
|
);
|
||||||
|
return plainTextToHtml(content);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
const hasEditorContent = (html?: string) => {
|
const hasEditorContent = (html?: string) => {
|
||||||
const text = String(html || '')
|
const text = String(html || '')
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
@@ -257,7 +285,8 @@
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
const formData = {
|
const formData = {
|
||||||
...form,
|
...form,
|
||||||
content: contentHtml.value
|
content: contentHtml.value,
|
||||||
|
contentFormat: 'html'
|
||||||
};
|
};
|
||||||
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
|
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
|
||||||
const msg = await saveOrUpdate(formData);
|
const msg = await saveOrUpdate(formData);
|
||||||
@@ -294,7 +323,7 @@
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
if (props.data) {
|
if (props.data) {
|
||||||
assignObject(form, props.data);
|
assignObject(form, props.data);
|
||||||
contentHtml.value = props.data.content || '';
|
contentHtml.value = toEditorHtml(props.data);
|
||||||
coverList.value = props.data.cover
|
coverList.value = props.data.cover
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,7 +58,11 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<ManuscriptEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<ManuscriptEdit
|
||||||
|
v-model:visible="showEdit"
|
||||||
|
:data="current"
|
||||||
|
@done="reload"
|
||||||
|
/>
|
||||||
<!-- 新增审核记录弹窗 -->
|
<!-- 新增审核记录弹窗 -->
|
||||||
<ReviewRecordModal
|
<ReviewRecordModal
|
||||||
v-model:visible="showReviewModal"
|
v-model:visible="showReviewModal"
|
||||||
@@ -71,7 +75,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { createVNode, ref } from 'vue';
|
import { createVNode, ref } from 'vue';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal, Tag } from 'ant-design-vue';
|
||||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||||
import type { EleProTable } from 'ele-admin-pro';
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
import { toDateString } from 'ele-admin-pro';
|
import { toDateString } from 'ele-admin-pro';
|
||||||
@@ -82,9 +86,14 @@
|
|||||||
import Search from './components/search.vue';
|
import Search from './components/search.vue';
|
||||||
import ManuscriptEdit from './components/manuscriptEdit.vue';
|
import ManuscriptEdit from './components/manuscriptEdit.vue';
|
||||||
import ReviewRecordModal from './components/reviewRecordModal.vue';
|
import ReviewRecordModal from './components/reviewRecordModal.vue';
|
||||||
import { pageManuscript, removeManuscript, removeBatchManuscript } from '@/api/cms/manuscript';
|
import {
|
||||||
|
pageManuscript,
|
||||||
|
removeManuscript,
|
||||||
|
removeBatchManuscript
|
||||||
|
} from '@/api/cms/manuscript';
|
||||||
import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model';
|
import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model';
|
||||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||||
|
import { detectFormat, normalizeFormat } from '@/utils/manuscript-content';
|
||||||
|
|
||||||
// 表格实例
|
// 表格实例
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
@@ -130,19 +139,36 @@
|
|||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
key: 'id',
|
key: 'id',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 90,
|
width: 90
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '标题',
|
title: '标题',
|
||||||
dataIndex: 'title',
|
dataIndex: 'title',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
align: 'center',
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '封面',
|
title: '封面',
|
||||||
dataIndex: 'cover',
|
dataIndex: 'cover',
|
||||||
key: 'cover',
|
key: 'cover',
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '正文格式',
|
||||||
|
dataIndex: 'contentFormat',
|
||||||
|
key: 'contentFormat',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
width: 110,
|
||||||
|
customRender: ({ record }: { record: Manuscript }) => {
|
||||||
|
const format =
|
||||||
|
normalizeFormat(record.contentFormat) || detectFormat(record.content);
|
||||||
|
if (format === 'html') {
|
||||||
|
return createVNode(Tag, { color: 'default' }, () => 'HTML');
|
||||||
|
}
|
||||||
|
return createVNode(Tag, { color: 'orange' }, () =>
|
||||||
|
format === 'markdown' ? 'Markdown' : '纯文本'
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
@@ -157,7 +183,7 @@
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
align: 'center',
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '当前审核状态',
|
title: '当前审核状态',
|
||||||
|
|||||||
Reference in New Issue
Block a user