| | |
| | | <template> |
| | | <div> |
| | | <div class="page"> |
| | | <h2 class="page-title">平台管理员</h2> |
| | | <el-form :inline="true" class="search-form" @submit.prevent="doSearch"> |
| | | <el-form-item label="姓名"> |
| | | <el-input v-model="filters.realName" placeholder="模糊查询" clearable style="width: 140px" /> |
| | | <el-input v-model="filters.realName" placeholder="请输入姓名" clearable style="width: 140px" /> |
| | | </el-form-item> |
| | | <el-form-item label="用户电话"> |
| | | <el-input v-model="filters.mobile" placeholder="模糊查询" clearable style="width: 140px" /> |
| | | <el-input v-model="filters.mobile" placeholder="请输入用户电话" clearable style="width: 140px" /> |
| | | </el-form-item> |
| | | <el-form-item label="角色"> |
| | | <el-select |
| | |
| | | <el-option |
| | | v-for="r in roles" |
| | | :key="r.id" |
| | | :label="`${r.roleName} (${r.roleCode})`" |
| | | :label="r.roleName" |
| | | :value="r.id" |
| | | /> |
| | | </el-select> |
| | |
| | | <el-button @click="resetSearch">重置</el-button> |
| | | </el-form-item> |
| | | <el-form-item class="push-right"> |
| | | <el-button v-permission="'sys:users:create'" type="success" @click="openCreate">新增用户</el-button> |
| | | <el-button v-permission="'sys:users:create'" type="primary" @click="openCreate">新增管理员</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <el-table :data="rows" v-loading="loading" stripe> |
| | | <div class="table-wrap"> |
| | | <el-table :data="rows" v-loading="loading" stripe height="100%"> |
| | | <el-table-column label="序号" width="64" :index="indexMethod" type="index" /> |
| | | <el-table-column prop="realName" label="姓名" width="100" show-overflow-tooltip /> |
| | | <el-table-column prop="username" label="登录账号" width="120" show-overflow-tooltip /> |
| | |
| | | <el-table-column label="操作" width="300" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <div class="table-ops"> |
| | | <el-button v-permission="'sys:users:update'" type="primary" link @click="openEdit(row)">编辑</el-button> |
| | | <el-button |
| | | v-if="!isSuperAdminRow(row)" |
| | | v-permission="'sys:users:update'" |
| | | type="primary" |
| | | link |
| | | @click="openEdit(row)" |
| | | > |
| | | 编辑 |
| | | </el-button> |
| | | <el-button |
| | | v-permission="'sys:users:status'" |
| | | v-if="row.status === 1" |
| | | type="danger" |
| | | v-if="row.status === 1 && !isSuperAdminRow(row)" |
| | | type="primary" |
| | | link |
| | | @click="confirmDisable(row)" |
| | | > |
| | | 禁用 |
| | | </el-button> |
| | | <el-button v-else v-permission="'sys:users:status'" type="success" link @click="enableUser(row)">启用</el-button> |
| | | <el-button v-permission="'sys:users:password'" type="primary" link @click="openResetPwd(row)">重置密码</el-button> |
| | | <el-button v-permission="'sys:users:delete'" type="danger" link @click="confirmDelete(row)">删除</el-button> |
| | | <el-button |
| | | v-else-if="!isSuperAdminRow(row)" |
| | | v-permission="'sys:users:status'" |
| | | type="primary" |
| | | link |
| | | @click="enableUser(row)" |
| | | > |
| | | 启用 |
| | | </el-button> |
| | | <el-button |
| | | v-if="!isSuperAdminRow(row)" |
| | | v-permission="'sys:users:password'" |
| | | type="primary" |
| | | link |
| | | @click="openResetPwd(row)" |
| | | > |
| | | 重置密码 |
| | | </el-button> |
| | | <el-button |
| | | v-if="!isSuperAdminRow(row)" |
| | | v-permission="'sys:users:delete'" |
| | | type="primary" |
| | | link |
| | | @click="confirmDelete(row)" |
| | | > |
| | | 删除 |
| | | </el-button> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-table> |
| | | </div> |
| | | |
| | | <el-pagination |
| | | class="pager" |
| | | class="main-pager" |
| | | background |
| | | layout="prev, pager, next" |
| | | layout="sizes, prev, pager, next" |
| | | :total="total" |
| | | :page-size="size" |
| | | :page-sizes="[10, 20, 50, 100]" |
| | | v-model:current-page="page" |
| | | @current-change="load" |
| | | @size-change="onSizeChange" |
| | | /> |
| | | |
| | | <!-- 新增 --> |
| | | <el-dialog v-model="dlgCreate" title="新增用户" width="520px" destroy-on-close @closed="resetCreateForm"> |
| | | <el-dialog v-model="dlgCreate" title="新增管理员" width="520px" destroy-on-close @closed="resetCreateForm"> |
| | | <el-form :model="createForm" label-width="100px"> |
| | | <el-form-item label="登录名" required> |
| | | <el-input v-model="createForm.username" autocomplete="off" /> |
| | | <el-form-item label="用户名称" required> |
| | | <el-input v-model="createForm.realName" placeholder="请输入用户名称" /> |
| | | </el-form-item> |
| | | <el-form-item label="密码" required> |
| | | <el-input v-model="createForm.password" type="password" show-password autocomplete="new-password" /> |
| | | <el-form-item label="联系电话" required> |
| | | <el-input v-model="createForm.mobile" placeholder="请输入联系电话" /> |
| | | </el-form-item> |
| | | <el-form-item label="姓名"> |
| | | <el-input v-model="createForm.realName" /> |
| | | <el-form-item label="登录账号" required> |
| | | <el-input v-model="createForm.username" autocomplete="off" placeholder="请输入登录账号" /> |
| | | </el-form-item> |
| | | <el-form-item label="手机"> |
| | | <el-input v-model="createForm.mobile" /> |
| | | <el-form-item label="初始密码" required> |
| | | <el-input |
| | | v-model="createForm.password" |
| | | type="password" |
| | | show-password |
| | | autocomplete="new-password" |
| | | placeholder="请输入至少12位含大小写字母、特殊字符的密码" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="头像URL"> |
| | | <el-input v-model="createForm.avatarUrl" placeholder="可选" /> |
| | | <el-form-item label="头像"> |
| | | <div class="avatar-upload-row"> |
| | | <el-avatar v-if="createForm.avatarUrl" :size="64" :src="createForm.avatarUrl" /> |
| | | <el-avatar v-else :size="64">{{ (createForm.realName || createForm.username || "?").slice(0, 1) }}</el-avatar> |
| | | <div class="avatar-upload-actions"> |
| | | <el-upload |
| | | :show-file-list="false" |
| | | accept=".jpg,.jpeg,.png,.webp,.gif" |
| | | :http-request="(opt) => uploadAvatar(opt, 'create')" |
| | | > |
| | | <el-button :loading="avatarUploading === 'create'">上传头像</el-button> |
| | | </el-upload> |
| | | <el-button v-if="createForm.avatarUrl" link type="danger" @click="createForm.avatarUrl = ''">清除</el-button> |
| | | <div class="avatar-upload-tip">支持 jpg、png、webp、gif,大小不超过 5MB</div> |
| | | </div> |
| | | </div> |
| | | </el-form-item> |
| | | <el-form-item label="角色" required> |
| | | <el-select v-model="createForm.roleIds" multiple placeholder="必选,可多选" style="width: 100%"> |
| | | <el-form-item label="所属角色" required> |
| | | <el-select v-model="createForm.roleIds" multiple placeholder="请选择所属角色(可多选)" style="width: 100%"> |
| | | <el-option |
| | | v-for="r in roles" |
| | | :key="r.id" |
| | | :label="`${r.roleName} (${r.roleCode})`" |
| | | :label="r.roleName" |
| | | :value="r.id" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="状态"> |
| | | <el-switch |
| | | v-model="createForm.statusEnabled" |
| | | inline-prompt |
| | | active-text="启用" |
| | | inactive-text="禁用" |
| | | :disabled="isAdminUsername(createForm.username)" |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | |
| | | <el-form-item label="手机"> |
| | | <el-input v-model="editForm.mobile" /> |
| | | </el-form-item> |
| | | <el-form-item label="头像URL"> |
| | | <el-input v-model="editForm.avatarUrl" /> |
| | | <el-form-item label="头像"> |
| | | <div class="avatar-upload-row"> |
| | | <el-avatar v-if="editForm.avatarUrl" :size="64" :src="editForm.avatarUrl" /> |
| | | <el-avatar v-else :size="64">{{ (editForm.realName || editForm.username || "?").slice(0, 1) }}</el-avatar> |
| | | <div class="avatar-upload-actions"> |
| | | <el-upload |
| | | :show-file-list="false" |
| | | accept=".jpg,.jpeg,.png,.webp,.gif" |
| | | :http-request="(opt) => uploadAvatar(opt, 'edit')" |
| | | > |
| | | <el-button :loading="avatarUploading === 'edit'">上传头像</el-button> |
| | | </el-upload> |
| | | <el-button v-if="editForm.avatarUrl" link type="danger" @click="editForm.avatarUrl = ''">清除</el-button> |
| | | <div class="avatar-upload-tip">支持 jpg、png、webp、gif,大小不超过 5MB</div> |
| | | </div> |
| | | </div> |
| | | </el-form-item> |
| | | <el-form-item label="角色" required> |
| | | <el-select v-model="editForm.roleIds" multiple placeholder="必选" style="width: 100%"> |
| | | <el-select v-model="editForm.roleIds" multiple placeholder="请选择所属角色" style="width: 100%"> |
| | | <el-option |
| | | v-for="r in roles" |
| | | :key="r.id" |
| | | :label="`${r.roleName} (${r.roleCode})`" |
| | | :label="r.roleName" |
| | | :value="r.id" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="状态"> |
| | | <el-switch |
| | | v-model="editForm.statusEnabled" |
| | | inline-prompt |
| | | active-text="启用" |
| | | inactive-text="禁用" |
| | | :disabled="isAdminUsername(editForm.username)" |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | |
| | | </el-dialog> |
| | | |
| | | <!-- 重置密码 --> |
| | | <el-dialog v-model="dlgPwd" title="重置密码" width="440px" destroy-on-close @closed="resetPwdForm"> |
| | | <el-dialog v-model="dlgPwd" title="重置密码" width="560px" destroy-on-close @closed="resetPwdForm"> |
| | | <el-form :model="pwdForm" label-width="120px"> |
| | | <el-form-item label="账号名称"> |
| | | <el-input v-model="pwdUserName" disabled /> |
| | | </el-form-item> |
| | | <el-form-item label="新密码" required> |
| | | <el-input v-model="pwdForm.newPassword" type="password" show-password autocomplete="new-password" /> |
| | | <el-input |
| | | v-model="pwdForm.newPassword" |
| | | type="password" |
| | | show-password |
| | | autocomplete="new-password" |
| | | placeholder="请输入至少12位含大小写字母、特殊字符的密码" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="确认新密码" required> |
| | | <el-input v-model="pwdForm.newPasswordConfirm" type="password" show-password autocomplete="new-password" /> |
| | | <el-input |
| | | v-model="pwdForm.newPasswordConfirm" |
| | | type="password" |
| | | show-password |
| | | autocomplete="new-password" |
| | | placeholder="请输入至少12位含大小写字母、特殊字符的密码" |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | |
| | | <script setup lang="ts"> |
| | | import { onMounted, reactive, ref } from "vue"; |
| | | import { ElMessage, ElMessageBox } from "element-plus"; |
| | | import type { UploadRequestOptions } from "element-plus"; |
| | | import http from "../api/http"; |
| | | import { normalizeFormData } from "../utils/formHelper"; |
| | | |
| | | type RoleRow = { id: number; roleCode: string; roleName: string }; |
| | | |
| | |
| | | |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const avatarUploading = ref<"create" | "edit" | "">(""); |
| | | const rows = ref<UserRow[]>([]); |
| | | const page = ref(1); |
| | | const size = ref(10); |
| | |
| | | const editingId = ref<number | null>(null); |
| | | const pwdUserId = ref<number | null>(null); |
| | | const pwdUserUuid = ref(""); |
| | | const pwdUserName = ref(""); |
| | | |
| | | const createForm = reactive({ |
| | | username: "", |
| | |
| | | mobile: "", |
| | | avatarUrl: "", |
| | | roleIds: [] as number[], |
| | | statusEnabled: true, |
| | | }); |
| | | |
| | | const editForm = reactive({ |
| | |
| | | mobile: "", |
| | | avatarUrl: "", |
| | | roleIds: [] as number[], |
| | | statusEnabled: true, |
| | | }); |
| | | |
| | | const pwdForm = reactive({ |
| | | newPassword: "", |
| | | newPasswordConfirm: "", |
| | | }); |
| | | const SUPER_ADMIN_USERNAME = "admin"; |
| | | |
| | | function indexMethod(idx: number) { |
| | | return (page.value - 1) * size.value + idx + 1; |
| | |
| | | load(); |
| | | } |
| | | |
| | | function onSizeChange(nextSize: number) { |
| | | size.value = nextSize; |
| | | page.value = 1; |
| | | load(); |
| | | } |
| | | |
| | | function resetCreateForm() { |
| | | createForm.username = ""; |
| | | createForm.password = ""; |
| | |
| | | createForm.mobile = ""; |
| | | createForm.avatarUrl = ""; |
| | | createForm.roleIds = []; |
| | | createForm.statusEnabled = true; |
| | | } |
| | | |
| | | function openCreate() { |
| | |
| | | } |
| | | |
| | | async function submitCreate() { |
| | | if (!createForm.username.trim() || !createForm.password) { |
| | | ElMessage.warning("请填写登录名与密码"); |
| | | if (!createForm.realName.trim()) { |
| | | ElMessage.warning("请填写用户名称"); |
| | | return; |
| | | } |
| | | if (createForm.password.length < 6) { |
| | | ElMessage.warning("密码至少 6 位"); |
| | | if (!createForm.mobile.trim()) { |
| | | ElMessage.warning("请填写联系电话"); |
| | | return; |
| | | } |
| | | if (!createForm.username.trim()) { |
| | | ElMessage.warning("请填写登录账号"); |
| | | return; |
| | | } |
| | | if (!createForm.password) { |
| | | ElMessage.warning("请填写初始密码"); |
| | | return; |
| | | } |
| | | if (!isStrongPassword(createForm.password)) { |
| | | ElMessage.warning("请输入至少12位含大小写字母、特殊字符的密码"); |
| | | return; |
| | | } |
| | | if (!createForm.roleIds.length) { |
| | | ElMessage.warning("请至少选择一个角色"); |
| | | return; |
| | | } |
| | | const nextStatus = isAdminUsername(createForm.username) ? 1 : (createForm.statusEnabled ? 1 : 0); |
| | | saving.value = true; |
| | | try { |
| | | await http.post("/sys/users", { |
| | | const payload = normalizeFormData({ |
| | | username: createForm.username.trim(), |
| | | password: createForm.password, |
| | | realName: createForm.realName || undefined, |
| | | mobile: createForm.mobile || undefined, |
| | | avatarUrl: createForm.avatarUrl || undefined, |
| | | roleIds: createForm.roleIds, |
| | | status: nextStatus, |
| | | }); |
| | | await http.post("/sys/users", payload); |
| | | ElMessage.success("已创建"); |
| | | dlgCreate.value = false; |
| | | load(); |
| | |
| | | editForm.mobile = u.mobile || ""; |
| | | editForm.avatarUrl = u.avatarUrl || ""; |
| | | editForm.roleIds = [...(u.roleIds || [])]; |
| | | editForm.statusEnabled = Number(u.status) !== 0; |
| | | dlgEdit.value = true; |
| | | } catch (e: unknown) { |
| | | ElMessage.error(e instanceof Error ? e.message : "加载详情失败"); |
| | |
| | | ElMessage.warning("请至少选择一个角色"); |
| | | return; |
| | | } |
| | | const nextStatus = isAdminUsername(editForm.username) ? 1 : (editForm.statusEnabled ? 1 : 0); |
| | | saving.value = true; |
| | | try { |
| | | await http.put(`/sys/users/${editingId.value}`, { |
| | | const payload = normalizeFormData({ |
| | | uuid: editForm.uuid, |
| | | realName: editForm.realName || undefined, |
| | | mobile: editForm.mobile || undefined, |
| | | avatarUrl: editForm.avatarUrl || undefined, |
| | | roleIds: editForm.roleIds, |
| | | status: nextStatus, |
| | | }); |
| | | await http.put(`/sys/users/${editingId.value}`, payload); |
| | | ElMessage.success("已保存"); |
| | | dlgEdit.value = false; |
| | | load(); |
| | |
| | | } |
| | | } |
| | | |
| | | async function uploadAvatar(opt: UploadRequestOptions, target: "create" | "edit") { |
| | | const fd = new FormData(); |
| | | fd.append("file", opt.file as File); |
| | | avatarUploading.value = target; |
| | | try { |
| | | const { data } = await http.post("/common/upload/image", fd, { |
| | | headers: { "Content-Type": "multipart/form-data" }, |
| | | }); |
| | | const url = String(data.result?.url || ""); |
| | | if (!url) { |
| | | throw new Error("上传结果缺少图片地址"); |
| | | } |
| | | if (target === "create") { |
| | | createForm.avatarUrl = url; |
| | | } else { |
| | | editForm.avatarUrl = url; |
| | | } |
| | | ElMessage.success("头像上传成功"); |
| | | } catch (e: unknown) { |
| | | ElMessage.error(e instanceof Error ? e.message : "头像上传失败"); |
| | | } finally { |
| | | avatarUploading.value = ""; |
| | | } |
| | | } |
| | | |
| | | function openResetPwd(row: UserRow) { |
| | | pwdUserId.value = row.id; |
| | | pwdUserUuid.value = row.uuid; |
| | | pwdUserName.value = row.username || ""; |
| | | pwdForm.newPassword = ""; |
| | | pwdForm.newPasswordConfirm = ""; |
| | | dlgPwd.value = true; |
| | |
| | | pwdForm.newPasswordConfirm = ""; |
| | | pwdUserId.value = null; |
| | | pwdUserUuid.value = ""; |
| | | pwdUserName.value = ""; |
| | | } |
| | | |
| | | async function submitPwd() { |
| | | if (pwdUserId.value == null) return; |
| | | if (pwdForm.newPassword.length < 6) { |
| | | ElMessage.warning("密码至少 6 位"); |
| | | if (!isStrongPassword(pwdForm.newPassword)) { |
| | | ElMessage.warning("请输入至少12位含大小写字母、特殊字符的密码"); |
| | | return; |
| | | } |
| | | if (pwdForm.newPassword !== pwdForm.newPasswordConfirm) { |
| | |
| | | } |
| | | saving.value = true; |
| | | try { |
| | | await http.post(`/sys/users/${pwdUserId.value}/password`, { |
| | | const payload = normalizeFormData({ |
| | | uuid: pwdUserUuid.value, |
| | | newPassword: pwdForm.newPassword, |
| | | newPasswordConfirm: pwdForm.newPasswordConfirm, |
| | | }); |
| | | await http.post(`/sys/users/${pwdUserId.value}/password`, payload); |
| | | ElMessage.success("密码已重置"); |
| | | dlgPwd.value = false; |
| | | } catch (e: unknown) { |
| | |
| | | } |
| | | } |
| | | |
| | | function isStrongPassword(value: string) { |
| | | return ( |
| | | value.length >= 12 |
| | | && value.length <= 64 |
| | | && /[a-z]/.test(value) |
| | | && /[A-Z]/.test(value) |
| | | && /[^A-Za-z0-9]/.test(value) |
| | | ); |
| | | } |
| | | |
| | | function isSuperAdminRow(row: UserRow) { |
| | | return (row.username || "").trim().toLowerCase() === SUPER_ADMIN_USERNAME; |
| | | } |
| | | |
| | | function isAdminUsername(username?: string) { |
| | | return (username || "").trim().toLowerCase() === SUPER_ADMIN_USERNAME; |
| | | } |
| | | |
| | | function confirmDisable(row: UserRow) { |
| | | if (isSuperAdminRow(row)) { |
| | | ElMessage.warning("超级管理员账号不可禁用"); |
| | | return; |
| | | } |
| | | ElMessageBox.confirm("禁用后该用户将无法登录管理后台,是否继续?", "确认禁用", { |
| | | confirmButtonText: "禁用", |
| | | cancelButtonText: "取消", |
| | |
| | | } |
| | | |
| | | function confirmDelete(row: UserRow) { |
| | | if (isSuperAdminRow(row)) { |
| | | ElMessage.warning("超级管理员账号不可删除"); |
| | | return; |
| | | } |
| | | ElMessageBox.confirm("删除为逻辑删除,且将解除该用户角色关联,是否继续?", "确认删除", { |
| | | confirmButtonText: "删除", |
| | | cancelButtonText: "取消", |
| | |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .page-title { |
| | | margin: 0 0 12px; |
| | | color: #2e446e; |
| | | font-size: 18px; |
| | | font-weight: 700; |
| | | } |
| | | |
| | | .page { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: calc(100vh - 190px); |
| | | min-height: 420px; |
| | | } |
| | | |
| | | .search-form { |
| | | margin-bottom: 12px; |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px 0; |
| | | flex: none; |
| | | } |
| | | .push-right { |
| | | margin-left: auto; |
| | | } |
| | | .pager { |
| | | .table-wrap { |
| | | flex: 1; |
| | | min-height: 0; |
| | | } |
| | | .main-pager { |
| | | margin-top: 12px; |
| | | justify-content: flex-end; |
| | | flex: none; |
| | | } |
| | | |
| | | .avatar-upload-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 14px; |
| | | } |
| | | |
| | | .avatar-upload-actions { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: flex-start; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .avatar-upload-tip { |
| | | color: #8a97b3; |
| | | font-size: 12px; |
| | | line-height: 1.4; |
| | | } |
| | | </style> |