MilliSim/.trae/documents/customers-page-redesign.md

18 KiB
Raw Blame History

客户管理页面重新设计实施计划

Summary

承接授权系统重构批次,前 6 项变更(后端续期 bug 修复、AuthorizationService 复用记录逻辑、UserService 联表查询、types/index.ts 字段扩展、Authorizations.vue/css 重新设计)已全部完成并验证。本计划仅聚焦最后一项剩余工作:重新设计 Customers.vue + Customers.css,让客户卡片显示当前授权状态,并将"授权"按钮的文案与行为根据授权状态动态化,与 Authorizations.vue 的蓝紫渐变设计语言保持一致。

Current State Analysis

已就绪的基础设施(无需重复实现)

  • 后端 UserDto 已新增 3 个字段:AuthorizationId / AuthorizationStatus / AuthorizationEndTimeDtos.cs
  • 后端 UserService.GetUsersAsync 已联表查询 Authorizations 表,按 UserId 分组取最新一条填充到 UserDtoUserService.cs
  • 前端 User 接口已扩展 3 个可选字段(types/index.ts
  • 前端 Authorizations.vue 已建立设计语言基准(蓝紫渐变 + 统计栏 + 状态胶囊 + 卡片渐变背景层),可作为本页面的视觉参考
  • 前端 authorizationApi.renew(id, days) 已发送 { days } body与后端 [FromBody] RenewAuthorizationRequest 修复匹配
  • 后端 CreateAuthorizationAsync 已重构为"复用原记录"逻辑(存在任意状态授权时复用,不新建),因此客户卡片上的"为客户授权"和"重新授权"都可调用 authorizationApi.create({ userId, days })

当前 Customers.vue 的不足

  1. 卡片不显示授权状态:仅显示用户状态徽章(启用/禁用),无授权信息
  2. 授权按钮文案固定"授权"图标按钮,无状态感知
  3. 授权弹窗过于简单:仅一个天数输入框,无快捷按钮、无续期后到期时间预览
  4. 视觉设计缺乏设计感:与重新设计后的 Authorizations.vue 风格不一致
  5. 续期无法在此页面完成:必须跳转到授权管理页面

Proposed Changes

文件 1: d:\TraeProject\MilliSim4\frontend\src\views\admin\Customers.vue

保留:页头卡片、筛选区(关键词 + 用户状态 TabFilter、新增/编辑客户弹窗(含头像上传、账号信息、联系方式、状态设置三个分区)、重置密码、删除客户逻辑。

变更 1卡片结构重新设计(替换原 L59-L112 的 data-grid 内容)

新卡片结构:

<div v-for="row in tableData" :key="row.id" class="data-item-card customer-card">
  <div class="card-gradient-bg"></div>
  <div class="card-content">
    <div class="card-top">
      <div class="card-avatar-wrap">
        <el-avatar :size="56" :src="row.avatar">
          <SvgIcon name="user" :size="28" />
        </el-avatar>
      </div>
      <!-- 授权状态胶囊中文 -->
      <span class="status-pill" :class="getAuthStatusClass(row.authorizationStatus)">
        <span class="status-dot"></span>
        {{ getAuthStatusLabel(row.authorizationStatus) }}
      </span>
    </div>
    <div class="card-name">{{ row.nickname || row.username }}</div>
    <div class="card-sub">@{{ row.username }}</div>

    <!-- 用户状态徽章 + 联系信息 -->
    <div class="card-info-list">
      <div class="card-info-row">
        <SvgIcon name="user" :size="13" color="var(--color-text-placeholder)" />
        <span class="info-label">用户状态</span>
        <span class="info-value" :class="row.status === 1 ? 'highlight' : 'inactive'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </span>
      </div>
      <div v-if="row.phone" class="card-info-row">
        <SvgIcon name="phone" :size="13" color="var(--color-text-placeholder)" />
        <span class="info-label">手机</span>
        <span class="info-value">{{ row.phone }}</span>
      </div>
      <div v-if="row.email" class="card-info-row">
        <SvgIcon name="mail" :size="13" color="var(--color-text-placeholder)" />
        <span class="info-label">邮箱</span>
        <span class="info-value text-ellipsis">{{ row.email }}</span>
      </div>
      <!-- 授权到期信息仅有授权时显示 -->
      <div v-if="row.authorizationStatus === 1" class="card-info-row">
        <SvgIcon name="calendar" :size="13" color="var(--color-text-placeholder)" />
        <span class="info-label">到期</span>
        <span class="info-value" :class="{ 'near-expire': remainingDays(row.authorizationEndTime) <= 7 }">
          {{ formatTime(row.authorizationEndTime) }}
          <span class="remaining-days"> {{ remainingDays(row.authorizationEndTime) }} </span>
        </span>
      </div>
      <div class="card-info-row">
        <SvgIcon name="clock" :size="13" color="var(--color-text-placeholder)" />
        <span class="info-label">注册</span>
        <span class="info-value">{{ formatTime(row.createdAt) }}</span>
      </div>
    </div>
  </div>

  <div class="card-footer">
    <span class="card-creator">{{ row.createdByName || '系统' }}</span>
    <div class="card-actions">
      <button class="card-action-btn" title="编辑" @click="openEdit(row)">
        <SvgIcon name="edit" :size="15" />
      </button>
      <!-- 动态授权按钮文案 + 行为按状态变化 -->
      <button
        class="card-action-btn"
        :class="getAuthButtonClass(row.authorizationStatus)"
        :title="getAuthButtonConfig(row.authorizationStatus).label"
        @click="openAuth(row)"
      >
        <SvgIcon :name="getAuthButtonConfig(row.authorizationStatus).icon" :size="15" />
        <span>{{ getAuthButtonConfig(row.authorizationStatus).label }}</span>
      </button>
      <button class="card-action-btn" title="重置密码" @click="handleResetPwd(row)">
        <SvgIcon name="lock" :size="15" />
      </button>
      <button class="card-action-btn danger" title="删除" @click="handleDelete(row)">
        <SvgIcon name="trash" :size="15" />
      </button>
    </div>
  </div>
</div>

变更 2脚本逻辑扩展(在 <script setup> 内新增)

  1. 导入 AuthorizationStatus

    import type { User, AuthorizationStatus } from '@/types'
    
  2. 授权状态配置(中文 + 样式类):

    const authStatusConfig: Record<number, { label: string; class: string }> = {
      0: { label: '已取消', class: 'cancelled' },
      1: { label: '授权有效', class: 'active' },
      2: { label: '已过期', class: 'expired' },
    }
    const noAuthConfig = { label: '未授权', class: 'none' }
    
    function getAuthStatusLabel(status?: AuthorizationStatus | null) {
      if (status === null || status === undefined) return noAuthConfig.label
      return authStatusConfig[status]?.label ?? noAuthConfig.label
    }
    function getAuthStatusClass(status?: AuthorizationStatus | null) {
      if (status === null || status === undefined) return noAuthConfig.class
      return authStatusConfig[status]?.class ?? noAuthConfig.class
    }
    
  3. 授权按钮文案 + 图标 + 样式类动态化:

    function getAuthButtonConfig(status?: AuthorizationStatus | null) {
      if (status === null || status === undefined)
        return { label: '为客户授权', icon: 'plus' }
      if (status === AuthorizationStatus.Active)
        return { label: '续期授权', icon: 'refresh' }
      return { label: '重新授权', icon: 'refresh' } // Cancelled / Expired
    }
    function getAuthButtonClass(status?: AuthorizationStatus | null) {
      // Active 不加 primary避免与"续期"操作冲突视觉),其余状态加 primary 强调引导
      if (status === null || status === undefined) return 'primary'
      if (status === AuthorizationStatus.Active) return ''
      return 'primary'
    }
    
  4. 重写 openAuth 根据状态填充表单:

    const authForm = reactive({
      userId: 0,
      userLabel: '',
      days: 30,
      // 新增:操作模式与原授权 ID用于续期调用 renew API
      mode: 'create' as 'create' | 'renew',
      authorizationId: 0,
      originalStatusLabel: '',
      endTime: '',
    })
    
    function openAuth(row: User) {
      authForm.userId = row.id
      authForm.userLabel = `${row.nickname || row.username}@${row.username}`
      authForm.endTime = row.authorizationEndTime ?? ''
      authForm.originalStatusLabel = getAuthStatusLabel(row.authorizationStatus)
      if (row.authorizationStatus === AuthorizationStatus.Active && row.authorizationId) {
        authForm.mode = 'renew'
        authForm.authorizationId = row.authorizationId
        authForm.days = 30
      } else {
        // 无授权 / 已取消 / 已过期 → 调用 create后端自动复用原记录或新建
        authForm.mode = 'create'
        authForm.authorizationId = 0
        authForm.days = 365
      }
      authVisible.value = true
    }
    
  5. 重写 saveAuth 根据 mode 调用不同 API

    async function saveAuth() {
      authSaving.value = true
      try {
        if (authForm.mode === 'renew') {
          await authorizationApi.renew(authForm.authorizationId, authForm.days)
          ElMessage.success('续期成功')
        } else {
          await authorizationApi.create({
            userId: authForm.userId,
            days: authForm.days,
          })
          ElMessage.success(authForm.originalStatusLabel === '未授权' ? '授权成功' : '重新授权成功')
        }
        authVisible.value = false
        fetchData()
      } catch {
        // 已处理
      } finally {
        authSaving.value = false
      }
    }
    
  6. 新增 remainingDays 工具函数(与 Authorizations.vue 一致):

    function remainingDays(endTime?: string | null) {
      if (!endTime) return 0
      const diff = dayjs(endTime).diff(dayjs(), 'day')
      return diff > 0 ? diff : 0
    }
    
  7. 新增 renewAfterDate 计算属性(用于弹窗内显示续期后到期时间):

    const renewAfterDate = computed(() => {
      if (!authForm.endTime) return '-'
      const base = dayjs(authForm.endTime).isAfter(dayjs()) ? dayjs(authForm.endTime) : dayjs()
      return base.add(authForm.days, 'day').format('YYYY-MM-DD HH:mm')
    })
    

变更 3授权弹窗重新设计(替换原 L258-L293 的 auth-dialog

新弹窗结构:

  • 头部使用 dialog-header-design + dialog-header-icon-lgadd-mode 或 edit-mode
  • 显示原授权状态(如有)
  • 授权范围(只读:全部项目)
  • 当前到期时间(仅 renew 模式显示)
  • 授权天数 + 快捷按钮 30/90/180/365 天
  • 续期模式显示"续期后新到期时间"
  • 底部按钮文案动态化:续期模式为"确认续期",新建/重新授权模式为"确认授权"
<el-dialog v-model="authVisible" width="480px" custom-class="dialog-card" destroy-on-close>
  <template #header>
    <div class="dialog-header-design">
      <div class="dialog-header-icon-lg" :class="authForm.mode === 'renew' ? 'edit-mode' : 'add-mode'">
        <SvgIcon :name="authForm.mode === 'renew' ? 'refresh' : 'key'" :size="24" color="#fff" />
      </div>
      <div class="dialog-header-text">
        <h3>{{ authForm.mode === 'renew' ? '授权续期' : (authForm.originalStatusLabel === '未授权' ? '为客户授权' : '重新授权') }}</h3>
        <p>{{ authForm.mode === 'renew' ? '延长客户的项目授权有效期' : '为客户开通或重新开通访问权限' }}</p>
      </div>
    </div>
  </template>
  <el-form :model="authForm" label-width="90px" class="designed-form">
    <el-form-item label="客户">
      <el-input :model-value="authForm.userLabel" disabled />
    </el-form-item>
    <el-form-item v-if="authForm.originalStatusLabel !== '未授权'" label="原状态">
      <el-input :model-value="authForm.originalStatusLabel" disabled />
    </el-form-item>
    <el-form-item label="授权范围">
      <el-input :model-value="'全部项目'" disabled />
    </el-form-item>
    <el-form-item v-if="authForm.mode === 'renew'" label="当前到期">
      <el-input :model-value="formatTime(authForm.endTime)" disabled />
    </el-form-item>
    <el-form-item label="授权天数" required>
      <el-input-number v-model="authForm.days" :min="1" :max="3650" style="width: 100%" />
      <div class="form-hint" v-if="authForm.mode === 'renew'">续期后新到期时间{{ renewAfterDate }}</div>
      <div class="form-hint" v-else>{{ authForm.originalStatusLabel === '未授权' ? '授权后客户可访问全部项目' : '重新授权后将重置起止时间复用原记录' }}</div>
    </el-form-item>
    <div class="quick-days">
      <el-button size="small" @click="authForm.days = 30">30 </el-button>
      <el-button size="small" @click="authForm.days = 90">90 </el-button>
      <el-button size="small" @click="authForm.days = 180">半年</el-button>
      <el-button size="small" @click="authForm.days = 365">1 </el-button>
    </div>
  </el-form>
  <template #footer>
    <div class="dialog-footer-custom">
      <el-button @click="authVisible = false">
        <SvgIcon name="close" :size="14" />
        <span>取消</span>
      </el-button>
      <el-button type="primary" class="primary-btn" :loading="authSaving" @click="saveAuth">
        <SvgIcon name="check" :size="14" color="#fff" />
        <span>{{ authForm.mode === 'renew' ? '确认续期' : '确认授权' }}</span>
      </el-button>
    </div>
  </template>
</el-dialog>

文件 2: d:\TraeProject\MilliSim4\frontend\src\views\admin\Customers.css

保留:现有的页面头部装饰(page-header-card::before/after)、筛选区样式、新增/编辑弹窗的表单分区设计、响应式断点、无障碍动画偏好。

变更 1新增客户卡片设计样式(与 Authorizations.css 设计语言对齐)

新增以下样式区块:

  1. 卡片渐变背景层 .customer-card .card-gradient-bg蓝紫渐变hover 加深)
  2. 卡片顶部头像区域 .customer-card .card-top + .card-avatar-wrap56×56 圆角蓝紫渐变背景hover 缩放旋转)
  3. 状态胶囊 .status-pill 及变体 .active / .expired / .cancelled / .none(与 Authorizations.css 一致的中文 + 颜色)
  4. 卡片信息列表 .card-info-list + .card-info-row(图标 + label + value 横向布局)
  5. info-value 变体 .highlight / .inactive / .near-expire
  6. 到期剩余天数 .remaining-days(小字提示)
  7. 卡片底部操作 .customer-card .card-footer + .card-action-btn + .primary / .danger 变体
  8. 卡片底部创建人标识 .card-creator
  9. 弹窗设计样式 .dialog-header-design + .dialog-header-icon-lg.add-mode / .edit-mode + .dialog-header-text + .designed-form + .form-hint + .quick-days(与 Authorizations.css 一致)

变更 2移除旧的 customer-head / status-badge / card-info 等过时样式

由于卡片结构变化,移除以下样式(避免冗余):

  • .customers-page .customer-head
  • .customers-page .status-badge 相关样式(如存在)
  • .customers-page .customer-card-content 中的简单 padding改为新的 .card-content

变更 3响应式与无障碍

保留现有的 767px 响应式断点逻辑,仅需在样式表底部补一条规则:在 767px 以下让 .card-info-list 仍然垂直堆叠(默认就是垂直,无需额外修改)。

Assumptions & Decisions

决策 1客户卡片上的"授权"按钮统一调用 openAuth

不再在卡片上直接放"续期/重新授权/取消授权"等多个按钮(那是授权管理页面的职责)。客户卡片只保留一个动态授权按钮 + 编辑 + 重置密码 + 删除四个按钮,避免视觉过载。

决策 2续期不在此页面调用取消授权

取消授权操作放在授权管理页面,客户管理页面只负责"为客户授权/续期/重新授权"的入口,避免职责重叠。

决策 3保留原有的"新增/编辑客户"弹窗设计

该弹窗已经使用 form-avatar-section + form-section + form-section-title 分区设计,视觉上符合预期,无需重写。仅授权弹窗需要重新设计。

决策 4status 字段(用户启用/禁用)作为卡片信息列表的一行

不再作为头像旁边的徽章,改为信息列表中的"用户状态"行,让授权状态胶囊独占顶部右侧位置,视觉层次更清晰。

假设

  • 后端 UserService.GetUsersAsync 已正确返回 authorizationId / authorizationStatus / authorizationEndTime(已在 Phase 1 验证)
  • 后端运行在 http://localhost:5041background job job-66befe03b12347d2b2643513f94454a5 已启动)
  • 前端 Vite dev server 已在热更新模式运行
  • authorizationApi.create({ userId, days }) 在客户已有 Cancelled/Expired 授权时会自动复用原记录(已在 AuthorizationService 中实现)
  • authorizationApi.renew(authId, days) 已发送 { days } body与后端 [FromBody] RenewAuthorizationRequest 匹配(已在 Phase 1 验证 api/index.ts

Verification Steps

实施完成后:

  1. 静态检查:确认 Customers.vue 无 TypeScript 错误Vite 热更新会自动报错)
  2. 视觉一致性:打开客户管理页面,确认卡片视觉与授权管理页面一致(蓝紫渐变背景层、头像渐变背景、状态胶囊中文 + 颜色)
  3. 授权状态展示分别查看有授权Active/Expired/Cancelled和无授权的客户卡片确认状态胶囊正确显示
  4. 授权按钮文案
    • 无授权客户 → "为客户授权" + primary 样式
    • Active 客户 → "续期授权" + 默认样式
    • Cancelled/Expired 客户 → "重新授权" + primary 样式
  5. 授权弹窗
    • 点击"为客户授权" → 弹窗标题"为客户授权",按钮"确认授权",调用 create
    • 点击"续期授权" → 弹窗标题"授权续期",显示当前到期 + 续期后到期预览,按钮"确认续期",调用 renew
    • 点击"重新授权" → 弹窗标题"重新授权",显示原状态,按钮"确认授权",调用 create
  6. 续期 bug 回归:点击"续期授权",输入天数,确认后端返回成功(不再提示"续期天数必须大于 0"
  7. 客户卡片状态刷新:授权操作成功后,fetchData 重新拉取列表,卡片状态胶囊自动更新
  8. 响应式:在 767px 以下窗口宽度检查卡片和弹窗是否正常显示