372 lines
18 KiB
Markdown
372 lines
18 KiB
Markdown
# 客户管理页面重新设计实施计划
|
||
|
||
## 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](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs#L49-L57))
|
||
- **后端** `UserService.GetUsersAsync` 已联表查询 `Authorizations` 表,按 UserId 分组取最新一条填充到 UserDto([UserService.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/UserService.cs#L82-L98))
|
||
- **前端** `User` 接口已扩展 3 个可选字段([types/index.ts](file:///d:/TraeProject/MilliSim4/frontend/src/types/index.ts#L73-L78))
|
||
- **前端** `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` 内容)
|
||
|
||
新卡片结构:
|
||
```vue
|
||
<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`:
|
||
```typescript
|
||
import type { User, AuthorizationStatus } from '@/types'
|
||
```
|
||
|
||
2. 授权状态配置(中文 + 样式类):
|
||
```typescript
|
||
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. 授权按钮文案 + 图标 + 样式类动态化:
|
||
```typescript
|
||
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` 根据状态填充表单:
|
||
```typescript
|
||
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:
|
||
```typescript
|
||
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 一致):
|
||
```typescript
|
||
function remainingDays(endTime?: string | null) {
|
||
if (!endTime) return 0
|
||
const diff = dayjs(endTime).diff(dayjs(), 'day')
|
||
return diff > 0 ? diff : 0
|
||
}
|
||
```
|
||
|
||
7. 新增 `renewAfterDate` 计算属性(用于弹窗内显示续期后到期时间):
|
||
```typescript
|
||
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 天
|
||
- 续期模式显示"续期后新到期时间"
|
||
- 底部按钮文案动态化:续期模式为"确认续期",新建/重新授权模式为"确认授权"
|
||
|
||
```vue
|
||
<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-wrap`(56×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` 分区设计,视觉上符合预期,无需重写。仅授权弹窗需要重新设计。
|
||
|
||
### 决策 4:`status` 字段(用户启用/禁用)作为卡片信息列表的一行
|
||
|
||
不再作为头像旁边的徽章,改为信息列表中的"用户状态"行,让授权状态胶囊独占顶部右侧位置,视觉层次更清晰。
|
||
|
||
### 假设
|
||
|
||
- 后端 `UserService.GetUsersAsync` 已正确返回 `authorizationId` / `authorizationStatus` / `authorizationEndTime`(已在 Phase 1 验证)
|
||
- 后端运行在 `http://localhost:5041`(background 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](file:///d:/TraeProject/MilliSim4/frontend/src/api/index.ts#L147-L148))
|
||
|
||
## 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 以下窗口宽度检查卡片和弹窗是否正常显示
|