소스 검색

feat(web-admin): add user admin, role grant, and audit pages

Complete system management UI for users, role-menu authorization tree,
and oper-log listing against existing admin APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 달 전
부모
커밋
2758567ee8
5개의 변경된 파일과 415개의 추가작업 그리고 5개의 파일을 삭제
  1. 36 0
      web-admin/src/api/admin.ts
  2. 3 5
      web-admin/src/router/index.ts
  3. 58 0
      web-admin/src/views/audit/index.vue
  4. 134 0
      web-admin/src/views/role/index.vue
  5. 184 0
      web-admin/src/views/user/index.vue

+ 36 - 0
web-admin/src/api/admin.ts

@@ -55,3 +55,39 @@ export function uploadAdminFile(file: File, folder = 'admin') {
     headers: { 'Content-Type': 'multipart/form-data' }
   })
 }
+
+export function fetchUsers() {
+  return http.get('/web/admin/users')
+}
+
+export function createUser(data: Record<string, any>) {
+  return http.post('/web/admin/users', data)
+}
+
+export function updateUser(id: string, data: Record<string, any>) {
+  return http.put(`/web/admin/users/${id}`, data)
+}
+
+export function resetUserPassword(id: string, password: string) {
+  return http.put(`/web/admin/users/${id}/reset-password`, { password })
+}
+
+export function fetchRoles() {
+  return http.get('/web/admin/roles')
+}
+
+export function fetchMenusTree() {
+  return http.get('/web/admin/menus/tree')
+}
+
+export function fetchRoleMenus(roleId: string) {
+  return http.get(`/web/admin/roles/${roleId}/menus`)
+}
+
+export function saveRoleMenus(roleId: string, menuIds: string[]) {
+  return http.put(`/web/admin/roles/${roleId}/menus`, { menuIds })
+}
+
+export function fetchOperLogs(params: Record<string, any>) {
+  return http.get('/web/admin/oper-logs', { params })
+}

+ 3 - 5
web-admin/src/router/index.ts

@@ -1,8 +1,6 @@
 import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
 import { useUserStore } from '@/stores/user'
 
-const Placeholder = () => import('@/views/_placeholder/index.vue')
-
 const businessRoutes: RouteRecordRaw[] = [
   {
     path: 'dashboard',
@@ -37,19 +35,19 @@ const businessRoutes: RouteRecordRaw[] = [
   {
     path: 'user',
     name: 'user',
-    component: Placeholder,
+    component: () => import('@/views/user/index.vue'),
     meta: { menuCode: 'user', title: '用户管理' }
   },
   {
     path: 'role',
     name: 'role',
-    component: Placeholder,
+    component: () => import('@/views/role/index.vue'),
     meta: { menuCode: 'role', title: '角色授权' }
   },
   {
     path: 'audit',
     name: 'audit',
-    component: Placeholder,
+    component: () => import('@/views/audit/index.vue'),
     meta: { menuCode: 'audit', title: '操作审计' }
   }
 ]

+ 58 - 0
web-admin/src/views/audit/index.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="panel">
+    <el-table :data="list" v-loading="loading" stripe>
+      <el-table-column prop="username" label="操作人" width="120" />
+      <el-table-column prop="module" label="模块" width="120" />
+      <el-table-column prop="action" label="动作" width="120" />
+      <el-table-column prop="detail" label="详情" min-width="240" show-overflow-tooltip />
+      <el-table-column prop="createTime" label="时间" min-width="160" />
+    </el-table>
+    <div class="pager">
+      <el-pagination
+        background
+        layout="total, prev, pager, next"
+        :total="total"
+        v-model:current-page="page"
+        :page-size="pageSize"
+        @current-change="load"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue'
+  import { fetchOperLogs } from '@/api/admin'
+
+  const loading = ref(false)
+  const list = ref<any[]>([])
+  const total = ref(0)
+  const page = ref(1)
+  const pageSize = 10
+
+  async function load() {
+    loading.value = true
+    try {
+      const res: any = await fetchOperLogs({ page: page.value, pageSize })
+      list.value = res.data.list || []
+      total.value = res.data.total || 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  onMounted(load)
+</script>
+
+<style scoped>
+  .panel {
+    background: #fff;
+    border-radius: 10px;
+    padding: 20px;
+  }
+  .pager {
+    margin-top: 14px;
+    display: flex;
+    justify-content: flex-end;
+  }
+</style>

+ 134 - 0
web-admin/src/views/role/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="panel role-page">
+    <aside class="roles">
+      <h3>角色</h3>
+      <div
+        v-for="r in roles"
+        :key="r.id"
+        class="role-item"
+        :class="{ active: r.id === currentId }"
+        @click="selectRole(r)"
+      >
+        <div class="name">{{ r.roleName }}</div>
+        <div class="code">{{ r.roleCode }}</div>
+      </div>
+    </aside>
+    <section class="grant">
+      <div class="head">
+        <h3>菜单授权 — {{ current?.roleName || '-' }}</h3>
+        <el-button type="primary" :disabled="!currentId" :loading="saving" @click="save"
+          >保存</el-button
+        >
+      </div>
+      <el-tree
+        v-if="treeData.length"
+        ref="treeRef"
+        show-checkbox
+        node-key="id"
+        :data="treeData"
+        :props="{ label: 'menuName', children: 'children' }"
+        default-expand-all
+      />
+      <el-empty v-else description="暂无菜单" />
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { nextTick, onMounted, ref } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchMenusTree, fetchRoleMenus, fetchRoles, saveRoleMenus } from '@/api/admin'
+
+  const roles = ref<any[]>([])
+  const treeData = ref<any[]>([])
+  const currentId = ref('')
+  const current = ref<any>(null)
+  const saving = ref(false)
+  const treeRef = ref()
+
+  async function loadRoles() {
+    const res: any = await fetchRoles()
+    roles.value = res.data || []
+    if (roles.value.length && !currentId.value) {
+      selectRole(roles.value[0])
+    }
+  }
+
+  async function loadTree() {
+    const res: any = await fetchMenusTree()
+    treeData.value = res.data || []
+  }
+
+  async function selectRole(r: any) {
+    current.value = r
+    currentId.value = r.id
+    const res: any = await fetchRoleMenus(r.id)
+    const ids: string[] = res.data?.menuIds || []
+    await nextTick()
+    treeRef.value?.setCheckedKeys(ids, false)
+  }
+
+  async function save() {
+    if (!currentId.value) return
+    saving.value = true
+    try {
+      const checked = treeRef.value?.getCheckedKeys(false) || []
+      const half = treeRef.value?.getHalfCheckedKeys() || []
+      const menuIds = [...checked, ...half]
+      await saveRoleMenus(currentId.value, menuIds)
+      ElMessage.success('授权已保存')
+    } finally {
+      saving.value = false
+    }
+  }
+
+  onMounted(async () => {
+    await loadTree()
+    await loadRoles()
+  })
+</script>
+
+<style scoped>
+  .role-page {
+    display: flex;
+    gap: 16px;
+    background: #fff;
+    border-radius: 10px;
+    padding: 16px;
+    min-height: 520px;
+  }
+  .roles {
+    width: 220px;
+    border-right: 1px solid #eee;
+    padding-right: 12px;
+  }
+  .role-item {
+    padding: 10px 12px;
+    border-radius: 8px;
+    cursor: pointer;
+    margin-bottom: 6px;
+  }
+  .role-item:hover,
+  .role-item.active {
+    background: #f8ecec;
+  }
+  .role-item .name {
+    font-weight: 600;
+  }
+  .role-item .code {
+    font-size: 12px;
+    color: #888;
+  }
+  .grant {
+    flex: 1;
+  }
+  .head {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 12px;
+  }
+  h3 {
+    margin: 0 0 12px;
+  }
+</style>

+ 184 - 0
web-admin/src/views/user/index.vue

@@ -0,0 +1,184 @@
+<template>
+  <div class="panel">
+    <div class="toolbar">
+      <el-button type="primary" @click="openCreate">新建用户</el-button>
+    </div>
+    <el-table :data="list" v-loading="loading" stripe>
+      <el-table-column prop="username" label="账号" min-width="120" />
+      <el-table-column prop="displayName" label="显示名" min-width="120" />
+      <el-table-column prop="roleName" label="角色" min-width="120" />
+      <el-table-column label="状态" width="100">
+        <template #default="{ row }">
+          <el-tag :type="row.status === '0' ? 'success' : 'info'" size="small">
+            {{ row.status === '0' ? '启用' : '停用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" min-width="260" fixed="right">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+          <el-button link type="primary" @click="toggleStatus(row)">
+            {{ row.status === '0' ? '停用' : '启用' }}
+          </el-button>
+          <el-button link type="primary" @click="openReset(row)">重置密码</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog v-model="editVisible" :title="editingId ? '编辑用户' : '新建用户'" width="480px">
+      <el-form :model="form" label-position="top">
+        <el-form-item label="账号" v-if="!editingId">
+          <el-input v-model="form.username" />
+        </el-form-item>
+        <el-form-item label="密码" v-if="!editingId">
+          <el-input v-model="form.password" type="password" show-password />
+        </el-form-item>
+        <el-form-item label="显示名">
+          <el-input v-model="form.displayName" />
+        </el-form-item>
+        <el-form-item label="角色">
+          <el-select v-model="form.roleId" style="width: 100%">
+            <el-option v-for="r in roles" :key="r.id" :label="r.roleName" :value="r.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveUser">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="pwdVisible" title="重置密码" width="420px">
+      <el-form label-position="top">
+        <el-form-item label="新密码">
+          <el-input v-model="newPassword" type="password" show-password />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="pwdVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="savePassword">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, reactive, ref } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import {
+    createUser,
+    fetchRoles,
+    fetchUsers,
+    resetUserPassword,
+    updateUser
+  } from '@/api/admin'
+
+  const loading = ref(false)
+  const saving = ref(false)
+  const list = ref<any[]>([])
+  const roles = ref<any[]>([])
+  const editVisible = ref(false)
+  const pwdVisible = ref(false)
+  const editingId = ref('')
+  const resetId = ref('')
+  const newPassword = ref('')
+  const form = reactive({
+    username: '',
+    password: '',
+    displayName: '',
+    roleId: ''
+  })
+
+  async function load() {
+    loading.value = true
+    try {
+      const [u, r]: any[] = await Promise.all([fetchUsers(), fetchRoles()])
+      list.value = u.data || []
+      roles.value = r.data || []
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function openCreate() {
+    editingId.value = ''
+    Object.assign(form, { username: '', password: '', displayName: '', roleId: roles.value[0]?.id || '' })
+    editVisible.value = true
+  }
+
+  function openEdit(row: any) {
+    editingId.value = row.id
+    Object.assign(form, {
+      username: row.username,
+      password: '',
+      displayName: row.displayName,
+      roleId: row.roleId
+    })
+    editVisible.value = true
+  }
+
+  function openReset(row: any) {
+    resetId.value = row.id
+    newPassword.value = ''
+    pwdVisible.value = true
+  }
+
+  async function saveUser() {
+    saving.value = true
+    try {
+      if (editingId.value) {
+        await updateUser(editingId.value, {
+          displayName: form.displayName,
+          roleId: form.roleId
+        })
+      } else {
+        await createUser({
+          username: form.username,
+          password: form.password,
+          displayName: form.displayName,
+          roleId: form.roleId
+        })
+      }
+      ElMessage.success('已保存')
+      editVisible.value = false
+      load()
+    } finally {
+      saving.value = false
+    }
+  }
+
+  async function toggleStatus(row: any) {
+    const status = row.status === '0' ? '1' : '0'
+    await updateUser(row.id, { status })
+    ElMessage.success(status === '0' ? '已启用' : '已停用')
+    load()
+  }
+
+  async function savePassword() {
+    if (!newPassword.value) {
+      ElMessage.warning('请输入新密码')
+      return
+    }
+    saving.value = true
+    try {
+      await resetUserPassword(resetId.value, newPassword.value)
+      ElMessage.success('密码已重置')
+      pwdVisible.value = false
+    } finally {
+      saving.value = false
+    }
+  }
+
+  onMounted(load)
+</script>
+
+<style scoped>
+  .panel {
+    background: #fff;
+    border-radius: 10px;
+    padding: 20px;
+  }
+  .toolbar {
+    margin-bottom: 14px;
+  }
+</style>