feat: 接入博客文章HTML富文本编辑模式
This commit is contained in:
parent
adab366275
commit
45f87d06d3
49
apps/web-antdv-next/src/views/blog/article/list.scss
Normal file
49
apps/web-antdv-next/src/views/blog/article/list.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/web-antdv-next/src/views/blog/article/list.spec.ts
Normal file
30
apps/web-antdv-next/src/views/blog/article/list.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,10 @@
|
|||||||
import type { TableColumnType } from 'antdv-next';
|
import type { TableColumnType } from 'antdv-next';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlogArticleEditorMode,
|
||||||
|
BlogArticleFormValues,
|
||||||
|
} from '../modules/article-form';
|
||||||
|
|
||||||
import type { WordpressBlogApi } from '#/api/blog';
|
import type { WordpressBlogApi } from '#/api/blog';
|
||||||
import type {
|
import type {
|
||||||
KtTableApi,
|
KtTableApi,
|
||||||
@ -9,6 +14,7 @@ import type {
|
|||||||
} from '#/components/ktTable';
|
} from '#/components/ktTable';
|
||||||
|
|
||||||
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
|
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
import { Plus, SvgDownloadIcon } from '@vben/icons';
|
import { Plus, SvgDownloadIcon } from '@vben/icons';
|
||||||
@ -27,9 +33,24 @@ import {
|
|||||||
} from '#/api/blog';
|
} from '#/api/blog';
|
||||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
import { KtMilkdownEditor } from '#/components/markdown';
|
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 { consumeBlogArticleFilters } from '../modules/use-article-filters';
|
||||||
|
|
||||||
|
import './list.scss';
|
||||||
|
|
||||||
type TermOption = {
|
type TermOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@ -54,7 +75,9 @@ const articleStatusOptions = [
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'BlogArticleList',
|
name: 'BlogArticleList',
|
||||||
setup() {
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
const editingId = ref<string>();
|
const editingId = ref<string>();
|
||||||
|
const contentEditMode = ref<BlogArticleEditorMode>('markdown');
|
||||||
const categoryOptions = ref<TermOption[]>([]);
|
const categoryOptions = ref<TermOption[]>([]);
|
||||||
const tagOptions = ref<TermOption[]>([]);
|
const tagOptions = ref<TermOption[]>([]);
|
||||||
|
|
||||||
@ -118,14 +141,13 @@ export default defineComponent({
|
|||||||
fieldName: 'excerpt',
|
fieldName: 'excerpt',
|
||||||
label: '摘要',
|
label: '摘要',
|
||||||
},
|
},
|
||||||
|
createBlogArticleEditorModeSchema(handleArticleEditorModeChange),
|
||||||
{
|
{
|
||||||
component: KtMilkdownEditor,
|
...createBlogArticleContentSchema(
|
||||||
componentProps: {
|
'markdown',
|
||||||
minHeight: 460,
|
KtMilkdownEditor,
|
||||||
placeholder: '请输入 Markdown 正文',
|
KtTiptapHtmlEditor,
|
||||||
},
|
),
|
||||||
fieldName: 'content',
|
|
||||||
label: '内容',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'Switch',
|
component: 'Switch',
|
||||||
@ -141,7 +163,8 @@ export default defineComponent({
|
|||||||
editingId.value ? '编辑文章' : '新建文章',
|
editingId.value ? '编辑文章' : '新建文章',
|
||||||
);
|
);
|
||||||
const [ArticleModal, articleModalApi] = useVbenModal({
|
const [ArticleModal, articleModalApi] = useVbenModal({
|
||||||
class: 'w-[760px]',
|
class: BLOG_ARTICLE_MODAL_CLASS,
|
||||||
|
contentClass: BLOG_ARTICLE_MODAL_CONTENT_CLASS,
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
await submitArticle();
|
await submitArticle();
|
||||||
@ -149,9 +172,9 @@ export default defineComponent({
|
|||||||
onOpenChange(isOpen: boolean) {
|
onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const { values } = articleModalApi.getData<{
|
const { values } = articleModalApi.getData<{
|
||||||
values?: WordpressBlogApi.ArticleBody;
|
values?: BlogArticleFormValues;
|
||||||
}>();
|
}>();
|
||||||
void resetArticleForm(values || getArticleFormDefaults());
|
void resetArticleForm(values || getBlogArticleCreateFormDefaults());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
|
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
|
||||||
@ -205,6 +228,12 @@ export default defineComponent({
|
|||||||
const rowActions: Array<
|
const rowActions: Array<
|
||||||
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
||||||
> = [
|
> = [
|
||||||
|
{
|
||||||
|
key: 'preview',
|
||||||
|
label: '预览',
|
||||||
|
onClick: openPreview,
|
||||||
|
permissionCodes: ['Blog:Article:Preview'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: '编辑',
|
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(' ', ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusOption(status?: string) {
|
function getStatusOption(status?: string) {
|
||||||
return (
|
return (
|
||||||
articleStatusOptions.find((item) => item.value === status) ||
|
articleStatusOptions.find((item) => item.value === status) ||
|
||||||
@ -385,28 +391,66 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArticleFormDefaults(
|
/**
|
||||||
searchValues: ArticleSearchValues = {},
|
* Resets modal form state before applying create or edit values.
|
||||||
): WordpressBlogApi.ArticleBody {
|
* @param values Article values selected for the current modal session.
|
||||||
return {
|
*/
|
||||||
categories: [...(searchValues.categories || [])],
|
async function resetArticleForm(values: BlogArticleFormValues) {
|
||||||
content: '',
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
excerpt: '',
|
|
||||||
slug: '',
|
|
||||||
status: 'draft',
|
|
||||||
sticky: false,
|
|
||||||
tags: [...(searchValues.tags || [])],
|
|
||||||
title: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetArticleForm(values: WordpressBlogApi.ArticleBody) {
|
|
||||||
await articleFormApi.resetForm();
|
await articleFormApi.resetForm();
|
||||||
await articleFormApi.setValues(values);
|
await articleFormApi.setValues(values);
|
||||||
await articleFormApi.resetValidate();
|
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(
|
async function openCreate(
|
||||||
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
|
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
|
||||||
) {
|
) {
|
||||||
@ -415,37 +459,42 @@ export default defineComponent({
|
|||||||
: await tableApi.getSearchValues();
|
: await tableApi.getSearchValues();
|
||||||
|
|
||||||
editingId.value = undefined;
|
editingId.value = undefined;
|
||||||
articleModalApi
|
const values = getBlogArticleCreateFormDefaults(searchValues);
|
||||||
.setData({ values: getArticleFormDefaults(searchValues) })
|
await setArticleEditorMode(values.editorMode || 'markdown');
|
||||||
.open();
|
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}`;
|
editingId.value = `${row.id}`;
|
||||||
articleModalApi
|
const values = getBlogArticleEditFormValues(row);
|
||||||
.setData({
|
await setArticleEditorMode(values.editorMode || 'markdown');
|
||||||
values: {
|
articleModalApi.setData({ values }).open();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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() {
|
async function submitArticle() {
|
||||||
const { valid } = await articleFormApi.validate();
|
const { valid } = await articleFormApi.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
const values =
|
const values = await articleFormApi.getValues<BlogArticleFormValues>();
|
||||||
await articleFormApi.getValues<WordpressBlogApi.ArticleBody>();
|
|
||||||
const title = values.title?.trim();
|
const title = values.title?.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
message.warning('请填写文章标题');
|
message.warning('请填写文章标题');
|
||||||
@ -455,9 +504,11 @@ export default defineComponent({
|
|||||||
articleModalApi.lock();
|
articleModalApi.lock();
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...buildBlogArticleSubmitPayload(
|
||||||
contentFormat: 'markdown' as const,
|
values,
|
||||||
id: editingId.value,
|
editingId.value,
|
||||||
|
contentEditMode.value,
|
||||||
|
),
|
||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
await (editingId.value
|
await (editingId.value
|
||||||
@ -559,7 +610,7 @@ export default defineComponent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleModal title={modalTitle.value}>
|
<ArticleModal title={modalTitle.value}>
|
||||||
<ArticleForm class="mx-2" />
|
<ArticleForm class={BLOG_ARTICLE_FORM_CLASS} />
|
||||||
</ArticleModal>
|
</ArticleModal>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
138
apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts
Normal file
138
apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
apps/web-antdv-next/src/views/blog/modules/article-form.ts
Normal file
268
apps/web-antdv-next/src/views/blog/modules/article-form.ts
Normal 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(' ', ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user