<template>
|
<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-form-item>
|
<el-form-item label="用户电话">
|
<el-input v-model="filters.mobile" placeholder="请输入用户电话" clearable style="width: 140px" />
|
</el-form-item>
|
<el-form-item label="角色">
|
<el-select
|
v-model="filters.roleId"
|
placeholder="全部"
|
clearable
|
style="width: 180px"
|
>
|
<el-option
|
v-for="r in roles"
|
:key="r.id"
|
:label="r.roleName"
|
:value="r.id"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item label="账号状态">
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
|
<el-option label="启用" :value="1" />
|
<el-option label="禁用" :value="0" />
|
</el-select>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" native-type="submit">搜索</el-button>
|
<el-button @click="resetSearch">重置</el-button>
|
</el-form-item>
|
<el-form-item class="push-right">
|
<el-button v-permission="'sys:users:create'" type="primary" @click="openCreate">新增管理员</el-button>
|
</el-form-item>
|
</el-form>
|
|
<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 prop="mobileMasked" label="联系电话" width="140" />
|
<el-table-column prop="rolesDisplay" label="角色" min-width="160" show-overflow-tooltip />
|
<el-table-column label="状态" width="88">
|
<template #default="{ row }">
|
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
{{ row.status === 1 ? "启用" : "禁用" }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column label="创建时间" width="170">
|
<template #default="{ row }">
|
{{ formatTime(row.createTime) }}
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="300" fixed="right">
|
<template #default="{ row }">
|
<div class="table-ops">
|
<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 && !isSuperAdminRow(row)"
|
type="primary"
|
link
|
@click="confirmDisable(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>
|
</div>
|
|
<el-pagination
|
class="main-pager"
|
background
|
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-form :model="createForm" label-width="100px">
|
<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.mobile" placeholder="请输入联系电话" />
|
</el-form-item>
|
<el-form-item label="登录账号" required>
|
<el-input v-model="createForm.username" autocomplete="off" placeholder="请输入登录账号" />
|
</el-form-item>
|
<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="头像">
|
<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-option
|
v-for="r in roles"
|
:key="r.id"
|
: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-button @click="dlgCreate = false">取消</el-button>
|
<el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button>
|
</template>
|
</el-dialog>
|
|
<!-- 编辑 -->
|
<el-dialog v-model="dlgEdit" title="编辑用户" width="520px" destroy-on-close>
|
<el-form :model="editForm" label-width="100px">
|
<el-form-item label="登录名">
|
<el-input v-model="editForm.username" disabled />
|
</el-form-item>
|
<el-form-item label="姓名">
|
<el-input v-model="editForm.realName" />
|
</el-form-item>
|
<el-form-item label="手机">
|
<el-input v-model="editForm.mobile" />
|
</el-form-item>
|
<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-option
|
v-for="r in roles"
|
:key="r.id"
|
: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-button @click="dlgEdit = false">取消</el-button>
|
<el-button type="primary" :loading="saving" @click="submitEdit">保存</el-button>
|
</template>
|
</el-dialog>
|
|
<!-- 重置密码 -->
|
<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"
|
placeholder="请输入至少12位含大小写字母、特殊字符的密码"
|
/>
|
</el-form-item>
|
<el-form-item label="确认新密码" required>
|
<el-input
|
v-model="pwdForm.newPasswordConfirm"
|
type="password"
|
show-password
|
autocomplete="new-password"
|
placeholder="请输入至少12位含大小写字母、特殊字符的密码"
|
/>
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<el-button @click="dlgPwd = false">取消</el-button>
|
<el-button type="primary" :loading="saving" @click="submitPwd">确定</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<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";
|
|
type RoleRow = { id: number; roleCode: string; roleName: string };
|
|
type UserRow = {
|
id: number;
|
uuid: string;
|
username: string;
|
realName?: string;
|
mobileMasked: string;
|
rolesDisplay: string;
|
status: number;
|
createTime?: 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 total = ref(0);
|
const roles = ref<RoleRow[]>([]);
|
|
const filters = reactive({
|
realName: "",
|
mobile: "",
|
roleId: undefined as number | undefined,
|
status: undefined as number | undefined,
|
});
|
|
const dlgCreate = ref(false);
|
const dlgEdit = ref(false);
|
const dlgPwd = ref(false);
|
const editingId = ref<number | null>(null);
|
const pwdUserId = ref<number | null>(null);
|
const pwdUserUuid = ref("");
|
const pwdUserName = ref("");
|
|
const createForm = reactive({
|
username: "",
|
password: "",
|
realName: "",
|
mobile: "",
|
avatarUrl: "",
|
roleIds: [] as number[],
|
statusEnabled: true,
|
});
|
|
const editForm = reactive({
|
uuid: "",
|
username: "",
|
realName: "",
|
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;
|
}
|
|
function formatTime(v: unknown) {
|
if (v == null || v === "") return "—";
|
const s = String(v);
|
return s.length >= 19 ? s.slice(0, 19).replace("T", " ") : s;
|
}
|
|
async function loadMeta() {
|
const { data } = await http.get("/sys/roles");
|
roles.value = (data.result || []) as RoleRow[];
|
}
|
|
function buildQueryParams() {
|
return {
|
page: page.value,
|
size: size.value,
|
realName: filters.realName?.trim() || undefined,
|
mobile: filters.mobile?.trim() || undefined,
|
roleId: filters.roleId,
|
status: filters.status,
|
};
|
}
|
|
async function load() {
|
loading.value = true;
|
try {
|
const { data } = await http.get("/sys/users", { params: buildQueryParams() });
|
rows.value = (data.result || []) as UserRow[];
|
total.value = Number(data.total) || 0;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载失败");
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function doSearch() {
|
page.value = 1;
|
load();
|
}
|
|
function resetSearch() {
|
filters.realName = "";
|
filters.mobile = "";
|
filters.roleId = undefined;
|
filters.status = undefined;
|
page.value = 1;
|
load();
|
}
|
|
function onSizeChange(nextSize: number) {
|
size.value = nextSize;
|
page.value = 1;
|
load();
|
}
|
|
function resetCreateForm() {
|
createForm.username = "";
|
createForm.password = "";
|
createForm.realName = "";
|
createForm.mobile = "";
|
createForm.avatarUrl = "";
|
createForm.roleIds = [];
|
createForm.statusEnabled = true;
|
}
|
|
function openCreate() {
|
resetCreateForm();
|
dlgCreate.value = true;
|
}
|
|
async function submitCreate() {
|
if (!createForm.realName.trim()) {
|
ElMessage.warning("请填写用户名称");
|
return;
|
}
|
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", {
|
username: createForm.username.trim(),
|
password: createForm.password,
|
realName: createForm.realName || undefined,
|
mobile: createForm.mobile || undefined,
|
avatarUrl: createForm.avatarUrl || undefined,
|
roleIds: createForm.roleIds,
|
status: nextStatus,
|
});
|
ElMessage.success("已创建");
|
dlgCreate.value = false;
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "保存失败");
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
async function openEdit(row: UserRow) {
|
const id = row.id;
|
editingId.value = id;
|
try {
|
const { data } = await http.get(`/sys/users/${id}`, { params: { uuid: row.uuid } });
|
const u = data.result;
|
editForm.uuid = u.uuid;
|
editForm.username = u.username;
|
editForm.realName = u.realName || "";
|
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 : "加载详情失败");
|
}
|
}
|
|
async function submitEdit() {
|
if (editingId.value == null) return;
|
if (!editForm.roleIds.length) {
|
ElMessage.warning("请至少选择一个角色");
|
return;
|
}
|
const nextStatus = isAdminUsername(editForm.username) ? 1 : (editForm.statusEnabled ? 1 : 0);
|
saving.value = true;
|
try {
|
await http.put(`/sys/users/${editingId.value}`, {
|
uuid: editForm.uuid,
|
realName: editForm.realName || undefined,
|
mobile: editForm.mobile || undefined,
|
avatarUrl: editForm.avatarUrl || undefined,
|
roleIds: editForm.roleIds,
|
status: nextStatus,
|
});
|
ElMessage.success("已保存");
|
dlgEdit.value = false;
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "保存失败");
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
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;
|
}
|
|
function resetPwdForm() {
|
pwdForm.newPassword = "";
|
pwdForm.newPasswordConfirm = "";
|
pwdUserId.value = null;
|
pwdUserUuid.value = "";
|
pwdUserName.value = "";
|
}
|
|
async function submitPwd() {
|
if (pwdUserId.value == null) return;
|
if (!isStrongPassword(pwdForm.newPassword)) {
|
ElMessage.warning("请输入至少12位含大小写字母、特殊字符的密码");
|
return;
|
}
|
if (pwdForm.newPassword !== pwdForm.newPasswordConfirm) {
|
ElMessage.warning("两次输入的新密码不一致");
|
return;
|
}
|
saving.value = true;
|
try {
|
await http.post(`/sys/users/${pwdUserId.value}/password`, {
|
uuid: pwdUserUuid.value,
|
newPassword: pwdForm.newPassword,
|
newPasswordConfirm: pwdForm.newPasswordConfirm,
|
});
|
ElMessage.success("密码已重置");
|
dlgPwd.value = false;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "重置失败");
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
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: "取消",
|
type: "warning",
|
})
|
.then(async () => {
|
try {
|
await http.patch(`/sys/users/${row.id}/status`, { uuid: row.uuid, status: 0 });
|
ElMessage.success("已禁用");
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "操作失败");
|
}
|
})
|
.catch(() => {});
|
}
|
|
async function enableUser(row: UserRow) {
|
try {
|
await http.patch(`/sys/users/${row.id}/status`, { uuid: row.uuid, status: 1 });
|
ElMessage.success("已启用");
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "操作失败");
|
}
|
}
|
|
function confirmDelete(row: UserRow) {
|
if (isSuperAdminRow(row)) {
|
ElMessage.warning("超级管理员账号不可删除");
|
return;
|
}
|
ElMessageBox.confirm("删除为逻辑删除,且将解除该用户角色关联,是否继续?", "确认删除", {
|
confirmButtonText: "删除",
|
cancelButtonText: "取消",
|
type: "warning",
|
})
|
.then(async () => {
|
try {
|
await http.delete(`/sys/users/${row.id}`, { params: { uuid: row.uuid } });
|
ElMessage.success("已删除");
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "操作失败");
|
}
|
})
|
.catch(() => {});
|
}
|
|
onMounted(async () => {
|
try {
|
await loadMeta();
|
} catch {
|
ElMessage.error("加载角色失败");
|
}
|
load();
|
});
|
</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;
|
}
|
.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>
|