Selaa lähdekoodia

feat(wm-base): #74 系统管理完整实现

- 新增数据字典管理(SysDictType/SysDictData entity + mapper + service + controller)
  - 字典类型CRUD + 字典数据CRUD + 本地ConcurrentHashMap缓存
  - SysDictionaryController /api/base/sys/dict
- 新增操作日志管理(SysOperationLog entity + mapper + service + controller)
  - 日志记录 + 分页查询(按用户/模块/操作/状态/时间筛选)+ CSV导出 + 清空
  - SysOperationLogController /api/base/sys/log
- 完善SysRoleService:5级角色预设 + 权限矩阵
  - admin/supervisor/biz_manager/field_ops/tech_maintain
  - 预设初始化接口 + 权限矩阵查询接口
- 完善SysRoleController:新增presets/permissions/init-presets端点
- SQL DDL: V_sys_dict_log.sql(建表+索引+预设角色+预设字典数据)
- 前端新增/完善:
  - UserList.vue(用户CRUD + 搜索 + 状态切换)
  - RoleList.vue(角色CRUD + 预设查看 + 权限矩阵查看)
  - DeptList.vue(部门树 + CRUD + 类型选择)
  - MenuList.vue(菜单树 + CRUD + 类型切换)
  - DictionaryView.vue(左右布局:字典类型列表 + 字典数据列表)
  - OperationLogView.vue(多条件筛选 + 详情 + CSV导出 + 清空)
  - system.ts API模块
  - router路由更新
- 单元测试:SysDictionaryServiceTest/SysRoleServiceTest/SysDeptServiceTest/SysOperationLogServiceTest
bot_dev2 5 päivää sitten
vanhempi
commit
f5aa27edbd
29 muutettua tiedostoa jossa 2485 lisäystä ja 89 poistoa
  1. 131
    0
      frontend/src/api/system.ts
  2. 2
    0
      frontend/src/router/index.ts
  3. 142
    0
      frontend/src/views/system/dept/DeptList.vue
  4. 0
    22
      frontend/src/views/system/dept/部门List.vue
  5. 257
    0
      frontend/src/views/system/dict/DictionaryView.vue
  6. 208
    0
      frontend/src/views/system/log/OperationLogView.vue
  7. 162
    0
      frontend/src/views/system/menu/MenuList.vue
  8. 0
    22
      frontend/src/views/system/menu/菜单List.vue
  9. 197
    0
      frontend/src/views/system/role/RoleList.vue
  10. 0
    22
      frontend/src/views/system/role/角色List.vue
  11. 176
    0
      frontend/src/views/system/user/UserList.vue
  12. 0
    22
      frontend/src/views/system/user/用户List.vue
  13. 102
    0
      wm-base/src/main/java/com/water/base/controller/SysDictionaryController.java
  14. 98
    0
      wm-base/src/main/java/com/water/base/controller/SysOperationLogController.java
  15. 22
    0
      wm-base/src/main/java/com/water/base/controller/SysRoleController.java
  16. 22
    0
      wm-base/src/main/java/com/water/base/entity/SysDictData.java
  17. 18
    0
      wm-base/src/main/java/com/water/base/entity/SysDictType.java
  18. 27
    0
      wm-base/src/main/java/com/water/base/entity/SysOperationLog.java
  19. 8
    0
      wm-base/src/main/java/com/water/base/mapper/SysDictDataMapper.java
  20. 8
    0
      wm-base/src/main/java/com/water/base/mapper/SysDictTypeMapper.java
  21. 8
    0
      wm-base/src/main/java/com/water/base/mapper/SysOperationLogMapper.java
  22. 122
    0
      wm-base/src/main/java/com/water/base/service/SysDictionaryService.java
  23. 114
    0
      wm-base/src/main/java/com/water/base/service/SysOperationLogService.java
  24. 104
    1
      wm-base/src/main/java/com/water/base/service/SysRoleService.java
  25. 175
    0
      wm-base/src/main/resources/db/V_sys_dict_log.sql
  26. 84
    0
      wm-base/src/test/java/com/water/base/service/SysDeptServiceTest.java
  27. 130
    0
      wm-base/src/test/java/com/water/base/service/SysDictionaryServiceTest.java
  28. 52
    0
      wm-base/src/test/java/com/water/base/service/SysOperationLogServiceTest.java
  29. 116
    0
      wm-base/src/test/java/com/water/base/service/SysRoleServiceTest.java

+ 131
- 0
frontend/src/api/system.ts Näytä tiedosto

@@ -0,0 +1,131 @@
1
+import request from './request'
2
+
3
+// ============ 用户管理 ============
4
+export function getUserList(page = 1, size = 10, username?: string) {
5
+  return request.get('/base/sys/user/list', { params: { page, size, username } })
6
+}
7
+export function getUserById(id: number) {
8
+  return request.get(`/base/sys/user/${id}`)
9
+}
10
+export function createUser(data: any) {
11
+  return request.post('/base/sys/user', data)
12
+}
13
+export function updateUser(id: number, data: any) {
14
+  return request.put(`/base/sys/user/${id}`, data)
15
+}
16
+export function toggleUserStatus(id: number, status: number) {
17
+  return request.put(`/base/sys/user/${id}/status?status=${status}`)
18
+}
19
+
20
+// ============ 角色管理 ============
21
+export function getRoleList(page = 1, size = 10) {
22
+  return request.get('/base/sys/role/list', { params: { page, size } })
23
+}
24
+export function getRoleById(id: number) {
25
+  return request.get(`/base/sys/role/${id}`)
26
+}
27
+export function createRole(data: any) {
28
+  return request.post('/base/sys/role', data)
29
+}
30
+export function updateRole(id: number, data: any) {
31
+  return request.put(`/base/sys/role/${id}`, data)
32
+}
33
+export function deleteRole(id: number) {
34
+  return request.delete(`/base/sys/role/${id}`)
35
+}
36
+export function getRolePresets() {
37
+  return request.get('/base/sys/role/presets')
38
+}
39
+export function getRolePermissions(roleKey: string) {
40
+  return request.get(`/base/sys/role/permissions/${roleKey}`)
41
+}
42
+export function initRolePresets() {
43
+  return request.post('/base/sys/role/init-presets')
44
+}
45
+
46
+// ============ 部门管理 ============
47
+export function getDeptTree() {
48
+  return request.get('/base/sys/dept/tree')
49
+}
50
+export function createDept(data: any) {
51
+  return request.post('/base/sys/dept', data)
52
+}
53
+export function updateDept(id: number, data: any) {
54
+  return request.put(`/base/sys/dept/${id}`, data)
55
+}
56
+export function deleteDept(id: number) {
57
+  return request.delete(`/base/sys/dept/${id}`)
58
+}
59
+
60
+// ============ 菜单管理 ============
61
+export function getMenuTree() {
62
+  return request.get('/base/sys/menu/tree')
63
+}
64
+export function getMenuList() {
65
+  return request.get('/base/sys/menu/list')
66
+}
67
+export function createMenu(data: any) {
68
+  return request.post('/base/sys/menu', data)
69
+}
70
+export function updateMenu(id: number, data: any) {
71
+  return request.put(`/base/sys/menu/${id}`, data)
72
+}
73
+export function deleteMenu(id: number) {
74
+  return request.delete(`/base/sys/menu/${id}`)
75
+}
76
+
77
+// ============ 数据字典 ============
78
+export function getDictTypes(dictName?: string) {
79
+  return request.get('/base/sys/dict/type/list', { params: { dictName } })
80
+}
81
+export function getDictTypeById(id: number) {
82
+  return request.get(`/base/sys/dict/type/${id}`)
83
+}
84
+export function createDictType(data: any) {
85
+  return request.post('/base/sys/dict/type', data)
86
+}
87
+export function updateDictType(id: number, data: any) {
88
+  return request.put(`/base/sys/dict/type/${id}`, data)
89
+}
90
+export function deleteDictType(id: number) {
91
+  return request.delete(`/base/sys/dict/type/${id}`)
92
+}
93
+export function getDictData(typeId: number) {
94
+  return request.get('/base/sys/dict/data/list', { params: { typeId } })
95
+}
96
+export function getDictDataByKey(dictKey: string) {
97
+  return request.get(`/base/sys/dict/data/key/${dictKey}`)
98
+}
99
+export function createDictData(data: any) {
100
+  return request.post('/base/sys/dict/data', data)
101
+}
102
+export function updateDictData(id: number, data: any) {
103
+  return request.put(`/base/sys/dict/data/${id}`, data)
104
+}
105
+export function deleteDictData(id: number) {
106
+  return request.delete(`/base/sys/dict/data/${id}`)
107
+}
108
+export function clearDictCache() {
109
+  return request.post('/base/sys/dict/cache/clear')
110
+}
111
+
112
+// ============ 操作日志 ============
113
+export function getLogList(params: {
114
+  page?: number; size?: number;
115
+  username?: string; module?: string;
116
+  operation?: string; status?: number;
117
+  startTime?: string; endTime?: string
118
+}) {
119
+  return request.get('/base/sys/log/list', { params })
120
+}
121
+export function getLogById(id: number) {
122
+  return request.get(`/base/sys/log/${id}`)
123
+}
124
+export function getLogExportUrl(params: Record<string, any> = {}) {
125
+  const query = new URLSearchParams()
126
+  Object.entries(params).forEach(([k, v]) => { if (v) query.set(k, String(v)) })
127
+  return `/api/base/sys/log/export?${query.toString()}`
128
+}
129
+export function cleanLogs() {
130
+  return request.delete('/base/sys/log/clean')
131
+}

+ 2
- 0
frontend/src/router/index.ts Näytä tiedosto

@@ -11,6 +11,8 @@ const routes = [
11 11
       { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
12 12
       { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
13 13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
14
+      { path: 'system/dict', name: 'dict', component: () => import('@/views/system/dict/DictionaryView.vue') },
15
+      { path: 'system/log', name: 'log', component: () => import('@/views/system/log/OperationLogView.vue') },
14 16
       { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15 17
       { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
16 18
     ]

+ 142
- 0
frontend/src/views/system/dept/DeptList.vue Näytä tiedosto

@@ -0,0 +1,142 @@
1
+<template>
2
+  <div class="page-container">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>部门管理</span>
7
+          <el-button type="primary" @click="openDialog()">新增部门</el-button>
8
+        </div>
9
+      </template>
10
+
11
+      <el-table :data="treeData" row-key="id" border default-expand-all>
12
+        <el-table-column prop="deptName" label="部门名称" min-width="200" />
13
+        <el-table-column prop="deptType" label="部门类型" width="120">
14
+          <template #default="{ row }">
15
+            <el-tag>{{ typeMap[row.deptType] || row.deptType }}</el-tag>
16
+          </template>
17
+        </el-table-column>
18
+        <el-table-column prop="leader" label="负责人" width="100" />
19
+        <el-table-column prop="phone" label="联系电话" width="130" />
20
+        <el-table-column prop="sortOrder" label="排序" width="70" />
21
+        <el-table-column prop="status" label="状态" width="80">
22
+          <template #default="{ row }">
23
+            <el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
24
+          </template>
25
+        </el-table-column>
26
+        <el-table-column label="操作" width="180" fixed="right">
27
+          <template #default="{ row }">
28
+            <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
29
+            <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
30
+          </template>
31
+        </el-table-column>
32
+      </el-table>
33
+    </el-card>
34
+
35
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑部门' : '新增部门'" width="500px">
36
+      <el-form :model="form" label-width="80px">
37
+        <el-form-item label="上级部门">
38
+          <el-tree-select
39
+            v-model="form.parentId"
40
+            :data="treeSelectData"
41
+            :props="{ label: 'deptName', value: 'id', children: 'children' }"
42
+            check-strictly
43
+            placeholder="选择上级部门(留空为顶级)"
44
+            clearable
45
+            style="width: 100%"
46
+          />
47
+        </el-form-item>
48
+        <el-form-item label="部门名称" required>
49
+          <el-input v-model="form.deptName" />
50
+        </el-form-item>
51
+        <el-form-item label="部门类型">
52
+          <el-select v-model="form.deptType">
53
+            <el-option label="水利局" value="water_bureau" />
54
+            <el-option label="水务公司" value="water_company" />
55
+            <el-option label="运维单位" value="ops" />
56
+          </el-select>
57
+        </el-form-item>
58
+        <el-form-item label="负责人">
59
+          <el-input v-model="form.leader" />
60
+        </el-form-item>
61
+        <el-form-item label="联系电话">
62
+          <el-input v-model="form.phone" />
63
+        </el-form-item>
64
+        <el-form-item label="排序">
65
+          <el-input-number v-model="form.sortOrder" :min="0" />
66
+        </el-form-item>
67
+        <el-form-item label="状态">
68
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
69
+        </el-form-item>
70
+      </el-form>
71
+      <template #footer>
72
+        <el-button @click="dialogVisible = false">取消</el-button>
73
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
74
+      </template>
75
+    </el-dialog>
76
+  </div>
77
+</template>
78
+
79
+<script setup lang="ts">
80
+import { ref, reactive, computed, onMounted } from 'vue'
81
+import { ElMessage, ElMessageBox } from 'element-plus'
82
+import { getDeptTree, createDept, updateDept, deleteDept } from '@/api/system'
83
+
84
+const typeMap: Record<string, string> = { water_bureau: '水利局', water_company: '水务公司', ops: '运维单位' }
85
+
86
+const treeData = ref<any[]>([])
87
+const dialogVisible = ref(false)
88
+const isEdit = ref(false)
89
+const editId = ref<number>(0)
90
+
91
+const defaultForm = { parentId: null as number | null, deptName: '', deptType: 'water_company', leader: '', phone: '', sortOrder: 0, status: 1 }
92
+const form = reactive({ ...defaultForm })
93
+
94
+const treeSelectData = computed(() => [{ id: 0, deptName: '顶级部门', children: treeData.value }])
95
+
96
+function loadData() {
97
+  getDeptTree().then((res: any) => {
98
+    treeData.value = res.data
99
+  })
100
+}
101
+
102
+function openDialog(row?: any) {
103
+  if (row) {
104
+    isEdit.value = true
105
+    editId.value = row.id
106
+    Object.assign(form, row)
107
+  } else {
108
+    isEdit.value = false
109
+    editId.value = 0
110
+    Object.assign(form, defaultForm)
111
+  }
112
+  dialogVisible.value = true
113
+}
114
+
115
+function handleSubmit() {
116
+  if (!form.deptName) { ElMessage.warning('请输入部门名称'); return }
117
+  const action = isEdit.value
118
+    ? updateDept(editId.value, form)
119
+    : createDept(form)
120
+  action.then(() => {
121
+    ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
122
+    dialogVisible.value = false
123
+    loadData()
124
+  })
125
+}
126
+
127
+function handleDelete(id: number) {
128
+  ElMessageBox.confirm('确认删除该部门? 子部门也将被删除', '提示', { type: 'warning' }).then(() => {
129
+    deleteDept(id).then(() => {
130
+      ElMessage.success('删除成功')
131
+      loadData()
132
+    })
133
+  })
134
+}
135
+
136
+onMounted(loadData)
137
+</script>
138
+
139
+<style scoped>
140
+.page-container { padding: 16px; }
141
+.card-header { display: flex; justify-content: space-between; align-items: center; }
142
+</style>

+ 0
- 22
frontend/src/views/system/dept/部门List.vue Näytä tiedosto

@@ -1,22 +0,0 @@
1
-<template>
2
-  <div>
3
-    <el-button type="primary" @click="dialogVisible=true">新增部门</el-button>
4
-    <el-table :data="tableData" style="margin-top:10px" border>
5
-      <el-table-column prop="id" label="ID" width="80" />
6
-      <el-table-column prop="name" label="名称" />
7
-      <el-table-column label="操作" width="180">
8
-        <template #default>
9
-          <el-button link type="primary">编辑</el-button>
10
-          <el-button link type="danger">删除</el-button>
11
-        </template>
12
-      </el-table-column>
13
-    </el-table>
14
-    <el-dialog v-model="dialogVisible" title="新增部门"><span>表单内容待实现</span></el-dialog>
15
-  </div>
16
-</template>
17
-
18
-<script setup lang="ts">
19
-import { ref } from 'vue'
20
-const dialogVisible = ref(false)
21
-const tableData = ref<Array<{id:number,name:string}>>([])
22
-</script>

+ 257
- 0
frontend/src/views/system/dict/DictionaryView.vue Näytä tiedosto

@@ -0,0 +1,257 @@
1
+<template>
2
+  <div class="page-container">
3
+    <el-row :gutter="16">
4
+      <!-- 左侧:字典类型 -->
5
+      <el-col :span="10">
6
+        <el-card shadow="never">
7
+          <template #header>
8
+            <div class="card-header">
9
+              <span>字典类型</span>
10
+              <div>
11
+                <el-button size="small" @click="clearCache">清空缓存</el-button>
12
+                <el-button size="small" type="primary" @click="openTypeDialog()">新增</el-button>
13
+              </div>
14
+            </div>
15
+          </template>
16
+
17
+          <el-form :inline="true" @submit.prevent="loadTypes">
18
+            <el-form-item>
19
+              <el-input v-model="searchName" placeholder="搜索字典名称" clearable @clear="loadTypes" />
20
+            </el-form-item>
21
+          </el-form>
22
+
23
+          <el-table :data="types" border highlight-current-row @current-change="onTypeSelect" style="margin-top: 10px">
24
+            <el-table-column prop="dictName" label="字典名称" />
25
+            <el-table-column prop="dictKey" label="字典标识" width="150" />
26
+            <el-table-column prop="status" label="状态" width="70">
27
+              <template #default="{ row }">
28
+                <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
29
+              </template>
30
+            </el-table-column>
31
+            <el-table-column label="操作" width="110">
32
+              <template #default="{ row }">
33
+                <el-button link type="primary" @click.stop="openTypeDialog(row)">编辑</el-button>
34
+                <el-button link type="danger" @click.stop="handleDeleteType(row.id)">删除</el-button>
35
+              </template>
36
+            </el-table-column>
37
+          </el-table>
38
+        </el-card>
39
+      </el-col>
40
+
41
+      <!-- 右侧:字典数据 -->
42
+      <el-col :span="14">
43
+        <el-card shadow="never">
44
+          <template #header>
45
+            <div class="card-header">
46
+              <span>字典数据 <el-tag v-if="selectedType" size="small" type="info">{{ selectedType.dictName }}</el-tag></span>
47
+              <el-button size="small" type="primary" :disabled="!selectedType" @click="openDataDialog()">新增</el-button>
48
+            </div>
49
+          </template>
50
+
51
+          <el-table :data="dataList" border>
52
+            <el-table-column prop="dictLabel" label="标签" />
53
+            <el-table-column prop="dictValue" label="值" width="120" />
54
+            <el-table-column prop="sortOrder" label="排序" width="70" />
55
+            <el-table-column prop="status" label="状态" width="70">
56
+              <template #default="{ row }">
57
+                <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
58
+              </template>
59
+            </el-table-column>
60
+            <el-table-column prop="remark" label="备注" />
61
+            <el-table-column label="操作" width="110">
62
+              <template #default="{ row }">
63
+                <el-button link type="primary" @click="openDataDialog(row)">编辑</el-button>
64
+                <el-button link type="danger" @click="handleDeleteData(row.id)">删除</el-button>
65
+              </template>
66
+            </el-table-column>
67
+          </el-table>
68
+        </el-card>
69
+      </el-col>
70
+    </el-row>
71
+
72
+    <!-- 字典类型对话框 -->
73
+    <el-dialog v-model="typeDialogVisible" :title="isTypeEdit ? '编辑字典类型' : '新增字典类型'" width="450px">
74
+      <el-form :model="typeForm" label-width="80px">
75
+        <el-form-item label="字典名称" required>
76
+          <el-input v-model="typeForm.dictName" />
77
+        </el-form-item>
78
+        <el-form-item label="字典标识" required>
79
+          <el-input v-model="typeForm.dictKey" :disabled="isTypeEdit" />
80
+        </el-form-item>
81
+        <el-form-item label="状态">
82
+          <el-switch v-model="typeForm.status" :active-value="1" :inactive-value="0" />
83
+        </el-form-item>
84
+        <el-form-item label="备注">
85
+          <el-input v-model="typeForm.remark" type="textarea" :rows="2" />
86
+        </el-form-item>
87
+      </el-form>
88
+      <template #footer>
89
+        <el-button @click="typeDialogVisible = false">取消</el-button>
90
+        <el-button type="primary" @click="handleSubmitType">确定</el-button>
91
+      </template>
92
+    </el-dialog>
93
+
94
+    <!-- 字典数据对话框 -->
95
+    <el-dialog v-model="dataDialogVisible" :title="isDataEdit ? '编辑字典数据' : '新增字典数据'" width="450px">
96
+      <el-form :model="dataForm" label-width="80px">
97
+        <el-form-item label="标签" required>
98
+          <el-input v-model="dataForm.dictLabel" />
99
+        </el-form-item>
100
+        <el-form-item label="值" required>
101
+          <el-input v-model="dataForm.dictValue" />
102
+        </el-form-item>
103
+        <el-form-item label="CSS Class">
104
+          <el-input v-model="dataForm.cssClass" />
105
+        </el-form-item>
106
+        <el-form-item label="排序">
107
+          <el-input-number v-model="dataForm.sortOrder" :min="0" />
108
+        </el-form-item>
109
+        <el-form-item label="状态">
110
+          <el-switch v-model="dataForm.status" :active-value="1" :inactive-value="0" />
111
+        </el-form-item>
112
+        <el-form-item label="备注">
113
+          <el-input v-model="dataForm.remark" type="textarea" :rows="2" />
114
+        </el-form-item>
115
+      </el-form>
116
+      <template #footer>
117
+        <el-button @click="dataDialogVisible = false">取消</el-button>
118
+        <el-button type="primary" @click="handleSubmitData">确定</el-button>
119
+      </template>
120
+    </el-dialog>
121
+  </div>
122
+</template>
123
+
124
+<script setup lang="ts">
125
+import { ref, reactive, onMounted } from 'vue'
126
+import { ElMessage, ElMessageBox } from 'element-plus'
127
+import {
128
+  getDictTypes, createDictType, updateDictType, deleteDictType,
129
+  getDictData, createDictData, updateDictData, deleteDictData,
130
+  clearDictCache
131
+} from '@/api/system'
132
+
133
+const searchName = ref('')
134
+const types = ref<any[]>([])
135
+const selectedType = ref<any>(null)
136
+const dataList = ref<any[]>([])
137
+
138
+// 字典类型表单
139
+const typeDialogVisible = ref(false)
140
+const isTypeEdit = ref(false)
141
+const typeEditId = ref<number>(0)
142
+const typeForm = reactive({ dictName: '', dictKey: '', status: 1, remark: '' })
143
+
144
+// 字典数据表单
145
+const dataDialogVisible = ref(false)
146
+const isDataEdit = ref(false)
147
+const dataEditId = ref<number>(0)
148
+const dataForm = reactive({ dictLabel: '', dictValue: '', cssClass: '', sortOrder: 0, status: 1, remark: '' })
149
+
150
+function loadTypes() {
151
+  getDictTypes(searchName.value || undefined).then((res: any) => {
152
+    types.value = res.data
153
+  })
154
+}
155
+
156
+function onTypeSelect(row: any) {
157
+  selectedType.value = row
158
+  if (row) {
159
+    loadDataList(row.id)
160
+  } else {
161
+    dataList.value = []
162
+  }
163
+}
164
+
165
+function loadDataList(typeId: number) {
166
+  getDictData(typeId).then((res: any) => {
167
+    dataList.value = res.data
168
+  })
169
+}
170
+
171
+// ============ 字典类型操作 ============
172
+function openTypeDialog(row?: any) {
173
+  if (row) {
174
+    isTypeEdit.value = true
175
+    typeEditId.value = row.id
176
+    Object.assign(typeForm, row)
177
+  } else {
178
+    isTypeEdit.value = false
179
+    typeEditId.value = 0
180
+    Object.assign(typeForm, { dictName: '', dictKey: '', status: 1, remark: '' })
181
+  }
182
+  typeDialogVisible.value = true
183
+}
184
+
185
+function handleSubmitType() {
186
+  if (!typeForm.dictName || !typeForm.dictKey) { ElMessage.warning('请填写完整信息'); return }
187
+  const action = isTypeEdit.value
188
+    ? updateDictType(typeEditId.value, typeForm)
189
+    : createDictType(typeForm)
190
+  action.then(() => {
191
+    ElMessage.success(isTypeEdit.value ? '更新成功' : '创建成功')
192
+    typeDialogVisible.value = false
193
+    loadTypes()
194
+  })
195
+}
196
+
197
+function handleDeleteType(id: number) {
198
+  ElMessageBox.confirm('删除字典类型将同时删除所有关联数据,确认?', '提示', { type: 'warning' }).then(() => {
199
+    deleteDictType(id).then(() => {
200
+      ElMessage.success('删除成功')
201
+      selectedType.value = null
202
+      dataList.value = []
203
+      loadTypes()
204
+    })
205
+  })
206
+}
207
+
208
+// ============ 字典数据操作 ============
209
+function openDataDialog(row?: any) {
210
+  if (!selectedType.value) return
211
+  if (row) {
212
+    isDataEdit.value = true
213
+    dataEditId.value = row.id
214
+    Object.assign(dataForm, row)
215
+  } else {
216
+    isDataEdit.value = false
217
+    dataEditId.value = 0
218
+    Object.assign(dataForm, { dictLabel: '', dictValue: '', cssClass: '', sortOrder: 0, status: 1, remark: '' })
219
+  }
220
+  dataDialogVisible.value = true
221
+}
222
+
223
+function handleSubmitData() {
224
+  if (!dataForm.dictLabel || !dataForm.dictValue) { ElMessage.warning('请填写完整信息'); return }
225
+  const payload = { ...dataForm, dictTypeId: selectedType.value.id }
226
+  const action = isDataEdit.value
227
+    ? updateDictData(dataEditId.value, payload)
228
+    : createDictData(payload)
229
+  action.then(() => {
230
+    ElMessage.success(isDataEdit.value ? '更新成功' : '创建成功')
231
+    dataDialogVisible.value = false
232
+    loadDataList(selectedType.value.id)
233
+  })
234
+}
235
+
236
+function handleDeleteData(id: number) {
237
+  ElMessageBox.confirm('确认删除该字典数据?', '提示', { type: 'warning' }).then(() => {
238
+    deleteDictData(id).then(() => {
239
+      ElMessage.success('删除成功')
240
+      loadDataList(selectedType.value.id)
241
+    })
242
+  })
243
+}
244
+
245
+function clearCache() {
246
+  clearDictCache().then(() => {
247
+    ElMessage.success('缓存已清空')
248
+  })
249
+}
250
+
251
+onMounted(loadTypes)
252
+</script>
253
+
254
+<style scoped>
255
+.page-container { padding: 16px; }
256
+.card-header { display: flex; justify-content: space-between; align-items: center; }
257
+</style>

+ 208
- 0
frontend/src/views/system/log/OperationLogView.vue Näytä tiedosto

@@ -0,0 +1,208 @@
1
+<template>
2
+  <div class="page-container">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>操作日志</span>
7
+          <div>
8
+            <el-button @click="handleExport">导出CSV</el-button>
9
+            <el-popconfirm title="确认清空所有日志?" @confirm="handleClean">
10
+              <template #reference>
11
+                <el-button type="danger">清空日志</el-button>
12
+              </template>
13
+            </el-popconfirm>
14
+          </div>
15
+        </div>
16
+      </template>
17
+
18
+      <!-- 筛选区 -->
19
+      <el-form :inline="true" @submit.prevent="loadData">
20
+        <el-form-item label="用户">
21
+          <el-input v-model="searchForm.username" placeholder="用户名" clearable style="width: 120px" />
22
+        </el-form-item>
23
+        <el-form-item label="模块">
24
+          <el-select v-model="searchForm.module" clearable placeholder="全部" style="width: 130px">
25
+            <el-option label="系统管理" value="系统管理" />
26
+            <el-option label="监控中心" value="监控中心" />
27
+            <el-option label="生产管理" value="生产管理" />
28
+            <el-option label="调度指挥" value="调度指挥" />
29
+            <el-option label="营收管理" value="营收管理" />
30
+          </el-select>
31
+        </el-form-item>
32
+        <el-form-item label="操作">
33
+          <el-input v-model="searchForm.operation" placeholder="操作名称" clearable style="width: 120px" />
34
+        </el-form-item>
35
+        <el-form-item label="状态">
36
+          <el-select v-model="searchForm.status" clearable placeholder="全部" style="width: 100px">
37
+            <el-option label="成功" :value="1" />
38
+            <el-option label="失败" :value="0" />
39
+          </el-select>
40
+        </el-form-item>
41
+        <el-form-item label="时间范围">
42
+          <el-date-picker
43
+            v-model="timeRange"
44
+            type="datetimerange"
45
+            range-separator="至"
46
+            start-placeholder="开始时间"
47
+            end-placeholder="结束时间"
48
+            value-format="YYYY-MM-DD HH:mm:ss"
49
+            style="width: 360px"
50
+          />
51
+        </el-form-item>
52
+        <el-form-item>
53
+          <el-button type="primary" @click="loadData">搜索</el-button>
54
+          <el-button @click="resetSearch">重置</el-button>
55
+        </el-form-item>
56
+      </el-form>
57
+
58
+      <el-table :data="tableData" border stripe style="width: 100%; margin-top: 10px">
59
+        <el-table-column prop="id" label="ID" width="70" />
60
+        <el-table-column prop="username" label="用户" width="100" />
61
+        <el-table-column prop="module" label="模块" width="100" />
62
+        <el-table-column prop="operation" label="操作" width="120" />
63
+        <el-table-column prop="requestMethod" label="方式" width="70">
64
+          <template #default="{ row }">
65
+            <el-tag size="small">{{ row.requestMethod }}</el-tag>
66
+          </template>
67
+        </el-table-column>
68
+        <el-table-column prop="requestUrl" label="请求URL" min-width="200" show-overflow-tooltip />
69
+        <el-table-column prop="duration" label="耗时" width="90">
70
+          <template #default="{ row }">
71
+            {{ row.duration }}ms
72
+          </template>
73
+        </el-table-column>
74
+        <el-table-column prop="status" label="状态" width="80">
75
+          <template #default="{ row }">
76
+            <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '成功' : '失败' }}</el-tag>
77
+          </template>
78
+        </el-table-column>
79
+        <el-table-column prop="createdAt" label="时间" width="170" />
80
+        <el-table-column label="操作" width="80" fixed="right">
81
+          <template #default="{ row }">
82
+            <el-button link type="primary" @click="viewDetail(row)">详情</el-button>
83
+          </template>
84
+        </el-table-column>
85
+      </el-table>
86
+
87
+      <el-pagination
88
+        v-model:current-page="pagination.page"
89
+        v-model:page-size="pagination.size"
90
+        :total="pagination.total"
91
+        :page-sizes="[10, 20, 50, 100]"
92
+        layout="total, sizes, prev, pager, next"
93
+        style="margin-top: 15px; justify-content: flex-end"
94
+        @size-change="loadData"
95
+        @current-change="loadData"
96
+      />
97
+    </el-card>
98
+
99
+    <!-- 详情对话框 -->
100
+    <el-dialog v-model="detailVisible" title="日志详情" width="700px">
101
+      <el-descriptions :column="2" border v-if="detailData">
102
+        <el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
103
+        <el-descriptions-item label="用户">{{ detailData.username }}</el-descriptions-item>
104
+        <el-descriptions-item label="模块">{{ detailData.module }}</el-descriptions-item>
105
+        <el-descriptions-item label="操作">{{ detailData.operation }}</el-descriptions-item>
106
+        <el-descriptions-item label="请求方式">{{ detailData.requestMethod }}</el-descriptions-item>
107
+        <el-descriptions-item label="耗时">{{ detailData.duration }}ms</el-descriptions-item>
108
+        <el-descriptions-item label="请求URL" :span="2">{{ detailData.requestUrl }}</el-descriptions-item>
109
+        <el-descriptions-item label="IP">{{ detailData.ip }}</el-descriptions-item>
110
+        <el-descriptions-item label="状态">
111
+          <el-tag :type="detailData.status === 1 ? 'success' : 'danger'">{{ detailData.status === 1 ? '成功' : '失败' }}</el-tag>
112
+        </el-descriptions-item>
113
+        <el-descriptions-item label="请求参数" :span="2">
114
+          <div class="code-block">{{ detailData.requestParams }}</div>
115
+        </el-descriptions-item>
116
+        <el-descriptions-item label="返回结果" :span="2">
117
+          <div class="code-block">{{ detailData.responseResult }}</div>
118
+        </el-descriptions-item>
119
+        <el-descriptions-item label="错误信息" :span="2" v-if="detailData.errorMsg">
120
+          <div class="code-block error">{{ detailData.errorMsg }}</div>
121
+        </el-descriptions-item>
122
+      </el-descriptions>
123
+    </el-dialog>
124
+  </div>
125
+</template>
126
+
127
+<script setup lang="ts">
128
+import { ref, reactive, onMounted } from 'vue'
129
+import { ElMessage } from 'element-plus'
130
+import { getLogList, getLogById, getLogExportUrl, cleanLogs } from '@/api/system'
131
+
132
+const searchForm = reactive({ username: '', module: '', operation: '', status: undefined as number | undefined })
133
+const timeRange = ref<[string, string] | null>(null)
134
+const tableData = ref<any[]>([])
135
+const pagination = reactive({ page: 1, size: 20, total: 0 })
136
+const detailVisible = ref(false)
137
+const detailData = ref<any>(null)
138
+
139
+function getSearchParams() {
140
+  const params: Record<string, any> = {}
141
+  if (searchForm.username) params.username = searchForm.username
142
+  if (searchForm.module) params.module = searchForm.module
143
+  if (searchForm.operation) params.operation = searchForm.operation
144
+  if (searchForm.status !== undefined) params.status = searchForm.status
145
+  if (timeRange.value) {
146
+    params.startTime = timeRange.value[0]
147
+    params.endTime = timeRange.value[1]
148
+  }
149
+  return params
150
+}
151
+
152
+function loadData() {
153
+  const params = getSearchParams()
154
+  getLogList({ page: pagination.page, size: pagination.size, ...params }).then((res: any) => {
155
+    tableData.value = res.data.records
156
+    pagination.total = res.data.total
157
+  })
158
+}
159
+
160
+function resetSearch() {
161
+  searchForm.username = ''
162
+  searchForm.module = ''
163
+  searchForm.operation = ''
164
+  searchForm.status = undefined
165
+  timeRange.value = null
166
+  pagination.page = 1
167
+  loadData()
168
+}
169
+
170
+function viewDetail(row: any) {
171
+  getLogById(row.id).then((res: any) => {
172
+    detailData.value = res.data
173
+    detailVisible.value = true
174
+  })
175
+}
176
+
177
+function handleExport() {
178
+  const params = getSearchParams()
179
+  const url = getLogExportUrl(params)
180
+  window.open(url, '_blank')
181
+}
182
+
183
+function handleClean() {
184
+  cleanLogs().then(() => {
185
+    ElMessage.success('日志已清空')
186
+    loadData()
187
+  })
188
+}
189
+
190
+onMounted(loadData)
191
+</script>
192
+
193
+<style scoped>
194
+.page-container { padding: 16px; }
195
+.card-header { display: flex; justify-content: space-between; align-items: center; }
196
+.code-block {
197
+  background: #f5f7fa;
198
+  padding: 8px;
199
+  border-radius: 4px;
200
+  font-family: monospace;
201
+  font-size: 12px;
202
+  white-space: pre-wrap;
203
+  word-break: break-all;
204
+  max-height: 200px;
205
+  overflow-y: auto;
206
+}
207
+.code-block.error { background: #fef0f0; color: #f56c6c; }
208
+</style>

+ 162
- 0
frontend/src/views/system/menu/MenuList.vue Näytä tiedosto

@@ -0,0 +1,162 @@
1
+<template>
2
+  <div class="page-container">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>菜单管理</span>
7
+          <el-button type="primary" @click="openDialog()">新增菜单</el-button>
8
+        </div>
9
+      </template>
10
+
11
+      <el-table :data="treeData" row-key="id" border default-expand-all>
12
+        <el-table-column prop="menuName" label="菜单名称" min-width="180" />
13
+        <el-table-column prop="menuType" label="类型" width="70">
14
+          <template #default="{ row }">
15
+            <el-tag :type="menuTypeMap[row.menuType]?.type || 'info'">{{ menuTypeMap[row.menuType]?.label || row.menuType }}</el-tag>
16
+          </template>
17
+        </el-table-column>
18
+        <el-table-column prop="icon" label="图标" width="80" />
19
+        <el-table-column prop="path" label="路径" min-width="150" />
20
+        <el-table-column prop="component" label="组件" min-width="150" />
21
+        <el-table-column prop="perms" label="权限标识" min-width="150" />
22
+        <el-table-column prop="sortOrder" label="排序" width="70" />
23
+        <el-table-column prop="visible" label="可见" width="70">
24
+          <template #default="{ row }">
25
+            <el-tag :type="row.visible === 1 ? 'success' : 'info'">{{ row.visible === 1 ? '是' : '否' }}</el-tag>
26
+          </template>
27
+        </el-table-column>
28
+        <el-table-column prop="status" label="状态" width="70">
29
+          <template #default="{ row }">
30
+            <el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
31
+          </template>
32
+        </el-table-column>
33
+        <el-table-column label="操作" width="180" fixed="right">
34
+          <template #default="{ row }">
35
+            <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
36
+            <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
37
+          </template>
38
+        </el-table-column>
39
+      </el-table>
40
+    </el-card>
41
+
42
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑菜单' : '新增菜单'" width="600px">
43
+      <el-form :model="form" label-width="80px">
44
+        <el-form-item label="菜单类型">
45
+          <el-radio-group v-model="form.menuType">
46
+            <el-radio value="M">目录</el-radio>
47
+            <el-radio value="C">菜单</el-radio>
48
+            <el-radio value="F">按钮</el-radio>
49
+          </el-radio-group>
50
+        </el-form-item>
51
+        <el-form-item label="上级菜单">
52
+          <el-tree-select
53
+            v-model="form.parentId"
54
+            :data="treeSelectData"
55
+            :props="{ label: 'menuName', value: 'id', children: 'children' }"
56
+            check-strictly
57
+            placeholder="选择上级菜单(留空为顶级)"
58
+            clearable
59
+            style="width: 100%"
60
+          />
61
+        </el-form-item>
62
+        <el-form-item label="菜单名称" required>
63
+          <el-input v-model="form.menuName" />
64
+        </el-form-item>
65
+        <el-form-item label="图标" v-if="form.menuType !== 'F'">
66
+          <el-input v-model="form.icon" />
67
+        </el-form-item>
68
+        <el-form-item label="路由路径" v-if="form.menuType !== 'F'">
69
+          <el-input v-model="form.path" />
70
+        </el-form-item>
71
+        <el-form-item label="组件路径" v-if="form.menuType === 'C'">
72
+          <el-input v-model="form.component" />
73
+        </el-form-item>
74
+        <el-form-item label="权限标识" v-if="form.menuType !== 'M'">
75
+          <el-input v-model="form.perms" />
76
+        </el-form-item>
77
+        <el-form-item label="排序">
78
+          <el-input-number v-model="form.sortOrder" :min="0" />
79
+        </el-form-item>
80
+        <el-form-item label="可见" v-if="form.menuType !== 'F'">
81
+          <el-switch v-model="form.visible" :active-value="1" :inactive-value="0" />
82
+        </el-form-item>
83
+        <el-form-item label="状态">
84
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
85
+        </el-form-item>
86
+      </el-form>
87
+      <template #footer>
88
+        <el-button @click="dialogVisible = false">取消</el-button>
89
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
90
+      </template>
91
+    </el-dialog>
92
+  </div>
93
+</template>
94
+
95
+<script setup lang="ts">
96
+import { ref, reactive, computed, onMounted } from 'vue'
97
+import { ElMessage, ElMessageBox } from 'element-plus'
98
+import { getMenuTree, createMenu, updateMenu, deleteMenu } from '@/api/system'
99
+
100
+const menuTypeMap: Record<string, { label: string; type: string }> = {
101
+  M: { label: '目录', type: '' },
102
+  C: { label: '菜单', type: 'success' },
103
+  F: { label: '按钮', type: 'warning' }
104
+}
105
+
106
+const treeData = ref<any[]>([])
107
+const dialogVisible = ref(false)
108
+const isEdit = ref(false)
109
+const editId = ref<number>(0)
110
+
111
+const defaultForm = { parentId: null as number | null, menuName: '', menuType: 'M', path: '', component: '', perms: '', icon: '', sortOrder: 0, visible: 1, status: 1 }
112
+const form = reactive({ ...defaultForm })
113
+
114
+const treeSelectData = computed(() => [{ id: 0, menuName: '顶级菜单', children: treeData.value }])
115
+
116
+function loadData() {
117
+  getMenuTree().then((res: any) => {
118
+    treeData.value = res.data
119
+  })
120
+}
121
+
122
+function openDialog(row?: any) {
123
+  if (row) {
124
+    isEdit.value = true
125
+    editId.value = row.id
126
+    Object.assign(form, row)
127
+  } else {
128
+    isEdit.value = false
129
+    editId.value = 0
130
+    Object.assign(form, defaultForm)
131
+  }
132
+  dialogVisible.value = true
133
+}
134
+
135
+function handleSubmit() {
136
+  if (!form.menuName) { ElMessage.warning('请输入菜单名称'); return }
137
+  const action = isEdit.value
138
+    ? updateMenu(editId.value, form)
139
+    : createMenu(form)
140
+  action.then(() => {
141
+    ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
142
+    dialogVisible.value = false
143
+    loadData()
144
+  })
145
+}
146
+
147
+function handleDelete(id: number) {
148
+  ElMessageBox.confirm('确认删除该菜单?', '提示', { type: 'warning' }).then(() => {
149
+    deleteMenu(id).then(() => {
150
+      ElMessage.success('删除成功')
151
+      loadData()
152
+    })
153
+  })
154
+}
155
+
156
+onMounted(loadData)
157
+</script>
158
+
159
+<style scoped>
160
+.page-container { padding: 16px; }
161
+.card-header { display: flex; justify-content: space-between; align-items: center; }
162
+</style>

+ 0
- 22
frontend/src/views/system/menu/菜单List.vue Näytä tiedosto

@@ -1,22 +0,0 @@
1
-<template>
2
-  <div>
3
-    <el-button type="primary" @click="dialogVisible=true">新增菜单</el-button>
4
-    <el-table :data="tableData" style="margin-top:10px" border>
5
-      <el-table-column prop="id" label="ID" width="80" />
6
-      <el-table-column prop="name" label="名称" />
7
-      <el-table-column label="操作" width="180">
8
-        <template #default>
9
-          <el-button link type="primary">编辑</el-button>
10
-          <el-button link type="danger">删除</el-button>
11
-        </template>
12
-      </el-table-column>
13
-    </el-table>
14
-    <el-dialog v-model="dialogVisible" title="新增菜单"><span>表单内容待实现</span></el-dialog>
15
-  </div>
16
-</template>
17
-
18
-<script setup lang="ts">
19
-import { ref } from 'vue'
20
-const dialogVisible = ref(false)
21
-const tableData = ref<Array<{id:number,name:string}>>([])
22
-</script>

+ 197
- 0
frontend/src/views/system/role/RoleList.vue Näytä tiedosto

@@ -0,0 +1,197 @@
1
+<template>
2
+  <div class="page-container">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>角色管理</span>
7
+          <div>
8
+            <el-button @click="loadPresets">查看预设</el-button>
9
+            <el-button type="primary" @click="openDialog()">新增角色</el-button>
10
+          </div>
11
+        </div>
12
+      </template>
13
+
14
+      <el-table :data="tableData" border stripe style="width: 100%">
15
+        <el-table-column prop="id" label="ID" width="70" />
16
+        <el-table-column prop="roleName" label="角色名称" width="120" />
17
+        <el-table-column prop="roleKey" label="角色标识" width="130">
18
+          <template #default="{ row }">
19
+            <el-tag type="info">{{ row.roleKey }}</el-tag>
20
+          </template>
21
+        </el-table-column>
22
+        <el-table-column prop="roleSort" label="排序" width="70" />
23
+        <el-table-column prop="dataScope" label="数据范围" width="100">
24
+          <template #default="{ row }">
25
+            <el-tag :type="scopeTypeMap[row.dataScope] || 'info'">{{ scopeLabelMap[row.dataScope] || row.dataScope }}</el-tag>
26
+          </template>
27
+        </el-table-column>
28
+        <el-table-column prop="status" label="状态" width="80">
29
+          <template #default="{ row }">
30
+            <el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
31
+          </template>
32
+        </el-table-column>
33
+        <el-table-column prop="remark" label="备注" />
34
+        <el-table-column label="操作" width="180" fixed="right">
35
+          <template #default="{ row }">
36
+            <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
37
+            <el-button link type="primary" @click="viewPermissions(row.roleKey)">权限</el-button>
38
+            <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
39
+          </template>
40
+        </el-table-column>
41
+      </el-table>
42
+
43
+      <el-pagination
44
+        v-model:current-page="pagination.page"
45
+        v-model:page-size="pagination.size"
46
+        :total="pagination.total"
47
+        :page-sizes="[10, 20, 50]"
48
+        layout="total, sizes, prev, pager, next"
49
+        style="margin-top: 15px; justify-content: flex-end"
50
+        @size-change="loadData"
51
+        @current-change="loadData"
52
+      />
53
+    </el-card>
54
+
55
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑角色' : '新增角色'" width="500px">
56
+      <el-form :model="form" label-width="80px">
57
+        <el-form-item label="角色名称" required>
58
+          <el-input v-model="form.roleName" />
59
+        </el-form-item>
60
+        <el-form-item label="角色标识" required>
61
+          <el-input v-model="form.roleKey" :disabled="isEdit" />
62
+        </el-form-item>
63
+        <el-form-item label="排序">
64
+          <el-input-number v-model="form.roleSort" :min="0" />
65
+        </el-form-item>
66
+        <el-form-item label="数据范围">
67
+          <el-select v-model="form.dataScope">
68
+            <el-option label="全部数据" value="ALL" />
69
+            <el-option label="部门数据" value="DEPT" />
70
+            <el-option label="自定义" value="CUSTOM" />
71
+            <el-option label="仅本人" value="SELF" />
72
+          </el-select>
73
+        </el-form-item>
74
+        <el-form-item label="状态">
75
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
76
+        </el-form-item>
77
+        <el-form-item label="备注">
78
+          <el-input v-model="form.remark" type="textarea" :rows="3" />
79
+        </el-form-item>
80
+      </el-form>
81
+      <template #footer>
82
+        <el-button @click="dialogVisible = false">取消</el-button>
83
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
84
+      </template>
85
+    </el-dialog>
86
+
87
+    <el-dialog v-model="presetVisible" title="5级角色预设" width="700px">
88
+      <el-table :data="presets" border>
89
+        <el-table-column prop="roleName" label="角色名称" width="120" />
90
+        <el-table-column prop="roleKey" label="标识" width="120" />
91
+        <el-table-column prop="roleSort" label="级别" width="60" />
92
+        <el-table-column prop="dataScope" label="数据范围" width="100" />
93
+        <el-table-column prop="remark" label="说明" />
94
+      </el-table>
95
+    </el-dialog>
96
+
97
+    <el-dialog v-model="permVisible" title="角色权限矩阵" width="600px">
98
+      <el-table :data="permData" border>
99
+        <el-table-column prop="module" label="模块" width="120" />
100
+        <el-table-column prop="permissions" label="权限">
101
+          <template #default="{ row }">
102
+            <el-tag v-for="p in row.permissions" :key="p" style="margin: 2px" size="small">{{ p }}</el-tag>
103
+          </template>
104
+        </el-table-column>
105
+      </el-table>
106
+    </el-dialog>
107
+  </div>
108
+</template>
109
+
110
+<script setup lang="ts">
111
+import { ref, reactive, onMounted } from 'vue'
112
+import { ElMessage, ElMessageBox } from 'element-plus'
113
+import { getRoleList, createRole, updateRole, deleteRole, getRolePresets, getRolePermissions } from '@/api/system'
114
+
115
+const scopeTypeMap: Record<string, string> = { ALL: 'danger', DEPT: 'warning', CUSTOM: '', SELF: 'info' }
116
+const scopeLabelMap: Record<string, string> = { ALL: '全部', DEPT: '部门', CUSTOM: '自定义', SELF: '本人' }
117
+
118
+const tableData = ref<any[]>([])
119
+const pagination = reactive({ page: 1, size: 10, total: 0 })
120
+const dialogVisible = ref(false)
121
+const isEdit = ref(false)
122
+const editId = ref<number>(0)
123
+
124
+const defaultForm = { roleName: '', roleKey: '', roleSort: 0, dataScope: 'SELF', status: 1, remark: '' }
125
+const form = reactive({ ...defaultForm })
126
+
127
+const presetVisible = ref(false)
128
+const presets = ref<any[]>([])
129
+const permVisible = ref(false)
130
+const permData = ref<any[]>([])
131
+
132
+function loadData() {
133
+  getRoleList(pagination.page, pagination.size).then((res: any) => {
134
+    tableData.value = res.data.records
135
+    pagination.total = res.data.total
136
+  })
137
+}
138
+
139
+function openDialog(row?: any) {
140
+  if (row) {
141
+    isEdit.value = true
142
+    editId.value = row.id
143
+    Object.assign(form, row)
144
+  } else {
145
+    isEdit.value = false
146
+    editId.value = 0
147
+    Object.assign(form, defaultForm)
148
+  }
149
+  dialogVisible.value = true
150
+}
151
+
152
+function handleSubmit() {
153
+  if (!form.roleName || !form.roleKey) { ElMessage.warning('请填写完整信息'); return }
154
+  const action = isEdit.value
155
+    ? updateRole(editId.value, form)
156
+    : createRole(form)
157
+  action.then(() => {
158
+    ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
159
+    dialogVisible.value = false
160
+    loadData()
161
+  })
162
+}
163
+
164
+function handleDelete(id: number) {
165
+  ElMessageBox.confirm('确认删除该角色?', '提示', { type: 'warning' }).then(() => {
166
+    deleteRole(id).then(() => {
167
+      ElMessage.success('删除成功')
168
+      loadData()
169
+    })
170
+  })
171
+}
172
+
173
+function loadPresets() {
174
+  getRolePresets().then((res: any) => {
175
+    presets.value = res.data
176
+    presetVisible.value = true
177
+  })
178
+}
179
+
180
+function viewPermissions(roleKey: string) {
181
+  getRolePermissions(roleKey).then((res: any) => {
182
+    const data = res.data || {}
183
+    permData.value = Object.entries(data).map(([module, perms]) => ({
184
+      module,
185
+      permissions: perms as string[]
186
+    }))
187
+    permVisible.value = true
188
+  })
189
+}
190
+
191
+onMounted(loadData)
192
+</script>
193
+
194
+<style scoped>
195
+.page-container { padding: 16px; }
196
+.card-header { display: flex; justify-content: space-between; align-items: center; }
197
+</style>

+ 0
- 22
frontend/src/views/system/role/角色List.vue Näytä tiedosto

@@ -1,22 +0,0 @@
1
-<template>
2
-  <div>
3
-    <el-button type="primary" @click="dialogVisible=true">新增角色</el-button>
4
-    <el-table :data="tableData" style="margin-top:10px" border>
5
-      <el-table-column prop="id" label="ID" width="80" />
6
-      <el-table-column prop="name" label="名称" />
7
-      <el-table-column label="操作" width="180">
8
-        <template #default>
9
-          <el-button link type="primary">编辑</el-button>
10
-          <el-button link type="danger">删除</el-button>
11
-        </template>
12
-      </el-table-column>
13
-    </el-table>
14
-    <el-dialog v-model="dialogVisible" title="新增角色"><span>表单内容待实现</span></el-dialog>
15
-  </div>
16
-</template>
17
-
18
-<script setup lang="ts">
19
-import { ref } from 'vue'
20
-const dialogVisible = ref(false)
21
-const tableData = ref<Array<{id:number,name:string}>>([])
22
-</script>

+ 176
- 0
frontend/src/views/system/user/UserList.vue Näytä tiedosto

@@ -0,0 +1,176 @@
1
+<template>
2
+  <div class="page-container">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>用户管理</span>
7
+          <el-button type="primary" @click="openDialog()">新增用户</el-button>
8
+        </div>
9
+      </template>
10
+
11
+      <el-form :inline="true" @submit.prevent="loadData">
12
+        <el-form-item label="用户名">
13
+          <el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
14
+        </el-form-item>
15
+        <el-form-item>
16
+          <el-button type="primary" @click="loadData">搜索</el-button>
17
+          <el-button @click="resetSearch">重置</el-button>
18
+        </el-form-item>
19
+      </el-form>
20
+
21
+      <el-table :data="tableData" border stripe style="width: 100%; margin-top: 10px">
22
+        <el-table-column prop="id" label="ID" width="70" />
23
+        <el-table-column prop="username" label="用户名" width="120" />
24
+        <el-table-column prop="realName" label="姓名" width="100" />
25
+        <el-table-column prop="nickname" label="昵称" width="100" />
26
+        <el-table-column prop="phone" label="手机号" width="130" />
27
+        <el-table-column prop="email" label="邮箱" />
28
+        <el-table-column prop="roleType" label="角色类型" width="100">
29
+          <template #default="{ row }">
30
+            <el-tag>{{ roleTypeMap[row.roleType] || row.roleType }}</el-tag>
31
+          </template>
32
+        </el-table-column>
33
+        <el-table-column prop="status" label="状态" width="80">
34
+          <template #default="{ row }">
35
+            <el-switch :model-value="row.status === 1"
36
+              @change="(val: boolean) => handleToggleStatus(row.id, val ? 1 : 0)" />
37
+          </template>
38
+        </el-table-column>
39
+        <el-table-column label="操作" width="150" fixed="right">
40
+          <template #default="{ row }">
41
+            <el-button link type="primary" @click="openDialog(row)">编辑</el-button>
42
+          </template>
43
+        </el-table-column>
44
+      </el-table>
45
+
46
+      <el-pagination
47
+        v-model:current-page="pagination.page"
48
+        v-model:page-size="pagination.size"
49
+        :total="pagination.total"
50
+        :page-sizes="[10, 20, 50]"
51
+        layout="total, sizes, prev, pager, next"
52
+        style="margin-top: 15px; justify-content: flex-end"
53
+        @size-change="loadData"
54
+        @current-change="loadData"
55
+      />
56
+    </el-card>
57
+
58
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '新增用户'" width="600px">
59
+      <el-form :model="form" label-width="80px">
60
+        <el-form-item label="用户名" required>
61
+          <el-input v-model="form.username" :disabled="isEdit" />
62
+        </el-form-item>
63
+        <el-form-item label="密码" v-if="!isEdit" required>
64
+          <el-input v-model="form.password" type="password" show-password />
65
+        </el-form-item>
66
+        <el-form-item label="姓名">
67
+          <el-input v-model="form.realName" />
68
+        </el-form-item>
69
+        <el-form-item label="昵称">
70
+          <el-input v-model="form.nickname" />
71
+        </el-form-item>
72
+        <el-form-item label="手机号">
73
+          <el-input v-model="form.phone" />
74
+        </el-form-item>
75
+        <el-form-item label="邮箱">
76
+          <el-input v-model="form.email" />
77
+        </el-form-item>
78
+        <el-form-item label="性别">
79
+          <el-radio-group v-model="form.gender">
80
+            <el-radio :value="0">未知</el-radio>
81
+            <el-radio :value="1">男</el-radio>
82
+            <el-radio :value="2">女</el-radio>
83
+          </el-radio-group>
84
+        </el-form-item>
85
+        <el-form-item label="角色类型">
86
+          <el-select v-model="form.roleType">
87
+            <el-option label="管理员" value="admin" />
88
+            <el-option label="分管领导" value="leader" />
89
+            <el-option label="业务管理" value="manager" />
90
+            <el-option label="运维人员" value="operator" />
91
+            <el-option label="技术维护" value="tech" />
92
+          </el-select>
93
+        </el-form-item>
94
+        <el-form-item label="状态">
95
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
96
+        </el-form-item>
97
+      </el-form>
98
+      <template #footer>
99
+        <el-button @click="dialogVisible = false">取消</el-button>
100
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
101
+      </template>
102
+    </el-dialog>
103
+  </div>
104
+</template>
105
+
106
+<script setup lang="ts">
107
+import { ref, reactive, onMounted } from 'vue'
108
+import { ElMessage } from 'element-plus'
109
+import { getUserList, createUser, updateUser, toggleUserStatus } from '@/api/system'
110
+
111
+const roleTypeMap: Record<string, string> = {
112
+  admin: '管理员', leader: '分管领导', manager: '业务管理', operator: '运维', tech: '技术'
113
+}
114
+
115
+const searchForm = reactive({ username: '' })
116
+const tableData = ref<any[]>([])
117
+const pagination = reactive({ page: 1, size: 10, total: 0 })
118
+const dialogVisible = ref(false)
119
+const isEdit = ref(false)
120
+const editId = ref<number>(0)
121
+
122
+const defaultForm = { username: '', password: '', realName: '', nickname: '', phone: '', email: '', gender: 0, roleType: 'operator', status: 1 }
123
+const form = reactive({ ...defaultForm })
124
+
125
+function loadData() {
126
+  getUserList(pagination.page, pagination.size, searchForm.username || undefined).then((res: any) => {
127
+    tableData.value = res.data.records
128
+    pagination.total = res.data.total
129
+  })
130
+}
131
+
132
+function resetSearch() {
133
+  searchForm.username = ''
134
+  pagination.page = 1
135
+  loadData()
136
+}
137
+
138
+function openDialog(row?: any) {
139
+  if (row) {
140
+    isEdit.value = true
141
+    editId.value = row.id
142
+    Object.assign(form, row)
143
+  } else {
144
+    isEdit.value = false
145
+    editId.value = 0
146
+    Object.assign(form, defaultForm)
147
+  }
148
+  dialogVisible.value = true
149
+}
150
+
151
+function handleSubmit() {
152
+  if (!form.username) { ElMessage.warning('请输入用户名'); return }
153
+  const action = isEdit.value
154
+    ? updateUser(editId.value, form)
155
+    : createUser(form)
156
+  action.then(() => {
157
+    ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
158
+    dialogVisible.value = false
159
+    loadData()
160
+  })
161
+}
162
+
163
+function handleToggleStatus(id: number, status: number) {
164
+  toggleUserStatus(id, status).then(() => {
165
+    ElMessage.success(status === 1 ? '已启用' : '已停用')
166
+    loadData()
167
+  })
168
+}
169
+
170
+onMounted(loadData)
171
+</script>
172
+
173
+<style scoped>
174
+.page-container { padding: 16px; }
175
+.card-header { display: flex; justify-content: space-between; align-items: center; }
176
+</style>

+ 0
- 22
frontend/src/views/system/user/用户List.vue Näytä tiedosto

@@ -1,22 +0,0 @@
1
-<template>
2
-  <div>
3
-    <el-button type="primary" @click="dialogVisible=true">新增用户</el-button>
4
-    <el-table :data="tableData" style="margin-top:10px" border>
5
-      <el-table-column prop="id" label="ID" width="80" />
6
-      <el-table-column prop="name" label="名称" />
7
-      <el-table-column label="操作" width="180">
8
-        <template #default>
9
-          <el-button link type="primary">编辑</el-button>
10
-          <el-button link type="danger">删除</el-button>
11
-        </template>
12
-      </el-table-column>
13
-    </el-table>
14
-    <el-dialog v-model="dialogVisible" title="新增用户"><span>表单内容待实现</span></el-dialog>
15
-  </div>
16
-</template>
17
-
18
-<script setup lang="ts">
19
-import { ref } from 'vue'
20
-const dialogVisible = ref(false)
21
-const tableData = ref<Array<{id:number,name:string}>>([])
22
-</script>

+ 102
- 0
wm-base/src/main/java/com/water/base/controller/SysDictionaryController.java Näytä tiedosto

@@ -0,0 +1,102 @@
1
+package com.water.base.controller;
2
+
3
+import com.water.base.entity.SysDictData;
4
+import com.water.base.entity.SysDictType;
5
+import com.water.base.service.SysDictionaryService;
6
+import com.water.common.core.result.R;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.util.List;
13
+
14
+@Tag(name = "数据字典管理")
15
+@RestController
16
+@RequestMapping("/sys/dict")
17
+@RequiredArgsConstructor
18
+public class SysDictionaryController {
19
+
20
+    private final SysDictionaryService dictionaryService;
21
+
22
+    // ============ 字典类型 ============
23
+
24
+    @Operation(summary = "字典类型列表")
25
+    @GetMapping("/type/list")
26
+    public R<List<SysDictType>> listTypes(@RequestParam(required = false) String dictName) {
27
+        return R.ok(dictionaryService.listTypes(dictName));
28
+    }
29
+
30
+    @Operation(summary = "字典类型详情")
31
+    @GetMapping("/type/{id}")
32
+    public R<SysDictType> getType(@PathVariable Long id) {
33
+        return R.ok(dictionaryService.getTypeById(id));
34
+    }
35
+
36
+    @Operation(summary = "新增字典类型")
37
+    @PostMapping("/type")
38
+    public R<String> createType(@RequestBody SysDictType type) {
39
+        dictionaryService.createType(type);
40
+        return R.ok("创建成功");
41
+    }
42
+
43
+    @Operation(summary = "编辑字典类型")
44
+    @PutMapping("/type/{id}")
45
+    public R<String> updateType(@PathVariable Long id, @RequestBody SysDictType type) {
46
+        type.setId(id);
47
+        dictionaryService.updateType(type);
48
+        return R.ok("更新成功");
49
+    }
50
+
51
+    @Operation(summary = "删除字典类型")
52
+    @DeleteMapping("/type/{id}")
53
+    public R<String> deleteType(@PathVariable Long id) {
54
+        dictionaryService.deleteType(id);
55
+        return R.ok("删除成功");
56
+    }
57
+
58
+    // ============ 字典数据 ============
59
+
60
+    @Operation(summary = "字典数据列表(按类型ID)")
61
+    @GetMapping("/data/list")
62
+    public R<List<SysDictData>> listData(@RequestParam Long typeId) {
63
+        return R.ok(dictionaryService.listDataByTypeId(typeId));
64
+    }
65
+
66
+    @Operation(summary = "字典数据列表(按字典key)")
67
+    @GetMapping("/data/key/{dictKey}")
68
+    public R<List<SysDictData>> listDataByKey(@PathVariable String dictKey) {
69
+        return R.ok(dictionaryService.listDataByKey(dictKey));
70
+    }
71
+
72
+    @Operation(summary = "新增字典数据")
73
+    @PostMapping("/data")
74
+    public R<String> createData(@RequestBody SysDictData data) {
75
+        dictionaryService.createData(data);
76
+        return R.ok("创建成功");
77
+    }
78
+
79
+    @Operation(summary = "编辑字典数据")
80
+    @PutMapping("/data/{id}")
81
+    public R<String> updateData(@PathVariable Long id, @RequestBody SysDictData data) {
82
+        data.setId(id);
83
+        dictionaryService.updateData(data);
84
+        return R.ok("更新成功");
85
+    }
86
+
87
+    @Operation(summary = "删除字典数据")
88
+    @DeleteMapping("/data/{id}")
89
+    public R<String> deleteData(@PathVariable Long id) {
90
+        dictionaryService.deleteData(id);
91
+        return R.ok("删除成功");
92
+    }
93
+
94
+    // ============ 缓存 ============
95
+
96
+    @Operation(summary = "清空字典缓存")
97
+    @PostMapping("/cache/clear")
98
+    public R<String> clearCache() {
99
+        dictionaryService.clearCache();
100
+        return R.ok("缓存已清空");
101
+    }
102
+}

+ 98
- 0
wm-base/src/main/java/com/water/base/controller/SysOperationLogController.java Näytä tiedosto

@@ -0,0 +1,98 @@
1
+package com.water.base.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.base.entity.SysOperationLog;
5
+import com.water.base.service.SysOperationLogService;
6
+import com.water.common.core.result.R;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import jakarta.servlet.http.HttpServletResponse;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.format.annotation.DateTimeFormat;
12
+import org.springframework.web.bind.annotation.*;
13
+
14
+import java.io.IOException;
15
+import java.io.OutputStreamWriter;
16
+import java.io.Writer;
17
+import java.nio.charset.StandardCharsets;
18
+import java.time.LocalDateTime;
19
+import java.util.List;
20
+
21
+@Tag(name = "操作日志管理")
22
+@RestController
23
+@RequestMapping("/sys/log")
24
+@RequiredArgsConstructor
25
+public class SysOperationLogController {
26
+
27
+    private final SysOperationLogService logService;
28
+
29
+    @Operation(summary = "分页查询操作日志")
30
+    @GetMapping("/list")
31
+    public R<Page<SysOperationLog>> list(
32
+            @RequestParam(defaultValue = "1") int page,
33
+            @RequestParam(defaultValue = "10") int size,
34
+            @RequestParam(required = false) String username,
35
+            @RequestParam(required = false) String module,
36
+            @RequestParam(required = false) String operation,
37
+            @RequestParam(required = false) Integer status,
38
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
39
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
40
+        return R.ok(logService.queryPage(page, size, username, module, operation, status, startTime, endTime));
41
+    }
42
+
43
+    @Operation(summary = "日志详情")
44
+    @GetMapping("/{id}")
45
+    public R<SysOperationLog> getById(@PathVariable Long id) {
46
+        return R.ok(logService.getById(id));
47
+    }
48
+
49
+    @Operation(summary = "导出操作日志(CSV)")
50
+    @GetMapping("/export")
51
+    public void export(
52
+            @RequestParam(required = false) String username,
53
+            @RequestParam(required = false) String module,
54
+            @RequestParam(required = false) String operation,
55
+            @RequestParam(required = false) Integer status,
56
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
57
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
58
+            HttpServletResponse response) throws IOException {
59
+        List<SysOperationLog> logs = logService.queryForExport(username, module, operation, status, startTime, endTime);
60
+
61
+        response.setContentType("text/csv; charset=UTF-8");
62
+        response.setHeader("Content-Disposition", "attachment; filename=operation_log.csv");
63
+        response.setCharacterEncoding("UTF-8");
64
+
65
+        try (Writer writer = new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8)) {
66
+            // BOM for Excel UTF-8
67
+            writer.write('\uFEFF');
68
+            writer.write("ID,用户,模块,操作,请求方式,请求URL,耗时(ms),状态,时间\n");
69
+            for (SysOperationLog log : logs) {
70
+                writer.write(String.format("%d,%s,%s,%s,%s,%s,%d,%s,%s\n",
71
+                        log.getId(),
72
+                        csvEscape(log.getUsername()),
73
+                        csvEscape(log.getModule()),
74
+                        csvEscape(log.getOperation()),
75
+                        csvEscape(log.getRequestMethod()),
76
+                        csvEscape(log.getRequestUrl()),
77
+                        log.getDuration() != null ? log.getDuration() : 0,
78
+                        log.getStatus() != null && log.getStatus() == 1 ? "成功" : "失败",
79
+                        log.getCreatedAt()));
80
+            }
81
+        }
82
+    }
83
+
84
+    @Operation(summary = "清空操作日志")
85
+    @DeleteMapping("/clean")
86
+    public R<String> clean() {
87
+        logService.cleanAll();
88
+        return R.ok("日志已清空");
89
+    }
90
+
91
+    private String csvEscape(String s) {
92
+        if (s == null) return "";
93
+        if (s.contains(",") || s.contains("\"") || s.contains("\n")) {
94
+            return "\"" + s.replace("\"", "\"\"") + "\"";
95
+        }
96
+        return s;
97
+    }
98
+}

+ 22
- 0
wm-base/src/main/java/com/water/base/controller/SysRoleController.java Näytä tiedosto

@@ -9,6 +9,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
9 9
 import lombok.RequiredArgsConstructor;
10 10
 import org.springframework.web.bind.annotation.*;
11 11
 
12
+import java.util.List;
13
+import java.util.Map;
14
+
12 15
 @Tag(name = "角色管理")
13 16
 @RestController
14 17
 @RequestMapping("/sys/role")
@@ -51,4 +54,23 @@ public class SysRoleController {
51 54
         sysRoleService.removeById(id);
52 55
         return R.ok("删除成功");
53 56
     }
57
+
58
+    @Operation(summary = "获取5级角色预设列表")
59
+    @GetMapping("/presets")
60
+    public R<List<SysRoleService.RolePreset>> presets() {
61
+        return R.ok(sysRoleService.getPresets());
62
+    }
63
+
64
+    @Operation(summary = "获取角色权限矩阵")
65
+    @GetMapping("/permissions/{roleKey}")
66
+    public R<Map<String, List<String>>> permissions(@PathVariable String roleKey) {
67
+        return R.ok(sysRoleService.getRolePermissions(roleKey));
68
+    }
69
+
70
+    @Operation(summary = "初始化预设角色")
71
+    @PostMapping("/init-presets")
72
+    public R<String> initPresets() {
73
+        sysRoleService.initPresets();
74
+        return R.ok("预设角色初始化完成");
75
+    }
54 76
 }

+ 22
- 0
wm-base/src/main/java/com/water/base/entity/SysDictData.java Näytä tiedosto

@@ -0,0 +1,22 @@
1
+package com.water.base.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("sys_dict_data")
9
+public class SysDictData {
10
+    @TableId(type = IdType.AUTO)
11
+    private Long id;
12
+    private Long dictTypeId;
13
+    private String dictLabel;
14
+    private String dictValue;
15
+    private String cssClass;
16
+    private String listClass;
17
+    private Integer sortOrder;
18
+    private Integer status;
19
+    private String remark;
20
+    private LocalDateTime createdAt;
21
+    private LocalDateTime updatedAt;
22
+}

+ 18
- 0
wm-base/src/main/java/com/water/base/entity/SysDictType.java Näytä tiedosto

@@ -0,0 +1,18 @@
1
+package com.water.base.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("sys_dict_type")
9
+public class SysDictType {
10
+    @TableId(type = IdType.AUTO)
11
+    private Long id;
12
+    private String dictName;
13
+    private String dictKey;
14
+    private Integer status;
15
+    private String remark;
16
+    private LocalDateTime createdAt;
17
+    private LocalDateTime updatedAt;
18
+}

+ 27
- 0
wm-base/src/main/java/com/water/base/entity/SysOperationLog.java Näytä tiedosto

@@ -0,0 +1,27 @@
1
+package com.water.base.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("sys_oper_log")
9
+public class SysOperationLog {
10
+    @TableId(type = IdType.AUTO)
11
+    private Long id;
12
+    private Long userId;
13
+    private String username;
14
+    private String module;
15
+    private String operation;
16
+    private String method;
17
+    private String requestMethod;
18
+    private String requestUrl;
19
+    private String requestParams;
20
+    private String responseResult;
21
+    private String ip;
22
+    private String location;
23
+    private Long duration;
24
+    private Integer status;
25
+    private String errorMsg;
26
+    private LocalDateTime createdAt;
27
+}

+ 8
- 0
wm-base/src/main/java/com/water/base/mapper/SysDictDataMapper.java Näytä tiedosto

@@ -0,0 +1,8 @@
1
+package com.water.base.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.base.entity.SysDictData;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SysDictDataMapper extends BaseMapper<SysDictData> {}

+ 8
- 0
wm-base/src/main/java/com/water/base/mapper/SysDictTypeMapper.java Näytä tiedosto

@@ -0,0 +1,8 @@
1
+package com.water.base.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.base.entity.SysDictType;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SysDictTypeMapper extends BaseMapper<SysDictType> {}

+ 8
- 0
wm-base/src/main/java/com/water/base/mapper/SysOperationLogMapper.java Näytä tiedosto

@@ -0,0 +1,8 @@
1
+package com.water.base.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.base.entity.SysOperationLog;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SysOperationLogMapper extends BaseMapper<SysOperationLog> {}

+ 122
- 0
wm-base/src/main/java/com/water/base/service/SysDictionaryService.java Näytä tiedosto

@@ -0,0 +1,122 @@
1
+package com.water.base.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
5
+import com.water.base.entity.SysDictData;
6
+import com.water.base.entity.SysDictType;
7
+import com.water.base.mapper.SysDictDataMapper;
8
+import com.water.base.mapper.SysDictTypeMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.util.*;
13
+import java.util.concurrent.ConcurrentHashMap;
14
+import java.util.stream.Collectors;
15
+
16
+@Service
17
+@RequiredArgsConstructor
18
+public class SysDictionaryService extends ServiceImpl<SysDictTypeMapper, SysDictType> {
19
+
20
+    private final SysDictDataMapper dictDataMapper;
21
+
22
+    /** 本地缓存: dictKey -> List<SysDictData> */
23
+    private final Map<String, List<SysDictData>> cache = new ConcurrentHashMap<>();
24
+
25
+    // ============ 字典类型 CRUD ============
26
+
27
+    public List<SysDictType> listTypes(String dictName) {
28
+        LambdaQueryWrapper<SysDictType> qw = new LambdaQueryWrapper<>();
29
+        if (dictName != null && !dictName.isEmpty()) {
30
+            qw.like(SysDictType::getDictName, dictName);
31
+        }
32
+        qw.orderByAsc(SysDictType::getId);
33
+        return this.list(qw);
34
+    }
35
+
36
+    public SysDictType getTypeById(Long id) {
37
+        return this.getById(id);
38
+    }
39
+
40
+    public void createType(SysDictType type) {
41
+        this.save(type);
42
+        evictCache(type.getDictKey());
43
+    }
44
+
45
+    public void updateType(SysDictType type) {
46
+        this.updateById(type);
47
+        evictCache(type.getDictKey());
48
+    }
49
+
50
+    public void deleteType(Long id) {
51
+        SysDictType type = this.getById(id);
52
+        if (type != null) {
53
+            // 同时删除关联的字典数据
54
+            dictDataMapper.delete(new LambdaQueryWrapper<SysDictData>()
55
+                    .eq(SysDictData::getDictTypeId, id));
56
+            this.removeById(id);
57
+            evictCache(type.getDictKey());
58
+        }
59
+    }
60
+
61
+    // ============ 字典数据 CRUD ============
62
+
63
+    public List<SysDictData> listDataByTypeId(Long typeId) {
64
+        return dictDataMapper.selectList(new LambdaQueryWrapper<SysDictData>()
65
+                .eq(SysDictData::getDictTypeId, typeId)
66
+                .orderByAsc(SysDictData::getSortOrder));
67
+    }
68
+
69
+    public List<SysDictData> listDataByKey(String dictKey) {
70
+        // 先查缓存
71
+        List<SysDictData> cached = cache.get(dictKey);
72
+        if (cached != null) {
73
+            return cached;
74
+        }
75
+        // 缓存未命中,查库
76
+        SysDictType type = this.getOne(new LambdaQueryWrapper<SysDictType>()
77
+                .eq(SysDictType::getDictKey, dictKey));
78
+        if (type == null) {
79
+            return Collections.emptyList();
80
+        }
81
+        List<SysDictData> data = listDataByTypeId(type.getId());
82
+        cache.put(dictKey, data);
83
+        return data;
84
+    }
85
+
86
+    public void createData(SysDictData data) {
87
+        dictDataMapper.insert(data);
88
+        evictByTypeId(data.getDictTypeId());
89
+    }
90
+
91
+    public void updateData(SysDictData data) {
92
+        dictDataMapper.updateById(data);
93
+        evictByTypeId(data.getDictTypeId());
94
+    }
95
+
96
+    public void deleteData(Long id) {
97
+        SysDictData data = dictDataMapper.selectById(id);
98
+        if (data != null) {
99
+            dictDataMapper.deleteById(id);
100
+            evictByTypeId(data.getDictTypeId());
101
+        }
102
+    }
103
+
104
+    // ============ 缓存管理 ============
105
+
106
+    public void clearCache() {
107
+        cache.clear();
108
+    }
109
+
110
+    private void evictCache(String dictKey) {
111
+        if (dictKey != null) {
112
+            cache.remove(dictKey);
113
+        }
114
+    }
115
+
116
+    private void evictByTypeId(Long typeId) {
117
+        SysDictType type = this.getById(typeId);
118
+        if (type != null) {
119
+            evictCache(type.getDictKey());
120
+        }
121
+    }
122
+}

+ 114
- 0
wm-base/src/main/java/com/water/base/service/SysOperationLogService.java Näytä tiedosto

@@ -0,0 +1,114 @@
1
+package com.water.base.service;
2
+
3
+import cn.dev33.satoken.stp.StpUtil;
4
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
7
+import com.water.base.entity.SysOperationLog;
8
+import com.water.base.mapper.SysOperationLogMapper;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.time.LocalDateTime;
12
+import java.util.List;
13
+
14
+@Service
15
+public class SysOperationLogService extends ServiceImpl<SysOperationLogMapper, SysOperationLog> {
16
+
17
+    /**
18
+     * 记录操作日志
19
+     */
20
+    public void record(String module, String operation, String method,
21
+                       String requestMethod, String requestUrl, String requestParams,
22
+                       String responseResult, Long duration, Integer status, String errorMsg) {
23
+        SysOperationLog log = new SysOperationLog();
24
+        try {
25
+            log.setUserId(StpUtil.getLoginIdAsLong());
26
+            log.setUsername((String) StpUtil.getSession().get("realName"));
27
+        } catch (Exception ignored) {
28
+            // 未登录场景
29
+        }
30
+        log.setModule(module);
31
+        log.setOperation(operation);
32
+        log.setMethod(method);
33
+        log.setRequestMethod(requestMethod);
34
+        log.setRequestUrl(requestUrl);
35
+        log.setRequestParams(truncate(requestParams, 2000));
36
+        log.setResponseResult(truncate(responseResult, 2000));
37
+        log.setDuration(duration);
38
+        log.setStatus(status);
39
+        log.setErrorMsg(truncate(errorMsg, 2000));
40
+        this.save(log);
41
+    }
42
+
43
+    /**
44
+     * 分页查询操作日志
45
+     */
46
+    public Page<SysOperationLog> queryPage(int page, int size,
47
+                                            String username, String module,
48
+                                            String operation, Integer status,
49
+                                            LocalDateTime startTime, LocalDateTime endTime) {
50
+        LambdaQueryWrapper<SysOperationLog> qw = new LambdaQueryWrapper<>();
51
+        if (username != null && !username.isEmpty()) {
52
+            qw.like(SysOperationLog::getUsername, username);
53
+        }
54
+        if (module != null && !module.isEmpty()) {
55
+            qw.eq(SysOperationLog::getModule, module);
56
+        }
57
+        if (operation != null && !operation.isEmpty()) {
58
+            qw.like(SysOperationLog::getOperation, operation);
59
+        }
60
+        if (status != null) {
61
+            qw.eq(SysOperationLog::getStatus, status);
62
+        }
63
+        if (startTime != null) {
64
+            qw.ge(SysOperationLog::getCreatedAt, startTime);
65
+        }
66
+        if (endTime != null) {
67
+            qw.le(SysOperationLog::getCreatedAt, endTime);
68
+        }
69
+        qw.orderByDesc(SysOperationLog::getCreatedAt);
70
+        return this.page(new Page<>(page, size), qw);
71
+    }
72
+
73
+    /**
74
+     * 查询导出列表(限制最大条数)
75
+     */
76
+    public List<SysOperationLog> queryForExport(String username, String module,
77
+                                                 String operation, Integer status,
78
+                                                 LocalDateTime startTime, LocalDateTime endTime) {
79
+        LambdaQueryWrapper<SysOperationLog> qw = new LambdaQueryWrapper<>();
80
+        if (username != null && !username.isEmpty()) {
81
+            qw.like(SysOperationLog::getUsername, username);
82
+        }
83
+        if (module != null && !module.isEmpty()) {
84
+            qw.eq(SysOperationLog::getModule, module);
85
+        }
86
+        if (operation != null && !operation.isEmpty()) {
87
+            qw.like(SysOperationLog::getOperation, operation);
88
+        }
89
+        if (status != null) {
90
+            qw.eq(SysOperationLog::getStatus, status);
91
+        }
92
+        if (startTime != null) {
93
+            qw.ge(SysOperationLog::getCreatedAt, startTime);
94
+        }
95
+        if (endTime != null) {
96
+            qw.le(SysOperationLog::getCreatedAt, endTime);
97
+        }
98
+        qw.orderByDesc(SysOperationLog::getCreatedAt);
99
+        qw.last("LIMIT 10000");
100
+        return this.list(qw);
101
+    }
102
+
103
+    /**
104
+     * 清空日志(管理员操作)
105
+     */
106
+    public void cleanAll() {
107
+        this.remove(new LambdaQueryWrapper<>());
108
+    }
109
+
110
+    private String truncate(String s, int maxLen) {
111
+        if (s == null) return null;
112
+        return s.length() > maxLen ? s.substring(0, maxLen) : s;
113
+    }
114
+}

+ 104
- 1
wm-base/src/main/java/com/water/base/service/SysRoleService.java Näytä tiedosto

@@ -1,9 +1,112 @@
1 1
 package com.water.base.service;
2 2
 
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
3 4
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
4 5
 import com.water.base.entity.SysRole;
5 6
 import com.water.base.mapper.SysRoleMapper;
6 7
 import org.springframework.stereotype.Service;
7 8
 
9
+import java.util.*;
10
+
8 11
 @Service
9
-public class SysRoleService extends ServiceImpl<SysRoleMapper, SysRole> {}
12
+public class SysRoleService extends ServiceImpl<SysRoleMapper, SysRole> {
13
+
14
+    /**
15
+     * 5级角色预设定义
16
+     * 管理员(admin) / 分管领导(supervisor) / 业务管理(biz_manager) / 运维(field_ops) / 技术(tech_maintain)
17
+     */
18
+    public static final List<RolePreset> ROLE_PRESETS = List.of(
19
+            new RolePreset("系统管理员", "admin", 1, "ALL",
20
+                    "拥有系统全部权限,可管理所有模块和用户"),
21
+            new RolePreset("分管领导", "supervisor", 2, "ALL",
22
+                    "可查看所有数据,审批流程,管理分管范围内的业务"),
23
+            new RolePreset("业务管理员", "biz_manager", 3, "DEPT",
24
+                    "管理本部门业务数据,包括数据录入、审核、报表等"),
25
+            new RolePreset("运维人员", "field_ops", 4, "DEPT",
26
+                    "负责设备巡检、告警处理、工单执行等现场运维工作"),
27
+            new RolePreset("技术维护", "tech_maintain", 5, "SELF",
28
+                    "系统技术维护,负责配置管理、日志查看、故障排查")
29
+    );
30
+
31
+    /**
32
+     * 权限矩阵:角色对应的模块权限
33
+     * key: roleKey, value: Map<module, permissions>
34
+     */
35
+    public static final Map<String, Map<String, List<String>>> PERMISSION_MATRIX = Map.of(
36
+            "admin", Map.of(
37
+                    "system", List.of("read", "write", "delete", "export"),
38
+                    "monitor", List.of("read", "write", "delete", "export"),
39
+                    "production", List.of("read", "write", "delete", "export"),
40
+                    "dispatch", List.of("read", "write", "delete", "export", "approve"),
41
+                    "revenue", List.of("read", "write", "delete", "export")
42
+            ),
43
+            "supervisor", Map.of(
44
+                    "system", List.of("read"),
45
+                    "monitor", List.of("read", "export"),
46
+                    "production", List.of("read", "write", "export", "approve"),
47
+                    "dispatch", List.of("read", "write", "export", "approve"),
48
+                    "revenue", List.of("read", "export")
49
+            ),
50
+            "biz_manager", Map.of(
51
+                    "system", List.of("read"),
52
+                    "monitor", List.of("read"),
53
+                    "production", List.of("read", "write", "export"),
54
+                    "dispatch", List.of("read", "write"),
55
+                    "revenue", List.of("read", "write")
56
+            ),
57
+            "field_ops", Map.of(
58
+                    "monitor", List.of("read"),
59
+                    "production", List.of("read"),
60
+                    "dispatch", List.of("read", "write")
61
+            ),
62
+            "tech_maintain", Map.of(
63
+                    "system", List.of("read", "write"),
64
+                    "monitor", List.of("read")
65
+            )
66
+    );
67
+
68
+    /**
69
+     * 初始化预设角色(幂等操作)
70
+     */
71
+    public void initPresets() {
72
+        for (RolePreset preset : ROLE_PRESETS) {
73
+            SysRole existing = this.getOne(new LambdaQueryWrapper<SysRole>()
74
+                    .eq(SysRole::getRoleKey, preset.roleKey()));
75
+            if (existing == null) {
76
+                SysRole role = new SysRole();
77
+                role.setRoleName(preset.roleName());
78
+                role.setRoleKey(preset.roleKey());
79
+                role.setRoleSort(preset.roleSort());
80
+                role.setDataScope(preset.dataScope());
81
+                role.setRemark(preset.remark());
82
+                role.setStatus(1);
83
+                this.save(role);
84
+            }
85
+        }
86
+    }
87
+
88
+    /**
89
+     * 获取角色权限矩阵
90
+     */
91
+    public Map<String, List<String>> getRolePermissions(String roleKey) {
92
+        return PERMISSION_MATRIX.getOrDefault(roleKey, Collections.emptyMap());
93
+    }
94
+
95
+    /**
96
+     * 获取所有预设角色信息
97
+     */
98
+    public List<RolePreset> getPresets() {
99
+        return ROLE_PRESETS;
100
+    }
101
+
102
+    /**
103
+     * 角色预设记录
104
+     */
105
+    public record RolePreset(
106
+            String roleName,
107
+            String roleKey,
108
+            int roleSort,
109
+            String dataScope,
110
+            String remark
111
+    ) {}
112
+}

+ 175
- 0
wm-base/src/main/resources/db/V_sys_dict_log.sql Näytä tiedosto

@@ -0,0 +1,175 @@
1
+-- =============================================
2
+-- 系统管理扩展 - 数据字典 & 操作日志
3
+-- 版本: V_sys_dict_log
4
+-- 描述: 补充数据字典和操作日志相关表(如V1__base已包含则跳过)
5
+-- =============================================
6
+
7
+-- 字典类型表(如不存在则创建)
8
+CREATE TABLE IF NOT EXISTS sys_dict_type (
9
+    id BIGSERIAL PRIMARY KEY,
10
+    dict_name VARCHAR(100) NOT NULL,
11
+    dict_key VARCHAR(100) UNIQUE NOT NULL,
12
+    status SMALLINT DEFAULT 1,
13
+    remark VARCHAR(500),
14
+    created_at TIMESTAMP DEFAULT NOW(),
15
+    updated_at TIMESTAMP DEFAULT NOW()
16
+);
17
+COMMENT ON TABLE sys_dict_type IS '字典类型表';
18
+
19
+-- 字典数据表(如不存在则创建)
20
+CREATE TABLE IF NOT EXISTS sys_dict_data (
21
+    id BIGSERIAL PRIMARY KEY,
22
+    dict_type_id BIGINT REFERENCES sys_dict_type(id),
23
+    dict_label VARCHAR(100) NOT NULL,
24
+    dict_value VARCHAR(100) NOT NULL,
25
+    css_class VARCHAR(100),
26
+    list_class VARCHAR(100),
27
+    sort_order INT DEFAULT 0,
28
+    status SMALLINT DEFAULT 1,
29
+    remark VARCHAR(500),
30
+    created_at TIMESTAMP DEFAULT NOW(),
31
+    updated_at TIMESTAMP DEFAULT NOW()
32
+);
33
+COMMENT ON TABLE sys_dict_data IS '字典数据表';
34
+
35
+-- 操作日志表(如不存在则创建)
36
+CREATE TABLE IF NOT EXISTS sys_oper_log (
37
+    id BIGSERIAL PRIMARY KEY,
38
+    user_id BIGINT,
39
+    username VARCHAR(50),
40
+    module VARCHAR(50),
41
+    operation VARCHAR(50),
42
+    method VARCHAR(200),
43
+    request_method VARCHAR(10),
44
+    request_url VARCHAR(500),
45
+    request_params TEXT,
46
+    response_result TEXT,
47
+    ip VARCHAR(50),
48
+    location VARCHAR(100),
49
+    duration BIGINT,
50
+    status SMALLINT DEFAULT 1,
51
+    error_msg TEXT,
52
+    created_at TIMESTAMP DEFAULT NOW()
53
+);
54
+COMMENT ON TABLE sys_oper_log IS '操作日志表';
55
+
56
+-- 索引
57
+CREATE INDEX IF NOT EXISTS idx_dict_data_type ON sys_dict_data(dict_type_id);
58
+CREATE INDEX IF NOT EXISTS idx_oper_log_created ON sys_oper_log(created_at);
59
+CREATE INDEX IF NOT EXISTS idx_oper_log_username ON sys_oper_log(username);
60
+CREATE INDEX IF NOT EXISTS idx_oper_log_module ON sys_oper_log(module);
61
+
62
+-- 预设5级角色(幂等)
63
+INSERT INTO sys_role (role_name, role_key, role_sort, data_scope, status, remark)
64
+SELECT '系统管理员', 'admin', 1, 'ALL', 1, '拥有系统全部权限,可管理所有模块和用户'
65
+WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_key = 'admin');
66
+
67
+INSERT INTO sys_role (role_name, role_key, role_sort, data_scope, status, remark)
68
+SELECT '分管领导', 'supervisor', 2, 'ALL', 1, '可查看所有数据,审批流程,管理分管范围内的业务'
69
+WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_key = 'supervisor');
70
+
71
+INSERT INTO sys_role (role_name, role_key, role_sort, data_scope, status, remark)
72
+SELECT '业务管理员', 'biz_manager', 3, 'DEPT', 1, '管理本部门业务数据,包括数据录入、审核、报表等'
73
+WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_key = 'biz_manager');
74
+
75
+INSERT INTO sys_role (role_name, role_key, role_sort, data_scope, status, remark)
76
+SELECT '运维人员', 'field_ops', 4, 'DEPT', 1, '负责设备巡检、告警处理、工单执行等现场运维工作'
77
+WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_key = 'field_ops');
78
+
79
+INSERT INTO sys_role (role_name, role_key, role_sort, data_scope, status, remark)
80
+SELECT '技术维护', 'tech_maintain', 5, 'SELF', 1, '系统技术维护,负责配置管理、日志查看、故障排查'
81
+WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_key = 'tech_maintain');
82
+
83
+-- 预设字典数据
84
+INSERT INTO sys_dict_type (dict_name, dict_key, status, remark)
85
+SELECT '用户性别', 'sys_user_gender', 1, '用户性别字典'
86
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_key = 'sys_user_gender');
87
+
88
+INSERT INTO sys_dict_type (dict_name, dict_key, status, remark)
89
+SELECT '通用状态', 'sys_common_status', 1, '通用状态字典'
90
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_key = 'sys_common_status');
91
+
92
+INSERT INTO sys_dict_type (dict_name, dict_key, status, remark)
93
+SELECT '部门类型', 'sys_dept_type', 1, '部门类型字典'
94
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_key = 'sys_dept_type');
95
+
96
+INSERT INTO sys_dict_type (dict_name, dict_key, status, remark)
97
+SELECT '角色类型', 'sys_role_type', 1, '角色类型字典'
98
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_key = 'sys_role_type');
99
+
100
+INSERT INTO sys_dict_type (dict_name, dict_key, status, remark)
101
+SELECT '数据权限范围', 'sys_data_scope', 1, '数据权限范围字典'
102
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_key = 'sys_data_scope');
103
+
104
+-- 性别字典数据
105
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
106
+SELECT id, '未知', '0', 0, 1 FROM sys_dict_type WHERE dict_key = 'sys_user_gender'
107
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_user_gender') AND dict_value = '0');
108
+
109
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
110
+SELECT id, '男', '1', 1, 1 FROM sys_dict_type WHERE dict_key = 'sys_user_gender'
111
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_user_gender') AND dict_value = '1');
112
+
113
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
114
+SELECT id, '女', '2', 2, 1 FROM sys_dict_type WHERE dict_key = 'sys_user_gender'
115
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_user_gender') AND dict_value = '2');
116
+
117
+-- 通用状态
118
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
119
+SELECT id, '停用', '0', 0, 1 FROM sys_dict_type WHERE dict_key = 'sys_common_status'
120
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_common_status') AND dict_value = '0');
121
+
122
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
123
+SELECT id, '启用', '1', 1, 1 FROM sys_dict_type WHERE dict_key = 'sys_common_status'
124
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_common_status') AND dict_value = '1');
125
+
126
+-- 部门类型
127
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
128
+SELECT id, '水利局', 'water_bureau', 0, 1 FROM sys_dict_type WHERE dict_key = 'sys_dept_type'
129
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_dept_type') AND dict_value = 'water_bureau');
130
+
131
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
132
+SELECT id, '水务公司', 'water_company', 1, 1 FROM sys_dict_type WHERE dict_key = 'sys_dept_type'
133
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_dept_type') AND dict_value = 'water_company');
134
+
135
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
136
+SELECT id, '运维单位', 'ops', 2, 1 FROM sys_dict_type WHERE dict_key = 'sys_dept_type'
137
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_dept_type') AND dict_value = 'ops');
138
+
139
+-- 角色类型
140
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
141
+SELECT id, '系统管理员', 'admin', 1, 1 FROM sys_dict_type WHERE dict_key = 'sys_role_type'
142
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_role_type') AND dict_value = 'admin');
143
+
144
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
145
+SELECT id, '分管领导', 'supervisor', 2, 1 FROM sys_dict_type WHERE dict_key = 'sys_role_type'
146
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_role_type') AND dict_value = 'supervisor');
147
+
148
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
149
+SELECT id, '业务管理员', 'biz_manager', 3, 1 FROM sys_dict_type WHERE dict_key = 'sys_role_type'
150
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_role_type') AND dict_value = 'biz_manager');
151
+
152
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
153
+SELECT id, '运维人员', 'field_ops', 4, 1 FROM sys_dict_type WHERE dict_key = 'sys_role_type'
154
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_role_type') AND dict_value = 'field_ops');
155
+
156
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
157
+SELECT id, '技术维护', 'tech_maintain', 5, 1 FROM sys_dict_type WHERE dict_key = 'sys_role_type'
158
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_role_type') AND dict_value = 'tech_maintain');
159
+
160
+-- 数据权限范围
161
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
162
+SELECT id, '全部数据', 'ALL', 0, 1 FROM sys_dict_type WHERE dict_key = 'sys_data_scope'
163
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_data_scope') AND dict_value = 'ALL');
164
+
165
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
166
+SELECT id, '部门数据', 'DEPT', 1, 1 FROM sys_dict_type WHERE dict_key = 'sys_data_scope'
167
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_data_scope') AND dict_value = 'DEPT');
168
+
169
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
170
+SELECT id, '自定义数据', 'CUSTOM', 2, 1 FROM sys_dict_type WHERE dict_key = 'sys_data_scope'
171
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_data_scope') AND dict_value = 'CUSTOM');
172
+
173
+INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order, status)
174
+SELECT id, '仅本人', 'SELF', 3, 1 FROM sys_dict_type WHERE dict_key = 'sys_data_scope'
175
+AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type_id = (SELECT id FROM sys_dict_type WHERE dict_key = 'sys_data_scope') AND dict_value = 'SELF');

+ 84
- 0
wm-base/src/test/java/com/water/base/service/SysDeptServiceTest.java Näytä tiedosto

@@ -0,0 +1,84 @@
1
+package com.water.base.service;
2
+
3
+import com.water.base.entity.SysDept;
4
+import com.water.base.mapper.SysDeptMapper;
5
+import org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.InjectMocks;
8
+import org.mockito.Mock;
9
+import org.mockito.junit.jupiter.MockitoExtension;
10
+
11
+import java.util.Arrays;
12
+import java.util.List;
13
+
14
+import static org.junit.jupiter.api.Assertions.*;
15
+
16
+@ExtendWith(MockitoExtension.class)
17
+class SysDeptServiceTest {
18
+
19
+    @Mock
20
+    private SysDeptMapper deptMapper;
21
+
22
+    @InjectMocks
23
+    private SysDeptService deptService;
24
+
25
+    @Test
26
+    void buildDeptTree_shouldBuildCorrectHierarchy() {
27
+        SysDept root = new SysDept();
28
+        root.setId(1L);
29
+        root.setParentId(0L);
30
+        root.setDeptName("总公司");
31
+
32
+        SysDept child1 = new SysDept();
33
+        child1.setId(2L);
34
+        child1.setParentId(1L);
35
+        child1.setDeptName("技术部");
36
+
37
+        SysDept child2 = new SysDept();
38
+        child2.setId(3L);
39
+        child2.setParentId(1L);
40
+        child2.setDeptName("市场部");
41
+
42
+        List<SysDept> tree = deptService.buildDeptTree(Arrays.asList(root, child1, child2));
43
+
44
+        assertEquals(1, tree.size());
45
+        assertEquals("总公司", tree.get(0).getDeptName());
46
+        assertEquals(2, tree.get(0).getChildren().size());
47
+    }
48
+
49
+    @Test
50
+    void buildDeptTree_shouldHandleEmptyList() {
51
+        List<SysDept> tree = deptService.buildDeptTree(List.of());
52
+        assertTrue(tree.isEmpty());
53
+    }
54
+
55
+    @Test
56
+    void buildDeptTree_shouldHandleMultipleRoots() {
57
+        SysDept root1 = new SysDept();
58
+        root1.setId(1L);
59
+        root1.setParentId(0L);
60
+        root1.setDeptName("公司A");
61
+
62
+        SysDept root2 = new SysDept();
63
+        root2.setId(2L);
64
+        root2.setParentId(0L);
65
+        root2.setDeptName("公司B");
66
+
67
+        List<SysDept> tree = deptService.buildDeptTree(Arrays.asList(root1, root2));
68
+
69
+        assertEquals(2, tree.size());
70
+    }
71
+
72
+    @Test
73
+    void buildDeptTree_shouldHandleNullParentId() {
74
+        SysDept root = new SysDept();
75
+        root.setId(1L);
76
+        root.setParentId(null);
77
+        root.setDeptName("顶级部门");
78
+
79
+        List<SysDept> tree = deptService.buildDeptTree(List.of(root));
80
+
81
+        assertEquals(1, tree.size());
82
+        assertEquals("顶级部门", tree.get(0).getDeptName());
83
+    }
84
+}

+ 130
- 0
wm-base/src/test/java/com/water/base/service/SysDictionaryServiceTest.java Näytä tiedosto

@@ -0,0 +1,130 @@
1
+package com.water.base.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.base.entity.SysDictData;
5
+import com.water.base.entity.SysDictType;
6
+import com.water.base.mapper.SysDictDataMapper;
7
+import com.water.base.mapper.SysDictTypeMapper;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.util.Arrays;
15
+import java.util.Collections;
16
+import java.util.List;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.any;
20
+import static org.mockito.Mockito.*;
21
+
22
+@ExtendWith(MockitoExtension.class)
23
+class SysDictionaryServiceTest {
24
+
25
+    @Mock
26
+    private SysDictTypeMapper dictTypeMapper;
27
+
28
+    @Mock
29
+    private SysDictDataMapper dictDataMapper;
30
+
31
+    @InjectMocks
32
+    private SysDictionaryService dictionaryService;
33
+
34
+    @Test
35
+    void listTypes_shouldReturnAllWhenNameIsNull() {
36
+        SysDictType type1 = new SysDictType();
37
+        type1.setId(1L);
38
+        type1.setDictName("用户性别");
39
+        type1.setDictKey("sys_user_gender");
40
+
41
+        when(dictTypeMapper.selectList(any(LambdaQueryWrapper.class)))
42
+            .thenReturn(Collections.singletonList(type1));
43
+
44
+        List<SysDictType> result = dictionaryService.listTypes(null);
45
+
46
+        assertEquals(1, result.size());
47
+        assertEquals("用户性别", result.get(0).getDictName());
48
+    }
49
+
50
+    @Test
51
+    void listDataByKey_shouldReturnDataFromCache() {
52
+        // First call populates cache
53
+        SysDictType type = new SysDictType();
54
+        type.setId(1L);
55
+        type.setDictKey("sys_user_gender");
56
+
57
+        SysDictData data1 = new SysDictData();
58
+        data1.setDictLabel("男");
59
+        data1.setDictValue("1");
60
+
61
+        when(dictTypeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(type);
62
+        when(dictDataMapper.selectList(any(LambdaQueryWrapper.class)))
63
+            .thenReturn(Collections.singletonList(data1));
64
+
65
+        List<SysDictData> result1 = dictionaryService.listDataByKey("sys_user_gender");
66
+        assertEquals(1, result1.size());
67
+
68
+        // Second call should use cache (no additional DB calls)
69
+        List<SysDictData> result2 = dictionaryService.listDataByKey("sys_user_gender");
70
+        assertEquals(1, result2.size());
71
+
72
+        // selectOne called only once because second time it's cached
73
+        verify(dictTypeMapper, times(1)).selectOne(any(LambdaQueryWrapper.class));
74
+    }
75
+
76
+    @Test
77
+    void listDataByKey_shouldReturnEmptyWhenKeyNotFound() {
78
+        when(dictTypeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
79
+
80
+        List<SysDictData> result = dictionaryService.listDataByKey("nonexistent_key");
81
+
82
+        assertTrue(result.isEmpty());
83
+    }
84
+
85
+    @Test
86
+    void createType_shouldSaveAndEvictCache() {
87
+        SysDictType type = new SysDictType();
88
+        type.setDictName("测试字典");
89
+        type.setDictKey("test_dict");
90
+
91
+        dictionaryService.createType(type);
92
+
93
+        verify(dictTypeMapper, times(1)).insert(type);
94
+    }
95
+
96
+    @Test
97
+    void deleteType_shouldRemoveTypeAndData() {
98
+        SysDictType type = new SysDictType();
99
+        type.setId(1L);
100
+        type.setDictKey("test_dict");
101
+
102
+        when(dictTypeMapper.selectById(1L)).thenReturn(type);
103
+        when(dictDataMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2);
104
+
105
+        dictionaryService.deleteType(1L);
106
+
107
+        verify(dictDataMapper, times(1)).delete(any(LambdaQueryWrapper.class));
108
+        verify(dictTypeMapper, times(1)).deleteById(1L);
109
+    }
110
+
111
+    @Test
112
+    void clearCache_shouldClearAllCachedData() {
113
+        // Populate cache
114
+        SysDictType type = new SysDictType();
115
+        type.setId(1L);
116
+        type.setDictKey("test_key");
117
+
118
+        when(dictTypeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(type);
119
+        when(dictDataMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
120
+
121
+        dictionaryService.listDataByKey("test_key");
122
+
123
+        // Clear cache
124
+        dictionaryService.clearCache();
125
+
126
+        // Next call should hit DB again
127
+        dictionaryService.listDataByKey("test_key");
128
+        verify(dictTypeMapper, times(2)).selectOne(any(LambdaQueryWrapper.class));
129
+    }
130
+}

+ 52
- 0
wm-base/src/test/java/com/water/base/service/SysOperationLogServiceTest.java Näytä tiedosto

@@ -0,0 +1,52 @@
1
+package com.water.base.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.base.entity.SysOperationLog;
5
+import com.water.base.mapper.SysOperationLogMapper;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import java.util.Arrays;
13
+import java.util.List;
14
+
15
+import static org.junit.jupiter.api.Assertions.*;
16
+import static org.mockito.ArgumentMatchers.any;
17
+import static org.mockito.Mockito.*;
18
+
19
+@ExtendWith(MockitoExtension.class)
20
+class SysOperationLogServiceTest {
21
+
22
+    @Mock
23
+    private SysOperationLogMapper logMapper;
24
+
25
+    @InjectMocks
26
+    private SysOperationLogService logService;
27
+
28
+    @Test
29
+    void queryForExport_shouldLimitTo10000() {
30
+        SysOperationLog log1 = new SysOperationLog();
31
+        log1.setId(1L);
32
+        log1.setModule("系统管理");
33
+        log1.setOperation("创建用户");
34
+
35
+        when(logMapper.selectList(any(LambdaQueryWrapper.class)))
36
+            .thenReturn(List.of(log1));
37
+
38
+        List<SysOperationLog> result = logService.queryForExport(null, null, null, null, null, null);
39
+
40
+        assertFalse(result.isEmpty());
41
+        assertEquals("系统管理", result.get(0).getModule());
42
+    }
43
+
44
+    @Test
45
+    void cleanAll_shouldRemoveAllLogs() {
46
+        when(logMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(100);
47
+
48
+        logService.cleanAll();
49
+
50
+        verify(logMapper, times(1)).delete(any(LambdaQueryWrapper.class));
51
+    }
52
+}

+ 116
- 0
wm-base/src/test/java/com/water/base/service/SysRoleServiceTest.java Näytä tiedosto

@@ -0,0 +1,116 @@
1
+package com.water.base.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.base.entity.SysRole;
5
+import com.water.base.mapper.SysRoleMapper;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import java.util.List;
13
+import java.util.Map;
14
+
15
+import static org.junit.jupiter.api.Assertions.*;
16
+import static org.mockito.ArgumentMatchers.any;
17
+import static org.mockito.Mockito.*;
18
+
19
+@ExtendWith(MockitoExtension.class)
20
+class SysRoleServiceTest {
21
+
22
+    @Mock
23
+    private SysRoleMapper roleMapper;
24
+
25
+    @InjectMocks
26
+    private SysRoleService roleService;
27
+
28
+    @Test
29
+    void presets_shouldReturn5Levels() {
30
+        List<SysRoleService.RolePreset> presets = roleService.getPresets();
31
+
32
+        assertEquals(5, presets.size());
33
+        assertEquals("admin", presets.get(0).roleKey());
34
+        assertEquals("supervisor", presets.get(1).roleKey());
35
+        assertEquals("biz_manager", presets.get(2).roleKey());
36
+        assertEquals("field_ops", presets.get(3).roleKey());
37
+        assertEquals("tech_maintain", presets.get(4).roleKey());
38
+    }
39
+
40
+    @Test
41
+    void permissionMatrix_adminShouldHaveFullAccess() {
42
+        Map<String, List<String>> perms = roleService.getRolePermissions("admin");
43
+
44
+        assertFalse(perms.isEmpty());
45
+        assertTrue(perms.containsKey("system"));
46
+        assertTrue(perms.get("system").contains("read"));
47
+        assertTrue(perms.get("system").contains("write"));
48
+        assertTrue(perms.get("system").contains("delete"));
49
+        assertTrue(perms.get("system").contains("export"));
50
+    }
51
+
52
+    @Test
53
+    void permissionMatrix_fieldOpsShouldHaveLimitedAccess() {
54
+        Map<String, List<String>> perms = roleService.getRolePermissions("field_ops");
55
+
56
+        assertFalse(perms.isEmpty());
57
+        assertFalse(perms.containsKey("system"));
58
+        assertTrue(perms.containsKey("dispatch"));
59
+        assertTrue(perms.containsKey("monitor"));
60
+    }
61
+
62
+    @Test
63
+    void permissionMatrix_unknownRoleShouldReturnEmpty() {
64
+        Map<String, List<String>> perms = roleService.getRolePermissions("unknown_role");
65
+
66
+        assertTrue(perms.isEmpty());
67
+    }
68
+
69
+    @Test
70
+    void initPresets_shouldSkipExistingRoles() {
71
+        // admin already exists
72
+        SysRole existingAdmin = new SysRole();
73
+        existingAdmin.setRoleKey("admin");
74
+
75
+        when(roleMapper.selectOne(any(LambdaQueryWrapper.class)))
76
+            .thenReturn(existingAdmin)  // admin exists
77
+            .thenReturn(null)           // supervisor doesn't exist
78
+            .thenReturn(null)           // biz_manager doesn't exist
79
+            .thenReturn(null)           // field_ops doesn't exist
80
+            .thenReturn(null);          // tech_maintain doesn't exist
81
+
82
+        roleService.initPresets();
83
+
84
+        // admin should NOT be inserted (already exists), others should be inserted
85
+        verify(roleMapper, times(4)).insert(any(SysRole.class));
86
+    }
87
+
88
+    @Test
89
+    void initPresets_shouldCreateAllWhenNoneExist() {
90
+        when(roleMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
91
+
92
+        roleService.initPresets();
93
+
94
+        verify(roleMapper, times(5)).insert(any(SysRole.class));
95
+    }
96
+
97
+    @Test
98
+    void presets_shouldHaveCorrectSortOrder() {
99
+        List<SysRoleService.RolePreset> presets = roleService.getPresets();
100
+
101
+        for (int i = 0; i < presets.size(); i++) {
102
+            assertEquals(i + 1, presets.get(i).roleSort());
103
+        }
104
+    }
105
+
106
+    @Test
107
+    void presets_shouldHaveCorrectDataScope() {
108
+        List<SysRoleService.RolePreset> presets = roleService.getPresets();
109
+
110
+        assertEquals("ALL", presets.get(0).dataScope());   // admin
111
+        assertEquals("ALL", presets.get(1).dataScope());   // supervisor
112
+        assertEquals("DEPT", presets.get(2).dataScope());  // biz_manager
113
+        assertEquals("DEPT", presets.get(3).dataScope());  // field_ops
114
+        assertEquals("SELF", presets.get(4).dataScope());  // tech_maintain
115
+    }
116
+}