feat: 接入博客文章HTML富文本编辑模式

This commit is contained in:
sunlei 2026-07-01 17:14:13 +08:00
parent adab366275
commit 45f87d06d3
5 changed files with 612 additions and 76 deletions

View File

@ -0,0 +1,49 @@
.blog-article-modal {
width: min(1080px, calc(100vw - 48px)) !important;
&__content {
padding: 16px 20px 20px;
}
}
.blog-article-form {
margin: 0;
&__content {
align-items: flex-start !important;
> label {
padding-top: 8px;
}
}
&__content,
&__html-rich,
&__html-rich .kt-tiptap-editor,
&__content .kt-milkdown-editor,
&__content .ant-input,
&__html-source {
width: 100%;
}
&__html-rich .kt-tiptap-editor,
&__content .kt-milkdown-editor,
&__html-source {
min-height: 560px;
}
&__html-rich .kt-tiptap-editor {
align-self: stretch;
}
&__html-source {
resize: vertical;
font-family:
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
font-size: 13px;
line-height: 1.65;
}
}

View File

@ -0,0 +1,30 @@
/* @vitest-environment node */
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('list.tsx', import.meta.url), 'utf8');
describe('blog article list preview entry', () => {
it('keeps the preview row action wired to the hidden iframe route', () => {
expect(source).toContain("key: 'preview'");
expect(source).toContain("permissionCodes: ['Blog:Article:Preview']");
expect(source).toContain("name: 'BlogArticlePreview'");
expect(source).toContain('articleId: row.id');
});
it('wires Tiptap editor mode switching without dropping current content', () => {
expect(source).toContain(
"import { KtTiptapHtmlEditor } from '#/components/richText';",
);
expect(source).toContain('createBlogArticleEditorModeSchema');
expect(source).toContain(
'void setArticleEditorMode(mode, { preserveContent: true });',
);
expect(source).toContain(
'contentFormat: getContentFormatForEditorMode(mode)',
);
expect(source).toContain('editorMode: mode');
});
});

View File

@ -1,5 +1,10 @@
import type { TableColumnType } from 'antdv-next';
import type {
BlogArticleEditorMode,
BlogArticleFormValues,
} from '../modules/article-form';
import type { WordpressBlogApi } from '#/api/blog';
import type {
KtTableApi,
@ -9,6 +14,7 @@ import type {
} from '#/components/ktTable';
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus, SvgDownloadIcon } from '@vben/icons';
@ -27,9 +33,24 @@ import {
} from '#/api/blog';
import { KtTable, useKtTable } from '#/components/ktTable';
import { KtMilkdownEditor } from '#/components/markdown';
import { KtTiptapHtmlEditor } from '#/components/richText';
import {
BLOG_ARTICLE_FORM_CLASS,
BLOG_ARTICLE_MODAL_CLASS,
BLOG_ARTICLE_MODAL_CONTENT_CLASS,
buildBlogArticleSubmitPayload,
createBlogArticleContentSchema,
createBlogArticleEditorModeSchema,
getBlogArticleCreateFormDefaults,
getBlogArticleEditFormValues,
getContentFormatForEditorMode,
getRenderedText,
} from '../modules/article-form';
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
import './list.scss';
type TermOption = {
label: string;
value: string;
@ -54,7 +75,9 @@ const articleStatusOptions = [
export default defineComponent({
name: 'BlogArticleList',
setup() {
const router = useRouter();
const editingId = ref<string>();
const contentEditMode = ref<BlogArticleEditorMode>('markdown');
const categoryOptions = ref<TermOption[]>([]);
const tagOptions = ref<TermOption[]>([]);
@ -118,14 +141,13 @@ export default defineComponent({
fieldName: 'excerpt',
label: '摘要',
},
createBlogArticleEditorModeSchema(handleArticleEditorModeChange),
{
component: KtMilkdownEditor,
componentProps: {
minHeight: 460,
placeholder: '请输入 Markdown 正文',
},
fieldName: 'content',
label: '内容',
...createBlogArticleContentSchema(
'markdown',
KtMilkdownEditor,
KtTiptapHtmlEditor,
),
},
{
component: 'Switch',
@ -141,7 +163,8 @@ export default defineComponent({
editingId.value ? '编辑文章' : '新建文章',
);
const [ArticleModal, articleModalApi] = useVbenModal({
class: 'w-[760px]',
class: BLOG_ARTICLE_MODAL_CLASS,
contentClass: BLOG_ARTICLE_MODAL_CONTENT_CLASS,
fullscreenButton: false,
async onConfirm() {
await submitArticle();
@ -149,9 +172,9 @@ export default defineComponent({
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = articleModalApi.getData<{
values?: WordpressBlogApi.ArticleBody;
values?: BlogArticleFormValues;
}>();
void resetArticleForm(values || getArticleFormDefaults());
void resetArticleForm(values || getBlogArticleCreateFormDefaults());
},
});
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
@ -205,6 +228,12 @@ export default defineComponent({
const rowActions: Array<
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
> = [
{
key: 'preview',
label: '预览',
onClick: openPreview,
permissionCodes: ['Blog:Article:Preview'],
},
{
key: 'edit',
label: '编辑',
@ -284,29 +313,6 @@ export default defineComponent({
];
}
function getRenderedText(value?: string | WordpressBlogApi.RenderedField) {
if (!value) return '';
if (typeof value === 'string') return stripHtml(value);
return stripHtml(value.raw || value.rendered || '');
}
function getEditableContent(
value?: string | WordpressBlogApi.RenderedField,
markdown?: string,
) {
if (markdown) return markdown;
if (!value) return '';
if (typeof value === 'string') return value;
return value.raw || value.rendered || '';
}
function stripHtml(value: string) {
return value
.replaceAll(/<[^>]+>/g, '')
.replaceAll('&nbsp;', ' ')
.trim();
}
function getStatusOption(status?: string) {
return (
articleStatusOptions.find((item) => item.value === status) ||
@ -385,28 +391,66 @@ export default defineComponent({
});
}
function getArticleFormDefaults(
searchValues: ArticleSearchValues = {},
): WordpressBlogApi.ArticleBody {
return {
categories: [...(searchValues.categories || [])],
content: '',
contentFormat: 'markdown',
excerpt: '',
slug: '',
status: 'draft',
sticky: false,
tags: [...(searchValues.tags || [])],
title: '',
};
}
async function resetArticleForm(values: WordpressBlogApi.ArticleBody) {
/**
* Resets modal form state before applying create or edit values.
* @param values Article values selected for the current modal session.
*/
async function resetArticleForm(values: BlogArticleFormValues) {
await articleFormApi.resetForm();
await articleFormApi.setValues(values);
await articleFormApi.resetValidate();
}
/**
* Handles editor mode changes from the form control without clearing the current content draft.
* @param mode Editor mode selected by the user.
*/
function handleArticleEditorModeChange(mode: BlogArticleEditorMode) {
void setArticleEditorMode(mode, { preserveContent: true });
}
/**
* Switches the content field between Markdown, rich HTML, and raw WordPress HTML editing.
* @param mode Editor mode selected for the current modal session.
* @param options Whether the current content draft should be reapplied after schema replacement.
* @param options.preserveContent Reapplies the existing content value after the editor component changes.
*/
async function setArticleEditorMode(
mode: BlogArticleEditorMode,
options: { preserveContent?: boolean } = {},
) {
const currentValues = options.preserveContent
? await articleFormApi.getValues<BlogArticleFormValues>()
: undefined;
contentEditMode.value = mode;
await articleFormApi.updateSchema([
createBlogArticleEditorModeSchema(handleArticleEditorModeChange),
createBlogArticleContentSchema(
mode,
KtMilkdownEditor,
KtTiptapHtmlEditor,
),
]);
const nextValues: Partial<BlogArticleFormValues> = {
contentFormat: getContentFormatForEditorMode(mode),
editorMode: mode,
};
if (
options.preserveContent &&
currentValues &&
'content' in currentValues
) {
nextValues.content = currentValues.content;
}
await articleFormApi.setValues(nextValues);
}
/**
* Opens the article modal in Markdown authoring mode with current table filters as defaults.
* @param context Optional table context supplied by a toolbar button.
*/
async function openCreate(
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
) {
@ -415,37 +459,42 @@ export default defineComponent({
: await tableApi.getSearchValues();
editingId.value = undefined;
articleModalApi
.setData({ values: getArticleFormDefaults(searchValues) })
.open();
const values = getBlogArticleCreateFormDefaults(searchValues);
await setArticleEditorMode(values.editorMode || 'markdown');
articleModalApi.setData({ values }).open();
}
function openEdit(row: WordpressBlogApi.Article) {
/**
* Opens the article modal using raw HTML mode for imported WordPress/Argon content.
* @param row Article row selected from the table.
*/
async function openEdit(row: WordpressBlogApi.Article) {
editingId.value = `${row.id}`;
articleModalApi
.setData({
values: {
categories: row.categories || [],
content: getEditableContent(row.content, row.contentMarkdown),
contentFormat: 'markdown',
excerpt: getRenderedText(row.excerpt),
id: row.id,
slug: row.slug || '',
status: row.status || 'draft',
sticky: !!row.sticky,
tags: row.tags || [],
title: getRenderedText(row.title),
},
})
.open();
const values = getBlogArticleEditFormValues(row);
await setArticleEditorMode(values.editorMode || 'markdown');
articleModalApi.setData({ values }).open();
}
/**
* @param row ID Blog Web
*/
function openPreview(row: WordpressBlogApi.Article) {
void router.push({
name: 'BlogArticlePreview',
params: {
articleId: row.id,
},
});
}
/**
* Validates and submits the article form with the active content format preserved.
*/
async function submitArticle() {
const { valid } = await articleFormApi.validate();
if (!valid) return;
const values =
await articleFormApi.getValues<WordpressBlogApi.ArticleBody>();
const values = await articleFormApi.getValues<BlogArticleFormValues>();
const title = values.title?.trim();
if (!title) {
message.warning('请填写文章标题');
@ -455,9 +504,11 @@ export default defineComponent({
articleModalApi.lock();
try {
const payload = {
...values,
contentFormat: 'markdown' as const,
id: editingId.value,
...buildBlogArticleSubmitPayload(
values,
editingId.value,
contentEditMode.value,
),
title,
};
await (editingId.value
@ -559,7 +610,7 @@ export default defineComponent({
/>
<ArticleModal title={modalTitle.value}>
<ArticleForm class="mx-2" />
<ArticleForm class={BLOG_ARTICLE_FORM_CLASS} />
</ArticleModal>
</Page>
);

View File

@ -0,0 +1,138 @@
/* @vitest-environment happy-dom */
import { describe, expect, it } from 'vitest';
import {
BLOG_ARTICLE_CONTENT_FIELD_CLASS,
BLOG_ARTICLE_FORM_CLASS,
BLOG_ARTICLE_HTML_RICH_CLASS,
BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
buildBlogArticleSubmitPayload,
createBlogArticleContentSchema,
getBlogArticleCreateFormDefaults,
getBlogArticleEditFormValues,
getContentFormatForEditorMode,
} from './article-form';
describe('blog article form helpers', () => {
it('edits WordPress Argon HTML articles as raw HTML to preserve codeblocks', () => {
const values = getBlogArticleEditFormValues({
categories: ['技术'],
contentHtml:
'<pre class="wp-block-code hljs-codeblock"><code class="hljs sql">select 1;</code></pre>',
contentMarkdown: '```sql\nselect 1;\n```',
excerpt: '摘要',
id: '50',
slug: 'wordpress-post',
status: 'publish',
tags: ['WordPress'],
title: 'WordPress 文章',
});
expect(values.editorMode).toBe('html-source');
expect(values.contentFormat).toBe('html');
expect(values.content).toContain('hljs-codeblock');
});
it('edits plain HTML articles with the rich HTML editor by default', () => {
const values = getBlogArticleEditFormValues({
categories: [],
contentHtml: '<h2>标题</h2><p>正文</p>',
excerpt: '',
id: '51',
slug: 'plain-html-post',
status: 'publish',
tags: [],
title: 'Plain HTML',
});
expect(values.editorMode).toBe('html-rich');
expect(values.contentFormat).toBe('html');
expect(values.content).toBe('<h2>标题</h2><p>正文</p>');
});
it('keeps new local articles in markdown editor mode', () => {
const values = getBlogArticleCreateFormDefaults();
expect(values.editorMode).toBe('markdown');
expect(values.contentFormat).toBe('markdown');
});
it('keeps markdown as the default editing mode for new local articles', () => {
const payload = buildBlogArticleSubmitPayload(
{
content: '# 标题',
contentFormat: 'markdown',
editorMode: 'markdown',
status: 'draft',
title: '新文章',
},
undefined,
'markdown',
);
expect(payload).toMatchObject({
content: '# 标题',
contentFormat: 'markdown',
title: '新文章',
});
});
it('keeps html format in update payloads instead of forcing markdown', () => {
const payload = buildBlogArticleSubmitPayload(
{
content: '<pre class="wp-block-code hljs-codeblock"></pre>',
contentFormat: 'markdown',
editorMode: 'html-source',
status: 'publish',
title: 'WordPress 文章',
},
'50',
'html-source',
);
expect(payload).toMatchObject({
contentFormat: 'html',
id: '50',
});
expect(payload).not.toHaveProperty('editorMode');
});
it('maps both html editor modes to the persisted html content format', () => {
expect(getContentFormatForEditorMode('html-rich')).toBe('html');
expect(getContentFormatForEditorMode('html-source')).toBe('html');
expect(getContentFormatForEditorMode('markdown')).toBe('markdown');
});
it('uses the rich HTML component and class for html-rich mode', () => {
const schema = createBlogArticleContentSchema(
'html-rich',
'MarkdownEditor',
'RichHtmlEditor',
);
expect(BLOG_ARTICLE_FORM_CLASS).toBe('blog-article-form');
expect(schema.formItemClass).toContain(BLOG_ARTICLE_CONTENT_FIELD_CLASS);
expect(schema.component).toBe('RichHtmlEditor');
expect(schema.componentProps).toMatchObject({
class: BLOG_ARTICLE_HTML_RICH_CLASS,
minHeight: 560,
placeholder: '请输入 HTML 富文本正文',
});
});
it('uses a full-width content form item and a tall raw html textarea', () => {
const schema = createBlogArticleContentSchema(
'html-source',
'MarkdownEditor',
'RichHtmlEditor',
);
expect(schema.formItemClass).toContain(BLOG_ARTICLE_CONTENT_FIELD_CLASS);
expect(schema.component).toBe('Textarea');
expect(schema.componentProps).toMatchObject({
autoSize: { maxRows: 30, minRows: 18 },
class: BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
});
});
});

View File

@ -0,0 +1,268 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { WordpressBlogApi } from '#/api/blog';
export type BlogArticleEditorMode = 'html-rich' | 'html-source' | 'markdown';
export type BlogArticleContentFormat = 'html' | 'markdown';
export type BlogArticleFormValues = WordpressBlogApi.ArticleBody & {
editorMode?: BlogArticleEditorMode;
};
export const BLOG_ARTICLE_FORM_CLASS = 'blog-article-form';
export const BLOG_ARTICLE_MODAL_CLASS = 'blog-article-modal';
export const BLOG_ARTICLE_MODAL_CONTENT_CLASS = 'blog-article-modal__content';
export const BLOG_ARTICLE_CONTENT_FIELD_CLASS = 'blog-article-form__content';
export const BLOG_ARTICLE_HTML_RICH_CLASS = 'blog-article-form__html-rich';
export const BLOG_ARTICLE_HTML_TEXTAREA_CLASS =
'blog-article-form__html-source';
export const BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT = 560;
const ARGON_HTML_PATTERN =
/\b(?:wp-block-|hljs-codeblock|hljs-ln|hljs-control|fancybox-wrapper|lazyload|collapse-block)/;
const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
/**
* Builds the content field schema for Markdown, rich HTML, or raw WordPress HTML preservation.
* @param mode Current editor mode; source mode uses a raw textarea to avoid runtime DOM rewrites.
* @param markdownEditor Markdown editor component used for normal local article authoring.
* @param richHtmlEditor Tiptap HTML editor component used for plain HTML authoring.
* @returns Vben form schema for the article content field.
*/
export function createBlogArticleContentSchema(
mode: BlogArticleEditorMode,
markdownEditor: unknown,
richHtmlEditor?: unknown,
): VbenFormSchema {
const common = {
fieldName: 'content',
formItemClass: BLOG_ARTICLE_CONTENT_FIELD_CLASS,
label: '内容',
};
if (mode === 'html-source') {
return {
...common,
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 30, minRows: 18 },
class: BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
placeholder: '保留 WordPress / Argon HTML 原文,保存时仅做安全清洗',
},
controlClass: 'w-full',
wrapperClass: 'items-stretch',
} as VbenFormSchema;
}
if (mode === 'html-rich') {
return {
...common,
component: richHtmlEditor as VbenFormSchema['component'],
componentProps: {
class: BLOG_ARTICLE_HTML_RICH_CLASS,
minHeight: BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT,
placeholder: '请输入 HTML 富文本正文',
},
controlClass: 'w-full',
wrapperClass: 'items-stretch',
} as VbenFormSchema;
}
return {
...common,
component: markdownEditor as VbenFormSchema['component'],
componentProps: {
minHeight: BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT,
placeholder: '请输入 Markdown 正文',
},
controlClass: 'w-full',
wrapperClass: 'items-stretch',
} as VbenFormSchema;
}
/**
* Builds the editor mode selector schema and forwards mode changes to the article list page.
* @param onChange Callback that receives the next editor mode selected in the form.
* @returns Vben form schema for selecting the article editor mode.
*/
export function createBlogArticleEditorModeSchema(
onChange?: (mode: BlogArticleEditorMode) => void,
): VbenFormSchema {
return {
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
onChange: (event: { target?: { value?: BlogArticleEditorMode } }) => {
const mode = event?.target?.value;
if (mode) onChange?.(mode);
},
options: [
{ label: 'Markdown', value: 'markdown' },
{ label: '富文本 HTML', value: 'html-rich' },
{ label: '源码 HTML', value: 'html-source' },
],
optionType: 'button',
},
fieldName: 'editorMode',
label: '编辑模式',
} as VbenFormSchema;
}
/**
* Maps the UI editor mode back to the API's persisted content format contract.
* @param mode Current editor mode from the article form.
* @returns API content format saved with the article.
*/
export function getContentFormatForEditorMode(
mode: BlogArticleEditorMode,
): BlogArticleContentFormat {
return mode === 'markdown' ? 'markdown' : 'html';
}
/**
* Chooses the editor mode for an existing article without forcing imported Argon HTML through Tiptap or Markdown.
* @param article Article row returned by the local blog API.
* @returns Source HTML for Argon runtime snapshots, rich HTML for plain tags, otherwise Markdown.
*/
export function getBlogArticleEditorMode(
article?: Partial<WordpressBlogApi.Article>,
): BlogArticleEditorMode {
const html = getRenderedValue(article?.contentHtml || article?.content);
if (!html) return 'markdown';
if (ARGON_HTML_PATTERN.test(html)) return 'html-source';
return HTML_TAG_PATTERN.test(html) ? 'html-rich' : 'markdown';
}
/**
* Chooses the persisted content format for an existing article.
* @param article Article row returned by the local blog API.
* @returns API content format derived from the detected editor mode.
*/
export function getBlogArticleContentFormat(
article?: Partial<WordpressBlogApi.Article>,
): BlogArticleContentFormat {
return getContentFormatForEditorMode(getBlogArticleEditorMode(article));
}
/**
* Builds form defaults for creating a new article from current table filters.
* @param searchValues Active table search filters used to prefill category and tag fields.
* @param searchValues.categories Active category filters copied into the create form.
* @param searchValues.tags Active tag filters copied into the create form.
* @returns Article form defaults in Markdown mode.
*/
export function getBlogArticleCreateFormDefaults(
searchValues: {
categories?: string[];
tags?: string[];
} = {},
): BlogArticleFormValues {
return {
categories: [...(searchValues.categories || [])],
content: '',
contentFormat: 'markdown',
editorMode: 'markdown',
excerpt: '',
slug: '',
status: 'draft',
sticky: false,
tags: [...(searchValues.tags || [])],
title: '',
};
}
/**
* Builds edit form values, preserving WordPress/Argon HTML when the article depends on runtime classes.
* @param row Article row selected from the article table.
* @returns Form values and content format for the edit modal.
*/
export function getBlogArticleEditFormValues(
row: WordpressBlogApi.Article,
): BlogArticleFormValues {
const editorMode = getBlogArticleEditorMode(row);
const contentFormat = getContentFormatForEditorMode(editorMode);
return {
categories: row.categories || [],
content:
contentFormat === 'html'
? getRenderedValue(row.contentHtml || row.content)
: getEditableMarkdown(row.content, row.contentMarkdown),
contentFormat,
editorMode,
excerpt: getRenderedText(row.excerpt),
id: row.id,
slug: row.slug || '',
status: row.status || 'draft',
sticky: !!row.sticky,
tags: row.tags || [],
title: getRenderedText(row.title),
};
}
/**
* Builds the API payload while preserving the current content format selected for the modal.
* @param values Current form values from Vben Form.
* @param editingId Current article id, or undefined for create.
* @param editorMode Current editor mode.
* @returns Payload accepted by the blog article save/update API.
*/
export function buildBlogArticleSubmitPayload(
values: BlogArticleFormValues,
editingId: string | undefined,
editorMode: BlogArticleEditorMode,
): WordpressBlogApi.ArticleBody {
const { editorMode: _editorMode, ...payloadValues } = values;
return {
...payloadValues,
contentFormat: getContentFormatForEditorMode(editorMode),
id: editingId,
title: values.title?.trim() || '',
};
}
/**
* Converts WordPress rendered fields or strings into readable plain text.
* @param value Rendered field or string value.
* @returns HTML-free text.
*/
export function getRenderedText(
value?: string | WordpressBlogApi.RenderedField,
) {
return stripHtml(getRenderedValue(value));
}
/**
* Reads a rendered field without stripping HTML.
* @param value Rendered field or string value.
* @returns Raw/rendered string.
*/
function getRenderedValue(value?: string | WordpressBlogApi.RenderedField) {
if (!value) return '';
if (typeof value === 'string') return value;
return value.raw || value.rendered || '';
}
/**
* Chooses editable Markdown from an API rendered field and its explicit Markdown source.
* @param value Rendered field or string value.
* @param markdown Explicit Markdown source saved on local articles.
* @returns Markdown text for the Milkdown editor.
*/
function getEditableMarkdown(
value?: string | WordpressBlogApi.RenderedField,
markdown?: string,
) {
if (markdown) return markdown;
return getRenderedValue(value);
}
/**
* Strips simple HTML tags from Admin labels and excerpts.
* @param value HTML or text value.
* @returns Trimmed text.
*/
function stripHtml(value: string) {
return value
.replaceAll(/<[^>]+>/g, '')
.replaceAll('&nbsp;', ' ')
.trim();
}