longz
2026-06-29 9d4cbff7464ebfd7803c2ce53ded49d0f73fe011
frontend-admin/src/views/UsersView.vue
@@ -1,11 +1,12 @@
<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
@@ -17,7 +18,7 @@
          <el-option
            v-for="r in roles"
            :key="r.id"
            :label="`${r.roleName} (${r.roleCode})`"
            :label="r.roleName"
            :value="r.id"
          />
        </el-select>
@@ -33,11 +34,12 @@
        <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 />
@@ -58,61 +60,125 @@
      <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>
@@ -133,18 +199,41 @@
        <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>
@@ -154,13 +243,28 @@
    </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>
@@ -174,7 +278,9 @@
<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 };
@@ -191,6 +297,7 @@
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);
@@ -210,6 +317,7 @@
const editingId = ref<number | null>(null);
const pwdUserId = ref<number | null>(null);
const pwdUserUuid = ref("");
const pwdUserName = ref("");
const createForm = reactive({
  username: "",
@@ -218,6 +326,7 @@
  mobile: "",
  avatarUrl: "",
  roleIds: [] as number[],
  statusEnabled: true,
});
const editForm = reactive({
@@ -227,12 +336,14 @@
  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;
@@ -287,6 +398,12 @@
  load();
}
function onSizeChange(nextSize: number) {
  size.value = nextSize;
  page.value = 1;
  load();
}
function resetCreateForm() {
  createForm.username = "";
  createForm.password = "";
@@ -294,6 +411,7 @@
  createForm.mobile = "";
  createForm.avatarUrl = "";
  createForm.roleIds = [];
  createForm.statusEnabled = true;
}
function openCreate() {
@@ -302,28 +420,43 @@
}
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();
@@ -346,6 +479,7 @@
    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 : "加载详情失败");
@@ -358,15 +492,18 @@
    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();
@@ -377,9 +514,35 @@
  }
}
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;
@@ -390,12 +553,13 @@
  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) {
@@ -404,11 +568,12 @@
  }
  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) {
@@ -418,7 +583,29 @@
  }
}
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: "取消",
@@ -447,6 +634,10 @@
}
function confirmDelete(row: UserRow) {
  if (isSuperAdminRow(row)) {
    ElMessage.warning("超级管理员账号不可删除");
    return;
  }
  ElMessageBox.confirm("删除为逻辑删除,且将解除该用户角色关联,是否继续?", "确认删除", {
    confirmButtonText: "删除",
    cancelButtonText: "取消",
@@ -475,18 +666,57 @@
</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>