MpUser.vue 10.6 KB
<template>
  <el-card shadow="never" class="box">
    <div class="toolbar">
      <el-input
        v-model="keyword"
        clearable
        placeholder="昵称 / 手机号 / OpenID"
        class="search"
        @keyup.enter="reload"
      />
      <el-button type="primary" @click="reload">搜索</el-button>
    </div>
    <el-table v-loading="loading" :data="list" stripe>
      <el-table-column prop="id" label="ID" width="70" />
      <el-table-column label="头像" width="72" align="center">
        <template #default="{ row }">
          <el-image
            v-if="resolveAdminMediaUrl(row.avatar)"
            :src="resolveAdminMediaUrl(row.avatar)"
            fit="cover"
            class="table-avatar"
            preview-teleported
            :preview-src-list="[resolveAdminMediaUrl(row.avatar)]"
          >
            <template #error>
              <div class="table-avatar-fail" title="图片加载失败">—</div>
            </template>
          </el-image>
          <span v-else class="table-avatar-ph">—</span>
        </template>
      </el-table-column>
      <el-table-column label="手机号" min-width="130" show-overflow-tooltip>
        <template #default="{ row }">
          {{ row.phone || "—" }}
        </template>
      </el-table-column>
      <el-table-column prop="openid" label="OpenID(脱敏)" width="140" />
      <el-table-column prop="nickname" label="昵称" width="110" />
      <el-table-column label="性别" width="72">
        <template #default="{ row }">
          {{ genderLabel(row.gender) }}
        </template>
      </el-table-column>
      <el-table-column prop="work_count" label="作品数" width="88" />
      <el-table-column label="状态" width="88">
        <template #default="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'danger'">
            {{ row.status === 1 ? "正常" : "禁用" }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="create_time" label="注册时间" min-width="160" />
      <el-table-column label="操作" width="200" fixed="right">
        <template #default="{ row }">
          <el-button type="primary" link @click="openEdit(row)">编辑</el-button>
          <el-button type="danger" link @click="onDeleteUser(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    <div class="pager">
      <el-pagination
        v-model:current-page="page"
        v-model:page-size="pageSize"
        :total="total"
        :page-sizes="[10, 20, 50]"
        layout="total, sizes, prev, pager, next"
        @current-change="load"
        @size-change="reload"
      />
    </div>

    <el-dialog
      v-model="editVisible"
      title="编辑用户"
      width="560px"
      align-center
      destroy-on-close
    >
      <template v-if="currentRow">
        <h4 class="section-title">账号信息</h4>
        <el-descriptions :column="1" border size="small" class="section-block">
          <el-descriptions-item label="用户 ID">{{ currentRow.id }}</el-descriptions-item>
          <el-descriptions-item label="手机号">{{ currentRow.phone || "—" }}</el-descriptions-item>
          <el-descriptions-item label="OpenID">{{ currentRow.openid }}</el-descriptions-item>
          <el-descriptions-item label="UnionID">{{ currentRow.unionid || "—" }}</el-descriptions-item>
        </el-descriptions>

        <el-form :model="form" label-width="88px" class="profile-form">
          <el-form-item label="头像">
            <div class="avatar-upload">
              <el-image
                v-if="avatarEditDisplaySrc"
                :src="avatarEditDisplaySrc"
                fit="cover"
                class="avatar-preview"
                preview-teleported
                :preview-src-list="[avatarEditDisplaySrc]"
              >
                <template #error>
                  <div class="avatar-preview-fail" title="图片加载失败">加载失败</div>
                </template>
              </el-image>
              <div
                v-else-if="form.avatar.trim()"
                class="avatar-preview avatar-preview--empty"
              >
                地址无效,请重新上传
              </div>
              <div v-else class="avatar-preview avatar-preview--empty">未设置</div>
              <el-upload
                :show-file-list="false"
                accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
                :http-request="handleAvatarUpload"
              >
                <el-button type="primary" :loading="uploading">上传图片</el-button>
              </el-upload>
            </div>
          </el-form-item>
          <el-form-item label="名字" required>
            <el-input
              v-model="form.nickname"
              maxlength="16"
              show-word-limit
              placeholder="必填,与小程序填写上限一致"
            />
          </el-form-item>
          <el-form-item label="简介">
            <el-input
              v-model="form.bio"
              type="textarea"
              :rows="3"
              maxlength="120"
              show-word-limit
              placeholder="一句话介绍自己"
            />
          </el-form-item>
          <el-form-item label="性别">
            <el-radio-group v-model="form.gender">
              <el-radio :value="0">保密</el-radio>
              <el-radio :value="1">男</el-radio>
              <el-radio :value="2">女</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="账号状态">
            <el-radio-group v-model="form.status">
              <el-radio :value="1">正常</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
        </el-form>
      </template>
      <template #footer>
        <el-button @click="editVisible = false">取消</el-button>
        <el-button type="primary" :loading="saving" @click="submitEdit">保存</el-button>
      </template>
    </el-dialog>
  </el-card>
</template>

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import type { UploadRequestOptions } from "element-plus";
import {
  deleteMpUser,
  fetchMpUsers,
  saveMpUser,
  uploadAdminImage,
  type MpUserRow,
} from "@/api/admin";
import { resolveAdminMediaUrl, sanitizeAvatarForSave } from "@/utils/mediaUrl";

const loading = ref(false);
const list = ref<MpUserRow[]>([]);
const keyword = ref("");
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);

const editVisible = ref(false);
const saving = ref(false);
const uploading = ref(false);
const currentRow = ref<MpUserRow | null>(null);
const form = reactive({
  id: 0,
  nickname: "",
  avatar: "",
  bio: "",
  gender: 0,
  status: 1,
});

const avatarEditDisplaySrc = computed(() => resolveAdminMediaUrl(form.avatar));

/** 与小程序「我的资料」性别选项一致:0 保密 1 男 2 女 */
function genderLabel(g: number): string {
  if (g === 1) return "男";
  if (g === 2) return "女";
  return "保密";
}

async function handleAvatarUpload(options: UploadRequestOptions) {
  uploading.value = true;
  try {
    const res = await uploadAdminImage(options.file);
    form.avatar = res.data.url;
    options.onSuccess(res);
  } catch (e) {
    options.onError(e as Error);
  } finally {
    uploading.value = false;
  }
}

async function load() {
  loading.value = true;
  try {
    const res = await fetchMpUsers({
      keyword: keyword.value.trim() || undefined,
      page: page.value,
      page_size: pageSize.value,
    });
    list.value = res.data.list;
    total.value = res.data.total;
  } finally {
    loading.value = false;
  }
}

function reload() {
  page.value = 1;
  void load();
}

function openEdit(row: MpUserRow) {
  currentRow.value = row;
  form.id = row.id;
  form.nickname = row.nickname ?? "";
  form.avatar = sanitizeAvatarForSave(row.avatar ?? "");
  form.bio = row.bio ?? "";
  form.gender = row.gender;
  form.status = row.status;
  editVisible.value = true;
}

async function submitEdit() {
  const nick = form.nickname.trim();
  if (!nick) {
    ElMessage.warning("请填写名字");
    return;
  }
  saving.value = true;
  try {
    await saveMpUser({
      id: form.id,
      nickname: nick,
      avatar: sanitizeAvatarForSave(form.avatar),
      bio: form.bio.trim(),
      gender: form.gender,
      status: form.status,
    });
    ElMessage.success("已保存");
    editVisible.value = false;
    currentRow.value = null;
    await load();
  } finally {
    saving.value = false;
  }
}

async function onDeleteUser(row: MpUserRow) {
  const name = row.nickname?.trim() || row.phone || `ID ${row.id}`;
  try {
    await ElMessageBox.confirm(
      `确定删除用户「${name}」?将一并删除该用户的全部作品、点赞及相关数据,且不可恢复。`,
      "删除用户",
      { type: "warning", confirmButtonText: "删除", cancelButtonText: "取消" }
    );
  } catch {
    return;
  }
  loading.value = true;
  try {
    await deleteMpUser(row.id);
    ElMessage.success("已删除");
    if (currentRow.value?.id === row.id) {
      editVisible.value = false;
      currentRow.value = null;
    }
    await load();
  } finally {
    loading.value = false;
  }
}

onMounted(() => {
  void load();
});
</script>

<style scoped>
.box {
  border-radius: 12px;
}

.toolbar {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
}

.search {
  max-width: 280px;
}

.pager {
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
}

.section-title {
  margin: 0 0 12px;
  font-size: 15px;
  font-weight: 600;
  color: #0f172a;
}

.section-title:not(:first-child) {
  margin-top: 20px;
}

.section-block {
  margin-bottom: 0;
}

.profile-form {
  margin-top: 20px;
}

.avatar-upload {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.avatar-preview {
  width: 72px;
  height: 72px;
  border-radius: 8px;
}

.avatar-preview--empty {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  color: #9ca3af;
  background: #f3f4f6;
}

.table-avatar {
  width: 40px;
  height: 40px;
  border-radius: 8px;
}

.table-avatar-ph {
  font-size: 13px;
  color: #9ca3af;
}

.table-avatar-fail {
  display: flex;
  width: 40px;
  height: 40px;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  font-size: 14px;
  color: #9ca3af;
  background: #f3f4f6;
}

.avatar-preview-fail {
  display: flex;
  width: 72px;
  height: 72px;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  font-size: 12px;
  color: #9ca3af;
  background: #f3f4f6;
}
</style>