feat: 初始化智能物联网设备管理系统前后端基础项目

完成项目整体结构搭建,包含:
1. 前端Vue3+TS+Vite+Antd项目基础配置与核心依赖
2. 后端MidwayJS+TypeORM+JWT权限认证框架
3. 完整的用户、角色、权限、日志数据库表结构
4. 核心业务模块API与基础服务实现
5. 全局样式、路由、状态管理配置
This commit is contained in:
andyesfly committed 2026-05-28 16:23:15 +08:00
commit 3d7eb12103
89 files changed
+16085

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
#app {
width: 100%;
height: 100vh;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import request from '@/utils/request';
export const authApi = {
login: (data: { username: string; password: string }) =>
request.post('/auth/login', data),
logout: () =>
request.post('/auth/logout'),
refreshToken: (refreshToken: string) =>
request.post('/auth/refresh', { refreshToken }),
getCurrentUser: () =>
request.get('/auth/currentUser'),
};
+31
View File
@@ -0,0 +1,31 @@
import request from '@/utils/request';
export interface DashboardStats {
deviceTotal: number;
deviceOnline: number;
alertToday: number;
activeUsers: number;
deviceTrend: Array<{ date: string; online: number; offline: number }>;
deviceTypeDistribution: Array<{ name: string; value: number }>;
recentAlerts: Array<{
id: number;
type: string;
device: string;
level: string;
time: string;
status: string;
}>;
recentActivities: Array<{
id: number;
user: string;
action: string;
module: string;
ip: string;
time: string;
}>;
}
export const dashboardApi = {
getStats: () =>
request.get<DashboardStats>('/dashboard/stats'),
};
+36
View File
@@ -0,0 +1,36 @@
import request from '@/utils/request'
export interface LogItem {
id: number
userId?: number
username?: string
action: string
module?: string
method: string
url: string
ipAddress: string
statusCode: number
duration: number
createdAt: string
}
export interface LogQuery {
page?: number
pageSize?: number
operator?: string
actionType?: string
moduleName?: string
startDate?: string
endDate?: string
}
export const logApi = {
getLogList: (params: LogQuery) =>
request.get('/logs', { params }),
exportLogs: (params: Omit<LogQuery, 'page' | 'pageSize'>) =>
request.get('/logs/export', {
params,
responseType: 'blob',
}),
}
+62
View File
@@ -0,0 +1,62 @@
import request from '@/utils/request';
export interface RoleInfo {
id: number;
name: string;
code: string;
description?: string;
status: number;
sortOrder: number;
userCount: number;
permissions?: any[];
created_at: string;
updated_at: string;
}
export interface PermissionTreeNode {
id: number;
name: string;
code: string;
type: string;
parent_id?: number | null;
sortOrder: number;
icon?: string;
route?: string;
component?: string;
status: number;
children?: PermissionTreeNode[];
checked?: boolean;
}
export interface RoleListResult {
list: RoleInfo[];
total: number;
page: number;
pageSize: number;
}
export const roleApi = {
getRoleList: (params?: { page?: number; pageSize?: number; keyword?: string }) =>
request.get<RoleListResult>('/roles', { params }),
getAllRoles: () =>
request.get<RoleInfo[]>('/roles/all'),
getRoleDetail: (id: number) =>
request.get<RoleInfo>(`/roles/${id}`),
createRole: (data: { name: string; code: string; description?: string; status?: number; sortOrder?: number }) =>
request.post('/roles', data),
updateRole: (id: number, data: { name?: string; description?: string; status?: number; sortOrder?: number }) =>
request.put(`/roles/${id}`, data),
deleteRole: (id: number) =>
request.delete(`/roles/${id}`),
getRolePermissions: (id: number) =>
request.get<PermissionTreeNode[]>(`/roles/${id}/permissions`),
updateRolePermissions: (id: number, permissionIds: number[]) =>
request.put(`/roles/${id}/permissions`, { permissionIds }),
};
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request';
export interface BasicSettings {
siteName: string;
logo: string;
sessionTimeout: number;
}
export interface SecuritySettings {
passwordMinLength: number;
maxLoginAttempts: number;
lockoutDuration: number;
ipWhitelist: string[];
}
export interface NotificationSettings {
enableEmail: boolean;
emailSmtpConfig: any;
}
export interface SystemSettings {
basic: BasicSettings;
security: SecuritySettings;
notification: NotificationSettings;
}
export const systemApi = {
getSettings: () =>
request.get<SystemSettings>('/settings'),
updateBasicSettings: (data: Partial<BasicSettings>) =>
request.put('/settings/basic', data),
updateSecuritySettings: (data: Partial<SecuritySettings>) =>
request.put('/settings/security', data),
updateNotificationSettings: (data: Partial<NotificationSettings>) =>
request.put('/settings/notification', data),
};
+76
View File
@@ -0,0 +1,76 @@
import request from '@/utils/request';
export interface UserInfo {
id: number;
username: string;
nickname: string;
email?: string;
phone?: string;
avatar?: string;
status: number;
userRoles?: UserRoleInfo[];
created_at: string;
updated_at: string;
}
export interface UserRoleInfo {
id: number;
userId: number;
roleId: number;
role?: RoleInfo;
}
export interface RoleInfo {
id: number;
name: string;
code: string;
description?: string;
status: number;
sortOrder: number;
}
export interface UserQuery {
page?: number;
pageSize?: number;
keyword?: string;
status?: number;
roleId?: number;
}
export interface CreateUserParams {
username: string;
password: string;
nickname: string;
email?: string;
phone?: string;
roleIds: number[];
status?: number;
}
export interface UpdateUserParams {
nickname?: string;
email?: string;
phone?: string;
avatar?: string;
roleIds?: number[];
status?: number;
password?: string;
}
export const userApi = {
getUserList: (params: UserQuery) => request.get('/users', { params }),
getRoleList: () => request.get<RoleInfo[]>('/users/roles'),
getUserDetail: (id: number) => request.get<UserInfo>(`/users/${id}`),
createUser: (data: CreateUserParams) => request.post('/users', data),
updateUser: (id: number, data: UpdateUserParams) => request.put(`/users/${id}`, data),
deleteUser: (id: number) => request.delete(`/users/${id}`),
batchDeleteUsers: (ids: number[]) => request.post('/users/batch-delete', { ids }),
updateStatus: (id: number, status: number) => request.put(`/users/${id}/status`, { status }),
};
+292
View File
@@ -0,0 +1,292 @@
// 引入变量
@import "./variables.less";
// ==================== 全局重置 ====================
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 14px;
line-height: 1.5715;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: @font-family;
color: @text-primary;
background-color: @bg-base;
line-height: @line-height-base;
}
// 链接
a {
color: @primary-color;
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: @primary-color-hover;
}
}
// 图片
img {
max-width: 100%;
vertical-align: middle;
}
// 列表
ul,
ol {
list-style: none;
}
// ==================== 工具类 ====================
// 文本工具类
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-primary {
color: @text-primary !important;
}
.text-secondary {
color: @text-secondary !important;
}
.text-success {
color: @success-color !important;
}
.text-warning {
color: @warning-color !important;
}
.text-error {
color: @error-color !important;
}
.font-bold {
font-weight: @font-weight-bold;
}
.font-medium {
font-weight: @font-weight-medium;
}
// 间距工具类
.mt-xs {
margin-top: @spacing-xs;
}
.mt-sm {
margin-top: @spacing-sm;
}
.mt-md {
margin-top: @spacing-md;
}
.mt-lg {
margin-top: @spacing-lg;
}
.mt-xl {
margin-top: @spacing-xl;
}
.mb-xs {
margin-bottom: @spacing-xs;
}
.mb-sm {
margin-bottom: @spacing-sm;
}
.mb-md {
margin-bottom: @spacing-md;
}
.mb-lg {
margin-bottom: @spacing-lg;
}
.mb-xl {
margin-bottom: @spacing-xl;
}
.p-xs {
padding: @spacing-xs;
}
.p-sm {
padding: @spacing-sm;
}
.p-md {
padding: @spacing-md;
}
.p-lg {
padding: @spacing-lg;
}
.p-xl {
padding: @spacing-xl;
}
// Flex工具类
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
// 显示/隐藏
.hidden {
display: none !important;
}
.visible {
visibility: visible;
}
.invisible {
visibility: hidden;
}
// 溢出控制
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 光标
.cursor-pointer {
cursor: pointer;
}
.cursor-not-allowed {
cursor: not-allowed;
}
// ==================== 页面容器 ====================
.page-container {
padding: @content-padding;
min-height: calc(100vh - @header-height - @content-padding * 2);
}
.page-header {
margin-bottom: @spacing-lg;
h2 {
font-size: @font-size-xxl;
font-weight: @font-weight-semibold;
margin-bottom: @spacing-xs;
}
p {
color: @text-secondary;
}
}
// ==================== 卡片统一样式 ====================
.custom-card {
background: @bg-white;
border-radius: @border-radius-lg;
box-shadow: @shadow-base;
transition: box-shadow @transition-base;
&:hover {
box-shadow: @shadow-lg;
}
}
// ==================== 表格增强样式 ====================
.enhanced-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: @font-weight-semibold;
color: @text-primary;
}
.ant-table-tbody > tr:hover > td {
background: @bg-hover;
}
}
// ==================== 表单统一样式 ====================
.search-form {
background: @bg-white;
padding: @spacing-lg;
border-radius: @border-radius-lg;
margin-bottom: @spacing-md;
}
// ==================== 动画 ====================
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-up {
animation: slideInUp 0.3s ease-out;
}
// ==================== 滚动条美化 ====================
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
&:hover {
background: #a8a8a8;
}
}
// Firefox
* {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
+87
View File
@@ -0,0 +1,87 @@
// ==================== 颜色系统 ====================
// 主色调(深蓝色系)
@primary-color: #1890ff;
@primary-color-hover: #40a9ff;
@primary-color-active: #096dd9;
@primary-color-outline: fade(#1890ff, 20%);
// 功能色
@success-color: #52c41a;
@warning-color: #faad14;
@error-color: #ff4d4f;
@info-color: #1890ff;
// 中性色
@text-primary: #1a1a1a; // 主要文本
@text-secondary: #595959; // 次要文本
@text-disabled: #bfbfbf; // 禁用文本
@text-placeholder: #bfbfbf; // 占位符
@border-color: #d9d9d9; // 边框颜色
@divider-color: #f0f0f0; // 分割线颜色
// 背景色
@bg-base: #f0f2f5; // 页面基础背景
@bg-white: #ffffff; // 白色背景
@bg-hover: #f5f5f5; // 悬停背景
@bg-selected: #e6f7ff; // 选中背景
// ==================== 字体系统 ====================
@font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
@font-code: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
@font-size-xs: 12px;
@font-size-sm: 13px;
@font-size-base: 14px;
@font-size-lg: 16px;
@font-size-xl: 20px;
@font-size-xxl: 24px;
@font-size-title: 28px;
@font-weight-normal: 400;
@font-weight-medium: 500;
@font-weight-semibold: 600;
@font-weight-bold: 700;
@line-height-tight: 1.25;
@line-height-base: 1.5715;
@line-height-relaxed: 1.75;
// ==================== 间距系统 ====================
@spacing-xs: 4px;
@spacing-sm: 8px;
@spacing-md: 16px;
@spacing-lg: 24px;
@spacing-xl: 32px;
@spacing-xxl: 48px;
// ==================== 圆角系统 ====================
@border-radius-sm: 4px;
@border-radius-base: 6px;
@border-radius-lg: 8px;
@border-radius-xl: 12px;
@border-radius-round: 9999px;
// ==================== 阴影系统 ====================
@shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
@shadow-base: 0 2px 8px rgba(0, 0, 0, 0.1);
@shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
@shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.15);
// ==================== 过渡动画 ====================
@transition-fast: 0.15s ease;
@transition-base: 0.25s ease;
@transition-slow: 0.35s ease;
// ==================== 布局尺寸 ====================
@sider-width: 240px;
@sider-collapsed-width: 80px;
@header-height: 64px;
@content-padding: 24px;
// ==================== 断点系统 ====================
@screen-xs: 480px;
@screen-sm: 576px;
@screen-md: 768px;
@screen-lg: 992px;
@screen-xl: 1200px;
@screen-xxl: 1600px;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+3
View File
@@ -0,0 +1,3 @@
<template>
<div>empty</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
import { ref, watchEffect, onMounted, computed } from 'vue'
type Theme = 'light' | 'dark'
export function useTheme() {
const theme = ref<Theme>('light')
const getPreferredTheme = (): Theme => {
const saved = localStorage.getItem('theme') as Theme | null
if (saved === 'light' || saved === 'dark') return saved
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
const applyTheme = (t: Theme) => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(t)
localStorage.setItem('theme', t)
}
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
onMounted(() => {
theme.value = getPreferredTheme()
applyTheme(theme.value)
})
watchEffect(() => {
applyTheme(theme.value)
})
return {
theme,
toggleTheme,
isDark: computed(() => theme.value === 'dark'),
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { App, Directive, DirectiveBinding } from 'vue';
const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
const { value } = binding;
if (!value) {
console.warn('[v-permission] 需要提供权限码');
return;
}
const stores = import('@/stores/permission');
stores.then(({ usePermissionStore }) => {
const store = usePermissionStore();
if (store.userInfo?.roles.includes('super_admin')) {
return;
}
if (!store.permissions.includes(value)) {
el.parentNode?.removeChild(el);
}
});
},
};
export default function setupPermissionDirective(app: App) {
app.directive('permission', permissionDirective);
}
+447
View File
@@ -0,0 +1,447 @@
<template>
<a-layout class="admin-layout">
<!-- 侧边栏 -->
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="240" :collapsed-width="80"
theme="dark" class="sider">
<!-- Logo区域 -->
<div class="logo" @click="$router.push('/dashboard')">
<img src="@/assets/vue.svg" alt="logo" class="logo-img" />
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
</div>
<!-- 菜单 -->
<a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" theme="dark"
:items="menuItems" @click="handleMenuClick" />
<!-- 折叠按钮 -->
<div class="collapse-btn" @click="toggleCollapse">
<component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" />
</div>
</a-layout-sider>
<a-layout>
<!-- 顶栏 -->
<a-layout-header class="header">
<div class="header-left">
<!-- 折叠触发器 -->
<div class="trigger" @click="toggleCollapse">
<component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" />
</div>
<!-- 面包屑导航 -->
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>
<HomeOutlined />
<span>首页</span>
</a-breadcrumb-item>
<template v-for="(item, index) in breadcrumbList" :key="index">
<a-breadcrumb-item v-if="item.path">
<router-link v-if="index < breadcrumbList.length - 1" :to="item.path">
{{ item.title }}
</router-link>
<span v-else>{{ item.title }}</span>
</a-breadcrumb-item>
</template>
</a-breadcrumb>
</div>
<div class="header-right">
<!-- 用户信息下拉菜单 -->
<a-dropdown :trigger="['click']">
<a-space class="user-info">
<a-avatar :size="32" :style="{ backgroundColor: '#1890ff' }">
{{ userInitial }}
</a-avatar>
<span class="username">{{ displayName }}</span>
<DownOutlined />
</a-space>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile" disabled>
<UserOutlined />
<span>个人中心</span>
</a-menu-item>
<a-menu-item key="password" disabled>
<LockOutlined />
<span>修改密码</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
<span style="color: #ff4d4f">退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<!-- 主内容区域 -->
<a-layout-content class="content">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="fade-slide" mode="out-in">
<div :key="currentRoute.path" class="view-wrapper">
<component :is="Component" />
</div>
</transition>
</router-view>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { ref, computed, watch, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
DashboardOutlined,
SettingOutlined,
UserOutlined,
SafetyCertificateOutlined,
ToolOutlined,
InfoCircleOutlined,
LockOutlined,
FileTextOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
HomeOutlined,
DownOutlined,
LogoutOutlined,
} from '@ant-design/icons-vue'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const route = useRoute()
const permissionStore = usePermissionStore()
// 状态管理
const collapsed = ref(false)
const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([])
// 菜单配置
const menuItems = computed(() => [
{
key: '/dashboard',
icon: () => h(DashboardOutlined),
label: '仪表盘',
},
{
key: '/system',
icon: () => h(SettingOutlined),
label: '系统管理',
children: [
{
key: '/system/user',
icon: () => h(UserOutlined),
label: '用户管理',
},
{
key: '/system/role',
icon: () => h(SafetyCertificateOutlined),
label: '角色权限',
},
],
},
{
key: '/settings',
icon: () => h(ToolOutlined),
label: '系统设置',
children: [
{
key: '/settings/basic',
icon: () => h(InfoCircleOutlined),
label: '基本设置',
},
{
key: '/settings/security',
icon: () => h(LockOutlined),
label: '安全设置',
},
],
},
{
key: '/log/operation',
icon: () => h(FileTextOutlined),
label: '操作日志',
},
])
// 计算属性
const userInfo = computed(() => permissionStore.userInfo)
const displayName = computed(() => userInfo.value?.nickname || userInfo.value?.username || '管理员')
const userInitial = computed(() => (displayName.value?.charAt(0) || 'A').toUpperCase())
// 面包屑列表
interface BreadcrumbItem {
title: string
path?: string
}
const breadcrumbList = computed<BreadcrumbItem[]>(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
return matched.map(item => ({
title: item.meta?.title as string,
path: item.path,
}))
})
// 监听路由变化,更新菜单选中状态
watch(
() => route.path,
(path) => {
selectedKeys.value = [path]
// 自动展开父级菜单
const pathParts = path.split('/').filter(Boolean)
if (pathParts.length > 1) {
openKeys.value = [`/${pathParts[0]}`]
}
},
{ immediate: true }
)
// 监听侧边栏折叠状态,折叠时关闭所有子菜单
watch(collapsed, (val) => {
if (val) {
openKeys.value = []
} else {
// 展开时根据当前路由自动打开对应的父菜单
const pathParts = route.path.split('/').filter(Boolean)
if (pathParts.length > 1) {
openKeys.value = [`/${pathParts[0]}`]
}
}
})
// 方法
function toggleCollapse() {
collapsed.value = !collapsed.value
}
function handleMenuClick({ key }: { key: string }) {
router.push(key)
}
function handleUserMenuClick({ key }: { key: string }) {
if (key === 'logout') {
permissionStore.logout()
}
}
</script>
<style scoped lang="less">
.admin-layout {
min-height: 100vh;
.sider {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
overflow: hidden;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
.logo-img {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.logo-title {
color: #fff;
font-size: 18px;
font-weight: 600;
margin: 0 0 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1px;
}
}
:deep(.ant-menu) {
border-right: none;
}
.collapse-btn {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.65);
font-size: 18px;
transition: all 0.3s;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
}
}
>.ant-layout {
margin-left: 240px;
transition: margin-left 0.2s;
&.ant-layout-has-sider+& {
margin-left: 80px;
}
}
// 当侧边栏折叠时的样式调整
.ant-layout-sider-collapsed~.ant-layout {
margin-left: 80px !important;
}
.header {
position: sticky;
top: 0;
z-index: 99;
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
height: 64px;
line-height: 64px;
.header-left {
display: flex;
align-items: center;
gap: 16px;
.trigger {
font-size: 18px;
padding: 0 12px;
cursor: pointer;
color: #333;
transition: all 0.3s;
border-radius: 4px;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
}
.breadcrumb {
font-size: 14px;
:deep(.anticon) {
margin-right: 4px;
font-size: 12px;
}
}
}
.header-right {
.user-info {
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.04);
color: #1890ff;
}
.username {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
}
}
}
.content {
margin: 16px;
min-height: calc(100vh - 64px - 32px);
.view-wrapper {
background: #fff;
padding: 24px;
border-radius: 8px;
min-height: calc(100vh - 64px - 32px - 48px); // 减去header、margin和padding
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
}
}
// 页面切换过渡动画
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.25s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(10px);
}
// 响应式设计
@media screen and (max-width: 768px) {
.admin-layout {
.ant-layout-sider {
position: fixed !important;
z-index: 101;
}
>.ant-layout {
margin-left: 80px !important;
}
.header {
padding: 0 12px;
.breadcrumb {
display: none; // 小屏幕隐藏面包屑
}
.username {
display: none; // 小屏幕隐藏用户名
}
}
.content {
margin: 12px;
.view-wrapper {
padding: 16px;
border-radius: 4px;
}
}
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from "vue";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import "@/assets/styles/global.less";
import App from "./App.vue";
import router from "./router";
import pinia from "./stores";
import setupPermissionDirective from "@/directives/permission";
const app = createApp(App);
app.use(Antd);
app.use(router);
app.use(pinia);
setupPermissionDirective(app);
app.mount("#app");
+6
View File
@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<div></div>
</template>
+132
View File
@@ -0,0 +1,132 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { usePermissionStore } from "@/stores/permission";
import { getToken } from "@/utils/auth";
const routes: RouteRecordRaw[] = [
{
path: "/login",
name: "Login",
component: () => import("@/views/login/index.vue"),
meta: { requiresAuth: false, title: "登录" }
},
{
path: "/",
component: () => import("@/layouts/index.vue"),
redirect: "/dashboard",
children: [
{
path: "dashboard",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: {
requiresAuth: true,
title: "仪表盘",
permission: "dashboard"
}
},
{
path: "system/user",
name: "UserList",
component: () => import("@/views/system/user/index.vue"),
meta: {
requiresAuth: true,
title: "用户管理",
permission: "user:list"
}
},
{
path: "system/role",
name: "RoleList",
component: () => import("@/views/system/role/index.vue"),
meta: {
requiresAuth: true,
title: "角色管理",
permission: "role:list"
}
},
{
path: "settings/basic",
name: "BasicSettings",
component: () => import("@/views/settings/basic.vue"),
meta: {
requiresAuth: true,
title: "基本设置",
permission: "system:basicSetting"
}
},
{
path: "settings/security",
name: "SecuritySettings",
component: () => import("@/views/settings/security.vue"),
meta: {
requiresAuth: true,
title: "安全设置",
permission: "system:securitySetting"
}
},
{
path: "log/operation",
name: "OperationLog",
component: () => import("@/views/log/operation.vue"),
meta: {
requiresAuth: true,
title: "操作日志",
permission: "log:operation"
}
}
]
},
{
path: "/:pathMatch(.*)*",
redirect: "/login"
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach(async (to, _from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 智能物联网设备管理系统` : "智能物联网设备管理系统";
const permissionStore = usePermissionStore();
const token = getToken();
if (to.meta.requiresAuth === false) {
if (to.path === "/login" && token) {
next({ path: "/" });
} else {
next();
}
return;
}
if (!token) {
next({
path: "/login",
query: { redirect: to.fullPath }
});
return;
}
if (!permissionStore.userInfo) {
try {
await permissionStore.fetchUserInfo();
} catch (error) {
permissionStore.logout();
return;
}
}
if (to.meta.permission) {
if (!permissionStore.hasPermission(to.meta.permission as string)) {
next({ path: "/403" });
return;
}
}
next();
});
export default router;
+5
View File
@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
+84
View File
@@ -0,0 +1,84 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authApi } from '@/api/auth';
import router from '@/router';
interface UserInfo {
id: number;
username: string;
nickname: string;
avatar?: string;
roles: string[];
permissions: string[];
}
export const usePermissionStore = defineStore('permission', () => {
const userInfo = ref<UserInfo | null>(null);
const token = ref<string>('');
const permissions = ref<string[]>([]);
const isLoggedIn = computed(() => !!token.value);
const hasRole = computed(() => (role: string) =>
userInfo.value?.roles.includes(role) || false
);
const hasPermission = computed(() => (permission: string) =>
permissions.value.includes(permission) ||
userInfo.value?.roles.includes('super_admin') || false
);
async function login(username: string, password: string) {
try {
const res = await authApi.login({ username, password });
if (res.code === 200) {
const { accessToken, refreshToken, userInfo: info } = res.data;
token.value = accessToken;
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
userInfo.value = info;
permissions.value = info.permissions || [];
return true;
}
throw new Error(res.message || '登录失败');
} catch (error) {
throw error;
}
}
function logout() {
token.value = '';
userInfo.value = null;
permissions.value = [];
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
router.push('/login');
}
async function fetchUserInfo() {
try {
const res = await authApi.getCurrentUser();
if (res.code === 200) {
userInfo.value = res.data;
permissions.value = res.data.permissions || [];
}
} catch (error) {
console.error('获取用户信息失败:', error);
}
}
return {
userInfo,
token,
permissions,
isLoggedIn,
hasRole,
hasPermission,
login,
logout,
fetchUserInfo,
};
});
+17
View File
@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+23
View File
@@ -0,0 +1,23 @@
const TOKEN_KEY = "access_token";
const REFRESH_TOKEN_KEY = "refresh_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
+105
View File
@@ -0,0 +1,105 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig, type AxiosResponse } from "axios";
import { message } from "ant-design-vue";
import { getToken, removeToken } from "./auth";
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
timeout: 15000,
headers: {
"Content-Type": "application/json"
}
});
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (config.method === "get") {
config.params = {
...config.params,
_t: Date.now()
};
}
return config;
},
(error) => {
console.error("请求错误:", error);
return Promise.reject(error);
}
);
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data;
if (res.code === 200 || res.code === 0) {
return res;
} else if (res.code === 401) {
message.error("登录已过期,请重新登录");
removeToken();
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
return Promise.reject(new Error(res.message || "未授权"));
} else if (res.code === 403) {
message.error("没有访问权限");
return Promise.reject(new Error(res.message || "禁止访问"));
} else {
message.error(res.message || "请求失败");
return Promise.reject(new Error(res.message || "请求失败"));
}
},
(error) => {
if (error.response) {
switch (error.response.status) {
case 400:
message.error("请求参数错误");
break;
case 401:
message.error("未授权,请重新登录");
removeToken();
window.location.href = "/login";
break;
case 403:
message.error("拒绝访问");
break;
case 404:
message.error("请求的资源不存在");
break;
case 500:
message.error("服务器内部错误");
break;
default:
message.error(error.response.data?.message || `请求失败(${error.response.status})`);
}
} else if (error.message.includes("timeout")) {
message.error("请求超时,请稍后重试");
} else if (error.message.includes("Network")) {
message.error("网络错误,请检查网络连接");
} else {
message.error("未知错误,请稍后重试");
}
return Promise.reject(error);
}
);
export default service;
export function get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config);
}
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config);
}
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config);
}
export function del<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config);
}
+606
View File
@@ -0,0 +1,606 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<h2 class="dashboard-title">系统运营概况</h2>
<div class="header-actions">
<span class="update-time">最后更新: {{ lastUpdateTime }}</span>
<a-button type="primary" :loading="loading" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</div>
</div>
<a-spin :spinning="loading" tip="加载中...">
<div class="dashboard-content">
<a-row :gutter="[16, 16]" class="stat-cards">
<a-col :xs="24" :sm="12" :md="6" v-for="card in statCards" :key="card.key">
<a-card :hoverable="true" class="stat-card" :class="`stat-card-${card.key}`">
<div class="stat-card-content">
<div class="stat-icon-wrapper" :style="{ background: card.iconBg }">
<component :is="card.icon" class="stat-icon" />
</div>
<div class="stat-info">
<div class="stat-value" :style="{ color: card.color }">
{{ stats?.[card.key] ?? '--' }}
</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="chart-section">
<a-col :xs="24" :lg="14">
<a-card title="设备趋势" class="chart-card">
<div ref="lineChartRef" class="chart-container"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="10">
<a-card title="设备类型分布" class="chart-card">
<div ref="pieChartRef" class="chart-container"></div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="list-section">
<a-col :xs="24" :lg="12">
<a-card title="最近告警" class="list-card">
<template #extra>
<a-badge :count="stats?.alertToday || 0" :number-style="{ backgroundColor: '#ff4d4f' }" />
</template>
<div class="alert-list">
<div
v-for="alert in stats?.recentAlerts"
:key="alert.id"
class="alert-item"
>
<div class="alert-header">
<a-tag :color="getAlertLevelColor(alert.level)">
{{ getAlertLevelText(alert.level) }}
</a-tag>
<span class="alert-device">{{ alert.device }}</span>
<a-tag :color="alert.status === 'pending' ? 'orange' : 'green'" class="alert-status">
{{ alert.status === 'pending' ? '待处理' : '已解决' }}
</a-tag>
</div>
<div class="alert-body">
<span class="alert-type">{{ alert.type }}</span>
<span class="alert-time">{{ alert.time }}</span>
</div>
</div>
<a-empty v-if="!stats?.recentAlerts?.length" description="暂无告警信息" />
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="最近活动" class="list-card">
<a-timeline class="activity-timeline">
<a-timeline-item
v-for="activity in stats?.recentActivities"
:key="activity.id"
:color="getActivityColor(activity.module)"
>
<div class="activity-content">
<div class="activity-header">
<span class="activity-user">{{ activity.user }}</span>
<a-tag color="blue" class="activity-module">{{ activity.module }}</a-tag>
</div>
<div class="activity-action">{{ activity.action }}</div>
<div class="activity-meta">
<span class="activity-time">{{ activity.time }}</span>
<span class="activity-ip">IP: {{ activity.ip }}</span>
</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-if="!stats?.recentActivities?.length" description="暂无活动记录" />
</a-card>
</a-col>
</a-row>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
import {
Card,
Col,
Row,
Statistic,
Timeline,
TimelineItem,
Tag,
Button,
Badge,
Spin,
Empty,
message,
} from 'ant-design-vue';
import {
DeviceOutlined,
CheckCircleOutlined,
AlertOutlined,
TeamOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import * as echarts from 'echarts';
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
const ACard = Card;
const ACol = Col;
const ARow = Row;
const ATimeline = Timeline;
const ATimelineItem = TimelineItem;
const stats = ref<DashboardStats | null>(null);
const loading = ref(false);
const lineChartRef = ref<HTMLDivElement>();
const pieChartRef = ref<HTMLDivElement>();
let lineChartInstance: echarts.ECharts | null = null;
let pieChartInstance: echarts.ECharts | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const lastUpdateTime = ref('--');
const statCards = [
{
key: 'deviceTotal',
title: '设备总数',
icon: markRaw(DeviceOutlined),
color: '#1890ff',
iconBg: '#e6f7ff',
},
{
key: 'deviceOnline',
title: '在线设备',
icon: markRaw(CheckCircleOutlined),
color: '#52c41a',
iconBg: '#f6ffed',
},
{
key: 'alertToday',
title: '今日告警',
icon: markRaw(AlertOutlined),
color: '#ff4d4f',
iconBg: '#fff2f0',
},
{
key: 'activeUsers',
title: '活跃用户',
icon: markRaw(TeamOutlined),
color: '#faad14',
iconBg: '#fffbe6',
},
];
onMounted(async () => {
await loadStats();
await nextTick();
initCharts();
startAutoRefresh();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
stopAutoRefresh();
disposeCharts();
window.removeEventListener('resize', handleResize);
});
async function loadStats() {
loading.value = true;
try {
const res = await dashboardApi.getStats();
if (res.code === 200) {
stats.value = res.data;
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN');
updateCharts();
}
} catch (error) {
console.error('加载仪表盘数据失败:', error);
message.error('加载数据失败,请稍后重试');
} finally {
loading.value = false;
}
}
function handleRefresh() {
loadStats();
}
function initCharts() {
if (lineChartRef.value) {
lineChartInstance = echarts.init(lineChartRef.value);
}
if (pieChartRef.value) {
pieChartInstance = echarts.init(pieChartRef.value);
}
}
function updateCharts() {
if (!stats.value) return;
if (lineChartInstance && stats.value.deviceTrend) {
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['在线设备', '离线设备'],
top: 0,
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: stats.value.deviceTrend.map(d => d.date.slice(5)),
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisLabel: { color: '#666' },
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: { color: '#666' },
},
series: [
{
name: '在线设备',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: stats.value.deviceTrend.map(d => d.online),
itemStyle: { color: '#52c41a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' },
]),
},
},
{
name: '离线设备',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: stats.value.deviceTrend.map(d => d.offline),
itemStyle: { color: '#ff4d4f' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(255, 77, 79, 0.3)' },
{ offset: 1, color: 'rgba(255, 77, 79, 0.05)' },
]),
},
},
],
};
lineChartInstance.setOption(option, true);
}
if (pieChartInstance && stats.value.deviceTypeDistribution) {
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c}台 ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
top: 'center',
textStyle: { color: '#666' },
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
formatter: '{b}\n{d}%',
fontSize: 12,
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
},
labelLine: {
length: 15,
length2: 10,
},
data: stats.value.deviceTypeDistribution.map((item, index) => ({
...item,
itemStyle: {
color: ['#1890ff', '#52c41a', '#faad14', '#722ed1', '#13c2c2'][index],
},
})),
},
],
};
pieChartInstance.setOption(option, true);
}
}
function handleResize() {
lineChartInstance?.resize();
pieChartInstance?.resize();
}
function startAutoRefresh() {
refreshTimer = setInterval(loadStats, 30000);
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function disposeCharts() {
lineChartInstance?.dispose();
pieChartInstance?.dispose();
lineChartInstance = null;
pieChartInstance = null;
}
function getAlertLevelColor(level: string): string {
const colors: Record<string, string> = {
high: 'red',
medium: 'orange',
low: 'blue',
};
return colors[level] || 'default';
}
function getAlertLevelText(level: string): string {
const texts: Record<string, string> = {
high: '严重',
medium: '警告',
low: '提示',
};
return texts[level] || level;
}
function getActivityColor(module: string): string {
const colors: Record<string, string> = {
认证: 'blue',
设备管理: 'green',
用户管理: 'purple',
监控中心: 'red',
数据分析: 'orange',
系统: 'cyan',
系统设置: 'geekblue',
};
return colors[module] || 'gray';
}
</script>
<style scoped lang="less">
.dashboard {
padding: 24px;
min-height: calc(100vh - 120px);
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.dashboard-title {
font-size: 20px;
font-weight: 600;
color: #1f1f1f;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
.update-time {
color: #999;
font-size: 13px;
}
}
}
.dashboard-content {
.stat-cards {
margin-bottom: 24px;
.stat-card {
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
.stat-icon-wrapper {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.stat-icon {
font-size: 28px;
}
}
.stat-info {
flex: 1;
text-align: left;
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 4px;
}
.stat-title {
font-size: 14px;
color: #666;
}
}
}
}
}
.chart-section {
margin-bottom: 24px;
.chart-card {
border-radius: 8px;
.chart-container {
height: 350px;
width: 100%;
}
}
}
.list-section {
.list-card {
border-radius: 8px;
min-height: 400px;
.alert-list {
max-height: 360px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #d9d9d9;
border-radius: 3px;
}
.alert-item {
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.alert-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.alert-device {
flex: 1;
font-weight: 500;
color: #333;
}
.alert-status {
font-size: 12px;
}
}
.alert-body {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 4px;
.alert-type {
color: #666;
font-size: 13px;
}
.alert-time {
color: #999;
font-size: 12px;
}
}
}
}
.activity-timeline {
max-height: 360px;
overflow-y: auto;
padding-top: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #d9d9d9;
border-radius: 3px;
}
.activity-content {
.activity-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.activity-user {
font-weight: 500;
color: #333;
}
.activity-module {
font-size: 11px;
}
}
.activity-action {
color: #666;
font-size: 13px;
margin-bottom: 4px;
}
.activity-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
}
}
}
}
}
}
}
</style>
+309
View File
@@ -0,0 +1,309 @@
<template>
<div class="operation-log">
<a-card :bordered="false" class="search-card">
<a-form layout="inline" :model="queryParams">
<a-form-item label="操作人">
<a-input
v-model:value="queryParams.operator"
placeholder="请输入操作人"
allow-clear
style="width: 160px"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="操作类型">
<a-select
v-model:value="queryParams.actionType"
placeholder="请选择操作类型"
allow-clear
style="width: 160px"
>
<a-select-option value="登录">登录</a-select-option>
<a-select-option value="创建">创建</a-select-option>
<a-select-option value="更新">更新</a-select-option>
<a-select-option value="删除">删除</a-select-option>
<a-select-option value="登出">登出</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围">
<a-range-picker
v-model:value="timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
style="width: 340px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<a-button type="primary" ghost :loading="exportLoading" @click="handleExport">
<template #icon><DownloadOutlined /></template>
导出
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card :bordered="false" class="table-card">
<a-table
:columns="columns"
:data-source="logList"
:loading="tableLoading"
:pagination="paginationConfig"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'statusCode'">
<a-tag :color="getStatusCodeColor(record.statusCode)">
{{ record.statusCode }}
</a-tag>
</template>
<template v-else-if="column.key === 'duration'">
<span :class="{ 'duration-warning': record.duration > 1000 }">
{{ record.duration }}ms
</span>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ formatTime(record.createdAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-tooltip :title="record.action">
<span class="action-text">{{ record.action }}</span>
</a-tooltip>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { logApi, type LogItem } from '@/api/log'
const tableLoading = ref(false)
const exportLoading = ref(false)
const logList = ref<LogItem[]>([])
const total = ref(0)
const timeRange = ref<any[]>([])
const queryParams = reactive({
page: 1,
pageSize: 10,
operator: '',
actionType: undefined as string | undefined,
startDate: '',
endDate: '',
})
const columns = [
{
title: '序号',
key: 'index',
width: 70,
customRender: ({ index }: { index: number }) => (queryParams.page - 1) * queryParams.pageSize + index + 1,
},
{
title: '操作人',
dataIndex: 'username',
key: 'username',
width: 120,
ellipsis: true,
},
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
ellipsis: true,
},
{
title: '操作模块',
dataIndex: 'module',
key: 'module',
width: 110,
ellipsis: true,
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
width: 100,
},
{
title: 'IP地址',
dataIndex: 'ipAddress',
key: 'ipAddress',
width: 140,
ellipsis: true,
},
{
title: '状态码',
dataIndex: 'statusCode',
key: 'statusCode',
width: 90,
align: 'center' as const,
},
{
title: '耗时(ms)',
dataIndex: 'duration',
key: 'duration',
width: 100,
align: 'center' as const,
},
{
title: '操作时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
},
]
const paginationConfig = reactive({
current: queryParams.page,
pageSize: queryParams.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (t: number) => `共 ${t} 条记录`,
})
function getStatusCodeColor(code: number): string {
if (code >= 200 && code < 300) return 'success'
if (code >= 400 && code < 500) return 'warning'
if (code >= 500) return 'error'
return 'default'
}
function formatTime(time: string): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
async function fetchLogList() {
tableLoading.value = true
try {
if (timeRange.value && timeRange.value.length === 2) {
queryParams.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
queryParams.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
} else {
queryParams.startDate = ''
queryParams.endDate = ''
}
const res: any = await logApi.getLogList(queryParams)
if (res.code === 200) {
logList.value = res.data.list
total.value = res.data.total
paginationConfig.total = res.data.total
}
} catch (error) {
message.error('获取日志列表失败')
} finally {
tableLoading.value = false
}
}
function handleSearch() {
queryParams.page = 1
paginationConfig.current = 1
fetchLogList()
}
function handleReset() {
queryParams.operator = ''
queryParams.actionType = undefined
queryParams.startDate = ''
queryParams.endDate = ''
timeRange.value = []
handleSearch()
}
function handleTableChange(pag: any) {
queryParams.page = pag.current
queryParams.pageSize = pag.pageSize
paginationConfig.current = pag.current
paginationConfig.pageSize = pag.pageSize
fetchLogList()
}
async function handleExport() {
exportLoading.value = true
try {
const params: any = {}
if (queryParams.operator) params.operator = queryParams.operator
if (queryParams.actionType) params.actionType = queryParams.actionType
if (timeRange.value && timeRange.value.length === 2) {
params.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
params.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
}
const response: any = await logApi.exportLogs(params)
const blob = new Blob([response], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `操作日志_${dayjs().format('YYYYMMDDHHmmss')}.csv`
link.click()
window.URL.revokeObjectURL(url)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
} finally {
exportLoading.value = false
}
}
onMounted(() => {
fetchLogList()
})
</script>
<style scoped lang="less">
.operation-log {
padding: 20px;
.search-card {
margin-bottom: 16px;
:deep(.ant-card-body) {
padding-bottom: 4px;
}
}
.table-card {
:deep(.ant-table) {
font-size: 13px;
}
}
.duration-warning {
color: #cf1322;
font-weight: 500;
}
.action-text {
max-width: 150px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
}
</style>
+347
View File
@@ -0,0 +1,347 @@
<template>
<div class="login-container">
<div class="bg-decoration">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<div class="login-card" :class="{ 'fade-in': true }">
<div class="brand-section">
<div class="logo-icon">
<ThunderboltOutlined />
</div>
<h1 class="title">智能电网设备管理系统</h1>
<p class="subtitle">Smart Grid IoT Management System</p>
</div>
<a-divider />
<a-form ref="formRef" :model="formState" :rules="rules" size="large" @finish="handleLogin">
<a-form-item name="username">
<a-input ref="usernameInputRef" v-model:value="formState.username" placeholder="请输入用户名" allow-clear
:disabled="loading" @pressEnter="focusPassword">
<template #prefix>
<UserOutlined class="input-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password ref="passwordInputRef" v-model:value="formState.password" placeholder="请输入密码" allow-clear
:disabled="loading" @pressEnter="submitForm">
<template #prefix>
<LockOutlined class="input-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item class="options-row">
<a-checkbox v-model:checked="formState.rememberMe" :disabled="loading">
记住我
</a-checkbox>
<a class="forgot-link" href="javascript:void(0)">忘记密码?</a>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" :loading="loading" block class="login-btn">
<template #icon v-if="!loading">
<span></span>
</template>
{{ loading ? '登录中...' : '登 录' }}
</a-button>
</a-form-item>
</a-form>
<div class="footer-info">
<p>默认账户:admin / admin123456</p>
</div>
</div>
<div class="copyright">
<p>&copy; 2026 智能电网设备管理系统 All Rights Reserved</p>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import {
UserOutlined,
LockOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const route = useRoute()
const permissionStore = usePermissionStore()
const formRef = ref()
const usernameInputRef = ref()
const passwordInputRef = ref()
const loading = ref(false)
const formState = reactive({
username: '',
password: '',
rememberMe: false,
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' },
],
}
onMounted(() => {
nextTick(() => {
const savedUsername = localStorage.getItem('remembered_username')
if (savedUsername) {
formState.username = savedUsername
formState.rememberMe = true
}
usernameInputRef.value?.focus()
})
})
function focusPassword() {
passwordInputRef.value?.focus()
}
function submitForm() {
formRef.value?.submit()
}
async function handleLogin() {
try {
loading.value = true
await permissionStore.login(formState.username, formState.password)
if (formState.rememberMe) {
localStorage.setItem('remembered_username', formState.username)
} else {
localStorage.removeItem('remembered_username')
}
message.success('登录成功!')
setTimeout(() => {
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}, 500)
} catch (error: any) {
message.error(error.message || '登录失败,请检查用户名和密码')
formState.password = ''
nextTick(() => {
passwordInputRef.value?.focus()
})
} finally {
loading.value = false
}
}
</script>
<style scoped lang="less">
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e22ce 100%);
position: relative;
overflow: hidden;
padding: 20px;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
.circle {
position: absolute;
border-radius: 50%;
opacity: 0.08;
}
.circle-1 {
width: 600px;
height: 600px;
top: -200px;
right: -100px;
background: #fff;
animation: float 20s ease-in-out infinite;
}
.circle-2 {
width: 400px;
height: 400px;
bottom: -150px;
left: -100px;
background: #fff;
animation: float 15s ease-in-out infinite reverse;
}
.circle-3 {
width: 200px;
height: 200px;
top: 50%;
left: 10%;
background: #fff;
animation: float 12s ease-in-out infinite;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-30px) rotate(5deg);
}
}
.login-card {
width: 420px;
max-width: 100%;
background: #fff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 1;
animation: slideUp 0.6s ease-out;
&.fade-in {
animation: slideUp 0.6s ease-out;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.brand-section {
text-align: center;
margin-bottom: 10px;
.logo-icon {
margin-bottom: 16px;
:deep(.anticon) {
font-size: 48px;
color: #1890ff;
filter: drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3));
}
}
.title {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
margin: 0 0 8px 0;
letter-spacing: 1px;
}
.subtitle {
font-size: 14px;
color: #999;
margin: 0;
font-weight: 400;
letter-spacing: 0.5px;
}
}
.options-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px !important;
}
.forgot-link {
color: #1890ff;
font-size: 14px;
transition: color 0.3s;
&:hover {
color: #40a9ff;
}
}
.login-btn {
height: 44px;
font-size: 16px;
font-weight: 500;
border-radius: 6px;
letter-spacing: 4px;
}
.footer-info {
text-align: center;
margin-top: 20px;
color: #bbb;
font-size: 12px;
}
.input-icon {
color: rgba(0, 0, 0, 0.25);
}
.copyright {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
z-index: 1;
p {
margin: 0;
}
}
@media (max-width: 480px) {
.login-card {
width: 100%;
padding: 30px 20px;
}
.brand-section .title {
font-size: 20px;
}
.copyright {
position: relative;
bottom: auto;
margin-top: 20px;
color: rgba(255, 255, 255, 0.5);
}
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<template>
<div class="basic-settings">
<a-card title="基本设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="站点名称" name="siteName">
<a-input
v-model:value="formState.siteName"
placeholder="请输入站点名称"
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-upload
name="file"
list-type="picture-card"
class="logo-uploader"
:show-upload-list="false"
:before-upload="beforeUpload"
:custom-request="handleUpload"
>
<img v-if="formState.logo" :src="formState.logo" alt="logo" style="width: 100%" />
<div v-else>
<plus-outlined />
<div class="ant-upload-text">上传</div>
</div>
</a-upload>
<div class="form-tip">支持 JPG、PNG 格式,建议尺寸 200x200px</div>
</a-form-item>
<a-form-item label="会话超时时间" name="sessionTimeout">
<a-input-number
v-model:value="formState.sessionTimeout"
:min="5"
:max="1440"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-1440</span>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :loading="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { systemApi, type BasicSettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<BasicSettings>({
siteName: '',
logo: '',
sessionTimeout: 120,
});
const rules = {
siteName: [
{ required: true, message: '请输入站点名称', trigger: 'blur' },
{ max: 50, message: '站点名称不能超过50个字符', trigger: 'blur' },
],
sessionTimeout: [
{ required: true, message: '请输入会话超时时间', trigger: 'blur' },
{ type: 'number', min: 5, max: 1440, message: '超时时间范围为5-1440分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.basic) {
Object.assign(formState, res.data.basic);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
function beforeUpload(file: File) {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的图片!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('图片大小不能超过 2MB!');
return false;
}
return true;
}
function handleUpload(options: any) {
const reader = new FileReader();
reader.onload = () => {
formState.logo = reader.result as string;
};
reader.readAsDataURL(options.file);
options.onSuccess();
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateBasicSettings({
siteName: formState.siteName,
logo: formState.logo,
sessionTimeout: formState.sessionTimeout,
});
if (res.code === 200) {
message.success('基本设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.basic-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
.logo-uploader {
:deep(.ant-upload) {
width: 128px;
height: 128px;
}
}
.ant-upload-text {
margin-top: 8px;
color: #999;
}
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="security-settings">
<a-card title="安全设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="密码最小长度" name="passwordMinLength">
<a-input-number
v-model:value="formState.passwordMinLength"
:min="6"
:max="20"
style="width: 200px"
/>
<span class="form-tip">范围 6-20 位</span>
</a-form-item>
<a-form-item label="最大登录尝试次数" name="maxLoginAttempts">
<a-input-number
v-model:value="formState.maxLoginAttempts"
:min="3"
:max="10"
style="width: 200px"
/>
<span class="form-tip">超过此次数将锁定账户</span>
</a-form-item>
<a-form-item label="账户锁定时长" name="lockoutDuration">
<a-input-number
v-model:value="formState.lockoutDuration"
:min="5"
:max="60"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-60</span>
</a-form-item>
<a-form-item label="IP白名单" name="ipWhitelist">
<a-select
v-model:value="formState.ipWhitelist"
mode="tags"
placeholder="输入IP地址后按回车添加"
style="width: 100%"
:token-separators="[',']"
/>
<div class="form-tip">
留空表示不限制IP访问,多个IP用回车或逗号分隔(如:192.168.1.1)
</div>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :saving="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { systemApi, type SecuritySettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<SecuritySettings>({
passwordMinLength: 6,
maxLoginAttempts: 5,
lockoutDuration: 30,
ipWhitelist: [],
});
const rules = {
passwordMinLength: [
{ required: true, message: '请输入密码最小长度', trigger: 'blur' },
{ type: 'number', min: 6, max: 20, message: '长度范围为6-20', trigger: 'blur' },
],
maxLoginAttempts: [
{ required: true, message: '请输入最大登录尝试次数', trigger: 'blur' },
{ type: 'number', min: 3, max: 10, message: '范围为3-10次', trigger: 'blur' },
],
lockoutDuration: [
{ required: true, message: '请输入账户锁定时长', trigger: 'blur' },
{ type: 'number', min: 5, max: 60, message: '范围为5-60分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.security) {
Object.assign(formState, res.data.security);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateSecuritySettings({
passwordMinLength: formState.passwordMinLength,
maxLoginAttempts: formState.maxLoginAttempts,
lockoutDuration: formState.lockoutDuration,
ipWhitelist: formState.ipWhitelist,
});
if (res.code === 200) {
message.success('安全设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.security-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="role-management">
<a-card title="角色管理">
<template #extra>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增角色
</a-button>
</template>
<div class="search-bar">
<a-input-search
v-model:value="keyword"
placeholder="搜索角色名称/编码"
allow-clear
style="width: 300px"
@search="handleSearch"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input-search>
</div>
<a-table
:columns="columns"
:data-source="roleList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span>{{ record.name }}</span>
</template>
<template v-if="column.dataIndex === 'code'">
<a-tag color="blue">{{ record.code }}</a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '启用' : '禁用'" />
</template>
<template v-if="column.dataIndex === 'userCount'">
<a @click="handleViewUsers(record)">{{ record.userCount }}</a>
</template>
<template v-if="column.dataIndex === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-if="column.dataIndex === 'action'">
<a-space>
<a-button type="link" size="small" @click="handlePermission(record)">
<KeyOutlined />权限配置
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定删除该角色?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="formModalVisible"
:title="formModalTitle"
:confirm-loading="formLoading"
@ok="handleSubmitForm"
@cancel="resetForm"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 17 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input
v-model:value="formData.code"
placeholder="请输入角色编码"
:disabled="isEdit"
/>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea v-model:value="formData.description" placeholder="请输入角色描述" :rows="3" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="permissionModalVisible"
title="权限配置"
width="600px"
:confirm-loading="permissionLoading"
@ok="handleSavePermissions"
>
<a-spin :spinning="permissionTreeLoading">
<a-tree
v-if="!permissionTreeLoading"
v-model:checkedKeys="checkedKeys"
:tree-data="permissionTree"
checkable
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:check-strictly="false"
:default-expand-all="true"
style="max-height: 500px; overflow-y: auto;"
>
<template #title="{ name, icon }">
<span>
<component v-if="icon" :is="icon" style="margin-right: 8px" />
{{ name }}
</span>
</template>
</a-tree>
</a-spin>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
PlusOutlined,
SearchOutlined,
KeyOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { roleApi, type RoleInfo, type PermissionTreeNode } from '@/api/role';
import dayjs from 'dayjs';
const loading = ref(false);
const roleList = ref<RoleInfo[]>([]);
const keyword = ref('');
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
});
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
},
{
title: '角色编码',
dataIndex: 'code',
key: 'code',
width: 150,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center' as const,
},
{
title: '关联用户数',
dataIndex: 'userCount',
key: 'userCount',
width: 120,
align: 'center' as const,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 280,
fixed: 'right' as const,
},
];
async function fetchRoleList() {
loading.value = true;
try {
const res: any = await roleApi.getRoleList({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: keyword.value || undefined,
});
if (res.code === 200) {
roleList.value = res.data.list;
pagination.total = res.data.total;
}
} catch (error) {
message.error('获取角色列表失败');
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.current = 1;
fetchRoleList();
}
function handleTableChange(pag: any) {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchRoleList();
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
const formModalVisible = ref(false);
const formLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const currentRoleId = ref<number | null>(null);
const formModalTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
const formData = reactive({
name: '',
code: '',
description: '',
sortOrder: 0,
status: 1,
});
const formRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
};
function handleAdd() {
isEdit.value = false;
currentRoleId.value = null;
resetForm();
formModalVisible.value = true;
}
function handleEdit(record: RoleInfo) {
isEdit.value = true;
currentRoleId.value = record.id;
formData.name = record.name;
formData.code = record.code;
formData.description = record.description || '';
formData.sortOrder = record.sortOrder;
formData.status = record.status;
formModalVisible.value = true;
}
function resetForm() {
formData.name = '';
formData.code = '';
formData.description = '';
formData.sortOrder = 0;
formData.status = 1;
formRef.value?.resetFields();
}
async function handleSubmitForm() {
try {
await formRef.value?.validate();
} catch {
return;
}
formLoading.value = true;
try {
let res: any;
if (isEdit.value && currentRoleId.value) {
res = await roleApi.updateRole(currentRoleId.value, {
name: formData.name,
description: formData.description,
sortOrder: formData.sortOrder,
status: formData.status,
});
} else {
res = await roleApi.createRole({
name: formData.name,
code: formData.code,
description: formData.description,
sortOrder: formData.sortOrder,
status: formData.status,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
formModalVisible.value = false;
fetchRoleList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
formLoading.value = false;
}
}
async function handleDelete(record: RoleInfo) {
try {
const res: any = await roleApi.deleteRole(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchRoleList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function handleViewUsers(_record: RoleInfo) {
message.info('查看用户功能开发中');
}
const permissionModalVisible = ref(false);
const permissionLoading = ref(false);
const permissionTreeLoading = ref(false);
const permissionTree = ref<PermissionTreeNode[]>([]);
const checkedKeys = ref<number[]>([]);
async function handlePermission(record: RoleInfo) {
currentRoleId.value = record.id;
permissionModalVisible.value = true;
checkedKeys.value = [];
await loadPermissionTree();
}
async function loadPermissionTree() {
permissionTreeLoading.value = true;
try {
if (!currentRoleId.value) return;
const res: any = await roleApi.getRolePermissions(currentRoleId.value);
if (res.code === 200) {
permissionTree.value = res.data;
extractCheckedKeys(res.data);
}
} catch (error) {
message.error('获取权限数据失败');
} finally {
permissionTreeLoading.value = false;
}
}
function extractCheckedKeys(tree: PermissionTreeNode[]) {
const keys: number[] = [];
function traverse(nodes: PermissionTreeNode[]) {
for (const node of nodes) {
if (node.checked) {
keys.push(node.id);
}
if (node.children?.length) {
traverse(node.children);
}
}
}
traverse(tree);
checkedKeys.value = keys;
}
async function handleSavePermissions() {
if (!currentRoleId.value) return;
permissionLoading.value = true;
try {
const res: any = await roleApi.updateRolePermissions(
currentRoleId.value,
checkedKeys.value as unknown as number[]
);
if (res.code === 200) {
message.success('权限保存成功');
permissionModalVisible.value = false;
} else {
message.error(res.message || '保存失败');
}
} catch (error) {
message.error('保存失败');
} finally {
permissionLoading.value = false;
}
}
onMounted(() => {
fetchRoleList();
});
</script>
<style scoped>
.role-management {
padding: 20px;
}
.search-bar {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
:deep(.ant-table) {
font-size: 14px;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>
+499
View File
@@ -0,0 +1,499 @@
<template>
<div class="user-management">
<a-card :bordered="false">
<a-form layout="inline" :model="searchForm" class="search-form">
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 220px" @pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<div class="table-operations">
<a-space>
<a-button type="primary" v-permission="'user:create'" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
<a-button danger v-permission="'user:delete'" :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
<template #icon><DeleteOutlined /></template>
批量删除 ({{ selectedRowKeys.length }})
</a-button>
</a-space>
</div>
<a-card :bordered="false">
<a-table
:columns="columns"
:data-source="dataSource"
:row-selection="rowSelection"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1200 }"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :size="40">{{ record.nickname?.charAt(0) }}</a-avatar>
</template>
<template v-if="column.key === 'roles'">
<a-space>
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.code)">
{{ role.role?.name }}
</a-tag>
</a-space>
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="启用"
un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
v-permission="'user:update'"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" v-permission="'user:update'" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该用户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger v-permission="'user:delete'">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无用户数据" />
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="用户名" name="username">
<a-input v-model:value="formData.username" placeholder="请输入用户名" :disabled="isEdit" />
</a-form-item>
<a-form-item label="密码" name="password" v-if="!isEdit || showPassword">
<a-input-password v-model:value="formData.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item label="确认密码" name="confirmPassword" v-if="!isEdit || showPassword">
<a-input-password v-model:value="formData.confirmPassword" placeholder="请再次输入密码" />
</a-form-item>
<a-form-item v-if="isEdit && !showPassword">
<a-button type="link" size="small" @click="showPassword = true">修改密码</a-button>
</a-form-item>
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="formData.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="角色" name="roleIds">
<a-select
v-model:value="formData.roleIds"
mode="multiple"
placeholder="请选择角色"
:options="roleOptions"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import { userApi, type UserInfo, type UserQuery, type RoleInfo } from '@/api/user';
const loading = ref(false);
const dataSource = ref<UserInfo[]>([]);
const total = ref(0);
const searchForm = reactive<UserQuery>({
page: 1,
pageSize: 10,
keyword: '',
status: undefined as any,
});
const selectedRowKeys = ref<number[]>([]);
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const showPassword = ref(false);
const roleOptions = ref<{ label: string; value: number }[]>([]);
const formData = reactive({
username: '',
password: '',
confirmPassword: '',
nickname: '',
phone: '',
email: '',
roleIds: [] as number[],
status: 1,
});
const validateConfirmPassword = async (_rule: any, value: string) => {
if (value && value !== formData.password) {
return Promise.reject(new Error('两次输入的密码不一致'));
}
return Promise.resolve();
};
const formRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' },
],
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
email: [{ type: 'email' as const, message: '邮箱格式不正确', trigger: 'blur' }],
roleIds: [{ required: true, message: '请选择角色', trigger: 'change', type: 'array' as const }],
};
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
{ title: '角色', key: 'roles', width: 200 },
{
title: '状态',
key: 'status',
width: 100,
align: 'center' as const,
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[]) => {
selectedRowKeys.value = keys;
},
}));
onMounted(() => {
fetchUserList();
fetchRoleList();
});
async function fetchUserList() {
loading.value = true;
try {
const res: any = await userApi.getUserList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取用户列表失败');
} finally {
loading.value = false;
}
}
async function fetchRoleList() {
try {
const res: any = await userApi.getRoleList();
if (res.code === 200) {
roleOptions.value = res.data.map((item: RoleInfo) => ({
label: item.name,
value: item.id,
}));
}
} catch (error) {
console.error('获取角色列表失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchUserList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.status = undefined as any;
searchForm.page = 1;
fetchUserList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchUserList();
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
showPassword.value = false;
modalTitle.value = '新增用户';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: UserInfo) {
isEdit.value = true;
editId.value = record.id;
showPassword.value = false;
modalTitle.value = '编辑用户';
resetForm();
formData.username = record.username;
formData.nickname = record.nickname;
formData.phone = record.phone || '';
formData.email = record.email || '';
formData.status = record.status;
formData.roleIds = record.userRoles?.map((ur) => ur.roleId) || [];
modalVisible.value = true;
}
function resetForm() {
formData.username = '';
formData.password = '';
formData.confirmPassword = '';
formData.nickname = '';
formData.phone = '';
formData.email = '';
formData.roleIds = [];
formData.status = 1;
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
let res: any;
if (isEdit.value && editId.value) {
const updateData: any = {
nickname: formData.nickname,
phone: formData.phone,
email: formData.email,
roleIds: formData.roleIds,
status: formData.status,
};
if (showPassword.value && formData.password) {
updateData.password = formData.password;
}
res = await userApi.updateUser(editId.value, updateData);
} else {
res = await userApi.createUser({
username: formData.username,
password: formData.password,
nickname: formData.nickname,
phone: formData.phone,
email: formData.email,
roleIds: formData.roleIds,
status: formData.status,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchUserList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await userApi.deleteUser(id);
if (res.code === 200) {
message.success('删除成功');
fetchUserList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function handleBatchDelete() {
if (!selectedRowKeys.value.length) return;
Modal.confirm({
title: '批量删除确认',
content: `确定要删除选中的 ${selectedRowKeys.value.length} 个用户吗?此操作不可恢复!`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const res: any = await userApi.batchDeleteUsers(selectedRowKeys.value);
if (res.code === 200) {
message.success(res.message);
selectedRowKeys.value = [];
fetchUserList();
} else {
message.error(res.message || '批量删除失败');
}
} catch (error) {
message.error('批量删除失败');
}
},
});
}
async function handleStatusChange(id: number, status: number) {
try {
const res: any = await userApi.updateStatus(id, status);
if (res.code === 200) {
message.success(status === 1 ? '已启用' : '已禁用');
fetchUserList();
} else {
message.error(res.message || '状态更新失败');
}
} catch (error) {
message.error('状态更新失败');
}
}
function getRoleColor(code?: string): string {
const colorMap: Record<string, string> = {
admin: 'red',
manager: 'orange',
operator: 'blue',
user: 'green',
};
return colorMap[code || ''] || 'default';
}
</script>
<style scoped lang="less">
.user-management {
padding: 24px;
.search-form {
margin-bottom: 16px;
}
.table-operations {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
background-color: #fafafa;
font-weight: 600;
}
.ant-table-tbody > tr:hover > td {
background-color: #e6f7ff;
}
}
:deep(.ant-avatar) {
background-color: #1890ff;
color: #fff;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />