开源准备

This commit is contained in:
zhangtao
2024-12-10 17:39:26 +08:00
commit cbaf324e19
315 changed files with 39314 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
<template>
<ConfigProvider :locale="zhCN">
<div id="app">
<router-view />
</div>
</ConfigProvider>
</template>
<script lang="ts" setup>
import { ConfigProvider } from 'ant-design-vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { useRouter } from "vue-router";
dayjs.locale('zh-cn');
// 禁用Promise reject输出控制台
window.addEventListener('unhandledrejection', function browserRejectionHandler(event) {
event && event.preventDefault()
})
</script>
<style>
* {
margin: 0;
padding: 0;
}
body,
html {
margin: 0;
width: 100%;
height: 100vh;
}
.ant-page-header {
padding: 20px 0;
}
.ant-table-thead>tr>th {
padding: 12px 8px !important;
}
.ant-table-wrapper .ant-table-tbody>tr>td {
padding: 12px 8px;
}
.ant-modal .ant-modal-header {
margin-bottom: 20px;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
import request from "@/utils/request";
export function getFileList(query) {
return request({
url: "/api/v1/common/file/list",
method: "get",
params: query,
});
}
export function uploadFile(body) {
return request({
url: "/api/v1/common/file/upload",
method: "post",
data: body,
});
}
export function downloadFile(query) {
return request({
url: "/api/v1/common/file/download",
method: "get",
params: query,
});
}
export function downloadFileResource(query) {
return request({
url: "/api/v1/common/file/download/resource",
method: "get",
params: query,
});
}
export function uploadFileOne(body) {
return request({
url: "/api/v1/common/file/upload/one",
method: "post",
data: body,
});
}
export function uploadFileMany(body) {
return request({
url: "/api/v1/common/file/upload/many",
method: "post",
data: body,
});
}
+51
View File
@@ -0,0 +1,51 @@
import request from "@/utils/request";
export function getCacheInfo(query) {
return request({
url: "/api/v1/monitor/cache/info",
method: "get",
params: query,
});
}
export function getCacheNames() {
return request({
url: "/api/v1/monitor/cache/get/names",
method: "get",
});
}
export function getCacheKeys(cacheName) {
return request({
url: `/api/v1/monitor/cache/get/keys/${cacheName}`,
method: "get",
});
}
export function getCacheValue(cacheName, cacheKey) {
return request({
url: `/api/v1/monitor/cache/get/value/${cacheName}/${cacheKey}`,
method: "get",
});
}
export function deleteCacheName(cacheName) {
return request({
url: `/api/v1/monitor/cache/delete/name/${cacheName}`,
method: "delete",
});
}
export function deleteCacheKey(cacheKey) {
return request({
url: `/api/v1/monitor/cache/delete/key/${cacheKey}`,
method: "delete",
});
}
export function deleteCacheAll() {
return request({
url: "/api/v1/monitor/cache/delete/all",
method: "delete",
});
}
+19
View File
@@ -0,0 +1,19 @@
import request from "@/utils/request";
// 查询在线用户列表
export function getOnlineList(query) {
return request({
url: '/monitor/online/list',
method: 'get',
params: query
})
}
// 强退用户
export function deleteOnline(body) {
return request({
url: "/monitor/online/delete",
method: "delete",
data: body,
});
}
+9
View File
@@ -0,0 +1,9 @@
import request from "@/utils/request";
// 获取服务信息
export function getServer() {
return request({
url: '/monitor/server/info',
method: 'get'
})
}
+35
View File
@@ -0,0 +1,35 @@
import request from "@/utils/request";
export function login(body) {
return request({
url: "/api/v1/system/auth/login",
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
data: body,
});
}
export function getNewToken(body) {
return request({
url: "/api/v1/system/auth/token/refresh",
method: "post",
data: body,
});
}
export function getCaptcha() {
return request({
url: "/api/v1/system/auth/captcha/get",
method: "post",
});
}
export function logout(body) {
return request({
url: "/api/v1/system/auth/logout",
method: "post",
data: body,
});
}
+50
View File
@@ -0,0 +1,50 @@
import request from "@/utils/request";
export function getDeptList(query) {
return request({
url: "/api/v1/system/dept/list",
method: "get",
params: query,
});
}
export function getDeptDetail(query) {
return request({
url: "/api/v1/system/dept/detail",
method: "get",
params: query,
});
}
export function createDept(body) {
return request({
url: "/api/v1/system/dept/create",
method: "post",
data: body,
});
}
export function updateDept(body) {
return request({
url: "/api/v1/system/dept/update",
method: "put",
data: body,
});
}
export function deleteDept(query) {
return request({
url: "/api/v1/system/dept/delete",
method: "delete",
params: query,
});
}
export function batchAvailableDept(body) {
return request({
url: "/api/v1/system/dept/available/setting",
method: "patch",
data: body,
});
}
+33
View File
@@ -0,0 +1,33 @@
import request from "@/utils/request";
export function getLogList(query) {
return request({
url: "/api/v1/system/log/list",
method: "get",
params: query,
});
}
export function getLogDetail(query) {
return request({
url: "/api/v1/system/log/detail",
method: "get",
params: query,
});
}
export function deleteLog(query) {
return request({
url: "/api/v1/system/log/delete",
method: "delete",
params: query,
});
}
export function exportLog(body) {
return request({
url: "/api/v1/system/log/export",
method: "post",
data: body,
});
}
+50
View File
@@ -0,0 +1,50 @@
import request from "@/utils/request";
export function getMenuList(query) {
return request({
url: "/api/v1/system/menu/list",
method: "get",
params: query,
});
}
export function getMenuDetail(query) {
return request({
url: "/api/v1/system/menu/detail",
method: "get",
params: query,
});
}
export function createMenu(body) {
return request({
url: "/api/v1/system/menu/create",
method: "post",
data: body,
});
}
export function updateMenu(body) {
return request({
url: "/api/v1/system/menu/update",
method: "put",
data: body,
});
}
export function deleteMenu(query) {
return request({
url: "/api/v1/system/menu/delete",
method: "delete",
params: query,
});
}
export function batchAvailableMenu(body) {
return request({
url: "/api/v1/system/menu/available/setting",
method: "patch",
data: body,
});
}
+57
View File
@@ -0,0 +1,57 @@
import request from "@/utils/request";
export function getNoticeList(query) {
return request({
url: "/api/v1/system/notice/list",
method: "get",
params: query,
});
}
export function getNoticeDetail(query) {
return request({
url: "/api/v1/system/notice/detail",
method: "get",
params: query,
});
}
export function createNotice(body) {
return request({
url: "/api/v1/system/notice/create",
method: "post",
data: body,
});
}
export function updateNotice(body) {
return request({
url: "/api/v1/system/notice/update",
method: "put",
data: body,
});
}
export function deleteNotice(query) {
return request({
url: "/api/v1/system/notice/delete",
method: "delete",
params: query,
});
}
export function batchAvailableNotice(body) {
return request({
url: "/api/v1/system/notice/available/setting",
method: "patch",
data: body,
});
}
export function exportNotice(body) {
return request({
url: "/api/v1/system/notice/export",
method: "post",
data: body,
});
}
+57
View File
@@ -0,0 +1,57 @@
import request from "@/utils/request";
export function getPositionList(query) {
return request({
url: "/api/v1/system/position/list",
method: "get",
params: query,
});
}
export function getPositionDetail(query) {
return request({
url: "/api/v1/system/position/detail",
method: "get",
params: query,
});
}
export function createPosition(body) {
return request({
url: "/api/v1/system/position/create",
method: "post",
data: body,
});
}
export function updatePosition(body) {
return request({
url: "/api/v1/system/position/update",
method: "put",
data: body,
});
}
export function deletePosition(query) {
return request({
url: "/api/v1/system/position/delete",
method: "delete",
params: query,
});
}
export function batchAvailablePosition(body) {
return request({
url: "/api/v1/system/position/available/setting",
method: "patch",
data: body,
});
}
export function exportPosition(body) {
return request({
url: "/api/v1/system/position/export",
method: "post",
data: body,
});
}
+65
View File
@@ -0,0 +1,65 @@
import request from "@/utils/request";
export function getRoleList(query) {
return request({
url: "/api/v1/system/role/list",
method: "get",
params: query,
});
}
export function getRoleDetail(query) {
return request({
url: "/api/v1/system/role/detail",
method: "get",
params: query,
});
}
export function createRole(body) {
return request({
url: "/api/v1/system/role/create",
method: "post",
data: body,
});
}
export function updateRole(body) {
return request({
url: "/api/v1/system/role/update",
method: "put",
data: body,
});
}
export function deleteRole(query) {
return request({
url: "/api/v1/system/role/delete",
method: "delete",
params: query,
});
}
export function batchAvailableRole(body) {
return request({
url: "/api/v1/system/role/available/setting",
method: "patch",
data: body,
});
}
export function setPermission(body) {
return request({
url: "/api/v1/system/role/permission/setting",
method: "patch",
data: body,
});
}
export function exportRole(body) {
return request({
url: "/api/v1/system/role/export",
method: "post",
data: body,
});
}
+121
View File
@@ -0,0 +1,121 @@
import request from "@/utils/request";
export function getCurrentUserInfo() {
return request({
url: "/api/v1/system/user/current/info",
method: "get",
});
}
export function uploadCurrentUserAvatar(body) {
return request({
url: "/api/v1/system/user/current/avatar/upload",
method: "post",
data: body,
headers: { "Content-Type": "multipart/form-data" },
});
}
export function updateCurrentUserInfo(body) {
return request({
url: "/api/v1/system/user/current/info/update",
method: "put",
data: body,
});
}
export function changeCurrentUserPassword(body) {
return request({
url: "/api/v1/system/user/current/password/change",
method: "put",
data: body,
});
}
export function registerUser(body) {
return request({
url: "/api/v1/system/user/register",
method: "post",
data: body,
});
}
export function forgetPassword(body) {
return request({
url: "/api/v1/system/user/forget/password",
method: "post",
data: body,
});
}
export function getUserList(query) {
return request({
url: "/api/v1/system/user/list",
method: "get",
params: query,
});
}
export function getUserDetail(query) {
return request({
url: "/api/v1/system/user/detail",
method: "get",
params: query,
});
}
export function createUser(body) {
return request({
url: "/api/v1/system/user/create",
method: "post",
data: body,
});
}
export function updateUser(body) {
return request({
url: "/api/v1/system/user/update",
method: "put",
data: body,
});
}
export function deleteUser(query) {
return request({
url: "/api/v1/system/user/delete",
method: "delete",
params: query,
});
}
export function batchAvailableUser(body) {
return request({
url: "/api/v1/system/user/available/setting",
method: "patch",
data: body,
});
}
export function exportUser(body) {
return request({
url: "/api/v1/system/user/export",
method: "post",
data: body,
});
}
export function importUserTemplate(body) {
return request({
url: "/api/v1/system/user/import/template",
method: "post",
data: body,
});
}
export function importUserData(body) {
return request({
url: "/api/v1/system/user/import/data",
method: "post",
data: body,
});
}
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="page-header">
<div class="page-header-breadcrumb">
<a-breadcrumb :routes="routes">
<template #itemRender="{ route, routes }">
<span v-if="routes.indexOf(route) === routes.length - 1">{{ route.breadcrumbName }}</span>
<router-link v-else :to="route.path">{{ route.breadcrumbName }}</router-link>
</template>
</a-breadcrumb>
</div>
<div class="page-header-title">
{{ title }}
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useRoute } from "vue-router";
const title = ref<string | undefined>('');
interface Route {
path: string,
breadcrumbName: string | undefined
}
const routes = ref<Route[]>([])
const route = useRoute();
const getBreadcrumb = () => {
title.value = route.meta.title?.toString();
routes.value.splice(0, routes.value.length);
if (route.matched.length > 2) {
route.matched.forEach((item, index) => {
if (index !== 0) {
routes.value.push({ path: item.path, breadcrumbName: item.meta.title?.toString() })
}
})
}
}
onMounted(() => getBreadcrumb());
</script>
<style lang="scss" scoped>
.page-header {
padding: 10px 0;
}
.page-header-title {
font-size: 20px;
font-weight: 600;
padding: 10px 0;
}
</style>
+390
View File
@@ -0,0 +1,390 @@
<template>
<a-layout class="basic-layout">
<!-- 顶部导航栏 -->
<a-layout-header class="header">
<div class="header-content">
<!-- 左侧 Logo -->
<div class="header-left">
<a-button type="link" class="logo-btn">
<a-image src="/logo.png" :preview="false" :width="28" :height="28" />
<h1 class="logo-title">fastapi-vue-admin</h1>
</a-button>
<a-button
type="link"
class="collapse-btn"
@click="toggleCollapsed"
>
<MenuFoldOutlined v-if="menuState.collapsed" />
<MenuUnfoldOutlined v-else />
</a-button>
</div>
<!-- 右侧工具栏 -->
<div class="header-right">
<!-- 文档按钮 -->
<a-tooltip title="说明文档" placement="bottom">
<a-button type="link" @click="openDocumentation" class="tool-btn">
<QuestionCircleOutlined />
</a-button>
</a-tooltip>
<!-- 通知按钮 -->
<a-badge :count="unreadCount" :dot="unreadCount > 99">
<a-button type="link" @click="openNotification" class="tool-btn">
<BellOutlined />
</a-button>
</a-badge>
<!-- 用户信息下拉菜单 -->
<a-dropdown>
<div class="user-dropdown">
<a-avatar :src="userAvatar" :size="32" />
<span class="username">{{ username }}</span>
<DownOutlined />
</div>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile">
<template #icon><UserOutlined /></template>
个人中心
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<template #icon><LogoutOutlined /></template>
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</a-layout-header>
<a-layout class="main-container">
<!-- 侧边栏 -->
<a-layout-sider
class="sider"
:collapsed="menuState.collapsed"
:trigger="null"
collapsible
:width="240"
:collapsedWidth="80"
>
<a-menu
class="side-menu"
mode="inline"
theme="light"
v-model:openKeys="menuState.openKeys"
v-model:selectedKeys="menuState.selectedKeys"
:items="menuState.menus"
@click="handleMenuClick"
/>
</a-layout-sider>
<!-- 主内容区 -->
<a-layout-content
class="content"
:class="{ collapsed: menuState.collapsed }"
>
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<div :key="route.path">
<keep-alive v-if="route.meta.keepAlive">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</div>
</transition>
</router-view>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script lang="ts" setup>
import { reactive, computed, watch, onMounted } from "vue";
import type { MenuProps, ItemType } from "ant-design-vue";
import { useRouter, useRoute } from "vue-router";
import storage from 'store';
import store from '@/store';
import * as icons from '@ant-design/icons-vue';
import { h } from 'vue';
import { listToTree } from '@/utils/util';
import { logout } from '@/api/system/auth';
import {
QuestionCircleOutlined,
BellOutlined,
UserOutlined,
LogoutOutlined,
DownOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons-vue';
// 路由相关
const router = useRouter();
const route = useRoute();
// 计算属性
const username = computed(() => store.state.user.basicInfo.username);
const userAvatar = computed(() => store.state.user.basicInfo.avatar);
const unreadCount = computed(() => 5); // 这里可以替换为实际的未读消息数量
// 菜单状态
const menuState = reactive({
collapsed: false,
rootSubmenuKeys: [],
openKeys: [],
selectedKeys: [route.path],
menus: []
});
// 切换菜单折叠状态
const toggleCollapsed = () => {
menuState.collapsed = !menuState.collapsed;
};
// 处理用户菜单点击
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'profile':
router.push('/profile');
break;
case 'logout':
handleLogout();
break;
}
};
// 处理退出登录
const handleLogout = async () => {
try {
await logout({ token: storage.get('Access-Token') });
storage.remove('Access-Token');
storage.remove('Refresh-Token');
await store.dispatch('clearUserInfo');
router.push('/login');
} catch (error) {
console.error('退出登录失败:', error);
}
};
// 处理菜单点击
const handleMenuClick: MenuProps['onClick'] = (menuInfo) => {
menuState.openKeys = menuInfo.keyPath.slice(0, -1) as string[];
router.push(menuInfo.key.toString());
};
// 监听路由变化
watch(() => route.path, (newPath) => {
const routePath = store.state.user.routeList.map(item => item.route_path);
if (!routePath.includes(newPath)) {
menuState.openKeys = [];
menuState.selectedKeys = [];
} else {
menuState.selectedKeys = [newPath];
}
});
// 生成菜单项
const generateMenuItem = (item: any): ItemType => {
const icon = item.icon ? () => h(icons[item.icon]) : null;
const children = item.children?.length
? item.children
.filter((child: any) => !child.hidden)
.map(generateMenuItem)
: undefined;
return {
key: item.route_path,
title: item.name,
label: item.name,
icon: icon,
children: children?.length ? children : undefined
};
};
// 初始化菜单
const initMenu = () => {
// 确保路由列表存在
if (!store.state.user.routeList?.length) {
return;
}
const menuTree = listToTree(store.state.user.routeList);
menuState.menus = menuTree
.filter(item => !item.hidden)
.map(generateMenuItem)
.filter(item => item !== null);
// 设置当前选中的菜单
const currentPath = route.path;
menuState.selectedKeys = [currentPath];
// 设置展开的菜单
const findParentPath = (menus: any[], targetPath: string, parentPath: string[] = []): string[] => {
for (const menu of menus) {
if (menu.key === targetPath) {
return parentPath;
}
if (menu.children?.length) {
const result = findParentPath(menu.children, targetPath, [...parentPath, menu.key]);
if (result.length) {
return result;
}
}
}
return [];
};
menuState.openKeys = findParentPath(menuState.menus, currentPath);
};
// 打开文档
const openDocumentation = () => {
window.open('/site/index.html', '_blank');
};
// 打开通知面板
const openNotification = () => {
console.log('打开通知面板');
};
onMounted(() => {
initMenu();
});
</script>
<style lang="scss" scoped>
.basic-layout {
min-height: 100vh;
:deep(.ant-layout-header) {
background: #fff;
padding: 0;
height: var(--header-height);
line-height: var(--header-height);
}
.header {
.header-content {
height: 60px;
display: flex;
align-items: center;
.header-left {
display: flex;
align-items: center;
.logo-btn {
display: flex;
align-items: center;
padding: 0 8px;
.logo-title {
margin: 0 0 0 12px;
color: rgba(0, 0, 0, 0.85);
font-weight: 600;
font-size: 18px;
white-space: nowrap;
}
}
}
.header-right {
display: flex;
align-items: center;
margin-left: auto;
.tool-btn {
padding: 0 12px;
font-size: 16px;
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
.user-dropdown {
display: flex;
align-items: center;
padding: 0 12px;
cursor: pointer;
.username {
margin: 0 8px;
color: rgba(0, 0, 0, 0.85);
white-space: nowrap;
}
}
}
}
}
}
.main-container {
margin-top: var(--header-height);
height: calc(100vh - var(--header-height));
display: flex;
flex-direction: row;
.sider {
box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);
background: #fff;
.side-menu {
height: 100%;
border-right: 0;
padding: 16px 0;
:deep(.ant-menu-item) {
margin: 4px 8px;
padding: 0 16px;
border-radius: 4px;
&.ant-menu-item-selected {
background: rgba(24, 144, 255, 0.1);
color: var(--primary-color);
font-weight: 500;
}
}
}
.ant-layout-header {
padding-inline: 0;
}
.ant-layout-header {
padding-inline: 0;
}
}
.ant-layout-sider-children .ant-menu-light {
color: rgba(0, 0, 0, 0.65);
}
.ant-layout-sider-children .ant-menu-light {
color: rgba(0, 0, 0, 0.65);
}
.content {
flex: 1;
padding: 24px;
background: var(--background-color);
overflow-y: auto; /* 确保垂直滚动条在需要时出现 */
}
}
// 过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import VChart from "vue-echarts";
import "echarts";
const app = createApp(App);
app.use(router);
app.use(Antd);
app.component("VChart", VChart);
app.mount("#app");
+25
View File
@@ -0,0 +1,25 @@
const modules = import.meta.glob("../views/**/**.vue");
export const generator = (routers) => {
return routers.map((item) => {
const currentRouter = {
path: item.route_path,
name: item.route_name,
component: item.component_path
? modules[`../views/${item.component_path}.vue`]
: null,
redirect: item.redirect,
meta: {
title: item.name,
icon: item.icon || undefined,
keepAlive: item.cache,
hidden: item.hidden,
order: item.order,
},
};
if (item.children && item.children.length > 0) {
currentRouter.children = generator(item.children);
}
return currentRouter;
});
};
+59
View File
@@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from "vue-router";
import BasicLayout from "@/layouts/basicLayout.vue";
import Login from "@/views/system/auth/login.vue";
import storage from "store";
import store from "@/store";
import { generator } from "./generateRouter";
import { listToTree } from "@/utils/util";
const routes = [
{ path: "/login", name: "Login", component: Login },
{
path: "/:catchAll(.*)",
name: "404",
meta: { title: "404" },
component: () => import("../views/exception/404.vue"),
},
];
const rootRouter = {
path: "/",
name: "Index",
redirect: "/dashboard/workplace",
component: BasicLayout,
children: [
{
path: "/profile",
name: "Profile",
meta: {
title: "个人中心",
keepAlive: true,
},
component: () => import("../views/current/profile.vue"),
},
],
};
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const token = storage.get("Access-Token");
if (!token && to.name !== "Login") {
return { name: "Login" };
} else if (token && to.name === "Login") {
return { name: "Index" };
} else if (token && !store.state.user.hasGetRoute) {
return store.dispatch("getUserInfo").then(() => {
const routersTree = listToTree(store.state.user.routeList);
const routerMap = generator(routersTree);
rootRouter.children = [...rootRouter.children, ...routerMap];
router.addRoute(rootRouter);
return to.fullPath;
});
}
});
export default router;
+14
View File
@@ -0,0 +1,14 @@
import { createStore } from "vuex";
import user from "./modules/user";
const store = createStore({
modules: {
user,
},
state: {},
mutations: {},
actions: {},
getters: {},
});
export default store;
+44
View File
@@ -0,0 +1,44 @@
import { getCurrentUserInfo } from "@/api/system/user";
const user = {
state: {
basicInfo: {},
routeList: [],
hasGetRoute: false,
},
mutations: {
setRoute(state, routers) {
state.routeList = routers;
state.hasGetRoute = true;
},
setAvatar(state, avatar) {
state.basicInfo.avatar = avatar;
},
},
actions: {
getUserInfo({ commit, state }) {
return new Promise((resolve) => {
getCurrentUserInfo()
.then((response) => {
const result = response.data;
const routers = result.data.menus;
delete result.data.menus;
commit("setRoute", routers);
state.basicInfo = Object.assign(state.basicInfo || {}, result.data);
resolve();
})
.catch((error) => {
console.log(error);
});
});
},
clearUserInfo({ state }) {
state.basicInfo = {};
state.routeList = [];
state.hasGetRoute = false;
},
},
};
export default user;
+256
View File
@@ -0,0 +1,256 @@
:root {
/* 基础变量 */
--font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
--line-height: 1.6;
--font-weight-normal: 400;
--font-weight-bold: 500;
/* 布局变量 */
--header-height: 69px;
--sider-width: 240px;
--collapsed-sider-width: 80px;
--transition-duration: 0.2s;
/* 颜色变量 */
--primary-color: #1890ff;
--text-color: rgba(0, 0, 0, 0.85);
--text-color-secondary: rgba(0, 0, 0, 0.45);
--border-color: #f0f0f0;
--background-color: #f0f2f5;
--component-background: #fff;
--box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15);
--box-shadow-light: 0 1px 4px rgba(0, 0, 0, 0.08);
}
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 100%;
background: var(--background-color);
font-family: var(--font-family);
line-height: var(--line-height);
color: var(--text-color);
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 链接样式 */
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-duration);
}
a:hover {
color: var(--primary-color);
}
/* 按钮基础样式 */
button {
font-family: inherit;
}
/* 布局相关的全局样式 */
.basic-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: var(--header-height);
background: #fff;
box-shadow: var(--box-shadow-light);
}
.header-content {
height: 100%;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
flex-shrink: 0;
}
.logo-btn {
display: flex !important;
align-items: center !important;
padding: 0 8px !important;
color: var(--text-color) !important;
}
.logo-title {
margin: 0 0 0 12px !important;
color: var(--text-color) !important;
font-weight: 600 !important;
font-size: 18px !important;
line-height: 1.5 !important;
}
.collapse-btn {
padding: 0 12px !important;
font-size: 16px !important;
color: var(--text-color) !important;
}
.collapse-btn:hover {
color: var(--primary-color) !important;
}
.header-right {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
}
.tool-btn {
padding: 0 12px !important;
font-size: 16px !important;
color: var(--text-color) !important;
}
.tool-btn:hover {
color: var(--primary-color) !important;
}
.user-dropdown {
display: flex;
align-items: center;
padding: 0 12px;
cursor: pointer;
}
.username {
margin: 0 8px;
color: var(--text-color);
}
h1 {
font-size: 3.2em;
line-height: 1.1;
color: var(--text-color-secondary);
}
.card {
padding: var(--card-padding);
background-color: var(--color-background-card);
border-radius: var(--card-border-radius);
box-shadow: var(--card-shadow);
}
#app {
max-width: var(--app-max-width);
margin: 0 auto;
padding: var(--app-padding);
text-align: var(--app-text-align);
}
/* Ant Design Layout 样式覆盖 */
.ant-layout-header {
background: #fff !important;
padding: 0 !important;
height: var(--header-height) !important;
line-height: var(--header-height) !important;
}
/* Ant Design 按钮样式覆盖 */
.ant-btn-link {
color: var(--text-color) !important;
padding: 0 !important;
}
.ant-btn-link:hover {
color: var(--primary-color) !important;
}
/* Ant Design 下拉菜单样式 */
.ant-dropdown-menu {
padding: 4px 0 !important;
}
.ant-dropdown-menu-item {
padding: 8px 16px !important;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
color: #213547;
}
.card {
background-color: #fff;
}
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
h1 {
font-size: 2.4em;
}
.card {
padding: 1.5em;
}
.left-header {
padding-left: 5px;
}
.right-header {
margin-right: 10px;
}
.logo-btn {
width: 200px;
}
.doc-button {
padding: 8px 20px;
font-size: 12px;
}
}
+113
View File
@@ -0,0 +1,113 @@
import axios from "axios";
import storage from "store";
import router from "@/router";
import { getNewToken } from "@/api/system/auth";
import { save_token } from "@/utils/util";
import notification from "ant-design-vue/es/notification";
// 创建 axios 实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: import.meta.env.VITE_TIMEOUT, // 请求超时时间
});
// 异常拦截处理器
const errorHandler = async (error) => {
console.log(error);
if (!error.response) {
return Promise.reject(error);
}
const data = error.response.data;
// 从 localstorage 获取 access_token
const access_token = storage.get("Access-Token");
// || data.msg === "用户未登录或登录已过期"
if (data.status_code === 403) {
storage.remove("Access-Token");
storage.remove("Refresh-Token");
notification.error({
message: "错误",
description: data.msg,
});
router.push("/login");
return Promise.reject(error);
}
if (data.status_code === 401) {
if (!access_token) {
notification.error({
message: "错误",
description: data.msg,
});
router.push("/login");
return Promise.reject(error);
}
const refresh_token = storage.get("Refresh-Token");
storage.remove("Access-Token");
storage.remove("Refresh-Token");
return getNewToken({ refresh_token: refresh_token })
.then((response) => {
const result = response.data;
if (result.status_code === 200) {
save_token(
result.data.access_token,
result.data.refresh_token,
result.data.expires_in
);
return request(error.response.config).then((response) => response);
}
notification.error({
message: "错误",
description: result.msg,
});
router.push("/login");
return Promise.reject(error);
})
.catch((error) => {
notification.error({
message: "错误",
description: data.msg,
});
return Promise.reject(error);
});
} else {
notification.error({
message: "错误",
description: data.msg,
});
return Promise.reject(error);
}
};
// 请求拦截器
request.interceptors.request.use((config) => {
const token = storage.get("Access-Token");
// 如果 token 存在
// 让每个请求携带自定义 token 请根据实际情况自行修改
if (token) {
config.headers["Authorization"] = "Bearer " + token;
}
return config;
}, errorHandler);
// 响应拦截器
request.interceptors.response.use((response) => {
// 打印全局响应
console.log(response);
if (response.data.status_code !== 200) {
notification.error({
message: "错误",
description: response.data.msg,
});
// 对请求错误做些什么
return Promise.reject(response);
}
return response;
}, errorHandler);
export default request;
+131
View File
@@ -0,0 +1,131 @@
import storage from "store";
export function timeFix() {
const time = new Date();
const hour = time.getHours();
return hour < 9
? "早上好"
: hour <= 11
? "上午好"
: hour <= 13
? "中午好"
: hour < 20
? "下午好"
: "晚上好";
}
export function getRangeDate(startDate, endDate) {
let targetArr = [];
let start = new Date(startDate);
let end = new Date(endDate);
let startDateInfo = {
year: start.getFullYear(),
month: start.getMonth() + 1,
day: start.getDate(),
};
let endDateInfo = {
year: end.getFullYear(),
month: end.getMonth() + 1,
day: end.getDate(),
};
if (startDateInfo.year === endDateInfo.year) {
//同年
if (startDateInfo.month !== endDateInfo.month) {
//同年,不同月份
//获取开始时间所在月的月底日期
let startMax = new Date(
startDateInfo.year,
startDateInfo.month,
0
).getDate();
let endNum = startMax - startDateInfo.day + endDateInfo.day;
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
if (i > startMax) {
targetArr.push(
`${endDateInfo.year}-${
endDateInfo.month < 10
? "0" + endDateInfo.month
: endDateInfo.month
}-${i - startMax < 10 ? "0" + (i - startMax) : i - startMax}`
);
} else {
targetArr.push(
`${startDateInfo.year}-${
startDateInfo.month < 10
? "0" + startDateInfo.month
: startDateInfo.month
}-${i < 10 ? "0" + i : i}`
);
}
}
} else {
//同年同月
for (let i = startDateInfo.day; i <= endDateInfo.day; i++) {
targetArr.push(
`${startDateInfo.year}-${
startDateInfo.month < 10
? "0" + startDateInfo.month
: startDateInfo.month
}-${i < 10 ? "0" + i : i}`
);
}
}
} else {
//不同年 【既然不同年那肯定也不同月】
let startMax = new Date(
startDateInfo.year,
startDateInfo.month,
0
).getDate();
let endNum = startMax - startDateInfo.day + endDateInfo.day;
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
if (i > startMax) {
targetArr.push(
`${endDateInfo.year}-${
endDateInfo.month < 10 ? "0" + endDateInfo.month : endDateInfo.month
}-${i - startMax < 10 ? "0" + (i - startMax) : i - startMax}`
);
} else {
targetArr.push(
`${startDateInfo.year}-${
startDateInfo.month < 10
? "0" + startDateInfo.month
: startDateInfo.month
}-${i < 10 ? "0" + i : i}`
);
}
}
}
return targetArr;
}
export function save_token(access_token, refresh_token, expires_in) {
storage.set("Access-Token", access_token, new Date().getTime() + expires_in);
storage.set("Refresh-Token", refresh_token);
}
export function listToTree(list) {
let resultList = list.filter((item) => {
let children = list.filter((child) => {
return item.id === child.parent_id;
});
if (children.length > 0) {
item.children = children;
}
return item.parent_id === null;
});
return cloneDeep(resultList);
}
export function cloneDeep(obj) {
return JSON.parse(JSON.stringify(obj));
}
export function isEmpty(obj) {
if (obj === undefined || obj === null || obj === "") {
return true;
} else {
return false;
}
}
+30
View File
@@ -0,0 +1,30 @@
<template>
<page-header />
<a-spin :spinning="loading">
<div :style="'height:' + height">
<iframe :src="url" frameborder="no" style="width: 100%; height: 100%" scrolling="auto" />
</div>
</a-spin>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import PageHeader from '@/components/PageHeader.vue';
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
const loading = ref(true)
const url = ref(import.meta.env.VITE_APP_BASE_API + "/docs")
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 300);
window.onresize = () => {
height.value = document.documentElement.clientHeight - 94.5 + "px;";
};
})
onBeforeUnmount(() => {
window.onresize = null;
})
</script>
+511
View File
@@ -0,0 +1,511 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 表格区域 -->
<a-card title="文件列表" :bordered="false" :headStyle="{ borderBottom: 'none', padding: '20px 24px' }">
<div class="file-manager">
<!-- 工具栏 -->
<div class="toolbar">
<a-space>
<a-button type="primary" @click="handleUpload">
<template #icon>
<UploadOutlined />
</template>
上传文件
</a-button>
<a-button @click="handleCreateFolder">
<template #icon>
<FolderAddOutlined />
</template>
新建文件夹
</a-button>
<a-button danger :disabled="!selectedRows.length" @click="handleBatchDelete">
<template #icon>
<DeleteOutlined />
</template>
批量删除
</a-button>
<a-button @click="handleRefresh">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</a-space>
<a-input-search v-model:value="searchText" placeholder="搜索文件名" style="width: 250px"
@search="onSearch" allow-clear enter-button />
</div>
<!-- 面包屑导航 -->
<a-breadcrumb class="mb-4">
<a-breadcrumb-item>
<a @click="navigateToRoot">根目录</a>
</a-breadcrumb-item>
<a-breadcrumb-item v-for="(path, index) in currentPath" :key="index">
<a @click="navigateTo(index)">{{ path }}</a>
</a-breadcrumb-item>
</a-breadcrumb>
<!-- 文件列表 -->
<a-table :columns="columns" :data-source="fileList"
:row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }" :loading="loading"
:pagination="{
current: 1,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
total: fileList.length,
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条 / 总共 ${total} 条`
}" :style="{ minHeight: '550px' }">
<!-- 文件名列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileName'">
<span class="file-name" @click="handleFileClick(record)">
<component :is="getFileIcon(record.type)" class="mr-2" />
{{ record.name }}
</span>
</template>
<!-- 大小列 -->
<template v-if="column.key === 'size'">
{{ formatFileSize(record.size) }}
</template>
<!-- 操作列 -->
<template v-if="column.key === 'action'">
<a-space>
<!-- <a-tooltip title="预览" v-if="isPreviewable(record)"> -->
<a title="预览">
<EyeOutlined @click="handlePreview(record)" /> <span
style="color: #1890ff;">预览</span>
</a>
<a title="下载" v-if="record.type !== 'folder'">
<DownloadOutlined @click="handleDownload(record)" style="color: blue;" /> <span
style="color: blue;">下载</span>
</a>
<a title="重命名">
<EditOutlined @click="handleRename(record)" style="color: blue;" /> <span
style="color: blue;">重命名</span>
</a>
<a title="删除">
<DeleteOutlined @click="handleDelete(record)" style="color: red;" /> <span
style="color: red;">删除</span>
</a>
</a-space>
</template>
</template>
</a-table>
</div>
</a-card>
<!-- 上传文件对话框 -->
<a-modal v-model:open="uploadModalVisible" title="上传文件" @ok="confirmUpload" @cancel="cancelUpload"
:maskClosable="false">
<a-upload-dragger v-model:fileList="uploadFileList" :multiple="true" :action="uploadApi"
:headers="uploadHeaders" :before-upload="beforeUpload" @change="handleUploadChange">
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持单个或批量上传单个文件大小不超过 {{ maxFileSize }}MB
</p>
</a-upload-dragger>
</a-modal>
<!-- 新建文件夹对话框 -->
<a-modal v-model:open="folderModalVisible" title="新建文件夹" @ok="confirmCreateFolder" @cancel="cancelCreateFolder">
<a-form :model="folderForm" :rules="folderRules">
<a-form-item label="文件夹名称" name="name">
<a-input v-model:value="folderForm.name" placeholder="请输入文件夹名称" @pressEnter="confirmCreateFolder" />
</a-form-item>
</a-form>
</a-modal>
<!-- 重命名对话框 -->
<a-modal v-model:open="renameModalVisible" title="重命名" @ok="confirmRename" @cancel="cancelRename">
<a-form :model="renameForm" :rules="renameRules">
<a-form-item label="新名称" name="newName">
<a-input v-model:value="renameForm.newName" placeholder="请输入新名称" @pressEnter="confirmRename" />
</a-form-item>
</a-form>
</a-modal>
<!-- 预览对话框 -->
<a-modal v-model:open="previewModalVisible" title="文件预览" :footer="null" width="800px" :maskClosable="false">
<div class="preview-content">
<!-- 根据文件类型显示不同的预览内容 -->
<img v-if="isImage" :src="previewUrl" style="max-width: 100%" />
<video v-else-if="isVideo" :src="previewUrl" controls style="max-width: 100%" />
<div v-else class="unsupported-preview">
该文件类型暂不支持预览
</div>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, h, resolveComponent } from 'vue';
import { message, Modal } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import {
UploadOutlined,
FolderAddOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
InboxOutlined,
FileOutlined,
FolderOutlined,
ReloadOutlined,
EyeOutlined
} from '@ant-design/icons-vue';
import PageHeader from '@/components/PageHeader.vue';
import { getFileList, uploadFile, downloadFile, downloadFileResource, uploadFileOne, uploadFileMany } from '@/api/common/file';
import storage from 'store';
// 常量定义
const maxFileSize = 100; // 最大文件大小(MB)
const supportedPreviewTypes = ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'];
const uploadApi = import.meta.env.VITE_APP_BASE_API + '/api/v1/file/upload';
// 表格列定义
const columns = [
{
title: '文件名',
dataIndex: 'name',
key: 'name',
ellipsis: true,
width: 200,
customRender: ({ text, record }: { text: string, record: tableDataType }) => {
const icon = record.type === 'directory' ? 'folder' : 'file';
return h('span', {}, [
h(resolveComponent(icon === 'folder' ? 'FolderOutlined' : 'FileOutlined'), { style: 'margin-right: 8px' }),
text
]);
}
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 120,
customRender: ({ text, record }: { text: number | null, record: tableDataType }) => {
return record.type === 'directory' ? '-' : formatFileSize(text || 0);
},
sorter: (a: tableDataType, b: tableDataType) => {
if (a.type === 'directory' && b.type === 'directory') return 0;
if (a.type === 'directory') return -1;
if (b.type === 'directory') return 1;
return (a.size || 0) - (b.size || 0);
},
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
customRender: ({ text }: { text: string }) => text === 'directory' ? '文件夹' : '文件',
sorter: (a: tableDataType, b: tableDataType) => (a.type || '').localeCompare(b.type || ''),
},
{
title: '修改时间',
dataIndex: 'modified_time',
key: 'modified_time',
width: 180,
customRender: ({ text }: { text: string | null }) => formatTimestamp(text),
sorter: (a: tableDataType, b: tableDataType) => {
if (!a.modified_time || !b.modified_time) return 0;
return new Date(a.modified_time).getTime() - new Date(b.modified_time).getTime();
}
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
customRender: ({ record }: { record: tableDataType }) => {
return h('div', { class: 'action-buttons' }, [
h(resolveComponent('a-space'), {}, () => [
record.type !== 'directory' && h(resolveComponent('a-button'), {
type: 'link',
onClick: () => handleDownload(record)
}, () => '下载'),
h(resolveComponent('a-button'), {
type: 'link',
onClick: () => handleRename(record)
}, () => '重命名'),
h(resolveComponent('a-button'), {
type: 'link',
danger: true,
onClick: () => handleDelete([record])
}, () => '删除')
])
]);
}
}
];
// 状态定义
const loading = ref(false);
const searchText = ref('');
const fileList = ref<tableDataType[]>([]);
const selectedRowKeys = ref<string[]>([]);
const selectedRows = ref<tableDataType[]>([]);
const uploadModalVisible = ref(false);
const folderModalVisible = ref(false);
const renameModalVisible = ref(false);
const previewModalVisible = ref(false);
const uploadFileList = ref([]);
const currentPath = ref<string[]>([]);
const previewUrl = ref('');
const isImage = ref(false);
const isVideo = ref(false);
export interface tableDataType {
name?: string;
size?: number;
type?: string;
modified_time?: string;
}
// 表单状态
const folderForm = reactive({
name: ''
});
const renameForm = reactive({
newName: '',
record: null as tableDataType
});
// 表单校验规则
const folderRules = {
name: [
{ required: true, message: '请输入文件夹名称' },
{ max: 50, message: '文件夹名称不能超过50个字符' },
{ pattern: /^[^\\/:*?"<>|]+$/, message: '文件夹名称不能包含特殊字符' }
]
};
const renameRules = {
newName: [
{ required: true, message: '请输入新名称' },
{ max: 100, message: '名称不能超过100个字符' },
{ pattern: /^[^\\/:*?"<>|]+$/, message: '名称不能包含特殊字符' }
]
};
// 上传相关
const uploadHeaders = {
Authorization: 'Bearer ' + storage.get('Access-Token')
};
// 生命周期钩子
onMounted(() => {
loadFileList();
});
// 方法定义
const loadFileList = async () => {
loading.value = true;
try {
const res = await getFileList({
path: currentPath.value.join('/')
});
// 确保数据符合 tableDataType 接口
fileList.value = (res.data.data || []).map((item: any) => ({
name: item.name || '',
size: typeof item.size === 'number' ? item.size : null,
type: item.type || '',
modified_time: item.modified_time || null
}));
} catch (error) {
message.error('获取文件列表失败');
console.error('获取文件列表失败:', error);
} finally {
loading.value = false;
}
};
const handleRefresh = () => {
loadFileList();
};
const formatFileSize = (size: number) => {
if (!size) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return size.toFixed(2) + ' ' + units[index];
};
const formatTimestamp = (timestamp: string | null) => {
if (!timestamp) return '-';
return new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const isPreviewable = (record: tableDataType) => {
if (record.type === 'folder') return false;
const ext = record.name.split('.').pop()?.toLowerCase();
return supportedPreviewTypes.includes(ext || '');
};
const handlePreview = (record: tableDataType) => {
previewModalVisible.value = true;
previewUrl.value = record.url;
const ext = record.name.split('.').pop()?.toLowerCase();
isImage.value = ['jpg', 'jpeg', 'png', 'gif'].includes(ext || '');
isVideo.value = ['mp4', 'webm'].includes(ext || '');
};
const beforeUpload = (file: File) => {
const isLt100M = file.size / 1024 / 1024 < maxFileSize;
if (!isLt100M) {
message.error(`文件大小不能超过 ${maxFileSize}MB!`);
}
return isLt100M;
};
const handleFileClick = (record: tableDataType) => {
if (record.type === 'folder') {
currentPath.value.push(record.name);
loadFileList();
}
};
const navigateToRoot = () => {
currentPath.value = [];
loadFileList();
};
const navigateTo = (index: number) => {
currentPath.value = currentPath.value.slice(0, index + 1);
loadFileList();
};
const handleBatchDelete = () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRows.value.length} 个文件/文件夹吗?`,
okText: '确认',
cancelText: '取消',
onOk: () => handleDelete(selectedRows.value)
});
};
const handleUpload = () => {
uploadModalVisible.value = true;
};
const handleCreateFolder = () => {
folderModalVisible.value = true;
};
const handleDelete = async (records: tableDataType[]) => {
// TODO: 实现删除逻辑
};
const handleDownload = async (record: tableDataType) => {
// TODO: 实现下载逻辑
};
const handleRename = (record: tableDataType) => {
renameModalVisible.value = true;
renameForm.record = record;
renameForm.newName = record.name;
};
const confirmUpload = () => {
uploadModalVisible.value = false;
loadFileList();
};
const cancelUpload = () => {
uploadModalVisible.value = false;
uploadFileList.value = [];
};
const confirmCreateFolder = async () => {
// TODO: 实现创建文件夹逻辑
};
const cancelCreateFolder = () => {
folderModalVisible.value = false;
folderForm.name = '';
};
const confirmRename = async () => {
// TODO: 实现重命名逻辑
};
const cancelRename = () => {
renameModalVisible.value = false;
renameForm.newName = '';
renameForm.record = null;
};
const handleUploadChange = (info: any) => {
// TODO: 实现上传状态变化处理逻辑
};
const onSearch = (value: string) => {
// TODO: 实现搜索逻辑
};
const onSelectChange = (keys: string[], rows: tableDataType[]) => {
selectedRowKeys.value = keys;
selectedRows.value = rows;
};
const getFileIcon = (type: string) => {
return type === 'folder' ? FolderOutlined : FileOutlined;
};
</script>
<style lang="scss" scoped>
.file-manager {
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
.file-name {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
color: #1890ff;
}
}
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
.unsupported-preview {
color: #999;
font-size: 16px;
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
export interface tableDataType {
name?: string;
size?: number;
type?: string;
modified_time?: string;
}
+30
View File
@@ -0,0 +1,30 @@
<template>
<page-header />
<a-spin :spinning="loading">
<div :style="'height:' + height">
<iframe :src="url" frameborder="no" style="width: 100%; height: 100%" scrolling="auto" />
</div>
</a-spin>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import PageHeader from '@/components/PageHeader.vue';
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
const loading = ref(true)
const url = ref(import.meta.env.VITE_APP_BASE_API + "/redoc")
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 300);
window.onresize = () => {
height.value = document.documentElement.clientHeight - 94.5 + "px;";
};
})
onBeforeUnmount(() => {
window.onresize = null;
})
</script>
+684
View File
@@ -0,0 +1,684 @@
<template>
<div class="profile-container">
<page-header title="个人设置" />
<div class="profile-content">
<a-row :gutter="[24, 24]">
<!-- 左侧信息卡片 -->
<a-col :xs="24" :sm="24" :md="8" :lg="6">
<a-card class="info-card" :bordered="false">
<div class="user-info-header">
<div class="avatar-wrapper">
<a-avatar :src="infoFormState.avatar" :size="120" />
<div class="avatar-upload-overlay">
<a-upload
name="file"
:show-upload-list="false"
:headers="uploadHeaders"
action="/api/v1/system/user/current/avatar/upload"
@change="avatarHandleChange"
>
<a-button type="link">
<template #icon><UploadOutlined /></template>
更换头像
</a-button>
</a-upload>
</div>
</div>
<h2 class="user-name">{{ infoFormState.name }}</h2>
<p class="user-role">{{ infoFormState.roles.join('、') }}</p>
</div>
<a-divider />
<a-descriptions :column="1" size="small" class="user-details">
<a-descriptions-item>
<template #label>
<span class="detail-label">
<IdcardOutlined />
<span>账号</span>
</span>
</template>
{{ infoFormState.username }}
</a-descriptions-item>
<a-descriptions-item>
<template #label>
<span class="detail-label">
<TeamOutlined />
<span>部门</span>
</span>
</template>
{{ infoFormState.deptName }}
</a-descriptions-item>
<a-descriptions-item>
<template #label>
<span class="detail-label">
<ApartmentOutlined />
<span>岗位</span>
</span>
</template>
{{ infoFormState.positions.join('、') }}
</a-descriptions-item>
<a-descriptions-item>
<template #label>
<span class="detail-label">
<PhoneOutlined />
<span>手机</span>
</span>
</template>
{{ infoFormState.mobile }}
</a-descriptions-item>
<a-descriptions-item>
<template #label>
<span class="detail-label">
<MailOutlined />
<span>邮箱</span>
</span>
</template>
{{ infoFormState.email }}
</a-descriptions-item>
<a-descriptions-item>
<template #label>
<span class="detail-label">
<ClockCircleOutlined />
<span>加入时间</span>
</span>
</template>
{{ infoFormState.created_at }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
<!-- 右侧设置区域 -->
<a-col :xs="24" :sm="24" :md="16" :lg="18">
<a-card class="settings-card" :bordered="false">
<a-tabs v-model:activeKey="selectedKeys[0]" @change="handleTabChange">
<a-tab-pane key="1">
<template #tab>
<span class="tab-label">
<UserOutlined />
<span>基本设置</span>
</span>
</template>
<div class="settings-form">
<a-form
layout="vertical"
:model="infoFormState"
@finish="onInfoFormFinish"
class="info-form"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-tooltip title="请输入您的真实姓名" placement="topLeft">
<a-input
v-model:value="infoFormState.name"
placeholder="请输入姓名"
allow-clear
>
<template #prefix><UserOutlined /></template>
</a-input>
</a-tooltip>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="性别"
name="gender"
:rules="[{ required: true, message: '请选择性别' }]"
>
<a-radio-group v-model:value="infoFormState.gender" class="gender-group">
<a-radio :value="1"></a-radio>
<a-radio :value="2"></a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="手机号码"
name="mobile"
:rules="[
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }
]"
>
<a-tooltip title="用于接收系统通知" placement="topLeft">
<a-input
v-model:value="infoFormState.mobile"
placeholder="请输入手机号码"
allow-clear
>
<template #prefix><PhoneOutlined /></template>
</a-input>
</a-tooltip>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="邮箱"
name="email"
:rules="[{ type: 'email', message: '请输入有效的邮箱地址' }]"
>
<a-tooltip title="用于接收重要通知" placement="topLeft">
<a-input
v-model:value="infoFormState.email"
placeholder="请输入邮箱"
allow-clear
>
<template #prefix><MailOutlined /></template>
</a-input>
</a-tooltip>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button
type="primary"
html-type="submit"
:loading="infoSubmitting"
class="submit-button"
>
<template #icon><SaveOutlined /></template>
保存更改
</a-button>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="2">
<template #tab>
<span class="tab-label">
<LockOutlined />
<span>安全设置</span>
</span>
</template>
<div class="settings-form">
<a-form
layout="vertical"
:model="passwordFormState"
@finish="onPasswordFormFinish"
class="password-form"
>
<a-form-item
label="当前密码"
name="oldPassword"
:rules="[{ required: true, message: '请输入当前密码' }]"
>
<a-input-password
v-model:value="passwordFormState.oldPassword"
placeholder="请输入当前密码"
allow-clear
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item
label="新密码"
name="newPassword"
:rules="[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能小于6位' }
]"
>
<a-input-password
v-model:value="passwordFormState.newPassword"
placeholder="请输入新密码"
allow-clear
>
<template #prefix><KeyOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item
label="确认新密码"
name="repeatPassword"
:rules="[
{ required: true, message: '请确认新密码' },
{ validator: validateRepeatPassword }
]"
>
<a-input-password
v-model:value="passwordFormState.repeatPassword"
placeholder="请再次输入新密码"
allow-clear
>
<template #prefix><CheckOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
:loading="passwordChanging"
class="submit-button"
>
<template #icon><SaveOutlined /></template>
更新密码
</a-button>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
import PageHeader from '@/components/PageHeader.vue';
import { ref, reactive, onMounted, h, watch } from 'vue';
import { message } from 'ant-design-vue';
import type { MenuProps, UploadChangeParam } from "ant-design-vue";
import {
UserOutlined,
LockOutlined,
PhoneOutlined,
MailOutlined,
IdcardOutlined,
TeamOutlined,
ApartmentOutlined,
SafetyCertificateOutlined,
UploadOutlined,
KeyOutlined,
CheckOutlined,
SaveOutlined,
QuestionCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined
} from '@ant-design/icons-vue';
import store from '@/store';
import storage from 'store';
import request from "@/utils/request";
import { updateCurrentUserInfo, changeCurrentUserPassword, avatarHandleChange } from '@/api/system/user';
import type { InfoFormState, PasswordFormState } from './types';
// 状态定义
const passwordChanging = ref(false);
const infoSubmitting = ref(false);
const selectedKeys = ref<string[]>(['1']);
const avatarFileList = ref([]);
// 表单状态
const infoFormState = reactive<InfoFormState>({
name: '',
gender: null,
mobile: '',
email: '',
username: '',
deptName: '',
positions: [],
roles: [],
avatar: '',
created_at: ''
});
const passwordFormState = reactive<PasswordFormState>({
oldPassword: '',
newPassword: '',
repeatPassword: ''
});
// 上传头像配置
const token = storage.get('Access-Token');
const uploadHeaders = token ? { Authorization: 'Bearer ' + token } : {};
// 监听selectedKeys变化
watch(selectedKeys, (newVal) => {
if (newVal[0] === '1') {
initInfoForm();
} else {
initPasswordForm();
}
});
// 菜单点击事件
const menuClick = ({ key }: { key: string }) => {
selectedKeys.value = [key];
};
// 基本信息表单提交
const onInfoFormFinish = async (values: any) => {
try {
infoSubmitting.value = true;
values.avatar = infoFormState.avatar;
const response = await updateCurrentUserInfo(values);
message.success({
content: response.data.msg,
icon: h(CheckCircleOutlined, { style: "color: #52c41a" })
});
await store.dispatch('getUserInfo');
} catch (error) {
console.error('更新基本信息失败:', error);
message.error({
content: '更新基本信息失败',
icon: h(CloseCircleOutlined, { style: "color: #ff4d4f" })
});
} finally {
infoSubmitting.value = false;
}
};
// 密码表单提交
const onPasswordFormFinish = async (values: any) => {
try {
if (values.newPassword !== values.repeatPassword) {
message.error({
content: '两次密码输入不一致',
icon: h(CloseCircleOutlined, { style: "color: #ff4d4f" })
});
return;
}
passwordChanging.value = true;
const data = {
old_password: values.oldPassword,
new_password: values.newPassword
};
const response = await changeCurrentUserPassword(data);
message.success({
content: response.data.msg,
icon: h(CheckCircleOutlined, { style: "color: #52c41a" })
});
initPasswordForm();
} catch (error) {
console.error('修改密码失败:', error);
message.error({
content: '修改密码失败',
icon: h(CloseCircleOutlined, { style: "color: #ff4d4f" })
});
} finally {
passwordChanging.value = false;
}
};
// 头像上传处理
const avatarHandleChange = (info: UploadChangeParam) => {
if (info.file.status === 'done') {
const response = info.file.response;
const apiUrl = import.meta.env.VITE_API_BASE_URL + import.meta.env.VITE_APP_BASE_API
const newAvatar = apiUrl + '/' + response.data.file_path;
infoFormState.avatar = newAvatar;
message.success({
content: '上传成功',
icon: h(CheckCircleOutlined, { style: "color: #52c41a" })
});
} else if (info.file.status === 'error') {
message.error({
content: '上传失败',
icon: h(CloseCircleOutlined, { style: "color: #ff4d4f" })
});
}
};
// 初始化表单
const initInfoForm = () => {
const basicInfo = store.state.user.basicInfo;
console.log("basicInfo", basicInfo);
Object.assign(infoFormState, {
name: basicInfo.name,
gender: basicInfo.gender,
mobile: basicInfo.mobile,
email: basicInfo.email,
username: basicInfo.username,
deptName: basicInfo.dept_name,
positions: basicInfo.positions.map(item => item.name),
roles: basicInfo.roles.map(item => item.name),
avatar: basicInfo.avatar,
created_at: basicInfo.created_at
});
};
const initPasswordForm = () => {
Object.assign(passwordFormState, {
oldPassword: '',
newPassword: '',
repeatPassword: ''
});
};
// 新增密码验证函数
const validateRepeatPassword = async (_rule: any, value: string) => {
if (value !== passwordFormState.newPassword) {
return Promise.reject('两次输入的密码不一致');
}
return Promise.resolve();
};
// 替换菜单点击为标签页切换
const handleTabChange = (key: string) => {
selectedKeys.value = [key];
if (key === '1') {
initInfoForm();
} else {
initPasswordForm();
}
};
onMounted(() => {
initInfoForm();
});
</script>
<style lang="scss" scoped>
:root {
--primary-color: #1890ff;
--border-radius: 8px;
--card-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--transition-duration: 0.3s;
--hover-bg-color: rgba(0, 0, 0, 0.85);
}
.profile-container {
min-height: 100vh;
background-color: #f0f2f5;
.profile-content {
padding: 24px;
@media (max-width: 768px) {
padding: 16px;
}
}
.info-card {
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
transition: all var(--transition-duration);
&:hover {
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12);
}
.user-info-header {
text-align: center;
padding: 24px 0;
.avatar-wrapper {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 16px;
border-radius: 50%;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity var(--transition-duration);
}
.avatar-upload-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--hover-bg-color);
padding: 12px 0;
opacity: 0;
transition: all var(--transition-duration);
transform: translateY(100%);
z-index: 1;
.ant-btn {
color: #fff;
font-weight: 500;
font-size: 14px;
&:hover {
color: #40a9ff;
}
.anticon {
margin-right: 4px;
font-size: 16px;
}
}
}
&:hover {
&::after {
opacity: 1;
}
.avatar-upload-overlay {
opacity: 1;
transform: translateY(0);
}
}
.ant-avatar {
width: 100%;
height: 100%;
}
}
.user-name {
margin: 8px 0 4px;
font-size: 20px;
line-height: 28px;
font-weight: 500;
}
.user-role {
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
}
.user-details {
:deep(.ant-descriptions-item) {
padding-bottom: 12px;
.detail-label {
display: inline-flex;
align-items: center;
color: rgba(0, 0, 0, 0.65);
white-space: nowrap;
.anticon {
margin-right: 8px;
font-size: 16px;
}
}
.ant-descriptions-item-content {
display: inline-block;
color: rgba(0, 0, 0, 0.88);
}
.ant-descriptions-item-colon {
display: inline-block;
margin: 0 8px;
}
}
}
}
.settings-card {
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
:deep(.ant-tabs-nav) {
margin-bottom: 24px;
}
.settings-form {
max-width: 800px;
margin: 0 auto;
padding: 24px;
@media (max-width: 768px) {
padding: 16px 0;
}
.submit-button {
min-width: 120px;
}
}
.info-form, .password-form {
.ant-form-item {
margin-bottom: 24px;
}
.ant-input-affix-wrapper {
border-radius: 4px;
&:hover, &:focus {
border-color: var(--primary-color);
}
}
.gender-group {
width: 100%;
}
}
.password-form {
max-width: 400px;
}
.tab-label {
display: flex;
align-items: center;
.anticon {
margin-right: 8px;
font-size: 16px;
}
}
}
}
// 添加过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
export interface InfoFormState {
name: string;
gender: number | null;
mobile: string;
email: string;
username: string;
deptName: string;
positions: [];
roles: [];
avatar: string;
created_at: string;
}
export interface PasswordFormState {
oldPassword: string;
newPassword: string;
repeatPassword: string;
}
+669
View File
@@ -0,0 +1,669 @@
<template>
<div style="padding: 15px 0;">
<a-row :gutter="24">
<a-col :xl="6" :lg="12" :md="12" :sm="24" :xs="24">
<a-card :bodyStyle="{ padding: '20px 24px 8px' }">
<a-skeleton :loading="loading">
<div>
<span style="color: rgba(0, 0, 0, 0.65)">总销售额</span>
<div style="float: right;">
<a-tooltip title="指标说明" placement="top">
<InfoCircleOutlined />
</a-tooltip>
</div>
<div class="value">
<span>¥ 126,560</span>
</div>
</div>
<div style="display: flex; height: 45px; padding-top: 10px;">
<div style="margin-right: 16px; align-self: flex-end;">
<span>
周同比
<span style="margin-left: 8px;">
12%
<CaretUpOutlined style="color:red;" />
</span>
</span>
</div>
<div style="align-self: flex-end;">
<span>
日同比
<span style="margin-left: 8px;">
11%
<CaretDownOutlined style="color:green;" />
</span>
</span>
</div>
</div>
<div style="border-top: 1px solid rgba(5, 5, 5, 0.06); margin-top: 10px; padding-top: 10px;">
<span>
日销售额
<span>12,423</span>
</span>
</div>
</a-skeleton>
</a-card>
</a-col>
<a-col :xl="6" :lg="12" :md="12" :sm="24" :xs="24">
<a-card :bodyStyle="{ padding: '20px 24px 8px' }">
<a-skeleton :loading="loading">
<div>
<span style="color: rgba(0, 0, 0, 0.65)">访问量</span>
<div style="float: right;">
<a-tooltip title="指标说明" placement="top">
<InfoCircleOutlined />
</a-tooltip>
</div>
<div class="value">
<span>8,846</span>
</div>
</div>
<div style="height: 45px; padding-top: 10px;">
<v-chart class="line-chart" :option="visitOption" autoresize />
</div>
<div style="border-top: 1px solid rgba(5, 5, 5, 0.06); margin-top: 10px; padding-top: 10px;">
<span>
日访问量
<span>1,234</span>
</span>
</div>
</a-skeleton>
</a-card>
</a-col>
<a-col :xl="6" :lg="12" :md="12" :sm="24" :xs="24">
<a-card :bodyStyle="{ padding: '20px 24px 8px' }">
<a-skeleton :loading="loading">
<div>
<span style="color: rgba(0, 0, 0, 0.65)">支付笔数</span>
<div style="float: right;">
<a-tooltip title="指标说明" placement="top">
<InfoCircleOutlined />
</a-tooltip>
</div>
<div class="value">
<span>6,560</span>
</div>
</div>
<div style="display: flex; height: 45px; padding-top: 10px;">
<v-chart class="payment-chart" :option="paymentOption" autoresize />
</div>
<div style="border-top: 1px solid rgba(5, 5, 5, 0.06); margin-top: 10px; padding-top: 10px;">
<span>
转化率
<span>60%</span>
</span>
</div>
</a-skeleton>
</a-card>
</a-col>
<a-col :xl="6" :lg="12" :md="12" :sm="24" :xs="24">
<a-card :bodyStyle="{ padding: '20px 24px 8px' }">
<a-skeleton :loading="loading">
<div>
<span style="color: rgba(0, 0, 0, 0.65)">运营活动效果</span>
<div style="float: right;">
<a-tooltip title="指标说明" placement="top">
<InfoCircleOutlined />
</a-tooltip>
</div>
<div class="value">
<span>78%</span>
</div>
</div>
<div style="height: 45px; line-height: 45px; padding-top: 10px;">
<a-progress :percent="78" status="active" :strokeColor="{ from: '#108ee9', to: '#87d068' }" />
</div>
<div style="display: flex; border-top: 1px solid rgba(5, 5, 5, 0.06); margin-top: 10px; padding-top: 10px;">
<div style="margin-right: 16px; align-self: flex-end;">
<span>
周同比
<span style="margin-left: 8px;">
12%
<CaretUpOutlined style="color:red;" />
</span>
</span>
</div>
<div style="align-self: flex-end;">
<span>
日同比
<span style="margin-left: 8px;">
11%
<CaretDownOutlined style="color:green;" />
</span>
</span>
</div>
</div>
</a-skeleton>
</a-card>
</a-col>
</a-row>
<div style="margin-top: 25px;">
<a-card :tab-list="tabList" :active-tab-key="barTypeKey" @tabChange="onTabChange">
<a-skeleton :loading="loading">
<template #tabBarExtraContent>
<div class="extra-item" style="">
<a>今日</a>
<a>本周</a>
<a>本月</a>
<a>本年</a>
</div>
<a-range-picker :style="{ width: '256px' }" />
</template>
<div v-if="barTypeKey == 'sales'">
<a-row :gutter="24">
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24">
<v-chart :option="barOption" autoresize style="width: 1060px; height: 300px;" />
</a-col>
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
<div class="rank">
<h4>门店销售额排名</h4>
<ul style="list-style: none; margin: 25px 0 0;">
<li :key="i" v-for="(item, i) in barRank">
<span class="active" v-if="i < 3">{{ i + 1 }}</span>
<span v-else>{{ i + 1 }}</span>
<span>{{ item }}</span>
<span>323,234</span>
</li>
</ul>
</div>
</a-col>
</a-row>
</div>
<div v-else>
<a-row :gutter="24">
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24">
<v-chart :option="barOption" autoresize style="width: 1060px; height: 300px;" />
</a-col>
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
<div class="rank">
<h4>门店访问量排名</h4>
<ul style="list-style: none; margin: 25px 0 0;">
<li :key="i" v-for="(item, i) in barRank">
<span class="active" v-if="i < 3">{{ i + 1 }}</span>
<span v-else>{{ i + 1 }}</span>
<span>{{ item }}</span>
<span>323,234</span>
</li>
</ul>
</div>
</a-col>
</a-row>
</div>
</a-skeleton>
</a-card>
</div>
<a-row :gutter="24" style="margin-top: 25px;">
<a-col :xl="12" :lg="24" :md="24" :sm="24" :xs="24">
<a-card title="线上热门搜索">
<a-skeleton :loading="loading">
<template #extra>
<a-dropdown placement="bottomRight">
<template #overlay>
<a-menu>
<a-menu-item key="1">操作一</a-menu-item>
<a-menu-item key="2">操作二</a-menu-item>
</a-menu>
</template>
<EllipsisOutlined />
</a-dropdown>
</template>
<a-row :gutter="68">
<a-col :sm="12" :xs="24">
<div style="color: rgba(0, 0, 0, 0.65);">
<span>搜索用户数</span>
<span style="margin-left: 8px;">
<a-tooltip title="指标说明" placement="top">
<InfoCircleOutlined />
</a-tooltip>
</span>
</div>
<div style="margin-top: 5px;">
<v-chart class="line-chart" :option="searchOption" autoresize />
</div>
</a-col>
<a-col :sm="12" :xs="24">
<div style="color: rgba(0, 0, 0, 0.65);">
<span>人均搜索次数</span>
<span style="margin-left: 8px;">
<a-tooltip title="指标说明" placement="top">
<InfoCircleOutlined />
</a-tooltip>
</span>
</div>
<div style="margin-top: 5px;">
<v-chart class="line-chart" :option="searchOption" autoresize />
</div>
</a-col>
</a-row>
<div style="margin-top: 10px;">
<a-table row-key="index" size="small" :columns="searchColumns" :data-source="searchData"
:pagination="{ pageSize: 5 }">
<template v-slot:bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'keyword'">
<a>{{ record.keyword }}</a>
</template>
<template v-if="column.dataIndex === 'range'">
<span v-if="record.status === 0">
{{ `${record.range}%` }}
<CaretUpOutlined style="color:red;" />
</span>
<span v-else>
{{ `${record.range}%` }}
<CaretDownOutlined style="color:green;" />
</span>
</template>
</template>
</a-table>
</div>
</a-skeleton>
</a-card>
</a-col>
<a-col :xl="12" :lg="24" :md="24" :sm="24" :xs="24">
<a-card title="销售额类别占比">
<a-skeleton :loading="loading">
<template #extra>
<span>
<a-radio-group v-model:value="salesType" @change="salesTabChange">
<a-radio-button value="all">全部渠道</a-radio-button>
<a-radio-button value="online">线上</a-radio-button>
<a-radio-button value="offline">门店</a-radio-button>
</a-radio-group>
</span>
<span style="margin-left: 20px;">
<a-dropdown placement="bottomRight">
<template #overlay>
<a-menu>
<a-menu-item key="1">操作一</a-menu-item>
<a-menu-item key="2">操作二</a-menu-item>
</a-menu>
</template>
<EllipsisOutlined />
</a-dropdown>
</span>
</template>
<span>
销售额
<v-chart class="pie-chart" :option="pieOptions" autoresize />
</span>
</a-skeleton>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import { InfoCircleOutlined, CaretUpOutlined, CaretDownOutlined, EllipsisOutlined } from '@ant-design/icons-vue';
import { getRangeDate } from '@/utils/util'
const loading = ref(true);
let y: number[] = [];
for (let i = 0; i < 15; i += 1) {
y.push(Math.ceil(Math.random() * 10 % 9))
}
let x: string[] = getRangeDate('2024-03-01', '2024-03-15');
const visitOption = {
color: '#2289ff',
grid: {
left: '-5%',
right: '0%',
bottom: '100%',
containLabel: true
},
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
boundaryGap: false,
show: false,
data: x
},
yAxis: {
type: 'value',
show: false,
splitLine: {
show: false
}
},
series: [
{
data: y,
type: 'line',
smooth: true,
lineStyle: {
width: 0
},
showSymbol: false,
areaStyle: {},
}
]
};
const paymentOption = {
color: '#2289ff',
grid: {
left: '-5%',
right: '0%',
bottom: '100%',
containLabel: true
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
xAxis: {
type: 'category',
show: false,
data: x
},
yAxis: {
type: 'value',
show: false,
splitLine: {
show: false
}
},
series: [
{
data: y,
type: 'bar'
}
]
};
const tabList = [
{
key: 'sales',
tab: '销售额',
},
{
key: 'visit',
tab: '访问量',
},
];
const barTypeKey = ref('sales');
let bar_x: string[] = [];
let bar_y = [1106, 505, 845, 778, 1098, 762, 615, 345, 343, 1177, 431, 813];
for (let i = 1; i < 13; i += 1) {
bar_x.push(`${i}`)
}
const barOption = reactive({
color: '#2289ff',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '0%',
bottom: '3%',
containLabel: true
},
xAxis: [
{
type: 'category',
data: bar_x,
axisTick: {
alignWithLabel: true
}
}
],
yAxis: [
{
type: 'value'
}
],
series: [
{
name: '销售量',
type: 'bar',
barWidth: '50%',
data: bar_y
}
]
});
let barRank: string[] = [];
for (let i = 0; i < 7; i += 1) {
barRank.push(`工专路${i}号店`)
}
const onTabChange = (value: string) => {
barTypeKey.value = value;
barOption.series[0].name = value == 'sales' ? '销售量' : '访问量';
};
let search_y: number[] = [];
for (let i = 0; i < 7; i += 1) {
search_y.push(Math.ceil(Math.random() * 10 % 9))
}
let search_x: string[] = getRangeDate('2024-03-01', '2024-03-07');
const searchOption = {
color: '#2289ff',
grid: {
left: '-6%',
right: '0%',
bottom: '100%',
containLabel: true
},
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
boundaryGap: false,
show: false,
data: search_x
},
yAxis: {
type: 'value',
show: false,
splitLine: {
show: false
}
},
series: [
{
data: search_y,
type: 'line',
smooth: true,
lineStyle: {
width: 0
},
showSymbol: false,
areaStyle: {},
}
]
};
const searchColumns = [
{
dataIndex: 'index',
title: '排名',
width: 90
},
{
dataIndex: 'keyword',
title: '搜索关键词'
},
{
dataIndex: 'count',
title: '用户数',
sorter: (a, b) => a.count - b.count
},
{
dataIndex: 'range',
title: '周涨幅',
sorter: (a, b) => a.range - b.range
}
];
const searchData = [];
for (let i = 0; i < 50; i += 1) {
searchData.push({
index: i + 1,
keyword: `搜索关键词-${i}`,
count: Math.floor(Math.random() * 1000),
range: Math.floor(Math.random() * 100),
status: Math.floor((Math.random() * 10) % 2)
})
}
const salesType = ref('all');
const salesTabChange = (item: any) => {
if (item.target.value == 'all') {
pieOptions.value.series[0].data = [
{ value: 1231, name: '其他' },
{ value: 1231, name: '母婴产品' },
{ value: 2341, name: '服饰箱包' },
{ value: 3113, name: '个护健康' },
{ value: 3321, name: '食用酒水' },
{ value: 4544, name: '家用电器' }
]
} else if (item.target.value == 'online') {
pieOptions.value.series[0].data = [
{ value: 111, name: '其他' },
{ value: 121, name: '母婴产品' },
{ value: 41, name: '服饰箱包' },
{ value: 311, name: '个护健康' },
{ value: 321, name: '食用酒水' },
{ value: 244, name: '家用电器' }
]
} else {
pieOptions.value.series[0].data = [
{ value: 65, name: '其他' },
{ value: 0, name: '母婴产品' },
{ value: 255, name: '服饰箱包' },
{ value: 344, name: '个护健康' },
{ value: 188, name: '食用酒水' },
{ value: 99, name: '家用电器' }
]
}
}
const pieOptions = ref({
tooltip: {
trigger: 'item'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: [
{ value: 1231, name: '其他' },
{ value: 1231, name: '母婴产品' },
{ value: 2341, name: '服饰箱包' },
{ value: 3113, name: '个护健康' },
{ value: 3321, name: '食用酒水' },
{ value: 4544, name: '家用电器' }
],
itemStyle: {
color: function (colors) {
var colorList = [
'#68c738',
'#7f6bff',
'#d787ff',
'#f18e56',
'#0dcccc',
'#2389ff'
];
return colorList[colors.dataIndex];
}
}
}
]
})
setTimeout(() => {
loading.value = false;
}, 500);
</script>
<style lang="scss" scoped>
.value {
height: 38px;
padding-top: 4px;
color: rgba(0, 0, 0, 0.88);
font-size: 30px;
line-height: 38px;
word-break: break-all;
}
.line-chart {
width: 100%;
height: 45px;
}
.payment-chart {
width: 100%;
height: 45px;
}
.extra-item {
display: inline-block;
a {
margin-right: 25px;
color: rgba(0, 0, 0, 0.88);
}
a:hover {
color: #1890ff;
}
}
.rank {
padding: 0 32px 32px 72px;
h4 {
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
font-weight: normal;
}
ul li {
margin-top: 16px;
}
ul li span {
&:first-child {
background-color: #f5f5f5;
border-radius: 20px;
display: inline-block;
font-size: 12px;
font-weight: 600;
margin-right: 24px;
height: 20px;
line-height: 20px;
width: 20px;
text-align: center;
}
&:last-child {
float: right;
}
&.active {
background-color: #314659;
color: #fff;
}
}
}
.pie-chart {
width: 725px;
height: 350px;
}
</style>
+317
View File
@@ -0,0 +1,317 @@
<template>
<page-header />
<div class="container">
<div class="page-header-content" style="display: flex; justify-content: space-between; padding-bottom: 20px;">
<a-row align="middle">
<a-col class="avatar">
<a-avatar :size="75" :src="userInfo.avatar" />
</a-col>
<a-col class="content" style="margin-left: 20px;">
<div class="content-title"
style="font-size: 20px; font-weight: 500; color: rgba(0, 0, 0, 0.88); margin-bottom: 12px;">
{{ timefix }}{{ userInfo.name }}<span class="welcome-text">{{ welcome }}</span>
</div>
<div style="color: rgba(0, 0, 0, 0.65);">{{userInfo.positions[0].name}} | {{userInfo.dept_name}}</div>
</a-col>
</a-row>
<a-row class="extra-content" justify="space-around">
<a-col class="stat-item">
<a-statistic title="项目数" :value="56" />
</a-col>
<a-col class="stat-item">
<a-statistic title="团队内排名" :value="8" suffix="/ 24" />
</a-col>
<a-col class="stat-item">
<a-statistic title="项目访问" :value="2223" />
</a-col>
</a-row>
</div>
<div>
<a-row :gutter="24">
<a-col :xl="16" :lg="24" :md="24" :sm="24" :xs="24">
<a-card title="进行中的项目">
<template #extra>
<a>全部项目</a>
</template>
<a-card-grid :key="i" v-for="(item, i) in projectItems" style="width: 33.33%;">
<a-skeleton :loading="loading">
<a-card-meta>
<template #title>
<a-avatar :size="30" :src="item.avatar" />
<a class="a-hover" style="margin-left: 10px; color: rgba(0, 0, 0, 0.88);">{{ item.title }}</a>
</template>
<template #description>
<div style="min-height: 45px;">{{ item.description }}</div>
</template>
</a-card-meta>
<div class="project-item">
<a class="a-hover" style="color: rgba(0, 0, 0, 0.65)">{{ item.author }}</a>
<span class="item-datetime">{{ item.datetime }}</span>
</div>
</a-skeleton>
</a-card-grid>
</a-card>
<a-card title="动态" :bordered="false" :bodyStyle="{ padding: '10px' }" style="margin-top: 20px;">
<a-list item-layout="horizontal" :data-source="activities">
<template #renderItem="{ item }">
<a-list-item>
<a-skeleton :loading="loading" active avatar>
<a-list-item-meta>
<template #avatar>
<a-avatar :src="item.avatar" />
</template>
<template #title>
<a class="a-hover">{{ item.username }}</a>
<span>
<a>{{ item.project }}</a>
{{ item.action }}
<a>{{ item.event }}</a>
</span>
</template>
<template #description>
<span class="item-datetime">几秒前</span>
</template>
</a-list-item-meta>
</a-skeleton>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :xl="8" :lg="24" :md="24" :sm="24" :xs="24">
<a-card title="快速开始 / 便捷导航" :bodyStyle="{ padding: '20px 0 8px 24px' }">
<a-button type="primary" size="small" ghost>
<template #icon>
<PlusOutlined />
</template>
添加
</a-button>
</a-card>
<a-card title="XX 指数" style="margin-top: 20px;">
<a-skeleton :loading="loading">
<v-chart class="chart" :option="chartOptions" autoresize />
</a-skeleton>
</a-card>
<a-card title="团队" :bodyStyle="{ padding: '10px' }" style="margin-top: 20px;">
<a-skeleton :loading="loading">
<div class="members">
<a-row>
<a-col :span="12" v-for="(item, index) in teams" :key="index" style="padding-left: 10px">
<a>
<a-avatar size="small" :src="item.avatar" />
<span class="member">{{ item.name }}</span>
</a>
</a-col>
</a-row>
</div>
</a-skeleton>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import PageHeader from '@/components/PageHeader.vue'
import { timeFix } from '@/utils/util';
import { PlusOutlined } from '@ant-design/icons-vue';
import store from '@/store';
const loading = ref(true);
let timefix = timeFix();
const userInfo = store.state.user.basicInfo;
console.log("store.state.user.basicInfo",store.state.user.basicInfo)
const welcome = '祝你开心每一天!';
const projectItems = [
{ title: 'Ant Design Vue', avatar: 'https://aliyuncdn.antdv.com/favicon.ico', description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', author: '中二少女团', datetime: '几秒前' },
{ title: 'Ant Design Pro', avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', description: '那时候我只会想自己想要什么,从不想自己拥有什么', author: '程序员日常', datetime: '7年前' },
{ title: 'Fastapi', avatar: 'https://fastapi.tiangolo.com/img/favicon.png', description: '那是一种内在的东西,他们到达不了,也无法触及的', author: '科学搬砖组', datetime: '几秒前' },
{ title: 'Vite', avatar: 'https://vitejs.dev/logo.svg', description: '生命就像一盒巧克力,结果往往出人意料', author: '骗你来学计算机', datetime: '7年前' },
{ title: 'Vue', avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', author: '全组都是吴彦祖', datetime: '几秒前' },
{ title: 'Python', avatar: 'https://python.p2hp.com/static/favicon.ico', description: '人生苦短,我用Python', author: '高逼格语言天团', datetime: '7年前' }
]
const activities = [
{
username: '曲丽丽',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png',
project: '高逼格设计天团',
action: '新建项目',
event: '六月迭代'
},
{
username: '付小小',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/cnrhVkzwxjPwAaCfPbdc.png',
project: '高逼格设计天团',
action: '更新项目',
event: '六月迭代'
},
{
username: '林东东',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/gaOngJwsRYRaVAuXXcmB.png',
project: '中二少女团',
action: '新建项目',
event: '六月迭代'
},
{
username: '周星星',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/WhxKECPNujWoWEFNdnJE.png',
project: '白鹭酱油开发组',
action: '更新项目',
event: '番组计划'
},
{
username: '朱偏右',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ubnKSIfAJTxIgXOKlciN.png',
project: '工程效能',
action: '发布了',
event: '留言'
}
// ,{
// username: '乐哥',
// avatar: 'https://gw.alipayobjects.com/zos/rmsportal/jZUIxmJycoymBprLOUbT.png',
// project: '程序员日常',
// action: '新建项目',
// event: '品牌迭代'
// }
];
const chartOptions = {
tooltip: {
trigger: 'item'
},
legend: {
icon: 'path://M773.12 870.4c-163.84 0-225.28-184.32-276.48-343.04C440.32 363.52 399.36 256 312.32 256 225.28 256 179.2 256 102.4 558.08c-5.12 25.6-35.84 46.08-61.44 35.84-25.6-5.12-46.08-35.84-35.84-61.44C76.8 230.4 143.36 153.6 312.32 153.6c163.84 0 225.28 184.32 276.48 343.04 56.32 163.84 97.28 271.36 184.32 271.36 40.96 0 117.76 0 143.36-174.08 5.12-25.6 30.72-46.08 56.32-40.96 25.6 5.12 46.08 30.72 40.96 56.32-20.48 174.08-102.4 261.12-240.64 261.12z',
data: ['个人', '团队', '部门'],
bottom: 10,
itemHeight: 7,
},
radar: {
shape: 'circle',
indicator: [
{ name: '引用', max: 10 },
{ name: '热度', max: 10 },
{ name: '贡献', max: 10 },
{ name: '产量', max: 10 },
{ name: '口碑', max: 10 }
]
},
series: [
{
name: 'Budget vs spending',
type: 'radar',
areaStyle: {},
symbol: 'none',
emphasis: {
focus: 'self'
},
data: [
{
value: [10, 7, 5, 4, 8],
name: '个人'
},
{
value: [3, 1, 3, 6, 9],
name: '团队'
},
{
value: [4, 7, 5, 6, 1],
name: '部门'
}
]
}
]
}
const teams = [
{ avatar: 'https://aliyuncdn.antdv.com/favicon.ico', name: '中二少女团' },
{ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', name: '程序员日常' },
{ avatar: 'https://fastapi.tiangolo.com/img/favicon.png', name: '科学搬砖组' },
{ avatar: 'https://vitejs.dev/logo.svg', name: '骗你来学计算机' },
{ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', name: '全组都是吴彦祖' },
{ avatar: 'https://python.p2hp.com/static/favicon.ico', name: '高逼格语言天团' }
]
setTimeout(() => {
loading.value = false;
}, 500);
</script>
<style lang="scss" scoped>
.stat-item:not(:last-child)::after {
position: absolute;
top: 8px;
right: 0;
width: 1px;
height: 45px;
background-color: rgba(5, 5, 5, 0.06);
content: '';
}
.stat-item {
padding: 0 25px;
}
.project-item {
margin-top: 10px;
font-size: 12px;
display: flex;
gap: 8px;
}
.item-datetime {
float: right;
color: rgba(0, 0, 0, 0.25);
}
.a-hover:hover {
color: #1890ff !important;
}
:deep(.ant-list-item-meta-content) {
h4 {
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
font-weight: normal;
}
}
.chart {
width: 450px;
height: 350px;
}
.members {
a {
display: block;
margin: 12px 0;
line-height: 24px;
height: 24px;
.member {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 24px;
max-width: 100px;
vertical-align: top;
margin-left: 12px;
transition: all 0.3s;
display: inline-block;
}
&:hover {
span {
color: #1890ff;
}
}
}
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<template>
<a-result
status="404"
title="404"
sub-title="抱歉,您访问的页面不存在"
>
<template #extra>
<a-button type="primary" @click="toHome">返回首页</a-button>
</template>
</a-result>
</template>
<script lang="ts" setup>
import { useRouter } from "vue-router";
const router = useRouter();
const toHome = () => {
router.push('/');
};
</script>
<style lang="scss" scoped>
.ant-result {
padding: 48px 32px;
}
</style>
+467
View File
@@ -0,0 +1,467 @@
<template>
<!-- 页面头部 -->
<page-header />
<div class="app-container">
<a-tabs>
<!-- 监控信息 Tab -->
<a-tab-pane key="monitor" tab="监控信息">
<a-row :gutter="16">
<!-- 基本信息 -->
<a-col :span="24">
<a-card>
<template #title>
<MonitorOutlined class="icon" />
<span class="title">Redis监控信息</span>
</template>
<a-table :dataSource="[cache.info || {}]" :pagination="false" :bordered="true">
<a-table-column key="redis_version" title="Redis版本" :customRender="({record}) => record?.redis_version || '-'" />
<a-table-column key="redis_mode" title="运行模式" :customRender="({record}) => record?.redis_mode === 'standalone' ? '单机' : '集群'" />
<a-table-column key="tcp_port" title="端口" :customRender="({record}) => record?.tcp_port || '-'" />
<a-table-column key="connected_clients" title="客户端数" :customRender="({record}) => record?.connected_clients || 0" />
<a-table-column key="uptime_in_days" title="运行时间(天)" :customRender="({record}) => record?.uptime_in_days || 0" />
<a-table-column key="used_memory_human" title="使用内存" :customRender="({record}) => record?.used_memory_human || '-'" />
<a-table-column key="used_cpu" title="使用CPU" :customRender="({record}) => record?.used_cpu_user_children ? parseFloat(record.used_cpu_user_children).toFixed(2) : '-'" />
<a-table-column key="maxmemory_human" title="内存配置" :customRender="({record}) => record?.maxmemory_human || '-'" />
<a-table-column key="aof_enabled" title="AOF是否开启" :customRender="({record}) => record?.aof_enabled === '0' ? '否' : '是'" />
<a-table-column key="rdb_status" title="RDB是否成功" :customRender="({record}) => record?.rdb_last_bgsave_status || '-'" />
<a-table-column key="dbSize" title="Key数量" :customRender="() => cache.db_size || 0" />
<a-table-column key="network" title="网络入口/出口" :customRender="({record}) => `${record?.instantaneous_input_kbps || 0}kps/${record?.instantaneous_output_kbps || 0}kps`" />
</a-table>
</a-card>
</a-col>
<!-- 监控图表 -->
<a-col :span="12" class="mt-4">
<a-card>
<template #title>
<PieChartOutlined class="icon" />
<span class="title">命令统计</span>
</template>
<div ref="commandstats" class="chart-container" />
</a-card>
</a-col>
<a-col :span="12" class="mt-4">
<a-card>
<template #title>
<DashboardOutlined class="icon" />
<span class="title">内存信息</span>
</template>
<div ref="usedmemory" class="chart-container" />
</a-card>
</a-col>
</a-row>
</a-tab-pane>
<!-- 缓存管理 Tab -->
<a-tab-pane key="cache" tab="缓存管理">
<a-row :gutter="16">
<!-- 缓存列表 -->
<a-col :span="8">
<a-card :bodyStyle="{ height: tableContainerHeight, minHeight: '600px' }">
<template #title>
<SolutionOutlined class="icon" />
<span class="title">缓存列表</span>
</template>
<template #extra>
<a-button type="link" @click="refreshCacheNames">
<template #icon><RedoOutlined /></template>
</a-button>
</template>
<a-table
:loading="loading"
:dataSource="cacheNames"
:scroll="{ y: tableHeight }"
:pagination="false"
rowKey="cache_name"
>
<a-table-column key="cache_name" title="缓存名称" align="center" :ellipsis="true">
<template #default="{ record }">
<a @click="getCacheKeyList(record)">{{ record.cache_name }}</a>
</template>
</a-table-column>
<a-table-column key="remark" title="备注" align="center" :ellipsis="true" dataIndex="remark" />
<a-table-column key="action" title="操作" width="60" align="center">
<template #customRender="{ record }">
<a-button type="link" @click.stop="handleClearCacheName(record)">
<template #icon><DeleteOutlined /></template>
</a-button>
</template>
</a-table-column>
</a-table>
</a-card>
</a-col>
<!-- 键名列表 -->
<a-col :span="8">
<a-card :bodyStyle="{ height: tableContainerHeight, minHeight: '600px' }">
<template #title>
<KeyOutlined class="icon" />
<span class="title">键名列表</span>
</template>
<template #extra>
<a-button type="link" @click="refreshCacheKeys">
<template #icon><RedoOutlined /></template>
</a-button>
</template>
<a-table
:loading="subLoading"
:dataSource="cacheKeys.map(key => ({ cacheKey: key }))"
:scroll="{ y: tableHeight }"
:pagination="false"
rowKey="cacheKey"
>
<a-table-column key="cacheKey" title="缓存键名" align="center" :ellipsis="true">
<template #default="{ record }">
<a @click="handleCacheValue(record.cacheKey)">{{ record.cacheKey }}</a>
</template>
</a-table-column>
<a-table-column key="action" title="操作" width="60" align="center">
<template #customRender="{ record }">
<a-button type="link" @click.stop="handleClearCacheKey(record.cacheKey)">
<template #icon><DeleteOutlined /></template>
</a-button>
</template>
</a-table-column>
</a-table>
</a-card>
</a-col>
<!-- 缓存内容 -->
<a-col :span="8">
<a-card :bodyStyle="{ height: tableContainerHeight, minHeight: '600px' }">
<template #title>
<FileOutlined class="icon" />
<span class="title">缓存内容</span>
</template>
<template #extra>
<a-button type="link" @click="handleClearCacheAll">清理全部</a-button>
</template>
<a-form :model="cacheForm" layout="vertical">
<a-form-item label="缓存名称:" name="cache_name">
<a-input v-model:value="cacheForm.cache_name" readonly />
</a-form-item>
<a-form-item label="缓存键名:" name="cache_key">
<a-input v-model:value="cacheForm.cache_key" readonly />
</a-form-item>
<a-form-item label="缓存内容:" name="cache_value">
<a-textarea
v-model:value="cacheForm.cache_value"
:rows="8"
readonly
/>
</a-form-item>
</a-form>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { DeleteOutlined, RedoOutlined, LineChartOutlined, SolutionOutlined, KeyOutlined, FileOutlined, MonitorOutlined, PieChartOutlined, DashboardOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import { getCacheInfo, getCacheNames, getCacheKeys, getCacheValue, deleteCacheName, deleteCacheKey, deleteCacheAll } from "@/api/monitor/cache";
import * as echarts from 'echarts';
import type { CacheInfo, CacheForm, CacheMonitor, RedisInfo } from './types';
import PageHeader from '@/components/PageHeader.vue';
// 响应式状态定义
const cacheNames = ref<CacheInfo[]>([]);
const cacheKeys = ref<string[]>([]);
const cacheForm = ref<CacheForm>({
cache_name: '',
cache_key: '',
cache_value: ''
});
const loading = ref(true);
const subLoading = ref(false);
const nowCacheName = ref('');
const tableHeight = ref(window.innerHeight - 350);
const cache = ref<CacheMonitor>({
info: {} as RedisInfo,
command_stats: [],
db_size: 0
});
const commandstats = ref<HTMLElement | null>(null);
const usedmemory = ref<HTMLElement | null>(null);
let commandstatsInstance: echarts.ECharts | null = null;
let usedmemoryInstance: echarts.ECharts | null = null;
// 计算属性
const tableContainerHeight = computed(() => `calc(100vh - 300px)`);
const resetCacheForm = () => {
cacheKeys.value = [];
cacheForm.value = {
cache_name: '',
cache_key: '',
cache_value: ''
};
};
// 缓存名称相关方法
const getCacheNameList = () => {
loading.value = true;
getCacheNames()
.then(({ data: result }) => {
if (result.status_code === 200) {
cacheNames.value = result.data || [];
resetCacheForm();
} else {
message.error(result.msg)
}
})
.catch(error => {
console.error('获取缓存列表出错:', error);
message.error('获取缓存列表失败');
})
.finally(() => {
loading.value = false;
});
};
// 刷新缓存列表
const refreshCacheNames = () => {
getCacheNameList();
message.success('刷新缓存列表成功');
};
// 清理缓存名称
const handleClearCacheName = (row: CacheInfo) => {
Modal.confirm({
title: '确认清理',
content: `确定要清理缓存名称[${row.cache_name}]吗?`,
onOk() {
deleteCacheName(row.cache_name)
.then(({ data: result }) => {
if (result.status_code === 200) {
message.success(result.msg);
getCacheNameList();
} else {
message.error(result.msg)
}
})
.catch(error => {
console.error('清理缓存失败:', error);
message.error('清理缓存失败');
});
}
});
};
// 缓存键名相关方法
const getCacheKeyList = (row?: CacheInfo) => {
const cacheName = row?.cache_name || nowCacheName.value;
if (!cacheName) return;
subLoading.value = true;
getCacheKeys(cacheName)
.then(({ data: result }) => {
if (result.status_code === 200) {
cacheKeys.value = result.data || [];
nowCacheName.value = cacheName;
cacheForm.value = {
cache_name: cacheName,
cache_key: '',
cache_value: ''
};
message.success(result.msg);
} else {
message.error(result.msg)
}
})
.catch(error => {
console.error('获取缓存键名列表失败:', error);
message.error('获取缓存键名列表失败');
})
.finally(() => {
subLoading.value = false;
});
};
// 刷新键名列表
const refreshCacheKeys = () => {
getCacheKeyList();
message.success('刷新键名列表成功');
};
// 清理缓存键名
const handleClearCacheKey = (cacheKey: string) => {
Modal.confirm({
title: '确认清理',
content: `确定要清理缓存键名[${cacheKey}]吗?`,
onOk() {
deleteCacheKey(cacheKey)
.then(({ data: result }) => {
if (result.status_code === 200) {
message.success(result.msg);
getCacheKeyList();
} else {
message.error(result.msg)
}
})
.catch(error => {
console.error('清理缓存键名失败:', error);
message.error('清理缓存键名失败');
});
}
});
};
// 缓存内容相关方法
const handleCacheValue = (cacheKey: string) => {
getCacheValue(nowCacheName.value, cacheKey)
.then(({ data: result }) => {
if (result.status_code === 200) {
cacheForm.value = result.data;
message.success(result.msg);
} else {
message.error(result.msg)
}
})
.catch(error => {
console.error('获取缓存内容失败:', error);
message.error('获取缓存内容失败');
});
};
// 清理全部缓存
const handleClearCacheAll = () => {
Modal.confirm({
title: '确认清理',
content: '确定要清理全部缓存吗?',
onOk() {
deleteCacheAll()
.then(({ data: result }) => {
if (result.status_code === 200) {
message.success(result.msg);
getCacheNameList();
} else {
message.error(result.msg)
}
})
.catch(error => {
console.error('清理全部缓存失败:', error);
message.error('清理全部缓存失败');
});
}
});
};
// 监控数据获取
const getInfo = () => {
getCacheInfo()
.then(({ data: result }) => {
if (result.status_code === 200) {
cache.value = result.data || {
info: {},
command_stats: [],
dbSize: 0
};
initCharts();
message.success(result.msg || '获取缓存监控数据成功');
} else {
message.error(result.msg || '获取缓存监控数据失败');
}
})
.catch(error => {
message.error('获取缓存监控数据失败:', error);
});
};
// 图表初始化
const initCharts = () => {
if (!commandstats.value || !usedmemory.value) return;
commandstatsInstance = echarts.init(commandstats.value, 'macarons');
usedmemoryInstance = echarts.init(usedmemory.value, 'macarons');
const commandStatsOption = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)'
},
series: [{
name: '命令',
type: 'pie',
roseType: 'radius',
radius: [15, 95],
center: ['50%', '38%'],
data: cache.value.command_stats || [],
animationEasing: 'cubicInOut',
animationDuration: 1000
}]
};
const usedMemory = cache.value.info?.used_memory_human || '0';
const usedMemoryOption = {
tooltip: {
formatter: `{b} <br/>{a} : ${usedMemory}`
},
series: [{
name: '峰值',
type: 'gauge',
min: 0,
max: 1000,
detail: {
formatter: usedMemory
},
data: [{
value: parseFloat(usedMemory) || 0,
name: '内存消耗'
}]
}]
};
commandstatsInstance.setOption(commandStatsOption);
usedmemoryInstance.setOption(usedMemoryOption);
};
// 窗口大小变化处理
const handleResize = () => {
tableHeight.value = window.innerHeight - 800;
commandstatsInstance?.resize();
usedmemoryInstance?.resize();
};
// 生命周期钩子
onMounted(() => {
getCacheNameList();
getInfo();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
commandstatsInstance?.dispose();
usedmemoryInstance?.dispose();
});
</script>
<style scoped>
.icon {
width: 1em;
height: 1em;
vertical-align: middle;
}
.title {
vertical-align: middle;
margin-left: 8px;
}
.mt-4 {
margin-top: 16px;
}
.chart-container {
height: 420px;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
export interface CacheForm {
cache_name: string;
cache_key: string;
cache_value: string;
}
export interface CacheInfo {
cache_key: string;
cache_name: string;
cache_value: string;
remark: string;
}
export interface CommandStats {
name: string;
value: string;
}
export interface RedisInfo {
redis_version: string;
redis_mode: string;
tcp_port: number;
connected_clients: number;
uptime_in_days: number;
used_memory_human: string;
used_cpu_user_children: string;
maxmemory_human: string;
aof_enabled: string;
rdb_last_bgsave_status: string;
instantaneous_input_kbps: number;
instantaneous_output_kbps: number;
}
export interface CacheMonitor {
command_stats: CommandStats[];
db_size: number;
info: RedisInfo;
}
+178
View File
@@ -0,0 +1,178 @@
<template>
<!-- 页面头部 -->
<page-header />
<!-- 搜索表单 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="handleQuery">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="ipaddr" label="主机" style="max-width: 300px;">
<a-input v-model:value="queryState.ipaddr" placeholder="请输入主机地址" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="user_name" label="用户名" style="max-width: 300px;">
<a-input v-model:value="queryState.user_name" placeholder="请输入登陆用户名称" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="login_location" label="登陆地点" style="max-width: 300px;">
<a-input v-model:value="queryState.login_location" placeholder="请输入登陆地点" allowClear></a-input>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="loading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetQuery">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<div class="table-wrapper">
<a-card title="在线用户列表"
:bordered="false"
:headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<a-table
:rowKey="record => record.session_id"
:columns="columns"
:data-source="tableData"
:loading="loading"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:style="{ minHeight: '500px' }"
:pagination="pagination">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-button type="link" @click="handleForceLogout(record)" danger>
<template #icon>
<DeleteOutlined />
强退
</template>
</a-button>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { DeleteOutlined } from '@ant-design/icons-vue';
import { Modal, message } from 'ant-design-vue';
import { getOnlineList, deleteOnline} from "@/api/monitor/online";
import type { QueryState, OnlineUser } from './types';
import PageHeader from '@/components/PageHeader.vue';
const onlineList = ref<OnlineUser[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNum = ref(1);
const pageSize = ref(10);
const queryState = ref<QueryState>({
ipaddr: undefined,
user_name: undefined
});
const columns = [
{ title: '会话编号', dataIndex: 'session_id', key: 'sessionId', ellipsis: true },
{ title: '登录名称', dataIndex: 'user_name', key: 'userName', align: 'center', ellipsis: true },
{ title: '主机', dataIndex: 'ipaddr', key: 'ipaddr', align: 'center', ellipsis: true },
{ title: '登录地点', dataIndex: 'login_location', key: 'loginLocation', align: 'center', ellipsis: true },
{ title: '操作系统', dataIndex: 'os', key: 'os', align: 'center', ellipsis: true },
{ title: '浏览器', dataIndex: 'browser', key: 'browser', align: 'center', ellipsis: true },
{ title: '登录时间', dataIndex: 'login_time', key: 'loginTime', align: 'center', width: 180 },
{ title: '操作', dataIndex: 'operation', key: 'operation', align: 'center', width: 120 }
];
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: onlineList.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const tableData = computed(() => {
if (!onlineList.value) return [];
const start = (pageNum.value - 1) * pageSize.value;
return onlineList.value.slice(start, start + pageSize.value);
});
const getList = async () => {
try {
loading.value = true;
const response = await getOnlineList(queryState.value);
onlineList.value = response?.data?.data?.items || [];
total.value = response?.data?.data?.total || 0;
} catch (error) {
message.error('获取数据失败');
onlineList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleQuery = () => {
pageNum.value = 1;
getList();
};
const resetQuery = () => {
Object.keys(queryState.value).forEach((key: string) => {
delete queryState.value[key];
});
pagination.current = 1;
getList();
};
const handleForceLogout = (row: OnlineUser) => {
if (!row?.user_name) return;
Modal.confirm({
title: '确认提示',
content: `是否确认强退名称为"${row.user_name}"的用户?`,
async onOk() {
try {
await deleteOnline(row.session_id);
await getList();
message.success('强退成功');
} catch (error) {
message.error('强退失败');
}
}
});
};
const handlePageChange = (page: number, size: number) => {
pageNum.value = page;
pageSize.value = size;
};
const handlePageSizeChange = (current: number, size: number) => {
pageSize.value = size;
pageNum.value = 1;
};
onMounted(() => {
getList();
});
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
@@ -0,0 +1,16 @@
export interface QueryState {
ipaddr?: string;
user_name?: string;
login_location?: string;
}
export interface OnlineUser {
session_id: string;
user_id: number;
user_name: string;
ipaddr: string;
login_location: string;
os: string;
browser: string;
login_time: string;
}
+408
View File
@@ -0,0 +1,408 @@
<template>
<div class="app-container">
<a-row :gutter="16">
<a-col :span="12" class="mb-4">
<a-card :loading="loading" :bordered="false" class="shadow-sm" :bodyStyle="{ padding: '24px', height: '360px' }">
<template #title>
<div class="flex items-center">
<DesktopOutlined class="text-primary text-lg mr-2" />
<span class="font-medium">CPU使用情况</span>
<a-tooltip title="展示CPU核心数及使用率">
<QuestionCircleOutlined class="ml-2 text-gray-400" />
</a-tooltip>
</div>
</template>
<div class="grid grid-cols-2 gap-4">
<div class="border rounded-lg p-4">
<div class="text-center mb-4">
<div class="text-lg font-bold">CPU核心数</div>
<AProgress type="circle" :percent="100" :format="() => server.cpu?.cpu_num || 0" status="normal" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span>用户使用率:</span>
<span class="font-medium">{{ (server.cpu?.used || 0).toFixed(1) }}%</span>
</div>
<div class="flex justify-between">
<span>系统使用率:</span>
<span class="font-medium">{{ (server.cpu?.sys || 0).toFixed(1) }}%</span>
</div>
<div class="flex justify-between">
<span>当前空闲率:</span>
<span class="font-medium">{{ (server.cpu?.free || 0).toFixed(1) }}%</span>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="text-center mb-4">
<div class="text-lg font-bold">CPU使用率</div>
<AProgress type="circle" :percent="server.cpu?.used || 0" :status="server.cpu?.used > 80 ? 'exception' : 'normal'" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span>总核心数:</span>
<span class="font-medium">{{ server.cpu?.cpu_num || 0 }}</span>
</div>
<div class="flex justify-between">
<span>已用核心:</span>
<span class="font-medium">{{ Math.floor((server.cpu?.used || 0) * server.cpu?.cpu_num / 100) }}</span>
</div>
<div class="flex justify-between">
<span>空闲核心:</span>
<span class="font-medium">{{ Math.floor((server.cpu?.free || 0) * server.cpu?.cpu_num / 100) }}</span>
</div>
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="12" class="mb-4">
<a-card :loading="loading" :bordered="false" class="shadow-sm" :bodyStyle="{ padding: '24px', height: '360px' }">
<template #title>
<div class="flex items-center">
<FileTextOutlined class="text-primary text-lg mr-2" />
<span class="font-medium">内存使用情况</span>
<a-tooltip title="展示系统内存和Python程序内存使用情况">
<QuestionCircleOutlined class="ml-2 text-gray-400" />
</a-tooltip>
</div>
</template>
<div class="grid grid-cols-2 gap-4">
<div class="border rounded-lg p-4">
<div class="text-center mb-4">
<div class="text-lg font-bold">系统内存</div>
<AProgress type="circle" :percent="server.mem?.usage || 0" :status="server.mem?.usage > 80 ? 'exception' : 'normal'" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span>总内存:</span>
<span class="font-medium">{{ server.mem?.total }}</span>
</div>
<div class="flex justify-between">
<span>已用内存:</span>
<span class="font-medium">{{ server.mem?.used }}</span>
</div>
<div class="flex justify-between">
<span>空闲内存:</span>
<span class="font-medium">{{ server.mem?.free }}</span>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="text-center mb-4">
<div class="text-lg font-bold">Python内存</div>
<AProgress type="circle" :percent="server.py?.memory_usage || 0" :status="server.py?.memory_usage > 80 ? 'exception' : 'normal'" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span>总内存:</span>
<span class="font-medium">{{ server.py?.memory_total }}</span>
</div>
<div class="flex justify-between">
<span>已用内存:</span>
<span class="font-medium">{{ server.py?.memory_used }}</span>
</div>
<div class="flex justify-between">
<span>空闲内存:</span>
<span class="font-medium">{{ server.py?.memory_free }}</span>
</div>
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="24" class="mb-4">
<a-card :loading="loading" :bordered="false" class="shadow-sm" :bodyStyle="{ padding: '24px' }">
<template #title>
<div class="flex items-center">
<MonitorOutlined class="text-primary text-lg mr-2" />
<span class="font-medium">服务器基本信息</span>
<a-tooltip title="展示服务器基本配置信息">
<QuestionCircleOutlined class="ml-2 text-gray-400" />
</a-tooltip>
</div>
</template>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="服务器名称">{{ server.sys?.computer_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ server.sys?.os_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务器IP">{{ server.sys?.computer_ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="系统架构">{{ server.sys?.os_arch || '-' }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
<a-col :span="24" class="mb-4">
<a-card :loading="loading" :bordered="false" class="shadow-sm" :bodyStyle="{ padding: '24px' }">
<template #title>
<div class="flex items-center">
<CodeOutlined class="text-primary text-lg mr-2" />
<span class="font-medium">Python运行环境</span>
<a-tooltip title="展示Python环境配置及运行状态">
<QuestionCircleOutlined class="ml-2 text-gray-400" />
</a-tooltip>
</div>
</template>
<a-descriptions :column="3" bordered>
<a-descriptions-item label="Python名称">{{ server.py?.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="Python版本">{{ server.py?.version || '-' }}</a-descriptions-item>
<a-descriptions-item label="启动时间">{{ server.py?.start_time || '-' }}</a-descriptions-item>
<a-descriptions-item label="运行时长">{{ server.py?.run_time || '-' }}</a-descriptions-item>
<a-descriptions-item label="安装路径">{{ server.py?.home || '-' }}</a-descriptions-item>
<a-descriptions-item label="项目路径">{{ server.sys?.user_dir || '-' }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
<a-col :span="24">
<a-card :loading="loading" :bordered="false" class="shadow-sm" :bodyStyle="{ padding: '24px' }">
<template #title>
<div class="flex items-center">
<HddOutlined class="text-primary text-lg mr-2" />
<span class="font-medium">磁盘使用情况</span>
<a-tooltip title="展示磁盘空间使用详情">
<QuestionCircleOutlined class="ml-2 text-gray-400" />
</a-tooltip>
</div>
</template>
<a-table
:dataSource="server.disks"
:pagination="false"
:bordered="true"
:scroll="{ x: 'max-content' }"
:rowKey="(record) => record.dir_name"
>
<a-table-column
key="dir_name"
title="盘符路径"
dataIndex="dir_name"
:ellipsis="true"
:width="100"
/>
<a-table-column
key="sys_type_name"
title="文件系统"
dataIndex="sys_type_name"
align="center"
:width="100"
/>
<a-table-column
key="type_name"
title="盘符名称"
dataIndex="type_name"
:width="300"
/>
<a-table-column
key="usage"
title="使用率"
align="center"
:width="120"
:customRender="({record}) => {
// 从record中获取usage值,确保是数字类型
const usageValue = Number(record.usage);
// 修改status的类型以匹配Progress组件的要求
const status = usageValue > 80 ? 'exception' : usageValue > 60 ? 'normal' : 'normal';
const strokeColor = usageValue > 80 ? '#ff4d4f' : usageValue > 60 ? '#faad14' : '#1677ff';
return h('div', { style: { padding: '0 10px' }}, [
h(AProgress, {
percent: usageValue,
size: 'small',
status,
strokeColor,
style: { marginBottom: '4px' }
}),
]);
}"
/>
<a-table-column
key="total"
title="总大小"
dataIndex="total"
align="center"
:width="100"
/>
<a-table-column
key="free"
title="可用大小"
dataIndex="free"
align="center"
:width="100"
/>
<a-table-column
key="used"
title="已用大小"
dataIndex="used"
align="center"
:width="100"
/>
</a-table>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, h } from 'vue';
import { getServer } from '@/api/monitor/server'
import { message } from 'ant-design-vue';
import { QuestionCircleOutlined, DesktopOutlined, FileTextOutlined, MonitorOutlined, CodeOutlined, HddOutlined } from '@ant-design/icons-vue';
import { Progress as AProgress } from 'ant-design-vue';
import type { ServerInfo } from './types';
const loading = ref(false);
const server = ref<ServerInfo>({
cpu: {
cpu_num: 0,
used: 0,
sys: 0,
free: 0
},
mem: {
total: '',
used: '',
free: '',
usage: 0
},
sys: {
computer_name: '',
os_name: '',
computer_ip: '',
os_arch: '',
user_dir: ''
},
py: {
name: '',
version: '',
start_time: '',
run_time: '',
home: '',
memory_total: '',
memory_used: '',
memory_free: '',
memory_usage: 0
},
disks: []
});
async function getList() {
loading.value = true;
try {
const response = await getServer();
server.value = response.data.data;
} catch (error) {
message.error('获取服务器信息失败');
console.error(error);
} finally {
loading.value = false;
}
}
onMounted(() => {
getList();
});
</script>
<style scoped>
.app-container {
padding: 20px;
}
.shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.text-primary {
color: #1677ff;
}
.text-warning {
color: #faad14;
}
.text-gray-400 {
color: #9ca3af;
}
.text-gray-500 {
color: #6b7280;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.p-4 {
padding: 1rem;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gap-4 {
gap: 1rem;
}
.space-y-2 > * + * {
margin-top: 0.5rem;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.text-center {
text-align: center;
}
.font-medium {
font-weight: 500;
}
.font-bold {
font-weight: 600;
}
.text-lg {
font-size: 1.125rem;
}
.rounded-lg {
border-radius: 0.5rem;
}
.border {
border: 1px solid #e5e7eb;
}
</style>
@@ -0,0 +1,51 @@
export interface Cpu {
cpu_num: number;
used: number;
sys: number;
free: number;
}
export interface Memory {
total: string;
used: string;
free: string;
usage: number;
}
export interface System {
computer_name: string;
os_name: string;
computer_ip: string;
os_arch: string;
user_dir: string;
}
export interface Python {
name: string;
version: string;
start_time: string;
run_time: string;
home: string;
memory_total: string;
memory_used: string;
memory_free: string;
memory_usage: number;
}
export interface SysFile {
dirName: string;
sysTypeName: string;
typeName: string;
total: string;
free: string;
used: string;
usage: number;
}
export interface ServerInfo {
cpu: Cpu;
mem: Memory;
sys: System;
py: Python;
disks: SysFile[];
}
+343
View File
@@ -0,0 +1,343 @@
<template>
<a-layout>
<!-- 页面主体 -->
<div class="container">
<a-layout-content :style="contentStyle">
<div class="header">
<div class="logo">
<a-image src="/logo.png" :preview="false" />
</div>
<div class="title">FastAPI Vue Admin</div>
</div>
<div class="desc">FastAPI Vue Admin 是完全开源的权限管理系统</div>
<div class="login-main" style="width: 330px; margin: 0 auto;">
<a-tabs centered>
<a-tab-pane :key="1" tab="账户密码登录">
<a-form :model="loginForm" @finish="onFinish">
<a-form-item name="username" :rules="[{ required: true, message: '用户名是必填项!' }]">
<a-input v-model:value="loginForm.username" placeholder="用户名: admin or test or demo">
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item name="password" :rules="[{ required: true, message: '密码是必填项!' }]">
<a-input-password v-model:value="loginForm.password" placeholder="密码: gitee 或 github 查看">
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item v-if="captchaState.enable" name="captcha" :rules="[{ required: true, message: '验证码是必填项!' }]">
<a-input v-model:value="loginForm.captcha" placeholder="验证码">
<template #addonAfter>
<div class="login-form-captcha" @click="requestCaptcha">
<a-image :src="captchaState.img_base" :preview="false" />
</div>
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="loginForm.remember">自动登录</a-checkbox>
<a class="login-form-forgot" @click="showModal('forgetPassword')">忘记密码</a>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" class="login-form-button" :loading="loginFlag">
登录
</a-button>
<div class="register-link">
还没有账号? <a @click="showModal('register')">立即注册</a>
</div>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</div>
</a-layout-content>
<!-- 页面底部 -->
<a-layout-footer :style="footerStyle">
<div class="footer-copyright">
<a-button type="link" href="https://gitee.com/tao__tao/fastapi_vue_admin.git">
<icon-font type="icon-copyright" :style="{ fontSize: '16px' }" />
Copyright © 2024 insistence.tech All Rights Reserved. 皖ICP备2023021369号-1
</a-button>
</div>
</a-layout-footer>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal
v-model:open="modalVisible"
:title="modalType === 'forgetPassword' ? '忘记密码' : '用户注册'"
@ok="handleModalSubmit"
:confirmLoading="modalLoading">
<!-- 忘记密码表单 -->
<a-form v-if="modalType === 'forgetPassword'"
:model="forgetPasswordForm"
ref="forgetPasswordFormRef"
:rules="{
username: [{ required: true, message: '请输入用户名!' }],
mobile: [{ required: true, message: '请输入手机号!', pattern: /^1[3-9]\d{9}$/ }],
new_password: [{ required: true, message: '请输入新密码!', min: 6 }]
}">
<a-form-item label="用户名" name="username">
<a-input v-model:value="forgetPasswordForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="手机号" name="mobile">
<a-input v-model:value="forgetPasswordForm.mobile" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="新密码" name="new_password">
<a-input-password v-model:value="forgetPasswordForm.new_password" placeholder="请输入新密码" />
</a-form-item>
</a-form>
<!-- 注册表单 -->
<a-form v-else
:model="registerForm"
ref="registerFormRef"
:rules="{
username: [{ required: true, message: '请输入用户名!', min: 3 }],
name: [{ required: true, message: '请输入名称!' }],
password: [{ required: true, message: '请输入密码!', min: 6 }],
mobile: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号!' }],
email: [{ type: 'email', message: '请输入正确的邮箱格式!' }]
}">
<a-form-item label="用户名" name="username">
<a-input v-model:value="registerForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="名称" name="name">
<a-input v-model:value="registerForm.name" placeholder="请输入名称" />
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item label="手机号" name="mobile">
<a-input v-model:value="registerForm.mobile" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="registerForm.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="性别" name="gender">
<a-radio-group v-model:value="registerForm.gender">
<a-radio :value="1"></a-radio>
<a-radio :value="2"></a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</a-layout>
</template>
<script lang="ts" setup>
import type { CSSProperties } from "vue";
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { UserOutlined, LockOutlined, createFromIconfontCN } from '@ant-design/icons-vue';
import { save_token } from "@/utils/util"
import { message } from 'ant-design-vue';
import { login, getCaptcha } from "@/api/system/auth"
import { registerUser, forgetPassword } from "@/api/system/user"
import type { LoginForm, CaptchaState, ForgetPasswordForm, RegisterForm } from './types'
const router = useRouter();
const loginFlag = ref(false);
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
});
const contentStyle: CSSProperties = {
minHeight: 900,
height: "900px",
background: "none",
padding: "200px 0",
};
const footerStyle: CSSProperties = {
textAlign: "center",
background: "none",
};
// 统一的弹窗控制
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalType = ref<'forgetPassword' | 'register'>('forgetPassword');
// 忘记密码相关
const forgetPasswordFormRef = ref();
const forgetPasswordForm = reactive<ForgetPasswordForm>({
username: '',
mobile: '',
new_password: ''
});
// 注册相关
const registerFormRef = ref();
const registerForm = reactive<RegisterForm>({
username: '',
name: '',
password: '',
mobile: '',
email: '',
gender: 1
});
const loginForm = reactive<LoginForm>({
username: "",
password: "",
captcha: "",
captcha_key: "",
remember: true
});
const captchaState = reactive<CaptchaState>({
enable: true,
key: "",
img_base: ""
});
const showModal = (type: 'forgetPassword' | 'register') => {
modalType.value = type;
modalVisible.value = true;
};
const handleModalSubmit = () => {
modalLoading.value = true;
if (modalType.value === 'forgetPassword') {
forgetPasswordFormRef.value.validate().then(() => {
forgetPassword(forgetPasswordForm)
.then(response => {
if (response.data.status_code === 200) {
message.success('密码重置成功');
modalVisible.value = false;
}
})
.finally(() => modalLoading.value = false);
});
} else {
registerFormRef.value.validate().then(() => {
registerUser({ ...registerForm })
.then(response => {
if (response.data.status_code === 200) {
message.success('注册成功');
modalVisible.value = false;
}
})
.finally(() => modalLoading.value = false);
});
}
};
const onFinish = (values: LoginForm) => {
loginFlag.value = true;
values.captcha_key = captchaState.key;
login(values)
.then(response => {
const { status_code, data } = response.data;
if (status_code === 200) {
save_token(data.access_token, data.refresh_token, data.expires_in);
router.push('/');
}
})
.catch(error => {
if (error.response?.data?.status_code === 500) {
requestCaptcha();
}
})
.finally(() => loginFlag.value = false);
};
const requestCaptcha = () => {
getCaptcha()
.then(response => {
const { status_code, data } = response.data;
if (status_code === 200) {
captchaState.key = data.key;
captchaState.img_base = data.img_base;
} else {
captchaState.enable = false;
}
})
.catch(() => captchaState.enable = false);
};
onMounted(requestCaptcha);
</script>
<style lang="scss" scoped>
.ant-btn-link {
color: rgba(0, 0, 0, 0.65);
margin-inline-end: 8px;
padding: 0;
}
.container {
background-image: url("/background.png");
background-size: 100% 100%;
.desc {
text-align: center;
font-size: 15px;
margin: 12px 0 40px;
}
.header {
display: flex;
line-height: 44px;
justify-content: center;
align-items: center;
.logo {
width: 44px;
height: 44px;
margin-inline-end: 16px;
}
.title {
font-size: 33px;
font-weight: 650;
}
}
}
.login-form {
&-button {
width: 100%;
}
&-captcha {
width: 80px;
cursor: pointer;
}
&-forgot {
float: right;
color: #1890ff;
cursor: pointer;
}
}
.register-link {
text-align: center;
margin-top: 16px;
a {
color: #1890ff;
cursor: pointer;
}
}
:deep(.ant-input-group .ant-input-group-addon) {
padding: 0;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
export interface LoginForm {
username: string;
password: string;
captcha: string;
captcha_key: string;
remember: boolean;
}
export interface CaptchaState {
enable: boolean;
key: string;
img_base: string;
}
export interface ForgetPasswordForm {
username: string;
mobile: string;
new_password: string;
}
export interface RegisterForm {
username: string;
name: string;
password: string;
mobile: string;
email: string;
gender: number;
}
+513
View File
@@ -0,0 +1,513 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 搜索表单 -->
<div class="tree-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="name" label="名称" style="max-width: 300px;">
<a-input v-model:value="queryState.name" placeholder="请输入名称" allowClear />
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-space>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button @click="resetFields">重置</a-button>
</a-space>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card
title="部门列表"
:bordered="false"
:headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }"
>
<template #extra>
<a-space>
<a-button type="primary" :icon="h(PlusOutlined)" @click="modalHandle('create')">新建</a-button>
<a-dropdown>
<template #overlay>
<a-menu @click="handleMoreClick">
<a-menu-item key="1"><CheckOutlined />批量启用</a-menu-item>
<a-menu-item key="2"><StopOutlined />批量停用</a-menu-item>
</a-menu>
</template>
<a-button>更多<DownOutlined /></a-button>
</a-dropdown>
</a-space>
</template>
<a-table
:rowKey="record => record.id"
:columns="columns"
:data-source="dataSource"
:loading="tableLoading"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:row-selection="rowSelection"
:pagination="false"
:style="{ minHeight: '500px' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span :style="{ color: !record.available ? 'rgb(255, 77, 79)' : 'rgba(0, 0, 0, .88)' }">
{{ record.name }}
</span>
</template>
<template v-else-if="column.dataIndex === 'available'">
<a-badge :status="record.available ? 'success' : 'error'" />
{{ record.available ? '启用' : '停用' }}
</template>
<template v-else-if="column.dataIndex === 'action'">
<a-space size="middle">
<a @click="modalHandle('view', record)">查看</a>
<a @click="modalHandle('update', record)">修改</a>
<a-popconfirm title="确定要删除吗?" ok-text="确定" cancel-text="取消" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal
v-model:open="openModal"
@ok="handleModalSumbit"
:width="800"
:destroyOnClose="true"
:confirmLoading="modalSubmitLoading"
style="top: 30px"
>
<template #title>
<span>{{ modalTitle === 'create' ? '新建部门' : (modalTitle === 'view' ? '查看部门' : '修改部门') }}</span>
</template>
<!-- 查看表单 -->
<template v-if="modalTitle === 'view'">
<a-spin :spinning="detailStateLoading">
<a-descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }"
:labelStyle="{ width: '140px' }"
bordered
>
<a-descriptions-item label="名称">{{ detailState.name }}</a-descriptions-item>
<a-descriptions-item label="排序">{{ detailState.order }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :color="detailState.available ? 'green' : 'red'" />
{{ detailState.available ? '启用' : '禁用' }}
</a-descriptions-item>
<a-descriptions-item label="上级部门" :span="2">{{ detailState.parent_name }}</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</template>
<!-- 新建表单 -->
<template v-else-if="modalTitle === 'create'">
<a-form
ref="createForm"
:model="createState"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 15 }"
>
<a-form-item
name="name"
label="名称"
:rules="[{ required: true, message: '请输入名称' }]"
>
<a-input v-model:value="createState.name" placeholder="请输入名称" allowClear />
</a-form-item>
<a-form-item
name="order"
label="排序"
:rules="[{ required: true, message: '请输入排序' }]"
>
<a-input-number v-model:value="createState.order" :min="1" />
</a-form-item>
<a-form-item name="parent_id" label="上级部门">
<a-tree-select
v-model:value="createState.parent_id"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
:tree-data="dataSource"
:field-names="{ children: 'children', label: 'name', value: 'id' }"
tree-node-filter-prop="name"
style="width: 100%"
show-search
allow-clear
placeholder="请选择上级部门"
/>
</a-form-item>
<a-form-item
name="available"
label="状态"
:rules="[{ required: true, message: '请选择状态' }]"
>
<a-radio-group v-model:value="createState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea
v-model:value="createState.description"
placeholder="请输入备注"
:rows="4"
allowClear
/>
</a-form-item>
</a-form>
</template>
<!-- 修改表单 -->
<template v-else>
<a-form
ref="updateForm"
:model="updateState"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 15 }"
>
<a-form-item
name="name"
label="名称"
:rules="[{ required: true, message: '请输入名称' }]"
>
<a-input v-model:value="updateState.name" placeholder="请输入名称" allowClear />
</a-form-item>
<a-form-item
name="order"
label="排序"
:rules="[{ required: true, message: '请输入排序' }]"
>
<a-input-number v-model:value="updateState.order" :min="1" />
</a-form-item>
<a-form-item name="parent_id" label="上级部门">
<a-tree-select
v-model:value="updateState.parent_id"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
:tree-data="dataSource"
:field-names="{ children: 'children', label: 'name', value: 'id' }"
tree-node-filter-prop="name"
style="width: 100%"
show-search
allow-clear
placeholder="请选择上级部门"
/>
</a-form-item>
<a-form-item
name="available"
label="状态"
:rules="[{ required: true, message: '请选择状态' }]"
>
<a-radio-group v-model:value="updateState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea
v-model:value="updateState.description"
placeholder="请输入备注"
:rows="4"
allowClear
/>
</a-form-item>
</a-form>
</template>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted, h, unref } from 'vue';
import { message, Modal, Table } from 'ant-design-vue';
import type { MenuProps, TableColumnsType } from 'ant-design-vue';
import { PlusOutlined, DownOutlined, CheckOutlined, StopOutlined } from '@ant-design/icons-vue';
import { listToTree, cloneDeep, isEmpty } from '@/utils/util';
import { getDeptList, createDept, updateDept, deleteDept, batchAvailableDept } from '@/api/system/dept';
import PageHeader from '@/components/PageHeader.vue'
import type { searchDataType, tableDataType } from './types';
// 响应式数据
const createForm = ref();
const updateForm = ref();
const tableLoading = ref(false);
const openModal = ref(false);
const modalTitle = ref('');
const modalSubmitLoading = ref(false);
const detailStateLoading = ref(false);
const dataSource = ref<tableDataType[]>([]);
const selectedRowKeys = ref<tableDataType['id'][]>([]);
const queryState = reactive<searchDataType>({
name: null,
available: null
});
const createState = reactive<tableDataType>({
name: '',
order: 1,
available: true,
parent_id: undefined,
description: ''
});
const updateState = reactive<tableDataType>({
id: undefined,
name: '',
order: 1,
available: true,
parent_id: undefined,
description: ''
});
const detailState = ref<tableDataType>({});
const columns = reactive<TableColumnsType>([
{
title: '部门名称',
dataIndex: 'name',
align: 'center',
key: 'name',
width: 160
},
{
title: '排序',
dataIndex: 'order',
key: 'order',
align: 'center',
width: 100
},
{
title: '状态',
dataIndex: 'available',
key: 'available',
align: 'center',
width: 100
},
{
title: '备注',
dataIndex: 'description',
key: 'description',
align: 'center'
},
{
title: '创建时间',
dataIndex: 'created_at',
align: 'center',
key: 'created_at'
},
{
title: '更新日期',
dataIndex: 'updated_at',
align: 'center',
key: 'created_at'
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
align: 'center',
width: 150
}
]);
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: (selectingRowKeys: tableDataType['id'][]) => {
selectedRowKeys.value = selectingRowKeys;
},
hideDefaultSelections: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
// 生命周期钩子
onMounted(() => loadingData());
// 查询
const onFinish = () => {
loadingData();
};
// 加载表格数据
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.name) {
params['name'] = queryState.name;
}
if (queryState.available) {
params['available'] = queryState.available === "true" ? true : false;
}
getDeptList(params).then(response => {
const result = response.data;
dataSource.value = listToTree(result.data.items);
}).catch(error => {
console.log(error);
}).finally(() => {
tableLoading.value = false;
});
};
// 重置查询
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
queryState.available = null;
loadingData();
};
// 删除
const deleteRow = (row: tableDataType) => {
deleteDept({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error);
});
};
// 批量启用/停用
const handleMoreClick: MenuProps['onClick'] = e => {
if (!selectedRowKeys.value || !(selectedRowKeys.value.length > 0)) {
message.warning('请先勾选数据');
return;
}
Modal.confirm({
title: '提示',
content: e.key == 1 ? '是否确定启用选择项?' : '是否确定停用选择项?',
onOk() {
const body = { ids: selectedRowKeys.value, available: e.key == 1 ? true : false };
batchAvailableDept(body).then(response => {
const result = response.data;
message.success(result.msg);
selectedRowKeys.value = [];
loadingData();
}).catch(error => {
console.log(error);
});
}
});
};
// 弹窗关键字处理
const modalHandle = (modalType: string, record?: tableDataType) => {
modalTitle.value = modalType;
openModal.value = true;
if (modalType === 'view' && record !== undefined) {
detailStateLoading.value = true;
detailState.value = record
detailStateLoading.value = false;
} else if (modalType === 'update' && record !== undefined) {
Object.keys(updateState).forEach(key => {
updateState[key] = record[key];
})
}
};
// 弹窗提交(详情/新建/修改)
const handleModalSumbit = () => {
modalSubmitLoading.value = true;
if (modalTitle.value === 'view') {
modalSubmitLoading.value = false;
openModal.value = false;
} else if (modalTitle.value === 'create') {
createForm.value.validate().then(() => {
const createBody = cloneDeep(createState);
Object.keys(createBody).forEach(key => {
if (isEmpty(createBody[key])) {
delete createBody[key];
}
});
createDept(createBody).then(response => {
const result = response.data;
modalSubmitLoading.value = false;
openModal.value = false;
Object.keys(createState).forEach(key => delete createState[key]);
createState.order = 1;
message.success(result.msg);
loadingData();
}).catch(error => {
console.error(error);
});
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error)
})
} else if (modalTitle.value === 'update') {
updateForm.value.validate().then(() => {
updateDept(updateState).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error)
});
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error)
})
}
};
</script>
<style lang="scss" scoped>
.tree-search-wrapper {
margin-block-end: 16px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
export interface searchDataType {
name?: string
available?: string
}
export interface tableDataType {
id?: number;
index?: number;
name?: string;
order?: number;
parent_id?: number;
parent_name?: string;
available?: boolean;
description?: string;
children?: tableDataType[];
created_at?: string;
updated_at?: string;
creator?: creatorType;
}
interface creatorType {
id?: number;
name?: string;
username?: string;
}
@@ -0,0 +1,211 @@
<template>
<a-modal title="选择创建人" v-model:open="openModal" :width="1200" :destroyOnClose="true" style="top: 30px">
<template #footer>
<a-button @click="handleModalCancel">取消</a-button>
<a-button @click="handleModalClear">清空</a-button>
<a-button type="primary" @click="handleModalSumbit">确定</a-button>
</template>
<div class="table-search-wrapper">
<a-card :bordered="true">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="creator_name" label="姓名" style="max-width: 300px;">
<a-input v-model:value="queryState.name" placeholder="请输入姓名" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<div class="table-wrapper">
<a-card title="用户列表" :bordered="true" :headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px' }">
<a-table :rowKey="record => record.id" :columns="columns" :data-source="dataSource" :loading="tableLoading"
:row-selection="rowSelection" @change="handleTableChange" :scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:pagination="pagination">
<template v-slot:bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用' : '禁用' }}
</span>
</template>
</template>
</a-table>
</a-card>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref } from 'vue';
import type { TableColumnsType } from 'ant-design-vue';
import { getUserList } from '@/api/system/user'
import type { searchCreatorDataType, creatorTableDataType } from './types'
const openModal = ref<boolean>(false);
const tableLoading = ref(false);
const queryState: searchCreatorDataType = reactive({
name: "",
available: undefined,
});
const columns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '姓名',
dataIndex: 'name',
align: 'center'
},
{
title: '状态',
dataIndex: 'available',
align: 'center'
},
{
title: '备注',
dataIndex: 'description',
align: 'center',
ellipsis: true,
width: 500
}
]
const dataSource = ref<creatorTableDataType[]>([]);
const loadingData = () => {
tableLoading.value = true;
dataSource.value = [];
let params = {};
if (queryState.name) {
params['name'] = queryState.name
}
if (queryState.available) {
params['available'] = queryState.available == "true" ? true : false;
}
params['page_no'] = pagination.current
params['page_size'] = pagination.pageSize
getUserList(params).then(response => {
const result = response.data;
dataSource.value = result.data.items;
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
tableLoading.value = false;
}).catch(error => {
tableLoading.value = false;
})
}
const onFinish = () => {
pagination.current = 1;
loadingData();
}
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
queryState.available = "true"
pagination.current = 1;
loadingData();
}
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
}
const selectedRowKeys = ref<creatorTableDataType['id'][]>([]);
const selectedRowName = ref<creatorTableDataType['name']>('');
const onSelectChange = (selectingRowKeys: creatorTableDataType['id'][], selectingRows: creatorTableDataType[]) => {
selectedRowKeys.value = selectingRowKeys;
selectedRowName.value = selectingRows[0].name;
}
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: onSelectChange,
hideDefaultSelections: true,
type: 'radio'
}
});
const emit = defineEmits(['event']);
const handleModalCancel = () => {
openModal.value = false;
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
queryState.available = "true"
pagination.current = 1;
pagination.pageSize = pagination.defaultPageSize;
selectedRowKeys.value = [];
selectedRowName.value = undefined;
}
const handleModalClear = () => {
handleModalCancel();
emit('event', selectedRowKeys.value, selectedRowName.value);
}
const handleModalSumbit = () => {
emit('event', selectedRowKeys.value, selectedRowName.value);
handleModalCancel();
}
defineExpose({
openModal,
selectedRowKeys,
selectedRowName,
loadingData
});
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 搜索表单 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="request_path" label="请求路径" style="max-width: 300px;">
<a-input v-model:value="queryState.request_path" placeholder="请输入请求路径" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="creator" label="创建人" style="max-width: 300px;">
<a-select v-model:value="queryState.creator_name" placeholder="请选择创建人" :open="false"
@click="selectModalHandle">
<template #suffixIcon>
<search-outlined />
</template>
</a-select>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="date-range-picker" label="创建日期" style="max-width: 350px;">
<a-range-picker v-model:value="queryState.date_range" value-format="YYYY-MM-DD" />
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card title="日志列表" :bordered="false" :headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<template #extra>
<a-button type="primary" :icon="h(DownOutlined)" @click="handleExport"
style="margin-right: 10px;">
导出
</a-button>
</template>
<a-table
:rowKey="record => record.id"
:columns="columns"
:data-source="dataSource"
:loading="tableLoading"
@change="handleTableChange"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:pagination="pagination"
:style="{ minHeight: '500px' }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'request_method'">
<a-tag :color="getRequestMethodColor(record.request_method)">{{ record.request_method }}</a-tag>
</template>
<template v-if="column.dataIndex === 'response_code'">
<a-tag :color="record.response_code === 200 ? 'green' : 'red'">{{ record.response_code }}</a-tag>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-space size="middle">
<a v-on:click="modalHandle('view', record, index)">查看</a>
<a-popconfirm title="确定要删除吗?" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal v-model:open="openModal" @ok="openModal = false" :width="800" :destroyOnClose="true" style="top: 30px">
<template #title>
<span>查看日志</span>
</template>
<a-spin :spinning="detailStateLoading">
<a-descriptions :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }" :labelStyle="{ width: '140px' }"
bordered>
<a-descriptions-item label="序号">{{ (pagination.current - 1) * pagination.pageSize + detailState.index + 1 }}</a-descriptions-item>
<a-descriptions-item label="请求地址" :span="2">{{ detailState.request_path }}</a-descriptions-item>
<a-descriptions-item label="请求方法">
<a-tag :color="getRequestMethodColor(detailState.request_method)">{{ detailState.request_method }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ detailState.request_ip }}</a-descriptions-item>
<a-descriptions-item label="浏览器">{{ detailState.request_browser }}</a-descriptions-item>
<a-descriptions-item label="系统">{{ detailState.request_os }}</a-descriptions-item>
<a-descriptions-item label="响应码" :span="4">
<a-tag :color="detailState.response_code === 200 ? 'green' : 'red'">{{ detailState.response_code
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="请求体" :span="4">{{ detailState.request_payload }}</a-descriptions-item>
<a-descriptions-item label="返回信息" :span="4">
<div class="scrollable-content">{{ detailState.response_json }}</div>
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-modal>
</div>
<!-- 选择人弹窗 -->
<SelectorModal ref="selectorModal" @event="handleSelectorModalEvent" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, h } from 'vue';
import type { TableColumnsType } from 'ant-design-vue';
import { SearchOutlined, DownOutlined} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import type { searchDataType, tableDataType, creatorType } from './types'
import PageHeader from '@/components/PageHeader.vue';
import { getLogList, deleteLog, exportLog } from '@/api/system/log'
import SelectorModal from './SelectorModal.vue'
import XLSX from 'xlsx';
const tableLoading = ref(false);
const dataSource = ref<tableDataType[]>([]);
const detailStateLoading = ref(false);
const openModal = ref(false);
const selectorModal = ref();
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
});
const queryState = reactive<searchDataType>({});
const detailState = ref<tableDataType>({});
const columns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '请求地址',
dataIndex: 'request_path',
align: 'center',
width: 200
},
{
title: '请求方法',
dataIndex: 'request_method',
align: 'center',
width: 80
},
{
title: 'IP地址',
dataIndex: 'request_ip',
align: 'center',
width: 100
},
{
title: '浏览器',
dataIndex: 'request_browser',
align: 'center',
width: 100
},
{
title: '系统',
dataIndex: 'request_os',
align: 'center',
width: 150
},
{
title: '响应码',
dataIndex: 'response_code',
align: 'center',
width: 80
},
{
title: '描述',
dataIndex: 'description',
align: 'center',
ellipsis: true,
// width: 500
},
{
title: '创建日期',
dataIndex: 'created_at',
align: 'center',
ellipsis: true,
width: 180
},
{
title: '操作',
dataIndex: 'operation',
align: 'center',
fixed: 'right',
width: 150
}
];
// 生命周期钩子
onMounted(() => loadingData());
// 查询
const onFinish = () => {
pagination.current = 1;
loadingData();
};
// 加载表格数据
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.request_path) {
params['request_path'] = queryState.request_path
}
if (queryState.creator) {
params['creator'] = queryState.creator
}
if (queryState.date_range) {
params['start_time'] = `${queryState.date_range[0]} 00:00:00`;
params['end_time'] = `${queryState.date_range[1]} 23:59:59`;
}
params['page_no'] = pagination.current;
params['page_size'] = pagination.pageSize;
getLogList(params).then(response => {
const result = response.data;
dataSource.value = result.data.items;
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
}).catch(error => {
console.log(error);
}).finally(() => {
tableLoading.value = false;
});
};
// 获取请求方法颜色
const getRequestMethodColor = (method: string) => {
const methodColors = {
GET: 'green',
POST: 'blue',
PUT: 'orange',
DELETE: 'red',
PATCH: 'purple',
HEAD: 'gray',
OPTIONS: 'cyan',
};
return methodColors[method] || 'black'; // 默认颜色为黑色
};
// 重置查询
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
pagination.current = 1;
loadingData();
};
// 删除
const deleteRow = (row: tableDataType) => {
deleteLog({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error)
})
};
// 表格分页
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
};
// 查看
const modalHandle = (modalType: string, record?: tableDataType, index?: number) => {
if (modalType === 'view' && record !== undefined) {
openModal.value = true;
detailStateLoading.value = true;
detailState.value = record;
detailState.value.index = index;
detailStateLoading.value = false;
}
};
// 选择人弹窗
const selectModalHandle = () => {
selectorModal.value.openModal = true;
selectorModal.value.selectedRowKeys = [queryState.creator];
selectorModal.value.selectedRowName = queryState.creator_name;
selectorModal.value.loadingData();
};
// 选择人弹窗事件
const handleSelectorModalEvent = (selectedSelectorRowKeys?: creatorType['id'][], selectedSelectorRowName?: creatorType['name']) => {
const creator = selectedSelectorRowKeys.length ? selectedSelectorRowKeys[0] : undefined;
const creator_name = selectedSelectorRowName || undefined;
queryState.creator = creator;
queryState.creator_name = creator_name;
};
/** 导出按钮操作 */
const handleExport = () => {
// 构建查询参数
const params = {
...queryState,
page_no: 1,
page_size: pagination.total // 导出所有数据
};
// 调用 exportLog 接口
exportLog(params).then(response => {
const blob = new Blob([response.data], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `log_${new Date().getTime()}.xlsx`; // 设置下载文件名
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('导出成功');
}).catch(error => {
console.error('导出失败:', error);
message.error('导出失败');
});
};
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
.scrollable-content {
max-height: 200px;
/* 设置最大高度 */
overflow-y: auto;
/* 添加垂直滚动条 */
white-space: pre-wrap;
/* 保留换行符并允许文本换行 */
}
.json-viewer {
max-height: 300px;
overflow-y: auto;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
export interface searchDataType {
request_path?: string;
creator?: number;
creator_name?: string;
date_range?: [string, string];
}
export interface tableDataType {
id?: number;
index?: number;
request_path?: string;
request_method?: string;
request_ip?: string;
request_browser?: string;
request_os?: string;
response_code?: number;
request_payload?: string;
response_json?: string;
description?: string;
creator?: creatorType;
created_at?: string;
updated_at?: string;
}
export interface searchCreatorDataType {
name?: string;
available?: string;
}
export interface creatorType {
id?: number;
name?: string;
username?: string;
}
+725
View File
@@ -0,0 +1,725 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 搜索表单 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="name" label="名称" style="max-width: 300px;">
<a-input v-model:value="queryState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card title="菜单列表"
:bordered="false"
:headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<template #extra>
<a-button type="primary" :icon="h(PlusOutlined)" @click="modalHandle('create')"
style="margin-right: 10px;">新建</a-button>
<a-dropdown>
<template #overlay>
<a-menu @click="handleMoreClick">
<a-menu-item key="1"><span style="margin-right: 10px;">
<CheckOutlined />
</span><span>批量启用</span></a-menu-item>
<a-menu-item key="2"><span style="margin-right: 10px;">
<StopOutlined />
</span><span>批量停用</span></a-menu-item>
</a-menu>
</template>
<a-button>更多
<DownOutlined />
</a-button>
</a-dropdown>
</template>
<a-table :rowKey="record => record.id"
:columns="columns"
:data-source="dataSource"
:row-selection="rowSelection"
:loading="tableLoading"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:pagination="false"
:style="{ minHeight: '500px' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span :style="{ color: !record.available ? 'rgb(255, 77, 79)' : 'rgba(0, 0, 0, .88)' }">
{{ record.name }}
</span>
</template>
<template v-if="column.dataIndex === 'type'">
<a-tag :color="record.type === 1 ? 'blue' : (record.type === 2 ? 'green' : 'orange')">
{{ record.type === 1 ? '目录' : (record.type === 2 ? '功能' : '权限') }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用' : '禁用' }}
</span>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-space size="middle">
<a @click="modalHandle('view', record)">查看</a>
<a @click="modalHandle('update', record)">修改</a>
<a-popconfirm title="确定删除吗?" ok-text="确定" cancel-text="取消" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal
v-model:open="openModal"
@ok="handleModalSumbit"
:width="800"
:destroyOnClose="true"
:confirmLoading="modalSubmitLoading"
style="top: 30px">
<template #title>
<span>{{ modalTitle === 'create' ? '新建菜单' : (modalTitle === 'view' ? '查看菜单' : '修改菜单') }}</span>
</template>
<div v-if="modalTitle === 'view'">
<a-spin :spinning="detailStateLoading">
<a-descriptions :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }" :labelStyle="{ width: '140px' }"
bordered>
<a-descriptions-item label="菜单名称">{{ detailState.name }}</a-descriptions-item>
<a-descriptions-item label="菜单类型">
<a-tag :color="detailState.type === 1 ? 'blue' : (detailState.type === 2 ? 'green' : 'orange')">
{{ detailState.type === 1 ? '目录' : (detailState.type === 2 ? '功能' : '权限') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="显示排序">{{ detailState.order }}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type !== 3" label="图标">
<a-button type="text" size="small" :icon="h(icons[detailState.icon])">{{ detailState.icon }}</a-button>
</a-descriptions-item>
<a-descriptions-item label="父级菜单" :span="2">{{ detailState.parent_name }}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type !== 1" label="权限标识" :span="2">{{ detailState.permission
}}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type !== 3" label="路由名称">{{ detailState.route_name
}}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type !== 3" label="路由路径">{{ detailState.route_path
}}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type === 1" label="重定向">{{ detailState.redirect
}}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type === 2" label="组件地址">{{ detailState.component_path
}}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type !== 3" label="是否缓存">{{ detailState.cache ? '' : ''
}}</a-descriptions-item>
<a-descriptions-item v-if="detailState.type !== 3" label="是否隐藏">{{ detailState.hidden ? '' : ''
}}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :color="detailState.available ? 'green' : 'red'" />{{ detailState.available ? '启用' : '禁用' }}
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
<div v-else-if="modalTitle === 'create'">
<a-form ref="createForm" :model="createState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="name" label="名称" :rules="[{ required: true, message: '请输入名称' }]">
<a-input v-model:value="createState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
<a-form-item name="type" label="类型" :rules="[{ required: true, message: '请选择类型' }]">
<a-radio-group v-model:value="createState.type">
<a-radio :value="1">目录</a-radio>
<a-radio :value="2">功能</a-radio>
<a-radio :value="3">权限</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="createState.type !== 1" name="permission" label="权限标识"
:rules="[{ required: createState.type !== 1 ? true : false, message: '请输入权限标识' }]">
<a-input v-model:value="createState.permission" placeholder="请输入权限标识" allowClear></a-input>
</a-form-item>
<a-form-item name="parent_id" label="父级菜单">
<a-tree-select v-model:value="createState.parent_id"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" :tree-data="menuSelectorTreeData"
:field-names="{ children: 'children', label: 'name', value: 'id' }" tree-node-filter-prop="name"
style="width: 100%" show-search allow-clear placeholder="请选择父级菜单"></a-tree-select>
</a-form-item>
<a-form-item name="icon" label="图标">
<a-popover placement="right" trigger="click">
<template #content>
<div class="icon-clear-btn-wrapper">
<a-button :icon="h(ClearOutlined)" @click="iconClearClickHandle" danger>清空</a-button>
</div>
<a-form-item-rest>
<a-input v-model:value="iconSelector.search" placeholder="搜索图标" allowClear />
</a-form-item-rest>
<a-tabs v-model:activeKey="iconSelector.activeTab" @change="iconTabHandleChange" tabPosition="left"
style="margin-top: 20px;">
<a-tab-pane v-for="(item, index) in iconDataSource" :key="index" :tab="item.type">
<div class="icon-wrapper">
<a-flex wrap="wrap" gap="small">
<a-button
v-for="item in iconData.slice((pagination.current - 1) * pagination.pageSize, pagination.current * pagination.pageSize)"
:icon="item.icon" @click="iconHandleClick(item)"
:class="createState.icon === item.name ? 'active' : ''" />
</a-flex>
</div>
</a-tab-pane>
</a-tabs>
<div class="icon-pagination-wrapper">
<a-pagination size="small" v-model:current="pagination.current"
v-model:pageSize="pagination.pageSize" :total="pagination.total" :showTotal="pagination.showTotal"
:showQuickJumper="pagination.showQuickJumper" :showSizeChanger="false" />
</div>
</template>
<a-button :icon="createState.icon ? h(icons[createState.icon]) : ''" style="width: 250px;">{{
createState.icon ? createState.icon : '请选择图标' }}</a-button>
</a-popover>
</a-form-item>
<a-form-item name="order" label="排序">
<a-input-number v-model:value="createState.order" :min="1" />
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="createState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="createState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
<div v-if="createState.type !== 3">
<a-divider style="font-weight: 700">以下均为前端配置项</a-divider>
<a-form-item name="route_name" label="路由名称"
:rules="[{ required: createState.type !== 3 ? true : false, message: '请输入路由名称' }]">
<a-input v-model:value="createState.route_name" placeholder="请输入路由名称" allowClear></a-input>
</a-form-item>
<a-form-item name="route_path" label="路由路径"
:rules="[{ required: createState.type !== 3 ? true : false, message: '请输入路由路径' }]">
<a-input v-model:value="createState.route_path" placeholder="请输入路由路径" allowClear></a-input>
</a-form-item>
<a-form-item v-if="createState.type === 1" name="redirect" label="重定向"
:rules="[{ required: createState.type === 1 ? true : false, message: '请输入重定向' }]">
<a-input v-model:value="createState.redirect" placeholder="请输入重定向" allowClear></a-input>
</a-form-item>
<a-form-item v-if="createState.type === 2" name="component_path" label="组件地址"
:rules="[{ required: createState.type === 2 ? true : false, message: '请输入组件地址' }]">
<a-input v-model:value="createState.component_path" placeholder="请输入组件地址" allowClear></a-input>
</a-form-item>
<a-form-item name="cache" label="是否缓存"
:rules="[{ required: createState.type !== 3 ? true : false, message: '请选择缓存状态' }]">
<a-switch v-model:checked="createState.cache"></a-switch>
</a-form-item>
<a-form-item name="hidden" label="是否隐藏"
:rules="[{ required: createState.type !== 3 ? true : false, message: '请选择隐藏状态' }]">
<a-switch v-model:checked="createState.hidden"></a-switch>
</a-form-item>
</div>
</a-form>
</div>
<div v-else>
<a-form ref="updateForm" :model="updateState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="name" label="名称" :rules="[{ required: true, message: '请输入名称' }]">
<a-input v-model:value="updateState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
<a-form-item v-if="updateState.type !== 1" name="permission" label="权限标识"
:rules="[{ required: updateState.type !== 1 ? true : false, message: '请输入权限标识' }]">
<a-input v-model:value="updateState.permission" placeholder="请输入权限标识" allowClear></a-input>
</a-form-item>
<a-form-item name="parent_id" label="父级菜单">
<a-tree-select v-model:value="updateState.parent_id"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" :tree-data="menuSelectorTreeData"
:field-names="{ children: 'children', label: 'name', value: 'id' }" tree-node-filter-prop="name"
style="width: 100%" show-search allow-clear placeholder="请选择父级菜单"></a-tree-select>
</a-form-item>
<a-form-item name="icon" label="图标">
<a-popover placement="right" trigger="click">
<template #content>
<div class="icon-clear-btn-wrapper">
<a-button :icon="h(ClearOutlined)" @click="iconClearClickHandle" danger>清空</a-button>
</div>
<a-form-item-rest>
<a-input v-model:value="iconSelector.search" placeholder="搜索图标" allowClear />
</a-form-item-rest>
<a-tabs v-model:activeKey="iconSelector.activeTab" @change="iconTabHandleChange" tabPosition="left"
style="margin-top: 20px;">
<a-tab-pane v-for="(item, index) in iconDataSource" :key="index" :tab="item.type">
<div class="icon-wrapper">
<a-flex wrap="wrap" gap="small">
<a-button
v-for="item in iconData.slice((pagination.current - 1) * pagination.pageSize, pagination.current * pagination.pageSize)"
:icon="item.icon" @click="iconHandleClick(item)"
:class="updateState.icon === item.name ? 'active' : ''" />
</a-flex>
</div>
</a-tab-pane>
</a-tabs>
<div class="icon-pagination-wrapper">
<a-pagination size="small" v-model:current="pagination.current"
v-model:pageSize="pagination.pageSize" :total="pagination.total" :showTotal="pagination.showTotal"
:showQuickJumper="pagination.showQuickJumper" :showSizeChanger="false" />
</div>
</template>
<a-button :icon="updateState.icon ? h(icons[updateState.icon]) : ''" style="width: 250px;">{{
updateState.icon ? updateState.icon : '请选择图标' }}</a-button>
</a-popover>
</a-form-item>
<a-form-item name="order" label="排序">
<a-input-number v-model:value="updateState.order" :min="1" />
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="updateState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="updateState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
<div v-if="updateState.type !== 3">
<a-divider style="font-weight: 700">以下均为前端配置项</a-divider>
<a-form-item name="route_name" label="路由名称"
:rules="[{ required: updateState.type !== 3 ? true : false, message: '请输入路由名称' }]">
<a-input v-model:value="updateState.route_name" placeholder="请输入路由名称" allowClear></a-input>
</a-form-item>
<a-form-item name="route_path" label="路由路径"
:rules="[{ required: updateState.type !== 3 ? true : false, message: '请输入路由路径' }]">
<a-input v-model:value="updateState.route_path" placeholder="请输入路由路径" allowClear></a-input>
</a-form-item>
<a-form-item v-if="updateState.type === 1" name="redirect" label="重定向"
:rules="[{ required: updateState.type === 1 ? true : false, message: '请输入重定向' }]">
<a-input v-model:value="updateState.redirect" placeholder="请输入重定向" allowClear></a-input>
</a-form-item>
<a-form-item v-if="updateState.type === 2" name="component_path" label="组件地址"
:rules="[{ required: updateState.type === 2 ? true : false, message: '请输入组件地址' }]">
<a-input v-model:value="updateState.component_path" placeholder="请输入组件地址" allowClear></a-input>
</a-form-item>
<a-form-item name="cache" label="是否缓存"
:rules="[{ required: updateState.type !== 3 ? true : false, message: '请选择缓存状态' }]">
<a-switch v-model:checked="updateState.cache"></a-switch>
</a-form-item>
<a-form-item name="hidden" label="是否隐藏"
:rules="[{ required: updateState.type !== 3 ? true : false, message: '请选择隐藏状态' }]">
<a-switch v-model:checked="updateState.hidden"></a-switch>
</a-form-item>
</div>
</a-form>
</div>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref, h, onMounted, watch } from 'vue';
import * as icons from '@ant-design/icons-vue';
import { message, Modal, Table } from 'ant-design-vue';
import type { TableColumnsType, MenuProps } from 'ant-design-vue';
import { PlusOutlined, DownOutlined, CheckOutlined, StopOutlined, ClearOutlined } from '@ant-design/icons-vue';
import { listToTree, cloneDeep, isEmpty } from '@/utils/util';
import axios from "axios"
import PageHeader from '@/components/PageHeader.vue';
import { getMenuList, createMenu, updateMenu, deleteMenu, batchAvailableMenu } from '@/api/system/menu'
import type { searchDataType, tableDataType } from './types'
// 响应式数据
const createForm = ref();
const updateForm = ref();
const tableLoading = ref(false);
const openModal = ref(false);
const modalTitle = ref('');
const modalSubmitLoading = ref(false);
const detailStateLoading = ref(false);
const menuSelectorTreeData = ref<tableDataType[]>([])
const dataSource = ref<tableDataType[]>([]);
const iconSelector = ref({ activeTab: 0, search: undefined });
const selectedRowKeys = ref<tableDataType['id'][]>([]);
let iconDataSource = [];
const iconData = ref([]);
const queryState = reactive<searchDataType>({
name: null,
available: null
});
const createState = reactive<tableDataType>({
name: '',
type: 1,
icon: '',
order: 1,
permission: '',
route_name: '',
route_path: '',
component_path: '',
redirect: '',
parent_id: undefined,
cache: true,
hidden: false,
available: true,
description: ''
})
const updateState = reactive<tableDataType>({
id: undefined,
name: '',
type: 1,
icon: '',
order: 1,
permission: '',
route_name: '',
route_path: '',
component_path: '',
redirect: '',
parent_id: undefined,
cache: true,
hidden: false,
available: true,
description: ''
})
const detailState = ref<tableDataType>({});
const pagination = reactive({
current: 1,
pageSize: 70,
showQuickJumper: true,
showSizeChanger: false,
total: 0,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const columns = reactive<TableColumnsType>([
{
title: '菜单名称',
dataIndex: 'name',
align: 'center',
},
{
title: '图标',
dataIndex: 'icon',
align: 'center',
},
{
title: '显示排序',
dataIndex: 'order',
align: 'center',
width: 80
},
{
title: '菜单类型',
dataIndex: 'type',
align: 'center',
width: 80
},
{
title: '权限标识',
dataIndex: 'permission',
align: 'center',
},
{
title: '状态',
dataIndex: 'available',
align: 'center',
},
{
title: '备注',
dataIndex: 'description',
align: 'center',
ellipsis: true,
width: 200
},
{
title: '操作',
dataIndex: 'operation',
fixed: 'right',
align: 'center',
width: 150
}
]);
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: (selectingRowKeys: tableDataType['id'][]) => {
selectedRowKeys.value = selectingRowKeys;
},
checkStrictly: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
// 生命周期钩子
onMounted(() => {
loadingData();
axios.get('/icons.json').then(response => {
response.data.forEach((item, index) => {
iconDataSource.push({ type: item.type, icons: [] });
item.icons.forEach(icon => {
iconDataSource[index].icons.push({ name: icon, icon: h(icons[icon]) })
})
});
iconData.value = iconDataSource[iconSelector.value.activeTab].icons;
pagination.total = iconData.value.length;
}).catch(error => {
console.log(error)
})
});
// 查询
const onFinish = () => {
loadingData();
};
// 加载表格数据
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.name) {
params['name'] = queryState.name
}
if (queryState.available) {
params['available'] = queryState.available == "true" ? true : false;
}
getMenuList(params).then(response => {
const result = response.data;
menuSelectorTreeData.value = listToTree(result.data.items.filter((item) => item.type !== 3));
dataSource.value = listToTree(result.data.items);
}).catch(error => {
console.log(error);
}).finally(() => {
tableLoading.value = false;
});
};
// 重置查询
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
queryState.available = null
loadingData();
}
// 弹窗关键字处理
const modalHandle = (modalType: string, record?: tableDataType) => {
modalTitle.value = modalType;
openModal.value = true;
if (modalType === 'view' && record !== undefined) {
detailStateLoading.value = true;
detailState.value = record;
detailStateLoading.value = false;
} else if (modalType === 'update' && record !== undefined) {
Object.keys(updateState).forEach(key => {
updateState[key] = record[key];
})
}
iconSelector.value = {
activeTab: 0,
search: undefined
}
}
// 删除
const deleteRow = (row: tableDataType) => {
deleteMenu({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error)
})
}
// 批量启用/停用
const handleMoreClick: MenuProps['onClick'] = e => {
if (!selectedRowKeys.value || !(selectedRowKeys.value.length > 0)) {
message.warning('请先勾选数据');
return;
}
Modal.confirm({
title: '提示',
content: e.key == 1 ? '是否确定启用选择项?' : '是否确定停用选择项?',
onOk() {
const body = { ids: selectedRowKeys.value, available: e.key == 1 ? true : false };
batchAvailableMenu(body).then(response => {
const result = response.data;
message.success(result.msg);
selectedRowKeys.value = [];
loadingData();
}).catch(error => {
console.log(error);
})
}
});
}
// 弹窗提交(详情/新建/修改)
const handleModalSumbit = () => {
modalSubmitLoading.value = true;
if (modalTitle.value === 'view') {
modalSubmitLoading.value = false;
openModal.value = false;
} else if (modalTitle.value === 'create') {
createForm.value.validate().then(() => {
const createBody = cloneDeep(createState);
Object.keys(createBody).forEach(key => {
if (isEmpty(createBody[key])) {
delete createBody[key];
}
})
createMenu(createBody).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
Object.keys(createState).forEach(key => delete createState[key])
createState.type = 1;
createState.order = 1;
createState.cache = true;
createState.hidden = false;
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
} else if (modalTitle.value === 'update') {
updateForm.value.validate().then(() => {
updateMenu(updateState).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}
}
// 监听菜单类型
watch(() => createState.type, (newType) => {
Object.keys(createState).forEach(key => delete createState[key])
createState.type = newType;
createState.order = 1;
createState.cache = true;
createState.hidden = false;
})
// 图标搜索
watch(() => iconSelector.value.search, (newSearchField) => iconSearch(newSearchField));
// 图标搜索
const iconSearch = (field) => {
let activeIcons = iconDataSource[iconSelector.value.activeTab].icons;
if (field) {
activeIcons = activeIcons.filter(item => item.name.toLowerCase().includes(field.toLowerCase()));
}
iconData.value = activeIcons;
pagination.current = 1;
pagination.total = iconData.value.length;
}
// 图标标签切换
const iconTabHandleChange = () => iconSearch(iconSelector.value.search);
// 图标点击
const iconHandleClick = (values) => {
if (modalTitle.value === 'create') {
createState.icon = values.name;
} else if (modalTitle.value === 'update') {
updateState.icon = values.name;
}
}
// 图标清空
const iconClearClickHandle = () => {
if (modalTitle.value === 'create') {
createState.icon = '';
} else if (modalTitle.value === 'update') {
updateState.icon = '';
}
}
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
.icon-wrapper {
min-width: 420px;
max-width: 420px;
min-height: 270px;
max-height: 270px;
}
.icon-clear-btn-wrapper {
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.icon-pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.active {
color: #4096ff;
border-color: #4096ff;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
export interface searchDataType {
name?: string
available?: string
}
export interface tableDataType {
id?: number;
name?: string;
type?: number;
icon?: string;
order?: number;
permission?: string;
route_name?: string;
route_path?: string;
component_path?: string;
redirect?: string;
parent_id?: number;
parent_name?: string;
cache?: boolean;
hidden?: boolean;
available?: boolean;
description?: string;
created_at?: string;
updated_at?: string;
children?: tableDataType[];
creator?: creatorType;
}
interface creatorType {
id?: number;
name?: string;
username?: string;
}
+508
View File
@@ -0,0 +1,508 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 搜索表单 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="name" label="名称" style="max-width: 300px;">
<a-input v-model:value="queryState.notice_title" placeholder="请输入标题" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card title="公告通知列表" :bordered="false" :headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<template #extra>
<a-button type="primary"
:icon="h(PlusOutlined)"
@click="modalHandle('create')"
style="margin-right: 10px;">新建</a-button>
<a-button type="primary"
:icon="h(DownOutlined)"
@click="handleExport"
style="margin-right: 10px;">导出
</a-button>
<a-dropdown>
<template #overlay>
<a-menu @click="handleMoreClick">
<a-menu-item key="1"><span style="margin-right: 10px;">
<CheckOutlined />
</span><span>批量启用</span></a-menu-item>
<a-menu-item key="2"><span style="margin-right: 10px;">
<StopOutlined />
</span><span>批量停用</span></a-menu-item>
</a-menu>
</template>
<a-button>更多
<DownOutlined />
</a-button>
</a-dropdown>
</template>
<a-table :rowKey="record => record.id" :columns="columns" :data-source="dataSource"
:row-selection="rowSelection" :loading="tableLoading" @change="handleTableChange"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }" :pagination="pagination"
:style="{ minHeight: '500px' }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'notice_title'">
<span :style="{ color: !record.available ? 'rgb(255, 77, 79)' : 'rgba(0, 0, 0, .88)' }">
{{ record.notice_title }}
</span>
</template>
<template v-if="column.dataIndex === 'notice_type'">
<span :style="{ color: record.notice_type === 1 ? '#108ee9' : '#f8e231' }">
{{ record.notice_type === 1 ? '通知' : '公告' }}
</span>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用' :
'禁用' }}
</span>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-space size="middle">
<a @click="modalHandle('view', index)">查看</a>
<a @click="modalHandle('update', index)">修改</a>
<a-popconfirm title="确定删除吗?" ok-text="确定" cancel-text="取消" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal v-model:open="openModal" @ok="handleModalSumbit" :width="800" :destroyOnClose="true"
:confirmLoading="modalSubmitLoading" style="top: 30px">
<template #title>
<span>{{ modalTitle === 'create' ? '新建公告通知' : (modalTitle === 'view' ? '查看公告通知' : '修改公告通知') }}</span>
</template>
<div v-if="modalTitle === 'view'">
<a-spin :spinning="detailStateLoading">
<a-descriptions :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }"
:labelStyle="{ width: '140px' }" bordered>
<a-descriptions-item label="序号">{{ (pagination.current - 1) * pagination.pageSize +
detailState.index + 1 }}</a-descriptions-item>
<a-descriptions-item label="标题">{{ detailState.notice_title }}</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="detailState.notice_type === 1 ? '#108ee9' : '#f8e231'">{{ detailState.notice_type === 1 ? '通知' : '公告' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="内容">{{ detailState.notice_content }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :color="detailState.available ? 'green' : 'red'" />{{ detailState.available ?
'启用' :
'禁用' }}
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description
}}</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
<div v-else-if="modalTitle === 'create'">
<a-form ref="createForm" :model="createState"
v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="notice_title" label="标题" :rules="[{ required: true, message: '请输入标题' }]">
<a-input v-model:value="createState.notice_title" placeholder="请输入标题" allowClear></a-input>
</a-form-item>
<a-form-item name="notice_type" label="类型" :rules="[{ required: true, message: '请选择类型' }]">
<a-radio-group v-model:value="createState.notice_type">
<a-radio :value="1">通知</a-radio>
<a-radio :value="2">公告</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="notice_content" label="内容" :rules="[{ required: true, message: '请输入内容' }]">
<a-input v-model:value="createState.notice_content" placeholder="请输入内容" allowClear></a-input>
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="createState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="createState.description" placeholder="请输入备注" :rows="4"
allowClear />
</a-form-item>
</a-form>
</div>
<div v-else>
<a-form ref="updateForm" :model="updateState"
v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="notice_title" label="标题" :rules="[{ required: true, message: '请输入标题' }]">
<a-input v-model:value="updateState.notice_title" placeholder="请输入标题" allowClear></a-input>
</a-form-item>
<a-form-item name="notice_type" label="类型" :rules="[{ required: true, message: '请选择类型' }]">
<a-radio-group v-model:value="updateState.notice_type">
<a-radio :value="1">通知</a-radio>
<a-radio :value="2">公告</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="notice_content" label="内容" :rules="[{ required: true, message: '请输入内容' }]">
<a-input v-model:value="updateState.notice_content" placeholder="请输入内容" allowClear></a-input>
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="updateState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="updateState.description" placeholder="请输入备注" :rows="4"
allowClear />
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref, onMounted, h } from 'vue';
import { Table, message, Modal } from 'ant-design-vue';
import type { TableColumnsType, MenuProps } from 'ant-design-vue';
import { PlusOutlined, DownOutlined, CheckOutlined, StopOutlined } from '@ant-design/icons-vue';
import { cloneDeep, isEmpty } from '@/utils/util';
import PageHeader from '@/components/PageHeader.vue';
import { getNoticeList, createNotice, updateNotice, deleteNotice, batchAvailableNotice, exportNotice} from '@/api/system/notice'
import type { searchDataType, tableDataType } from './types'
import XLSX from 'xlsx';
const createForm = ref();
const updateForm = ref();
const tableLoading = ref(false);
const openModal = ref(false);
const modalTitle = ref('');
const modalSubmitLoading = ref(false);
const detailStateLoading = ref(false);
const dataSource = ref<tableDataType[]>([]);
const selectedRowKeys = ref<tableDataType['id'][]>([]);
const queryState = reactive<searchDataType>({
notice_title: null,
available: null
});
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const createState = reactive<tableDataType>({
notice_title: '',
notice_type: 1,
notice_content: '',
available: true,
description: ''
})
const updateState = reactive<tableDataType>({
id: undefined,
notice_title: '',
notice_type: 1,
notice_content: '',
available: true,
description: ''
})
const detailState = ref<tableDataType>({})
const columns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '标题',
dataIndex: 'notice_title',
align: 'center'
},
{
title: '类型',
dataIndex: 'notice_type',
align: 'center'
},
{
title: '内容',
dataIndex: 'notice_content',
align: 'center'
},
{
title: '状态',
dataIndex: 'available',
align: 'center'
},
{
title: '备注',
dataIndex: 'description',
align: 'center',
ellipsis: true,
// width: 500
},
{
title: '创建日期',
dataIndex: 'created_at',
align: 'center',
ellipsis: true,
// width: 120
},
{
title: '更新日期',
dataIndex: 'updated_at',
align: 'center',
ellipsis: true,
// width: 120
},
{
title: '操作',
dataIndex: 'operation',
align: 'center',
fixed: 'right',
width: 150
}
];
// 表格选中配置
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: (selectingRowKeys: tableDataType['id'][]) => {
selectedRowKeys.value = selectingRowKeys;
},
hideDefaultSelections: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
// 加载表格数据
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.notice_title) {
params['notice_title'] = queryState.notice_title
}
if (queryState.available) {
params['available'] = queryState.available == true ? true : false;
}
params['page_no'] = pagination.current
params['page_size'] = pagination.pageSize
getNoticeList(params).then(response => {
const result = response.data;
dataSource.value = result.data.items;
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
}).catch(error => {
console.log(error);
}).finally(() => {
tableLoading.value = false;
});
}
// 生命周期钩子
onMounted(() => loadingData());
// 查询
const onFinish = () => {
pagination.current = 1;
loadingData();
};
// 重置查询
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
pagination.current = 1;
queryState.available = null
loadingData();
}
// 表格分页处理
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
}
// 弹窗关键字处理
const modalHandle = (modalType: string, index?: number) => {
modalTitle.value = modalType;
openModal.value = true;
if (modalType === 'view' && index !== undefined) {
detailStateLoading.value = true;
detailState.value = dataSource.value[index];
detailState.value.index = index;
detailStateLoading.value = false;
} else if (modalType === 'update' && index !== undefined) {
const selected = dataSource.value[index];
Object.keys(updateState).forEach(key => {
updateState[key] = selected[key];
})
}
}
// 删除
const deleteRow = (row: tableDataType) => {
deleteNotice({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error)
})
}
// 批量启用/停用
const handleMoreClick: MenuProps['onClick'] = e => {
if (!selectedRowKeys.value || !(selectedRowKeys.value.length > 0)) {
message.warning('请先勾选数据');
return;
}
Modal.confirm({
title: '提示',
content: e.key == 1 ? '是否确定启用选择项?' : '是否确定停用选择项?',
onOk() {
const body = { ids: selectedRowKeys.value, available: e.key == 1 ? true : false };
batchAvailableNotice(body).then(response => {
const result = response.data;
message.success(result.msg);
selectedRowKeys.value = [];
loadingData();
}).catch(error => {
console.log(error);
})
}
});
}
// 弹窗提交(详情/新建/修改)
const handleModalSumbit = () => {
modalSubmitLoading.value = true;
if (modalTitle.value === 'view') {
modalSubmitLoading.value = false;
openModal.value = false;
} else if (modalTitle.value === 'create') {
createForm.value.validate().then(() => {
const createBody = cloneDeep(createState);
Object.keys(createBody).forEach(key => {
if (isEmpty(createBody[key])) {
delete createBody[key];
}
})
createNotice(createBody).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
Object.keys(createState).forEach(key => delete createState[key]);
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
} else if (modalTitle.value === 'update') {
updateForm.value.validate().then(() => {
updateNotice(updateState).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
message.success(response.data.msg);
loadingData();
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}
}
// 导出按钮操作
const handleExport = () => {
// 构建查询参数
const params = {
...queryState,
page_no: 1,
page_size: pagination.total // 导出所有数据
};
// 调用 exportLog 接口
exportNotice(params).then(response => {
const blob = new Blob([response.data], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notice_${new Date().getTime()}.xlsx`; // 设置下载文件名
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('导出成功');
}).catch(error => {
console.error('导出失败:', error);
message.error('导出失败');
});
};
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
export interface searchDataType {
notice_title?: string
available?: boolean
}
export interface tableDataType {
id?: number;
index?: number;
notice_title?: string;
notice_type?: number;
notice_content?: string;
available?: boolean;
description?: string;
created_at?: string;
updated_at?: string;
creator?: creatorType;
}
interface creatorType {
id?: number;
name?: string;
username?: string;
}
@@ -0,0 +1,486 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 搜索表单 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="name" label="名称" style="max-width: 300px;">
<a-input v-model:value="queryState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card title="岗位列表"
:bordered="false"
:headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<template #extra>
<a-button type="primary"
:icon="h(PlusOutlined)"
@click="modalHandle('create')"
style="margin-right: 10px;">
新建
</a-button>
<a-button type="primary" :icon="h(DownOutlined)" @click="handleExport"
style="margin-right: 10px;">
导出
</a-button>
<a-dropdown>
<template #overlay>
<a-menu @click="handleMoreClick">
<a-menu-item key="1"><span style="margin-right: 10px;">
<CheckOutlined />
</span><span>批量启用</span></a-menu-item>
<a-menu-item key="2"><span style="margin-right: 10px;">
<StopOutlined />
</span><span>批量停用</span></a-menu-item>
</a-menu>
</template>
<a-button>更多
<DownOutlined />
</a-button>
</a-dropdown>
</template>
<a-table :rowKey="record => record.id"
:columns="columns"
:data-source="dataSource"
:row-selection="rowSelection"
:loading="tableLoading"
@change="handleTableChange"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:pagination="pagination"
:style="{ minHeight: '500px' }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'name'">
<span :style="{ color: !record.available ? 'rgb(255, 77, 79)' : 'rgba(0, 0, 0, .88)' }">
{{ record.name }}
</span>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用' : '禁用' }}
</span>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-space size="middle">
<a @click="modalHandle('view', index)">查看</a>
<a @click="modalHandle('update', index)">修改</a>
<a-popconfirm title="确定删除吗?" ok-text="确定" cancel-text="取消" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal
v-model:open="openModal"
@ok="handleModalSumbit"
:width="800"
:destroyOnClose="true"
:confirmLoading="modalSubmitLoading"
style="top: 30px"
>
<template #title>
<span>{{ modalTitle === 'create' ? '新建岗位' : (modalTitle === 'view' ? '查看岗位' : '修改岗位') }}</span>
</template>
<div v-if="modalTitle === 'view'">
<a-spin :spinning="detailStateLoading">
<a-descriptions :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }" :labelStyle="{ width: '140px' }"
bordered>
<a-descriptions-item label="序号">{{ (pagination.current - 1) * pagination.pageSize + detailState.index + 1 }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ detailState.name }}</a-descriptions-item>
<a-descriptions-item label="排序">{{ detailState.order }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :color="detailState.available ? 'green' : 'red'" />{{ detailState.available ? '启用' : '禁用' }}
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
<div v-else-if="modalTitle === 'create'">
<a-form ref="createForm" :model="createState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="name" label="名称" :rules="[{ required: true, message: '请输入名称' }]">
<a-input v-model:value="createState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
<a-form-item name="order" label="排序" :rules="[{ required: true, message: '请输入排序' }]">
<a-input-number v-model:value="createState.order" :min="1" />
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="createState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="createState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
</a-form>
</div>
<div v-else>
<a-form ref="updateForm" :model="updateState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="name" label="名称" :rules="[{ required: true, message: '请输入名称' }]">
<a-input v-model:value="updateState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
<a-form-item name="order" label="排序" :rules="[{ required: true, message: '请输入排序' }]">
<a-input-number v-model:value="updateState.order" :min="1" />
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="updateState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="updateState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref, onMounted, h } from 'vue';
import { Table, message, Modal } from 'ant-design-vue';
import type { TableColumnsType, MenuProps } from 'ant-design-vue';
import { PlusOutlined, DownOutlined, CheckOutlined, StopOutlined } from '@ant-design/icons-vue';
import { cloneDeep, isEmpty } from '@/utils/util';
import PageHeader from '@/components/PageHeader.vue';
import { getPositionList, createPosition, updatePosition, deletePosition, batchAvailablePosition, exportPosition } from '@/api/system/position'
import type { searchDataType, tableDataType } from './types'
import XLSX from 'xlsx';
const createForm = ref();
const updateForm = ref();
const tableLoading = ref(false);
const openModal = ref(false);
const modalTitle = ref('');
const modalSubmitLoading = ref(false);
const detailStateLoading = ref(false);
const dataSource = ref<tableDataType[]>([]);
const selectedRowKeys = ref<tableDataType['id'][]>([]);
const queryState = reactive<searchDataType>({
name: null,
available: null
});
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const createState = reactive<tableDataType>({
name: '',
order: 1,
available: true,
description: ''
})
const updateState = reactive<tableDataType>({
id: undefined,
name: '',
order: 1,
available: true,
description: ''
})
const detailState = ref<tableDataType>({})
const columns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '名称',
dataIndex: 'name',
align: 'center'
},
{
title: '排序',
dataIndex: 'order',
align: 'center'
},
{
title: '状态',
dataIndex: 'available',
align: 'center'
},
{
title: '备注',
dataIndex: 'description',
align: 'center',
ellipsis: true,
// width: 500
},
{
title: '创建日期',
dataIndex: 'created_at',
align: 'center',
ellipsis: true,
// width: 120
},
{
title: '更新日期',
dataIndex: 'updated_at',
align: 'center',
ellipsis: true,
// width: 120
},
{
title: '操作',
dataIndex: 'operation',
align: 'center',
fixed: 'right',
width: 150
}
];
// 表格选中配置
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: (selectingRowKeys: tableDataType['id'][]) => {
selectedRowKeys.value = selectingRowKeys;
},
hideDefaultSelections: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
// 加载表格数据
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.name) {
params['name'] = queryState.name
}
if (queryState.available) {
params['available'] = queryState.available == "true" ? true : false;
}
params['page_no'] = pagination.current
params['page_size'] = pagination.pageSize
getPositionList(params).then(response => {
const result = response.data;
dataSource.value = result.data.items;
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
}).catch(error => {
console.log(error);
}).finally(() => {
tableLoading.value = false;
});
}
// 生命周期钩子
onMounted(() => loadingData());
// 查询
const onFinish = () => {
pagination.current = 1;
loadingData();
};
// 重置查询
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
pagination.current = 1;
queryState.available = null
loadingData();
}
// 表格分页处理
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
}
// 弹窗关键字处理
const modalHandle = (modalType: string, index?: number) => {
modalTitle.value = modalType;
openModal.value = true;
if (modalType === 'view' && index !== undefined) {
detailStateLoading.value = true;
detailState.value = dataSource.value[index];
detailState.value.index = index;
detailStateLoading.value = false;
} else if (modalType === 'update' && index !== undefined) {
const selected = dataSource.value[index];
Object.keys(updateState).forEach(key => {
updateState[key] = selected[key];
})
}
}
// 删除
const deleteRow = (row: tableDataType) => {
deletePosition({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error)
})
}
// 批量启用/停用
const handleMoreClick: MenuProps['onClick'] = e => {
if (!selectedRowKeys.value || !(selectedRowKeys.value.length > 0)) {
message.warning('请先勾选数据');
return;
}
Modal.confirm({
title: '提示',
content: e.key == 1 ? '是否确定启用选择项?' : '是否确定停用选择项?',
onOk() {
const body = { ids: selectedRowKeys.value, available: e.key == 1 ? true : false };
batchAvailablePosition(body).then(response => {
const result = response.data;
message.success(result.msg);
selectedRowKeys.value = [];
loadingData();
}).catch(error => {
console.log(error);
})
}
});
}
// 弹窗提交(详情/新建/修改)
const handleModalSumbit = () => {
modalSubmitLoading.value = true;
if (modalTitle.value === 'view') {
modalSubmitLoading.value = false;
openModal.value = false;
} else if (modalTitle.value === 'create') {
createForm.value.validate().then(() => {
const createBody = cloneDeep(createState);
Object.keys(createBody).forEach(key => {
if (isEmpty(createBody[key])) {
delete createBody[key];
}
})
createPosition(createBody).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
Object.keys(createState).forEach(key => delete createState[key]);
createState.order = 1;
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
} else if (modalTitle.value === 'update') {
updateForm.value.validate().then(() => {
updatePosition(updateState).then(response => {
modalSubmitLoading.value = false;
openModal.value = false;
message.success(response.data.msg);
loadingData();
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}
}
/** 导出按钮操作 */
const handleExport = () => {
// 构建查询参数
const params = {
...queryState,
page_no: 1,
page_size: pagination.total // 导出所有数据
};
// 调用 exportLog 接口
exportPosition(params).then(response => {
const blob = new Blob([response.data], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `position_${new Date().getTime()}.xlsx`; // 设置下载文件名
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('导出成功');
}).catch(error => {
console.error('导出失败:', error);
message.error('导出失败');
});
};
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
@@ -0,0 +1,22 @@
export interface searchDataType {
name?: string
available?: string
}
export interface tableDataType {
id?: number;
index?: number;
name?: string;
order?: number;
available?: boolean;
description?: string;
created_at?: string;
updated_at?: string;
creator?: creatorType;
}
interface creatorType {
id?: number;
name?: string;
username?: string;
}
@@ -0,0 +1,223 @@
<template>
<!-- 授权弹窗 -->
<a-drawer v-model:open="openDrawer" :destroyOnClose="true" :title="`授权 - ${currentRole?.name || ''}`" placement="right" :width="1500"
:bodyStyle="{ padding: 'none' }">
<template #extra>
<a-button type="primary" @click="handleDrawerSave" :loading="drawerSaving">保存</a-button>
</template>
<div style="display: flex;">
<div style="min-width: 300px;">
<div style="display: flex; gap: 10px; ">
<div style="width: 10px; background-color: #1677ff;"></div>
<div>
<span style="font-size: 16px;">数据授权</span>
<a-tooltip placement="right">
<template #title>
<span>授权用户可操作的数据范围</span>
</template>
<QuestionCircleOutlined style="margin-left: 5px;" />
</a-tooltip>
</div>
</div>
<a-select v-model:value="permissionState.data_scope" style="width: 80%; margin-top: 15px;">
<a-select-option :value="1">仅本人数据权限</a-select-option>
<a-select-option :value="2">本部门数据权限</a-select-option>
<a-select-option :value="3">本部门及以下数据权限</a-select-option>
<a-select-option :value="4">全部数据权限</a-select-option>
<a-select-option :value="5">自定义数据权限</a-select-option>
</a-select>
<a-tree v-if="permissionState.data_scope === 5 && deptTreeData.length"
:checkedKeys="permissionState.dept_ids"
:rowKey="record => record.id"
:tree-data="deptTreeData"
:defaultExpandAll="true"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
@check="deptTreeCheck" checkable checkStrictly style="margin-top: 15px;" />
</div>
<a-divider type="vertical" style="height: 80vh;" />
<div>
<div style="display: flex; gap: 10px;">
<div style="width: 10px; background-color: #1677ff;"></div>
<div>
<span style="font-size: 16px;">菜单授权</span>
<a-tooltip placement="right">
<template #title>
<span>授权用户在菜单中可操作的范围</span>
</template>
<QuestionCircleOutlined style="margin-left: 5px;" />
</a-tooltip>
</div>
</div>
<div style="margin-top: 15px;">
<a-table :rowKey="record => record.id"
:columns="menuColumns"
:data-source="menuTreeData"
:row-selection="menuRowSelection"
:loading="tableLoading"
:scroll="{ x: 500, y: 'calc(100vh - 270px)' }"
:pagination="false"
:style="{ minHeight: '700px' }"
:expandAll="true">
<template v-slot:bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'type'">
<a-tag :color="record.type === 1 ? 'blue' : (record.type === 2 ? 'green' : 'orange')">
{{ record.type === 1 ? '目录' : (record.type === 2 ? '功能' : '权限') }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用'
: '禁用' }}
</span>
</template>
</template>
</a-table>
</div>
</div>
</div>
<template #footer>
<div style="height: 50px;"></div>
</template>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { listToTree } from '@/utils/util';
import { message } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { QuestionCircleOutlined } from '@ant-design/icons-vue';
import type { tableDataType, permissionDataType, permissionDeptType, permissionMenuType } from './types'
import { getMenuList } from '@/api/system/menu';
import { getDeptList } from '@/api/system/dept';
import { setPermission } from '@/api/system/role';
const openDrawer = ref(false);
const currentRole = ref(null);
const permissionState = ref<permissionDataType>({
role_ids: [],
menu_ids: [],
data_scope: 1,
dept_ids: []
});
const deptTreeData = ref<permissionDeptType[]>([]);
const menuTreeData = ref<permissionMenuType[]>([]);
const tableLoading = ref(false);
const drawerSaving = ref(false);
const menuColumns: TableColumnsType = [
{
title: '菜单名称',
dataIndex: 'name'
},
{
title: '菜单类型',
dataIndex: 'type',
width: 100
},
{
title: '权限标识',
dataIndex: 'permission',
},
{
title: '状态',
dataIndex: 'available',
width: 100
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
width: 400
}
];
// 初始化方法,用于打开抽屉并加载数据
const init = async (record: tableDataType) => {
if (!record) {
message.error('请选择角色')
return
}
openDrawer.value = true;
tableLoading.value = true;
try {
// 获取部门树
const deptResponse = await getDeptList();
deptTreeData.value = listToTree(deptResponse.data.data.items);
// 获取菜单树
const menuResponse = await getMenuList();
menuTreeData.value = listToTree(menuResponse.data.data.items);
currentRole.value = record;
// 初始化权限状态
permissionState.value = {
role_ids: [record.id],
menu_ids: record.menus?.map(menu => menu.id) || [],
data_scope: record.data_scope || 1,
dept_ids: record.depts?.map(dept => dept.id) || []
}
} catch (error) {
console.error('获取权限数据失败:', error);
message.error('获取权限数据失败');
} finally {
tableLoading.value = false;
}
}
// 部门树选择回调
const deptTreeCheck = (checkedKeys) => {
permissionState.value.dept_ids = checkedKeys.checked;
}
const emit = defineEmits(['event']);
// 保存权限设置
const handleDrawerSave = () => {
drawerSaving.value = true;
setPermission(permissionState.value).then(response => {
message.success(response.data.msg);
drawerSaving.value = false;
openDrawer.value = false;
emit('event');
}).catch(error => {
console.error('保存权限失败:', error);
message.error('保存权限失败');
drawerSaving.value = false;
})
}
// 菜单选择变更回调
const onMenuSelectChange = (selectingRowKeys: permissionMenuType['id'][]) => {
permissionState.value.menu_ids = selectingRowKeys;
}
// 菜单表格选择配置
const menuRowSelection = computed(() => {
return {
selectedRowKeys: permissionState.value.menu_ids,
checkStrictly: true,
onChange: onMenuSelectChange
}
});
defineExpose({
init,
permissionState
});
</script>
<style lang="scss" scoped></style>
+498
View File
@@ -0,0 +1,498 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 表格搜索 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="name" label="名称" style="max-width: 300px;">
<a-input v-model:value="queryState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card title="角色列表"
:bordered="false"
:headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<template #extra>
<a-button type="primary" :icon="h(PlusOutlined)" @click="modalHandle('create')"
style="margin-right: 10px;">
新建
</a-button>
<a-button type="primary" :icon="h(DownOutlined)" @click="handleExport"
style="margin-right: 10px;">
导出
</a-button>
<a-dropdown>
<template #overlay>
<a-menu @click="handleMoreClick">
<a-menu-item key="1"><span style="margin-right: 10px;">
<CheckOutlined />
</span><span>批量启用</span></a-menu-item>
<a-menu-item key="2"><span style="margin-right: 10px;">
<StopOutlined />
</span><span>批量停用</span></a-menu-item>
</a-menu>
</template>
<a-button>更多
<DownOutlined />
</a-button>
</a-dropdown>
</template>
<a-table :rowKey="record => record.id"
:columns="columns"
:data-source="dataSource"
:row-selection="rowSelection"
:loading="tableLoading"
@change="handleTableChange"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }"
:pagination="pagination"
:style="{ minHeight: '500px' }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'name'">
<span :style="{ color: !record.available ? 'rgb(255, 77, 79)' : 'rgba(0, 0, 0, .88)' }">
{{ record.name }}
</span>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用' : '禁用' }}
</span>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-space size="middle">
<a @click="modalHandle('view', index)">查看</a>
<a @click="modalHandle('update', index)">修改</a>
<a @click="modalHandle('permission', index)">授权</a>
<a-popconfirm title="确定删除吗?" ok-text="确定" cancel-text="取消" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal v-model:open="openModal" @ok="handleModalSumbit" :width="800" :destroyOnClose="true"
:confirmLoading="modalSubmitLoading" style="top: 30px">
<template #title>
<span>{{ modalTitle === 'create' ? '新建角色' : (modalTitle === 'view' ? '查看角色' : '修改角色') }}</span>
</template>
<div v-if="modalTitle === 'view'">
<a-spin :spinning="detailStateLoading">
<a-descriptions :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }" :labelStyle="{ width: '140px' }"
bordered>
<a-descriptions-item label="序号">{{ (pagination.current - 1) * pagination.pageSize + detailState.index + 1 }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ detailState.name }}</a-descriptions-item>
<a-descriptions-item label="排序">{{ detailState.order }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :color="detailState.available ? 'green' : 'red'" />{{ detailState.available ? '启用' : '禁用' }}
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
<div v-else-if="modalTitle === 'create'">
<a-form ref="createForm" :model="createState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="name" label="名称" :rules="[{ required: true, message: '请输入名称' }]">
<a-input v-model:value="createState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
<a-form-item name="order" label="排序" :rules="[{ required: true, message: '请输入排序' }]">
<a-input-number v-model:value="createState.order" :min="1" />
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="createState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="createState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
</a-form>
</div>
<div v-else>
<a-form ref="updateForm" :model="updateState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="name" label="名称" :rules="[{ required: true, message: '请输入名称' }]">
<a-input v-model:value="updateState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
<a-form-item name="order" label="排序" :rules="[{ required: true, message: '请输入排序' }]">
<a-input-number v-model:value="updateState.order" :min="1" />
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="updateState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="updateState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
<!-- 授权弹窗 -->
<PermissionDrawer ref="permissionDrawer" @event="handlePermissionDrawerEvent" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref, onMounted, h } from 'vue';
import { Table, message, Modal } from 'ant-design-vue';
import type { TableColumnsType, MenuProps } from 'ant-design-vue';
import { PlusOutlined, DownOutlined, CheckOutlined, StopOutlined } from '@ant-design/icons-vue';
import { cloneDeep, isEmpty } from '@/utils/util';
import PageHeader from '@/components/PageHeader.vue';
import PermissionDrawer from './PermissionDrawer.vue'
import type { searchDataType, tableDataType } from './types'
import { getRoleList, createRole, updateRole, deleteRole, batchAvailableRole, exportRole } from '@/api/system/role'
import XLSX from 'xlsx';
const createForm = ref();
const updateForm = ref();
const tableLoading = ref(false);
const openModal = ref(false);
const permissionDrawer = ref();
const modalTitle = ref('');
const modalSubmitLoading = ref(false);
const detailStateLoading = ref(false);
const dataSource = ref<tableDataType[]>([]);
const selectedRowKeys = ref<tableDataType['id'][]>([]);
const queryState: searchDataType = reactive({
name: null,
available: null
});
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const createState = reactive<tableDataType>({
name: '',
order: 1,
available: true,
description: ''
})
const updateState = reactive<tableDataType>({
id: undefined,
name: '',
order: 1,
available: true,
description: ''
})
const detailState = ref<tableDataType>({})
const columns = reactive<TableColumnsType>([
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '名称',
dataIndex: 'name',
align: 'center',
// width: 80
},
{
title: '排序',
dataIndex: 'order',
align: 'center',
width: 80
},
{
title: '状态',
dataIndex: 'available',
align: 'center',
// width: 80
},
{
title: '备注',
dataIndex: 'description',
align: 'center',
ellipsis: true,
width: 150
},
{
title: '创建日期',
dataIndex: 'created_at',
align: 'center',
ellipsis: true,
// width: 120
},
{
title: '更新日期',
dataIndex: 'updated_at',
align: 'center',
ellipsis: true,
// width: 120
},
{
title: '操作',
dataIndex: 'operation',
align: 'center',
fixed: 'right',
width: 180
}
]);
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: (selectingRowKeys: tableDataType['id'][]) => {
selectedRowKeys.value = selectingRowKeys;
},
hideDefaultSelections: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
// 生命周期钩子
onMounted(() => loadingData());
// 查询
const onFinish = () => {
pagination.current = 1;
loadingData();
};
// 加载表格数据
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.name) {
params['name'] = queryState.name
}
if (queryState.available) {
params['available'] = queryState.available == "true" ? true : false;
}
params['page_no'] = pagination.current
params['page_size'] = pagination.pageSize
getRoleList(params).then(response => {
const result = response.data;
dataSource.value = result.data.items;
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
}).catch(error => {
console.error(error);
}).finally(() => {
tableLoading.value = false;
})
}
// 重置查询
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
pagination.current = 1;
queryState.available = null
loadingData();
}
// 删除
const deleteRow = (row: tableDataType) => {
deleteRole({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error)
})
}
// 批量启用/停用
const handleMoreClick: MenuProps['onClick'] = e => {
if (!selectedRowKeys.value || !(selectedRowKeys.value.length > 0)) {
message.warning('请先勾选数据');
return;
}
Modal.confirm({
title: '提示',
content: e.key == 1 ? '是否确定启用选择项?' : '是否确定停用选择项?',
onOk() {
const body = { ids: selectedRowKeys.value, available: e.key == 1 ? true : false };
batchAvailableRole(body).then(response => {
const result = response.data;
message.success(result.msg);
selectedRowKeys.value = [];
loadingData();
}).catch(error => {
console.log(error);
})
}
});
}
// 表格分页
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
}
// 弹窗关键字处理
const modalHandle = (modalType: string, index?: number) => {
modalTitle.value = modalType;
openModal.value = true;
if (modalType === 'view' && index !== undefined) {
detailStateLoading.value = true;
detailState.value = dataSource.value[index];
detailState.value.index = index;
detailStateLoading.value = false;
} else if (modalType === 'update' && index !== undefined) {
Object.keys(updateState).forEach(key => {
updateState[key] = dataSource.value[index][key];
})
} else if (modalType === 'permission' && index !== undefined) {
openModal.value = false;
permissionDrawer.value.init(dataSource.value[index]);
}
}
// 弹窗提交(详情/新建/修改)
const handleModalSumbit = () => {
modalSubmitLoading.value = true;
if (modalTitle.value === 'view') {
modalSubmitLoading.value = false;
openModal.value = false;
} else if (modalTitle.value === 'create') {
createForm.value.validate().then(() => {
const createBody = cloneDeep(createState);
Object.keys(createBody).forEach(key => {
if (isEmpty(createBody[key])) {
delete createBody[key];
}
})
createRole(createBody).then(response => {
const result = response.data;
modalSubmitLoading.value = false;
openModal.value = false;
Object.keys(createState).forEach(key => delete createState[key]);
createState.order = 1;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
} else if (modalTitle.value === 'update') {
updateForm.value.validate().then(() => {
updateRole(updateState).then(response => {
const result = response.data;
modalSubmitLoading.value = false;
openModal.value = false;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.log(error)
})
}
}
/** 导出按钮操作 */
const handleExport = () => {
// 构建查询参数
const params = {
...queryState,
page_no: 1,
page_size: pagination.total // 导出所有数据
};
// 调用 exportLog 接口
exportRole(params).then(response => {
const blob = new Blob([response.data], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `role_${new Date().getTime()}.xlsx`; // 设置下载文件名
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('导出成功');
}).catch(error => {
console.error('导出失败:', error);
message.error('导出失败');
});
};
// 授权弹窗事件
const handlePermissionDrawerEvent = () => loadingData();
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
export interface searchDataType {
name?: string
available?: string
}
export interface tableDataType {
id?: number;
index?: number;
name?: string;
order?: number;
data_scope?: number;
available?: boolean;
description?: string;
created_at?: string;
updated_at?: string;
creator?: creatorType;
menus?: permissionMenuType[];
depts?: permissionDeptType[];
}
export interface permissionDataType {
role_ids?: tableDataType['id'][];
menu_ids?: permissionMenuType['id'][];
data_scope?: number;
dept_ids?: number[];
}
export interface permissionDeptType {
id?: number;
name?: string;
parent_id?: number;
children?: permissionDeptType[];
}
export interface permissionMenuType {
id?: number;
name?: string;
type?: number;
permission?: string;
parent_id?: number;
available?: boolean;
description?: string;
children?: permissionMenuType[];
}
interface creatorType {
id?: number;
name?: string;
username?: string;
}
@@ -0,0 +1,225 @@
<template>
<a-modal :title="title" v-model:open="openModal" :width="1200" :destroyOnClose="true" style="top: 30px">
<template #footer>
<a-button @click="handleModalCancel">取消</a-button>
<a-button @click="handleModalClear">清空</a-button>
<a-button type="primary" @click="handleModalSumbit">确定</a-button>
</template>
<div class="table-search-wrapper">
<a-card :bordered="true">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="name" label="名称" style="max-width: 300px;">
<a-input v-model:value="queryState.name" placeholder="请输入名称" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState.available" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<div>
<a-table :rowKey="record => record.id" :columns="columns" :data-source="dataSource" :row-selection="rowSelection"
:loading="tableLoading" @change="handleTableChange" :scroll="{ x: 500, y: 330 }" :pagination="pagination"
:style="{ minHeight: '330px' }">
<template v-slot:bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'available'">
<span><a-badge :color="record.available ? 'green' : 'red'" /> {{ record.available ? '启用' : '禁用' }}
</span>
</template>
</template>
</a-table>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref } from 'vue';
import { Table } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { getRoleList } from '@/api/system/role'
import { getPositionList } from '@/api/system/position'
import type { searchSelectDataType, roleSelectorType, positionSelectorType } from './types.ts';
const subject = ref('');
const openModal = ref(false);
const tableLoading = ref(false);
const dataSource = ref<roleSelectorType[] | positionSelectorType[]>([]);
const selectedRowKeys = ref<roleSelectorType['id'][] | positionSelectorType['id'][]>([]);
const selectedRowItemNames = ref<roleSelectorType['name'][] | positionSelectorType['name'][]>([]);
const queryState: searchSelectDataType = reactive({
name: "",
available: 'true'
});
const pagination = reactive({
current: 1,
pageSize: 20,
defaultPageSize: 20,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const columns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '名称',
dataIndex: 'name',
align: 'center'
},
{
title: '状态',
dataIndex: 'available',
align: 'center'
},
{
title: '备注',
dataIndex: 'description',
align: 'center',
ellipsis: true,
width: 500
}
];
const title = computed(() => {
return subject.value === 'role' ? '选择角色' : '选择岗位';
});
const loadingData = () => {
if (!subject.value) {
return;
}
tableLoading.value = true;
dataSource.value = [];
let params = {};
if (queryState.name) {
params['name'] = queryState.name
}
if (queryState.available) {
params['available'] = queryState.available == "true" ? true : false;
}
params['page_no'] = pagination.current
params['page_size'] = pagination.pageSize
const requestApi = subject.value == 'role' ? getRoleList(params) : getPositionList(params);
requestApi.then(response => {
const result = response.data;
dataSource.value = result.data.items;
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
tableLoading.value = false;
}).catch(error => {
console.log(error);
tableLoading.value = false;
})
}
const onFinish = () => {
pagination.current = 1;
loadingData();
};
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
pagination.current = 1;
queryState.available = "true"
loadingData();
}
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
}
const onSelectChange = (
selectingRowKeys: roleSelectorType['id'][] | positionSelectorType['id'][],
selectingRows: roleSelectorType[] | positionSelectorType[],
) => {
selectedRowKeys.value = selectingRowKeys;
selectedRowItemNames.value = selectingRows.map(row => row.name);
}
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: onSelectChange,
hideDefaultSelections: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
const emit = defineEmits(['event']);
const handleModalSumbit = () => {
emit('event', subject.value, selectedRowKeys.value, selectedRowItemNames.value);
handleModalCancel();
}
const handleModalClear = () => {
handleModalCancel();
emit('event', subject.value, selectedRowKeys.value, selectedRowItemNames.value);
}
const handleModalCancel = () => {
openModal.value = false;
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
queryState.available = "true"
pagination.current = 1;
pagination.pageSize = pagination.defaultPageSize;
selectedRowKeys.value = [];
selectedRowItemNames.value = [];
}
defineExpose({
subject,
openModal,
selectedRowKeys,
selectedRowItemNames,
loadingData
});
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
+846
View File
@@ -0,0 +1,846 @@
<template>
<div>
<!-- 页面头部 -->
<page-header />
<!-- 表格搜索区域 -->
<div class="table-search-wrapper">
<a-card :bordered="false">
<a-form :model="queryState" @finish="onFinish">
<a-row>
<a-col flex="0 1 450px">
<a-form-item name="username" label="用户名" style="max-width: 300px;">
<a-input v-model:value="queryState['username']" placeholder="请输入用户名" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="name" label="姓名" style="max-width: 300px;">
<a-input v-model:value="queryState['name']" placeholder="请输入姓名" allowClear></a-input>
</a-form-item>
</a-col>
<a-col flex="0 1 450px">
<a-form-item name="available" label="状态" style="max-width: 300px;">
<a-select v-model:value="queryState['available']" placeholder="全部" allowClear>
<a-select-option value="true">启用</a-select-option>
<a-select-option value="false">停用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col>
<a-button type="primary" html-type="submit" :loading="tableLoading">查询</a-button>
<a-button style="margin: 0 8px" @click="resetFields">重置</a-button>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<a-card title="用户列表" :bordered="false" :headStyle="{ borderBottom: 'none', padding: '20px 24px' }"
:bodyStyle="{ padding: '0 24px', minHeight: 'calc(100vh - 400px)' }">
<template #extra>
<a-button type="primary" :icon="h(PlusOutlined)" @click="modalHandle('create')" style="margin-right: 10px;">
新建
</a-button>
<a-button type="primary" :icon="h(UploadOutlined)" @click="modalHandle('import')" style="margin-right: 10px;">
导入
</a-button>
<a-button type="primary" :icon="h(DownOutlined)" @click="handleExport" style="margin-right: 10px;">
导出
</a-button>
<a-dropdown>
<template #overlay>
<a-menu @click="handleMoreClick">
<a-menu-item key="1"><span style="margin-right: 10px;">
<CheckOutlined />
</span><span>批量启用</span></a-menu-item>
<a-menu-item key="2"><span style="margin-right: 10px;">
<StopOutlined />
</span><span>批量停用</span></a-menu-item>
</a-menu>
</template>
<a-button>更多
<DownOutlined />
</a-button>
</a-dropdown>
</template>
<a-table :rowKey="record => record.id" :columns="columns" :data-source="dataSource"
:row-selection="rowSelection" :loading="tableLoading" @change="handleTableChange"
:scroll="{ x: 500, y: 'calc(100vh - 500px)' }" :pagination="pagination" :style="{ minHeight: '500px' }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
<span>{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</span>
</template>
<template v-if="column.dataIndex === 'name'">
<span :style="{ color: !record.available ? 'rgb(255, 77, 79)' : 'rgba(0, 0, 0, .88)' }">
{{ record.name }}
</span>
</template>
<template v-if="column.dataIndex === 'dept'">
<span>{{ record.dept_name }}</span>
</template>
<template v-if="column.dataIndex === 'roles'">
<span>{{ record.roleNames }}</span>
</template>
<template v-if="column.dataIndex === 'positions'">
<span>{{ record.positionNames }}</span>
</template>
<template v-if="column.dataIndex === 'gender'">
<a-tag :color="record.gender === 1 ? 'blue' : 'pink'">{{ record.gender === 1 ? '男' : '女' }}</a-tag>
</template>
<template v-if="column.dataIndex === 'available'">
<span>
<a-badge :color="record.available ? 'green' : 'red'" />
{{ record.available ? '启用' : '禁用' }}
</span>
</template>
<template v-if="column.dataIndex === 'is_superuser'">
<span>
<a-badge :color="record.is_superuser ? 'green' : 'red'" />
{{ record.is_superuser ? '是' : '否' }}
</span>
</template>
<template v-if="column.dataIndex === 'operation'">
<a-space size="middle">
<a v-on:click="modalHandle('view', index)">查看</a>
<a v-on:click="modalHandle('update', index)">修改</a>
<a-popconfirm title="确定删除吗?" ok-text="确定" cancel-text="取消" @confirm="deleteRow(record)">
<a style="color: red;">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 弹窗区域 -->
<div class="modal-wrapper">
<a-modal v-model:open="openModal" @ok="handleModalSumbit" :width="800" :destroyOnClose="true"
:confirmLoading="modalSubmitLoading" style="top: 30px">
<template #title>
<span>{{ modalTitle === 'create' ? '新建用户' : (modalTitle === 'view' ? '查看用户' : (modalTitle === 'import' ? '导入用户' : '修改用户')) }}</span>
</template>
<div v-if="modalTitle === 'view'">
<a-spin :spinning="detailStateLoading">
<a-descriptions :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 1, xs: 1 }" :labelStyle="{ width: '140px' }"
bordered>
<a-descriptions-item label="序号">{{ (pagination.current - 1) * pagination.pageSize + detailState.index + 1
}}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ detailState.username }}</a-descriptions-item>
<a-descriptions-item label="姓名">{{ detailState.name }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ detailState.gender === 1 ? '男' : '女' }}</a-descriptions-item>
<a-descriptions-item label="部门" :span="2">{{ detailState.dept_name }}</a-descriptions-item>
<a-descriptions-item label="角色" :span="2">{{ detailState.roleNames }}</a-descriptions-item>
<a-descriptions-item label="岗位" :span="2">{{ detailState.positionNames }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ detailState.email }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ detailState.mobile }}</a-descriptions-item>
<a-descriptions-item label="是否超管">
<a-badge :color="detailState.is_superuser ? 'green' : 'red'" />{{ detailState.is_superuser ? '是' : '否'
}}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge :color="detailState.available ? 'green' : 'red'" />{{ detailState.available ? '启用' : '禁用' }}
</a-descriptions-item>
<a-descriptions-item label="上次登录时间">{{ detailState.last_login }}</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detailState.creator ? detailState.creator.name : '-'
}}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detailState.created_at }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ detailState.updated_at }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detailState.description }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
<div v-else-if="modalTitle === 'import'">
<!-- 用户导入对话框 -->
<a-modal :title="upload.title" v-model:visible="upload.open" width="400px" centered>
<a-upload ref="uploadRef"
:before-upload="beforeUpload"
:multiple="false"
:accept="'.xlsx, .xls'"
:headers="upload.headers"
:action="upload.url + '?updateSupport=' + upload.updateSupport"
:disabled="upload.isUploading"
:custom-request="customRequest"
:show-upload-list="false" drag>
<p class="ant-upload-drag-icon">
<upload-outlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
<a-checkbox v-model:checked="upload.updateSupport" /> 是否更新已存在的用户数据
</p>
<p class="ant-upload-hint">
仅允许导入 xlsxlsx 格式的文件
<a :href="importUserTemplate" target="_blank"
style="font-size: 12px; vertical-align: baseline;">下载模板</a>
</p>
</a-upload>
<template #footer>
<a-button type="primary" @click="submitFileForm"> </a-button>
<a-button @click="upload.open = false"> </a-button>
</template>
</a-modal>
</div>
<div v-else-if="modalTitle === 'create'">
<a-form ref="createForm" :model="createState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="username" label="用户名" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="createState.username" placeholder="请输入用户名" allowClear></a-input>
</a-form-item>
<a-form-item name="name" label="姓名" :rules="[{ required: true, message: '请输入姓名' }]">
<a-input v-model:value="createState.name" placeholder="请输入姓名" allowClear></a-input>
</a-form-item>
<a-form-item name="dept_id" label="部门" :rules="[{ required: true, message: '请选择部门' }]">
<a-tree-select v-model:value="createState.dept_id"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" :tree-data="deptTreeData"
:field-names="{ children: 'children', label: 'name', value: 'id' }" placeholder="请选择部门"
tree-node-filter-prop="name" style="width: 100%" show-search allow-clear></a-tree-select>
</a-form-item>
<a-form-item name="role_ids" label="角色">
<a-select v-model:value="createState.roleNames" :open="false" @click="selectModalHandle('role')"
placeholder="请选择角色">
<template #suffixIcon>
<SearchOutlined />
</template>
</a-select>
</a-form-item>
<a-form-item name="position_ids" label="岗位">
<a-select v-model:value="createState.positionNames" :open="false" @click="selectModalHandle('position')"
placeholder="请选择岗位">
<template #suffixIcon>
<SearchOutlined />
</template>
</a-select>
</a-form-item>
<a-form-item name="password" label="密码" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="createState.password" placeholder="请输入密码" allowClear></a-input-password>
</a-form-item>
<a-form-item name="gender" label="性别" :rules="[{ required: true, message: '请选择性别' }]">
<a-select v-model:value="createState.gender" placeholder="请选择性别" allowClear>
<a-select-option :value="1"></a-select-option>
<a-select-option :value="2"></a-select-option>
</a-select>
</a-form-item>
<a-form-item name="email" label="邮箱">
<a-input v-model:value="createState.email" placeholder="请输入邮箱" allowClear></a-input>
</a-form-item>
<a-form-item name="mobile" label="联系电话">
<a-input v-model:value="createState.mobile" placeholder="请输入电话" allowClear></a-input>
</a-form-item>
<a-form-item name="is_superuser" label="是否超管" :rules="[{ required: true, message: '请选择是否超管' }]">
<a-radio-group v-model:value="createState.is_superuser">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="createState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="createState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
</a-form>
</div>
<div v-else>
<a-form ref="updateForm" :model="updateState" v-bind="{ labelCol: { span: 5 }, wrapperCol: { span: 15 } }">
<a-form-item name="username" label="用户名" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="updateState.username" placeholder="请输入用户名" allowClear></a-input>
</a-form-item>
<a-form-item name="name" label="姓名" :rules="[{ required: true, message: '请输入姓名' }]">
<a-input v-model:value="updateState.name" placeholder="请输入姓名" allowClear></a-input>
</a-form-item>
<a-form-item name="dept_id" label="部门" :rules="[{ required: true, message: '请选择部门' }]">
<a-tree-select v-model:value="updateState.dept_id"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" :tree-data="deptTreeData"
:field-names="{ children: 'children', label: 'name', value: 'id' }" placeholder="请选择部门"
tree-node-filter-prop="name" style="width: 100%" show-search allow-clear></a-tree-select>
</a-form-item>
<a-form-item name="role_ids" label="角色">
<a-select v-model:value="updateState.roleNames" :open="false" @click="selectModalHandle('role')"
placeholder="请选择角色">
<template #suffixIcon>
<SearchOutlined />
</template>
</a-select>
</a-form-item>
<a-form-item name="position_ids" label="岗位">
<a-select v-model:value="updateState.positionNames" :open="false" @click="selectModalHandle('position')"
placeholder="请选择岗位">
<template #suffixIcon>
<SearchOutlined />
</template>
</a-select>
</a-form-item>
<a-form-item v-if="!showPasswordInput" label="修改密码">
<a-checkbox v-model:checked="showPasswordInput"></a-checkbox>
</a-form-item>
<a-form-item v-else name="password" label="密码">
<a-input-password v-model:value="updateState.password" placeholder="请输入密码" allowClear></a-input-password>
</a-form-item>
<a-form-item name="gender" label="性别" :rules="[{ required: true, message: '请选择性别' }]">
<a-select v-model:value="updateState.gender" placeholder="请选择性别" allowClear>
<a-select-option :value="1"></a-select-option>
<a-select-option :value="2"></a-select-option>
</a-select>
</a-form-item>
<a-form-item name="email" label="邮箱">
<a-input v-model:value="updateState.email" placeholder="请输入邮箱" allowClear></a-input>
</a-form-item>
<a-form-item name="mobile" label="联系电话">
<a-input v-model:value="updateState.mobile" placeholder="请输入电话" allowClear></a-input>
</a-form-item>
<a-form-item name="is_superuser" label="是否超管" :rules="[{ required: true, message: '请选择是否超管' }]">
<a-radio-group v-model:value="updateState.is_superuser">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="available" label="状态" :rules="[{ required: true, message: '请选择状态' }]">
<a-radio-group v-model:value="updateState.available">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="description" label="备注">
<a-textarea v-model:value="updateState.description" placeholder="请输入备注" :rows="4" allowClear />
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
<!-- 选择器弹窗 -->
<SelectorModal ref="selectorModal" @event="handleSelectorModalEvent" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, unref, onMounted, h } from 'vue';
import { Table, message, Modal } from 'ant-design-vue';
import type { TableColumnsType, MenuProps } from 'ant-design-vue';
import { PlusOutlined, DownOutlined, UploadOutlined, CheckOutlined, StopOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { isEmpty, listToTree } from '@/utils/util';
import PageHeader from '@/components/PageHeader.vue';
import { getDeptList } from '@/api/system/dept'
import { getUserList, createUser, updateUser, deleteUser, batchAvailableUser, exportUser, importUserTemplate, importUserData } from '@/api/system/user'
import SelectorModal from './SelectorModal.vue'
import type { searchDataType, tableDataType, deptTreeType, roleSelectorType, positionSelectorType } from './types'
import XLSX from 'xlsx';
import store from '@/store';
import storage from 'store';
const tableLoading = ref(false);
const openModal = ref(false);
const modalTitle = ref('');
const modalSubmitLoading = ref(false);
const detailStateLoading = ref(false);
const showPasswordInput = ref(false);
const createForm = ref();
const updateForm = ref();
const selectorModal = ref();
const dataSource = ref<tableDataType[]>([]);
const selectedRowKeys = ref<tableDataType['id'][]>([]);
const deptTreeData = ref<deptTreeType[]>([]);
const queryState = reactive<searchDataType>({
username: null,
name: null,
available: null
});
const pagination = reactive({
current: 1,
pageSize: 10,
defaultPageSize: 10,
showSizeChanger: true,
total: dataSource.value.length,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 总共 ${total}`
})
const createState = reactive<tableDataType>({
username: '',
name: '',
dept_id: undefined,
dept_name: '',
role_ids: undefined,
roleNames: undefined,
position_ids: undefined,
positionNames: undefined,
password: '',
gender: undefined,
email: '',
mobile: '',
is_superuser: false,
available: true,
description: ''
})
const updateState = reactive<tableDataType>({
id: undefined,
username: '',
name: '',
dept_id: undefined,
dept_name: '',
role_ids: [],
roleNames: undefined,
position_ids: [],
positionNames: undefined,
password: '',
gender: undefined,
email: '',
mobile: '',
is_superuser: false,
available: true,
description: '',
})
const detailState = ref<tableDataType>({});
const columns = reactive<TableColumnsType>([
{
title: '序号',
dataIndex: 'index',
align: 'center',
width: 80
},
{
title: '用户名',
dataIndex: 'username',
ellipsis: true,
align: 'center',
width: 80
},
{
title: '姓名',
dataIndex: 'name',
ellipsis: true,
align: 'center',
width: 80
},
{
title: '是否超管',
dataIndex: 'is_superuser',
align: 'center',
width: 80
},
{
title: '部门',
dataIndex: 'dept',
ellipsis: true,
align: 'center',
width: 80
},
{
title: '角色',
dataIndex: 'roles',
ellipsis: true,
align: 'center',
width: 100
},
{
title: '岗位',
dataIndex: 'positions',
ellipsis: true,
align: 'center',
width: 100
},
{
title: '邮箱',
dataIndex: 'email',
align: 'center',
// width: 160
},
{
title: '联系电话',
dataIndex: 'mobile',
align: 'center',
width: 120
},
{
title: '性别',
dataIndex: 'gender',
align: 'center',
width: 80
},
{
title: '状态',
dataIndex: 'available',
align: 'center',
width: 80
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
align: 'center',
// width: 200
},
{
title: '操作',
dataIndex: 'operation',
fixed: 'right',
align: 'center',
width: 150
}
]);
const rowSelection = computed(() => {
return {
selectedRowKeys: unref(selectedRowKeys),
onChange: (selectingRowKeys: tableDataType['id'][]) => {
selectedRowKeys.value = selectingRowKeys;
},
hideDefaultSelections: true,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE
]
}
});
//
onMounted(() => loadingData());
//
const onFinish = () => {
pagination.current = 1;
loadingData();
};
//
const loadingData = () => {
tableLoading.value = true;
let params = {};
if (queryState.username) {
params['username'] = queryState.username
}
if (queryState.name) {
params['name'] = queryState.name
}
if (queryState.available) {
params['available'] = queryState.available == "true" ? true : false;
}
params['page_no'] = pagination.current
params['page_size'] = pagination.pageSize
getDeptList().then(response => {
const result = response.data;
deptTreeData.value = listToTree(result.data.items);
})
getUserList(params).then(response => {
const result = response.data;
dataSource.value = result.data.items.map((item: tableDataType) => {
item.roleNames = item.roles ? item.roles.map(item => item.name).join("") : undefined;
item.positionNames = item.positions ? item.positions.map(item => item.name).join("") : undefined;
return item;
});
pagination.total = result.data.total;
pagination.current = result.data.page_no;
pagination.pageSize = result.data.page_size;
}).catch(error => {
console.log(error);
}).finally(() => {
tableLoading.value = false;
});
}
//
const resetFields = () => {
Object.keys(queryState).forEach((key: string) => {
delete queryState[key];
});
pagination.current = 1;
queryState.available = null
loadingData();
}
//
const deleteRow = (row: tableDataType) => {
deleteUser({ id: row.id }).then(response => {
const result = response.data;
message.success(result.msg);
loadingData();
}).catch(error => {
console.log(error);
})
}
// /
const handleMoreClick: MenuProps['onClick'] = e => {
if (!selectedRowKeys.value || !(selectedRowKeys.value.length > 0)) {
message.warning('请先勾选数据');
return;
}
Modal.confirm({
title: '提示',
content: e.key == 1 ? '是否确定启用选择项?' : '是否确定停用选择项?',
onOk() {
const body = { ids: selectedRowKeys.value, available: e.key == 1 ? true : false };
batchAvailableUser(body).then(response => {
const result = response.data;
message.success(result.msg);
selectedRowKeys.value = [];
loadingData();
}).catch(error => {
console.log(error);
})
}
});
}
//
const handleTableChange = (values: any) => {
pagination.current = values.current;
pagination.pageSize = values.pageSize;
loadingData();
}
//
const modalHandle = (modalType: string, index?: number) => {
modalTitle.value = modalType;
openModal.value = true;
if (modalType === 'view' && index !== undefined) {
detailStateLoading.value = true;
detailState.value = dataSource.value[index];
detailState.value.index = index;
detailStateLoading.value = false;
} else if (modalType === 'update' && index !== undefined) {
const selected = dataSource.value[index];
Object.keys(updateState).forEach(key => {
if (selected[key] !== undefined) {
updateState[key] = selected[key];
}
})
if (selected['roles']) {
updateState.role_ids = selected.roles.map(role => role.id);
}
if (selected['positions']) {
updateState.position_ids = selected.positions.map(position => position.id);
}
}else if (modalType === 'import' && index !== undefined) {
}
}
// //
const handleModalSumbit = () => {
modalSubmitLoading.value = true;
if (modalTitle.value === 'view') {
modalSubmitLoading.value = false;
openModal.value = false;
} else if (modalTitle.value === 'create') {
createForm.value.validate().then(() => {
const createBody = {
username: createState.username,
name: createState.name,
password: createState.password,
dept_id: createState.dept_id,
role_ids: createState.role_ids,
position_ids: createState.position_ids,
gender: createState.gender,
email: createState.email,
mobile: createState.mobile,
available: createState.available,
description: createState.description,
is_superuser: createState.is_superuser
}
Object.keys(createBody).forEach(key => {
if (isEmpty(createBody[key])) {
delete createBody[key];
}
})
createUser(createBody).then(response => {
const result = response.data;
modalSubmitLoading.value = false;
openModal.value = false;
Object.keys(createState).forEach(key => delete createState[key]);
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error);
})
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error);
})
} else if (modalTitle.value === 'update') {
updateForm.value.validate().then(() => {
const updateBody = {
id: updateState.id,
username: updateState.username,
name: updateState.name,
dept_id: updateState.dept_id,
role_ids: updateState.role_ids,
position_ids: updateState.position_ids,
gender: updateState.gender,
email: updateState.email,
mobile: updateState.mobile,
available: updateState.available,
description: updateState.description,
is_superuser: updateState.is_superuser
}
if (showPasswordInput.value && updateState.password) {
updateBody['password'] = updateState.password;
}
updateUser(updateBody).then(response => {
const result = response.data;
modalSubmitLoading.value = false;
openModal.value = false;
message.success(result.msg);
loadingData();
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error)
})
}).catch(error => {
modalSubmitLoading.value = false;
console.error(error)
})
} else if (modalTitle.value === 'import') {
}
}
//
const selectModalHandle = (subject: string) => {
selectorModal.value.subject = subject;
selectorModal.value.openModal = true;
const ids_key = subject === 'role' ? 'role_ids' : 'position_ids';
const names_key = subject === 'role' ? 'roleNames' : 'positionNames';
const selectedKeys = modalTitle.value === 'create' ? createState[ids_key] : updateState[ids_key];
const selectedRowItemNames = modalTitle.value === 'create' ? (createState[names_key] ?? '') : (updateState[names_key] ?? '');
selectorModal.value.selectedRowKeys = selectedKeys;
selectorModal.value.selectedRowItemNames = selectedRowItemNames.split(', ');
selectorModal.value.loadingData();
}
//
const handleSelectorModalEvent = (
subject: string,
selectedSelectorRowKeys: roleSelectorType['id'][] | positionSelectorType['id'][],
selectedSelectorRowNames: roleSelectorType['name'][] | positionSelectorType['name'][]
) => {
const ids_key = subject === 'role' ? 'role_ids' : 'position_ids';
const names_key = subject === 'role' ? 'roleNames' : 'positionNames';
if (modalTitle.value === 'create') {
createState[ids_key] = selectedSelectorRowKeys;
createState[names_key] = selectedSelectorRowNames.join(', ');
} else {
updateState[ids_key] = selectedSelectorRowKeys;
updateState[names_key] = selectedSelectorRowNames.join(', ');
}
}
//
const token = storage.get('Access-Token');
//
const upload = reactive({
title: '用户导入',
open: false,
headers: token ? { Authorization: 'Bearer ' + token } : {},
url: importUserData, //
// url: import.meta.env.VITE_APP_BASE_API + "/system/user/import/data",
isUploading: false,
updateSupport: false,
});
//
const handleExport = () => {
//
const params = {
...queryState,
page_no: 1,
page_size: pagination.total //
};
// exportLog
exportUser(params).then(response => {
const blob = new Blob([response.data], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `user_${new Date().getTime()}.xlsx`; //
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('导出成功');
}).catch(error => {
console.error('导出失败:', error);
message.error('导出失败');
});
};
//
const downloadTemplate = () => {
window.location.href = importUserTemplate; //
};
//
const beforeUpload = (file: File) => {
const isExcel = file.type === 'application/vnd.ms-excel' || file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (!isExcel) {
message.error('只能上传 Excel 文件!');
}
return isExcel;
};
const customRequest = ({ file, onSuccess, onError }: any) => {
upload.isUploading = true;
const formData = new FormData();
formData.append('file', file);
formData.append('updateSupport', upload.updateSupport.toString());
importUserData(formData)
.then((response) => {
upload.isUploading = false;
upload.open = false;
message.success('用户导入成功');
onSuccess(response);
loadingData(); //
})
.catch((error) => {
upload.isUploading = false;
onError(error);
message.error('用户导入失败');
});
};
/** 提交上传文件 */
const submitFileForm = () => {
uploadRef.value.upload();
};
</script>
<style lang="scss" scoped>
.table-search-wrapper {
margin-block-end: 16px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
export interface searchDataType {
username?: string
name?: string
available?: string
}
export interface searchSelectDataType {
name?: string
available?: string
}
export interface tableDataType {
id?: number;
index?: number;
username?: string;
name?: string;
email?: string;
mobile?: string;
gender?: number;
password?: string;
dept_id?: deptTreeType['id'];
dept_name?: deptTreeType['name'];
roles?: roleSelectorType[];
roleNames?: string;
role_ids?: roleSelectorType['id'][];
positions?: positionSelectorType[];
positionNames?: string;
position_ids?: positionSelectorType['id'][];
is_superuser?: boolean;
available?: boolean;
description?: string;
last_login?: string;
created_at?: string;
updated_at?: string;
creator?: creatorType;
}
export interface deptTreeType {
id?: number;
name?: string;
parent_id?: number;
children?: deptTreeType[];
}
export interface roleSelectorType {
id?: number;
name?: string;
available?: boolean;
description?: string;
}
export interface positionSelectorType {
id?: number;
name?: string;
available?: boolean;
description?: string;
}
interface creatorType {
id?: number;
name?: string;
username?: string;
}