登录权限

This commit is contained in:
wangxiaoxian 2026-03-09 00:31:38 +08:00
parent 53bd72b37e
commit a51e8e8d66
35 changed files with 16844 additions and 4374 deletions

17033
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,14 +9,18 @@
},
"dependencies": {
"axios": "^0.21.1",
"cache-loader": "^4.1.0",
"element-ui": "^2.15.6",
"html-webpack-plugin": "^4.5.2",
"vue": "^2.6.14",
"vue-router": "^3.5.1"
"vue-loader": "^15.11.1",
"vue-router": "^3.5.1",
"webpack": "^4.47.0"
},
"devDependencies": {
"@vue/cli-plugin-eslint": "^5.0.9",
"@vue/cli-plugin-router": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/cli-service": "^4.5.19",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^7.14.0"

View File

@ -1,209 +1,12 @@
<template>
<div id="app">
<el-container>
<!-- 顶部导航栏 -->
<el-header height="60px" class="app-header">
<div class="header-left">
<!-- 移动端菜单按钮 -->
<el-button
v-if="isMobile"
type="text"
class="menu-toggle-btn"
@click="drawerVisible = true"
>
<i class="el-icon-s-fold" style="font-size: 24px; color: white;"></i>
</el-button>
<h1 class="app-title">租房管理系统</h1>
</div>
<el-button type="primary" plain size="small" @click="logout">退出</el-button>
</el-header>
<el-container>
<!-- PC端侧边栏 -->
<el-aside
v-if="!isMobile"
width="200px"
class="app-aside"
>
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleSelect"
background-color="#f0f2f5"
text-color="#333"
active-text-color="#409EFF"
>
<el-menu-item index="dashboard">
<i class="el-icon-s-home"></i>
<span>首页</span>
</el-menu-item>
<el-menu-item index="region-list">
<i class="el-icon-location"></i>
<span>区域管理</span>
</el-menu-item>
<el-menu-item index="apartment-list">
<i class="el-icon-office-building"></i>
<span>公寓管理</span>
</el-menu-item>
<el-menu-item index="room-list">
<i class="el-icon-menu"></i>
<span>房间管理</span>
</el-menu-item>
<el-menu-item index="rental-list">
<i class="el-icon-key"></i>
<span>租房管理</span>
</el-menu-item>
<el-menu-item index="rental-archive">
<i class="el-icon-document"></i>
<span>租赁档案</span>
</el-menu-item>
<el-menu-item index="water-archive">
<i class="el-icon-document"></i>
<span>水费档案</span>
</el-menu-item>
<el-menu-item index="rent-statistics">
<i class="el-icon-data-analysis"></i>
<span>租金统计</span>
</el-menu-item>
<el-menu-item index="room-statistics">
<i class="el-icon-data-analysis"></i>
<span>房间状态统计</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-main class="app-main">
<router-view />
</el-main>
</el-container>
</el-container>
<!-- 移动端侧边栏抽屉 -->
<el-drawer
:visible.sync="drawerVisible"
:with-header="false"
:size="drawerWidth"
direction="ltr"
class="mobile-drawer"
>
<div class="drawer-header">
<h3>菜单</h3>
<el-button type="text" @click="drawerVisible = false">
<i class="el-icon-close" style="font-size: 20px;"></i>
</el-button>
</div>
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleMobileSelect"
background-color="#fff"
text-color="#333"
active-text-color="#409EFF"
>
<el-menu-item index="dashboard">
<i class="el-icon-s-home"></i>
<span>首页</span>
</el-menu-item>
<el-menu-item index="region-list">
<i class="el-icon-location"></i>
<span>区域管理</span>
</el-menu-item>
<el-menu-item index="apartment-list">
<i class="el-icon-office-building"></i>
<span>公寓管理</span>
</el-menu-item>
<el-menu-item index="room-list">
<i class="el-icon-menu"></i>
<span>房间管理</span>
</el-menu-item>
<el-menu-item index="rental-list">
<i class="el-icon-key"></i>
<span>租房管理</span>
</el-menu-item>
<el-menu-item index="rental-archive">
<i class="el-icon-document"></i>
<span>租赁档案</span>
</el-menu-item>
<el-menu-item index="water-archive">
<i class="el-icon-document"></i>
<span>水费档案</span>
</el-menu-item>
<el-menu-item index="rent-statistics">
<i class="el-icon-data-analysis"></i>
<span>租金统计</span>
</el-menu-item>
<el-menu-item index="room-statistics">
<i class="el-icon-data-analysis"></i>
<span>房间状态统计</span>
</el-menu-item>
</el-menu>
</el-drawer>
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
activeIndex: 'dashboard',
isMobile: false,
drawerVisible: false,
drawerWidth: '70%'
}
},
mounted() {
this.checkDevice()
window.addEventListener('resize', this.checkDevice)
},
beforeDestroy() {
window.removeEventListener('resize', this.checkDevice)
},
methods: {
checkDevice() {
const width = window.innerWidth
this.isMobile = width <= 768
//
if (width <= 375) {
this.drawerWidth = '80%'
} else if (width <= 768) {
this.drawerWidth = '70%'
}
},
handleSelect(key, keyPath) {
this.activeIndex = key
this.navigateToRoute(key)
},
handleMobileSelect(key, keyPath) {
this.activeIndex = key
this.drawerVisible = false
this.navigateToRoute(key)
},
navigateToRoute(key) {
const routeMap = {
'dashboard': '/',
'region-list': '/region/list',
'region-add': '/region/add',
'apartment-list': '/apartment/list',
'apartment-add': '/apartment/add',
'room-list': '/room/list',
'room-add': '/room/add',
'rental-list': '/rental/list',
'rental-add': '/rental/add',
'rental-archive': '/rental/archive',
'water-archive': '/water/archive',
'rent-statistics': '/statistics/rent',
'room-statistics': '/statistics/room'
}
if (routeMap[key] && this.$route.path !== routeMap[key]) {
this.$router.push(routeMap[key])
}
},
logout() {
this.$message.success('退出成功')
}
}
name: 'App'
}
</script>
@ -220,180 +23,4 @@ export default {
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
/* 顶部导航栏样式 */
.app-header {
background-color: #333;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
}
.menu-toggle-btn {
padding: 0;
margin: 0;
}
.app-title {
margin: 0;
font-size: 18px;
}
/* 侧边栏样式 */
.app-aside {
background-color: #f0f2f5;
min-height: calc(100vh - 60px);
}
.el-menu-vertical-demo:not(.el-menu--collapse) {
width: 200px;
min-height: 400px;
}
/* 主内容区样式 */
.app-main {
padding: 20px;
background-color: #f5f7fa;
min-height: calc(100vh - 60px);
}
/* 移动端抽屉样式 */
.mobile-drawer .el-drawer__body {
padding: 0;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
background-color: #f5f7fa;
}
.drawer-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
/* 移动端适配 */
@media screen and (max-width: 768px) {
.app-header {
padding: 0 15px;
}
.app-title {
font-size: 16px;
}
.app-main {
padding: 10px;
}
/* 表格横向滚动 */
.el-table {
width: 100%;
overflow-x: auto;
}
.el-table__body-wrapper {
overflow-x: auto;
}
/* 表单元素适配 */
.el-form-item {
margin-bottom: 15px;
}
.el-form-item__label {
float: none;
display: block;
text-align: left;
padding: 0 0 8px;
line-height: 1.5;
}
.el-form-item__content {
margin-left: 0 !important;
}
/* 搜索表单适配 */
.search-form .el-form-item {
display: block;
margin-right: 0;
}
.search-form .el-form-item__content {
width: 100%;
}
.search-form .el-input,
.search-form .el-select {
width: 100%;
}
/* 卡片适配 */
.el-card {
margin-bottom: 10px;
}
.el-card__header {
padding: 12px 15px;
}
.el-card__body {
padding: 15px;
}
/* 分页适配 */
.el-pagination {
text-align: center;
padding: 10px 0;
}
.el-pagination .el-pagination__total,
.el-pagination .el-pagination__sizes,
.el-pagination .el-pagination__jump {
display: none;
}
/* 按钮组适配 */
.el-button + .el-button {
margin-left: 5px;
}
/* 对话框适配 */
.el-dialog {
width: 90% !important;
margin-top: 10vh !important;
}
.el-dialog__body {
padding: 15px;
}
}
/* 小屏幕手机适配 */
@media screen and (max-width: 375px) {
.app-title {
font-size: 14px;
}
.app-main {
padding: 8px;
}
.el-card__body {
padding: 12px;
}
}
</style>

View File

@ -1,16 +1,6 @@
// API服务层用于与后端进行交互
import { get, post, put, del } from './request';
// 区域管理API
export const regionApi = {
getAll: () => get('/regions'),
list: (params = {}) => get('/regions/list', params),
getById: (id) => get(`/regions/${id}`),
create: (data) => post('/regions', data),
update: (id, data) => put(`/regions/${id}`, data),
delete: (id) => del(`/regions/${id}`)
};
// 公寓管理API
export const apartmentApi = {
getAll: (params = {}) => get('/apartments', params),
@ -66,7 +56,8 @@ export const statisticsApi = {
getRoomStatus: () => get('/statistics/room-status'),
getRegionHouseStats: () => get('/statistics/region-house'),
getRegionApartmentHouseStats: () => get('/statistics/region-apartment-house'),
getDashboardStats: () => get('/statistics/dashboard')
getDashboardStats: () => get('/statistics/dashboard'),
getApartmentRoomStatusStats: () => get('/statistics/apartment-room-status')
};
// 水费管理API
@ -89,7 +80,6 @@ export const electricityBillApi = {
};
export default {
region: regionApi,
apartment: apartmentApi,
room: roomApi,
tenant: tenantApi,

21
src/api/auth.js Normal file
View File

@ -0,0 +1,21 @@
import { post, get } from './request'
// 登录
export function login(data) {
return post('/auth/login', data)
}
// 登出
export function logout() {
return post('/auth/logout')
}
// 获取当前用户信息
export function getCurrentUser() {
return get('/auth/user')
}
// 修改密码
export function changePassword(data) {
return post('/auth/change-password', data)
}

21
src/api/log.js Normal file
View File

@ -0,0 +1,21 @@
import { get, post } from './request'
// 获取操作日志列表
export function getOperationLogs(params) {
return get('/logs/operation', params)
}
// 获取登录日志列表
export function getLoginLogs(params) {
return get('/logs/login', params)
}
// 清空操作日志
export function clearOperationLogs(data) {
return post('/logs/operation/clear', data)
}
// 清空登录日志
export function clearLoginLogs(data) {
return post('/logs/login/clear', data)
}

46
src/api/menu.js Normal file
View File

@ -0,0 +1,46 @@
import { get, post, put, del } from './request'
// 获取菜单树
export function getMenuTree(params) {
return get('/menus/tree', params)
}
// 获取菜单列表
export function getMenuList(params) {
return get('/menus', params)
}
// 获取菜单详情
export function getMenuById(id) {
return get(`/menus/${id}`)
}
// 创建菜单
export function createMenu(data) {
return post('/menus', data)
}
// 更新菜单
export function updateMenu(id, data) {
return put(`/menus/${id}`, data)
}
// 删除菜单
export function deleteMenu(id) {
return del(`/menus/${id}`)
}
// 获取角色的菜单权限
export function getRoleMenus(roleId) {
return get(`/menus/role/${roleId}`)
}
// 分配菜单权限给角色
export function assignMenusToRole(roleId, data) {
return post(`/menus/role/${roleId}/assign`, data)
}
// 获取当前用户的菜单权限
export function getUserMenus() {
return get('/menus/user/menus')
}

View File

@ -1,18 +1,64 @@
// 统一请求处理
import { getToken, isTokenExpired, clearAuth } from '@/utils/auth'
import router from '@/router'
const API_BASE_URL = process.env.VUE_APP_API_BASE_URL || '/api';
// 不需要认证的接口列表
const publicUrls = ['/auth/login']
// 检查是否为公开接口
function isPublicUrl(url) {
return publicUrls.some(publicUrl => url.includes(publicUrl))
}
// 通用请求函数
export async function request(url, options = {}) {
try {
const isPublic = isPublicUrl(url)
// 检查 Token 是否过期(公开接口跳过)
if (!isPublic && isTokenExpired()) {
clearAuth()
// 避免重复导航
if (router.currentRoute.path !== '/login') {
router.push('/login')
}
throw new Error('登录已过期,请重新登录')
}
// 获取 Token 并添加到请求头
const token = getToken()
const headers = {
'Content-Type': 'application/json',
...options.headers
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
headers
});
// 处理 401 未授权
if (response.status === 401) {
const errorData = await response.json();
// 检查是否是认证失败(如用户名密码错误)
if (errorData.code === 401 && errorData.message) {
throw new Error(errorData.message);
}
// 其他 401 情况(如 token 过期)
clearAuth()
// 避免重复导航
if (router.currentRoute.path !== '/login') {
router.push('/login')
}
throw new Error('登录已过期,请重新登录')
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

31
src/api/role.js Normal file
View File

@ -0,0 +1,31 @@
import { get, post, put, del } from './request'
// 获取角色列表
export function getRoles(params) {
return get('/roles', params)
}
// 获取角色详情
export function getRoleById(id) {
return get(`/roles/${id}`)
}
// 创建角色
export function createRole(data) {
return post('/roles', data)
}
// 更新角色
export function updateRole(id, data) {
return put(`/roles/${id}`, data)
}
// 删除角色
export function deleteRole(id) {
return del(`/roles/${id}`)
}
// 获取所有角色(用于下拉选择)
export function getAllRoles() {
return get('/roles/all/list')
}

51
src/api/user.js Normal file
View File

@ -0,0 +1,51 @@
import { get, post, put, del } from './request'
// 获取用户列表
export function getUserList(params) {
return get('/users', params)
}
// 获取当前用户信息
export function getUserInfo() {
return get('/users/info')
}
// 更新个人资料
export function updateUserProfile(data) {
return put('/users/profile', data)
}
// 修改密码
export function changePassword(data) {
return post('/users/change-password', data)
}
// 创建用户
export function createUser(data) {
return post('/users', data)
}
// 更新用户
export function updateUser(id, data) {
return put(`/users/${id}`, data)
}
// 删除用户
export function deleteUser(id) {
return del(`/users/${id}`)
}
// 重置用户密码
export function resetUserPassword(id) {
return post(`/users/${id}/reset-password`)
}
// 获取用户列表(用于下拉选择)
export function getAllUsers() {
return get('/users/all/list')
}
// 获取所有角色(用于下拉选择)
export function getAllRoles() {
return get('/roles/all/list')
}

View File

@ -0,0 +1,81 @@
<template>
<div class="auth-layout">
<div class="auth-container">
<div class="auth-header">
<h1 class="auth-title">租房管理系统</h1>
<p class="auth-subtitle">欢迎登录</p>
</div>
<div class="auth-content">
<router-view />
</div>
</div>
<div class="auth-footer">
<p>© 2026 租房管理系统</p>
</div>
</div>
</template>
<script>
export default {
name: 'AuthLayout'
}
</script>
<style scoped>
.auth-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.auth-container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 420px;
padding: 40px;
}
.auth-header {
text-align: center;
margin-bottom: 30px;
}
.auth-title {
font-size: 28px;
font-weight: 600;
color: #333;
margin: 0 0 10px 0;
}
.auth-subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
.auth-content {
width: 100%;
}
.auth-footer {
margin-top: 30px;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
@media (max-width: 480px) {
.auth-container {
padding: 30px 20px;
}
.auth-title {
font-size: 24px;
}
}
</style>

376
src/layouts/MainLayout.vue Normal file
View File

@ -0,0 +1,376 @@
<template>
<div id="app">
<el-container>
<!-- 顶部导航栏 -->
<el-header height="60px" class="app-header">
<div class="header-left">
<!-- 移动端菜单按钮 -->
<el-button
v-if="isMobile"
type="text"
class="menu-toggle-btn"
@click="drawerVisible = true"
>
<i class="el-icon-s-fold" style="font-size: 24px; color: white;"></i>
</el-button>
<h1 class="app-title">租房管理系统</h1>
</div>
<div class="header-right">
<el-dropdown @command="handleUserCommand">
<span class="user-info">
<i class="el-icon-user-solid"></i>
{{ username }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</el-header>
<el-container>
<!-- PC端侧边栏 -->
<el-aside
v-if="!isMobile"
width="200px"
class="app-aside"
>
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleSelect"
background-color="#f0f2f5"
text-color="#333"
active-text-color="#409EFF"
>
<template v-for="menu in menuList">
<!-- 有子菜单 -->
<el-submenu v-if="menu.children && menu.children.length > 0" :key="menu.id" :index="menu.code">
<template slot="title">
<i :class="menu.icon || 'el-icon-menu'"></i>
<span>{{ menu.name }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.id"
:index="child.code"
>
<i :class="child.icon || 'el-icon-document'"></i>
<span>{{ child.name }}</span>
</el-menu-item>
</el-submenu>
<!-- 没有子菜单 -->
<el-menu-item v-else :key="menu.id" :index="menu.code">
<i :class="menu.icon || 'el-icon-document'"></i>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-main class="app-main">
<router-view />
</el-main>
</el-container>
</el-container>
<!-- 移动端侧边栏抽屉 -->
<el-drawer
:visible.sync="drawerVisible"
:with-header="false"
:size="drawerWidth"
direction="ltr"
class="mobile-drawer"
>
<div class="drawer-header">
<h3>菜单</h3>
<el-button type="text" @click="drawerVisible = false">
<i class="el-icon-close" style="font-size: 20px;"></i>
</el-button>
</div>
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleMobileSelect"
background-color="#fff"
text-color="#333"
active-text-color="#409EFF"
>
<template v-for="menu in menuList">
<!-- 有子菜单 -->
<el-submenu v-if="menu.children && menu.children.length > 0" :key="menu.id" :index="menu.code">
<template slot="title">
<i :class="menu.icon || 'el-icon-menu'"></i>
<span>{{ menu.name }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.id"
:index="child.code"
>
<i :class="child.icon || 'el-icon-document'"></i>
<span>{{ child.name }}</span>
</el-menu-item>
</el-submenu>
<!-- 没有子菜单 -->
<el-menu-item v-else :key="menu.id" :index="menu.code">
<i :class="menu.icon || 'el-icon-document'"></i>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
</el-menu>
</el-drawer>
</div>
</template>
<script>
import { getUserInfo, getUserMenus, clearAuth } from '@/utils/auth'
export default {
name: 'MainLayout',
data() {
return {
activeIndex: '',
isMobile: false,
drawerVisible: false,
drawerWidth: '70%',
username: '',
menuList: []
}
},
mounted() {
this.checkDevice()
window.addEventListener('resize', this.checkDevice)
this.loadUserInfo()
this.loadMenus()
this.setActiveMenu()
},
beforeDestroy() {
window.removeEventListener('resize', this.checkDevice)
},
watch: {
'$route': 'setActiveMenu'
},
methods: {
checkDevice() {
const width = window.innerWidth
this.isMobile = width <= 768
//
if (width <= 375) {
this.drawerWidth = '80%'
} else if (width <= 768) {
this.drawerWidth = '70%'
}
},
loadUserInfo() {
const userInfo = getUserInfo()
if (userInfo) {
this.username = userInfo.username || '用户'
}
},
loadMenus() {
//
const menus = getUserMenus()
this.menuList = this.filterButtons(menus)
},
filterButtons(menus) {
//
return menus.filter(menu => menu.type !== 'button').map(menu => {
if (menu.children && menu.children.length > 0) {
return {
...menu,
children: this.filterButtons(menu.children)
}
}
return menu
})
},
setActiveMenu() {
//
const path = this.$route.path
const menu = this.findMenuByPath(this.menuList, path)
if (menu) {
this.activeIndex = menu.code
}
},
findMenuByPath(menus, path) {
for (const menu of menus) {
if (menu.path === path) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = this.findMenuByPath(menu.children, path)
if (found) {
return found
}
}
}
return null
},
handleSelect(key, keyPath) {
this.activeIndex = key
this.navigateToRoute(key)
},
handleMobileSelect(key, keyPath) {
this.activeIndex = key
this.drawerVisible = false
this.navigateToRoute(key)
},
navigateToRoute(key) {
//
const menu = this.findMenuByCode(this.menuList, key)
if (menu && menu.path && this.$route.path !== menu.path) {
this.$router.push(menu.path)
}
},
findMenuByCode(menus, code) {
for (const menu of menus) {
if (menu.code === code) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = this.findMenuByCode(menu.children, code)
if (found) {
return found
}
}
}
return null
},
handleUserCommand(command) {
switch (command) {
case 'profile':
this.$router.push('/user/profile')
break
case 'password':
this.$router.push('/user/profile?tab=password')
break
case 'logout':
this.logout()
break
}
},
logout() {
clearAuth()
this.$message.success('退出成功')
this.$router.push('/login')
}
}
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
/* 顶部导航栏样式 */
.app-header {
background-color: #333;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
}
.header-right {
display: flex;
align-items: center;
padding-right: 20px;
}
.user-info {
color: white;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
transition: all 0.3s ease;
}
.user-info:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.user-info .el-icon-arrow-down {
font-size: 12px;
}
/* 下拉菜单样式 */
.el-dropdown-menu {
padding: 8px 0;
}
.el-dropdown-menu__item {
padding: 8px 20px;
font-size: 14px;
}
.el-dropdown-menu__item:hover {
background-color: #f5f7fa;
}
.menu-toggle-btn {
padding: 0;
margin: 0;
}
.app-title {
margin: 0;
font-size: 18px;
}
/* 侧边栏样式 */
.app-aside {
background-color: #f0f2f5;
min-height: calc(100vh - 60px);
}
.el-menu-vertical-demo {
border-right: none;
}
/* 主内容区样式 */
.app-main {
background-color: #fff;
padding: 20px;
min-height: calc(100vh - 60px);
}
/* 抽屉头部样式 */
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
}
.drawer-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
</style>

View File

@ -1,103 +1,147 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import { isAuthenticated } from '@/utils/auth'
Vue.use(VueRouter)
const routes = [
// 登录路由 - 使用 AuthLayout
{
path: '/login',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: '',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { public: true }
}
]
},
// 系统路由 - 使用 MainLayout
{
path: '/',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue')
component: () => import('@/layouts/MainLayout.vue'),
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue')
},
// 公寓管理
{
path: '/apartment/list',
name: 'ApartmentList',
component: () => import('@/views/apartment/List.vue')
},
{
path: '/apartment/add',
name: 'ApartmentAdd',
component: () => import('@/views/apartment/Add.vue')
},
{
path: '/apartment/edit/:id',
name: 'ApartmentEdit',
component: () => import('@/views/apartment/Edit.vue')
},
// 房间管理
{
path: '/room/list',
name: 'RoomList',
component: () => import('@/views/room/List.vue')
},
{
path: '/room/add',
name: 'RoomAdd',
component: () => import('@/views/room/Add.vue')
},
{
path: '/room/edit/:id',
name: 'RoomEdit',
component: () => import('@/views/room/Edit.vue')
},
// 租房管理
{
path: '/rental/list',
name: 'RentalList',
component: () => import('@/views/rental/List.vue')
},
{
path: '/rental/add',
name: 'RentalAdd',
component: () => import('@/views/rental/Add.vue')
},
{
path: '/rental/edit/:id',
name: 'RentalEdit',
component: () => import('@/views/rental/Edit.vue')
},
{
path: '/rental/detail/:id',
name: 'RentalDetail',
component: () => import('@/views/rental/Detail.vue')
},
{
path: '/rental/archive',
name: 'RentalArchive',
component: () => import('@/views/rental/RentalArchive.vue')
},
{
path: '/water/archive',
name: 'WaterArchive',
component: () => import('@/views/rental/WaterArchive.vue')
},
// 统计分析
{
path: '/statistics/rent',
name: 'RentStatistics',
component: () => import('@/views/statistics/Rent.vue')
},
{
path: '/statistics/room',
name: 'RoomStatistics',
component: () => import('@/views/statistics/House.vue')
},
// 用户管理
{
path: '/user/list',
name: 'UserList',
component: () => import('@/views/user/List.vue')
},
{
path: '/user/profile',
name: 'UserProfile',
component: () => import('@/views/user/Profile.vue')
},
// 日志管理
{
path: '/log/operation',
name: 'OperationLog',
component: () => import('@/views/log/OperationLog.vue')
},
{
path: '/log/login',
name: 'LoginLog',
component: () => import('@/views/log/LoginLog.vue')
},
// 角色管理
{
path: '/role/list',
name: 'RoleList',
component: () => import('@/views/role/List.vue')
},
// 菜单管理
{
path: '/menu/list',
name: 'MenuList',
component: () => import('@/views/menu/List.vue')
}
]
},
// 区域管理
// 404 页面
{
path: '/region/list',
name: 'RegionList',
component: () => import('../views/region/List.vue')
},
{
path: '/region/add',
name: 'RegionAdd',
component: () => import('../views/region/Add.vue')
},
{
path: '/region/edit/:id',
name: 'RegionEdit',
component: () => import('../views/region/Edit.vue')
},
// 公寓管理
{
path: '/apartment/list',
name: 'ApartmentList',
component: () => import('../views/apartment/List.vue')
},
{
path: '/apartment/add',
name: 'ApartmentAdd',
component: () => import('../views/apartment/Add.vue')
},
{
path: '/apartment/edit/:id',
name: 'ApartmentEdit',
component: () => import('../views/apartment/Edit.vue')
},
// 房间管理
{
path: '/room/list',
name: 'RoomList',
component: () => import('../views/room/List.vue')
},
{
path: '/room/add',
name: 'RoomAdd',
component: () => import('../views/room/Add.vue')
},
{
path: '/room/edit/:id',
name: 'RoomEdit',
component: () => import('../views/room/Edit.vue')
},
// 租房管理
{
path: '/rental/list',
name: 'RentalList',
component: () => import('../views/rental/List.vue')
},
{
path: '/rental/add',
name: 'RentalAdd',
component: () => import('../views/rental/Add.vue')
},
{
path: '/rental/edit/:id',
name: 'RentalEdit',
component: () => import('../views/rental/Edit.vue')
},
{
path: '/rental/detail/:id',
name: 'RentalDetail',
component: () => import('../views/rental/Detail.vue')
},
{
path: '/rental/archive',
name: 'RentalArchive',
component: () => import('../views/rental/RentalArchive.vue')
},
{
path: '/water/archive',
name: 'WaterArchive',
component: () => import('../views/rental/WaterArchive.vue')
},
// 统计分析
{
path: '/statistics/rent',
name: 'RentStatistics',
component: () => import('../views/statistics/Rent.vue')
},
{
path: '/statistics/room',
name: 'RoomStatistics',
component: () => import('../views/statistics/House.vue')
path: '*',
redirect: '/'
}
]
@ -107,4 +151,20 @@ const router = new VueRouter({
routes
})
export default router
// 路由守卫
router.beforeEach((to, from, next) => {
// 检查路由是否需要认证
const isPublicRoute = to.matched.some(record => record.meta.public)
if (!isPublicRoute && !isAuthenticated()) {
// 未登录且访问需要认证的页面,跳转到登录页
next('/login')
} else if (to.path === '/login' && isAuthenticated()) {
// 已登录但访问登录页,跳转到首页
next('/')
} else {
next()
}
})
export default router

83
src/utils/auth.js Normal file
View File

@ -0,0 +1,83 @@
// Token 过期时间2小时 = 7200000毫秒
const TOKEN_EXPIRE_TIME = 2 * 60 * 60 * 1000
const TOKEN_KEY = 'rentease_token'
const USER_INFO_KEY = 'rentease_user_info'
const USER_MENUS_KEY = 'rentease_user_menus'
const TOKEN_TIME_KEY = 'rentease_token_time'
// 设置登录信息
export function setAuth(token, userInfo, menus) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo))
localStorage.setItem(USER_MENUS_KEY, JSON.stringify(menus || []))
localStorage.setItem(TOKEN_TIME_KEY, Date.now().toString())
}
// 获取 Token
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
// 获取用户信息
export function getUserInfo() {
const userInfoStr = localStorage.getItem(USER_INFO_KEY)
try {
return userInfoStr ? JSON.parse(userInfoStr) : null
} catch {
return null
}
}
// 检查 Token 是否过期
export function isTokenExpired() {
const tokenTime = localStorage.getItem(TOKEN_TIME_KEY)
if (!tokenTime) return true
const elapsed = Date.now() - parseInt(tokenTime)
return elapsed > TOKEN_EXPIRE_TIME
}
// 检查是否已登录
export function isAuthenticated() {
const token = getToken()
if (!token) return false
if (isTokenExpired()) {
clearAuth()
return false
}
return true
}
// 清除登录信息
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
localStorage.removeItem(USER_MENUS_KEY)
localStorage.removeItem(TOKEN_TIME_KEY)
}
// 获取用户菜单
export function getUserMenus() {
const menusStr = localStorage.getItem(USER_MENUS_KEY)
try {
return menusStr ? JSON.parse(menusStr) : []
} catch {
return []
}
}
// 设置用户菜单
export function setUserMenus(menus) {
localStorage.setItem(USER_MENUS_KEY, JSON.stringify(menus || []))
}
// 获取剩余有效时间(毫秒)
export function getTokenRemainingTime() {
const tokenTime = localStorage.getItem(TOKEN_TIME_KEY)
if (!tokenTime) return 0
const elapsed = Date.now() - parseInt(tokenTime)
const remaining = TOKEN_EXPIRE_TIME - elapsed
return remaining > 0 ? remaining : 0
}

45
src/utils/permission.js Normal file
View File

@ -0,0 +1,45 @@
import { getUserMenus } from './auth'
/**
* 检查用户是否有权限
* @param {string} permission - 权限代码
* @returns {boolean} - 是否有权限
*/
export function hasPermission(permission) {
const menus = getUserMenus()
return checkPermission(menus, permission)
}
/**
* 递归检查权限
* @param {Array} menus - 菜单列表
* @param {string} permission - 权限代码
* @returns {boolean} - 是否有权限
*/
function checkPermission(menus, permission) {
for (const menu of menus) {
if (menu.code === permission) {
return true
}
if (menu.children && menu.children.length > 0) {
if (checkPermission(menu.children, permission)) {
return true
}
}
}
return false
}
/**
* 检查用户是否为管理员
* @returns {boolean} - 是否为管理员
*/
export function isAdmin() {
const userInfo = localStorage.getItem('rentease_user_info')
try {
const user = userInfo ? JSON.parse(userInfo) : null
return user && (user.isSuperAdmin === 1 || (user.role && user.role.code === 'admin'))
} catch {
return false
}
}

View File

@ -2,21 +2,10 @@
<div class="dashboard">
<el-card class="welcome-card">
<h2>欢迎使用租房管理系统</h2>
<p>本系统提供区域管理房源管理租房管理和统计分析等功能</p>
<p>本系统提供房源管理租房管理和统计分析等功能</p>
</el-card>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :xs="12" :sm="12" :md="8">
<el-card class="stat-card">
<div class="stat-item">
<i class="el-icon-location stat-icon"></i>
<div class="stat-info">
<div class="stat-value">{{ regionCount }}</div>
<div class="stat-label">区域数量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="8">
<el-card class="stat-card">
<div class="stat-item">
@ -122,18 +111,17 @@
<el-card class="stats-table-card">
<template slot="header">
<div class="card-header">
<span>区域公寓房间状态分布</span>
<span>公寓房间状态分布</span>
</div>
</template>
<div class="table-wrapper">
<el-table
:data="regionApartmentHouseStats"
:data="apartmentHouseStats"
style="width: 100%"
show-summary
:summary-method="getSummary"
class="stats-table"
>
<el-table-column prop="region" label="区域" min-width="100"></el-table-column>
<el-table-column prop="apartment" label="公寓" min-width="100"></el-table-column>
<el-table-column prop="empty" label="空房" width="70"></el-table-column>
<el-table-column prop="reserved" label="预订" width="70"></el-table-column>
@ -150,13 +138,12 @@
</template>
<script>
import { regionApi, apartmentApi, roomApi, tenantApi, contractApi, statisticsApi } from '../api/api'
import { apartmentApi, roomApi, tenantApi, contractApi, statisticsApi } from '../api/api'
export default {
name: 'Dashboard',
data() {
return {
regionCount: 0,
apartmentCount: 0,
roomCount: 0,
emptyRoomCount: 0,
@ -166,7 +153,7 @@ export default {
expiredRoomCount: 0,
collectedRentAmount: 0,
collectedWaterAmount: 0,
regionApartmentHouseStats: []
apartmentHouseStats: []
}
},
mounted() {
@ -175,17 +162,13 @@ export default {
methods: {
async loadData() {
try {
//
const [dashboardStatsResponse, regionApartmentHouseStatsResponse] = await Promise.all([
statisticsApi.getDashboardStats(),
statisticsApi.getRegionApartmentHouseStats()
])
//
const dashboardStatsResponse = await statisticsApi.getDashboardStats()
//
const dashboardStats = dashboardStatsResponse.data || dashboardStatsResponse
//
this.regionCount = dashboardStats.regionCount
this.apartmentCount = dashboardStats.apartmentCount
this.roomCount = dashboardStats.roomCount
this.emptyRoomCount = dashboardStats.emptyRoomCount
@ -195,7 +178,10 @@ export default {
this.expiredRoomCount = dashboardStats.expiredRoomCount
this.collectedRentAmount = dashboardStats.collectedRentAmount
this.collectedWaterAmount = dashboardStats.collectedWaterAmount
this.regionApartmentHouseStats = regionApartmentHouseStatsResponse
//
const apartmentRoomStatusResponse = await statisticsApi.getApartmentRoomStatusStats()
this.apartmentHouseStats = apartmentRoomStatusResponse.data || apartmentRoomStatusResponse
} catch (error) {
this.$message.error('加载数据失败')
}
@ -214,10 +200,6 @@ export default {
sums[index] = '合计';
return;
}
if (index === 1) {
sums[index] = '';
return;
}
const values = data.map(item => Number(item[column.property]) || 0);
if (values.every(value => !isNaN(value))) {
sums[index] = values.reduce((prev, curr) => {

View File

@ -7,11 +7,7 @@
</div>
</template>
<el-form :model="apartmentForm" :rules="rules" ref="apartmentForm" label-width="100px" class="form-content">
<el-form-item label="区域" prop="regionId">
<el-select v-model="apartmentForm.regionId" placeholder="请选择区域" style="width: 100%">
<el-option v-for="region in regions" :key="region.id" :label="region.name" :value="region.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="公寓名称" prop="name">
<el-input v-model="apartmentForm.name" placeholder="请输入公寓名称"></el-input>
</el-form-item>
@ -29,22 +25,17 @@
</template>
<script>
import { apartmentApi, regionApi } from '../../api/api'
import { apartmentApi } from '../../api/api'
export default {
name: 'ApartmentAdd',
data() {
return {
apartmentForm: {
regionId: '',
name: '',
address: ''
},
regions: [],
rules: {
regionId: [
{ required: true, message: '请选择区域', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入公寓名称', trigger: 'blur' }
]
@ -52,17 +43,9 @@ export default {
}
},
mounted() {
this.loadRegions()
},
methods: {
async loadRegions() {
try {
const response = await regionApi.list()
this.regions = response
} catch (error) {
this.$message.error('加载区域数据失败')
}
},
async submitForm() {
this.$refs.apartmentForm.validate(async (valid) => {
if (valid) {

View File

@ -7,11 +7,7 @@
</div>
</template>
<el-form :model="apartmentForm" :rules="rules" ref="apartmentForm" label-width="100px" class="form-content">
<el-form-item label="区域" prop="regionId">
<el-select v-model="apartmentForm.regionId" placeholder="请选择区域" style="width: 100%">
<el-option v-for="region in regions" :key="region.id" :label="region.name" :value="region.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="公寓名称" prop="name">
<el-input v-model="apartmentForm.name" placeholder="请输入公寓名称"></el-input>
</el-form-item>
@ -29,7 +25,7 @@
</template>
<script>
import { apartmentApi, regionApi } from '../../api/api'
import { apartmentApi } from '../../api/api'
export default {
name: 'ApartmentEdit',
@ -37,15 +33,10 @@ export default {
return {
apartmentForm: {
id: '',
regionId: '',
name: '',
address: ''
},
regions: [],
rules: {
regionId: [
{ required: true, message: '请选择区域', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入公寓名称', trigger: 'blur' }
]
@ -53,18 +44,10 @@ export default {
}
},
mounted() {
this.loadRegions()
this.loadApartmentData()
},
methods: {
async loadRegions() {
try {
const response = await regionApi.list()
this.regions = response
} catch (error) {
this.$message.error('加载区域数据失败')
}
},
async loadApartmentData() {
try {
const id = this.$route.params.id

View File

@ -4,18 +4,13 @@
<template slot="header">
<div class="card-header">
<span>公寓管理</span>
<el-button type="primary" size="small" @click="handleAdd">添加公寓</el-button>
<el-button type="primary" size="small" @click="handleAdd" v-if="hasPermission('apartment:add')">添加公寓</el-button>
</div>
</template>
<!-- 搜索表单 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="区域">
<el-select v-model="searchForm.regionId" placeholder="请选择区域" style="width: 100%">
<el-option label="全部" value=""></el-option>
<el-option v-for="region in regions" :key="region.id" :label="region.name" :value="region.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="公寓名称">
<el-input v-model="searchForm.name" placeholder="请输入公寓名称"></el-input>
</el-form-item>
@ -29,14 +24,13 @@
<div class="table-wrapper hidden-xs-only">
<el-table :data="apartments" style="width: 100%" v-loading="isLoading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="regionName" label="区域" min-width="100"></el-table-column>
<el-table-column prop="name" label="公寓名称" min-width="120"></el-table-column>
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180"></el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="handleEdit(scope.row.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
<el-button size="mini" type="primary" @click="handleEdit(scope.row.id)" v-if="hasPermission('apartment:edit')">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row.id)" v-if="hasPermission('apartment:delete')">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -50,10 +44,7 @@
<span class="mobile-card-id">ID: {{ item.id }}</span>
</div>
<div class="mobile-card-body">
<div class="mobile-card-item">
<span class="mobile-card-label">区域:</span>
<span class="mobile-card-value">{{ item.regionName || '-' }}</span>
</div>
<div class="mobile-card-item">
<span class="mobile-card-label">地址:</span>
<span class="mobile-card-value">{{ item.address || '-' }}</span>
@ -64,8 +55,8 @@
</div>
</div>
<div class="mobile-card-footer">
<el-button size="mini" type="primary" @click="handleEdit(item.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(item.id)">删除</el-button>
<el-button size="mini" type="primary" @click="handleEdit(item.id)" v-if="hasPermission('apartment:edit')">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(item.id)" v-if="hasPermission('apartment:delete')">删除</el-button>
</div>
</div>
</div>
@ -86,17 +77,16 @@
</template>
<script>
import { apartmentApi, regionApi } from '../../api/api'
import { apartmentApi } from '../../api/api'
import { hasPermission } from '../../utils/permission'
export default {
name: 'ApartmentList',
data() {
return {
regions: [],
apartments: [],
total: 0,
searchForm: {
regionId: '',
name: ''
},
currentPage: 1,
@ -119,19 +109,15 @@ export default {
window.removeEventListener('resize', this.checkDevice)
},
methods: {
hasPermission,
checkDevice() {
this.isMobile = window.innerWidth <= 768
},
async loadData() {
this.isLoading = true
try {
//
const regionsResponse = await regionApi.getAll()
this.regions = regionsResponse
//
const params = {
regionId: this.searchForm.regionId,
name: this.searchForm.name,
page: this.currentPage,
pageSize: this.pageSize
@ -139,13 +125,7 @@ export default {
//
const apartmentsResponse = await apartmentApi.getAll(params)
this.apartments = apartmentsResponse.data.map(apartment => {
const region = regionsResponse.find(r => r.id == apartment.regionId)
return {
...apartment,
regionName: region ? region.name : ''
}
})
this.apartments = apartmentsResponse.data
this.total = apartmentsResponse.total
} catch (error) {
this.$message.error('加载数据失败')
@ -182,7 +162,6 @@ export default {
},
resetSearch() {
this.searchForm = {
regionId: '',
name: ''
}
this.currentPage = 1

106
src/views/auth/Login.vue Normal file
View File

@ -0,0 +1,106 @@
<template>
<div class="login-page">
<el-form
ref="loginForm"
:model="loginForm"
:rules="loginRules"
class="login-form"
@keyup.enter.native="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
prefix-icon="el-icon-user"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
prefix-icon="el-icon-lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
:loading="loading"
type="primary"
size="large"
class="login-btn"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { login } from '@/api/auth'
import { setAuth } from '@/utils/auth'
export default {
name: 'Login',
data() {
return {
loginForm: {
username: '',
password: ''
},
loginRules: {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
},
loading: false
}
},
methods: {
handleLogin() {
this.$refs.loginForm.validate(async valid => {
if (!valid) return
this.loading = true
try {
const res = await login(this.loginForm)
if (res.code === 200) {
setAuth(res.data.token, res.data.userInfo, res.data.menus)
this.$message.success('登录成功')
this.$router.push('/')
} else {
this.$message.error(res.message || '登录失败')
}
} catch (error) {
this.$message.error(error.message || '登录失败')
} finally {
this.loading = false
}
})
}
}
}
</script>
<style scoped>
.login-page {
width: 100%;
}
.login-form {
width: 100%;
}
.login-btn {
width: 100%;
margin-top: 10px;
}
</style>

212
src/views/log/LoginLog.vue Normal file
View File

@ -0,0 +1,212 @@
<template>
<div class="login-log-page">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>登录日志</span>
<el-button
style="float: right; margin-left: 10px;"
type="danger"
size="small"
@click="handleClear"
>
清空日志
</el-button>
</div>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="登录类型">
<el-select v-model="searchForm.loginType" placeholder="请选择类型" clearable>
<el-option label="登录" value="login" />
<el-option label="登出" value="logout" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="fail" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="searchForm.timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="yyyy-MM-dd HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 日志列表 -->
<el-table v-loading="loading" :data="logList" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="loginType" label="类型" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.loginType === 'login'" type="primary">登录</el-tag>
<el-tag v-else type="info">登出</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP地址" width="150" />
<el-table-column prop="userAgent" label="浏览器信息" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 'success'" type="success">成功</el-tag>
<el-tag v-else type="danger">失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="信息" show-overflow-tooltip />
<el-table-column prop="createTime" label="时间" width="180">
<template slot-scope="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next"
:total="total"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:current-page="currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
<script>
import { getLoginLogs, clearLoginLogs } from '@/api/log'
export default {
name: 'LoginLog',
data() {
return {
loading: false,
logList: [],
total: 0,
currentPage: 1,
pageSize: 20,
searchForm: {
username: '',
loginType: '',
status: '',
timeRange: []
}
}
},
mounted() {
this.fetchLogList()
},
methods: {
async fetchLogList() {
this.loading = true
try {
const params = {
page: this.currentPage,
pageSize: this.pageSize,
username: this.searchForm.username,
loginType: this.searchForm.loginType,
status: this.searchForm.status
}
if (this.searchForm.timeRange && this.searchForm.timeRange.length === 2) {
params.startTime = this.searchForm.timeRange[0]
params.endTime = this.searchForm.timeRange[1]
}
const res = await getLoginLogs(params)
if (res.code === 200) {
this.logList = res.data.list
this.total = res.data.total
} else {
this.$message.error(res.message || '获取日志失败')
}
} catch (error) {
this.$message.error(error.message || '获取日志失败')
} finally {
this.loading = false
}
},
handleSearch() {
this.currentPage = 1
this.fetchLogList()
},
handleReset() {
this.searchForm = {
username: '',
loginType: '',
status: '',
timeRange: []
}
this.currentPage = 1
this.fetchLogList()
},
handleSizeChange(val) {
this.pageSize = val
this.fetchLogList()
},
handleCurrentChange(val) {
this.currentPage = val
this.fetchLogList()
},
handleClear() {
this.$confirm('确定要清空登录日志吗?此操作不可恢复!', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const params = {}
if (this.searchForm.timeRange && this.searchForm.timeRange.length === 2) {
params.startTime = this.searchForm.timeRange[0]
params.endTime = this.searchForm.timeRange[1]
}
const res = await clearLoginLogs(params)
if (res.code === 200) {
this.$message.success('清空成功')
this.fetchLogList()
} else {
this.$message.error(res.message || '清空失败')
}
} catch (error) {
this.$message.error(error.message || '清空失败')
}
}).catch(() => {})
},
formatDate(date) {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN')
}
}
}
</script>
<style scoped>
.login-log-page {
padding: 20px;
}
.search-form {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
</style>

View File

@ -0,0 +1,225 @@
<template>
<div class="operation-log-page">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>操作日志</span>
<el-button
style="float: right; margin-left: 10px;"
type="danger"
size="small"
@click="handleClear"
>
清空日志
</el-button>
</div>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="操作模块">
<el-select v-model="searchForm.module" placeholder="请选择模块" clearable>
<el-option label="区域管理" value="区域管理" />
<el-option label="公寓管理" value="公寓管理" />
<el-option label="房间管理" value="房间管理" />
<el-option label="租房管理" value="租房管理" />
<el-option label="水费管理" value="水费管理" />
<el-option label="电费管理" value="电费管理" />
<el-option label="用户管理" value="用户管理" />
</el-select>
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="searchForm.action" placeholder="请选择类型" clearable>
<el-option label="查询" value="查询" />
<el-option label="新增" value="新增" />
<el-option label="修改" value="修改" />
<el-option label="删除" value="删除" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="fail" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="searchForm.timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="yyyy-MM-dd HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 日志列表 -->
<el-table v-loading="loading" :data="logList" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="操作用户" width="120" />
<el-table-column prop="module" label="操作模块" width="120" />
<el-table-column prop="action" label="操作类型" width="100" />
<el-table-column prop="description" label="操作描述" show-overflow-tooltip />
<el-table-column prop="method" label="请求方法" width="100" />
<el-table-column prop="ip" label="IP地址" width="150" />
<el-table-column prop="status" label="状态" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 'success'" type="success">成功</el-tag>
<el-tag v-else type="danger">失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration" label="耗时(ms)" width="100" />
<el-table-column prop="createTime" label="操作时间" width="180">
<template slot-scope="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next"
:total="total"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:current-page="currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
<script>
import { getOperationLogs, clearOperationLogs } from '@/api/log'
export default {
name: 'OperationLog',
data() {
return {
loading: false,
logList: [],
total: 0,
currentPage: 1,
pageSize: 20,
searchForm: {
username: '',
module: '',
action: '',
status: '',
timeRange: []
}
}
},
mounted() {
this.fetchLogList()
},
methods: {
async fetchLogList() {
this.loading = true
try {
const params = {
page: this.currentPage,
pageSize: this.pageSize,
username: this.searchForm.username,
module: this.searchForm.module,
action: this.searchForm.action,
status: this.searchForm.status
}
if (this.searchForm.timeRange && this.searchForm.timeRange.length === 2) {
params.startTime = this.searchForm.timeRange[0]
params.endTime = this.searchForm.timeRange[1]
}
const res = await getOperationLogs(params)
if (res.code === 200) {
this.logList = res.data.list
this.total = res.data.total
} else {
this.$message.error(res.message || '获取日志失败')
}
} catch (error) {
this.$message.error(error.message || '获取日志失败')
} finally {
this.loading = false
}
},
handleSearch() {
this.currentPage = 1
this.fetchLogList()
},
handleReset() {
this.searchForm = {
username: '',
module: '',
action: '',
status: '',
timeRange: []
}
this.currentPage = 1
this.fetchLogList()
},
handleSizeChange(val) {
this.pageSize = val
this.fetchLogList()
},
handleCurrentChange(val) {
this.currentPage = val
this.fetchLogList()
},
handleClear() {
this.$confirm('确定要清空操作日志吗?此操作不可恢复!', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const params = {}
if (this.searchForm.timeRange && this.searchForm.timeRange.length === 2) {
params.startTime = this.searchForm.timeRange[0]
params.endTime = this.searchForm.timeRange[1]
}
const res = await clearOperationLogs(params)
if (res.code === 200) {
this.$message.success('清空成功')
this.fetchLogList()
} else {
this.$message.error(res.message || '清空失败')
}
} catch (error) {
this.$message.error(error.message || '清空失败')
}
}).catch(() => {})
},
formatDate(date) {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN')
}
}
}
</script>
<style scoped>
.operation-log-page {
padding: 20px;
}
.search-form {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
</style>

392
src/views/menu/List.vue Normal file
View File

@ -0,0 +1,392 @@
<template>
<div class="menu-list-page">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>菜单管理</span>
<el-button
v-if="hasPermission('menu:add')"
style="float: right;"
type="primary"
size="small"
@click="handleAdd"
>
添加菜单
</el-button>
</div>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="菜单名称">
<el-input v-model="searchForm.name" placeholder="请输入菜单名称" clearable />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="searchForm.type" placeholder="请选择类型" clearable>
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button v-if="hasPermission('menu:search')" type="primary" @click="handleSearch">查询</el-button>
<el-button v-if="hasPermission('menu:reset')" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 菜单树表格 -->
<el-table
v-loading="loading"
:data="menuTree"
border
style="width: 100%"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="name" label="菜单名称" width="200" />
<el-table-column prop="code" label="菜单编码" width="180" />
<el-table-column prop="type" label="类型" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.type === 'menu'" type="primary">菜单</el-tag>
<el-tag v-else type="success">按钮</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" width="200" />
<el-table-column prop="component" label="组件路径" width="200" />
<el-table-column prop="icon" label="图标" width="100">
<template slot-scope="scope">
<i v-if="scope.row.icon" :class="scope.row.icon" />
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="visible" label="显示" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.visible === 'show'" type="success">显示</el-tag>
<el-tag v-else type="info">隐藏</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 'active'" type="success">启用</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template slot-scope="scope">
<el-button
v-if="hasPermission('menu:add-child')"
type="text"
size="small"
@click="handleAddChild(scope.row)"
>
添加子菜单
</el-button>
<el-button
v-if="hasPermission('menu:edit')"
type="text"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
v-if="hasPermission('menu:delete')"
type="text"
size="small"
style="color: #f56c6c;"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 添加/编辑菜单对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
>
<el-form
ref="menuForm"
:model="menuForm"
:rules="menuRules"
label-width="100px"
>
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="menuForm.parentId"
:data="menuOptions"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择上级菜单(不选则为顶级菜单)"
clearable
check-strictly
/>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
<el-input v-model="menuForm.name" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单编码" prop="code">
<el-input v-model="menuForm.code" placeholder="请输入菜单编码" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-radio-group v-model="menuForm.type">
<el-radio label="menu">菜单</el-radio>
<el-radio label="button">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="menuForm.type === 'menu'" label="路由路径" prop="path">
<el-input v-model="menuForm.path" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item v-if="menuForm.type === 'menu'" label="组件路径" prop="component">
<el-input v-model="menuForm.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input v-model="menuForm.icon" placeholder="请输入图标class">
<template slot="prepend">
<i v-if="menuForm.icon" :class="menuForm.icon" />
</template>
</el-input>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="menuForm.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="是否显示" prop="visible">
<el-radio-group v-model="menuForm.visible">
<el-radio label="show">显示</el-radio>
<el-radio label="hide">隐藏</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="menuForm.status">
<el-radio label="active">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getMenuTree, getMenuList, createMenu, updateMenu, deleteMenu } from '@/api/menu'
import { hasPermission } from '@/utils/permission'
export default {
name: 'MenuList',
data() {
return {
loading: false,
menuTree: [],
menuOptions: [],
searchForm: {
name: '',
type: '',
status: ''
},
dialogVisible: false,
dialogTitle: '添加菜单',
isEdit: false,
submitLoading: false,
menuForm: {
id: null,
parentId: null,
name: '',
code: '',
type: 'menu',
path: '',
component: '',
icon: '',
sort: 0,
visible: 'show',
status: 'active'
},
menuRules: {
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入菜单编码', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择类型', trigger: 'change' }
],
path: [
{ required: true, message: '请输入路由路径', trigger: 'blur' }
]
}
}
},
mounted() {
this.fetchMenuTree()
},
methods: {
hasPermission,
async fetchMenuTree() {
this.loading = true
try {
const params = { ...this.searchForm }
const res = await getMenuTree(params)
if (res.code === 200) {
this.menuTree = res.data
this.menuOptions = this.buildMenuOptions(res.data)
} else {
this.$message.error(res.message || '获取菜单列表失败')
}
} catch (error) {
this.$message.error(error.message || '获取菜单列表失败')
} finally {
this.loading = false
}
},
handleSearch() {
this.fetchMenuTree()
},
handleReset() {
this.searchForm = {
name: '',
type: '',
status: ''
}
this.fetchMenuTree()
},
handleAdd() {
this.isEdit = false
this.dialogTitle = '添加菜单'
this.menuForm = {
id: null,
parentId: null,
name: '',
code: '',
type: 'menu',
path: '',
component: '',
icon: '',
sort: 0,
visible: 'show',
status: 'active'
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.menuForm.clearValidate()
})
},
handleAddChild(row) {
this.isEdit = false
this.dialogTitle = '添加子菜单'
this.menuForm = {
id: null,
parentId: row.id,
name: '',
code: '',
type: 'menu',
path: '',
component: '',
icon: '',
sort: 0,
visible: 'show',
status: 'active'
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.menuForm.clearValidate()
})
},
handleEdit(row) {
this.isEdit = true
this.dialogTitle = '编辑菜单'
this.menuForm = {
id: row.id,
parentId: row.parentId,
name: row.name,
code: row.code,
type: row.type,
path: row.path,
component: row.component,
icon: row.icon,
sort: row.sort,
visible: row.visible,
status: row.status
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.menuForm.clearValidate()
})
},
handleSubmit() {
this.$refs.menuForm.validate(async valid => {
if (!valid) return
this.submitLoading = true
try {
let res
if (this.isEdit) {
res = await updateMenu(this.menuForm.id, this.menuForm)
} else {
res = await createMenu(this.menuForm)
}
if (res.code === 200) {
this.$message.success(this.isEdit ? '更新成功' : '添加成功')
this.dialogVisible = false
this.fetchMenuTree()
} else {
this.$message.error(res.message || (this.isEdit ? '更新失败' : '添加失败'))
}
} catch (error) {
this.$message.error(error.message || (this.isEdit ? '更新失败' : '添加失败'))
} finally {
this.submitLoading = false
}
})
},
handleDelete(row) {
this.$confirm(`确定要删除菜单 "${row.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteMenu(row.id)
if (res.code === 200) {
this.$message.success('删除成功')
this.fetchMenuTree()
} else {
this.$message.error(res.message || '删除失败')
}
} catch (error) {
this.$message.error(error.message || '删除失败')
}
}).catch(() => {})
},
buildMenuOptions(menus, parentId = null) {
return menus
.filter(menu => menu.parentId === parentId)
.map(menu => ({
id: menu.id,
name: menu.name,
children: this.buildMenuOptions(menus, menu.id)
}))
}
}
}
</script>
<style scoped>
.menu-list-page {
padding: 20px;
}
.search-form {
margin-bottom: 20px;
}
</style>

View File

@ -1,114 +0,0 @@
<template>
<div class="region-add">
<el-card>
<template slot="header">
<div class="card-header">
<span>添加区域</span>
</div>
</template>
<el-form :model="regionForm" :rules="rules" ref="regionForm" label-width="100px" class="form-content">
<el-form-item label="区域名称" prop="name">
<el-input v-model="regionForm.name" placeholder="请输入区域名称"></el-input>
</el-form-item>
<el-form-item label="区域描述" prop="description">
<el-input type="textarea" v-model="regionForm.description" placeholder="请输入区域描述" rows="4"></el-input>
</el-form-item>
<el-form-item class="form-actions">
<el-button type="primary" @click="submitForm">提交</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button @click="goBack">返回</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { regionApi } from '../../api/api'
export default {
name: 'RegionAdd',
data() {
return {
regionForm: {
name: '',
description: ''
},
rules: {
name: [
{ required: true, message: '请输入区域名称', trigger: 'blur' }
]
}
}
},
methods: {
async submitForm() {
this.$refs.regionForm.validate(async (valid) => {
if (valid) {
try {
await regionApi.create(this.regionForm)
this.$message.success('添加成功')
this.$router.push('/region/list')
} catch (error) {
this.$message.error('添加失败')
}
} else {
return false
}
})
},
resetForm() {
this.$refs.regionForm.resetFields()
},
goBack() {
this.$router.push('/region/list')
}
}
}
</script>
<style scoped>
.region-add {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-content {
max-width: 600px;
}
.form-actions {
margin-top: 30px;
}
/* 移动端适配 */
@media screen and (max-width: 768px) {
.form-content {
max-width: 100%;
}
.form-content .el-form-item__label {
float: none;
display: block;
text-align: left;
margin-bottom: 5px;
}
.form-content .el-form-item__content {
margin-left: 0 !important;
}
.form-actions {
text-align: center;
}
.form-actions .el-button {
margin-bottom: 10px;
}
}
</style>

View File

@ -1,129 +0,0 @@
<template>
<div class="region-edit">
<el-card>
<template slot="header">
<div class="card-header">
<span>编辑区域</span>
</div>
</template>
<el-form :model="regionForm" :rules="rules" ref="regionForm" label-width="100px" class="form-content">
<el-form-item label="区域名称" prop="name">
<el-input v-model="regionForm.name" placeholder="请输入区域名称"></el-input>
</el-form-item>
<el-form-item label="区域描述" prop="description">
<el-input type="textarea" v-model="regionForm.description" placeholder="请输入区域描述" rows="4"></el-input>
</el-form-item>
<el-form-item class="form-actions">
<el-button type="primary" @click="submitForm">提交</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button @click="goBack">返回</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { regionApi } from '../../api/api'
export default {
name: 'RegionEdit',
data() {
return {
regionForm: {
id: '',
name: '',
description: ''
},
rules: {
name: [
{ required: true, message: '请输入区域名称', trigger: 'blur' }
]
}
}
},
mounted() {
this.loadRegionData()
},
methods: {
async loadRegionData() {
try {
const id = this.$route.params.id
const region = await regionApi.getById(id)
if (region) {
this.regionForm = region
}
} catch (error) {
this.$message.error('加载区域数据失败')
}
},
async submitForm() {
this.$refs.regionForm.validate(async (valid) => {
if (valid) {
try {
await regionApi.update(this.regionForm.id, this.regionForm)
this.$message.success('编辑成功')
this.$router.push('/region/list')
} catch (error) {
this.$message.error('编辑失败')
}
} else {
return false
}
})
},
resetForm() {
this.loadRegionData()
},
goBack() {
this.$router.push('/region/list')
}
}
}
</script>
<style scoped>
.region-edit {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-content {
max-width: 600px;
}
.form-actions {
margin-top: 30px;
}
/* 移动端适配 */
@media screen and (max-width: 768px) {
.form-content {
max-width: 100%;
}
.form-content .el-form-item__label {
float: none;
display: block;
text-align: left;
margin-bottom: 5px;
}
.form-content .el-form-item__content {
margin-left: 0 !important;
}
.form-actions {
text-align: center;
}
.form-actions .el-button {
margin-bottom: 10px;
}
}
</style>

View File

@ -1,263 +0,0 @@
<template>
<div class="region-list">
<el-card>
<template slot="header">
<div class="card-header">
<span>区域列表</span>
<el-button type="primary" size="small" @click="handleAdd">添加区域</el-button>
</div>
</template>
<!-- PC端表格 -->
<div class="table-wrapper hidden-xs-only">
<el-table :data="regions" style="width: 100%" v-loading="isLoading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="区域名称" min-width="120"></el-table-column>
<el-table-column prop="description" label="区域描述" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180"></el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="handleEdit(scope.row.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 移动端卡片列表 -->
<div class="mobile-list hidden-sm-and-up">
<div v-for="item in regions" :key="item.id" class="mobile-card">
<div class="mobile-card-header">
<span class="mobile-card-title">{{ item.name }}</span>
<span class="mobile-card-id">ID: {{ item.id }}</span>
</div>
<div class="mobile-card-body">
<div class="mobile-card-item">
<span class="mobile-card-label">描述:</span>
<span class="mobile-card-value">{{ item.description || '-' }}</span>
</div>
<div class="mobile-card-item">
<span class="mobile-card-label">创建时间:</span>
<span class="mobile-card-value">{{ item.createTime }}</span>
</div>
</div>
<div class="mobile-card-footer">
<el-button size="mini" type="primary" @click="handleEdit(item.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(item.id)">删除</el-button>
</div>
</div>
</div>
<div class="pagination-wrapper">
<el-pagination
:layout="paginationLayout"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="currentPage"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
></el-pagination>
</div>
</el-card>
</div>
</template>
<script>
import { regionApi } from '../../api/api'
export default {
name: 'RegionList',
data() {
return {
regions: [],
total: 0,
currentPage: 1,
pageSize: 10,
isLoading: false,
isMobile: false
}
},
computed: {
paginationLayout() {
return this.isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'
}
},
mounted() {
this.checkDevice()
window.addEventListener('resize', this.checkDevice)
this.loadRegions()
},
beforeDestroy() {
window.removeEventListener('resize', this.checkDevice)
},
methods: {
checkDevice() {
this.isMobile = window.innerWidth <= 768
},
async loadRegions() {
this.isLoading = true
try {
const params = {
page: this.currentPage,
pageSize: this.pageSize
}
const response = await regionApi.getAll(params)
this.regions = response.data || response
this.total = response.total || 0
} catch (error) {
this.$message.error('加载区域数据失败')
} finally {
this.isLoading = false
}
},
handleAdd() {
this.$router.push('/region/add')
},
handleEdit(id) {
this.$router.push(`/region/edit/${id}`)
},
async handleDelete(id) {
this.$confirm('确定要删除这个区域吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await regionApi.delete(id)
this.$message.success('删除成功')
this.loadRegions()
} catch (error) {
this.$message.error('删除失败')
}
}).catch(() => {
//
})
},
handleCurrentChange(val) {
this.currentPage = val
this.loadRegions()
},
handleSizeChange(val) {
this.pageSize = val
this.currentPage = 1
this.loadRegions()
}
}
}
</script>
<style scoped>
.region-list {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-wrapper {
overflow-x: auto;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 移动端卡片列表样式 */
.mobile-list {
display: none;
}
.mobile-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 15px;
margin-bottom: 10px;
}
.mobile-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.mobile-card-title {
font-size: 16px;
font-weight: bold;
color: #303133;
}
.mobile-card-id {
font-size: 12px;
color: #909399;
}
.mobile-card-body {
margin-bottom: 10px;
}
.mobile-card-item {
display: flex;
margin-bottom: 8px;
font-size: 14px;
}
.mobile-card-label {
color: #909399;
min-width: 70px;
flex-shrink: 0;
}
.mobile-card-value {
color: #606266;
flex: 1;
word-break: break-all;
}
.mobile-card-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 10px;
border-top: 1px solid #ebeef5;
}
/* 响应式显示控制 */
@media screen and (max-width: 768px) {
.hidden-xs-only {
display: none;
}
.mobile-list {
display: block;
}
.pagination-wrapper {
justify-content: center;
}
.card-header {
flex-wrap: wrap;
gap: 10px;
}
.card-header span {
font-size: 16px;
}
}
@media screen and (min-width: 769px) {
.hidden-sm-and-up {
display: none;
}
}
</style>

View File

@ -76,6 +76,7 @@
<script>
import { rentalApi, roomApi, apartmentApi } from '../../api/api'
import { getUserInfo } from '../../utils/auth'
export default {
name: 'RentalAdd',
@ -124,6 +125,7 @@ export default {
this.rentalForm.startDate = new Date()
this.updateEndDate()
this.loadData()
this.setDefaultTenantName()
},
methods: {
async loadData() {
@ -197,6 +199,12 @@ export default {
endDate.setMonth(endDate.getMonth() + this.leaseMonths)
this.rentalForm.endDate = endDate
}
},
setDefaultTenantName() {
const userInfo = getUserInfo()
if (userInfo && userInfo.nickname) {
this.rentalForm.tenantName = userInfo.nickname
}
}
}
}

View File

@ -5,14 +5,14 @@
<div class="card-header">
<span>房屋详情</span>
<div class="action-buttons">
<el-button v-if="room.status === 'empty' || room.status === 'reserved'" type="primary" size="small" @click="handleRent">租房</el-button>
<el-button v-if="room.status === 'empty'" type="warning" size="small" @click="handleReserve">预订</el-button>
<el-button v-if="room.status === 'reserved'" type="warning" size="small" @click="handleCancelReserve">取消预订</el-button>
<el-button v-if="room.status === 'rented'" type="warning" size="small" @click="handleCheckout">退房</el-button>
<el-button v-if="!room.otherStatus || room.otherStatus === ''" type="info" size="small" @click="handleCleaning">打扫</el-button>
<el-button v-if="!room.otherStatus || room.otherStatus === ''" type="danger" size="small" @click="handleMaintenance">维修</el-button>
<el-button v-if="room.otherStatus === 'cleaning'" type="success" size="small" @click="handleComplete">打扫完成</el-button>
<el-button v-if="room.otherStatus === 'maintenance'" type="success" size="small" @click="handleComplete">维修完成</el-button>
<el-button v-if="(room.status === 'empty' || room.status === 'reserved') && hasPermission('rental:add')" type="primary" size="small" @click="handleRent">租房</el-button>
<el-button v-if="room.status === 'empty' && hasPermission('room:update')" type="warning" size="small" @click="handleReserve">预订</el-button>
<el-button v-if="room.status === 'reserved' && hasPermission('room:update')" type="warning" size="small" @click="handleCancelReserve">取消预订</el-button>
<el-button v-if="room.status === 'rented' && hasPermission('rental:update')" type="warning" size="small" @click="handleCheckout">退房</el-button>
<el-button v-if="(!room.otherStatus || room.otherStatus === '') && hasPermission('room:update')" type="info" size="small" @click="handleCleaning">打扫</el-button>
<el-button v-if="(!room.otherStatus || room.otherStatus === '') && hasPermission('room:update')" type="danger" size="small" @click="handleMaintenance">维修</el-button>
<el-button v-if="room.otherStatus === 'cleaning' && hasPermission('room:update')" type="success" size="small" @click="handleComplete">打扫完成</el-button>
<el-button v-if="room.otherStatus === 'maintenance' && hasPermission('room:update')" type="success" size="small" @click="handleComplete">维修完成</el-button>
<el-button type="primary" size="small" @click="goBack">返回</el-button>
</div>
</div>
@ -66,10 +66,10 @@
<el-table-column prop="createTime" label="创建时间" min-width="140"></el-table-column>
<el-table-column label="操作" min-width="180" fixed="right">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="handleEditRental(scope.row)">编辑</el-button>
<el-button type="success" size="mini" @click="handleRenewRental(scope.row)">续租</el-button>
<el-button type="danger" size="mini" @click="handleDeleteRental(scope.row.id)">删除</el-button>
</template>
<el-button type="primary" size="mini" @click="handleEditRental(scope.row)" v-if="hasPermission('rental:edit')">编辑</el-button>
<el-button type="success" size="mini" @click="handleRenewRental(scope.row)" v-if="hasPermission('rental:add')">续租</el-button>
<el-button type="danger" size="mini" @click="handleDeleteRental(scope.row.id)" v-if="hasPermission('rental:delete')">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
@ -87,7 +87,7 @@
</el-tab-pane>
<el-tab-pane label="水费记录" name="water">
<div class="section-header">
<el-button type="primary" size="small" @click="handleAddWaterBill">添加水费</el-button>
<el-button type="primary" size="small" @click="handleAddWaterBill" v-if="hasPermission('water:add')">添加水费</el-button>
</div>
<div class="table-wrapper">
<el-table :data="waterBills" style="width: 100%" class="detail-table">
@ -111,9 +111,9 @@
</el-table-column>
<el-table-column label="操作" min-width="140" fixed="right">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="handleEditWaterBill(scope.row)">编辑</el-button>
<el-button type="danger" size="mini" @click="handleDeleteWaterBill(scope.row.id)">删除</el-button>
</template>
<el-button type="primary" size="mini" @click="handleEditWaterBill(scope.row)" v-if="hasPermission('water:edit')">编辑</el-button>
<el-button type="danger" size="mini" @click="handleDeleteWaterBill(scope.row.id)" v-if="hasPermission('water:delete')">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
@ -133,7 +133,7 @@
<!-- 水费编辑对话框 -->
<el-dialog title="编辑水费" :visible.sync="waterBillDialogVisible" width="500px">
<el-form :model="waterBillForm" :rules="waterBillRules" ref="waterBillForm" label-width="90px">
<el-form :model="waterBillForm" :rules="waterBillRules" ref="waterBillForm" label-width="120px">
<el-form-item label="开始日期" prop="startDate">
<el-date-picker v-model="waterBillForm.startDate" type="date" placeholder="选择开始日期" style="width: 100%"></el-date-picker>
</el-form-item>
@ -202,6 +202,8 @@
<script>
import { roomApi, rentalApi, waterBillApi } from '../../api/api'
import { hasPermission } from '../../utils/permission'
import { getUserInfo } from '../../utils/auth'
export default {
name: 'RentalDetail',
@ -291,6 +293,7 @@ export default {
}
},
methods: {
hasPermission,
formatDate(date) {
if (!date) return null
const d = new Date(date)
@ -455,7 +458,7 @@ export default {
this.$message.error('房间信息加载失败,无法进行预订操作')
return
}
this.$confirm('确定要预订这个房间吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@ -477,7 +480,7 @@ export default {
this.$message.error('房间信息加载失败,无法进行取消预订操作')
return
}
this.$confirm('确定要取消预订吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@ -499,7 +502,7 @@ export default {
this.$message.error('房间信息加载失败,无法进行退房操作')
return
}
this.$confirm('确定要退房吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@ -604,7 +607,7 @@ export default {
unitPrice: this.waterBillForm.unitPrice !== '' ? parseFloat(this.waterBillForm.unitPrice) : null,
status: this.waterBillForm.status
}
if (this.waterBillForm.id) {
await waterBillApi.update(this.waterBillForm.id, data)
this.$message.success('水费记录更新成功')
@ -674,7 +677,7 @@ export default {
} else {
await rentalApi.create(this.rentalForm)
this.$message.success('租赁记录添加成功')
if (this.renewingRentalId) {
await rentalApi.update(this.renewingRentalId, { status: 'expired' })
this.$message.success('原租赁记录状态已更新为已到期')
@ -710,6 +713,10 @@ export default {
},
goBack() {
this.$router.push({ path: '/rental/list', query: this.returnQuery })
},
getDefaultTenantName() {
const userInfo = getUserInfo()
return userInfo && userInfo.nickname ? userInfo.nickname : ''
}
}
}
@ -810,46 +817,46 @@ export default {
flex-direction: column;
align-items: flex-start;
}
.card-header span {
font-size: 16px;
margin-bottom: 10px;
}
.action-buttons {
width: 100%;
justify-content: flex-start;
}
.room-info-section h2 {
font-size: 18px;
}
.room-basic-info {
grid-template-columns: 1fr;
gap: 10px;
}
.info-item {
flex-direction: column;
align-items: flex-start;
}
.info-item .label {
width: auto;
margin-bottom: 3px;
font-size: 12px;
color: #909399;
}
.info-item .value {
font-size: 14px;
}
.pagination-wrapper {
justify-content: center;
}
.section-header {
flex-direction: column;
gap: 10px;

View File

@ -56,11 +56,6 @@
<div class="table-wrapper hidden-xs-only">
<el-table :data="rentalList" style="width: 100%" v-loading="isLoading">
<el-table-column prop="tenantName" label="租客" min-width="100"></el-table-column>
<el-table-column label="区域" min-width="150">
<template slot-scope="scope">
{{ scope.row.Room.Apartment.Region.name }}
</template>
</el-table-column>
<el-table-column label="公寓" min-width="150">
<template slot-scope="scope">
{{ scope.row.Room.Apartment.name }}
@ -86,9 +81,9 @@
<el-table-column prop="createTime" label="创建时间" min-width="150"></el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="success" size="mini" @click="handleRenew(scope.row)">续租</el-button>
<el-button type="danger" size="mini" @click="handleDelete(scope.row.id)">删除</el-button>
<el-button type="primary" size="mini" @click="handleEdit(scope.row)" v-if="hasPermission('rental:edit')">编辑</el-button>
<el-button type="success" size="mini" @click="handleRenew(scope.row)" v-if="hasPermission('rental:add')">续租</el-button>
<el-button type="danger" size="mini" @click="handleDelete(scope.row.id)" v-if="hasPermission('rental:delete')">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -104,10 +99,6 @@
</el-tag>
</div>
<div class="mobile-card-body">
<div class="mobile-card-item">
<span class="mobile-card-label">区域</span>
<span class="mobile-card-value">{{ item.Room.Apartment.Region.name }}</span>
</div>
<div class="mobile-card-item">
<span class="mobile-card-label">公寓</span>
<span class="mobile-card-value">{{ item.Room.Apartment.name }}</span>
@ -134,9 +125,9 @@
</div>
</div>
<div class="mobile-card-footer">
<el-button type="primary" size="mini" @click="handleEdit(item)">编辑</el-button>
<el-button type="success" size="mini" @click="handleRenew(item)">续租</el-button>
<el-button type="danger" size="mini" @click="handleDelete(item.id)">删除</el-button>
<el-button type="primary" size="mini" @click="handleEdit(item)" v-if="hasPermission('rental:edit')">编辑</el-button>
<el-button type="success" size="mini" @click="handleRenew(item)" v-if="hasPermission('rental:add')">续租</el-button>
<el-button type="danger" size="mini" @click="handleDelete(item.id)" v-if="hasPermission('rental:delete')">删除</el-button>
</div>
</div>
</div>
@ -196,6 +187,8 @@
<script>
import { rentalApi, apartmentApi, roomApi } from '../../api/api'
import { hasPermission } from '../../utils/permission'
import { getUserInfo } from '../../utils/auth'
export default {
name: 'RentalArchive',
@ -253,6 +246,7 @@ export default {
window.removeEventListener('resize', this.handleResize)
},
methods: {
hasPermission,
handleResize() {
this.$forceUpdate()
},
@ -420,6 +414,10 @@ export default {
this.$message.error('删除失败')
}
}).catch(() => {})
},
getDefaultTenantName() {
const userInfo = getUserInfo()
return userInfo && userInfo.nickname ? userInfo.nickname : ''
}
}
}

View File

@ -4,7 +4,7 @@
<template slot="header">
<div class="card-header">
<span>水费档案</span>
<el-button type="primary" size="small" @click="handleAdd">添加水费</el-button>
<el-button type="primary" size="small" @click="handleAdd" v-if="hasPermission('water:add')">添加水费</el-button>
</div>
</template>
<el-form :inline="true" :model="searchForm" class="search-form">
@ -43,7 +43,7 @@
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- PC端表格 -->
<div class="table-wrapper hidden-xs-only">
<el-table :data="waterBillList" style="width: 100%" v-loading="isLoading">
@ -73,13 +73,13 @@
<el-table-column prop="createTime" label="创建时间" min-width="150"></el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="danger" size="mini" @click="handleDelete(scope.row.id)">删除</el-button>
<el-button type="primary" size="mini" @click="handleEdit(scope.row)" v-if="hasPermission('water:edit')">编辑</el-button>
<el-button type="danger" size="mini" @click="handleDelete(scope.row.id)" v-if="hasPermission('water:delete')">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 移动端卡片列表 -->
<div class="mobile-list hidden-sm-and-up">
<div v-for="item in waterBillList" :key="item.id" class="mobile-card">
@ -112,12 +112,12 @@
</div>
</div>
<div class="mobile-card-footer">
<el-button type="primary" size="mini" @click="handleEdit(item)">编辑</el-button>
<el-button type="danger" size="mini" @click="handleDelete(item.id)">删除</el-button>
<el-button type="primary" size="mini" @click="handleEdit(item)" v-if="hasPermission('water:edit')">编辑</el-button>
<el-button type="danger" size="mini" @click="handleDelete(item.id)" v-if="hasPermission('water:delete')">删除</el-button>
</div>
</div>
</div>
<div class="pagination-wrapper">
<el-pagination
@size-change="handleSizeChange"
@ -132,7 +132,7 @@
</el-card>
<el-dialog :title="waterBillForm.id ? '编辑水费' : '添加水费'" :visible.sync="waterBillDialogVisible" width="500px">
<el-form :model="waterBillForm" :rules="waterBillRules" ref="waterBillForm" label-width="90px">
<el-form :model="waterBillForm" :rules="waterBillRules" ref="waterBillForm" label-width="120px">
<el-form-item label="房间" prop="roomId">
<el-select v-model="waterBillForm.roomId" placeholder="请选择房间" style="width: 100%">
<el-option v-for="room in allRooms" :key="room.id" :label="`${getApartmentName(room.id)} - ${room.roomNumber}`" :value="room.id"></el-option>
@ -171,6 +171,7 @@
<script>
import { waterBillApi, apartmentApi, roomApi } from '../../api/api'
import { hasPermission } from '../../utils/permission'
export default {
name: 'WaterArchive',
@ -234,6 +235,7 @@ export default {
window.removeEventListener('resize', this.handleResize)
},
methods: {
hasPermission,
formatDate(date) {
if (!date) return null
const d = new Date(date)
@ -392,7 +394,7 @@ export default {
unitPrice: this.waterBillForm.unitPrice !== '' ? parseFloat(this.waterBillForm.unitPrice) : null,
status: this.waterBillForm.status
}
if (this.waterBillForm.id) {
await waterBillApi.update(this.waterBillForm.id, data)
this.$message.success('水费记录更新成功')
@ -458,16 +460,16 @@ export default {
margin-right: 0;
margin-bottom: 10px;
}
.search-form .el-form-item__content {
width: 100%;
}
.search-form .el-select,
.search-form .el-date-picker {
width: 100%;
}
.pagination-wrapper {
justify-content: center;
}

400
src/views/role/List.vue Normal file
View File

@ -0,0 +1,400 @@
<template>
<div class="role-list-page">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>角色管理</span>
<el-button
v-if="hasPermission('role:add')"
style="float: right;"
type="primary"
size="small"
@click="handleAdd"
>
添加角色
</el-button>
</div>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="角色名称">
<el-input v-model="searchForm.name" placeholder="请输入角色名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button v-if="hasPermission('role:search')" type="primary" @click="handleSearch">查询</el-button>
<el-button v-if="hasPermission('role:reset')" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 角色列表 -->
<el-table v-loading="loading" :data="roleList" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="code" label="角色编码" />
<el-table-column prop="description" label="角色描述" show-overflow-tooltip />
<el-table-column prop="status" label="状态">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 'active'" type="success">启用</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间">
<template slot-scope="scope">
{{ formatDate(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template slot-scope="scope">
<el-button
v-if="hasPermission('role:edit')"
type="text"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
v-if="hasPermission('role:assign')"
type="text"
size="small"
@click="handleAssignMenus(scope.row)"
>
配置权限
</el-button>
<el-button
v-if="hasPermission('role:delete')"
type="text"
size="small"
style="color: #f56c6c;"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next"
:total="total"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:current-page="currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<!-- 添加/编辑角色对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
>
<el-form
ref="roleForm"
:model="roleForm"
:rules="roleRules"
label-width="80px"
>
<el-form-item label="角色名称" prop="name">
<el-input v-model="roleForm.name" />
</el-form-item>
<el-form-item label="角色编码" prop="code">
<el-input v-model="roleForm.code" :disabled="isEdit" />
</el-form-item>
<el-form-item label="角色描述" prop="description">
<el-input v-model="roleForm.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="roleForm.status" style="width: 100%;">
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-dialog>
<!-- 配置菜单权限对话框 -->
<el-dialog
title="配置菜单权限"
:visible.sync="menuDialogVisible"
width="500px"
>
<el-tree
ref="menuTree"
:data="menuTree"
:props="{ label: 'name', children: 'children' }"
node-key="id"
show-checkbox
default-expand-all
/>
<div slot="footer">
<el-button @click="menuDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="menuSubmitLoading" @click="handleMenuSubmit">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getRoles, createRole, updateRole, deleteRole } from '@/api/role'
import { getMenuTree, getRoleMenus, assignMenusToRole } from '@/api/menu'
import { hasPermission } from '@/utils/permission'
export default {
name: 'RoleList',
data() {
return {
loading: false,
roleList: [],
total: 0,
currentPage: 1,
pageSize: 10,
searchForm: {
name: '',
status: ''
},
dialogVisible: false,
dialogTitle: '添加角色',
isEdit: false,
submitLoading: false,
roleForm: {
id: null,
name: '',
code: '',
description: '',
status: 'active'
},
roleRules: {
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
description: [
{ max: 200, message: '最多 200 个字符', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
},
menuDialogVisible: false,
menuSubmitLoading: false,
menuTree: [],
currentRoleId: null
}
},
mounted() {
this.fetchRoleList()
this.fetchMenuTree()
},
methods: {
hasPermission,
async fetchRoleList() {
this.loading = true
try {
const params = {
page: this.currentPage,
pageSize: this.pageSize,
...this.searchForm
}
const res = await getRoles(params)
if (res.code === 200) {
this.roleList = res.data.list
this.total = res.data.total
} else {
this.$message.error(res.message || '获取角色列表失败')
}
} catch (error) {
this.$message.error(error.message || '获取角色列表失败')
} finally {
this.loading = false
}
},
handleSearch() {
this.currentPage = 1
this.fetchRoleList()
},
handleReset() {
this.searchForm = {
name: '',
status: ''
}
this.currentPage = 1
this.fetchRoleList()
},
handleSizeChange(val) {
this.pageSize = val
this.fetchRoleList()
},
handleCurrentChange(val) {
this.currentPage = val
this.fetchRoleList()
},
handleAdd() {
this.isEdit = false
this.dialogTitle = '添加角色'
this.roleForm = {
id: null,
name: '',
code: '',
description: '',
status: 'active'
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.roleForm.clearValidate()
})
},
handleEdit(row) {
this.isEdit = true
this.dialogTitle = '编辑角色'
this.roleForm = {
id: row.id,
name: row.name,
code: row.code,
description: row.description || '',
status: row.status
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.roleForm.clearValidate()
})
},
handleSubmit() {
this.$refs.roleForm.validate(async valid => {
if (!valid) return
this.submitLoading = true
try {
let res
if (this.isEdit) {
res = await updateRole(this.roleForm.id, {
name: this.roleForm.name,
description: this.roleForm.description,
status: this.roleForm.status
})
} else {
res = await createRole(this.roleForm)
}
if (res.code === 200) {
this.$message.success(this.isEdit ? '更新成功' : '添加成功')
this.dialogVisible = false
this.fetchRoleList()
} else {
this.$message.error(res.message || (this.isEdit ? '更新失败' : '添加失败'))
}
} catch (error) {
this.$message.error(error.message || (this.isEdit ? '更新失败' : '添加失败'))
} finally {
this.submitLoading = false
}
})
},
handleDelete(row) {
this.$confirm(`确定要删除角色 "${row.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteRole(row.id)
if (res.code === 200) {
this.$message.success('删除成功')
this.fetchRoleList()
} else {
this.$message.error(res.message || '删除失败')
}
} catch (error) {
this.$message.error(error.message || '删除失败')
}
}).catch(() => {})
},
async fetchMenuTree() {
try {
const res = await getMenuTree()
if (res.code === 200) {
this.menuTree = res.data
}
} catch (error) {
console.error('获取菜单树失败:', error)
}
},
async handleAssignMenus(row) {
this.currentRoleId = row.id
this.menuDialogVisible = true
try {
const res = await getRoleMenus(row.id)
if (res.code === 200) {
const menuIds = res.data.map(menu => menu.id)
this.$nextTick(() => {
this.$refs.menuTree.setCheckedKeys(menuIds)
})
}
} catch (error) {
this.$message.error('获取角色菜单权限失败')
}
},
async handleMenuSubmit() {
const checkedKeys = this.$refs.menuTree.getCheckedKeys()
const halfCheckedKeys = this.$refs.menuTree.getHalfCheckedKeys()
const allCheckedKeys = [...checkedKeys, ...halfCheckedKeys]
this.menuSubmitLoading = true
try {
const res = await assignMenusToRole(this.currentRoleId, {
menuIds: allCheckedKeys
})
if (res.code === 200) {
this.$message.success('配置成功')
this.menuDialogVisible = false
} else {
this.$message.error(res.message || '配置失败')
}
} catch (error) {
this.$message.error(error.message || '配置失败')
} finally {
this.menuSubmitLoading = false
}
},
formatDate(date) {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN')
}
}
}
</script>
<style scoped>
.role-list-page {
padding: 20px;
}
.search-form {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
</style>

View File

@ -4,7 +4,7 @@
<template slot="header">
<div class="card-header">
<span>房间管理</span>
<el-button type="primary" size="small" @click="handleAdd">添加房间</el-button>
<el-button type="primary" size="small" @click="handleAdd" v-if="hasPermission('room:add')">添加房间</el-button>
</div>
</template>
@ -80,8 +80,8 @@
<el-table-column prop="createTime" label="创建时间" width="180"></el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="handleEdit(scope.row.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
<el-button size="mini" type="primary" @click="handleEdit(scope.row.id)" v-if="hasPermission('room:edit')">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row.id)" v-if="hasPermission('room:delete')">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -126,8 +126,8 @@
</div>
</div>
<div class="mobile-card-footer">
<el-button size="mini" type="primary" @click="handleEdit(item.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(item.id)">删除</el-button>
<el-button size="mini" type="primary" @click="handleEdit(item.id)" v-if="hasPermission('room:edit')">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(item.id)" v-if="hasPermission('room:delete')">删除</el-button>
</div>
</div>
</div>
@ -149,6 +149,7 @@
<script>
import { roomApi, apartmentApi } from '../../api/api'
import { hasPermission } from '../../utils/permission'
export default {
name: 'RoomList',
@ -185,6 +186,7 @@ export default {
window.removeEventListener('resize', this.checkDevice)
},
methods: {
hasPermission,
checkDevice() {
this.isMobile = window.innerWidth <= 768
},

398
src/views/user/List.vue Normal file
View File

@ -0,0 +1,398 @@
<template>
<div class="user-list-page">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>用户管理</span>
<el-button
v-if="hasPermission('user:add')"
style="float: right;"
type="primary"
size="small"
@click="handleAdd"
>
添加用户
</el-button>
</div>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="searchForm.roleId" placeholder="请选择角色" clearable>
<el-option
v-for="role in roleList"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button v-if="hasPermission('user:search')" type="primary" @click="handleSearch">查询</el-button>
<el-button v-if="hasPermission('user:reset')" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 用户列表 -->
<el-table v-loading="loading" :data="userList" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column label="角色">
<template slot-scope="scope">
<el-tag type="info">{{ scope.row.role && scope.row.role.name ? scope.row.role.name : '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 'active'" type="success">启用</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间">
<template slot-scope="scope">
{{ formatDate(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<template slot-scope="scope">
<el-button
v-if="hasPermission('user:edit')"
type="text"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
v-if="hasPermission('user:reset-password')"
type="text"
size="small"
@click="handleResetPassword(scope.row)"
>
重置密码
</el-button>
<el-button
v-if="hasPermission('user:delete')"
type="text"
size="small"
style="color: #f56c6c;"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next"
:total="total"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:current-page="currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<!-- 添加/编辑用户对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="500px"
>
<el-form
ref="userForm"
:model="userForm"
:rules="userRules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="userForm.username" :disabled="isEdit" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="userForm.nickname" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码" prop="password">
<el-input v-model="userForm.password" type="password" show-password />
</el-form-item>
<el-form-item label="角色" prop="roleId">
<el-select v-model="userForm.roleId" style="width: 100%;">
<el-option
v-for="role in roleList"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="userForm.status" style="width: 100%;">
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword } from '@/api/user'
import { getAllRoles } from '@/api/role'
import { getUserInfo } from '@/utils/auth'
import { hasPermission } from '@/utils/permission'
export default {
name: 'UserList',
data() {
return {
loading: false,
userList: [],
roleList: [],
total: 0,
currentPage: 1,
pageSize: 10,
searchForm: {
username: '',
roleId: '',
status: ''
},
dialogVisible: false,
dialogTitle: '添加用户',
isEdit: false,
submitLoading: false,
currentUserId: null,
isAdmin: false,
userForm: {
id: null,
username: '',
nickname: '',
password: '',
roleId: null,
status: 'active'
},
userRules: {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
nickname: [
{ max: 20, message: '最多 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
],
roleId: [
{ required: true, message: '请选择角色', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
}
},
mounted() {
this.loadUserInfo()
this.fetchRoleList()
this.fetchUserList()
},
methods: {
loadUserInfo() {
const userInfo = getUserInfo()
if (userInfo) {
this.currentUserId = userInfo.id
this.isAdmin = userInfo.role === 'admin' || (userInfo.role && userInfo.role.code === 'admin')
}
},
hasPermission,
async fetchRoleList() {
try {
const res = await getAllRoles()
if (res.code === 200) {
this.roleList = res.data
}
} catch (error) {
console.error('获取角色列表失败:', error)
}
},
async fetchUserList() {
this.loading = true
try {
const params = {
page: this.currentPage,
pageSize: this.pageSize,
...this.searchForm
}
const res = await getUserList(params)
if (res.code === 200) {
this.userList = res.data.list
this.total = res.data.total
} else {
this.$message.error(res.message || '获取用户列表失败')
}
} catch (error) {
this.$message.error(error.message || '获取用户列表失败')
} finally {
this.loading = false
}
},
handleSearch() {
this.currentPage = 1
this.fetchUserList()
},
handleReset() {
this.searchForm = {
username: '',
role: ''
}
this.currentPage = 1
this.fetchUserList()
},
handleSizeChange(val) {
this.pageSize = val
this.fetchUserList()
},
handleCurrentChange(val) {
this.currentPage = val
this.fetchUserList()
},
handleAdd() {
this.isEdit = false
this.dialogTitle = '添加用户'
this.userForm = {
id: null,
username: '',
nickname: '',
password: '',
role: 'user'
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.userForm.clearValidate()
})
},
handleEdit(row) {
this.isEdit = true
this.dialogTitle = '编辑用户'
this.userForm = {
id: row.id,
username: row.username,
nickname: row.nickname || '',
password: '',
roleId: row.roleId,
status: row.status
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.userForm.clearValidate()
})
},
handleSubmit() {
this.$refs.userForm.validate(async valid => {
if (!valid) return
this.submitLoading = true
try {
let res
if (this.isEdit) {
res = await updateUser(this.userForm.id, {
nickname: this.userForm.nickname,
roleId: this.userForm.roleId,
status: this.userForm.status
})
} else {
res = await createUser(this.userForm)
}
if (res.code === 200) {
this.$message.success(this.isEdit ? '更新成功' : '添加成功')
this.dialogVisible = false
this.fetchUserList()
} else {
this.$message.error(res.message || (this.isEdit ? '更新失败' : '添加失败'))
}
} catch (error) {
this.$message.error(error.message || (this.isEdit ? '更新失败' : '添加失败'))
} finally {
this.submitLoading = false
}
})
},
handleResetPassword(row) {
this.$confirm(`确定要重置用户 "${row.username}" 的密码吗重置后密码为123456`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await resetUserPassword(row.id)
if (res.code === 200) {
this.$message.success('密码重置成功新密码为123456')
} else {
this.$message.error(res.message || '密码重置失败')
}
} catch (error) {
this.$message.error(error.message || '密码重置失败')
}
}).catch(() => {})
},
handleDelete(row) {
this.$confirm(`确定要删除用户 "${row.username}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUser(row.id)
if (res.code === 200) {
this.$message.success('删除成功')
this.fetchUserList()
} else {
this.$message.error(res.message || '删除失败')
}
} catch (error) {
this.$message.error(error.message || '删除失败')
}
}).catch(() => {})
},
formatDate(date) {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN')
}
}
}
</script>
<style scoped>
.user-list-page {
padding: 20px;
}
.search-form {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
</style>

211
src/views/user/Profile.vue Normal file
View File

@ -0,0 +1,211 @@
<template>
<div class="profile-container">
<el-card shadow="hover">
<template slot="header">
<div class="card-header">
<span>个人中心</span>
</div>
</template>
<el-tabs v-model="activeTab">
<!-- 基本资料 -->
<el-tab-pane label="基本资料" name="basic">
<el-form :model="userForm" :rules="rules" ref="userForm" label-width="120px">
<el-form-item label="用户名" prop="username">
<el-input v-model="userForm.username" disabled />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="userForm.nickname" />
</el-form-item>
<el-form-item label="角色">
<el-tag type="info">{{ userForm.role && userForm.role.name ? userForm.role.name : '-' }}</el-tag>
</el-form-item>
<el-form-item label="状态">
<el-tag :type="userForm.status === 'active' ? 'success' : 'danger'">
{{ userForm.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</el-form-item>
<el-form-item label="创建时间">
<el-input v-model="userForm.createdAt" disabled />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="updateProfile" :loading="loading">保存修改</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 修改密码 -->
<el-tab-pane label="修改密码" name="password">
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordForm" label-width="120px">
<el-form-item label="当前密码" prop="oldPassword">
<el-input type="password" v-model="passwordForm.oldPassword" placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input type="password" v-model="passwordForm.newPassword" placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input type="password" v-model="passwordForm.confirmPassword" placeholder="请确认新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="updatePassword" :loading="passwordLoading">修改密码</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script>
import { getUserInfo, updateUserProfile, changePassword } from '@/api/user'
export default {
data() {
return {
activeTab: 'basic',
loading: false,
passwordLoading: false,
userForm: {
username: '',
nickname: '',
role: null,
status: '',
createdAt: ''
},
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
rules: {
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' }
]
},
passwordRules: {
oldPassword: [
{ required: true, message: '请输入当前密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少 6 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== this.passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
}
},
mounted() {
// URL
const tab = this.$route.query.tab
if (tab === 'password') {
this.activeTab = 'password'
}
this.loadUserProfile()
},
methods: {
async loadUserProfile() {
try {
const res = await getUserInfo()
if (res.code === 200) {
this.userForm = {
...res.data,
createdAt: res.data.createdAt ? new Date(res.data.createdAt).toLocaleString() : ''
}
}
} catch (error) {
this.$message.error('获取用户信息失败')
}
},
async updateProfile() {
this.$refs.userForm.validate(async (valid) => {
if (valid) {
this.loading = true
try {
const res = await updateUserProfile({
nickname: this.userForm.nickname
})
if (res.code === 200) {
this.$message.success('个人资料更新成功')
}
} catch (error) {
this.$message.error('更新失败')
} finally {
this.loading = false
}
}
})
},
async updatePassword() {
this.$refs.passwordForm.validate(async (valid) => {
if (valid) {
this.passwordLoading = true
try {
const res = await changePassword({
oldPassword: this.passwordForm.oldPassword,
newPassword: this.passwordForm.newPassword
})
if (res.code === 200) {
this.$message.success('密码修改成功,请重新登录')
setTimeout(() => {
this.$router.push('/login')
}, 1500)
}
} catch (error) {
this.$message.error('密码修改失败')
} finally {
this.passwordLoading = false
this.passwordForm = {
oldPassword: '',
newPassword: '',
confirmPassword: ''
}
}
}
})
}
}
}
</script>
<style scoped>
.profile-container {
max-width: 800px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.el-tabs {
margin-top: 20px;
}
.el-form-item {
margin-bottom: 20px;
}
</style>

View File

@ -1,14 +1,15 @@
module.exports = {
devServer: {
port: 8080,
proxy: {
'/api': {
target: 'http://localhost:3000',
target: 'http://127.0.0.1:3000',
changeOrigin: true,
pathRewrite: {
'^/api': '/api'
}
ws: true,
secure: false,
logLevel: 'debug'
}
}
},
lintOnSave: false
};
};