重命名CubeListPager和CubeListToolbarSearch
Yann authored at 2025-07-28 22:53:20 Yann committed at 2025-07-28 23:04:14
34.19 KiB
cube-front
<template>
  <div class="user-page">
    <!-- ─── 页头 ─── -->
    <div class="page-header">
      <div class="header-brand">
        <div class="brand-icon">
          <svg
            width="20"
            height="20"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
          >
            <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
            <circle cx="9" cy="7" r="4" />
            <path d="M23 21v-2a4 4 0 00-3-3.87" />
            <path d="M16 3.13a4 4 0 010 7.75" />
          </svg>
        </div>
        <div>
          <p class="brand-sup">系统管理</p>
          <h1 class="brand-title">用户管理</h1>
        </div>
      </div>
      <div class="header-right">
        <div class="count-stat">
          <span class="count-num">{{ queryParams.total || 0 }}</span>
          <span class="count-label">用户总数</span>
        </div>
        <button class="hdr-btn" @click="queryUser">
          <svg
            width="13"
            height="13"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
          >
            <polyline points="23 4 23 10 17 10" />
            <polyline points="1 20 1 14 7 14" />
            <path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" />
          </svg>
          刷新
        </button>
        <button class="hdr-btn prime" @click="handleAdd">
          <svg
            width="13"
            height="13"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2.5"
          >
            <line x1="12" y1="5" x2="12" y2="19" />
            <line x1="5" y1="12" x2="19" y2="12" />
          </svg>
          新增用户
        </button>
      </div>
    </div>

    <!-- ─── 搜索栏 ─── -->
    <div class="search-bar">
      <div class="search-input-wrap">
        <svg
          class="search-icon"
          width="14"
          height="14"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
        >
          <circle cx="11" cy="11" r="8" />
          <line x1="21" y1="21" x2="16.65" y2="16.65" />
        </svg>
        <input
          class="search-input"
          v-model="searchQ"
          placeholder="搜索用户名、显示名称、邮箱..."
          @keyup.enter="doSearch"
        />
        <button
          v-if="searchQ"
          class="search-clear"
          @click="
            searchQ = '';
            doSearch();
          "
        >
          ✕
        </button>
      </div>
      <button class="search-btn primary" @click="doSearch">搜索</button>
      <button class="search-btn ghost" @click="doReset">重置</button>
    </div>

    <!-- ─── 数据表格 ─── -->
    <div class="data-panel" v-loading="loading">
      <!-- 表头 -->
      <div class="row-head">
        <div class="col col-user">用户信息</div>
        <div class="col col-role">角色</div>
        <div class="col col-dept">部门</div>
        <div class="col col-contact">联系方式</div>
        <div class="col col-status">状态</div>
        <div class="col col-time">更新时间</div>
        <div class="col col-ops">操作</div>
      </div>

      <!-- 数据行 -->
      <TransitionGroup name="rows" tag="div" class="rows-body">
        <div
          v-for="user in tableData"
          :key="user.id"
          class="data-row"
          :class="{ 'is-off': !user.enable }"
        >
          <div class="col col-user">
            <div class="avatar" :class="user.sex === 1 ? 'avatar--male' : 'avatar--female'">
              <img
                v-if="user.avatar && !avatarError[user.id]"
                :src="getAvatarUrl(user.avatar)"
                @error="avatarError[user.id] = true"
              />
              <span v-else class="avatar-init">{{
                (user.displayName || user.name || '?').charAt(0)
              }}</span>
            </div>
            <div class="user-info">
              <span class="user-dname">{{ user.displayName || user.name }}</span>
              <span class="user-uname">@{{ user.name }}</span>
            </div>
          </div>
          <div class="col col-role">
            <span v-if="user.roleName" class="chip chip--role">{{ user.roleName }}</span>
            <span v-else class="nil">—</span>
          </div>
          <div class="col col-dept">
            <span v-if="user.departmentName" class="dept-tag">{{ user.departmentName }}</span>
            <span v-else class="nil">—</span>
          </div>
          <div class="col col-contact">
            <div v-if="user.mail" class="contact-line">
              <svg
                width="11"
                height="11"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
              >
                <path
                  d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
                />
                <polyline points="22,6 12,13 2,6" />
              </svg>
              {{ user.mail }}
            </div>
            <div v-if="user.mobile" class="contact-line">
              <svg
                width="11"
                height="11"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
              >
                <rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
                <line x1="12" y1="18" x2="12.01" y2="18" />
              </svg>
              {{ user.mobile }}
            </div>
            <span v-if="!user.mail && !user.mobile" class="nil">—</span>
          </div>
          <div class="col col-status">
            <div class="status-pill" :class="user.enable ? 'status-ok' : 'status-off'">
              <i class="status-dot"></i>
              {{ user.enable ? '正常' : '禁用' }}
            </div>
          </div>
          <div class="col col-time">
            <span class="time-val" :title="user.updateTime">{{ formatTime(user.updateTime) }}</span>
          </div>
          <div class="col col-ops">
            <button class="op-btn edit-btn" @click="handleEdit(user)">
              <svg
                width="11"
                height="11"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
              >
                <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
                <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
              </svg>
              编辑
            </button>
            <button class="op-btn del-btn" @click="handleDelete(user)">
              <svg
                width="11"
                height="11"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
              >
                <polyline points="3 6 5 6 21 6" />
                <path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
                <path d="M10 11v6M14 11v6" />
              </svg>
              删除
            </button>
          </div>
        </div>
      </TransitionGroup>

      <!-- 空状态 -->
      <div v-if="!loading && tableData.length === 0" class="empty-state">
        <svg
          width="52"
          height="52"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="1"
        >
          <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
          <circle cx="9" cy="7" r="4" />
          <path d="M23 21v-2a4 4 0 00-3-3.87" />
          <path d="M16 3.13a4 4 0 010 7.75" />
        </svg>
        <p>暂无用户数据</p>
      </div>
    </div>

    <!-- ─── 分页 ─── -->
    <div class="pager-bar">
      <span class="pager-info"
        >共 <b>{{ queryParams.total || 0 }}</b> 条记录</span
      >
      <CubeListPager
        :total="queryParams.total"
        :current-page="queryParams.pageIndex"
        :page-size="queryParams.pageSize"
        :on-current-change="CurrentPageChange"
        :on-size-change="PageSizeChange"
        :on-callback="callback"
      />
    </div>

    <!-- ─── 用户表单弹窗 ─── -->
    <el-dialog
      v-model="dialogVisible"
      :title="formType === 'add' ? '新增用户' : '编辑用户'"
      width="520px"
    >
      <el-form ref="userFormRef" :model="userForm" :rules="userFormRules" label-width="90px">
        <el-form-item label="用户名" prop="name">
          <el-input v-model="userForm.name" placeholder="请输入用户名" />
        </el-form-item>
        <el-form-item label="显示名称" prop="displayName">
          <el-input v-model="userForm.displayName" placeholder="请输入显示名称" />
        </el-form-item>
        <el-form-item label="性别" prop="sex">
          <el-radio-group v-model="userForm.sex">
            <el-radio :label="1">男</el-radio>
            <el-radio :label="0">女</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="邮箱" prop="mail">
          <el-input v-model="userForm.mail" placeholder="请输入邮箱" />
        </el-form-item>
        <el-form-item label="手机号" prop="mobile">
          <el-input v-model="userForm.mobile" placeholder="请输入手机号" />
        </el-form-item>
        <el-form-item label="角色" prop="roleID">
          <el-select v-model="userForm.roleID" placeholder="请选择角色" style="width: 100%">
            <el-option
              v-for="role in roleOptions"
              :key="role.value"
              :label="role.label"
              :value="role.value"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="密码" prop="password" v-if="formType === 'add'">
          <el-input
            v-model="userForm.password"
            type="password"
            placeholder="请输入密码"
            show-password
          />
        </el-form-item>
        <el-form-item label="状态" prop="enable">
          <el-switch v-model="userForm.enable" :active-value="true" :inactive-value="false" />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="userForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <div>
            <el-button
              v-if="formType === 'edit1111'"
              type="warning"
              size="small"
              @click="handleChangePasswordInEdit"
              >修改密码</el-button
            >
          </div>
          <div>
            <el-button @click="dialogVisible = false">取消</el-button>
            <el-button type="primary" @click="submitForm">确定</el-button>
          </div>
        </div>
      </template>
    </el-dialog>

    <!-- ─── 修改密码弹窗 ─── -->
    <el-dialog v-model="changePasswordDialogVisible" title="修改密码" width="400px">
      <el-form
        ref="changePasswordFormRef"
        :model="changePasswordForm"
        :rules="changePasswordFormRules"
        label-width="90px"
      >
        <el-form-item label="用户名">
          <el-input v-model="changePasswordForm.name" disabled />
        </el-form-item>
        <el-form-item label="旧密码" prop="oldPassword">
          <el-input
            v-model="changePasswordForm.oldPassword"
            type="password"
            placeholder="请输入旧密码"
            show-password
          />
        </el-form-item>
        <el-form-item label="新密码" prop="newPassword">
          <el-input
            v-model="changePasswordForm.newPassword"
            type="password"
            placeholder="请输入新密码"
            show-password
          />
        </el-form-item>
        <el-form-item label="确认密码" prop="newPassword2">
          <el-input
            v-model="changePasswordForm.newPassword2"
            type="password"
            placeholder="请再次输入新密码"
            show-password
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="changePasswordDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitChangePassword">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { request } from 'cube-front/core/utils/request';
import {
  apiDataToList,
  apiDataToSingle,
  handleDeleteOperation,
  handleFormSubmit,
} from 'cube-front/core/utils/api-helpers';
import CubeListPager from 'cube-front/core/components/CubeListPager.vue';
import type { SelectOption, BaseEntity, EnableStatus } from 'cube-front/core/types/common';
import { pageInfoDefault } from 'cube-front/core/types/common';

// 定义用户类型接口
interface User extends BaseEntity, EnableStatus {
  /** 用户名 */
  name: string;
  /** 显示名称 */
  displayName: string;
  /** 邮箱 */
  mail: string;
  /** 手机号 */
  mobile: string;
  /** 密码 */
  password?: string;
  /** 性别:1-男,0-女 */
  sex: number;
  /** 头像 */
  avatar?: string;
  /** 角色ID */
  roleID: number;
  /** 部门ID */
  departmentID: number;
  /** 注册时间 */
  registerTime: string;
  /** 最后登录时间 */
  lastLogin: string;
  /** 角色名称 */
  roleName?: string;
  /** 部门名称 */
  departmentName?: string;
}

// 定义修改密码表单接口
interface ChangePasswordForm {
  /** 用户名 */
  name: string;
  /** 旧密码 */
  oldPassword: string;
  /** 新密码 */
  newPassword: string;
  /** 确认密码 */
  newPassword2: string;
}

// 定义初始用户表单数据
const initialUserForm: User = {
  id: 0,
  name: '',
  displayName: '',
  mail: '',
  mobile: '',
  password: '',
  enable: true,
  sex: 1,
  avatar: '',
  roleID: 0,
  departmentID: 0,
  registerTime: '',
  lastLogin: '',
  updateTime: '',
  remark: '',
  roleName: '',
  departmentName: '',
};

// 表格数据
const tableData = ref<User[]>([]);
const loading = ref(false);

// 用户表单相关
const dialogVisible = ref(false);
const formType = ref<'add' | 'edit'>('add');
const userFormRef = ref<FormInstance | null>(null);
const userForm = reactive<User>({ ...initialUserForm });

// 修改密码相关
const changePasswordDialogVisible = ref(false);
const changePasswordFormRef = ref<FormInstance | null>(null);
const changePasswordForm = reactive<ChangePasswordForm>({
  name: '',
  oldPassword: '',
  newPassword: '',
  newPassword2: '',
});

// 角色选项数据
const roleOptions = ref<SelectOption[]>([]);
const roleOptionsLoaded = ref(false); // 标记角色数据是否已加载

// 页面请求参数
const queryParams = reactive({
  q: '',
  ...pageInfoDefault,
});

// 搜索关键字(绑定输入框)
const searchQ = ref('');
// 头像加载失败记录
const avatarError = ref<Record<number, boolean>>({});

// 时间友好格式化
const formatTime = (time: string): string => {
  if (!time) return '—';
  const d = new Date(time);
  if (isNaN(d.getTime())) return time;
  const diff = Date.now() - d.getTime();
  const mins = Math.floor(diff / 60000);
  const hours = Math.floor(diff / 3600000);
  const days = Math.floor(diff / 86400000);
  if (mins < 1) return '刚刚';
  if (hours < 1) return `${mins} 分钟前`;
  if (days < 1) return `${hours} 小时前`;
  if (days < 30) return `${days} 天前`;
  return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
};

// 搜索操作
const doSearch = () => {
  Object.assign(queryParams, { q: searchQ.value, pageIndex: 1 });
  queryUser();
};

const doReset = () => {
  searchQ.value = '';
  Object.assign(queryParams, { q: '', pageIndex: 1 });
  queryUser();
};

// 组件回调函数
const callback = (e?: Record<string, unknown>) => {
  console.log(e?.type, e?.params);
  const query = Object.assign(queryParams, e?.params || {});
  console.log('queryParams:', query);
  queryUser();
};
//查询请求 - 使用新的fetchPageData方法,更简洁
const queryUser = async () => {
  loading.value = true;
  try {
    const c = await request.get('/Admin/User/', { params: queryParams });
    const { list, page } = apiDataToList<User>(c);
    tableData.value = list;
    queryParams.total = page?.totalCount; // 更新总数
  } catch {
    tableData.value = [];
    queryParams.total = 0;
  } finally {
    loading.value = false;
  }
};

// 表单验证规则
const userFormRules = reactive<FormRules>({
  name: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 1, max: 50, message: '长度在 1 到 50 个字符', trigger: 'blur' },
  ],
  displayName: [{ min: 1, max: 50, message: '长度在 1 到 50 个字符', trigger: 'blur' }],
  password: [
    { required: formType.value === 'add', message: '请输入密码', trigger: 'blur' },
    { min: 8, max: 200, message: '长度在 8 到 200 个字符', trigger: 'blur' },
  ],
  sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
  mail: [
    { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
    { max: 50, message: '长度不能超过 50 个字符', trigger: 'blur' },
  ],
  mobile: [
    { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
    { max: 50, message: '长度不能超过 50 个字符', trigger: 'blur' },
  ],
  code: [{ max: 50, message: '长度不能超过 50 个字符', trigger: 'blur' }],
  avatar: [{ max: 200, message: '长度不能超过 200 个字符', trigger: 'blur' }],
  roleIds: [{ max: 200, message: '长度不能超过 200 个字符', trigger: 'blur' }],
  lastLoginIP: [{ max: 50, message: '长度不能超过 50 个字符', trigger: 'blur' }],
  registerIP: [{ max: 50, message: '长度不能超过 50 个字符', trigger: 'blur' }],
  ex4: [{ max: 50, message: '长度不能超过 50 个字符', trigger: 'blur' }],
  remark: [{ max: 500, message: '长度不能超过 500 个字符', trigger: 'blur' }],
});

// 修改密码表单验证规则
const changePasswordFormRules = reactive<FormRules>({
  oldPassword: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
  newPassword: [
    { required: true, message: '请输入新密码', trigger: 'blur' },
    { min: 8, max: 200, message: '长度在 8 到 200 个字符', trigger: 'blur' },
  ],
  newPassword2: [
    { required: true, message: '请再次输入新密码', trigger: 'blur' },
    {
      validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
        if (value !== changePasswordForm.newPassword) {
          callback(new Error('两次输入的密码不一致'));
        } else {
          callback();
        }
      },
      trigger: 'blur',
    },
  ],
});

// 获取头像完整URL
const getAvatarUrl = (avatar: string): string => {
  if (!avatar) return '';
  // 如果头像路径以"/"开头,拼接当前域名
  // if (avatar.startsWith('/')) { return `${window.location.origin}${avatar}`; }
  return avatar; // 如果是完整的URL(http或https开头),直接返回
};

// 加载角色数据
const loadRoleOptions = async (forceRefresh = false) => {
  // 如果不是强制刷新且已经加载过角色数据,直接返回
  if (!forceRefresh && roleOptionsLoaded.value) {
    return;
  }
  try {
    const data = await request.get('/Admin/Role');
    const { list } = apiDataToList<{ id: number; name: string }>(data);
    roleOptions.value = list.map((role: { id: number; name: string }) => ({
      value: role.id,
      label: role.name,
    }));
    roleOptionsLoaded.value = true; // 标记为已加载
  } catch (error) {
    console.error('加载角色数据失败:', error);
    roleOptions.value = [];
    roleOptionsLoaded.value = false;
  }
};

// 页码变更处理
const CurrentPageChange = (page: number) => {
  queryParams.pageIndex = page;
};

// 每页显示条数变更处理
const PageSizeChange = (size: number) => {
  queryParams.pageSize = size;
  queryParams.pageIndex = 1;
};

// 搜索按钮点击事件
const SearchData = (e?: Record<string, unknown>) => {
  Object.assign(queryParams, { pageIndex: 1 }, e || {});
  console.log('SearchData:', queryParams);
};

// 重置按钮点击事件
const ResetData = (e?: Record<string, unknown>) => {
  Object.assign(queryParams, { pageIndex: 1 }, e || {});
  console.log('ResetData:', queryParams);
};

// 新增用户
const handleAdd = () => {
  formType.value = 'add';
  Object.assign(userForm, { ...initialUserForm });
  dialogVisible.value = true;
};

// 编辑用户 - 演示如何使用fetchSingleData获取单个用户详情
const handleEdit = async (row: User) => {
  formType.value = 'edit';
  // // 方式1:直接使用传入的row数据
  // Object.assign(userForm, {
  //   id: row.id,
  //   name: row.name,
  //   displayName: row.displayName,
  //   mail: row.mail,
  //   mobile: row.mobile,
  //   enable: row.enable,
  //   sex: row.sex,
  //   avatar: row.avatar,
  //   roleID: row.roleID,
  //   departmentID: row.departmentID,
  //   remark: row.remark
  // });

  try {
    // 方式2:请求最新数据
    const data = await request.get(`/Admin/User/Detail?id=${row.id}`, { params: queryParams });
    const userDetail = apiDataToSingle<User>(data);
    if (userDetail) {
      Object.assign(userForm, userDetail);
    }
    dialogVisible.value = true;
  } catch (error) {
    console.error('获取用户详情失败:', error);
  }
};

// 修改密码 - 在编辑弹窗中使用
const handleChangePasswordInEdit = () => {
  changePasswordForm.name = userForm.name;
  changePasswordForm.oldPassword = '';
  changePasswordForm.newPassword = '';
  changePasswordForm.newPassword2 = '';
  changePasswordDialogVisible.value = true;
};

// 清空密码 - 在编辑弹窗中使用
const handleClearPasswordInEdit = () => {
  ElMessageBox.confirm(
    `确认清空用户 [${userForm.displayName || userForm.name}] 的密码吗?清空后该用户将无法使用密码登录。`,
    '确认清空密码',
    {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning',
    },
  )
    .then(async () => {
      try {
        await request.post('/Admin/User/ClearPassword', null, { params: { id: userForm.id } });
        ElMessage.success('密码清空成功');
        dialogVisible.value = false; // 关闭用户编辑对话框
      } catch (error) {
        console.error('清空密码失败:', error);
        ElMessage.error('清空密码失败');
      }
    })
    .catch(() => {
      // 用户取消操作
    });
};

// 删除用户
const handleDelete = (row: User) => {
  handleDeleteOperation(
    () => request.delete('/Admin/User', { params: { id: row.id } }),
    queryUser, //() => null,
    '确认删除[' + (row.displayName || row.name) + ']用户吗?',
  );
};

// 提交表单
const submitForm = async () => {
  const apiCall = async () => {
    if (formType.value === 'add') {
      // 创建用户,过滤掉不需要的字段
      const userData = {
        name: userForm.name,
        displayName: userForm.displayName,
        mail: userForm.mail,
        mobile: userForm.mobile,
        password: userForm.password,
        enable: userForm.enable,
        sex: userForm.sex,
        avatar: userForm.avatar,
        roleID: userForm.roleID,
        departmentID: userForm.departmentID,
        remark: userForm.remark,
      };
      await request.post('/Admin/User', userData);
    } else if (formType.value === 'edit') {
      await request.put('/Admin/User', userForm);
    }
  };

  const onSuccess = () => {
    dialogVisible.value = false;
    queryUser();
  };

  await handleFormSubmit(userFormRef.value, apiCall, onSuccess);
};

// 提交修改密码表单
const submitChangePassword = async () => {
  const apiCall = async () => {
    await request.post('/Admin/User/ChangePassword', {
      name: changePasswordForm.name,
      oldPassword: changePasswordForm.oldPassword,
      newPassword: changePasswordForm.newPassword,
      newPassword2: changePasswordForm.newPassword2,
    });
  };

  const onSuccess = () => {
    changePasswordDialogVisible.value = false; // 关闭修改密码对话框
    ElMessage.success('密码修改成功');
    // 重置表单
    changePasswordForm.oldPassword = '';
    changePasswordForm.newPassword = '';
    changePasswordForm.newPassword2 = '';
  };

  await handleFormSubmit(changePasswordFormRef.value, apiCall, onSuccess);
};

// 初始化加载数据
onMounted(() => {
  queryUser();
  loadRoleOptions(true); // 页面加载时强制刷新角色数据
});
</script>

<style lang="scss" scoped>
/* ─── 页面容器 ─── */
.user-page {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 20px 24px;
  min-height: 100%;
  background: var(--bg-primary);
  color: var(--text-primary);
}

/* ─── 页头 ─── */
.page-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 24px;
  background: var(--bg-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  position: relative;
  overflow: hidden;

  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 1px;
    background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);
    opacity: 0.5;
  }
}

.header-brand {
  display: flex;
  align-items: center;
  gap: 14px;
}

.brand-icon {
  width: 42px;
  height: 42px;
  border-radius: 10px;
  background: rgba(74, 222, 128, 0.08);
  border: 1px solid rgba(74, 222, 128, 0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent);
  flex-shrink: 0;
}

.brand-sup {
  font-size: 11px;
  color: var(--text-muted);
  letter-spacing: 0.5px;
  margin: 0 0 3px;
}

.brand-title {
  font-size: 18px;
  font-weight: 700;
  color: var(--text-primary);
  margin: 0;
  letter-spacing: -0.2px;
}

.header-right {
  display: flex;
  align-items: center;
  gap: 12px;
}

.count-stat {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  margin-right: 6px;
}

.count-num {
  font-size: 24px;
  font-weight: 800;
  color: var(--accent);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: -1px;
}

.count-label {
  font-size: 10px;
  color: var(--text-muted);
  margin-top: 1px;
}

.hdr-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  font-size: 13px;
  font-weight: 500;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: all 0.15s;
  border: 1px solid var(--border-subtle);
  background: transparent;
  color: var(--text-secondary);

  &:hover {
    background: rgba(255, 255, 255, 0.05);
    color: var(--text-primary);
    border-color: rgba(255, 255, 255, 0.12);
  }

  &.prime {
    background: var(--accent);
    border-color: var(--accent);
    color: #0a0e14;
    font-weight: 600;

    &:hover {
      filter: brightness(1.1);
      box-shadow: 0 0 18px rgba(74, 222, 128, 0.35);
    }
  }
}

/* ─── 搜索栏 ─── */
.search-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  background: var(--bg-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}

.search-input-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 12px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  transition:
    border-color 0.15s,
    background 0.15s;

  &:focus-within {
    border-color: rgba(74, 222, 128, 0.5);
    background: rgba(74, 222, 128, 0.04);
  }
}

.search-icon {
  color: var(--text-muted);
  flex-shrink: 0;
}

.search-input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  padding: 10px 0;
  font-size: 13px;
  color: var(--text-primary);

  &::placeholder {
    color: var(--text-muted);
  }
}

.search-clear {
  background: transparent;
  border: none;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 13px;
  line-height: 1;
  padding: 3px 5px;
  border-radius: 4px;

  &:hover {
    color: var(--text-primary);
  }
}

.search-btn {
  padding: 9px 18px;
  font-size: 13px;
  font-weight: 500;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: all 0.15s;
  white-space: nowrap;

  &.primary {
    background: var(--accent);
    border: 1px solid var(--accent);
    color: #0a0e14;

    &:hover {
      filter: brightness(1.1);
      box-shadow: 0 0 12px rgba(74, 222, 128, 0.3);
    }
  }

  &.ghost {
    background: transparent;
    border: 1px solid var(--border-subtle);
    color: var(--text-secondary);

    &:hover {
      background: rgba(255, 255, 255, 0.05);
      color: var(--text-primary);
    }
  }
}

/* ─── 数据面板 ─── */
.data-panel {
  background: var(--bg-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  overflow: hidden;
  overflow-x: auto;
  flex: 1;
}

.row-head {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1.6fr 80px 96px 118px;
  padding: 10px 20px;
  background: rgba(255, 255, 255, 0.02);
  border-bottom: 1px solid var(--border-subtle);

  .col {
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.3px;
    color: var(--text-muted);
    white-space: nowrap;
  }
}

.rows-body {
  display: contents;
}

.data-row {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1.6fr 80px 96px 118px;
  padding: 13px 20px;
  border-bottom: 1px solid var(--border-subtle);
  transition: background 0.12s;
  align-items: center;

  &:last-child {
    border-bottom: none;
  }

  &:hover {
    background: rgba(74, 222, 128, 0.03);

    .op-btn {
      opacity: 1;
    }
  }

  &.is-off {
    opacity: 0.5;
  }
}

.col {
  display: flex;
  align-items: center;
}
.col-user {
  gap: 12px;
}

/* ─── 头像 ─── */
.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;

  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }

  &.avatar--male {
    background: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%);
  }
  &.avatar--female {
    background: linear-gradient(135deg, #f43f5e 0%, #fb7185 100%);
  }
}

.avatar-init {
  font-size: 14px;
  font-weight: 700;
  color: #fff;
  line-height: 1;
  text-transform: uppercase;
}

.user-info {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.user-dname {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.user-uname {
  font-size: 11px;
  color: var(--text-muted);
  font-family: 'SFMono-Regular', Consolas, 'Cascadia Code', monospace;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ─── 角色 ─── */
.chip--role {
  display: inline-flex;
  padding: 3px 10px;
  border-radius: 20px;
  font-size: 11px;
  font-weight: 600;
  background: rgba(74, 222, 128, 0.1);
  color: var(--accent);
  border: 1px solid rgba(74, 222, 128, 0.2);
  white-space: nowrap;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}

.dept-tag {
  font-size: 12px;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

.nil {
  color: var(--text-muted);
  font-size: 14px;
}

/* ─── 联系方式 ─── */
.col-contact {
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}

.contact-line {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 12px;
  color: var(--text-secondary);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;

  svg {
    flex-shrink: 0;
    color: var(--text-muted);
  }
}

/* ─── 状态 ─── */
.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 500;

  &.status-ok {
    color: var(--accent);

    .status-dot {
      background: var(--accent);
      box-shadow: 0 0 6px rgba(74, 222, 128, 0.8);
      animation: dot-pulse 2.5s infinite;
    }
  }

  &.status-off {
    color: var(--text-muted);
    .status-dot {
      background: var(--text-muted);
    }
  }
}

.status-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  flex-shrink: 0;
  display: inline-block;
}

@keyframes dot-pulse {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.6;
    transform: scale(1.3);
  }
}

.time-val {
  font-size: 12px;
  color: var(--text-muted);
  white-space: nowrap;
}

/* ─── 操作按钮 ─── */
.col-ops {
  gap: 6px;
}

.op-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 5px 10px;
  font-size: 12px;
  font-weight: 500;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.12s;
  opacity: 0.8;
  white-space: nowrap;

  &.edit-btn {
    background: rgba(74, 222, 128, 0.07);
    border: 1px solid rgba(74, 222, 128, 0.18);
    color: var(--accent);

    &:hover {
      background: rgba(74, 222, 128, 0.14);
      border-color: var(--accent);
      opacity: 1;
    }
  }

  &.del-btn {
    background: rgba(251, 113, 133, 0.07);
    border: 1px solid rgba(251, 113, 133, 0.18);
    color: #fb7185;

    &:hover {
      background: rgba(251, 113, 133, 0.14);
      border-color: #fb7185;
      opacity: 1;
    }
  }
}

/* ─── 空状态 ─── */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 64px 20px;
  color: var(--text-muted);

  p {
    font-size: 14px;
    margin: 0;
  }
}

/* ─── 分页条 ─── */
.pager-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  background: var(--bg-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}

.pager-info {
  font-size: 13px;
  color: var(--text-muted);

  b {
    color: var(--text-primary);
    font-weight: 600;
  }
}

/* ─── 行过渡动画 ─── */
.rows-enter-active,
.rows-leave-active {
  transition:
    opacity 0.18s ease,
    transform 0.18s ease;
}
.rows-enter-from,
.rows-leave-to {
  opacity: 0;
  transform: translateY(-6px);
}

/* ─── 弹窗底部 ─── */
.dialog-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
</style>