feat: 接入博客 Markdown 管理能力
This commit is contained in:
parent
2f06b3722e
commit
8b7f773b2f
@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antdv-next/icons": "catalog:",
|
"@antdv-next/icons": "catalog:",
|
||||||
|
"@milkdown/crepe": "^7.21.2",
|
||||||
"@tanstack/vue-query": "catalog:",
|
"@tanstack/vue-query": "catalog:",
|
||||||
"@vben-core/menu-ui": "workspace:*",
|
"@vben-core/menu-ui": "workspace:*",
|
||||||
"@vben-core/shadcn-ui": "workspace:*",
|
"@vben-core/shadcn-ui": "workspace:*",
|
||||||
|
|||||||
@ -14,55 +14,122 @@ export namespace WordpressBlogApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
categories?: number[];
|
categories?: string[];
|
||||||
|
categoriesResolved?: Term[];
|
||||||
content?: RenderedField | string;
|
content?: RenderedField | string;
|
||||||
|
contentHtml?: string;
|
||||||
|
contentMarkdown?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
excerpt?: RenderedField | string;
|
excerpt?: RenderedField | string;
|
||||||
id: number;
|
id: number | string;
|
||||||
link?: string;
|
link?: string;
|
||||||
modified?: string;
|
modified?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
tags?: number[];
|
tags?: string[];
|
||||||
|
tagsResolved?: Term[];
|
||||||
title?: RenderedField | string;
|
title?: RenderedField | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleBody {
|
export interface ArticleBody {
|
||||||
categories?: number[];
|
authorName?: string;
|
||||||
|
categories?: string[];
|
||||||
content?: string;
|
content?: string;
|
||||||
|
contentFormat?: 'html' | 'markdown';
|
||||||
|
cover?: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
id?: number;
|
id?: number | string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
tags?: number[];
|
tags?: string[];
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleQuery extends Recordable<any> {
|
export interface ArticleQuery extends Recordable<any> {
|
||||||
categories?: number[] | string;
|
categories?: string | string[];
|
||||||
pageNo?: number;
|
pageNo?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: 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 {
|
export interface Term {
|
||||||
count?: number;
|
count?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
id: number;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
parent?: number;
|
parent?: number | string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TermBody {
|
export interface TermBody {
|
||||||
description?: string;
|
description?: string;
|
||||||
id?: number;
|
id?: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
parent?: number;
|
parent?: number | string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,83 +145,118 @@ export namespace WordpressBlogApi {
|
|||||||
export function getArticleList(params: WordpressBlogApi.ArticleQuery) {
|
export function getArticleList(params: WordpressBlogApi.ArticleQuery) {
|
||||||
return requestClient.get<
|
return requestClient.get<
|
||||||
WordpressBlogApi.PageResult<WordpressBlogApi.Article>
|
WordpressBlogApi.PageResult<WordpressBlogApi.Article>
|
||||||
>('/wordpress/article/list', { params });
|
>('/blog/article/list', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getArticleDetail(id: number | string) {
|
export function getArticleDetail(id: number | string) {
|
||||||
return requestClient.get<WordpressBlogApi.Article>(
|
return requestClient.get<WordpressBlogApi.Article>('/blog/article/detail', {
|
||||||
'/wordpress/article/detail',
|
params: { id },
|
||||||
{ params: { id } },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createArticle(data: WordpressBlogApi.ArticleBody) {
|
export function createArticle(data: WordpressBlogApi.ArticleBody) {
|
||||||
return requestClient.post<WordpressBlogApi.Article>(
|
return requestClient.post<WordpressBlogApi.Article>(
|
||||||
'/wordpress/article/save',
|
'/blog/article/save',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateArticle(data: WordpressBlogApi.ArticleBody) {
|
export function updateArticle(data: WordpressBlogApi.ArticleBody) {
|
||||||
return requestClient.post<WordpressBlogApi.Article>(
|
return requestClient.post<WordpressBlogApi.Article>(
|
||||||
'/wordpress/article/update',
|
'/blog/article/update',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteArticle(id: number | string, force = true) {
|
export function deleteArticle(id: number | string) {
|
||||||
return requestClient.post<WordpressBlogApi.Article>(
|
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 = {}) {
|
export function getCategoryList(params: WordpressBlogApi.TermQuery = {}) {
|
||||||
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
|
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
|
||||||
'/wordpress/category/list',
|
'/blog/category/list',
|
||||||
{ params },
|
{ params },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCategory(data: WordpressBlogApi.TermBody) {
|
export function createCategory(data: WordpressBlogApi.TermBody) {
|
||||||
return requestClient.post<WordpressBlogApi.Term>(
|
return requestClient.post<WordpressBlogApi.Term>('/blog/category/save', data);
|
||||||
'/wordpress/category/save',
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCategory(data: WordpressBlogApi.TermBody) {
|
export function updateCategory(data: WordpressBlogApi.TermBody) {
|
||||||
return requestClient.post<WordpressBlogApi.Term>(
|
return requestClient.post<WordpressBlogApi.Term>(
|
||||||
'/wordpress/category/update',
|
'/blog/category/update',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCategory(id: number | string, force = true) {
|
export function deleteCategory(id: number | string, force = true) {
|
||||||
return requestClient.post<WordpressBlogApi.Term>(
|
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 = {}) {
|
export function getTagList(params: WordpressBlogApi.TermQuery = {}) {
|
||||||
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
|
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
|
||||||
'/wordpress/tag/list',
|
'/blog/tag/list',
|
||||||
{ params },
|
{ params },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTag(data: WordpressBlogApi.TermBody) {
|
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) {
|
export function updateTag(data: WordpressBlogApi.TermBody) {
|
||||||
return requestClient.post<WordpressBlogApi.Term>(
|
return requestClient.post<WordpressBlogApi.Term>('/blog/tag/update', data);
|
||||||
'/wordpress/tag/update',
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTag(id: number | string, force = true) {
|
export function deleteTag(id: number | string, force = true) {
|
||||||
return requestClient.post<WordpressBlogApi.Term>(
|
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',
|
'BlogArticleCreate',
|
||||||
'BlogArticleDelete',
|
'BlogArticleDelete',
|
||||||
'BlogArticleEdit',
|
'BlogArticleEdit',
|
||||||
|
'BlogArticleImport',
|
||||||
'BlogCategory',
|
'BlogCategory',
|
||||||
'BlogCategoryCreate',
|
'BlogCategoryCreate',
|
||||||
'BlogCategoryDelete',
|
'BlogCategoryDelete',
|
||||||
@ -16,6 +17,9 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
|
|||||||
'BlogTagCreate',
|
'BlogTagCreate',
|
||||||
'BlogTagDelete',
|
'BlogTagDelete',
|
||||||
'BlogTagEdit',
|
'BlogTagEdit',
|
||||||
|
'BlogTheme',
|
||||||
|
'BlogThemeImport',
|
||||||
|
'BlogThemeSave',
|
||||||
'QqBot',
|
'QqBot',
|
||||||
'QqBotAccount',
|
'QqBotAccount',
|
||||||
'QqBotAccountConfig',
|
'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',
|
name: 'BlogTag',
|
||||||
path: '/blog/tag',
|
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 { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
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 { useVbenForm } from '#/adapter/form';
|
||||||
import {
|
import {
|
||||||
createArticle,
|
createArticle,
|
||||||
deleteArticle,
|
deleteArticle,
|
||||||
|
getArticleCategoryOptions,
|
||||||
getArticleList,
|
getArticleList,
|
||||||
getCategoryList,
|
getArticleTagOptions,
|
||||||
getTagList,
|
importWordpressArticles,
|
||||||
updateArticle,
|
updateArticle,
|
||||||
} from '#/api/blog';
|
} from '#/api/blog';
|
||||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
|
import { KtMilkdownEditor } from '#/components/markdown';
|
||||||
|
|
||||||
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
|
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
|
||||||
|
|
||||||
type TermOption = {
|
type TermOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArticleSearchValues = {
|
type ArticleSearchValues = {
|
||||||
categories?: number[];
|
categories?: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
tags?: number[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const AKtTable = KtTable as any;
|
const AKtTable = KtTable as any;
|
||||||
@ -52,7 +54,7 @@ const articleStatusOptions = [
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'BlogArticleList',
|
name: 'BlogArticleList',
|
||||||
setup() {
|
setup() {
|
||||||
const editingId = ref<number>();
|
const editingId = ref<string>();
|
||||||
const categoryOptions = ref<TermOption[]>([]);
|
const categoryOptions = ref<TermOption[]>([]);
|
||||||
const tagOptions = ref<TermOption[]>([]);
|
const tagOptions = ref<TermOption[]>([]);
|
||||||
|
|
||||||
@ -82,7 +84,7 @@ export default defineComponent({
|
|||||||
{
|
{
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '可选,WordPress slug',
|
placeholder: '可选,默认由标题生成',
|
||||||
},
|
},
|
||||||
fieldName: 'slug',
|
fieldName: 'slug',
|
||||||
label: '别名',
|
label: '别名',
|
||||||
@ -90,9 +92,9 @@ export default defineComponent({
|
|||||||
{
|
{
|
||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: () => ({
|
componentProps: () => ({
|
||||||
mode: 'multiple',
|
mode: 'tags',
|
||||||
options: categoryOptions.value,
|
options: categoryOptions.value,
|
||||||
placeholder: '选择分类',
|
placeholder: '输入或选择分类',
|
||||||
}),
|
}),
|
||||||
fieldName: 'categories',
|
fieldName: 'categories',
|
||||||
label: '分类',
|
label: '分类',
|
||||||
@ -100,9 +102,9 @@ export default defineComponent({
|
|||||||
{
|
{
|
||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: () => ({
|
componentProps: () => ({
|
||||||
mode: 'multiple',
|
mode: 'tags',
|
||||||
options: tagOptions.value,
|
options: tagOptions.value,
|
||||||
placeholder: '选择标签',
|
placeholder: '输入或选择标签',
|
||||||
}),
|
}),
|
||||||
fieldName: 'tags',
|
fieldName: 'tags',
|
||||||
label: '标签',
|
label: '标签',
|
||||||
@ -117,10 +119,10 @@ export default defineComponent({
|
|||||||
label: '摘要',
|
label: '摘要',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'Textarea',
|
component: KtMilkdownEditor,
|
||||||
componentProps: {
|
componentProps: {
|
||||||
autoSize: { maxRows: 12, minRows: 6 },
|
minHeight: 460,
|
||||||
placeholder: '支持 HTML 内容',
|
placeholder: '请输入 Markdown 正文',
|
||||||
},
|
},
|
||||||
fieldName: 'content',
|
fieldName: 'content',
|
||||||
label: '内容',
|
label: '内容',
|
||||||
@ -157,7 +159,12 @@ export default defineComponent({
|
|||||||
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
|
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
|
||||||
{ dataIndex: 'categories', key: 'categories', title: '分类', width: 180 },
|
{ dataIndex: 'categories', key: 'categories', title: '分类', width: 180 },
|
||||||
{ dataIndex: 'tags', key: 'tags', 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> = {
|
const api: KtTableApi<WordpressBlogApi.Article, ArticleSearchValues> = {
|
||||||
@ -165,12 +172,14 @@ export default defineComponent({
|
|||||||
return await getArticleList({
|
return await getArticleList({
|
||||||
categories: Array.isArray(params.categories)
|
categories: Array.isArray(params.categories)
|
||||||
? params.categories.join(',')
|
? params.categories.join(',')
|
||||||
: undefined,
|
: params.categories,
|
||||||
pageNo: params.pageNo,
|
pageNo: params.pageNo,
|
||||||
pageSize: params.pageSize,
|
pageSize: params.pageSize,
|
||||||
search: params.search,
|
search: params.search,
|
||||||
status: params.status || undefined,
|
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'],
|
permissionCodes: ['Blog:Article:Create'],
|
||||||
type: 'primary',
|
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<
|
const rowActions: Array<
|
||||||
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
||||||
@ -241,7 +258,7 @@ export default defineComponent({
|
|||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
mode: 'multiple',
|
mode: 'tags',
|
||||||
options: categoryOptions.value,
|
options: categoryOptions.value,
|
||||||
},
|
},
|
||||||
fieldName: 'categories',
|
fieldName: 'categories',
|
||||||
@ -251,7 +268,7 @@ export default defineComponent({
|
|||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
mode: 'multiple',
|
mode: 'tags',
|
||||||
options: tagOptions.value,
|
options: tagOptions.value,
|
||||||
},
|
},
|
||||||
fieldName: 'tags',
|
fieldName: 'tags',
|
||||||
@ -270,6 +287,16 @@ export default defineComponent({
|
|||||||
return stripHtml(value.raw || value.rendered || '');
|
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) {
|
function stripHtml(value: string) {
|
||||||
return value
|
return value
|
||||||
.replaceAll(/<[^>]+>/g, '')
|
.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}`;
|
return options.find((item) => item.value === value)?.label || `${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,8 +320,8 @@ export default defineComponent({
|
|||||||
if (!filters) return false;
|
if (!filters) return false;
|
||||||
|
|
||||||
await tableApi.setSearchValues({
|
await tableApi.setSearchValues({
|
||||||
categories: filters.categories || [],
|
categories: (filters.categories || []).map((item) => `${item}`),
|
||||||
tags: filters.tags || [],
|
tags: (filters.tags || []).map((item) => `${item}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -302,16 +329,16 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function loadTermOptions() {
|
async function loadTermOptions() {
|
||||||
const [categories, tags] = await Promise.all([
|
const [categories, tags] = await Promise.all([
|
||||||
getCategoryList({ hide_empty: false, pageNo: 1, pageSize: 100 }),
|
getArticleCategoryOptions({ pageNo: 1, pageSize: 200 }),
|
||||||
getTagList({ hide_empty: false, pageNo: 1, pageSize: 100 }),
|
getArticleTagOptions({ pageNo: 1, pageSize: 200 }),
|
||||||
]);
|
]);
|
||||||
categoryOptions.value = categories.list.map((item) => ({
|
categoryOptions.value = categories.list.map((item) => ({
|
||||||
label: item.name,
|
label: item.name,
|
||||||
value: item.id,
|
value: item.name,
|
||||||
}));
|
}));
|
||||||
tagOptions.value = tags.list.map((item) => ({
|
tagOptions.value = tags.list.map((item) => ({
|
||||||
label: item.name,
|
label: item.name,
|
||||||
value: item.id,
|
value: item.name,
|
||||||
}));
|
}));
|
||||||
tableApi.setProps({
|
tableApi.setProps({
|
||||||
formOptions: {
|
formOptions: {
|
||||||
@ -338,7 +365,7 @@ export default defineComponent({
|
|||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
mode: 'multiple',
|
mode: 'tags',
|
||||||
options: categoryOptions.value,
|
options: categoryOptions.value,
|
||||||
},
|
},
|
||||||
fieldName: 'categories',
|
fieldName: 'categories',
|
||||||
@ -348,7 +375,7 @@ export default defineComponent({
|
|||||||
component: 'Select',
|
component: 'Select',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
mode: 'multiple',
|
mode: 'tags',
|
||||||
options: tagOptions.value,
|
options: tagOptions.value,
|
||||||
},
|
},
|
||||||
fieldName: 'tags',
|
fieldName: 'tags',
|
||||||
@ -359,22 +386,48 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function filterByCategory(id: number) {
|
async function filterByCategory(value: string) {
|
||||||
await tableApi.setSearchValues({ categories: [id] });
|
await tableApi.setSearchValues({ categories: [value] });
|
||||||
await tableApi.search();
|
await tableApi.search();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function filterByTag(id: number) {
|
async function filterByTag(value: string) {
|
||||||
await tableApi.setSearchValues({ tags: [id] });
|
await tableApi.setSearchValues({ tags: [value] });
|
||||||
await tableApi.search();
|
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(
|
function getArticleFormDefaults(
|
||||||
searchValues: ArticleSearchValues = {},
|
searchValues: ArticleSearchValues = {},
|
||||||
): WordpressBlogApi.ArticleBody {
|
): WordpressBlogApi.ArticleBody {
|
||||||
return {
|
return {
|
||||||
categories: [...(searchValues.categories || [])],
|
categories: [...(searchValues.categories || [])],
|
||||||
content: '',
|
content: '',
|
||||||
|
contentFormat: 'markdown',
|
||||||
excerpt: '',
|
excerpt: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
@ -404,12 +457,13 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: WordpressBlogApi.Article) {
|
function openEdit(row: WordpressBlogApi.Article) {
|
||||||
editingId.value = row.id;
|
editingId.value = `${row.id}`;
|
||||||
articleModalApi
|
articleModalApi
|
||||||
.setData({
|
.setData({
|
||||||
values: {
|
values: {
|
||||||
categories: row.categories || [],
|
categories: row.categories || [],
|
||||||
content: getRenderedText(row.content),
|
content: getEditableContent(row.content, row.contentMarkdown),
|
||||||
|
contentFormat: 'markdown',
|
||||||
excerpt: getRenderedText(row.excerpt),
|
excerpt: getRenderedText(row.excerpt),
|
||||||
id: row.id,
|
id: row.id,
|
||||||
slug: row.slug || '',
|
slug: row.slug || '',
|
||||||
@ -438,6 +492,7 @@ export default defineComponent({
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
|
contentFormat: 'markdown' as const,
|
||||||
id: editingId.value,
|
id: editingId.value,
|
||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
@ -446,6 +501,7 @@ export default defineComponent({
|
|||||||
: createArticle(payload));
|
: createArticle(payload));
|
||||||
message.success('文章保存成功');
|
message.success('文章保存成功');
|
||||||
await articleModalApi.close();
|
await articleModalApi.close();
|
||||||
|
await loadTermOptions();
|
||||||
await tableApi.reload();
|
await tableApi.reload();
|
||||||
} finally {
|
} finally {
|
||||||
articleModalApi.unlock();
|
articleModalApi.unlock();
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export default defineComponent({
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const editingId = ref<number>();
|
const editingId = ref<number | string>();
|
||||||
const tableRows = ref<WordpressBlogApi.Term[]>([]);
|
const tableRows = ref<WordpressBlogApi.Term[]>([]);
|
||||||
const parentOptions = computed(() =>
|
const parentOptions = computed(() =>
|
||||||
tableRows.value
|
tableRows.value
|
||||||
@ -80,7 +80,7 @@ export default defineComponent({
|
|||||||
{
|
{
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '可选,WordPress slug',
|
placeholder: '可选,默认由名称生成',
|
||||||
},
|
},
|
||||||
fieldName: 'slug',
|
fieldName: 'slug',
|
||||||
label: '别名',
|
label: '别名',
|
||||||
@ -189,7 +189,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
confirm: (row) =>
|
confirm: (row) =>
|
||||||
`确认删除${props.title}「${row.name}」吗?WordPress 分类和标签不支持回收站,本操作会强制删除该条目,但不会删除已关联文章。`,
|
`确认删除${props.title}「${row.name}」吗?本操作不会删除已关联文章。`,
|
||||||
danger: true,
|
danger: true,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: '删除',
|
label: '删除',
|
||||||
@ -333,8 +333,8 @@ export default defineComponent({
|
|||||||
function openRelatedArticles(row: WordpressBlogApi.Term) {
|
function openRelatedArticles(row: WordpressBlogApi.Term) {
|
||||||
setBlogArticleFilters(
|
setBlogArticleFilters(
|
||||||
props.kind === 'category'
|
props.kind === 'category'
|
||||||
? { categories: [row.id] }
|
? { categories: [row.name] }
|
||||||
: { tags: [row.id] },
|
: { tags: [row.name] },
|
||||||
);
|
);
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BlogArticle',
|
name: 'BlogArticle',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export interface BlogArticleFilters {
|
export interface BlogArticleFilters {
|
||||||
categories?: number[];
|
categories?: Array<number | string>;
|
||||||
tags?: number[];
|
tags?: Array<number | string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pendingFilters: BlogArticleFilters | null = null;
|
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