feat(admin): 接入博客管理和按钮权限

This commit is contained in:
sunlei 2026-05-18 20:05:25 +08:00
parent 4b4e6de601
commit ce95d93c2b
22 changed files with 1376 additions and 60 deletions

View File

@ -0,0 +1 @@
export * from './wordpress';

View 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}`,
);
}

View File

@ -10,9 +10,15 @@ export namespace AuthApi {
/** 登录接口返回值 */ /** 登录接口返回值 */
export interface LoginResult { export interface LoginResult {
accessToken: string; accessToken: string;
wordpressAvailable?: boolean;
wordpressAuth?: WordpressAuthResult['auth'] & { wordpressAuth?: WordpressAuthResult['auth'] & {
user?: Record<string, any>; user?: Record<string, any>;
}; };
wordpressError?: null | {
error?: any;
message?: string;
status?: number;
};
} }
export interface RefreshTokenResult { export interface RefreshTokenResult {

View File

@ -3,6 +3,19 @@ import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
const SUPPORTED_ADMIN_MENU_NAMES = new Set([ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'Blog',
'BlogArticle',
'BlogArticleCreate',
'BlogArticleDelete',
'BlogArticleEdit',
'BlogCategory',
'BlogCategoryCreate',
'BlogCategoryDelete',
'BlogCategoryEdit',
'BlogTag',
'BlogTagCreate',
'BlogTagDelete',
'BlogTagEdit',
'System', 'System',
'SystemDept', 'SystemDept',
'SystemDeptCreate', 'SystemDeptCreate',

View File

@ -1,3 +1,4 @@
export * from './blog';
export * from './core'; export * from './core';
export * from './examples'; export * from './examples';
export * from './system'; export * from './system';

View 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>
);
},
});

View File

@ -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>

View File

@ -14,7 +14,7 @@ import { router } from '#/router';
import { initComponentAdapter } from './adapter/component'; import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form'; import { initSetupVbenForm } from './adapter/form';
import App from './app.vue'; import App from './app';
import { initTimezone } from './timezone-init'; import { initTimezone } from './timezone-init';
async function bootstrap(namespace: string) { async function bootstrap(namespace: string) {

View File

@ -15,7 +15,10 @@ import { $t } from '#/locales';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue'); const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
async function generateAccess(options: GenerateMenuAndRoutesOptions) { 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 = { const layoutMap: ComponentRecordType = {
BasicLayout, BasicLayout,

View File

@ -35,14 +35,15 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */ /** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes]; const accessRoutes = [...dynamicRoutes, ...staticRoutes];
const componentKeys: string[] = Object.keys( const componentKeys: string[] = Object.keys({
import.meta.glob('../../views/**/*.vue'), ...import.meta.glob('../../views/**/*.tsx'),
) ...import.meta.glob('../../views/**/*.vue'),
})
.filter((item) => !item.includes('/modules/')) .filter((item) => !item.includes('/modules/'))
.map((v) => { .map((v) => {
const path = v.replace('../../views/', '/'); 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 }; export { accessRoutes, componentKeys, coreRouteNames, routes };

View 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;

View 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('&nbsp;', ' ')
.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>
);
},
});

View 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="分类" />;
},
});

View 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>
);
},
});

View 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="标签" />;
},
});

View File

@ -8,6 +8,10 @@ import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept'; import { getDeptList } from '#/api/system/dept';
import { $t } from '#/locales'; import { $t } from '#/locales';
type PermissionOptions = {
canAccess?: (code: string) => boolean;
};
/** /**
* 使export一个数组常量 * 使export一个数组常量
*/ */
@ -76,7 +80,10 @@ export function useSchema(): VbenFormSchema[] {
*/ */
export function useColumns( export function useColumns(
onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>, onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
options: PermissionOptions = {},
): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] { ): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
const canAccess = options.canAccess || (() => true);
return [ return [
{ {
align: 'left', align: 'left',
@ -113,14 +120,19 @@ export function useColumns(
options: [ options: [
{ {
code: 'append', code: 'append',
show: () => canAccess('System:Dept:Create'),
text: '新增下级', text: '新增下级',
}, },
'edit', // 默认的编辑按钮 {
code: 'edit',
show: () => canAccess('System:Dept:Edit'),
},
{ {
code: 'delete', // 默认的删除按钮 code: 'delete', // 默认的删除按钮
disabled: (row: SystemDeptApi.SystemDept) => { disabled: (row: SystemDeptApi.SystemDept) => {
return !!(row.children && row.children.length > 0); return !!(row.children && row.children.length > 0);
}, },
show: () => canAccess('System:Dept:Delete'),
}, },
], ],
}, },

View File

@ -5,6 +5,7 @@ import type {
} from '#/adapter/vxe-table'; } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept'; import type { SystemDeptApi } from '#/api/system/dept';
import { useAccess } from '@vben/access';
import { Page, useVbenModal } from '@vben/common-ui'; import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
@ -22,6 +23,12 @@ const [FormModal, formModalApi] = useVbenModal({
destroyOnClose: true, destroyOnClose: true,
}); });
const { hasAccessByCodes } = useAccess();
function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
/** /**
* 编辑部门 * 编辑部门
* @param row * @param row
@ -94,7 +101,7 @@ function onActionClick({
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
gridEvents: {}, gridEvents: {},
gridOptions: { gridOptions: {
columns: useColumns(onActionClick), columns: useColumns(onActionClick, { canAccess: hasPermission }),
height: 'auto', height: 'auto',
keepSource: true, keepSource: true,
pagerConfig: { pagerConfig: {
@ -133,7 +140,11 @@ function refreshGrid() {
<FormModal @success="refreshGrid" /> <FormModal @success="refreshGrid" />
<Grid table-title="部门列表"> <Grid table-title="部门列表">
<template #toolbar-tools> <template #toolbar-tools>
<Button type="primary" @click="onCreate"> <Button
v-if="hasPermission('System:Dept:Create')"
type="primary"
@click="onCreate"
>
<Plus class="size-5" /> <Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }} {{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
</Button> </Button>

View File

@ -3,6 +3,10 @@ import type { SystemMenuApi } from '#/api/system/menu';
import { $t } from '#/locales'; import { $t } from '#/locales';
type PermissionOptions = {
canAccess?: (code: string) => boolean;
};
export function getMenuTypeOptions() { export function getMenuTypeOptions() {
return [ return [
{ {
@ -23,7 +27,10 @@ export function getMenuTypeOptions() {
export function useColumns( export function useColumns(
onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>, onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>,
options: PermissionOptions = {},
): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] { ): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] {
const canAccess = options.canAccess || (() => true);
return [ return [
{ {
align: 'left', align: 'left',
@ -92,10 +99,17 @@ export function useColumns(
options: [ options: [
{ {
code: 'append', code: 'append',
show: () => canAccess('System:Menu:Create'),
text: '新增下级', text: '新增下级',
}, },
'edit', // 默认的编辑按钮 {
'delete', // 默认的删除按钮 code: 'edit',
show: () => canAccess('System:Menu:Edit'),
},
{
code: 'delete',
show: () => canAccess('System:Menu:Delete'),
},
], ],
}, },
field: 'operation', field: 'operation',

View File

@ -4,6 +4,7 @@ import type {
VxeTableGridOptions, VxeTableGridOptions,
} from '#/adapter/vxe-table'; } from '#/adapter/vxe-table';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer } from '@vben/common-ui'; import { Page, useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon, Plus } from '@vben/icons'; import { IconifyIcon, Plus } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
@ -23,9 +24,15 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
destroyOnClose: true, destroyOnClose: true,
}); });
const { hasAccessByCodes } = useAccess();
function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: { gridOptions: {
columns: useColumns(onActionClick), columns: useColumns(onActionClick, { canAccess: hasPermission }),
height: 'auto', height: 'auto',
keepSource: true, keepSource: true,
pagerConfig: { pagerConfig: {
@ -115,7 +122,11 @@ function onDelete(row: SystemMenuApi.SystemMenu) {
<FormDrawer @success="onRefresh" /> <FormDrawer @success="onRefresh" />
<Grid> <Grid>
<template #toolbar-tools> <template #toolbar-tools>
<Button type="primary" @click="onCreate"> <Button
v-if="hasPermission('System:Menu:Create')"
type="primary"
@click="onCreate"
>
<Plus class="size-5" /> <Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }} {{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }}
</Button> </Button>

View File

@ -4,6 +4,10 @@ import type { SystemRoleApi } from '#/api';
import { $t } from '#/locales'; import { $t } from '#/locales';
type PermissionOptions = {
canAccess?: (code: string) => boolean;
};
export function useFormSchema(): VbenFormSchema[] { export function useFormSchema(): VbenFormSchema[] {
return [ return [
{ {
@ -77,7 +81,10 @@ export function useGridFormSchema(): VbenFormSchema[] {
export function useColumns<T = SystemRoleApi.SystemRole>( export function useColumns<T = SystemRoleApi.SystemRole>(
onActionClick: OnActionClickFn<T>, onActionClick: OnActionClickFn<T>,
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>, onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
options: PermissionOptions = {},
): VxeTableGridOptions['columns'] { ): VxeTableGridOptions['columns'] {
const canAccess = options.canAccess || (() => true);
return [ return [
{ {
field: 'name', field: 'name',
@ -117,6 +124,16 @@ export function useColumns<T = SystemRoleApi.SystemRole>(
onClick: onActionClick, onClick: onActionClick,
}, },
name: 'CellOperation', name: 'CellOperation',
options: [
{
code: 'edit',
show: () => canAccess('System:Role:Edit'),
},
{
code: 'delete',
show: () => canAccess('System:Role:Delete'),
},
],
}, },
field: 'operation', field: 'operation',
fixed: 'right', fixed: 'right',

View File

@ -7,6 +7,7 @@ import type {
} from '#/adapter/vxe-table'; } from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api'; import type { SystemRoleApi } from '#/api';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer } from '@vben/common-ui'; import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
@ -24,6 +25,12 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
destroyOnClose: true, destroyOnClose: true,
}); });
const { hasAccessByCodes } = useAccess();
function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]], fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
@ -31,7 +38,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
submitOnChange: true, submitOnChange: true,
}, },
gridOptions: { gridOptions: {
columns: useColumns(onActionClick, onStatusChange), columns: useColumns(
onActionClick,
hasPermission('System:Role:Edit') ? onStatusChange : undefined,
{ canAccess: hasPermission },
),
height: 'auto', height: 'auto',
keepSource: true, keepSource: true,
proxyConfig: { proxyConfig: {
@ -154,7 +165,11 @@ function onCreate() {
<FormDrawer @success="onRefresh" /> <FormDrawer @success="onRefresh" />
<Grid :table-title="$t('system.role.list')"> <Grid :table-title="$t('system.role.list')">
<template #toolbar-tools> <template #toolbar-tools>
<Button type="primary" @click="onCreate"> <Button
v-if="hasPermission('System:Role:Create')"
type="primary"
@click="onCreate"
>
<Plus class="size-5" /> <Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.role.name')]) }} {{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
</Button> </Button>

View File

@ -78,13 +78,13 @@ function convertRoutes(
// 页面组件转换 // 页面组件转换
} else if (component) { } else if (component) {
const normalizePath = normalizeViewPath(component); const normalizePath = normalizeViewPath(component);
const pageKey = normalizePath.endsWith('.vue') const pageKeys = getPageKeys(normalizePath);
? normalizePath const pageKey = pageKeys.find((key) => pageMap[key]);
: `${normalizePath}.vue`;
if (pageMap[pageKey]) { if (pageKey) {
route.component = pageMap[pageKey]; route.component = pageMap[pageKey];
} else { } 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']; route.component = pageMap['/_core/fallback/not-found.vue'];
} }
} }
@ -105,4 +105,11 @@ function normalizeViewPath(path: string): string {
// 这里耦合了vben-admin的目录结构 // 这里耦合了vben-admin的目录结构
return viewPath.replace(/^\/views/, ''); return viewPath.replace(/^\/views/, '');
} }
function getPageKeys(path: string): string[] {
if (/\.(tsx|vue)$/.test(path)) return [path];
return [`${path}.tsx`, `${path}.vue`];
}
export { generateRoutesByBackend }; export { generateRoutesByBackend };