优化功能
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
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
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
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:
@@ -65,7 +65,9 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
||||
// 创建a标签下载
|
||||
const link = document.createElement('a'); // 创建a标签
|
||||
link.href = canvas.toDataURL(); // 是canvas对象的一种方法,用于将canvas对象转换为base64位编码
|
||||
link.setAttribute('download', `${doctorInfo.value.doctor.name}${name}.png`); // 利用了a标签的download 来下载 canvas图片
|
||||
// 使用推广员名称或门店名称作为文件名
|
||||
const fileName = data.value?.nick_name || data.value?.qr_code?.store?.name || '推广二维码';
|
||||
link.setAttribute('download', `${fileName}${name}.png`); // 利用了a标签的download 来下载 canvas图片
|
||||
link.style.display = 'none'; // 将图片隐藏起来
|
||||
document.body.append(link); // 插入到其中
|
||||
link.click();
|
||||
|
||||
@@ -24,6 +24,7 @@ import { openWindow } from '@vben/utils';
|
||||
|
||||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
||||
import {getQuickMenuApi} from "#/views/system/admin/api";
|
||||
import {getLatestNotice} from "#/views/system/notice/api";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -95,32 +96,17 @@ const todoItems = ref<WorkbenchTodoItem[]>([
|
||||
title: '修复UI显示问题',
|
||||
},
|
||||
]);
|
||||
const trendItems: WorkbenchTrendItem[] = [
|
||||
{
|
||||
avatar: 'svg:logo',
|
||||
content: `药师审方功能上线啦!`,
|
||||
date: '2025-5-26',
|
||||
title: '萧康云医',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:logo',
|
||||
content: `诊所、医生、药师基础功能已完成`,
|
||||
date: '2025-4-30',
|
||||
title: '萧康云医',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:logo',
|
||||
content: `医生开方功能上线啦!`,
|
||||
date: '2025-3-11',
|
||||
title: '萧康云医',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:logo',
|
||||
content: `萧康云医上线啦!`,
|
||||
date: '2025-1-7',
|
||||
title: '萧康云医',
|
||||
},
|
||||
];
|
||||
const trendItems = ref<WorkbenchTrendItem[]>([]);
|
||||
|
||||
/**
|
||||
* 获取最新公告
|
||||
*/
|
||||
function getLatestNoticeList() {
|
||||
getLatestNotice(10).then((res) => {
|
||||
trendItems.value = res;
|
||||
});
|
||||
}
|
||||
getLatestNoticeList();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ export async function getMenuTreeOptionSelect() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索菜单(支持按 title 模糊搜索)
|
||||
* @param data 搜索参数 { keyword: string, filter_by_user?: number }
|
||||
*/
|
||||
export async function searchMenu(data: { keyword: string; filter_by_user?: number }) {
|
||||
return requestClient.get<any>(`${prefix}search-menu`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取详情
|
||||
* @param id
|
||||
|
||||
50
apps/web-antd/src/views/system/notice/api/index.ts
Normal file
50
apps/web-antd/src/views/system/notice/api/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'announcement/';
|
||||
/**
|
||||
* 分页查询公告列表
|
||||
* @param data
|
||||
*/
|
||||
export async function getNoticeList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新公告(用于工作台)
|
||||
* @param limit
|
||||
*/
|
||||
export async function getLatestNotice(limit: number = 10) {
|
||||
return requestClient.get<any>(`${prefix}latest`, { params: { limit } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取详情
|
||||
* @param id
|
||||
*/
|
||||
export async function getNoticeInfo(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增公告
|
||||
* @param data
|
||||
*/
|
||||
export async function createNotice(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑公告
|
||||
* @param data
|
||||
*/
|
||||
export async function updateNotice(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除公告
|
||||
* @param data
|
||||
*/
|
||||
export async function deleteNotice(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
61
apps/web-antd/src/views/system/notice/components/modal.vue
Normal file
61
apps/web-antd/src/views/system/notice/components/modal.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createNotice, updateNotice } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateNotice : createNotice;
|
||||
submitApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
isUpdate.value = update;
|
||||
formApi.setValues(values);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}公告`"
|
||||
class="w-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
93
apps/web-antd/src/views/system/notice/config/form.ts
Normal file
93
apps/web-antd/src/views/system/notice/config/form.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12', // 24栅格,
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-6',
|
||||
// 所有表单项
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
},
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenTextarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入内容',
|
||||
rows: 4,
|
||||
},
|
||||
fieldName: 'content',
|
||||
label: '内容',
|
||||
formItemClass: 'col-span-12',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入头像/图标(如:svg:logo)',
|
||||
},
|
||||
fieldName: 'avatar',
|
||||
label: '头像/图标',
|
||||
defaultValue: 'svg:logo',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入日期(格式:2025-5-26)',
|
||||
},
|
||||
fieldName: 'date',
|
||||
label: '日期',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{
|
||||
label: '启用',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '禁用',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
rules: 'required',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
45
apps/web-antd/src/views/system/notice/config/search.ts
Normal file
45
apps/web-antd/src/views/system/notice/config/search.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
},
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{
|
||||
label: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: '启用',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '禁用',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
84
apps/web-antd/src/views/system/notice/config/table.ts
Normal file
84
apps/web-antd/src/views/system/notice/config/table.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getNoticeList } from '../api';
|
||||
|
||||
interface RowType {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
avatar: string;
|
||||
date: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'title', align: 'left', title: '标题' },
|
||||
{ field: 'content', align: 'left', title: '内容', width: 300 },
|
||||
{ field: 'avatar', title: '头像/图标' },
|
||||
{ field: 'date', title: '日期', width: 150 },
|
||||
{ field: 'sort', title: '排序', width: 100 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{ field: 'created_at', title: '创建时间' },
|
||||
{
|
||||
type: 'html',
|
||||
align: 'right',
|
||||
title: '操作',
|
||||
slots: { default: 'action' },
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getNoticeList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
export: false, // 导出
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
135
apps/web-antd/src/views/system/notice/index.vue
Normal file
135
apps/web-antd/src/views/system/notice/index.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { deleteNotice } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||
}
|
||||
deleteNotice({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.reload();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="公告管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
<Icon icon="ant-design:down-outlined" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'green' : 'red'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
58
apps/web-antd/src/views/system/role-quick-nav/api/index.ts
Normal file
58
apps/web-antd/src/views/system/role-quick-nav/api/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'role-quick-nav/';
|
||||
/**
|
||||
* 分页查询快捷导航列表
|
||||
* @param data
|
||||
*/
|
||||
export async function getRoleQuickNavList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取详情
|
||||
* @param id
|
||||
*/
|
||||
export async function getRoleQuickNavInfo(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增快捷导航
|
||||
* @param data
|
||||
*/
|
||||
export async function createRoleQuickNav(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑快捷导航
|
||||
* @param data
|
||||
*/
|
||||
export async function updateRoleQuickNav(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除快捷导航
|
||||
* @param data
|
||||
*/
|
||||
export async function deleteRoleQuickNav(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据角色ID获取快捷导航
|
||||
* @param roleId
|
||||
*/
|
||||
export async function getRoleQuickNavByRoleId(roleId: number) {
|
||||
return requestClient.get<any>(`${prefix}get-by-role-id`, { params: { role_id: roleId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存快捷导航
|
||||
* @param data
|
||||
*/
|
||||
export async function batchSaveRoleQuickNav(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}batch-save`, data);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createRoleQuickNav, updateRoleQuickNav } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateRoleQuickNav : createRoleQuickNav;
|
||||
submitApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
isUpdate.value = update;
|
||||
formApi.setValues(values);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}快捷导航`"
|
||||
class="w-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
107
apps/web-antd/src/views/system/role-quick-nav/config/form.ts
Normal file
107
apps/web-antd/src/views/system/role-quick-nav/config/form.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { getMenuTreeOptionSelect } from '#/views/system/menu/api';
|
||||
import { getRoleOption } from '#/views/system/role/api';
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12', // 24栅格,
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-6',
|
||||
// 所有表单项
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getRoleOption,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择角色',
|
||||
},
|
||||
fieldName: 'role_id',
|
||||
label: '角色',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
childrenField: 'children',
|
||||
labelField: 'title',
|
||||
valueField: 'id',
|
||||
api: getMenuTreeOptionSelect,
|
||||
placeholder: '请选择菜单(选择后自动填充标题、图标、地址)',
|
||||
onChange: (value: any, formApi: any) => {
|
||||
// 选择菜单后,自动填充title、icon、url
|
||||
if (value) {
|
||||
// 这里需要通过API获取菜单详情来填充
|
||||
// 暂时由后端处理
|
||||
}
|
||||
},
|
||||
},
|
||||
fieldName: 'menu_id',
|
||||
label: '菜单',
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
},
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入图标',
|
||||
},
|
||||
fieldName: 'icon',
|
||||
label: '图标',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入跳转地址',
|
||||
},
|
||||
fieldName: 'url',
|
||||
label: '跳转地址',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入颜色(如:#00d8ff)',
|
||||
},
|
||||
fieldName: 'color',
|
||||
label: '颜色',
|
||||
defaultValue: '#00d8ff',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
rules: 'required',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { getRoleOption } from '#/views/system/role/api';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getRoleOption,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择角色',
|
||||
allowClear: true,
|
||||
},
|
||||
fieldName: 'role_id',
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
},
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getRoleQuickNavList } from '../api';
|
||||
|
||||
interface RowType {
|
||||
id: string;
|
||||
role_id: number;
|
||||
menu_id: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
color: string;
|
||||
sort: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'role_id', align: 'left', title: '角色ID', width: 100 },
|
||||
{ field: 'title', align: 'left', title: '标题' },
|
||||
{ field: 'icon', title: '图标', slots: { default: 'icon' } },
|
||||
{ field: 'url', title: '跳转地址' },
|
||||
{ field: 'color', title: '颜色', width: 120 },
|
||||
{ field: 'sort', title: '排序', width: 100 },
|
||||
{ field: 'created_at', title: '创建时间' },
|
||||
{
|
||||
type: 'html',
|
||||
align: 'right',
|
||||
title: '操作',
|
||||
slots: { default: 'action' },
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getRoleQuickNavList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
export: false, // 导出
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
133
apps/web-antd/src/views/system/role-quick-nav/index.vue
Normal file
133
apps/web-antd/src/views/system/role-quick-nav/index.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { deleteRoleQuickNav } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||
}
|
||||
deleteRoleQuickNav({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.reload();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="快捷导航管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
<Icon icon="ant-design:down-outlined" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #icon="{ row }">
|
||||
<Icon :icon="row.icon" style="font-size: 20px" />
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
@@ -17,6 +17,12 @@ const treeRef = ref();
|
||||
const treeData = ref([]);
|
||||
const isExpand = ref(false);
|
||||
|
||||
// 计算标题:授权菜单 - 角色名称
|
||||
const drawerTitle = computed(() => {
|
||||
const roleName = record.value?.name || '';
|
||||
return roleName ? `授权菜单 - ${roleName}` : '授权菜单';
|
||||
});
|
||||
|
||||
// 勾选的key
|
||||
const checkedKeys = ref([]);
|
||||
// 提交的勾选的key,会进行特殊处理,包含半勾状态的父节点halfCheckedKeys
|
||||
@@ -111,7 +117,7 @@ defineExpose(DrawerApi);
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<Drawer class="w-[60%]" title="授权菜单">
|
||||
<Drawer class="w-[60%]" :title="drawerTitle">
|
||||
<Button type="primary" @click="handleExpandAndCollapse">
|
||||
{{ isExpand ? '折叠' : '展开' }}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 菜单搜索选择组件(自定义实现)
|
||||
*
|
||||
* @description 自定义菜单搜索下拉选择器,不依赖antd的Select组件
|
||||
* - 支持模糊搜索菜单 title
|
||||
* - 支持多选
|
||||
* - 只显示叶子节点(有 path 和 component 的菜单项)
|
||||
* - 支持 roleMenuIds 过滤
|
||||
* @author 系统
|
||||
* @date 2024
|
||||
*/
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { Input, Spin, Tag } from 'ant-design-vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { getMenuTreeOption, searchMenu } from '#/views/system/menu/api';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
// ==================== Props 定义 ====================
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 选中的菜单ID列表
|
||||
*/
|
||||
modelValue?: number[];
|
||||
/**
|
||||
* 是否按用户过滤
|
||||
*/
|
||||
filterByUser?: boolean;
|
||||
/**
|
||||
* 角色已授权的菜单ID列表
|
||||
*/
|
||||
roleMenuIds?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
filterByUser: true,
|
||||
roleMenuIds: undefined,
|
||||
});
|
||||
|
||||
// ==================== Emits 定义 ====================
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 选中值变化时触发
|
||||
* @param value 选中的菜单ID列表
|
||||
*/
|
||||
(e: 'update:modelValue', value: number[]): void;
|
||||
}>();
|
||||
|
||||
// ==================== 响应式数据 ====================
|
||||
|
||||
/**
|
||||
* 搜索关键词
|
||||
*/
|
||||
const searchKeyword = ref('');
|
||||
|
||||
/**
|
||||
* 是否正在搜索
|
||||
*/
|
||||
const isSearching = ref(false);
|
||||
|
||||
/**
|
||||
* 扁平化的菜单列表(搜索结果或已选中的菜单)
|
||||
*/
|
||||
const menuList = ref<any[]>([]);
|
||||
|
||||
/**
|
||||
* 已选中菜单的完整信息(用于显示标签)
|
||||
*/
|
||||
const selectedMenuMap = ref<Map<number, any>>(new Map());
|
||||
|
||||
/**
|
||||
* 过滤后的菜单列表(搜索结果直接来自后端,不需要前端过滤)
|
||||
*/
|
||||
const filteredMenuList = computed(() => {
|
||||
return menuList.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 已选中的菜单列表(从 selectedMenuMap 获取)
|
||||
*/
|
||||
const selectedMenus = computed(() => {
|
||||
if (!props.modelValue || props.modelValue.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return props.modelValue
|
||||
.map((id) => selectedMenuMap.value.get(id))
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否显示下拉列表
|
||||
*/
|
||||
const showDropdown = ref(false);
|
||||
|
||||
/**
|
||||
* 组件容器引用
|
||||
*/
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* 当前高亮的选项索引(用于键盘导航)
|
||||
*/
|
||||
const highlightIndex = ref(-1);
|
||||
|
||||
// ==================== 方法定义 ====================
|
||||
|
||||
/**
|
||||
* 从树形数据中提取所有叶子节点
|
||||
* @param nodes 树形节点数组
|
||||
* @param roleMenuIds 角色已授权的菜单ID列表
|
||||
*/
|
||||
const flattenLeafNodes = (nodes: any[], roleMenuIds?: number[]): any[] => {
|
||||
const leafNodes: any[] = [];
|
||||
|
||||
const traverse = (node: any) => {
|
||||
const hasPath = node.path && node.path.trim() !== '';
|
||||
const hasComponent = node.component && node.component.trim() !== '';
|
||||
const noChildren = !node.children || node.children.length === 0;
|
||||
|
||||
// 如果是叶子节点
|
||||
if (hasPath && hasComponent && noChildren) {
|
||||
// 如果提供了 roleMenuIds,需要检查是否在授权列表中
|
||||
if (roleMenuIds === undefined || roleMenuIds.includes(node.id)) {
|
||||
leafNodes.push({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
icon: node.icon,
|
||||
path: node.path,
|
||||
component: node.component,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 递归遍历子节点
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(traverse);
|
||||
return leafNodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索菜单
|
||||
* @param keyword 搜索关键词
|
||||
*/
|
||||
const searchMenus = debounce(async (keyword: string) => {
|
||||
if (!keyword || keyword.length < 1) {
|
||||
menuList.value = [];
|
||||
showDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
showDropdown.value = true;
|
||||
|
||||
try {
|
||||
const res = await searchMenu({
|
||||
keyword: keyword,
|
||||
filter_by_user: props.filterByUser ? 1 : 0,
|
||||
});
|
||||
|
||||
// 如果提供了 roleMenuIds,需要过滤
|
||||
let filteredRes = res;
|
||||
if (props.roleMenuIds !== undefined && props.roleMenuIds.length > 0) {
|
||||
filteredRes = res.filter((menu: any) => props.roleMenuIds!.includes(menu.id));
|
||||
}
|
||||
|
||||
// 更新已选中菜单的信息(如果搜索结果中包含)
|
||||
filteredRes.forEach((menu: any) => {
|
||||
if (props.modelValue?.includes(menu.id)) {
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
}
|
||||
});
|
||||
|
||||
menuList.value = filteredRes;
|
||||
} catch (error) {
|
||||
console.error('搜索菜单失败:', error);
|
||||
menuList.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
/**
|
||||
* 处理输入变化
|
||||
* @param e 输入事件
|
||||
*/
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
searchKeyword.value = target.value;
|
||||
highlightIndex.value = -1;
|
||||
|
||||
if (target.value) {
|
||||
isSearching.value = true;
|
||||
searchMenus(target.value);
|
||||
} else {
|
||||
showDropdown.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输入框获得焦点
|
||||
*/
|
||||
function handleFocus() {
|
||||
if (filteredMenuList.value.length > 0 && searchKeyword.value) {
|
||||
showDropdown.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选中菜单
|
||||
* @param menu 选中的菜单数据
|
||||
*/
|
||||
function handleSelectMenu(menu: any) {
|
||||
const currentValue = props.modelValue || [];
|
||||
const isSelected = currentValue.includes(menu.id);
|
||||
|
||||
if (isSelected) {
|
||||
// 取消选中
|
||||
const newValue = currentValue.filter((id) => id !== menu.id);
|
||||
selectedMenuMap.value.delete(menu.id);
|
||||
emit('update:modelValue', newValue);
|
||||
} else {
|
||||
// 选中
|
||||
const newValue = [...currentValue, menu.id];
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
// 不清空搜索关键词,保持下拉显示
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理移除菜单
|
||||
* @param menuId 菜单ID
|
||||
*/
|
||||
function handleRemoveMenu(menuId: number) {
|
||||
const currentValue = props.modelValue || [];
|
||||
const newValue = currentValue.filter((id) => id !== menuId);
|
||||
selectedMenuMap.value.delete(menuId);
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* @param e 键盘事件
|
||||
*/
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown.value || filteredMenuList.value.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.min(
|
||||
highlightIndex.value + 1,
|
||||
filteredMenuList.value.length - 1,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightIndex.value >= 0) {
|
||||
handleSelectMenu(filteredMenuList.value[highlightIndex.value]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点击外部关闭下拉
|
||||
* @param e 点击事件
|
||||
*/
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据(用于显示已选中的菜单)
|
||||
*/
|
||||
const initData = async () => {
|
||||
try {
|
||||
// 如果需要显示已选中的菜单,加载完整列表
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
const res = await getMenuTreeOption({
|
||||
filterByUser: props.filterByUser ? 1 : 0,
|
||||
});
|
||||
|
||||
// 扁平化为叶子节点列表
|
||||
const allMenus = flattenLeafNodes(res, props.roleMenuIds);
|
||||
|
||||
// 更新已选中菜单的完整信息
|
||||
allMenus.forEach((menu) => {
|
||||
if (props.modelValue!.includes(menu.id)) {
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索时 menuList 会被更新,初始时为空
|
||||
menuList.value = [];
|
||||
} catch (error) {
|
||||
console.error('获取菜单列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
initData();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
// ==================== 监听 ====================
|
||||
|
||||
/**
|
||||
* 监听 roleMenuIds 变化,重新初始化数据
|
||||
*/
|
||||
watch(
|
||||
() => props.roleMenuIds,
|
||||
(newVal) => {
|
||||
if (newVal !== undefined) {
|
||||
initData();
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 filterByUser 变化,重新初始化数据
|
||||
*/
|
||||
watch(
|
||||
() => props.filterByUser,
|
||||
() => {
|
||||
initData();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 modelValue 变化,更新已选中菜单信息
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newVal) => {
|
||||
if (newVal && newVal.length > 0) {
|
||||
// 检查是否有缺失的菜单信息
|
||||
const missingIds = newVal.filter((id) => !selectedMenuMap.value.has(id));
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
// 加载缺失的菜单信息
|
||||
try {
|
||||
const res = await getMenuTreeOption({
|
||||
filterByUser: props.filterByUser ? 1 : 0,
|
||||
});
|
||||
|
||||
const allMenus = flattenLeafNodes(res, props.roleMenuIds);
|
||||
|
||||
allMenus.forEach((menu) => {
|
||||
if (missingIds.includes(menu.id)) {
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取菜单信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理已取消选中的菜单
|
||||
selectedMenuMap.value.forEach((menu, id) => {
|
||||
if (!newVal.includes(id)) {
|
||||
selectedMenuMap.value.delete(id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 清空已选中菜单
|
||||
selectedMenuMap.value.clear();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// ==================== 暴露方法 ====================
|
||||
|
||||
/**
|
||||
* 暴露方法供父组件调用
|
||||
*/
|
||||
defineExpose({
|
||||
refresh: initData,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="menu-search-select">
|
||||
<!-- 搜索输入框 -->
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="输入菜单名称搜索..."
|
||||
allow-clear
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<LoadingOutlined v-if="isSearching" class="text-gray-400" />
|
||||
<SearchOutlined v-else class="text-gray-400" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- 已选中的菜单标签 -->
|
||||
<div v-if="selectedMenus.length > 0" class="selected-tags">
|
||||
<Tag
|
||||
v-for="menu in selectedMenus"
|
||||
:key="menu.id"
|
||||
closable
|
||||
class="mb-2"
|
||||
@close="handleRemoveMenu(menu.id)"
|
||||
>
|
||||
<Icon v-if="menu.icon" :icon="menu.icon" class="mr-1" />
|
||||
{{ $t(menu.title) }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
已选择 {{ selectedMenus.length }} 个菜单项
|
||||
</div>
|
||||
|
||||
<!-- 下拉列表 -->
|
||||
<div v-if="showDropdown" class="menu-dropdown">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isSearching" class="menu-dropdown__loading">
|
||||
<Spin size="small" />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 无结果 -->
|
||||
<div
|
||||
v-else-if="filteredMenuList.length === 0"
|
||||
class="menu-dropdown__empty"
|
||||
>
|
||||
暂无匹配菜单
|
||||
</div>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<div v-else class="menu-dropdown__list">
|
||||
<div
|
||||
v-for="(menu, index) in filteredMenuList"
|
||||
:key="menu.id"
|
||||
class="menu-item"
|
||||
:class="{
|
||||
'menu-item--active': index === highlightIndex,
|
||||
'menu-item--selected': props.modelValue?.includes(menu.id),
|
||||
}"
|
||||
@click="handleSelectMenu(menu)"
|
||||
@mouseenter="highlightIndex = index"
|
||||
>
|
||||
<div class="menu-item__content">
|
||||
<Icon v-if="menu.icon" :icon="menu.icon" class="menu-item__icon" />
|
||||
<div class="menu-item__info">
|
||||
<div class="menu-item__title">{{ $t(menu.title) }}</div>
|
||||
<div class="menu-item__path">{{ menu.path }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.modelValue?.includes(menu.id)" class="menu-item__check">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-search-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
margin-top: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
&__loading,
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__path {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__check {
|
||||
flex-shrink: 0;
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.dark {
|
||||
.menu-dropdown {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&__path {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
342
apps/web-antd/src/views/system/role/components/quick-nav.vue
Normal file
342
apps/web-antd/src/views/system/role/components/quick-nav.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Card, Row, Col, Empty, Popconfirm } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { getMenuTreeOption } from '#/views/system/menu/api';
|
||||
import { getRoleQuickNavList, deleteRoleQuickNav, batchSaveRoleQuickNav } from '#/views/system/role-quick-nav/api';
|
||||
import { getMenuIdsByRoleIds } from '../api';
|
||||
import MenuSearchSelect from './menu-search-select.vue';
|
||||
|
||||
const record = ref();
|
||||
const quickNavList = ref<any[]>([]);
|
||||
const menuTreeData = ref<any[]>([]);
|
||||
const checkedMenuIds = ref<number[]>([]);
|
||||
const loading = ref(false);
|
||||
const roleMenuIds = ref<number[]>([]); // 角色已授权的菜单ID列表
|
||||
|
||||
// 计算标题:快捷导航管理 - 角色名称
|
||||
const drawerTitle = computed(() => {
|
||||
const roleName = record.value?.name || '';
|
||||
return roleName ? `快捷导航管理 - ${roleName}` : '快捷导航管理';
|
||||
});
|
||||
|
||||
// 从菜单树中获取菜单信息
|
||||
const getMenuById = (menuId: number) => {
|
||||
const findMenu = (nodes: any[]): any => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === menuId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findMenu(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findMenu(menuTreeData.value);
|
||||
};
|
||||
|
||||
// 合并已保存和新选择的快捷导航
|
||||
const displayQuickNavs = computed(() => {
|
||||
// 已保存的快捷导航
|
||||
const saved = quickNavList.value.map((item) => ({
|
||||
...item,
|
||||
isSaved: true,
|
||||
}));
|
||||
|
||||
// 新选择但未保存的快捷导航(从菜单树查找)
|
||||
const newSelected = checkedMenuIds.value
|
||||
.filter((id) => !quickNavList.value.some((item) => item.menu_id === id))
|
||||
.map((menuId, index) => {
|
||||
const menu = getMenuById(menuId);
|
||||
if (!menu) return null;
|
||||
return {
|
||||
id: menuId,
|
||||
menu_id: menuId,
|
||||
title: menu.title || '',
|
||||
icon: menu.icon || '',
|
||||
url: menu.path || '',
|
||||
color: ['#1fdaca', '#bf0c2c', '#e18525', '#4daf1bc9', '#00d8ff'][index % 5] || '#00d8ff',
|
||||
sort: quickNavList.value.length + index + 1,
|
||||
isSaved: false,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return [...saved, ...newSelected];
|
||||
});
|
||||
|
||||
// 获取角色已授权的菜单ID列表
|
||||
const fetchRoleMenuIds = async () => {
|
||||
if (!record.value?.id) {
|
||||
roleMenuIds.value = undefined; // 改为 undefined
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getMenuIdsByRoleIds({
|
||||
id: record.value.id,
|
||||
});
|
||||
// 空数组也改为 undefined,表示该角色没有授权任何菜单
|
||||
roleMenuIds.value = Array.isArray(res) && res.length > 0 ? res : undefined;
|
||||
} catch (error) {
|
||||
console.error('获取角色菜单ID失败:', error);
|
||||
message.error('获取角色菜单ID失败');
|
||||
roleMenuIds.value = undefined; // 改为 undefined
|
||||
}
|
||||
};
|
||||
|
||||
// 获取菜单树数据
|
||||
const fetchMenuTree = async () => {
|
||||
try {
|
||||
const res = await getMenuTreeOption({
|
||||
filterByUser: 1,
|
||||
});
|
||||
menuTreeData.value = res;
|
||||
} catch (error) {
|
||||
console.error('获取菜单树失败:', error);
|
||||
message.error('获取菜单树失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取快捷导航列表
|
||||
const fetchQuickNavList = async () => {
|
||||
if (!record.value?.id) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getRoleQuickNavList({
|
||||
role_id: record.value.id,
|
||||
page: 1,
|
||||
pageSize: 1000, // 获取所有数据
|
||||
});
|
||||
// 处理返回的数据格式
|
||||
const items = res.items || res.data || res || [];
|
||||
// 按sort排序,然后按id排序
|
||||
quickNavList.value = items.sort((a: any, b: any) => {
|
||||
if (a.sort !== b.sort) {
|
||||
return (a.sort || 0) - (b.sort || 0);
|
||||
}
|
||||
return (a.id || 0) - (b.id || 0);
|
||||
});
|
||||
|
||||
// 设置已选中的菜单ID
|
||||
checkedMenuIds.value = items.map((item: any) => item.menu_id).filter(Boolean);
|
||||
|
||||
// 使用 nextTick 确保 MenuTreeSelector 能正确响应
|
||||
await nextTick();
|
||||
} catch (error) {
|
||||
console.error('获取快捷导航列表失败:', error);
|
||||
message.error('获取快捷导航列表失败');
|
||||
quickNavList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 批量保存快捷导航
|
||||
const handleSave = async () => {
|
||||
if (!record.value?.id) {
|
||||
message.error('角色ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkedMenuIds.value.length === 0) {
|
||||
message.warning('请至少选择一个菜单');
|
||||
return;
|
||||
}
|
||||
|
||||
DrawerApi.setState({
|
||||
loading: true,
|
||||
confirmLoading: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await batchSaveRoleQuickNav({
|
||||
role_id: record.value.id,
|
||||
menu_id: checkedMenuIds.value,
|
||||
});
|
||||
message.success('保存成功');
|
||||
await fetchQuickNavList();
|
||||
DrawerApi.close();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
DrawerApi.setState({
|
||||
loading: false,
|
||||
confirmLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除快捷导航(从已选中移除)
|
||||
const handleRemove = (menuId: number) => {
|
||||
checkedMenuIds.value = checkedMenuIds.value.filter((id) => id !== menuId);
|
||||
message.success('已移除');
|
||||
};
|
||||
|
||||
// 删除已保存的快捷导航(从数据库删除)
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 找到要删除的快捷导航
|
||||
const item = quickNavList.value.find((item) => item.id === id);
|
||||
if (item && item.menu_id) {
|
||||
// 从选中列表中移除
|
||||
checkedMenuIds.value = checkedMenuIds.value.filter((menuId) => menuId !== item.menu_id);
|
||||
}
|
||||
|
||||
await deleteRoleQuickNav({ ids: [id] });
|
||||
message.success('删除成功');
|
||||
await fetchQuickNavList();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const [Drawer, DrawerApi] = useVbenDrawer({
|
||||
onOpenChange(isOpen) {
|
||||
record.value = isOpen ? DrawerApi.getData()?.record : {};
|
||||
if (isOpen) {
|
||||
DrawerApi.setState({
|
||||
loading: true,
|
||||
});
|
||||
// 先获取角色已授权的菜单ID,再加载菜单树,最后加载快捷导航列表
|
||||
fetchRoleMenuIds()
|
||||
.then(() => {
|
||||
// 等待 MenuTreeSelector 初始化完成,响应 roleMenuIds 的变化
|
||||
return nextTick();
|
||||
})
|
||||
.then(() => fetchMenuTree())
|
||||
.then(() => {
|
||||
// 等待菜单树准备好后再获取快捷导航列表
|
||||
return nextTick();
|
||||
})
|
||||
.then(() => fetchQuickNavList())
|
||||
.finally(() => {
|
||||
DrawerApi.setState({
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
onConfirm: handleSave,
|
||||
});
|
||||
|
||||
// 暴露DrawerApi供父组件调用
|
||||
defineExpose({
|
||||
DrawerApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="drawerTitle" class="w-[95%]">
|
||||
<div class="flex h-full gap-4">
|
||||
<!-- 左侧:菜单树选择 -->
|
||||
<div class="w-1/2 border-r pr-4">
|
||||
<MenuSearchSelect
|
||||
v-model="checkedMenuIds"
|
||||
:filter-by-user="true"
|
||||
:role-menu-ids="roleMenuIds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已选择的快捷导航卡片列表 -->
|
||||
<div class="w-1/2 pl-4 flex flex-col">
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
已选择 {{ displayQuickNavs.length }} 个快捷导航
|
||||
<span v-if="quickNavList.length > 0" class="ml-2 text-gray-400">
|
||||
(已保存 {{ quickNavList.length }} 个)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 flex-1">
|
||||
<Icon icon="ant-design:loading-outlined" class="text-2xl animate-spin" />
|
||||
</div>
|
||||
<Empty
|
||||
v-else-if="displayQuickNavs.length === 0"
|
||||
description="请在左侧选择菜单"
|
||||
class="flex-1"
|
||||
/>
|
||||
<div v-else class="flex-1 overflow-y-auto">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in displayQuickNavs"
|
||||
:key="`${item.menu_id}-${item.isSaved ? 'saved' : 'new'}`"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
>
|
||||
<Card
|
||||
class="quick-nav-card"
|
||||
:style="{
|
||||
borderTop: `4px solid ${item.color || '#00d8ff'}`,
|
||||
}"
|
||||
hoverable
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center p-4 min-h-[180px]">
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
class="mb-3 flex items-center justify-center"
|
||||
:style="{
|
||||
color: item.color || '#00d8ff',
|
||||
}"
|
||||
>
|
||||
<Icon :icon="item.icon" class="text-4xl" />
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="mb-2 text-center font-medium text-base truncate w-full">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
|
||||
<!-- 跳转地址 -->
|
||||
<div class="mb-3 text-center text-xs text-gray-500 truncate w-full px-2">
|
||||
{{ item.url }}
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<Popconfirm
|
||||
:title="item.isSaved ? '确定要删除这个快捷导航吗?' : '确定要移除这个快捷导航吗?'"
|
||||
@confirm="item.isSaved ? handleDelete(item.id) : handleRemove(item.menu_id)"
|
||||
>
|
||||
<Button type="link" size="small" danger @click.stop>
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
{{ item.isSaved ? '删除' : '移除' }}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-nav-card {
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-nav-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.quick-nav-card :deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import AuthMenu from "#/views/system/role/components/auth-menu.vue";
|
||||
import QuickNav from "#/views/system/role/components/quick-nav.vue";
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
@@ -65,6 +66,17 @@ const handleAuthMenu = (record: any) => {
|
||||
});
|
||||
authMenuRef.value.open();
|
||||
};
|
||||
|
||||
// 快捷导航
|
||||
const quickNavRef = ref();
|
||||
const handleQuickNav = (record: any) => {
|
||||
if (quickNavRef.value && quickNavRef.value.DrawerApi) {
|
||||
quickNavRef.value.DrawerApi.setData({
|
||||
record,
|
||||
});
|
||||
quickNavRef.value.DrawerApi.open();
|
||||
}
|
||||
};
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
@@ -83,6 +95,7 @@ const deleteApi = (row: any) => {
|
||||
<Page auto-content-height title="角色管理">
|
||||
<FormModal />
|
||||
<AuthMenu ref="authMenuRef" />
|
||||
<QuickNav ref="quickNavRef" />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -139,6 +152,14 @@ const deleteApi = (row: any) => {
|
||||
// auth: ['admin', 'sys:role:detail'],
|
||||
onClick: handleAuthMenu.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '快捷导航',
|
||||
type: 'link',
|
||||
icon: 'mdi:compass-outline',
|
||||
size: 'small',
|
||||
// auth: ['admin', 'sys:role:detail'],
|
||||
onClick: handleQuickNav.bind(null, row),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user