18 KiB
客户管理页面重新设计实施计划
Summary
承接授权系统重构批次,前 6 项变更(后端续期 bug 修复、AuthorizationService 复用记录逻辑、UserService 联表查询、types/index.ts 字段扩展、Authorizations.vue/css 重新设计)已全部完成并验证。本计划仅聚焦最后一项剩余工作:重新设计 Customers.vue + Customers.css,让客户卡片显示当前授权状态,并将"授权"按钮的文案与行为根据授权状态动态化,与 Authorizations.vue 的蓝紫渐变设计语言保持一致。
Current State Analysis
已就绪的基础设施(无需重复实现)
- 后端
UserDto已新增 3 个字段:AuthorizationId/AuthorizationStatus/AuthorizationEndTime(Dtos.cs) - 后端
UserService.GetUsersAsync已联表查询Authorizations表,按 UserId 分组取最新一条填充到 UserDto(UserService.cs) - 前端
User接口已扩展 3 个可选字段(types/index.ts) - 前端
Authorizations.vue已建立设计语言基准(蓝紫渐变 + 统计栏 + 状态胶囊 + 卡片渐变背景层),可作为本页面的视觉参考 - 前端
authorizationApi.renew(id, days)已发送{ days }body,与后端[FromBody] RenewAuthorizationRequest修复匹配 - 后端
CreateAuthorizationAsync已重构为"复用原记录"逻辑(存在任意状态授权时复用,不新建),因此客户卡片上的"为客户授权"和"重新授权"都可调用authorizationApi.create({ userId, days })
当前 Customers.vue 的不足
- 卡片不显示授权状态:仅显示用户状态徽章(启用/禁用),无授权信息
- 授权按钮文案固定:"授权"图标按钮,无状态感知
- 授权弹窗过于简单:仅一个天数输入框,无快捷按钮、无续期后到期时间预览
- 视觉设计缺乏设计感:与重新设计后的 Authorizations.vue 风格不一致
- 续期无法在此页面完成:必须跳转到授权管理页面
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> 内新增)
-
导入
AuthorizationStatus:import type { User, AuthorizationStatus } from '@/types' -
授权状态配置(中文 + 样式类):
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 } -
授权按钮文案 + 图标 + 样式类动态化:
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' } -
重写
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 } -
重写
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 } } -
新增
remainingDays工具函数(与 Authorizations.vue 一致):function remainingDays(endTime?: string | null) { if (!endTime) return 0 const diff = dayjs(endTime).diff(dayjs(), 'day') return diff > 0 ? diff : 0 } -
新增
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-lg(add-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 设计语言对齐)
新增以下样式区块:
- 卡片渐变背景层
.customer-card .card-gradient-bg(蓝紫渐变,hover 加深) - 卡片顶部头像区域
.customer-card .card-top+.card-avatar-wrap(56×56 圆角,蓝紫渐变背景,hover 缩放旋转) - 状态胶囊
.status-pill及变体.active/.expired/.cancelled/.none(与 Authorizations.css 一致的中文 + 颜色) - 卡片信息列表
.card-info-list+.card-info-row(图标 + label + value 横向布局) - info-value 变体
.highlight/.inactive/.near-expire - 到期剩余天数
.remaining-days(小字提示) - 卡片底部操作
.customer-card .card-footer+.card-action-btn+.primary/.danger变体 - 卡片底部创建人标识
.card-creator - 弹窗设计样式
.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 分区设计,视觉上符合预期,无需重写。仅授权弹窗需要重新设计。
决策 4:status 字段(用户启用/禁用)作为卡片信息列表的一行
不再作为头像旁边的徽章,改为信息列表中的"用户状态"行,让授权状态胶囊独占顶部右侧位置,视觉层次更清晰。
假设
- 后端
UserService.GetUsersAsync已正确返回authorizationId/authorizationStatus/authorizationEndTime(已在 Phase 1 验证) - 后端运行在
http://localhost:5041(background jobjob-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
实施完成后:
- 静态检查:确认
Customers.vue无 TypeScript 错误(Vite 热更新会自动报错) - 视觉一致性:打开客户管理页面,确认卡片视觉与授权管理页面一致(蓝紫渐变背景层、头像渐变背景、状态胶囊中文 + 颜色)
- 授权状态展示:分别查看有授权(Active/Expired/Cancelled)和无授权的客户卡片,确认状态胶囊正确显示
- 授权按钮文案:
- 无授权客户 → "为客户授权" + primary 样式
- Active 客户 → "续期授权" + 默认样式
- Cancelled/Expired 客户 → "重新授权" + primary 样式
- 授权弹窗:
- 点击"为客户授权" → 弹窗标题"为客户授权",按钮"确认授权",调用
create - 点击"续期授权" → 弹窗标题"授权续期",显示当前到期 + 续期后到期预览,按钮"确认续期",调用
renew - 点击"重新授权" → 弹窗标题"重新授权",显示原状态,按钮"确认授权",调用
create
- 点击"为客户授权" → 弹窗标题"为客户授权",按钮"确认授权",调用
- 续期 bug 回归:点击"续期授权",输入天数,确认后端返回成功(不再提示"续期天数必须大于 0")
- 客户卡片状态刷新:授权操作成功后,
fetchData重新拉取列表,卡片状态胶囊自动更新 - 响应式:在 767px 以下窗口宽度检查卡片和弹窗是否正常显示