feat: 接入博客 Markdown 管理能力

This commit is contained in:
sunlei 2026-06-05 14:41:44 +08:00
parent 2f06b3722e
commit 8b7f773b2f
12 changed files with 2497 additions and 84 deletions

View File

@ -27,6 +27,7 @@
},
"dependencies": {
"@antdv-next/icons": "catalog:",
"@milkdown/crepe": "^7.21.2",
"@tanstack/vue-query": "catalog:",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",

View File

@ -14,55 +14,122 @@ export namespace WordpressBlogApi {
}
export interface Article {
categories?: number[];
categories?: string[];
categoriesResolved?: Term[];
content?: RenderedField | string;
contentHtml?: string;
contentMarkdown?: string;
date?: string;
excerpt?: RenderedField | string;
id: number;
id: number | string;
link?: string;
modified?: string;
slug?: string;
status?: string;
sticky?: boolean;
tags?: number[];
tags?: string[];
tagsResolved?: Term[];
title?: RenderedField | string;
}
export interface ArticleBody {
categories?: number[];
authorName?: string;
categories?: string[];
content?: string;
contentFormat?: 'html' | 'markdown';
cover?: string;
excerpt?: string;
id?: number;
id?: number | string;
slug?: string;
status?: string;
sticky?: boolean;
tags?: number[];
tags?: string[];
title: string;
}
export interface ArticleQuery extends Recordable<any> {
categories?: number[] | string;
categories?: string | string[];
pageNo?: number;
pageSize?: number;
search?: string;
status?: string;
tags?: number[] | string;
tags?: string | string[];
}
export interface ArticleImportWordpressBody {
all?: boolean;
overwrite?: boolean;
pageNo?: number;
pageSize?: number;
}
export interface ArticleImportWordpressResult {
created: number;
items: Array<{
action: 'created' | 'skipped' | 'updated';
id: string;
slug: string;
title: string;
}>;
pageCount?: number;
skipped: number;
total: number;
updated: number;
}
export interface ThemeConfig {
argonConfig?: Record<string, any>;
backgroundDarkBrightness?: number;
backgroundDarkImage?: string;
backgroundDarkOpacity?: number;
backgroundImage?: string;
backgroundOpacity?: number;
bodyClass?: string[];
darkmodeAutoSwitch?: string;
enableCustomThemeColor?: boolean;
headerMenu?: ThemeMenuItem[];
htmlClass?: string[];
site?: {
authorAvatar?: string;
authorName?: string;
description?: string;
home?: string;
title?: string;
url?: string;
};
sidebarMenu?: ThemeMenuItem[];
themeCardRadius?: number | string;
themeColor?: string;
themeColorRgb?: string;
themeVersion?: string;
}
export interface ThemeMenuItem {
external?: boolean;
href: string;
icon?: string;
label: string;
}
export interface ThemeConfigBody {
config?: ThemeConfig;
source?: string;
}
export interface Term {
count?: number;
description?: string;
id: number;
id: number | string;
name: string;
parent?: number;
parent?: number | string;
slug?: string;
}
export interface TermBody {
description?: string;
id?: number;
id?: number | string;
name: string;
parent?: number;
parent?: number | string;
slug?: string;
}
@ -78,83 +145,118 @@ export namespace WordpressBlogApi {
export function getArticleList(params: WordpressBlogApi.ArticleQuery) {
return requestClient.get<
WordpressBlogApi.PageResult<WordpressBlogApi.Article>
>('/wordpress/article/list', { params });
>('/blog/article/list', { params });
}
export function getArticleDetail(id: number | string) {
return requestClient.get<WordpressBlogApi.Article>(
'/wordpress/article/detail',
{ params: { id } },
);
return requestClient.get<WordpressBlogApi.Article>('/blog/article/detail', {
params: { id },
});
}
export function createArticle(data: WordpressBlogApi.ArticleBody) {
return requestClient.post<WordpressBlogApi.Article>(
'/wordpress/article/save',
'/blog/article/save',
data,
);
}
export function updateArticle(data: WordpressBlogApi.ArticleBody) {
return requestClient.post<WordpressBlogApi.Article>(
'/wordpress/article/update',
'/blog/article/update',
data,
);
}
export function deleteArticle(id: number | string, force = true) {
export function deleteArticle(id: number | string) {
return requestClient.post<WordpressBlogApi.Article>(
`/wordpress/article/remove?id=${id}&force=${force}`,
`/blog/article/remove?id=${id}`,
);
}
export function importWordpressArticles(
data: WordpressBlogApi.ArticleImportWordpressBody,
) {
return requestClient.post<WordpressBlogApi.ArticleImportWordpressResult>(
'/blog/article/import-wordpress',
data,
);
}
export function getThemeConfig() {
return requestClient.get<WordpressBlogApi.ThemeConfig>('/blog/theme/config');
}
export function saveThemeConfig(data: WordpressBlogApi.ThemeConfigBody) {
return requestClient.post<WordpressBlogApi.ThemeConfig>(
'/blog/theme/save',
data,
);
}
export function importWordpressThemeConfig() {
return requestClient.post<WordpressBlogApi.ThemeConfig>(
'/blog/theme/import-wordpress',
);
}
export function getArticleCategoryOptions(
params: WordpressBlogApi.TermQuery = {},
) {
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
'/blog/article/category-options',
{ params },
);
}
export function getArticleTagOptions(params: WordpressBlogApi.TermQuery = {}) {
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
'/blog/article/tag-options',
{ params },
);
}
export function getCategoryList(params: WordpressBlogApi.TermQuery = {}) {
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
'/wordpress/category/list',
'/blog/category/list',
{ params },
);
}
export function createCategory(data: WordpressBlogApi.TermBody) {
return requestClient.post<WordpressBlogApi.Term>(
'/wordpress/category/save',
data,
);
return requestClient.post<WordpressBlogApi.Term>('/blog/category/save', data);
}
export function updateCategory(data: WordpressBlogApi.TermBody) {
return requestClient.post<WordpressBlogApi.Term>(
'/wordpress/category/update',
'/blog/category/update',
data,
);
}
export function deleteCategory(id: number | string, force = true) {
return requestClient.post<WordpressBlogApi.Term>(
`/wordpress/category/remove?id=${id}&force=${force}`,
`/blog/category/remove?id=${id}&force=${force}`,
);
}
export function getTagList(params: WordpressBlogApi.TermQuery = {}) {
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
'/wordpress/tag/list',
'/blog/tag/list',
{ params },
);
}
export function createTag(data: WordpressBlogApi.TermBody) {
return requestClient.post<WordpressBlogApi.Term>('/wordpress/tag/save', data);
return requestClient.post<WordpressBlogApi.Term>('/blog/tag/save', data);
}
export function updateTag(data: WordpressBlogApi.TermBody) {
return requestClient.post<WordpressBlogApi.Term>(
'/wordpress/tag/update',
data,
);
return requestClient.post<WordpressBlogApi.Term>('/blog/tag/update', data);
}
export function deleteTag(id: number | string, force = true) {
return requestClient.post<WordpressBlogApi.Term>(
`/wordpress/tag/remove?id=${id}&force=${force}`,
`/blog/tag/remove?id=${id}&force=${force}`,
);
}

View File

@ -8,6 +8,7 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'BlogArticleCreate',
'BlogArticleDelete',
'BlogArticleEdit',
'BlogArticleImport',
'BlogCategory',
'BlogCategoryCreate',
'BlogCategoryDelete',
@ -16,6 +17,9 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'BlogTagCreate',
'BlogTagDelete',
'BlogTagEdit',
'BlogTheme',
'BlogThemeImport',
'BlogThemeSave',
'QqBot',
'QqBotAccount',
'QqBotAccountConfig',

View File

@ -0,0 +1,48 @@
.kt-milkdown-editor {
display: flex;
min-height: var(--kt-milkdown-min-height, 360px);
flex-direction: column;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
&--disabled {
opacity: 0.72;
}
&--loading {
position: relative;
}
&__root {
min-height: inherit;
width: 100%;
flex: 1;
overflow: auto;
}
&__placeholder {
display: flex;
min-height: inherit;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
font-size: 14px;
}
.milkdown {
min-height: inherit;
}
.milkdown .editor {
min-height: inherit;
padding: 18px 22px;
outline: none;
}
.milkdown .ProseMirror {
min-height: calc(var(--kt-milkdown-min-height, 360px) - 36px);
outline: none;
}
}

View File

@ -0,0 +1,254 @@
import type { CrepeConfig } from '@milkdown/crepe';
import type { CSSProperties, PropType } from 'vue';
import {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
ref,
shallowRef,
watch,
} from 'vue';
import { Crepe, CrepeFeature } from '@milkdown/crepe';
import '@milkdown/crepe/theme/frame.css';
import './KtMilkdownEditor.scss';
export type KtMilkdownEventRegistrar = Parameters<Crepe['on']>[0];
export type KtMilkdownCrepeOptions = Partial<
Omit<CrepeConfig, 'defaultValue' | 'featureConfigs' | 'features' | 'root'>
>;
export interface KtMilkdownEditorExpose {
getEditor: () => Crepe | null;
getMarkdown: () => string;
recreate: (value?: string) => Promise<void>;
setReadonly: (value: boolean) => void;
}
function toCssSize(value?: number | string) {
if (value === undefined || value === null || value === '') return undefined;
return typeof value === 'number' ? `${value}px` : value;
}
function toEventRegistrars(
value?: KtMilkdownEventRegistrar | KtMilkdownEventRegistrar[],
) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
export default defineComponent({
name: 'KtMilkdownEditor',
props: {
disabled: {
default: false,
type: Boolean,
},
crepeOptions: {
default: undefined,
type: Object as PropType<KtMilkdownCrepeOptions>,
},
featureConfigs: {
default: undefined,
type: Object as PropType<CrepeConfig['featureConfigs']>,
},
features: {
default: undefined,
type: Object as PropType<CrepeConfig['features']>,
},
minHeight: {
default: 360,
type: [Number, String] as PropType<number | string>,
},
modelValue: {
default: '',
type: String,
},
placeholder: {
default: '请输入 Markdown 内容',
type: String,
},
registerEvents: {
default: undefined,
type: [Array, Function] as PropType<
KtMilkdownEventRegistrar | KtMilkdownEventRegistrar[]
>,
},
readonly: {
default: false,
type: Boolean,
},
},
emits: {
blur: () => true,
change: (_value: string, _previousValue: string) => true,
created: (_editor: Crepe) => true,
destroyed: () => true,
error: (_error: unknown) => true,
focus: () => true,
'update:modelValue': (_value: string) => true,
},
setup(props, { emit, expose }) {
const rootRef = ref<HTMLDivElement | null>(null);
const editor = shallowRef<Crepe | null>(null);
const currentMarkdown = ref(props.modelValue || '');
const loading = ref(false);
let createVersion = 0;
const readonlyState = computed(() => props.readonly || props.disabled);
const editorStyle = computed<CSSProperties>(() => ({
'--kt-milkdown-min-height': toCssSize(props.minHeight),
}));
async function destroyEditor() {
const currentEditor = editor.value;
editor.value = null;
if (!currentEditor) return;
await currentEditor.destroy();
emit('destroyed');
}
function registerEditorEvents(nextEditor: Crepe) {
nextEditor.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
if (markdown === previousMarkdown) return;
currentMarkdown.value = markdown;
emit('update:modelValue', markdown);
emit('change', markdown, previousMarkdown);
});
listener.focus(() => emit('focus'));
listener.blur(() => emit('blur'));
});
for (const register of toEventRegistrars(props.registerEvents)) {
nextEditor.on(register);
}
}
async function createEditor(markdown = props.modelValue ?? '') {
const root = rootRef.value;
if (!root) return;
const version = (createVersion += 1);
loading.value = true;
try {
await destroyEditor();
root.innerHTML = '';
currentMarkdown.value = markdown;
const nextEditor = new Crepe({
...props.crepeOptions,
defaultValue: markdown,
featureConfigs: {
...props.featureConfigs,
[CrepeFeature.Placeholder]: {
mode: 'block',
text: props.placeholder,
...props.featureConfigs?.[CrepeFeature.Placeholder],
},
},
features: {
[CrepeFeature.AI]: false,
[CrepeFeature.TopBar]: true,
...props.features,
},
root,
});
registerEditorEvents(nextEditor);
await nextEditor.create();
nextEditor.setReadonly(readonlyState.value);
if (version !== createVersion) {
await nextEditor.destroy();
return;
}
editor.value = nextEditor;
emit('created', nextEditor);
} catch (error) {
emit('error', error);
} finally {
if (version === createVersion) {
loading.value = false;
}
}
}
function setReadonly(value: boolean) {
editor.value?.setReadonly(value);
}
expose({
getEditor: () => editor.value,
getMarkdown: () => editor.value?.getMarkdown() || currentMarkdown.value,
recreate: createEditor,
setReadonly,
} satisfies KtMilkdownEditorExpose);
watch(
rootRef,
async (root) => {
if (!root) return;
await nextTick();
await createEditor(props.modelValue ?? '');
},
{ immediate: true },
);
watch(
() => props.modelValue,
async (value = '') => {
if (value === currentMarkdown.value) return;
await createEditor(value);
},
);
watch(readonlyState, (value) => {
setReadonly(value);
});
watch(
() => [
props.placeholder,
props.features,
props.featureConfigs,
props.registerEvents,
props.crepeOptions,
],
async () => {
await createEditor(currentMarkdown.value);
},
{ deep: true },
);
onBeforeUnmount(async () => {
createVersion += 1;
await destroyEditor();
});
return () => (
<div
class={[
'kt-milkdown-editor',
{
'kt-milkdown-editor--disabled': readonlyState.value,
'kt-milkdown-editor--loading': loading.value,
},
]}
style={editorStyle.value}
>
<div class="kt-milkdown-editor__root" ref={rootRef} />
{loading.value ? (
<div class="kt-milkdown-editor__placeholder">...</div>
) : null}
</div>
);
},
});

View File

@ -0,0 +1,6 @@
export { default as KtMilkdownEditor } from './KtMilkdownEditor';
export type {
KtMilkdownCrepeOptions,
KtMilkdownEditorExpose,
KtMilkdownEventRegistrar,
} from './KtMilkdownEditor';

View File

@ -38,6 +38,15 @@ const routes: RouteRecordRaw[] = [
name: 'BlogTag',
path: '/blog/tag',
},
{
component: () => import('#/views/blog/theme/config'),
meta: {
icon: 'lucide:palette',
title: '主题配置',
},
name: 'BlogTheme',
path: '/blog/theme',
},
],
},
];

View File

@ -11,33 +11,35 @@ import type {
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Plus, SvgDownloadIcon } from '@vben/icons';
import { message, Tag } from 'antdv-next';
import { message, Modal, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createArticle,
deleteArticle,
getArticleCategoryOptions,
getArticleList,
getCategoryList,
getTagList,
getArticleTagOptions,
importWordpressArticles,
updateArticle,
} from '#/api/blog';
import { KtTable, useKtTable } from '#/components/ktTable';
import { KtMilkdownEditor } from '#/components/markdown';
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
type TermOption = {
label: string;
value: number;
value: string;
};
type ArticleSearchValues = {
categories?: number[];
categories?: string[];
search?: string;
status?: string;
tags?: number[];
tags?: string[];
};
const AKtTable = KtTable as any;
@ -52,7 +54,7 @@ const articleStatusOptions = [
export default defineComponent({
name: 'BlogArticleList',
setup() {
const editingId = ref<number>();
const editingId = ref<string>();
const categoryOptions = ref<TermOption[]>([]);
const tagOptions = ref<TermOption[]>([]);
@ -82,7 +84,7 @@ export default defineComponent({
{
component: 'Input',
componentProps: {
placeholder: '可选,WordPress slug',
placeholder: '可选,默认由标题生成',
},
fieldName: 'slug',
label: '别名',
@ -90,9 +92,9 @@ export default defineComponent({
{
component: 'Select',
componentProps: () => ({
mode: 'multiple',
mode: 'tags',
options: categoryOptions.value,
placeholder: '选择分类',
placeholder: '输入或选择分类',
}),
fieldName: 'categories',
label: '分类',
@ -100,9 +102,9 @@ export default defineComponent({
{
component: 'Select',
componentProps: () => ({
mode: 'multiple',
mode: 'tags',
options: tagOptions.value,
placeholder: '选择标签',
placeholder: '输入或选择标签',
}),
fieldName: 'tags',
label: '标签',
@ -117,10 +119,10 @@ export default defineComponent({
label: '摘要',
},
{
component: 'Textarea',
component: KtMilkdownEditor,
componentProps: {
autoSize: { maxRows: 12, minRows: 6 },
placeholder: '支持 HTML 内容',
minHeight: 460,
placeholder: '请输入 Markdown 正文',
},
fieldName: 'content',
label: '内容',
@ -157,7 +159,12 @@ export default defineComponent({
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
{ dataIndex: 'categories', key: 'categories', title: '分类', width: 180 },
{ dataIndex: 'tags', key: 'tags', title: '标签', width: 180 },
{ dataIndex: 'modified', key: 'modified', title: '更新时间', width: 180 },
{
dataIndex: 'updateTime',
key: 'modified',
title: '更新时间',
width: 180,
},
];
const api: KtTableApi<WordpressBlogApi.Article, ArticleSearchValues> = {
@ -165,12 +172,14 @@ export default defineComponent({
return await getArticleList({
categories: Array.isArray(params.categories)
? params.categories.join(',')
: undefined,
: params.categories,
pageNo: params.pageNo,
pageSize: params.pageSize,
search: params.search,
status: params.status || undefined,
tags: Array.isArray(params.tags) ? params.tags.join(',') : undefined,
tags: Array.isArray(params.tags)
? params.tags.join(',')
: params.tags,
});
},
};
@ -185,6 +194,14 @@ export default defineComponent({
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>
@ -241,7 +258,7 @@ export default defineComponent({
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
mode: 'tags',
options: categoryOptions.value,
},
fieldName: 'categories',
@ -251,7 +268,7 @@ export default defineComponent({
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
mode: 'tags',
options: tagOptions.value,
},
fieldName: 'tags',
@ -270,6 +287,16 @@ export default defineComponent({
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, '')
@ -284,7 +311,7 @@ export default defineComponent({
);
}
function getTermLabel(options: TermOption[], value: number) {
function getTermLabel(options: TermOption[], value: string) {
return options.find((item) => item.value === value)?.label || `${value}`;
}
@ -293,8 +320,8 @@ export default defineComponent({
if (!filters) return false;
await tableApi.setSearchValues({
categories: filters.categories || [],
tags: filters.tags || [],
categories: (filters.categories || []).map((item) => `${item}`),
tags: (filters.tags || []).map((item) => `${item}`),
});
return true;
@ -302,16 +329,16 @@ export default defineComponent({
async function loadTermOptions() {
const [categories, tags] = await Promise.all([
getCategoryList({ hide_empty: false, pageNo: 1, pageSize: 100 }),
getTagList({ hide_empty: false, pageNo: 1, pageSize: 100 }),
getArticleCategoryOptions({ pageNo: 1, pageSize: 200 }),
getArticleTagOptions({ pageNo: 1, pageSize: 200 }),
]);
categoryOptions.value = categories.list.map((item) => ({
label: item.name,
value: item.id,
value: item.name,
}));
tagOptions.value = tags.list.map((item) => ({
label: item.name,
value: item.id,
value: item.name,
}));
tableApi.setProps({
formOptions: {
@ -338,7 +365,7 @@ export default defineComponent({
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
mode: 'tags',
options: categoryOptions.value,
},
fieldName: 'categories',
@ -348,7 +375,7 @@ export default defineComponent({
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
mode: 'tags',
options: tagOptions.value,
},
fieldName: 'tags',
@ -359,22 +386,48 @@ export default defineComponent({
});
}
async function filterByCategory(id: number) {
await tableApi.setSearchValues({ categories: [id] });
async function filterByCategory(value: string) {
await tableApi.setSearchValues({ categories: [value] });
await tableApi.search();
}
async function filterByTag(id: number) {
await tableApi.setSearchValues({ tags: [id] });
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();
},
});
}
function getArticleFormDefaults(
searchValues: ArticleSearchValues = {},
): WordpressBlogApi.ArticleBody {
return {
categories: [...(searchValues.categories || [])],
content: '',
contentFormat: 'markdown',
excerpt: '',
slug: '',
status: 'draft',
@ -404,12 +457,13 @@ export default defineComponent({
}
function openEdit(row: WordpressBlogApi.Article) {
editingId.value = row.id;
editingId.value = `${row.id}`;
articleModalApi
.setData({
values: {
categories: row.categories || [],
content: getRenderedText(row.content),
content: getEditableContent(row.content, row.contentMarkdown),
contentFormat: 'markdown',
excerpt: getRenderedText(row.excerpt),
id: row.id,
slug: row.slug || '',
@ -438,6 +492,7 @@ export default defineComponent({
try {
const payload = {
...values,
contentFormat: 'markdown' as const,
id: editingId.value,
title,
};
@ -446,6 +501,7 @@ export default defineComponent({
: createArticle(payload));
message.success('文章保存成功');
await articleModalApi.close();
await loadTermOptions();
await tableApi.reload();
} finally {
articleModalApi.unlock();

View File

@ -54,7 +54,7 @@ export default defineComponent({
const route = useRoute();
const router = useRouter();
const editingId = ref<number>();
const editingId = ref<number | string>();
const tableRows = ref<WordpressBlogApi.Term[]>([]);
const parentOptions = computed(() =>
tableRows.value
@ -80,7 +80,7 @@ export default defineComponent({
{
component: 'Input',
componentProps: {
placeholder: '可选,WordPress slug',
placeholder: '可选,默认由名称生成',
},
fieldName: 'slug',
label: '别名',
@ -189,7 +189,7 @@ export default defineComponent({
},
{
confirm: (row) =>
`确认删除${props.title}${row.name}」吗?WordPress 分类和标签不支持回收站,本操作会强制删除该条目,但不会删除已关联文章。`,
`确认删除${props.title}${row.name}」吗?本操作不会删除已关联文章。`,
danger: true,
key: 'delete',
label: '删除',
@ -333,8 +333,8 @@ export default defineComponent({
function openRelatedArticles(row: WordpressBlogApi.Term) {
setBlogArticleFilters(
props.kind === 'category'
? { categories: [row.id] }
: { tags: [row.id] },
? { categories: [row.name] }
: { tags: [row.name] },
);
router.push({
name: 'BlogArticle',

View File

@ -1,6 +1,6 @@
export interface BlogArticleFilters {
categories?: number[];
tags?: number[];
categories?: Array<number | string>;
tags?: Array<number | string>;
}
let pendingFilters: BlogArticleFilters | null = null;

View File

@ -0,0 +1,164 @@
import type { WordpressBlogApi } from '#/api/blog';
import { computed, defineComponent, onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Button, message, Modal, Space, Tag } from 'antdv-next';
import {
getThemeConfig,
importWordpressThemeConfig,
saveThemeConfig,
} from '#/api/blog';
const AButton = Button as any;
export default defineComponent({
name: 'BlogThemeConfig',
setup() {
const { hasAccessByCodes } = useAccess();
const config = ref<WordpressBlogApi.ThemeConfig>({});
const jsonText = ref('');
const loading = ref(false);
const saving = ref(false);
const importing = ref(false);
const canImport = computed(() => hasAccessByCodes(['Blog:Theme:Import']));
const canSave = computed(() => hasAccessByCodes(['Blog:Theme:Save']));
const summaryItems = computed(() => [
{ label: '站点标题', value: config.value.site?.title || '-' },
{ label: '作者', value: config.value.site?.authorName || '-' },
{ label: '主题色', value: config.value.themeColor || '-' },
{ label: '圆角', value: config.value.themeCardRadius ?? '-' },
{ label: '版本', value: config.value.themeVersion || '-' },
{ label: '深色模式', value: config.value.darkmodeAutoSwitch || '-' },
{
label: '菜单',
value: `${config.value.headerMenu?.length || 0}/${config.value.sidebarMenu?.length || 0}`,
},
{
label: '背景',
value: config.value.backgroundImage || '-',
},
]);
async function loadConfig() {
loading.value = true;
try {
const nextConfig = await getThemeConfig();
applyConfig(nextConfig);
} finally {
loading.value = false;
}
}
function applyConfig(nextConfig: WordpressBlogApi.ThemeConfig) {
config.value = nextConfig || {};
jsonText.value = JSON.stringify(config.value, null, 2);
}
function parseJsonConfig() {
try {
return JSON.parse(
jsonText.value || '{}',
) as WordpressBlogApi.ThemeConfig;
} catch {
message.warning('主题配置 JSON 格式不正确');
return null;
}
}
async function saveConfig() {
const nextConfig = parseJsonConfig();
if (!nextConfig) return;
saving.value = true;
try {
const savedConfig = await saveThemeConfig({
config: nextConfig,
source: 'admin',
});
applyConfig(savedConfig);
message.success('主题配置保存成功');
} finally {
saving.value = false;
}
}
function confirmImportWordpress() {
Modal.confirm({
cancelText: '取消',
content: '将读取已配置 WordPress 站点的 Argon 主题配置并保存到本地。',
okText: '开始导入',
title: '导入 WordPress 主题配置',
async onOk() {
importing.value = true;
try {
const importedConfig = await importWordpressThemeConfig();
applyConfig(importedConfig);
message.success('主题配置导入成功');
} finally {
importing.value = false;
}
},
});
}
onMounted(() => {
void loadConfig();
});
return () => (
<Page autoContentHeight>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="rounded-md bg-background p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="m-0 text-base font-medium"></h2>
<div class="mt-2 flex flex-wrap gap-2">
{summaryItems.value.map((item) => (
<Tag key={item.label}>
{item.label}{item.value}
</Tag>
))}
</div>
</div>
<Space>
<AButton loading={loading.value} onClick={loadConfig}>
</AButton>
{canImport.value ? (
<AButton
loading={importing.value}
onClick={confirmImportWordpress}
>
WordPress
</AButton>
) : null}
{canSave.value ? (
<AButton
loading={saving.value}
onClick={saveConfig}
type="primary"
>
</AButton>
) : null}
</Space>
</div>
</div>
<textarea
class="min-h-[520px] flex-1 resize-none rounded-md border border-border bg-background p-4 font-mono text-sm leading-6 outline-none transition-colors focus:border-primary"
onInput={(event) => {
jsonText.value = (event.target as HTMLTextAreaElement).value;
}}
spellcheck={false}
value={jsonText.value}
/>
</div>
</Page>
);
},
});

File diff suppressed because it is too large Load Diff