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 {
|
||||
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(' ', ' ')
|
||||
.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>
|
||||
);
|
||||
|
||||
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