From ce95d93c2bf85dd28c762baa17e52d30286ba30e Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 18 May 2026 20:05:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=8E=A5=E5=85=A5=E5=8D=9A?= =?UTF-8?q?=E5=AE=A2=E7=AE=A1=E7=90=86=E5=92=8C=E6=8C=89=E9=92=AE=E6=9D=83?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/src/api/blog/index.ts | 1 + apps/web-antdv-next/src/api/blog/wordpress.ts | 151 +++++ apps/web-antdv-next/src/api/core/auth.ts | 6 + apps/web-antdv-next/src/api/core/menu.ts | 13 + apps/web-antdv-next/src/api/index.ts | 1 + apps/web-antdv-next/src/app.tsx | 40 ++ apps/web-antdv-next/src/app.vue | 39 -- apps/web-antdv-next/src/bootstrap.ts | 2 +- apps/web-antdv-next/src/router/access.ts | 5 +- .../web-antdv-next/src/router/routes/index.ts | 11 +- .../src/router/routes/modules/blog.ts | 45 ++ .../src/views/blog/article/list.tsx | 581 ++++++++++++++++++ .../src/views/blog/category/list.tsx | 10 + .../views/blog/modules/term-management.tsx | 406 ++++++++++++ .../src/views/blog/tag/list.tsx | 10 + .../src/views/system/dept/data.ts | 14 +- .../src/views/system/dept/list.vue | 15 +- .../src/views/system/menu/data.ts | 18 +- .../src/views/system/menu/list.vue | 15 +- .../src/views/system/role/data.ts | 17 + .../src/views/system/role/list.vue | 19 +- .../src/helpers/generate-routes-backend.ts | 17 +- 22 files changed, 1376 insertions(+), 60 deletions(-) create mode 100644 apps/web-antdv-next/src/api/blog/index.ts create mode 100644 apps/web-antdv-next/src/api/blog/wordpress.ts create mode 100644 apps/web-antdv-next/src/app.tsx delete mode 100644 apps/web-antdv-next/src/app.vue create mode 100644 apps/web-antdv-next/src/router/routes/modules/blog.ts create mode 100644 apps/web-antdv-next/src/views/blog/article/list.tsx create mode 100644 apps/web-antdv-next/src/views/blog/category/list.tsx create mode 100644 apps/web-antdv-next/src/views/blog/modules/term-management.tsx create mode 100644 apps/web-antdv-next/src/views/blog/tag/list.tsx diff --git a/apps/web-antdv-next/src/api/blog/index.ts b/apps/web-antdv-next/src/api/blog/index.ts new file mode 100644 index 0000000..4e07ed1 --- /dev/null +++ b/apps/web-antdv-next/src/api/blog/index.ts @@ -0,0 +1 @@ +export * from './wordpress'; diff --git a/apps/web-antdv-next/src/api/blog/wordpress.ts b/apps/web-antdv-next/src/api/blog/wordpress.ts new file mode 100644 index 0000000..bb6a4d2 --- /dev/null +++ b/apps/web-antdv-next/src/api/blog/wordpress.ts @@ -0,0 +1,151 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +export namespace WordpressBlogApi { + export interface PageResult { + list: T[]; + total: number; + } + + export interface RenderedField { + raw?: string; + rendered?: string; + } + + export interface Article { + categories?: number[]; + content?: RenderedField | string; + date?: string; + excerpt?: RenderedField | string; + id: number; + link?: string; + modified?: string; + slug?: string; + status?: string; + sticky?: boolean; + tags?: number[]; + title?: RenderedField | string; + } + + export interface ArticleBody { + categories?: number[]; + content?: string; + excerpt?: string; + id?: number; + slug?: string; + status?: string; + sticky?: boolean; + tags?: number[]; + title: string; + } + + export interface Term { + count?: number; + description?: string; + id: number; + name: string; + parent?: number; + slug?: string; + } + + export interface TermBody { + description?: string; + id?: number; + name: string; + parent?: number; + slug?: string; + } + + export interface TermQuery extends Recordable { + hide_empty?: boolean; + pageNo?: number; + pageSize?: number; + parent?: number | string; + search?: string; + } +} + +export function getArticleList(params: Recordable) { + return requestClient.get< + WordpressBlogApi.PageResult + >('/wordpress/article/list', { params }); +} + +export function getArticleDetail(id: number | string) { + return requestClient.get( + '/wordpress/article/detail', + { params: { id } }, + ); +} + +export function createArticle(data: WordpressBlogApi.ArticleBody) { + return requestClient.post( + '/wordpress/article/save', + data, + ); +} + +export function updateArticle(data: WordpressBlogApi.ArticleBody) { + return requestClient.post( + '/wordpress/article/update', + data, + ); +} + +export function deleteArticle(id: number | string, force = true) { + return requestClient.post( + `/wordpress/article/remove?id=${id}&force=${force}`, + ); +} + +export function getCategoryList(params: WordpressBlogApi.TermQuery = {}) { + return requestClient.get>( + '/wordpress/category/list', + { params }, + ); +} + +export function createCategory(data: WordpressBlogApi.TermBody) { + return requestClient.post( + '/wordpress/category/save', + data, + ); +} + +export function updateCategory(data: WordpressBlogApi.TermBody) { + return requestClient.post( + '/wordpress/category/update', + data, + ); +} + +export function deleteCategory(id: number | string, force = true) { + return requestClient.post( + `/wordpress/category/remove?id=${id}&force=${force}`, + ); +} + +export function getTagList(params: WordpressBlogApi.TermQuery = {}) { + return requestClient.get>( + '/wordpress/tag/list', + { params }, + ); +} + +export function createTag(data: WordpressBlogApi.TermBody) { + return requestClient.post('/wordpress/tag/save', data); +} + +export function updateTag(data: WordpressBlogApi.TermBody) { + return requestClient.post( + '/wordpress/tag/update', + data, + ); +} + +export function deleteTag(id: number | string, force = true) { + return requestClient.post( + `/wordpress/tag/remove?id=${id}&force=${force}`, + ); +} diff --git a/apps/web-antdv-next/src/api/core/auth.ts b/apps/web-antdv-next/src/api/core/auth.ts index f1a5d3c..5138730 100644 --- a/apps/web-antdv-next/src/api/core/auth.ts +++ b/apps/web-antdv-next/src/api/core/auth.ts @@ -10,9 +10,15 @@ export namespace AuthApi { /** 登录接口返回值 */ export interface LoginResult { accessToken: string; + wordpressAvailable?: boolean; wordpressAuth?: WordpressAuthResult['auth'] & { user?: Record; }; + wordpressError?: null | { + error?: any; + message?: string; + status?: number; + }; } export interface RefreshTokenResult { diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index c151b9f..814b53b 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -3,6 +3,19 @@ import type { RouteRecordStringComponent } from '@vben/types'; import { requestClient } from '#/api/request'; const SUPPORTED_ADMIN_MENU_NAMES = new Set([ + 'Blog', + 'BlogArticle', + 'BlogArticleCreate', + 'BlogArticleDelete', + 'BlogArticleEdit', + 'BlogCategory', + 'BlogCategoryCreate', + 'BlogCategoryDelete', + 'BlogCategoryEdit', + 'BlogTag', + 'BlogTagCreate', + 'BlogTagDelete', + 'BlogTagEdit', 'System', 'SystemDept', 'SystemDeptCreate', diff --git a/apps/web-antdv-next/src/api/index.ts b/apps/web-antdv-next/src/api/index.ts index 3c3fa0d..fd27cf5 100644 --- a/apps/web-antdv-next/src/api/index.ts +++ b/apps/web-antdv-next/src/api/index.ts @@ -1,3 +1,4 @@ +export * from './blog'; export * from './core'; export * from './examples'; export * from './system'; diff --git a/apps/web-antdv-next/src/app.tsx b/apps/web-antdv-next/src/app.tsx new file mode 100644 index 0000000..2541395 --- /dev/null +++ b/apps/web-antdv-next/src/app.tsx @@ -0,0 +1,40 @@ +import { computed, defineComponent } from 'vue'; +import { RouterView } from 'vue-router'; + +import { useAntdDesignTokens } from '@vben/hooks'; +import { preferences, usePreferences } from '@vben/preferences'; + +import { App as AntdApp, ConfigProvider, theme } from 'antdv-next'; + +import { antdLocale } from '#/locales'; + +export default defineComponent({ + name: 'App', + setup() { + const { isDark } = usePreferences(); + const { tokens } = useAntdDesignTokens(); + + const tokenTheme = computed(() => { + const algorithm = isDark.value + ? [theme.darkAlgorithm] + : [theme.defaultAlgorithm]; + + if (preferences.app.compact) { + algorithm.push(theme.compactAlgorithm); + } + + return { + algorithm, + token: tokens, + }; + }); + + return () => ( + + + + + + ); + }, +}); diff --git a/apps/web-antdv-next/src/app.vue b/apps/web-antdv-next/src/app.vue deleted file mode 100644 index 59ca861..0000000 --- a/apps/web-antdv-next/src/app.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/apps/web-antdv-next/src/bootstrap.ts b/apps/web-antdv-next/src/bootstrap.ts index 0c5a0d8..d7438ca 100644 --- a/apps/web-antdv-next/src/bootstrap.ts +++ b/apps/web-antdv-next/src/bootstrap.ts @@ -14,7 +14,7 @@ import { router } from '#/router'; import { initComponentAdapter } from './adapter/component'; import { initSetupVbenForm } from './adapter/form'; -import App from './app.vue'; +import App from './app'; import { initTimezone } from './timezone-init'; async function bootstrap(namespace: string) { diff --git a/apps/web-antdv-next/src/router/access.ts b/apps/web-antdv-next/src/router/access.ts index e9e9851..1592b08 100644 --- a/apps/web-antdv-next/src/router/access.ts +++ b/apps/web-antdv-next/src/router/access.ts @@ -15,7 +15,10 @@ import { $t } from '#/locales'; const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue'); async function generateAccess(options: GenerateMenuAndRoutesOptions) { - const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue'); + const pageMap: ComponentRecordType = { + ...import.meta.glob('../views/**/*.tsx'), + ...import.meta.glob('../views/**/*.vue'), + }; const layoutMap: ComponentRecordType = { BasicLayout, diff --git a/apps/web-antdv-next/src/router/routes/index.ts b/apps/web-antdv-next/src/router/routes/index.ts index 0d2e376..7960c38 100644 --- a/apps/web-antdv-next/src/router/routes/index.ts +++ b/apps/web-antdv-next/src/router/routes/index.ts @@ -35,14 +35,15 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name); /** 有权限校验的路由列表,包含动态路由和静态路由 */ const accessRoutes = [...dynamicRoutes, ...staticRoutes]; -const componentKeys: string[] = Object.keys( - import.meta.glob('../../views/**/*.vue'), -) +const componentKeys: string[] = Object.keys({ + ...import.meta.glob('../../views/**/*.tsx'), + ...import.meta.glob('../../views/**/*.vue'), +}) .filter((item) => !item.includes('/modules/')) .map((v) => { const path = v.replace('../../views/', '/'); - return path.endsWith('.vue') ? path.slice(0, -4) : path; + return path.replace(/\.(tsx|vue)$/, ''); }) - .filter((path) => path.startsWith('/system/')); + .filter((path) => path.startsWith('/blog/') || path.startsWith('/system/')); export { accessRoutes, componentKeys, coreRouteNames, routes }; diff --git a/apps/web-antdv-next/src/router/routes/modules/blog.ts b/apps/web-antdv-next/src/router/routes/modules/blog.ts new file mode 100644 index 0000000..f30a5e5 --- /dev/null +++ b/apps/web-antdv-next/src/router/routes/modules/blog.ts @@ -0,0 +1,45 @@ +import type { RouteRecordRaw } from 'vue-router'; + +const routes: RouteRecordRaw[] = [ + { + meta: { + icon: 'lucide:newspaper', + order: 100, + title: '博客管理', + }, + name: 'Blog', + path: '/blog', + redirect: '/blog/article', + children: [ + { + component: () => import('#/views/blog/article/list'), + meta: { + icon: 'lucide:file-text', + title: '文章管理', + }, + name: 'BlogArticle', + path: '/blog/article', + }, + { + component: () => import('#/views/blog/category/list'), + meta: { + icon: 'lucide:folder-tree', + title: '分类管理', + }, + name: 'BlogCategory', + path: '/blog/category', + }, + { + component: () => import('#/views/blog/tag/list'), + meta: { + icon: 'lucide:tags', + title: '标签管理', + }, + name: 'BlogTag', + path: '/blog/tag', + }, + ], + }, +]; + +export default routes; diff --git a/apps/web-antdv-next/src/views/blog/article/list.tsx b/apps/web-antdv-next/src/views/blog/article/list.tsx new file mode 100644 index 0000000..6907876 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/article/list.tsx @@ -0,0 +1,581 @@ +import type { WordpressBlogApi } from '#/api/blog'; + +import { computed, defineComponent, onMounted, reactive, ref } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; + +import { useAccess } from '@vben/access'; +import { Page } from '@vben/common-ui'; +import { Plus, RotateCw } from '@vben/icons'; + +import { + Button, + Form, + FormItem, + Input, + message, + Modal, + Select, + Space, + Switch, + Table, + Tag, + TextArea, +} from 'antdv-next'; + +import { + createArticle, + deleteArticle, + getArticleList, + getCategoryList, + getTagList, + updateArticle, +} from '#/api/blog'; + +type TermOption = { + label: string; + value: number; +}; + +const AButton = Button as any; +const AInput = Input as any; +const AModal = Modal as any; +const ASelect = Select as any; +const ASwitch = Switch as any; +const ATable = Table as any; +const ATextArea = TextArea as any; + +export default defineComponent({ + name: 'BlogArticleList', + setup() { + const route = useRoute(); + const router = useRouter(); + const { hasAccessByCodes } = useAccess(); + + const articleStatusOptions = [ + { color: 'success', label: '已发布', value: 'publish' }, + { color: 'default', label: '草稿', value: 'draft' }, + { color: 'warning', label: '待审核', value: 'pending' }, + { color: 'processing', label: '私有', value: 'private' }, + ]; + + const loading = ref(false); + const saving = ref(false); + const modalOpen = ref(false); + const rows = ref([]); + const total = ref(0); + const editingId = ref(); + const categoryOptions = ref([]); + const tagOptions = ref([]); + + const query = reactive({ + categoryId: undefined as number | undefined, + pageNo: 1, + pageSize: 10, + search: '', + status: undefined as string | undefined, + tagId: undefined as number | undefined, + }); + + const form = reactive({ + categories: [], + content: '', + excerpt: '', + slug: '', + status: 'draft', + sticky: false, + tags: [], + title: '', + }); + + const modalTitle = computed(() => + editingId.value ? '编辑文章' : '新建文章', + ); + const canCreate = computed(() => hasAccessByCodes(['Blog:Article:Create'])); + const canEdit = computed(() => hasAccessByCodes(['Blog:Article:Edit'])); + const canDelete = computed(() => hasAccessByCodes(['Blog:Article:Delete'])); + const canOperate = computed(() => canEdit.value || canDelete.value); + const columns = computed(() => { + const baseColumns = [ + { dataIndex: 'title', key: 'title', title: '标题' }, + { 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, + }, + ]; + + return canOperate.value + ? [...baseColumns, { key: 'action', title: '操作', width: 150 }] + : baseColumns; + }); + + function getRenderedText(value?: string | WordpressBlogApi.RenderedField) { + if (!value) return ''; + if (typeof value === 'string') return stripHtml(value); + return stripHtml(value.raw || value.rendered || ''); + } + + function stripHtml(value: string) { + return value + .replaceAll(/<[^>]+>/g, '') + .replaceAll(' ', ' ') + .trim(); + } + + function getStatusOption(status?: string) { + return ( + articleStatusOptions.find((item) => item.value === status) || + articleStatusOptions[1] + ); + } + + function getTermLabel(options: TermOption[], value: number) { + return options.find((item) => item.value === value)?.label || `${value}`; + } + + function getRouteNumber(name: 'category' | 'tag') { + const value = route.query[name]; + const rawValue = Array.isArray(value) ? value[0] : value; + const id = Number(rawValue); + + return Number.isFinite(id) && id > 0 ? id : undefined; + } + + function readRouteFilters() { + query.categoryId = getRouteNumber('category'); + query.tagId = getRouteNumber('tag'); + } + + function syncRouteFilters() { + const nextQuery = { ...route.query }; + + if (query.categoryId) { + nextQuery.category = `${query.categoryId}`; + } else { + delete nextQuery.category; + } + + if (query.tagId) { + nextQuery.tag = `${query.tagId}`; + } else { + delete nextQuery.tag; + } + + return router.replace({ + name: 'BlogArticle', + query: nextQuery, + }); + } + + 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 }), + ]); + categoryOptions.value = categories.list.map((item) => ({ + label: item.name, + value: item.id, + })); + tagOptions.value = tags.list.map((item) => ({ + label: item.name, + value: item.id, + })); + } + + async function loadArticles() { + loading.value = true; + try { + const result = await getArticleList({ + categories: query.categoryId ? `${query.categoryId}` : undefined, + pageNo: query.pageNo, + pageSize: query.pageSize, + search: query.search, + status: query.status || undefined, + tags: query.tagId ? `${query.tagId}` : undefined, + }); + rows.value = result.list; + total.value = result.total; + } finally { + loading.value = false; + } + } + + async function searchArticles() { + query.pageNo = 1; + await syncRouteFilters(); + await loadArticles(); + } + + function resetSearch() { + query.categoryId = undefined; + query.search = ''; + query.status = undefined; + query.tagId = undefined; + query.pageNo = 1; + syncRouteFilters(); + loadArticles(); + } + + async function filterByCategory(id: number) { + query.categoryId = id; + query.pageNo = 1; + await syncRouteFilters(); + await loadArticles(); + } + + async function filterByTag(id: number) { + query.tagId = id; + query.pageNo = 1; + await syncRouteFilters(); + await loadArticles(); + } + + function openCreate() { + editingId.value = undefined; + Object.assign(form, { + categories: [], + content: '', + excerpt: '', + slug: '', + status: 'draft', + sticky: false, + tags: [], + title: '', + }); + modalOpen.value = true; + } + + function openEdit(row: Record | WordpressBlogApi.Article) { + const article = row as WordpressBlogApi.Article; + editingId.value = article.id; + Object.assign(form, { + categories: article.categories || [], + content: getRenderedText(article.content), + excerpt: getRenderedText(article.excerpt), + id: article.id, + slug: article.slug || '', + status: article.status || 'draft', + sticky: !!article.sticky, + tags: article.tags || [], + title: getRenderedText(article.title), + }); + modalOpen.value = true; + } + + async function submitArticle() { + if (!form.title.trim()) { + message.warning('请填写文章标题'); + return; + } + saving.value = true; + try { + const payload = { + ...form, + id: editingId.value, + title: form.title.trim(), + }; + await (editingId.value + ? updateArticle(payload) + : createArticle(payload)); + message.success('文章保存成功'); + modalOpen.value = false; + loadArticles(); + } finally { + saving.value = false; + } + } + + function confirmDelete( + row: Record | WordpressBlogApi.Article, + ) { + const article = row as WordpressBlogApi.Article; + Modal.confirm({ + content: `确认删除文章「${getRenderedText(article.title) || article.id}」吗?`, + onOk: async () => { + await deleteArticle(article.id); + message.success('文章删除成功'); + loadArticles(); + }, + title: '删除文章', + }); + } + + function handleTableChange(pagination: any) { + query.pageNo = pagination.current || 1; + query.pageSize = pagination.pageSize || 10; + loadArticles(); + } + + onMounted(async () => { + readRouteFilters(); + await loadTermOptions(); + await loadArticles(); + }); + + return () => ( + +
+
+ + { + query.search = value; + }} + placeholder="搜索标题或内容" + value={query.search} + /> + { + query.status = value; + }} + options={articleStatusOptions} + placeholder="文章状态" + value={query.status} + /> + { + query.categoryId = value; + }} + options={categoryOptions.value} + placeholder="文章分类" + value={query.categoryId} + /> + { + query.tagId = value; + }} + options={tagOptions.value} + placeholder="文章标签" + value={query.tagId} + /> + 查询 + + + 重置 + + + {canCreate.value ? ( + + + 新建文章 + + ) : null} +
+ +
+ { + if (column.key === 'title') { + return ( +
+
+ {getRenderedText(record.title) || '-'} +
+ {record.link ? ( + + 查看原文 + + ) : null} +
+ ); + } + + if (column.key === 'status') { + const status = getStatusOption(record.status); + return {status?.label}; + } + + if (column.key === 'categories') { + return record.categories?.length ? ( + + {record.categories.map((item: number) => ( + filterByCategory(item)} + > + {getTermLabel(categoryOptions.value, item)} + + ))} + + ) : ( + - + ); + } + + if (column.key === 'tags') { + return record.tags?.length ? ( + + {record.tags.map((item: number) => ( + filterByTag(item)} + > + {getTermLabel(tagOptions.value, item)} + + ))} + + ) : ( + - + ); + } + + if (column.key === 'action') { + return ( + + {canEdit.value ? ( + openEdit(record)} type="link"> + 编辑 + + ) : null} + {canDelete.value ? ( + confirmDelete(record)} + type="link" + > + 删除 + + ) : null} + + ); + } + + return undefined; + }, + }} + /> +
+
+ + { + modalOpen.value = value; + }} + open={modalOpen.value} + title={modalTitle.value} + width="760px" + > +
+ + { + form.title = value; + }} + placeholder="请输入文章标题" + value={form.title} + /> + + + { + form.status = value; + }} + options={articleStatusOptions} + value={form.status} + /> + + + { + form.slug = value; + }} + placeholder="可选,WordPress slug" + value={form.slug} + /> + + + { + form.categories = value; + }} + options={categoryOptions.value} + placeholder="选择分类" + value={form.categories} + /> + + + { + form.tags = value; + }} + options={tagOptions.value} + placeholder="选择标签" + value={form.tags} + /> + + + { + form.excerpt = value; + }} + placeholder="可选,文章摘要" + value={form.excerpt} + /> + + + { + form.content = value; + }} + placeholder="支持 HTML 内容" + value={form.content} + /> + + + { + form.sticky = value; + }} + /> + +
+
+
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/views/blog/category/list.tsx b/apps/web-antdv-next/src/views/blog/category/list.tsx new file mode 100644 index 0000000..5e4badb --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/category/list.tsx @@ -0,0 +1,10 @@ +import { defineComponent } from 'vue'; + +import TermManagement from '../modules/term-management'; + +export default defineComponent({ + name: 'BlogCategoryList', + setup() { + return () => ; + }, +}); 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 new file mode 100644 index 0000000..de53300 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/modules/term-management.tsx @@ -0,0 +1,406 @@ +import type { PropType } from 'vue'; + +import type { WordpressBlogApi } from '#/api/blog'; + +import { + computed, + defineComponent, + onMounted, + reactive, + ref, + watch, +} from 'vue'; +import { useRoute, useRouter } from 'vue-router'; + +import { useAccess } from '@vben/access'; +import { Page } from '@vben/common-ui'; +import { Plus, RotateCw } from '@vben/icons'; + +import { + Button, + Form, + FormItem, + Input, + message, + Modal, + Select, + Space, + Table, + TextArea, +} from 'antdv-next'; + +import { + createCategory, + createTag, + deleteCategory, + deleteTag, + getCategoryList, + getTagList, + updateCategory, + updateTag, +} from '#/api/blog'; + +const AButton = Button as any; +const AInput = Input as any; +const AModal = Modal as any; +const ASelect = Select as any; +const ATable = Table as any; +const ATextArea = TextArea as any; + +export default defineComponent({ + name: 'BlogTermManagement', + props: { + kind: { + required: true, + type: String as PropType<'category' | 'tag'>, + }, + title: { + required: true, + type: String, + }, + }, + setup(props) { + const route = useRoute(); + const router = useRouter(); + const { hasAccessByCodes } = useAccess(); + + const loading = ref(false); + const saving = ref(false); + const modalOpen = ref(false); + const rows = ref([]); + const total = ref(0); + const editingId = ref(); + + const query = reactive({ + pageNo: 1, + pageSize: 10, + search: '', + }); + + const form = reactive({ + description: '', + name: '', + parent: undefined, + slug: '', + }); + + const modalTitle = computed(() => + editingId.value ? `编辑${props.title}` : `新建${props.title}`, + ); + const permissionModule = computed(() => + props.kind === 'category' ? 'Blog:Category' : 'Blog:Tag', + ); + const canCreate = computed(() => + hasAccessByCodes([`${permissionModule.value}:Create`]), + ); + const canEdit = computed(() => + hasAccessByCodes([`${permissionModule.value}:Edit`]), + ); + const canDelete = computed(() => + hasAccessByCodes([`${permissionModule.value}:Delete`]), + ); + const canViewArticles = computed(() => + hasAccessByCodes(['Blog:Article:List']), + ); + const canOperate = computed( + () => canViewArticles.value || canEdit.value || canDelete.value, + ); + const columns = computed(() => { + const baseColumns = [ + { dataIndex: 'name', key: 'name', title: '名称' }, + { dataIndex: 'slug', key: 'slug', title: '别名', width: 180 }, + { dataIndex: 'count', key: 'count', title: '文章数', width: 100 }, + { dataIndex: 'description', key: 'description', title: '描述' }, + ]; + + return canOperate.value + ? [...baseColumns, { key: 'action', title: '操作', width: 220 }] + : baseColumns; + }); + + const parentOptions = computed(() => + rows.value + .filter((item) => item.id !== editingId.value) + .map((item) => ({ label: item.name, value: item.id })), + ); + + function getRouteSearch() { + const value = route.query.search; + return Array.isArray(value) ? value[0] || '' : value || ''; + } + + async function requestList() { + const params = { + hide_empty: false, + pageNo: query.pageNo, + pageSize: query.pageSize, + search: query.search, + }; + return props.kind === 'category' + ? await getCategoryList(params) + : await getTagList(params); + } + + async function loadTerms() { + loading.value = true; + try { + const result = await requestList(); + rows.value = result.list; + total.value = result.total; + } finally { + loading.value = false; + } + } + + function resetSearch() { + query.search = ''; + query.pageNo = 1; + loadTerms(); + } + + function openCreate() { + editingId.value = undefined; + Object.assign(form, { + description: '', + name: '', + parent: undefined, + slug: '', + }); + modalOpen.value = true; + } + + function openEdit(row: Record | WordpressBlogApi.Term) { + const term = row as WordpressBlogApi.Term; + editingId.value = term.id; + Object.assign(form, { + description: term.description || '', + id: term.id, + name: term.name, + parent: term.parent || undefined, + slug: term.slug || '', + }); + modalOpen.value = true; + } + + async function submitTerm() { + if (!form.name.trim()) { + message.warning(`请填写${props.title}名称`); + return; + } + saving.value = true; + try { + const payload = { + ...form, + id: editingId.value, + name: form.name.trim(), + }; + if (props.kind === 'category') { + await (editingId.value + ? updateCategory(payload) + : createCategory(payload)); + } else { + await (editingId.value ? updateTag(payload) : createTag(payload)); + } + message.success(`${props.title}保存成功`); + modalOpen.value = false; + loadTerms(); + } finally { + saving.value = false; + } + } + + function confirmDelete(row: Record | WordpressBlogApi.Term) { + const term = row as WordpressBlogApi.Term; + Modal.confirm({ + content: `确认删除${props.title}「${term.name}」吗?`, + onOk: async () => { + await (props.kind === 'category' + ? deleteCategory(term.id) + : deleteTag(term.id)); + message.success(`${props.title}删除成功`); + loadTerms(); + }, + title: `删除${props.title}`, + }); + } + + function openRelatedArticles( + row: Record | WordpressBlogApi.Term, + ) { + const term = row as WordpressBlogApi.Term; + router.push({ + name: 'BlogArticle', + query: + props.kind === 'category' + ? { category: `${term.id}` } + : { tag: `${term.id}` }, + }); + } + + function handleTableChange(pagination: any) { + query.pageNo = pagination.current || 1; + query.pageSize = pagination.pageSize || 10; + loadTerms(); + } + + watch( + () => props.kind, + () => { + query.search = getRouteSearch(); + query.pageNo = 1; + loadTerms(); + }, + ); + + onMounted(() => { + query.search = getRouteSearch(); + loadTerms(); + }); + + return () => ( + +
+
+ + { + query.search = value; + }} + placeholder={`搜索${props.title}名称`} + value={query.search} + /> + 查询 + + + 重置 + + + {canCreate.value ? ( + + + 新建{props.title} + + ) : null} +
+ +
+ { + if (column.key === 'description') { + return ( + + {record.description || '-'} + + ); + } + + if (column.key === 'action') { + return ( + + {canViewArticles.value ? ( + openRelatedArticles(record)} + type="link" + > + 查看文章 + + ) : null} + {canEdit.value ? ( + openEdit(record)} type="link"> + 编辑 + + ) : null} + {canDelete.value ? ( + confirmDelete(record)} + type="link" + > + 删除 + + ) : null} + + ); + } + + return undefined; + }, + }} + /> +
+
+ + { + modalOpen.value = value; + }} + open={modalOpen.value} + title={modalTitle.value} + width="620px" + > +
+ + { + form.name = value; + }} + placeholder={`请输入${props.title}名称`} + value={form.name} + /> + + + { + form.slug = value; + }} + placeholder="可选,WordPress slug" + value={form.slug} + /> + + {props.kind === 'category' ? ( + + { + form.parent = value; + }} + options={parentOptions.value} + placeholder="选择父级分类" + value={form.parent} + /> + + ) : null} + + { + form.description = value; + }} + placeholder="可选" + value={form.description} + /> + +
+
+
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/views/blog/tag/list.tsx b/apps/web-antdv-next/src/views/blog/tag/list.tsx new file mode 100644 index 0000000..9bde974 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/tag/list.tsx @@ -0,0 +1,10 @@ +import { defineComponent } from 'vue'; + +import TermManagement from '../modules/term-management'; + +export default defineComponent({ + name: 'BlogTagList', + setup() { + return () => ; + }, +}); diff --git a/apps/web-antdv-next/src/views/system/dept/data.ts b/apps/web-antdv-next/src/views/system/dept/data.ts index 4877362..d2ad728 100644 --- a/apps/web-antdv-next/src/views/system/dept/data.ts +++ b/apps/web-antdv-next/src/views/system/dept/data.ts @@ -8,6 +8,10 @@ import { z } from '#/adapter/form'; import { getDeptList } from '#/api/system/dept'; import { $t } from '#/locales'; +type PermissionOptions = { + canAccess?: (code: string) => boolean; +}; + /** * 获取编辑表单的字段配置。如果没有使用多语言,可以直接export一个数组常量 */ @@ -76,7 +80,10 @@ export function useSchema(): VbenFormSchema[] { */ export function useColumns( onActionClick?: OnActionClickFn, + options: PermissionOptions = {}, ): VxeTableGridOptions['columns'] { + const canAccess = options.canAccess || (() => true); + return [ { align: 'left', @@ -113,14 +120,19 @@ export function useColumns( options: [ { code: 'append', + show: () => canAccess('System:Dept:Create'), text: '新增下级', }, - 'edit', // 默认的编辑按钮 + { + code: 'edit', + show: () => canAccess('System:Dept:Edit'), + }, { code: 'delete', // 默认的删除按钮 disabled: (row: SystemDeptApi.SystemDept) => { return !!(row.children && row.children.length > 0); }, + show: () => canAccess('System:Dept:Delete'), }, ], }, diff --git a/apps/web-antdv-next/src/views/system/dept/list.vue b/apps/web-antdv-next/src/views/system/dept/list.vue index f98099d..dea1920 100644 --- a/apps/web-antdv-next/src/views/system/dept/list.vue +++ b/apps/web-antdv-next/src/views/system/dept/list.vue @@ -5,6 +5,7 @@ import type { } from '#/adapter/vxe-table'; import type { SystemDeptApi } from '#/api/system/dept'; +import { useAccess } from '@vben/access'; import { Page, useVbenModal } from '@vben/common-ui'; import { Plus } from '@vben/icons'; @@ -22,6 +23,12 @@ const [FormModal, formModalApi] = useVbenModal({ destroyOnClose: true, }); +const { hasAccessByCodes } = useAccess(); + +function hasPermission(code: string) { + return hasAccessByCodes([code]); +} + /** * 编辑部门 * @param row @@ -94,7 +101,7 @@ function onActionClick({ const [Grid, gridApi] = useVbenVxeGrid({ gridEvents: {}, gridOptions: { - columns: useColumns(onActionClick), + columns: useColumns(onActionClick, { canAccess: hasPermission }), height: 'auto', keepSource: true, pagerConfig: { @@ -133,7 +140,11 @@ function refreshGrid() {