f
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
2026-01-10 18:11:29 +08:00
commit 69f3c4ce45
1443 changed files with 125558 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemApiApi } from '#/api';
import { $t } from '#/locales';
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'api_name',
label: $t('system.api.apiName'),
rules: 'required',
},
{
component: 'Input',
fieldName: 'api_code',
label: $t('system.api.apiCode'),
rules: 'required',
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
placeholder: '请选择请求方法',
options: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
],
},
fieldName: 'method',
label: $t('system.api.method'),
rules: 'required',
},
{
component: 'Input',
fieldName: 'url',
label: $t('system.api.url'),
rules: 'required',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.api.status'),
},
{
component: 'Textarea',
fieldName: 'description',
label: $t('system.api.description'),
},
];
}
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'api_name',
label: $t('system.api.apiName'),
},
{
component: 'Input',
fieldName: 'api_code',
label: $t('system.api.apiCode'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
options: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
],
},
fieldName: 'method',
label: $t('system.api.method'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
},
fieldName: 'status',
label: $t('system.api.status'),
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: $t('system.api.createTime'),
},
];
}
export function useColumns<T = SystemApiApi.SystemApiItem>(
onActionClick: OnActionClickFn<T>,
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'api_name',
title: $t('system.api.apiName'),
width: 200,
},
{
field: 'api_code',
title: $t('system.api.apiCode'),
width: 200,
},
{
field: 'method',
title: $t('system.api.method'),
width: 100,
},
{
field: 'url',
title: $t('system.api.url'),
minWidth: 200,
},
{
cellRender: {
attrs: { beforeChange: onStatusChange },
name: onStatusChange ? 'CellSwitch' : 'CellTag',
},
field: 'status',
title: $t('system.api.status'),
width: 100,
},
{
field: 'description',
minWidth: 150,
title: $t('system.api.description'),
},
{
field: 'create_time',
title: $t('system.api.createTime'),
width: 200,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'api_name',
nameTitle: $t('system.api.apiName'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
'edit', // 默认的编辑按钮
'delete', // 默认的删除按钮
],
},
field: 'operation',
fixed: 'right',
title: $t('system.api.operation'),
width: 130,
},
];
}

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemApiApi } from '#/api';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteApi, getApiList, updateApi } from '#/api';
import { $t } from '#/locales';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick, onStatusChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
props: {
result: 'list',
total: 'total',
},
autoLoad: true,
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<SystemApiApi.SystemApiItem>,
});
function onActionClick(e: OnActionClickParams<SystemApiApi.SystemApiItem>) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
}
}
/**
* 将Antd的Modal.confirm封装为promise方便在异步函数中调用。
* @param content 提示内容
* @param title 提示标题
*/
function confirm(content: string, title: string) {
return new Promise((reslove, reject) => {
Modal.confirm({
content,
onCancel() {
reject(new Error('已取消'));
},
onOk() {
reslove(true);
},
title,
});
});
}
/**
* 状态开关即将改变
* @param newStatus 期望改变的状态值
* @param row 行数据
* @returns 返回false则中止改变返回其他值undefined、true则允许改变
*/
async function onStatusChange(
newStatus: number,
row: SystemApiApi.SystemApiItem,
) {
const status: Recordable<string> = {
0: '禁用',
1: '启用',
};
try {
await confirm(
`你要将${row.api_name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
`切换状态`,
);
await updateApi(row.id, {
...row,
status: newStatus as 0 | 1,
});
return true;
} catch {
return false;
}
}
function onEdit(row: SystemApiApi.SystemApiItem) {
formDrawerApi.setData(row).open();
}
function onDelete(row: SystemApiApi.SystemApiItem) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.api_name]),
duration: 0,
key: 'action_process_msg',
});
deleteApi(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.api_name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
function onRefresh() {
gridApi.query();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid :table-title="$t('system.api.list')">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.api.name')]) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { SystemApiApi } from '#/api';
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
import { createApi, updateApi } from '#/api';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits<{
success: [];
}>();
const formData = ref<SystemApiApi.SystemApiItem>();
const schema = useFormSchema();
const [Form, formApi] = useVbenForm({
commonConfig: {
colon: true,
formItemClass: 'col-span-2 md:col-span-1',
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-2 gap-x-4',
});
const [Drawer, drawerApi] = useVbenDrawer({
onConfirm: onSubmit,
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemApiApi.SystemApiItem>();
if (data) {
formData.value = data;
formApi.setValues(formData.value);
} else {
formApi.resetForm();
}
}
},
});
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
drawerApi.lock();
const data = await formApi.getValues<Omit<SystemApiApi.SystemApiItem, 'id' | 'create_time' | 'update_time'>>();
try {
await (formData.value?.id
? updateApi(formData.value.id, data)
: createApi(data));
drawerApi.close();
emit('success');
} finally {
drawerApi.unlock();
}
}
}
const getDrawerTitle = computed(() =>
formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.api.name')])
: $t('ui.actionTitle.create', [$t('system.api.name')]),
);
</script>
<template>
<Drawer class="w-full max-w-[600px]" :title="getDrawerTitle">
<Form class="mx-4" />
</Drawer>
</template>

View File

@@ -0,0 +1,135 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { $t } from '#/locales';
/**
* 获取编辑表单的字段配置。如果没有使用多语言可以直接export一个数组常量
*/
export function useSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('system.dept.deptName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.dept.deptName'), 2]))
.max(
20,
$t('ui.formRules.maxLength', [$t('system.dept.deptName'), 20]),
),
},
{
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: getDeptList,
class: 'w-full',
labelField: 'name',
valueField: 'id',
childrenField: 'children',
},
fieldName: 'pid',
label: $t('system.dept.parentDept'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.dept.status'),
},
{
component: 'Textarea',
componentProps: {
maxLength: 50,
rows: 3,
showCount: true,
},
fieldName: 'remark',
label: $t('system.dept.remark'),
rules: z
.string()
.max(50, $t('ui.formRules.maxLength', [$t('system.dept.remark'), 50]))
.optional(),
},
];
}
/**
* 获取表格列配置
* @description 使用函数的形式返回列数据而不是直接export一个Array常量是为了响应语言切换时重新翻译表头
* @param onActionClick 表格操作按钮点击事件
*/
export function useColumns(
onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
return [
{
align: 'left',
field: 'name',
fixed: 'left',
title: $t('system.dept.deptName'),
treeNode: true,
width: 150,
},
{
cellRender: { name: 'CellTag' },
field: 'status',
title: $t('system.dept.status'),
width: 100,
},
{
field: 'createTime',
title: $t('system.dept.createTime'),
width: 180,
},
{
field: 'remark',
title: $t('system.dept.remark'),
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: $t('system.dept.name'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
},
'edit', // 默认的编辑按钮
{
code: 'delete', // 默认的删除按钮
disabled: (row: SystemDeptApi.SystemDept) => {
return !!(row.children && row.children.length > 0);
},
},
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.dept.operation'),
width: 200,
},
];
}

View File

@@ -0,0 +1,143 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDept, getDeptList } from '#/api/system/dept';
import { $t } from '#/locales';
import { useColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/**
* 编辑部门
* @param row
*/
function onEdit(row: SystemDeptApi.SystemDept) {
formModalApi.setData(row).open();
}
/**
* 添加下级部门
* @param row
*/
function onAppend(row: SystemDeptApi.SystemDept) {
formModalApi.setData({ pid: row.id }).open();
}
/**
* 创建新部门
*/
function onCreate() {
formModalApi.setData(null).open();
}
/**
* 删除部门
* @param row
*/
function onDelete(row: SystemDeptApi.SystemDept) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteDept(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
refreshGrid();
})
.catch(() => {
hideLoading();
});
}
/**
* 表格操作按钮的回调函数
*/
function onActionClick({
code,
row,
}: OnActionClickParams<SystemDeptApi.SystemDept>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridEvents: {},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getDeptList();
},
},
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
zoom: true,
},
treeConfig: {
parentField: 'pid',
rowField: 'id',
transform: false,
},
} as VxeTableGridOptions,
});
/**
* 刷新表格
*/
function refreshGrid() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<FormModal @success="refreshGrid" />
<Grid table-title="部门列表">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createDept, updateDept } from '#/api/system/dept';
import { $t } from '#/locales';
import { useSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemDeptApi.SystemDept>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.dept.name')])
: $t('ui.actionTitle.create', [$t('system.dept.name')]);
});
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: useSchema(),
showDefaultActions: false,
});
function resetForm() {
formApi.resetForm();
formApi.setValues(formData.value || {});
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues();
try {
await (formData.value?.id
? updateDept(formData.value.id, data)
: createDept(data));
modalApi.close();
emit('success');
} finally {
modalApi.lock(false);
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<SystemDeptApi.SystemDept>();
if (data) {
if (data.pid === 0) {
data.pid = undefined;
}
formData.value = data;
formApi.setValues(formData.value);
}
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<template #prepend-footer>
<div class="flex-auto">
<Button type="primary" danger @click="resetForm">
{{ $t('common.reset') }}
</Button>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,109 @@
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { $t } from '#/locales';
export function getMenuTypeOptions() {
return [
{
color: 'processing',
label: $t('system.menu.typeCatalog'),
value: 'catalog',
},
{ color: 'default', label: $t('system.menu.typeMenu'), value: 'menu' },
{ color: 'error', label: $t('system.menu.typeButton'), value: 'action' },
{
color: 'success',
label: $t('system.menu.typeEmbedded'),
value: 'embedded',
},
{ color: 'warning', label: $t('system.menu.typeLink'), value: 'link' },
];
}
export function useColumns(
onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>,
): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] {
return [
{
align: 'left',
field: 'meta.title',
fixed: 'left',
slots: { default: 'title' },
title: $t('system.menu.menuTitle'),
treeNode: true,
width: 250,
},
{
align: 'center',
cellRender: { name: 'CellTag', options: getMenuTypeOptions() },
field: 'type',
title: $t('system.menu.type'),
width: 100,
},
{
field: 'authCode',
title: $t('system.menu.authCode'),
width: 200,
},
{
align: 'left',
field: 'path',
title: $t('system.menu.path'),
width: 200,
},
{
align: 'left',
field: 'component',
formatter: ({ row }) => {
switch (row.type) {
case 'catalog':
case 'menu': {
return row.component ?? '';
}
case 'embedded': {
return row.meta?.iframeSrc ?? '';
}
case 'link': {
return row.meta?.link ?? '';
}
}
return '';
},
minWidth: 200,
title: $t('system.menu.component'),
},
{
cellRender: { name: 'CellTag' },
field: 'status',
title: $t('system.menu.status'),
width: 100,
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
},
'edit', // 默认的编辑按钮
'delete', // 默认的删除按钮
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.menu.operation'),
width: 200,
},
];
}

View File

@@ -0,0 +1,162 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon, Plus } from '@vben/icons';
import { $t } from '@vben/locales';
import { MenuBadge } from '@vben-core/menu-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMenu, getMenuList, SystemMenuApi } from '#/api/system/menu';
import { useColumns } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getMenuList();
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
zoom: true,
},
treeConfig: {
parentField: 'pid',
rowField: 'id',
transform: false,
},
} as VxeTableGridOptions,
});
function onActionClick({
code,
row,
}: OnActionClickParams<SystemMenuApi.SystemMenu>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
default: {
break;
}
}
}
function onRefresh() {
gridApi.query();
}
function onEdit(row: SystemMenuApi.SystemMenu) {
formDrawerApi.setData(row).open();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
function onAppend(row: SystemMenuApi.SystemMenu) {
formDrawerApi.setData({ pid: row.id }).open();
}
function onDelete(row: SystemMenuApi.SystemMenu) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteMenu(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid>
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }}
</Button>
</template>
<template #title="{ row }">
<div class="flex w-full items-center gap-1">
<div class="size-5 flex-shrink-0">
<IconifyIcon
v-if="row.type === 'button'"
icon="carbon:security"
class="size-full"
/>
<IconifyIcon
v-else-if="row.meta?.icon"
:icon="row.meta?.icon || 'carbon:circle-dash'"
class="size-full"
/>
</div>
<span class="flex-auto">{{ $t(row.meta?.title) }}</span>
<div class="items-center justify-end"></div>
</div>
<MenuBadge
v-if="row.meta?.badgeType"
class="menu-badge"
:badge="row.meta.badge"
:badge-type="row.meta.badgeType"
:badge-variants="row.meta.badgeVariants"
/>
</template>
</Grid>
</Page>
</template>
<style lang="scss" scoped>
.menu-badge {
top: 50%;
right: 0;
transform: translateY(-50%);
& > :deep(div) {
padding-top: 0;
padding-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,503 @@
<script lang="ts" setup>
import type { ChangeEvent } from 'ant-design-vue/es/_util/EventInterface';
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, h, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import { getPopupContainer } from '@vben/utils';
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
import { useVbenForm, z } from '#/adapter/form';
import {
createMenu,
getMenuList,
SystemMenuApi,
updateMenu,
} from '#/api/system/menu';
import { $t } from '#/locales';
import { componentKeys } from '#/router/routes';
import { getMenuTypeOptions } from '../data';
const emit = defineEmits<{
success: [];
}>();
const formData = ref<SystemMenuApi.SystemMenu>();
const titleSuffix = ref<string>();
const schema: VbenFormSchema[] = [
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: getMenuTypeOptions(),
optionType: 'button',
},
defaultValue: 'menu',
fieldName: 'type',
formItemClass: 'col-span-2 md:col-span-2',
label: $t('system.menu.type'),
},
{
component: 'Input',
fieldName: 'name',
label: $t('system.menu.menuName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.menuName'), 2]))
.max(30, $t('ui.formRules.maxLength', [$t('system.menu.menuName'), 30])),
// .refine(
// async (value: string) => {
// return !(await isMenuNameExists(value, formData.value?.id));
// },
// (value) => ({
// message: $t('ui.formRules.alreadyExists', [
// $t('system.menu.menuName'),
// value,
// ]),
// }),
// ),
},
{
component: 'ApiTreeSelect',
componentProps: {
api: getMenuList,
class: 'w-full',
filterTreeNode(input: string, node: Recordable<any>) {
if (!input || input.length === 0) {
return true;
}
const title: string = node.meta?.title ?? '';
if (!title) return false;
return title.includes(input) || $t(title).includes(input);
},
getPopupContainer,
labelField: 'meta.title',
showSearch: true,
treeDefaultExpandAll: true,
valueField: 'id',
childrenField: 'children',
},
fieldName: 'pid',
label: $t('system.menu.parent'),
renderComponentContent() {
return {
title({ label, meta }: { label: string; meta: Recordable<any> }) {
const coms = [];
if (!label) return '';
if (meta?.icon) {
coms.push(h(IconifyIcon, { class: 'size-4', icon: meta.icon }));
}
coms.push(h('span', { class: '' }, $t(label || '')));
return h('div', { class: 'flex items-center gap-1' }, coms);
},
};
},
},
{
component: 'Input',
componentProps() {
// 不需要处理多语言时就无需这么做
return {
addonAfter: titleSuffix.value,
onChange({ target: { value } }: ChangeEvent) {
titleSuffix.value = value && $te(value) ? $t(value) : undefined;
},
};
},
fieldName: 'meta.title',
label: $t('system.menu.menuTitle'),
rules: 'required',
},
{
component: 'Input',
dependencies: {
show: (values) => {
return ['catalog', 'embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'path',
label: $t('system.menu.path'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
.refine(
(value: string) => {
return value.startsWith('/');
},
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
),
// .refine(
// async (value: string) => {
// return !(await isMenuPathExists(value, formData.value?.id));
// },
// (value) => ({
// message: $t('ui.formRules.alreadyExists', [
// $t('system.menu.path'),
// value,
// ]),
// }),
// ),
},
{
component: 'Input',
dependencies: {
show: (values) => {
return ['embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'activePath',
help: $t('system.menu.activePathHelp'),
label: $t('system.menu.activePath'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
.refine(
(value: string) => {
return value.startsWith('/');
},
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
)
// .refine(async (value: string) => {
// return await isMenuPathExists(value, formData.value?.id);
// }, $t('system.menu.activePathMustExist'))
.optional(),
},
{
component: 'IconPicker',
componentProps: {
prefix: 'carbon',
},
dependencies: {
show: (values) => {
return ['catalog', 'embedded', 'link', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.icon',
label: $t('system.menu.icon'),
},
{
component: 'IconPicker',
componentProps: {
prefix: 'carbon',
},
dependencies: {
show: (values) => {
return ['catalog', 'embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.activeIcon',
label: $t('system.menu.activeIcon'),
},
{
component: 'AutoComplete',
componentProps: {
allowClear: true,
class: 'w-full',
filterOption(input: string, option: { value: string }) {
return option.value.toLowerCase().includes(input.toLowerCase());
},
options: componentKeys.map((v) => ({ value: v })),
},
dependencies: {
rules: (values) => {
return values.type === 'menu' ? 'required' : null;
},
show: (values) => {
return values.type === 'menu';
},
triggerFields: ['type'],
},
fieldName: 'component',
label: $t('system.menu.component'),
},
{
component: 'Input',
dependencies: {
show: (values) => {
return ['embedded', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'linkSrc',
label: $t('system.menu.linkSrc'),
rules: z.string().url($t('ui.formRules.invalidURL')),
},
{
component: 'Input',
dependencies: {
rules: (values) => {
return values.type === 'action' ? 'required' : null;
},
show: (values) => {
return ['action', 'catalog', 'embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'authCode',
label: $t('system.menu.authCode'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.menu.status'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
options: [
{ label: $t('system.menu.badgeType.dot'), value: 'dot' },
{ label: $t('system.menu.badgeType.normal'), value: 'normal' },
],
},
dependencies: {
show: (values) => {
return values.type !== 'action';
},
triggerFields: ['type'],
},
fieldName: 'meta.badgeType',
label: $t('system.menu.badgeType.title'),
},
{
component: 'Input',
componentProps: (values) => {
return {
allowClear: true,
class: 'w-full',
disabled: values.meta?.badgeType !== 'normal',
};
},
dependencies: {
show: (values) => {
return values.type !== 'action';
},
triggerFields: ['type'],
},
fieldName: 'meta.badge',
label: $t('system.menu.badge'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
options: SystemMenuApi.BadgeVariants.map((v) => ({
label: v,
value: v,
})),
},
dependencies: {
show: (values) => {
return values.type !== 'action';
},
triggerFields: ['type'],
},
fieldName: 'meta.badgeVariants',
label: $t('system.menu.badgeVariants'),
},
{
component: 'Divider',
dependencies: {
show: (values) => {
return !['action', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'divider1',
formItemClass: 'col-span-2 md:col-span-2 pb-0',
hideLabel: true,
renderComponentContent() {
return {
default: () => $t('system.menu.advancedSettings'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return ['menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.keepAlive',
renderComponentContent() {
return {
default: () => $t('system.menu.keepAlive'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return ['embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.affixTab',
renderComponentContent() {
return {
default: () => $t('system.menu.affixTab'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return !['action'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideInMenu',
renderComponentContent() {
return {
default: () => $t('system.menu.hideInMenu'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return ['catalog', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideChildrenInMenu',
renderComponentContent() {
return {
default: () => $t('system.menu.hideChildrenInMenu'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return !['action', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideInBreadcrumb',
renderComponentContent() {
return {
default: () => $t('system.menu.hideInBreadcrumb'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return !['action', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideInTab',
renderComponentContent() {
return {
default: () => $t('system.menu.hideInTab'),
};
},
},
];
const breakpoints = useBreakpoints(breakpointsTailwind);
const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value);
const [Form, formApi] = useVbenForm({
commonConfig: {
colon: true,
formItemClass: 'col-span-2 md:col-span-1',
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-2 gap-x-4',
});
const [Drawer, drawerApi] = useVbenDrawer({
onConfirm: onSubmit,
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemMenuApi.SystemMenu>();
if (data?.type === 'link') {
data.linkSrc = data.meta?.link;
} else if (data?.type === 'embedded') {
data.linkSrc = data.meta?.iframeSrc;
}
if (data) {
formData.value = data;
formApi.setValues(formData.value);
titleSuffix.value = formData.value.meta?.title
? $t(formData.value.meta.title)
: '';
} else {
formApi.resetForm();
titleSuffix.value = '';
}
}
},
});
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
drawerApi.lock();
const data =
await formApi.getValues<
Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>
>();
if (data.type === 'link') {
data.meta = { ...data.meta, link: data.linkSrc };
} else if (data.type === 'embedded') {
data.meta = { ...data.meta, iframeSrc: data.linkSrc };
}
delete data.linkSrc;
try {
await (formData.value?.id
? updateMenu(formData.value.id, data)
: createMenu(data));
drawerApi.close();
emit('success');
} finally {
drawerApi.unlock();
}
}
}
const getDrawerTitle = computed(() =>
formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.menu.name')])
: $t('ui.actionTitle.create', [$t('system.menu.name')]),
);
</script>
<template>
<Drawer class="w-full max-w-[800px]" :title="getDrawerTitle">
<Form class="mx-4" :layout="isHorizontal ? 'horizontal' : 'vertical'" />
</Drawer>
</template>

View File

@@ -0,0 +1,148 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api';
import { $t } from '#/locales';
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'role_name',
label: $t('system.role.roleName'),
rules: 'required',
},
{
component: 'Input',
fieldName: 'role_code',
label: $t('system.role.roleCode'),
rules: 'required',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.role.status'),
},
{
component: 'Textarea',
fieldName: 'description',
label: $t('system.role.remark'),
},
{
component: 'Input',
fieldName: 'menu_ids',
formItemClass: 'items-start',
label: $t('system.role.setPermissions'),
modelPropName: 'modelValue',
},
];
}
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'role_name',
label: $t('system.role.roleName'),
},
{ component: 'Input', fieldName: 'id', label: $t('system.role.id') },
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
},
fieldName: 'status',
label: $t('system.role.status'),
},
{
component: 'Input',
fieldName: 'description',
label: $t('system.role.remark'),
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: $t('system.role.createTime'),
},
];
}
export function useColumns<T = SystemRoleApi.SystemRoleItem>(
onActionClick: OnActionClickFn<T>,
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'role_name',
title: $t('system.role.roleName'),
width: 200,
},
{
field: 'role_code',
title: $t('system.role.roleCode'),
width: 200,
},
{
cellRender: {
attrs: { beforeChange: onStatusChange },
name: onStatusChange ? 'CellSwitch' : 'CellTag',
},
field: 'status',
title: $t('system.role.status'),
width: 100,
},
{
field: 'description',
minWidth: 100,
title: $t('system.role.remark'),
},
{
field: 'create_time',
title: $t('system.role.createTime'),
width: 200,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'role_name',
nameTitle: $t('system.role.roleName'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
text: $t('ui.actionTitle.edit', [$t('system.role.name')]),
},
{
code: 'api-permissions',
text: $t('system.role.setApiPermissions'),
},
{
code: 'delete',
text: $t('ui.actionTitle.delete', [$t('system.role.name')]),
danger: true,
},
],
},
field: 'operation',
fixed: 'right',
title: $t('system.role.operation'),
width: 200,
},
];
}

View File

@@ -0,0 +1,179 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteRole, getRoleList, updateRole } from '#/api';
import { $t } from '#/locales';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ApiPermissions from './modules/api-permissions.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [ApiPermissionsDrawer, apiPermissionsDrawerApi] = useVbenDrawer({
connectedComponent: ApiPermissions,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick, onStatusChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getRoleList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<SystemRoleApi.SystemRoleItem>,
});
function onActionClick(e: OnActionClickParams<SystemRoleApi.SystemRoleItem>) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
case 'api-permissions': {
onApiPermissions(e.row);
break;
}
}
}
/**
* 将Antd的Modal.confirm封装为promise方便在异步函数中调用。
* @param content 提示内容
* @param title 提示标题
*/
function confirm(content: string, title: string) {
return new Promise((reslove, reject) => {
Modal.confirm({
content,
onCancel() {
reject(new Error('已取消'));
},
onOk() {
reslove(true);
},
title,
});
});
}
/**
* 状态开关即将改变
* @param newStatus 期望改变的状态值
* @param row 行数据
* @returns 返回false则中止改变返回其他值undefined、true则允许改变
*/
async function onStatusChange(
newStatus: number,
row: SystemRoleApi.SystemRoleItem,
) {
const status: Recordable<string> = {
0: '禁用',
1: '启用',
};
try {
await confirm(
`你要将${row.role_name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
`切换状态`,
);
await updateRole(row.id, { status: newStatus });
return true;
} catch {
return false;
}
}
function onEdit(row: SystemRoleApi.SystemRoleItem) {
formDrawerApi.setData(row).open();
}
function onDelete(row: SystemRoleApi.SystemRoleItem) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.role_name]),
duration: 0,
key: 'action_process_msg',
});
deleteRole(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.role_name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
function onRefresh() {
gridApi.query();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
function onApiPermissions(row: SystemRoleApi.SystemRoleItem) {
apiPermissionsDrawerApi.setData(row).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<ApiPermissionsDrawer @success="onRefresh" />
<Grid :table-title="$t('system.role.list')">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,201 @@
<script lang="ts" setup>
import type { SystemRoleApi } from '#/api';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Button, Checkbox, message, Spin } from 'ant-design-vue';
import { getAllApiList, getRoleApiList, updateRoleApi } from '#/api';
import { $t } from '#/locales';
const emits = defineEmits(['success']);
const loading = ref(false);
const allApiList = ref<any[]>([]);
const roleApiList = ref<any[]>([]);
const selectedApiIds = ref<string[]>([]);
const formData = ref<SystemRoleApi.SystemRoleItem>();
const roleId = ref<string>();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
await onSave();
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemRoleApi.SystemRoleItem>();
if (data) {
formData.value = data;
roleId.value = data.id;
fetchRoleApiList();
}
fetchAllApiList();
}
},
});
// 计算已选中的API ID
const selectedApiIdsSet = computed(() => new Set(selectedApiIds.value));
// 获取所有API列表
async function fetchAllApiList() {
try {
loading.value = true;
const response = await getAllApiList({ status: 1 }); // 只获取启用的API
allApiList.value = response.items || [];
} catch (error) {
console.error('获取API列表失败:', error);
message.error('获取API列表失败');
} finally {
loading.value = false;
}
}
// 获取角色已分配的API权限
async function fetchRoleApiList() {
if (!roleId.value) return;
try {
loading.value = true;
const response = await getRoleApiList(roleId.value);
roleApiList.value = response.items || [];
selectedApiIds.value = roleApiList.value.map((item) => item.api_id);
} catch (error) {
console.error('获取角色API权限失败:', error);
message.error('获取角色API权限失败');
} finally {
loading.value = false;
}
}
// 全选/取消全选
function toggleSelectAll(e: any) {
const checked = e.target.checked;
selectedApiIds.value = checked
? allApiList.value.map((api) => api.api_id)
: [];
}
// 判断是否全选
const isAllSelected = computed(() => {
return (
allApiList.value.length > 0 &&
selectedApiIds.value.length === allApiList.value.length
);
});
// 判断是否部分选中
const isIndeterminate = computed(() => {
return (
selectedApiIds.value.length > 0 &&
selectedApiIds.value.length < allApiList.value.length
);
});
// 保存API权限
async function onSave() {
if (!roleId.value) return;
try {
loading.value = true;
await updateRoleApi({
role_id: roleId.value,
api_ids: selectedApiIds.value,
});
message.success('API权限保存成功');
emits('success');
drawerApi.close();
} catch (error) {
console.error('保存API权限失败:', error);
message.error('保存API权限失败');
} finally {
loading.value = false;
}
}
// 重置选择
function onReset() {
selectedApiIds.value = roleApiList.value.map((item) => item.api_id);
}
// 计算抽屉标题
const getDrawerTitle = computed(() => {
return formData.value?.role_name
? `${$t('system.role.setApiPermissions')} - ${formData.value.role_name}`
: $t('system.role.setApiPermissions');
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<div class="p-4">
<div class="mb-4">
<p class="text-sm text-gray-500">
为角色分配API访问权限勾选的API将被允许访问
</p>
</div>
<Spin :spinning="loading">
<div class="space-y-4">
<!-- 全选操作 -->
<div class="flex items-center gap-4 rounded-lg bg-gray-50 p-3">
<Checkbox :checked="isAllSelected" :indeterminate="isIndeterminate" @change="toggleSelectAll">
全选
</Checkbox>
<span class="text-sm text-gray-500">
已选择 {{ selectedApiIds.length }} / {{ allApiList.length }} 个API
</span>
</div>
<!-- API列表 -->
<div class="max-h-96 overflow-y-auto rounded-lg border">
<div v-for="api in allApiList" :key="api.api_id"
class="flex items-center gap-3 border-b p-3 last:border-b-0 hover:bg-gray-50">
<Checkbox :checked="selectedApiIdsSet.has(api.api_id)" @change="
(e) => {
if (e.target.checked) {
selectedApiIds.push(api.api_id);
} else {
const index = selectedApiIds.indexOf(api.api_id);
if (index > -1) {
selectedApiIds.splice(index, 1);
}
}
}
" />
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{{ api.api_name }}</span>
<span class="rounded px-2 py-1 text-xs" :class="{
'bg-blue-100 text-blue-800': api.method === 'GET',
'bg-green-100 text-green-800': api.method === 'POST',
'bg-orange-100 text-orange-800': api.method === 'PUT',
'bg-red-100 text-red-800': api.method === 'DELETE',
}">
{{ api.method }}
</span>
</div>
<div class="mt-1 text-sm text-gray-500">
{{ api.url }}
</div>
<div v-if="api.description" class="mt-1 text-xs text-gray-400">
{{ api.description }}
</div>
</div>
</div>
</div>
</div>
</Spin>
<!-- 操作按钮 -->
<div class="mt-6 flex justify-end gap-2">
<Button @click="onReset"> 重置 </Button>
<Button type="primary" @click="onSave" :loading="loading">
保存
</Button>
</div>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,139 @@
<script lang="ts" setup>
import type { DataNode } from 'ant-design-vue/es/tree';
import type { Recordable } from '@vben/types';
import type { SystemRoleApi } from '#/api/system/role';
import { computed, ref } from 'vue';
import { useVbenDrawer, VbenTree } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Spin } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { createRole, updateRole } from '#/api/system/role';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emits = defineEmits(['success']);
const formData = ref<SystemRoleApi.SystemRole>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const permissions = ref<DataNode[]>([]);
const loadingPermissions = ref(false);
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
drawerApi.lock();
(id.value ? updateRole(id.value, values) : createRole(values))
.then(() => {
emits('success');
drawerApi.close();
})
.catch(() => {
drawerApi.unlock();
});
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemRoleApi.SystemRole>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
formApi.setValues(data);
} else {
id.value = undefined;
}
if (permissions.value.length === 0) {
loadPermissions();
}
}
},
});
async function loadPermissions() {
loadingPermissions.value = true;
try {
const res = await getMenuList();
permissions.value = res as unknown as DataNode[];
} finally {
loadingPermissions.value = false;
}
}
const getDrawerTitle = computed(() => {
return formData.value?.id
? $t('common.edit', $t('system.role.name'))
: $t('common.create', $t('system.role.name'));
});
function getNodeClass(node: Recordable<any>) {
const classes: string[] = [];
if (node.value?.type === 'button') {
classes.push('inline-flex');
if (node.index % 3 >= 1) {
classes.push('!pl-0');
}
}
return classes.join(' ');
}
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form>
<template #menu_ids="slotProps">
<Spin :spinning="loadingPermissions" wrapper-class-name="w-full">
<VbenTree
:tree-data="permissions"
multiple
bordered
:default-expanded-level="2"
:get-node-class="getNodeClass"
v-bind="slotProps"
value-field="id"
label-field="meta.title"
icon-field="meta.icon"
>
<template #node="{ value }">
<IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
{{ $t(value.meta.title) }}
</template>
</VbenTree>
</Spin>
</template>
</Form>
</Drawer>
</template>
<style lang="css" scoped>
:deep(.ant-tree-title) {
.tree-actions {
display: none;
margin-left: 20px;
}
}
:deep(.ant-tree-title:hover) {
.tree-actions {
display: flex;
flex: auto;
justify-content: flex-end;
margin-left: 20px;
}
}
</style>

View File

@@ -0,0 +1,137 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemUserApi } from '#/api';
import { ref } from 'vue';
import { $t } from '#/locales';
// 角色列表引用,可以在加载后更新
export const roleOptions = ref<{ label: string; value: number | string }[]>([]);
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.userName'),
rules: 'required',
},
{
component: 'Input',
fieldName: 'real_name',
label: $t('system.user.realName'),
rules: 'required',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.user.status'),
},
{
component: 'Select',
componentProps: {
mode: 'multiple',
allowClear: true,
options: roleOptions,
class: 'w-full',
},
fieldName: 'role_ids',
formItemClass: 'items-start',
label: $t('system.user.setPermissions'),
},
];
}
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.userName'),
},
{
component: 'Input',
fieldName: 'real_name',
label: $t('system.user.realName'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
},
fieldName: 'status',
label: $t('system.user.status'),
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: $t('system.user.createTime'),
},
];
}
export function useColumns<T = SystemUserApi.SystemUser>(
onActionClick: OnActionClickFn<T>,
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'username',
title: $t('system.user.userName'),
minWidth: 200,
},
{
field: 'real_name',
title: $t('system.user.realName'),
minWidth: 200,
},
{
cellRender: {
attrs: { beforeChange: onStatusChange },
name: onStatusChange ? 'CellSwitch' : 'CellTag',
},
field: 'status',
title: $t('system.user.status'),
minWidth: 100,
},
{
field: 'create_time',
title: $t('system.user.createTime'),
minWidth: 200,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'username',
nameTitle: $t('system.user.userName'),
onClick: onActionClick,
},
options: [
{ code: 'edit', text: '编辑' },
{ code: 'resetPassword', text: '重置密码' },
{ code: 'delete', text: '删除' },
],
name: 'CellOperation',
},
field: 'operation',
fixed: 'right',
title: $t('system.user.operation'),
width: 200,
},
];
}

View File

@@ -0,0 +1,179 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemUserApi } from '#/api';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteUser, getUserList, updateUser } from '#/api';
import { $t } from '#/locales';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ResetPasswordForm from './modules/reset-password-form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [ResetPasswordDrawer, resetPasswordDrawerApi] = useVbenDrawer({
connectedComponent: ResetPasswordForm,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick, onStatusChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getUserList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<SystemUserApi.SystemUser>,
});
function onActionClick(e: OnActionClickParams<SystemUserApi.SystemUser>) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
case 'resetPassword': {
onResetPassword(e.row);
break;
}
}
}
/**
* 将Antd的Modal.confirm封装为promise方便在异步函数中调用。
* @param content 提示内容
* @param title 提示标题
*/
function confirm(content: string, title: string) {
return new Promise((reslove, reject) => {
Modal.confirm({
content,
onCancel() {
reject(new Error('已取消'));
},
onOk() {
reslove(true);
},
title,
});
});
}
/**
* 状态开关即将改变
* @param newStatus 期望改变的状态值
* @param row 行数据
* @returns 返回false则中止改变返回其他值undefined、true则允许改变
*/
async function onStatusChange(
newStatus: number,
row: SystemUserApi.SystemUser,
) {
const status: Recordable<string> = {
0: '禁用',
1: '启用',
};
try {
await confirm(
`你要将${row.username}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
`切换状态`,
);
await updateUser(row.id, { status: newStatus });
return true;
} catch {
return false;
}
}
function onEdit(row: SystemUserApi.SystemUser) {
formDrawerApi.setData(row).open();
}
function onResetPassword(row: SystemUserApi.SystemUser) {
resetPasswordDrawerApi.setData(row).open();
}
function onDelete(row: SystemUserApi.SystemUser) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.username]),
duration: 0,
key: 'action_process_msg',
});
deleteUser(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.username]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
function onRefresh() {
gridApi.query();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer />
<ResetPasswordDrawer @success="onRefresh" />
<Grid :table-title="$t('system.user.list')">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.user.name')]) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Spin } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getRoleList } from '#/api/system/role';
import { createUser, updateUser } from '#/api/system/user';
import { $t } from '#/locales';
import { roleOptions, useFormSchema } from '../data';
const emits = defineEmits(['success']);
const formData = ref<SystemUserApi.SystemUser>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const roles = ref<Recordable<any>[]>([]);
const loadingRoles = ref(false);
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
// 确保role_ids是数字数组
if (values.role_ids && Array.isArray(values.role_ids)) {
values.role_ids = values.role_ids.map((id) =>
typeof id === 'string' ? Number.parseInt(id, 10) : id,
);
}
drawerApi.lock();
(id.value ? updateUser(id.value, values) : createUser(values))
.then(() => {
emits('success');
drawerApi.close();
})
.catch(() => {
drawerApi.unlock();
});
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemUserApi.SystemUser>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
// 确保role_ids存在且为数组
if (data.role_ids && !Array.isArray(data.role_ids)) {
data.role_ids = [data.role_ids];
}
formApi.setValues(data);
} else {
id.value = undefined;
}
if (roleOptions.value.length === 0) {
loadRoles();
}
}
},
});
async function loadRoles() {
loadingRoles.value = true;
try {
const res = await getRoleList({ page: 1, pageSize: 1000 });
roles.value = res.items;
// 更新全局的roleOptions
roleOptions.value = roles.value.map((role) => ({
label: role.role_name,
value: role.id,
}));
} finally {
loadingRoles.value = false;
}
}
const getDrawerTitle = computed(() => {
return formData.value?.id
? $t('common.edit', $t('system.user.name'))
: $t('common.create', $t('system.user.name'));
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<Spin :spinning="loadingRoles">
<Form />
</Spin>
</Drawer>
</template>
<style lang="css" scoped>
:deep(.ant-tree-title) {
.tree-actions {
display: none;
margin-left: 20px;
}
}
:deep(.ant-tree-title:hover) {
.tree-actions {
display: flex;
flex: auto;
justify-content: flex-end;
margin-left: 20px;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenDrawer, z } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { resetPassword } from '#/api/system/user';
const emits = defineEmits(['success']);
const formData = ref<SystemUserApi.SystemUser>();
const [Form, formApi] = useVbenForm({
schema: [
{
component: 'InputPassword',
fieldName: 'password',
label: '新密码',
rules: z.string().min(1, { message: '请输入新密码' }),
},
{
component: 'InputPassword',
fieldName: 'confirmPassword',
label: '确认密码',
dependencies: {
rules(values) {
const { password } = values;
return z
.string()
.min(1, { message: '请确认密码' })
.refine((value) => value === password, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['password'],
},
},
],
showDefaultActions: false,
});
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
drawerApi.lock();
resetPassword(id.value, { password: values.password })
.then(() => {
emits('success');
drawerApi.close();
})
.catch(() => {
drawerApi.unlock();
});
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemUserApi.SystemUser>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
} else {
id.value = undefined;
}
}
},
});
const getDrawerTitle = computed(() => {
return '重置密码';
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form />
</Drawer>
</template>