mirror of
https://github.com/KwiTsukasa/kt-template-admin.git
synced 2026-05-27 16:35:47 +08:00
feat(admin): 接入博客管理和按钮权限
This commit is contained in:
parent
4b4e6de601
commit
ce95d93c2b
1
apps/web-antdv-next/src/api/blog/index.ts
Normal file
1
apps/web-antdv-next/src/api/blog/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './wordpress';
|
||||
151
apps/web-antdv-next/src/api/blog/wordpress.ts
Normal file
151
apps/web-antdv-next/src/api/blog/wordpress.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace WordpressBlogApi {
|
||||
export interface PageResult<T> {
|
||||
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<any> {
|
||||
hide_empty?: boolean;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
parent?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function getArticleList(params: Recordable<any>) {
|
||||
return requestClient.get<
|
||||
WordpressBlogApi.PageResult<WordpressBlogApi.Article>
|
||||
>('/wordpress/article/list', { params });
|
||||
}
|
||||
|
||||
export function getArticleDetail(id: number | string) {
|
||||
return requestClient.get<WordpressBlogApi.Article>(
|
||||
'/wordpress/article/detail',
|
||||
{ params: { id } },
|
||||
);
|
||||
}
|
||||
|
||||
export function createArticle(data: WordpressBlogApi.ArticleBody) {
|
||||
return requestClient.post<WordpressBlogApi.Article>(
|
||||
'/wordpress/article/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateArticle(data: WordpressBlogApi.ArticleBody) {
|
||||
return requestClient.post<WordpressBlogApi.Article>(
|
||||
'/wordpress/article/update',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteArticle(id: number | string, force = true) {
|
||||
return requestClient.post<WordpressBlogApi.Article>(
|
||||
`/wordpress/article/remove?id=${id}&force=${force}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCategoryList(params: WordpressBlogApi.TermQuery = {}) {
|
||||
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
|
||||
'/wordpress/category/list',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
export function createCategory(data: WordpressBlogApi.TermBody) {
|
||||
return requestClient.post<WordpressBlogApi.Term>(
|
||||
'/wordpress/category/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCategory(data: WordpressBlogApi.TermBody) {
|
||||
return requestClient.post<WordpressBlogApi.Term>(
|
||||
'/wordpress/category/update',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCategory(id: number | string, force = true) {
|
||||
return requestClient.post<WordpressBlogApi.Term>(
|
||||
`/wordpress/category/remove?id=${id}&force=${force}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTagList(params: WordpressBlogApi.TermQuery = {}) {
|
||||
return requestClient.get<WordpressBlogApi.PageResult<WordpressBlogApi.Term>>(
|
||||
'/wordpress/tag/list',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
export function createTag(data: WordpressBlogApi.TermBody) {
|
||||
return requestClient.post<WordpressBlogApi.Term>('/wordpress/tag/save', data);
|
||||
}
|
||||
|
||||
export function updateTag(data: WordpressBlogApi.TermBody) {
|
||||
return requestClient.post<WordpressBlogApi.Term>(
|
||||
'/wordpress/tag/update',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteTag(id: number | string, force = true) {
|
||||
return requestClient.post<WordpressBlogApi.Term>(
|
||||
`/wordpress/tag/remove?id=${id}&force=${force}`,
|
||||
);
|
||||
}
|
||||
@ -10,9 +10,15 @@ export namespace AuthApi {
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
wordpressAvailable?: boolean;
|
||||
wordpressAuth?: WordpressAuthResult['auth'] & {
|
||||
user?: Record<string, any>;
|
||||
};
|
||||
wordpressError?: null | {
|
||||
error?: any;
|
||||
message?: string;
|
||||
status?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RefreshTokenResult {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './blog';
|
||||
export * from './core';
|
||||
export * from './examples';
|
||||
export * from './system';
|
||||
|
||||
40
apps/web-antdv-next/src/app.tsx
Normal file
40
apps/web-antdv-next/src/app.tsx
Normal file
@ -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 () => (
|
||||
<ConfigProvider locale={antdLocale.value} theme={tokenTheme.value}>
|
||||
<AntdApp>
|
||||
<RouterView />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -1,39 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'antdv-next';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||
<App>
|
||||
<RouterView />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
45
apps/web-antdv-next/src/router/routes/modules/blog.ts
Normal file
45
apps/web-antdv-next/src/router/routes/modules/blog.ts
Normal file
@ -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;
|
||||
581
apps/web-antdv-next/src/views/blog/article/list.tsx
Normal file
581
apps/web-antdv-next/src/views/blog/article/list.tsx
Normal file
@ -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<WordpressBlogApi.Article[]>([]);
|
||||
const total = ref(0);
|
||||
const editingId = ref<number>();
|
||||
const categoryOptions = ref<TermOption[]>([]);
|
||||
const tagOptions = ref<TermOption[]>([]);
|
||||
|
||||
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<WordpressBlogApi.ArticleBody>({
|
||||
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<string, any> | 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<string, any> | 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 () => (
|
||||
<Page autoContentHeight>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 bg-card px-4 py-3">
|
||||
<Space wrap>
|
||||
<AInput
|
||||
allowClear
|
||||
class="w-[260px]"
|
||||
onPressEnter={searchArticles}
|
||||
onUpdate:value={(value: string) => {
|
||||
query.search = value;
|
||||
}}
|
||||
placeholder="搜索标题或内容"
|
||||
value={query.search}
|
||||
/>
|
||||
<ASelect
|
||||
allowClear
|
||||
class="w-[150px]"
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
query.status = value;
|
||||
}}
|
||||
options={articleStatusOptions}
|
||||
placeholder="文章状态"
|
||||
value={query.status}
|
||||
/>
|
||||
<ASelect
|
||||
allowClear
|
||||
class="w-[150px]"
|
||||
onUpdate:value={(value: number | undefined) => {
|
||||
query.categoryId = value;
|
||||
}}
|
||||
options={categoryOptions.value}
|
||||
placeholder="文章分类"
|
||||
value={query.categoryId}
|
||||
/>
|
||||
<ASelect
|
||||
allowClear
|
||||
class="w-[150px]"
|
||||
onUpdate:value={(value: number | undefined) => {
|
||||
query.tagId = value;
|
||||
}}
|
||||
options={tagOptions.value}
|
||||
placeholder="文章标签"
|
||||
value={query.tagId}
|
||||
/>
|
||||
<AButton onClick={searchArticles}>查询</AButton>
|
||||
<AButton onClick={resetSearch}>
|
||||
<RotateCw class="size-4" />
|
||||
重置
|
||||
</AButton>
|
||||
</Space>
|
||||
{canCreate.value ? (
|
||||
<AButton onClick={openCreate} type="primary">
|
||||
<Plus class="size-4" />
|
||||
新建文章
|
||||
</AButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 bg-card p-4">
|
||||
<ATable
|
||||
columns={columns.value}
|
||||
dataSource={rows.value}
|
||||
loading={loading.value}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: query.pageNo,
|
||||
pageSize: query.pageSize,
|
||||
showSizeChanger: true,
|
||||
total: total.value,
|
||||
}}
|
||||
rowKey="id"
|
||||
scroll={{ x: 980 }}
|
||||
v-slots={{
|
||||
bodyCell: ({ column, record }: any) => {
|
||||
if (column.key === 'title') {
|
||||
return (
|
||||
<div class="max-w-[420px]">
|
||||
<div class="truncate font-medium">
|
||||
{getRenderedText(record.title) || '-'}
|
||||
</div>
|
||||
{record.link ? (
|
||||
<a
|
||||
class="text-xs text-primary"
|
||||
href={record.link}
|
||||
target="_blank"
|
||||
>
|
||||
查看原文
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === 'status') {
|
||||
const status = getStatusOption(record.status);
|
||||
return <Tag color={status?.color}>{status?.label}</Tag>;
|
||||
}
|
||||
|
||||
if (column.key === 'categories') {
|
||||
return record.categories?.length ? (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{record.categories.map((item: number) => (
|
||||
<Tag
|
||||
class="cursor-pointer"
|
||||
color="blue"
|
||||
key={item}
|
||||
onClick={() => filterByCategory(item)}
|
||||
>
|
||||
{getTermLabel(categoryOptions.value, item)}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === 'tags') {
|
||||
return record.tags?.length ? (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{record.tags.map((item: number) => (
|
||||
<Tag
|
||||
class="cursor-pointer"
|
||||
key={item}
|
||||
onClick={() => filterByTag(item)}
|
||||
>
|
||||
{getTermLabel(tagOptions.value, item)}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === 'action') {
|
||||
return (
|
||||
<Space>
|
||||
{canEdit.value ? (
|
||||
<AButton onClick={() => openEdit(record)} type="link">
|
||||
编辑
|
||||
</AButton>
|
||||
) : null}
|
||||
{canDelete.value ? (
|
||||
<AButton
|
||||
danger
|
||||
onClick={() => confirmDelete(record)}
|
||||
type="link"
|
||||
>
|
||||
删除
|
||||
</AButton>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AModal
|
||||
confirmLoading={saving.value}
|
||||
onOk={submitArticle}
|
||||
onUpdate:open={(value: boolean) => {
|
||||
modalOpen.value = value;
|
||||
}}
|
||||
open={modalOpen.value}
|
||||
title={modalTitle.value}
|
||||
width="760px"
|
||||
>
|
||||
<Form labelCol={{ span: 4 }} model={form} wrapperCol={{ span: 19 }}>
|
||||
<FormItem label="标题" required>
|
||||
<AInput
|
||||
onUpdate:value={(value: string) => {
|
||||
form.title = value;
|
||||
}}
|
||||
placeholder="请输入文章标题"
|
||||
value={form.title}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<ASelect
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
form.status = value;
|
||||
}}
|
||||
options={articleStatusOptions}
|
||||
value={form.status}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="别名">
|
||||
<AInput
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
form.slug = value;
|
||||
}}
|
||||
placeholder="可选,WordPress slug"
|
||||
value={form.slug}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="分类">
|
||||
<ASelect
|
||||
mode="multiple"
|
||||
onUpdate:value={(value: number[] | undefined) => {
|
||||
form.categories = value;
|
||||
}}
|
||||
options={categoryOptions.value}
|
||||
placeholder="选择分类"
|
||||
value={form.categories}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="标签">
|
||||
<ASelect
|
||||
mode="multiple"
|
||||
onUpdate:value={(value: number[] | undefined) => {
|
||||
form.tags = value;
|
||||
}}
|
||||
options={tagOptions.value}
|
||||
placeholder="选择标签"
|
||||
value={form.tags}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="摘要">
|
||||
<ATextArea
|
||||
autoSize={{ maxRows: 4, minRows: 2 }}
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
form.excerpt = value;
|
||||
}}
|
||||
placeholder="可选,文章摘要"
|
||||
value={form.excerpt}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="内容">
|
||||
<ATextArea
|
||||
autoSize={{ maxRows: 12, minRows: 6 }}
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
form.content = value;
|
||||
}}
|
||||
placeholder="支持 HTML 内容"
|
||||
value={form.content}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="置顶">
|
||||
<ASwitch
|
||||
checked={form.sticky}
|
||||
onUpdate:checked={(value: boolean) => {
|
||||
form.sticky = value;
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</AModal>
|
||||
</Page>
|
||||
);
|
||||
},
|
||||
});
|
||||
10
apps/web-antdv-next/src/views/blog/category/list.tsx
Normal file
10
apps/web-antdv-next/src/views/blog/category/list.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import TermManagement from '../modules/term-management';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogCategoryList',
|
||||
setup() {
|
||||
return () => <TermManagement kind="category" title="分类" />;
|
||||
},
|
||||
});
|
||||
406
apps/web-antdv-next/src/views/blog/modules/term-management.tsx
Normal file
406
apps/web-antdv-next/src/views/blog/modules/term-management.tsx
Normal file
@ -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<WordpressBlogApi.Term[]>([]);
|
||||
const total = ref(0);
|
||||
const editingId = ref<number>();
|
||||
|
||||
const query = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
});
|
||||
|
||||
const form = reactive<WordpressBlogApi.TermBody>({
|
||||
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<string, any> | 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<string, any> | 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<string, any> | 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 () => (
|
||||
<Page autoContentHeight>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 bg-card px-4 py-3">
|
||||
<Space wrap>
|
||||
<AInput
|
||||
allowClear
|
||||
class="w-[260px]"
|
||||
onPressEnter={loadTerms}
|
||||
onUpdate:value={(value: string) => {
|
||||
query.search = value;
|
||||
}}
|
||||
placeholder={`搜索${props.title}名称`}
|
||||
value={query.search}
|
||||
/>
|
||||
<AButton onClick={loadTerms}>查询</AButton>
|
||||
<AButton onClick={resetSearch}>
|
||||
<RotateCw class="size-4" />
|
||||
重置
|
||||
</AButton>
|
||||
</Space>
|
||||
{canCreate.value ? (
|
||||
<AButton onClick={openCreate} type="primary">
|
||||
<Plus class="size-4" />
|
||||
新建{props.title}
|
||||
</AButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 bg-card p-4">
|
||||
<ATable
|
||||
columns={columns.value}
|
||||
dataSource={rows.value}
|
||||
loading={loading.value}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: query.pageNo,
|
||||
pageSize: query.pageSize,
|
||||
showSizeChanger: true,
|
||||
total: total.value,
|
||||
}}
|
||||
rowKey="id"
|
||||
scroll={{ x: 820 }}
|
||||
v-slots={{
|
||||
bodyCell: ({ column, record }: any) => {
|
||||
if (column.key === 'description') {
|
||||
return (
|
||||
<span class="line-clamp-2">
|
||||
{record.description || '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === 'action') {
|
||||
return (
|
||||
<Space>
|
||||
{canViewArticles.value ? (
|
||||
<AButton
|
||||
onClick={() => openRelatedArticles(record)}
|
||||
type="link"
|
||||
>
|
||||
查看文章
|
||||
</AButton>
|
||||
) : null}
|
||||
{canEdit.value ? (
|
||||
<AButton onClick={() => openEdit(record)} type="link">
|
||||
编辑
|
||||
</AButton>
|
||||
) : null}
|
||||
{canDelete.value ? (
|
||||
<AButton
|
||||
danger
|
||||
onClick={() => confirmDelete(record)}
|
||||
type="link"
|
||||
>
|
||||
删除
|
||||
</AButton>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AModal
|
||||
confirmLoading={saving.value}
|
||||
onOk={submitTerm}
|
||||
onUpdate:open={(value: boolean) => {
|
||||
modalOpen.value = value;
|
||||
}}
|
||||
open={modalOpen.value}
|
||||
title={modalTitle.value}
|
||||
width="620px"
|
||||
>
|
||||
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
|
||||
<FormItem label="名称" required>
|
||||
<AInput
|
||||
onUpdate:value={(value: string) => {
|
||||
form.name = value;
|
||||
}}
|
||||
placeholder={`请输入${props.title}名称`}
|
||||
value={form.name}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="别名">
|
||||
<AInput
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
form.slug = value;
|
||||
}}
|
||||
placeholder="可选,WordPress slug"
|
||||
value={form.slug}
|
||||
/>
|
||||
</FormItem>
|
||||
{props.kind === 'category' ? (
|
||||
<FormItem label="父级分类">
|
||||
<ASelect
|
||||
allowClear
|
||||
onUpdate:value={(value: number | undefined) => {
|
||||
form.parent = value;
|
||||
}}
|
||||
options={parentOptions.value}
|
||||
placeholder="选择父级分类"
|
||||
value={form.parent}
|
||||
/>
|
||||
</FormItem>
|
||||
) : null}
|
||||
<FormItem label="描述">
|
||||
<ATextArea
|
||||
autoSize={{ maxRows: 6, minRows: 3 }}
|
||||
onUpdate:value={(value: string | undefined) => {
|
||||
form.description = value;
|
||||
}}
|
||||
placeholder="可选"
|
||||
value={form.description}
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</AModal>
|
||||
</Page>
|
||||
);
|
||||
},
|
||||
});
|
||||
10
apps/web-antdv-next/src/views/blog/tag/list.tsx
Normal file
10
apps/web-antdv-next/src/views/blog/tag/list.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import TermManagement from '../modules/term-management';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogTagList',
|
||||
setup() {
|
||||
return () => <TermManagement kind="tag" title="标签" />;
|
||||
},
|
||||
});
|
||||
@ -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<SystemDeptApi.SystemDept>,
|
||||
options: PermissionOptions = {},
|
||||
): VxeTableGridOptions<SystemDeptApi.SystemDept>['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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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() {
|
||||
<FormModal @success="refreshGrid" />
|
||||
<Grid table-title="部门列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Button
|
||||
v-if="hasPermission('System:Dept:Create')"
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
|
||||
</Button>
|
||||
|
||||
@ -3,6 +3,10 @@ import type { SystemMenuApi } from '#/api/system/menu';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
type PermissionOptions = {
|
||||
canAccess?: (code: string) => boolean;
|
||||
};
|
||||
|
||||
export function getMenuTypeOptions() {
|
||||
return [
|
||||
{
|
||||
@ -23,7 +27,10 @@ export function getMenuTypeOptions() {
|
||||
|
||||
export function useColumns(
|
||||
onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>,
|
||||
options: PermissionOptions = {},
|
||||
): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] {
|
||||
const canAccess = options.canAccess || (() => true);
|
||||
|
||||
return [
|
||||
{
|
||||
align: 'left',
|
||||
@ -92,10 +99,17 @@ export function useColumns(
|
||||
options: [
|
||||
{
|
||||
code: 'append',
|
||||
show: () => canAccess('System:Menu:Create'),
|
||||
text: '新增下级',
|
||||
},
|
||||
'edit', // 默认的编辑按钮
|
||||
'delete', // 默认的删除按钮
|
||||
{
|
||||
code: 'edit',
|
||||
show: () => canAccess('System:Menu:Edit'),
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
show: () => canAccess('System:Menu:Delete'),
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon, Plus } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
@ -23,9 +24,15 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
function hasPermission(code: string) {
|
||||
return hasAccessByCodes([code]);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
columns: useColumns(onActionClick, { canAccess: hasPermission }),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
@ -115,7 +122,11 @@ function onDelete(row: SystemMenuApi.SystemMenu) {
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid>
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Button
|
||||
v-if="hasPermission('System:Menu:Create')"
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }}
|
||||
</Button>
|
||||
|
||||
@ -4,6 +4,10 @@ import type { SystemRoleApi } from '#/api';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
type PermissionOptions = {
|
||||
canAccess?: (code: string) => boolean;
|
||||
};
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
@ -77,7 +81,10 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
export function useColumns<T = SystemRoleApi.SystemRole>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
|
||||
options: PermissionOptions = {},
|
||||
): VxeTableGridOptions['columns'] {
|
||||
const canAccess = options.canAccess || (() => true);
|
||||
|
||||
return [
|
||||
{
|
||||
field: 'name',
|
||||
@ -117,6 +124,16 @@ export function useColumns<T = SystemRoleApi.SystemRole>(
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'edit',
|
||||
show: () => canAccess('System:Role:Edit'),
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
show: () => canAccess('System:Role:Delete'),
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemRoleApi } from '#/api';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
@ -24,6 +25,12 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
function hasPermission(code: string) {
|
||||
return hasAccessByCodes([code]);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
|
||||
@ -31,7 +38,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick, onStatusChange),
|
||||
columns: useColumns(
|
||||
onActionClick,
|
||||
hasPermission('System:Role:Edit') ? onStatusChange : undefined,
|
||||
{ canAccess: hasPermission },
|
||||
),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
@ -154,7 +165,11 @@ function onCreate() {
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid :table-title="$t('system.role.list')">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Button
|
||||
v-if="hasPermission('System:Role:Create')"
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
|
||||
</Button>
|
||||
|
||||
@ -78,13 +78,13 @@ function convertRoutes(
|
||||
// 页面组件转换
|
||||
} else if (component) {
|
||||
const normalizePath = normalizeViewPath(component);
|
||||
const pageKey = normalizePath.endsWith('.vue')
|
||||
? normalizePath
|
||||
: `${normalizePath}.vue`;
|
||||
if (pageMap[pageKey]) {
|
||||
const pageKeys = getPageKeys(normalizePath);
|
||||
const pageKey = pageKeys.find((key) => pageMap[key]);
|
||||
|
||||
if (pageKey) {
|
||||
route.component = pageMap[pageKey];
|
||||
} else {
|
||||
console.error(`route component is invalid: ${pageKey}`, route);
|
||||
console.error(`route component is invalid: ${pageKeys[0]}`, route);
|
||||
route.component = pageMap['/_core/fallback/not-found.vue'];
|
||||
}
|
||||
}
|
||||
@ -105,4 +105,11 @@ function normalizeViewPath(path: string): string {
|
||||
// 这里耦合了vben-admin的目录结构
|
||||
return viewPath.replace(/^\/views/, '');
|
||||
}
|
||||
|
||||
function getPageKeys(path: string): string[] {
|
||||
if (/\.(tsx|vue)$/.test(path)) return [path];
|
||||
|
||||
return [`${path}.tsx`, `${path}.vue`];
|
||||
}
|
||||
|
||||
export { generateRoutesByBackend };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user