Bläddra i källkod

更新用户角色权限,允许经理角色访问用户管理页面;优化用户列表API,支持按ID和名称搜索;增强用户表单验证逻辑,限制角色修改权限;改进搜索功能,添加清除搜索条件按钮。

wuyi 4 dagar sedan
förälder
incheckning
4faaa2b990
5 ändrade filer med 131 tillägg och 47 borttagningar
  1. 1 1
      src/router/index.js
  2. 9 8
      src/services/api.js
  3. 3 3
      src/views/MainView.vue
  4. 13 15
      src/views/SmsTaskView.vue
  5. 105 20
      src/views/UserView.vue

+ 1 - 1
src/router/index.js

@@ -37,7 +37,7 @@ const router = createRouter({
           path: 'user',
           name: 'user',
           component: () => import('@/views/UserView.vue'),
-          meta: { roles: ['admin'] }
+          meta: { roles: ['admin', 'manager'] }
         },
         {
           path: 'sys-config',

+ 9 - 8
src/services/api.js

@@ -55,13 +55,15 @@ export const resetPasswordApi = async (password) => {
   return response.data
 }
 
-export const listUsersApi = async (page, size) => {
-  const response = await api.get('/users', {
-    params: {
-      page,
-      size
-    }
-  })
+export const listUsersApi = async (page, size, id, name) => {
+  const params = { page, size }
+  if (id !== undefined && id !== null && id !== '') {
+    params.id = id
+  }
+  if (name !== undefined && name !== null && name !== '') {
+    params.name = name
+  }
+  const response = await api.get('/users', { params })
   return response.data
 }
 
@@ -109,7 +111,6 @@ export const getSysConfigByName = async (name) => {
   return response.data
 }
 
-
 // 文件上传API
 export const uploadFile = async (file) => {
   const formData = new FormData()

+ 3 - 3
src/views/MainView.vue

@@ -47,7 +47,7 @@ const allNavItems = [
     label: '用户管理',
     icon: 'pi pi-fw pi-user',
     name: 'user',
-    roles: ['admin']
+    roles: ['admin', 'manager']
   },
   {
     label: '参数配置',
@@ -177,7 +177,7 @@ const handleResetPassword = async ({ valid, values }) => {
         </FloatLabel>
         <Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
           $form.password.error?.message
-        }}</Message>
+          }}</Message>
         <FloatLabel variant="on" class="mt-4">
           <IconField>
             <InputIcon class="pi pi-lock" />
@@ -188,7 +188,7 @@ const handleResetPassword = async ({ valid, values }) => {
         </FloatLabel>
         <Message v-if="$form.confirmPassword?.invalid" severity="error" size="small" variant="simple">{{
           $form.confirmPassword.error?.message
-        }}</Message>
+          }}</Message>
         <div class="field mt-4 text-right">
           <Button label="取消" severity="secondary" @click="resetingPasswordData.visible = false" />
           <Button label="保存" type="submit" class="ml-4" />

+ 13 - 15
src/views/SmsTaskView.vue

@@ -284,7 +284,7 @@ const isTaskOperating = (taskId) => {
 // 开始任务
 const handleStartTask = async (task) => {
     if (isTaskOperating(task.id)) return
-    
+
     operatingTaskIds.value.add(task.id)
     try {
         await startSmsTask(task.id)
@@ -312,7 +312,7 @@ const handleStartTask = async (task) => {
 // 暂停任务
 const handlePauseTask = async (task) => {
     if (isTaskOperating(task.id)) return
-    
+
     operatingTaskIds.value.add(task.id)
     try {
         await pauseSmsTask(task.id)
@@ -441,7 +441,7 @@ const lastClickTime = ref({
 const checkThrottle = (actionType) => {
     const now = Date.now()
     const lastTime = lastClickTime.value[actionType]
-    
+
     if (now - lastTime < 1000) {
         toast.add({
             severity: 'warn',
@@ -451,7 +451,7 @@ const checkThrottle = (actionType) => {
         })
         return false
     }
-    
+
     lastClickTime.value[actionType] = now
     return true
 }
@@ -497,12 +497,13 @@ onMounted(() => {
                     </FloatLabel>
                 </div>
 
+                <!-- 搜索按钮 -->
+                <Button icon="pi pi-search" label="搜索" @click="applyFilters" size="small" />
+                <Button v-if="filters.status" icon="pi pi-times" @click="resetFilters" label="清除" severity="secondary"
+                    size="small" />
+
                 <!-- 左侧按钮组 -->
                 <div class="flex items-center gap-2 flex-nowrap">
-                    <Button icon="pi pi-filter-slash" label="重置" severity="secondary" @click="resetFilters"
-                        size="small" />
-                    <Button icon="pi pi-search" label="搜索" @click="applyFilters" size="small" />
-
                     <span class="w-px h-6 bg-[var(--p-content-border-color)] mx-1"></span>
                     <Button icon="pi pi-refresh" @click="handleRefresh" label="刷新" size="small" />
                 </div>
@@ -572,14 +573,11 @@ onMounted(() => {
                     <div class="flex gap-1 justify-center">
                         <Button v-if="canStartTask(slotProps.data)" icon="pi pi-play" severity="success" size="small"
                             text rounded aria-label="开始" v-tooltip.top="'开始'"
-                            :disabled="isTaskOperating(slotProps.data.id)"
-                            :loading="isTaskOperating(slotProps.data.id)"
+                            :disabled="isTaskOperating(slotProps.data.id)" :loading="isTaskOperating(slotProps.data.id)"
                             @click="handleStartTask(slotProps.data)" />
                         <Button v-if="canPauseTask(slotProps.data)" icon="pi pi-pause" severity="warn" size="small" text
-                            rounded aria-label="暂停" v-tooltip.top="'暂停'"
-                            :disabled="isTaskOperating(slotProps.data.id)"
-                            :loading="isTaskOperating(slotProps.data.id)"
-                            @click="handlePauseTask(slotProps.data)" />
+                            rounded aria-label="暂停" v-tooltip.top="'暂停'" :disabled="isTaskOperating(slotProps.data.id)"
+                            :loading="isTaskOperating(slotProps.data.id)" @click="handlePauseTask(slotProps.data)" />
                         <Button icon="pi pi-eye" severity="info" size="small" text rounded aria-label="详情"
                             v-tooltip.top="'详情'" @click="openTaskDetailDialog(slotProps.data)" v-if="isAdmin" />
                         <Button icon="pi pi-pencil" severity="info" size="small" text rounded aria-label="编辑"
@@ -703,7 +701,7 @@ onMounted(() => {
                 </div>
                 <div class="text-[var(--p-text-color)] leading-relaxed whitespace-pre-wrap break-words">{{
                     currentTask.message
-                    }}</div>
+                }}</div>
             </div>
 
             <div class="mb-4 flex items-center gap-2">

+ 105 - 20
src/views/UserView.vue

@@ -1,6 +1,7 @@
 <script setup>
 import { UserRole } from '@/enums'
 import { createUserApi, listUsersApi, updateUserApi } from '@/services/api'
+import { useUserStore } from '@/stores/user'
 import { Form } from '@primevue/forms'
 import { zodResolver } from '@primevue/forms/resolvers/zod'
 import { useDateFormat } from '@vueuse/core'
@@ -13,6 +14,7 @@ import FloatLabel from 'primevue/floatlabel'
 import IconField from 'primevue/iconfield'
 import InputIcon from 'primevue/inputicon'
 import InputText from 'primevue/inputtext'
+import InputNumber from 'primevue/inputnumber'
 import Message from 'primevue/message'
 import Password from 'primevue/password'
 import { useToast } from 'primevue/usetoast'
@@ -20,6 +22,9 @@ import { computed, onMounted, ref } from 'vue'
 import { z } from 'zod'
 
 const toast = useToast()
+const userStore = useUserStore()
+const currentUserRole = computed(() => userStore.userInfo?.role || '')
+
 const tableData = ref({
   content: [],
   metadata: {
@@ -29,11 +34,29 @@ const tableData = ref({
   }
 })
 const search = ref('')
+const searchId = ref(null)
+const isAdmin = computed(() => currentUserRole.value === 'admin')
+
 const fetchData = async () => {
-  const response = await listUsersApi(tableData.value.metadata.page, tableData.value.metadata.size)
+  const id = isAdmin.value && searchId.value ? Number(searchId.value) : undefined
+  const name = search.value.trim() || undefined
+  const response = await listUsersApi(
+    tableData.value.metadata.page,
+    tableData.value.metadata.size,
+    id,
+    name
+  )
   tableData.value = response
 }
 
+// 清除搜索条件
+const clearSearch = () => {
+  search.value = ''
+  searchId.value = null
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
 const handlePageChange = (event) => {
   console.log('handlePageChange', event)
   tableData.value.metadata.page = event.page
@@ -50,13 +73,32 @@ const getRoleName = (role) => {
   return UserRole[role] || role
 }
 
-// 用户角色选项
+// 用户角色选项 - 根据当前用户角色限制
 const roleOptions = computed(() => {
-  const allowedRoles = ['user', 'admin', 'channel', 'operator']
-  return allowedRoles.map((role) => ({
-    value: role,
-    label: UserRole[role]
-  }))
+  // ADMIN 可以创建所有角色
+  if (isAdmin.value) {
+    return ['admin', 'manager', 'user'].map((role) => ({
+      value: role,
+      label: UserRole[role]
+    }))
+  }
+  
+  // MANAGER 在创建模式下只能创建 user 角色
+  // 在编辑模式下,显示所有角色(但禁用),以便查看当前角色
+  if (isEditMode.value) {
+    return ['admin', 'manager', 'user'].map((role) => ({
+      value: role,
+      label: UserRole[role]
+    }))
+  }
+  
+  // MANAGER 创建新用户时只能选择 user
+  return [
+    {
+      value: 'user',
+      label: UserRole.user
+    }
+  ]
 })
 
 // 用户表单相关
@@ -77,16 +119,21 @@ const userFormResolver = computed(() => {
     ? z.string().optional() // 更新时密码可选
     : z.string().min(8, { message: '密码至少8位' })
 
-  return zodResolver(
-    z.object({
-      name: z.string().min(1, { message: '用户名不能为空' }),
-      password: passwordRules,
-      confirmPassword: z
-        .string()
-        .refine((val) => !userForm.value.password || val === userForm.value.password, { message: '密码不一致' }),
-      role: z.string().min(1, { message: '请选择角色' })
-    })
-  )
+  // 构建基础验证对象
+  const baseSchema = {
+    name: z.string().min(1, { message: '用户名不能为空' }),
+    password: passwordRules,
+    confirmPassword: z
+      .string()
+      .refine((val) => !userForm.value.password || val === userForm.value.password, { message: '密码不一致' })
+  }
+
+  // ADMIN 可以修改角色,MANAGER 不能修改角色
+  if (isAdmin.value || !isEditMode.value) {
+    baseSchema.role = z.string().min(1, { message: '请选择角色' })
+  }
+
+  return zodResolver(z.object(baseSchema))
 })
 
 const openNewUserDialog = () => {
@@ -120,9 +167,14 @@ const saveUser = async ({ valid, values }) => {
   try {
     // 构建提交数据,过滤掉不需要的confirmPassword
     const submitData = {
-      name: values.name,
-      role: values.role
+      name: values.name
+    }
+
+    // 只有 ADMIN 可以设置/修改角色,或者创建新用户时可以设置角色
+    if (isAdmin.value || !isEditMode.value) {
+      submitData.role = values.role
     }
+    // MANAGER 在编辑模式下不能修改角色,不传 role 字段
 
     if (values.password) {
       submitData.password = values.password
@@ -173,15 +225,44 @@ onMounted(() => {
     <!-- 操作栏 -->
     <div class="mb-4 overflow-x-auto py-2">
       <div class="flex items-center gap-2 flex-nowrap min-w-[760px]">
+        <!-- 搜索框:用户名 -->
         <div class="field w-36">
           <IconField>
             <InputIcon>
               <i class="pi pi-search" />
             </InputIcon>
-            <InputText v-model="search" placeholder="搜索" fluid />
+            <InputText v-model="search" placeholder="搜索用户名" fluid size="small" @keyup.enter="fetchData" />
           </IconField>
         </div>
 
+        <!-- 搜索框:ID(仅 ADMIN 显示) -->
+        <div v-if="isAdmin" class="field w-36">
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-hashtag" />
+            </InputIcon>
+            <InputNumber
+              v-model="searchId"
+              placeholder="搜索ID"
+              :useGrouping="false"
+              fluid
+              size="small"
+              @keyup.enter="fetchData"
+            />
+          </IconField>
+        </div>
+
+        <!-- 搜索按钮 -->
+        <Button icon="pi pi-search" @click="fetchData" label="搜索" size="small" />
+        <Button
+          v-if="search || searchId"
+          icon="pi pi-times"
+          @click="clearSearch"
+          label="清除"
+          severity="secondary"
+          size="small"
+        />
+
         <!-- 左侧按钮组 -->
         <div class="flex items-center gap-2 flex-nowrap">
           <span class="w-px h-6 bg-[var(--p-content-border-color)] mx-1"></span>
@@ -318,6 +399,7 @@ onMounted(() => {
               :options="roleOptions"
               optionLabel="label"
               optionValue="value"
+              :disabled="isEditMode && !isAdmin"
               fluid
             />
             <label for="role">角色</label>
@@ -325,6 +407,9 @@ onMounted(() => {
           <Message v-if="$form.role?.invalid" severity="error" size="small" variant="simple">
             {{ $form.role.error?.message }}
           </Message>
+          <div v-if="isEditMode && !isAdmin" class="text-sm text-gray-500 mt-1 ml-1">
+            * 您无权修改用户角色
+          </div>
         </div>
 
         <div class="flex justify-end gap-2 mt-4">