diff --git a/apps/web-antdv-next/package.json b/apps/web-antdv-next/package.json index 5be5d7b..582641c 100644 --- a/apps/web-antdv-next/package.json +++ b/apps/web-antdv-next/package.json @@ -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:*", diff --git a/apps/web-antdv-next/src/api/blog/wordpress.ts b/apps/web-antdv-next/src/api/blog/wordpress.ts index 36c3433..e0b1465 100644 --- a/apps/web-antdv-next/src/api/blog/wordpress.ts +++ b/apps/web-antdv-next/src/api/blog/wordpress.ts @@ -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 { - 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; + 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 - >('/wordpress/article/list', { params }); + >('/blog/article/list', { params }); } export function getArticleDetail(id: number | string) { - return requestClient.get( - '/wordpress/article/detail', - { params: { id } }, - ); + return requestClient.get('/blog/article/detail', { + params: { id }, + }); } export function createArticle(data: WordpressBlogApi.ArticleBody) { return requestClient.post( - '/wordpress/article/save', + '/blog/article/save', data, ); } export function updateArticle(data: WordpressBlogApi.ArticleBody) { return requestClient.post( - '/wordpress/article/update', + '/blog/article/update', data, ); } -export function deleteArticle(id: number | string, force = true) { +export function deleteArticle(id: number | string) { return requestClient.post( - `/wordpress/article/remove?id=${id}&force=${force}`, + `/blog/article/remove?id=${id}`, + ); +} + +export function importWordpressArticles( + data: WordpressBlogApi.ArticleImportWordpressBody, +) { + return requestClient.post( + '/blog/article/import-wordpress', + data, + ); +} + +export function getThemeConfig() { + return requestClient.get('/blog/theme/config'); +} + +export function saveThemeConfig(data: WordpressBlogApi.ThemeConfigBody) { + return requestClient.post( + '/blog/theme/save', + data, + ); +} + +export function importWordpressThemeConfig() { + return requestClient.post( + '/blog/theme/import-wordpress', + ); +} + +export function getArticleCategoryOptions( + params: WordpressBlogApi.TermQuery = {}, +) { + return requestClient.get>( + '/blog/article/category-options', + { params }, + ); +} + +export function getArticleTagOptions(params: WordpressBlogApi.TermQuery = {}) { + return requestClient.get>( + '/blog/article/tag-options', + { params }, ); } export function getCategoryList(params: WordpressBlogApi.TermQuery = {}) { return requestClient.get>( - '/wordpress/category/list', + '/blog/category/list', { params }, ); } export function createCategory(data: WordpressBlogApi.TermBody) { - return requestClient.post( - '/wordpress/category/save', - data, - ); + return requestClient.post('/blog/category/save', data); } export function updateCategory(data: WordpressBlogApi.TermBody) { return requestClient.post( - '/wordpress/category/update', + '/blog/category/update', data, ); } export function deleteCategory(id: number | string, force = true) { return requestClient.post( - `/wordpress/category/remove?id=${id}&force=${force}`, + `/blog/category/remove?id=${id}&force=${force}`, ); } export function getTagList(params: WordpressBlogApi.TermQuery = {}) { return requestClient.get>( - '/wordpress/tag/list', + '/blog/tag/list', { params }, ); } export function createTag(data: WordpressBlogApi.TermBody) { - return requestClient.post('/wordpress/tag/save', data); + return requestClient.post('/blog/tag/save', data); } export function updateTag(data: WordpressBlogApi.TermBody) { - return requestClient.post( - '/wordpress/tag/update', - data, - ); + return requestClient.post('/blog/tag/update', data); } export function deleteTag(id: number | string, force = true) { return requestClient.post( - `/wordpress/tag/remove?id=${id}&force=${force}`, + `/blog/tag/remove?id=${id}&force=${force}`, ); } diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index 29859b2..89f0808 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -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', diff --git a/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.scss b/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.scss new file mode 100644 index 0000000..b67859f --- /dev/null +++ b/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.scss @@ -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; + } +} diff --git a/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.tsx b/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.tsx new file mode 100644 index 0000000..ec14a20 --- /dev/null +++ b/apps/web-antdv-next/src/components/markdown/KtMilkdownEditor.tsx @@ -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[0]; +export type KtMilkdownCrepeOptions = Partial< + Omit +>; + +export interface KtMilkdownEditorExpose { + getEditor: () => Crepe | null; + getMarkdown: () => string; + recreate: (value?: string) => Promise; + 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, + }, + featureConfigs: { + default: undefined, + type: Object as PropType, + }, + features: { + default: undefined, + type: Object as PropType, + }, + minHeight: { + default: 360, + type: [Number, String] as PropType, + }, + 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(null); + const editor = shallowRef(null); + const currentMarkdown = ref(props.modelValue || ''); + const loading = ref(false); + let createVersion = 0; + + const readonlyState = computed(() => props.readonly || props.disabled); + const editorStyle = computed(() => ({ + '--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 () => ( +
+
+ {loading.value ? ( +
编辑器加载中...
+ ) : null} +
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/components/markdown/index.ts b/apps/web-antdv-next/src/components/markdown/index.ts new file mode 100644 index 0000000..e84309f --- /dev/null +++ b/apps/web-antdv-next/src/components/markdown/index.ts @@ -0,0 +1,6 @@ +export { default as KtMilkdownEditor } from './KtMilkdownEditor'; +export type { + KtMilkdownCrepeOptions, + KtMilkdownEditorExpose, + KtMilkdownEventRegistrar, +} from './KtMilkdownEditor'; diff --git a/apps/web-antdv-next/src/router/routes/modules/blog.ts b/apps/web-antdv-next/src/router/routes/modules/blog.ts index f30a5e5..d68b83e 100644 --- a/apps/web-antdv-next/src/router/routes/modules/blog.ts +++ b/apps/web-antdv-next/src/router/routes/modules/blog.ts @@ -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', + }, ], }, ]; diff --git a/apps/web-antdv-next/src/views/blog/article/list.tsx b/apps/web-antdv-next/src/views/blog/article/list.tsx index 01ed3cc..05e6c5d 100644 --- a/apps/web-antdv-next/src/views/blog/article/list.tsx +++ b/apps/web-antdv-next/src/views/blog/article/list.tsx @@ -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(); + const editingId = ref(); const categoryOptions = ref([]); const tagOptions = ref([]); @@ -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 = { @@ -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: , + key: 'import-wordpress', + label: '导入 WordPress', + onClick: confirmImportWordpress, + permissionCodes: ['Blog:Article:Import'], + type: 'default', + }, ]; const rowActions: Array< KtTableRowAction @@ -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, + ) { + 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(); diff --git a/apps/web-antdv-next/src/views/blog/modules/term-management.tsx b/apps/web-antdv-next/src/views/blog/modules/term-management.tsx index 727dbb5..3bf3ada 100644 --- a/apps/web-antdv-next/src/views/blog/modules/term-management.tsx +++ b/apps/web-antdv-next/src/views/blog/modules/term-management.tsx @@ -54,7 +54,7 @@ export default defineComponent({ const route = useRoute(); const router = useRouter(); - const editingId = ref(); + const editingId = ref(); const tableRows = ref([]); 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', diff --git a/apps/web-antdv-next/src/views/blog/modules/use-article-filters.ts b/apps/web-antdv-next/src/views/blog/modules/use-article-filters.ts index be9419f..e0025f8 100644 --- a/apps/web-antdv-next/src/views/blog/modules/use-article-filters.ts +++ b/apps/web-antdv-next/src/views/blog/modules/use-article-filters.ts @@ -1,6 +1,6 @@ export interface BlogArticleFilters { - categories?: number[]; - tags?: number[]; + categories?: Array; + tags?: Array; } let pendingFilters: BlogArticleFilters | null = null; diff --git a/apps/web-antdv-next/src/views/blog/theme/config.tsx b/apps/web-antdv-next/src/views/blog/theme/config.tsx new file mode 100644 index 0000000..58e4b7f --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/theme/config.tsx @@ -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({}); + 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 () => ( + +
+
+
+
+

主题配置

+
+ {summaryItems.value.map((item) => ( + + {item.label}:{item.value} + + ))} +
+
+ + + 刷新 + + {canImport.value ? ( + + 导入 WordPress + + ) : null} + {canSave.value ? ( + + 保存配置 + + ) : null} + +
+
+ +