feat: 接入博客 Markdown 管理能力
This commit is contained in:
parent
2f06b3722e
commit
8b7f773b2f
@ -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:*",
|
||||
|
||||
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
254
apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.tsx
Normal file
254
apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
6
apps/web-antdv-next/src/components/markdown/index.ts
Normal file
6
apps/web-antdv-next/src/components/markdown/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { default as KtMilkdownEditor } from './KtMilkdownEditor';
|
||||
export type {
|
||||
KtMilkdownCrepeOptions,
|
||||
KtMilkdownEditorExpose,
|
||||
KtMilkdownEventRegistrar,
|
||||
} from './KtMilkdownEditor';
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export interface BlogArticleFilters {
|
||||
categories?: number[];
|
||||
tags?: number[];
|
||||
categories?: Array<number | string>;
|
||||
tags?: Array<number | string>;
|
||||
}
|
||||
|
||||
let pendingFilters: BlogArticleFilters | null = null;
|
||||
|
||||
164
apps/web-antdv-next/src/views/blog/theme/config.tsx
Normal file
164
apps/web-antdv-next/src/views/blog/theme/config.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
1781
pnpm-lock.yaml
1781
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user