mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 05:02:57 +00:00
开源准备
This commit is contained in:
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 获取服务信息
|
||||
export function getServer() {
|
||||
return request({
|
||||
url: '/monitor/server/info',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface tableDataType {
|
||||
name?: string;
|
||||
size?: number;
|
||||
type?: string;
|
||||
modified_time?: string;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
仅允许导入 xls、xlsx 格式的文件。
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user