619 lines
18 KiB
TypeScript
619 lines
18 KiB
TypeScript
import type { TableColumnType } from 'antdv-next';
|
|
|
|
import type {
|
|
BlogArticleEditorMode,
|
|
BlogArticleFormValues,
|
|
} from '../modules/article-form';
|
|
|
|
import type { WordpressBlogApi } from '#/api/blog';
|
|
import type {
|
|
KtTableApi,
|
|
KtTableButton,
|
|
KtTableContext,
|
|
KtTableRowAction,
|
|
} 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';
|
|
|
|
import { message, Modal, Tag } from 'antdv-next';
|
|
|
|
import { useVbenForm } from '#/adapter/form';
|
|
import {
|
|
createArticle,
|
|
deleteArticle,
|
|
getArticleCategoryOptions,
|
|
getArticleList,
|
|
getArticleTagOptions,
|
|
importWordpressArticles,
|
|
updateArticle,
|
|
} 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;
|
|
};
|
|
|
|
type ArticleSearchValues = {
|
|
categories?: string[];
|
|
search?: string;
|
|
status?: string;
|
|
tags?: string[];
|
|
};
|
|
|
|
const AKtTable = KtTable as any;
|
|
|
|
const articleStatusOptions = [
|
|
{ color: 'success', label: '已发布', value: 'publish' },
|
|
{ color: 'default', label: '草稿', value: 'draft' },
|
|
{ color: 'warning', label: '待审核', value: 'pending' },
|
|
{ color: 'processing', label: '私有', value: 'private' },
|
|
];
|
|
|
|
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[]>([]);
|
|
|
|
const [ArticleForm, articleFormApi] = useVbenForm({
|
|
commonConfig: {
|
|
labelClass: 'w-20',
|
|
},
|
|
layout: 'horizontal',
|
|
schema: [
|
|
{
|
|
component: 'Input',
|
|
componentProps: {
|
|
placeholder: '请输入文章标题',
|
|
},
|
|
fieldName: 'title',
|
|
label: '标题',
|
|
rules: 'required',
|
|
},
|
|
{
|
|
component: 'Select',
|
|
componentProps: {
|
|
options: articleStatusOptions,
|
|
},
|
|
fieldName: 'status',
|
|
label: '状态',
|
|
},
|
|
{
|
|
component: 'Input',
|
|
componentProps: {
|
|
placeholder: '可选,默认由标题生成',
|
|
},
|
|
fieldName: 'slug',
|
|
label: '别名',
|
|
},
|
|
{
|
|
component: 'Select',
|
|
componentProps: () => ({
|
|
mode: 'tags',
|
|
options: categoryOptions.value,
|
|
placeholder: '输入或选择分类',
|
|
}),
|
|
fieldName: 'categories',
|
|
label: '分类',
|
|
},
|
|
{
|
|
component: 'Select',
|
|
componentProps: () => ({
|
|
mode: 'tags',
|
|
options: tagOptions.value,
|
|
placeholder: '输入或选择标签',
|
|
}),
|
|
fieldName: 'tags',
|
|
label: '标签',
|
|
},
|
|
{
|
|
component: 'Textarea',
|
|
componentProps: {
|
|
autoSize: { maxRows: 4, minRows: 2 },
|
|
placeholder: '可选,文章摘要',
|
|
},
|
|
fieldName: 'excerpt',
|
|
label: '摘要',
|
|
},
|
|
createBlogArticleEditorModeSchema(handleArticleEditorModeChange),
|
|
{
|
|
...createBlogArticleContentSchema(
|
|
'markdown',
|
|
KtMilkdownEditor,
|
|
KtTiptapHtmlEditor,
|
|
),
|
|
},
|
|
{
|
|
component: 'Switch',
|
|
fieldName: 'sticky',
|
|
label: '置顶',
|
|
},
|
|
],
|
|
showDefaultActions: false,
|
|
wrapperClass: 'grid-cols-1',
|
|
});
|
|
|
|
const modalTitle = computed(() =>
|
|
editingId.value ? '编辑文章' : '新建文章',
|
|
);
|
|
const [ArticleModal, articleModalApi] = useVbenModal({
|
|
class: BLOG_ARTICLE_MODAL_CLASS,
|
|
contentClass: BLOG_ARTICLE_MODAL_CONTENT_CLASS,
|
|
fullscreenButton: false,
|
|
async onConfirm() {
|
|
await submitArticle();
|
|
},
|
|
onOpenChange(isOpen: boolean) {
|
|
if (!isOpen) return;
|
|
const { values } = articleModalApi.getData<{
|
|
values?: BlogArticleFormValues;
|
|
}>();
|
|
void resetArticleForm(values || getBlogArticleCreateFormDefaults());
|
|
},
|
|
});
|
|
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
|
|
{ dataIndex: 'title', key: 'title', title: '标题', width: 280 },
|
|
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
|
|
{ dataIndex: 'categories', key: 'categories', title: '分类', width: 180 },
|
|
{ dataIndex: 'tags', key: 'tags', title: '标签', width: 180 },
|
|
{
|
|
dataIndex: 'updateTime',
|
|
key: 'modified',
|
|
title: '更新时间',
|
|
width: 180,
|
|
},
|
|
];
|
|
|
|
const api: KtTableApi<WordpressBlogApi.Article, ArticleSearchValues> = {
|
|
list: (params) =>
|
|
getArticleList({
|
|
categories: Array.isArray(params.categories)
|
|
? params.categories.join(',')
|
|
: params.categories,
|
|
pageNo: params.pageNo,
|
|
pageSize: params.pageSize,
|
|
search: params.search,
|
|
status: params.status || undefined,
|
|
tags: Array.isArray(params.tags)
|
|
? params.tags.join(',')
|
|
: params.tags,
|
|
}),
|
|
};
|
|
const buttons: Array<
|
|
KtTableButton<WordpressBlogApi.Article, ArticleSearchValues>
|
|
> = [
|
|
{
|
|
icon: <Plus class="kt-table__button-icon" />,
|
|
key: 'create',
|
|
label: '新建文章',
|
|
onClick: openCreate,
|
|
permissionCodes: ['Blog:Article:Create'],
|
|
type: 'primary',
|
|
},
|
|
{
|
|
icon: <SvgDownloadIcon class="kt-table__button-icon" />,
|
|
key: 'import-wordpress',
|
|
label: '导入 WordPress',
|
|
onClick: confirmImportWordpress,
|
|
permissionCodes: ['Blog:Article:Import'],
|
|
type: 'default',
|
|
},
|
|
];
|
|
const rowActions: Array<
|
|
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
|
> = [
|
|
{
|
|
key: 'preview',
|
|
label: '预览',
|
|
onClick: openPreview,
|
|
permissionCodes: ['Blog:Article:Preview'],
|
|
},
|
|
{
|
|
key: 'edit',
|
|
label: '编辑',
|
|
onClick: openEdit,
|
|
permissionCodes: ['Blog:Article:Edit'],
|
|
},
|
|
{
|
|
confirm: (row) =>
|
|
`确认删除文章「${getRenderedText(row.title) || row.id}」吗?`,
|
|
danger: true,
|
|
key: 'delete',
|
|
label: '删除',
|
|
onClick: async (row, context) => {
|
|
await deleteArticle(row.id);
|
|
message.success('文章删除成功');
|
|
await context.reload();
|
|
},
|
|
permissionCodes: ['Blog:Article:Delete'],
|
|
},
|
|
];
|
|
|
|
const [registerTable, tableApi] = useKtTable<
|
|
WordpressBlogApi.Article,
|
|
ArticleSearchValues
|
|
>({
|
|
api,
|
|
buttons,
|
|
columns,
|
|
formOptions: {
|
|
schema: getArticleSearchSchema(),
|
|
},
|
|
immediate: false,
|
|
rowActions,
|
|
tableTitle: '文章管理',
|
|
});
|
|
|
|
function getArticleSearchSchema() {
|
|
return [
|
|
{
|
|
component: 'Input',
|
|
componentProps: {
|
|
allowClear: true,
|
|
placeholder: '搜索标题或内容',
|
|
},
|
|
fieldName: 'search',
|
|
label: '关键词',
|
|
},
|
|
{
|
|
component: 'Select',
|
|
componentProps: {
|
|
allowClear: true,
|
|
options: articleStatusOptions,
|
|
},
|
|
fieldName: 'status',
|
|
label: '文章状态',
|
|
},
|
|
{
|
|
component: 'Select',
|
|
componentProps: {
|
|
allowClear: true,
|
|
mode: 'tags',
|
|
options: categoryOptions.value,
|
|
},
|
|
fieldName: 'categories',
|
|
label: '文章分类',
|
|
},
|
|
{
|
|
component: 'Select',
|
|
componentProps: {
|
|
allowClear: true,
|
|
mode: 'tags',
|
|
options: tagOptions.value,
|
|
},
|
|
fieldName: 'tags',
|
|
label: '文章标签',
|
|
},
|
|
];
|
|
}
|
|
|
|
function getStatusOption(status?: string) {
|
|
return (
|
|
articleStatusOptions.find((item) => item.value === status) ||
|
|
articleStatusOptions[1]
|
|
);
|
|
}
|
|
|
|
function getTermLabel(options: TermOption[], value: string) {
|
|
return options.find((item) => item.value === value)?.label || `${value}`;
|
|
}
|
|
|
|
async function applyPendingFilters() {
|
|
const filters = consumeBlogArticleFilters();
|
|
if (!filters) return false;
|
|
|
|
await tableApi.setSearchValues({
|
|
categories: (filters.categories || []).map((item) => `${item}`),
|
|
tags: (filters.tags || []).map((item) => `${item}`),
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
async function loadTermOptions() {
|
|
const [categories, tags] = await Promise.all([
|
|
getArticleCategoryOptions({ pageNo: 1, pageSize: 200 }),
|
|
getArticleTagOptions({ pageNo: 1, pageSize: 200 }),
|
|
]);
|
|
categoryOptions.value = categories.list.map((item) => ({
|
|
label: item.name,
|
|
value: item.name,
|
|
}));
|
|
tagOptions.value = tags.list.map((item) => ({
|
|
label: item.name,
|
|
value: item.name,
|
|
}));
|
|
tableApi.setProps({
|
|
formOptions: {
|
|
schema: getArticleSearchSchema(),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function filterByCategory(value: string) {
|
|
await tableApi.setSearchValues({ categories: [value] });
|
|
await tableApi.search();
|
|
}
|
|
|
|
async function filterByTag(value: string) {
|
|
await tableApi.setSearchValues({ tags: [value] });
|
|
await tableApi.search();
|
|
}
|
|
|
|
function confirmImportWordpress(
|
|
context: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
|
|
) {
|
|
Modal.confirm({
|
|
cancelText: '取消',
|
|
content:
|
|
'将从已配置的 WordPress 公开接口全量导入文章;同别名文章默认跳过。',
|
|
okText: '开始导入',
|
|
title: '导入 WordPress 文章',
|
|
async onOk() {
|
|
const result = await importWordpressArticles({
|
|
all: true,
|
|
overwrite: false,
|
|
pageSize: 100,
|
|
});
|
|
const pageCount = result.pageCount || 1;
|
|
message.success(
|
|
`导入完成:扫描 ${pageCount} 页,新增 ${result.created} 篇,跳过 ${result.skipped} 篇,更新 ${result.updated} 篇`,
|
|
);
|
|
await loadTermOptions();
|
|
await context.reload();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
) {
|
|
const searchValues = context
|
|
? await context.getSearchValues()
|
|
: await tableApi.getSearchValues();
|
|
|
|
editingId.value = undefined;
|
|
const values = getBlogArticleCreateFormDefaults(searchValues);
|
|
await setArticleEditorMode(values.editorMode || 'markdown');
|
|
articleModalApi.setData({ values }).open();
|
|
}
|
|
|
|
/**
|
|
* 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}`;
|
|
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<BlogArticleFormValues>();
|
|
const title = values.title?.trim();
|
|
if (!title) {
|
|
message.warning('请填写文章标题');
|
|
return;
|
|
}
|
|
|
|
articleModalApi.lock();
|
|
try {
|
|
const payload = {
|
|
...buildBlogArticleSubmitPayload(
|
|
values,
|
|
editingId.value,
|
|
contentEditMode.value,
|
|
),
|
|
title,
|
|
};
|
|
await (editingId.value
|
|
? updateArticle(payload)
|
|
: createArticle(payload));
|
|
message.success('文章保存成功');
|
|
await articleModalApi.close();
|
|
await loadTermOptions();
|
|
await tableApi.reload();
|
|
} finally {
|
|
articleModalApi.unlock();
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadTermOptions();
|
|
await applyPendingFilters();
|
|
await tableApi.reload();
|
|
});
|
|
|
|
onActivated(async () => {
|
|
if (await applyPendingFilters()) {
|
|
await tableApi.search();
|
|
}
|
|
});
|
|
|
|
return () => (
|
|
<Page autoContentHeight>
|
|
<AKtTable
|
|
onRegister={registerTable}
|
|
v-slots={{
|
|
bodyCell: ({ column, record }: any) => {
|
|
const article = record as WordpressBlogApi.Article;
|
|
|
|
if (column.key === 'title') {
|
|
return (
|
|
<div class="max-w-[420px]">
|
|
<div class="truncate font-medium">
|
|
{getRenderedText(article.title) || '-'}
|
|
</div>
|
|
{article.link ? (
|
|
<a
|
|
class="text-xs text-primary"
|
|
href={article.link}
|
|
target="_blank"
|
|
>
|
|
查看原文
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (column.key === 'status') {
|
|
const status = getStatusOption(article.status);
|
|
return <Tag color={status?.color}>{status?.label}</Tag>;
|
|
}
|
|
|
|
if (column.key === 'categories') {
|
|
return article.categories?.length ? (
|
|
<div class="flex flex-wrap gap-1">
|
|
{article.categories.map((item) => (
|
|
<Tag
|
|
class="cursor-pointer"
|
|
color="blue"
|
|
key={item}
|
|
onClick={() => filterByCategory(item)}
|
|
>
|
|
{getTermLabel(categoryOptions.value, item)}
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span>-</span>
|
|
);
|
|
}
|
|
|
|
if (column.key === 'tags') {
|
|
return article.tags?.length ? (
|
|
<div class="flex flex-wrap gap-1">
|
|
{article.tags.map((item) => (
|
|
<Tag
|
|
class="cursor-pointer"
|
|
key={item}
|
|
onClick={() => filterByTag(item)}
|
|
>
|
|
{getTermLabel(tagOptions.value, item)}
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span>-</span>
|
|
);
|
|
}
|
|
|
|
return undefined;
|
|
},
|
|
}}
|
|
/>
|
|
|
|
<ArticleModal title={modalTitle.value}>
|
|
<ArticleForm class={BLOG_ARTICLE_FORM_CLASS} />
|
|
</ArticleModal>
|
|
</Page>
|
|
);
|
|
},
|
|
});
|