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

372 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 客户管理页面重新设计实施计划
## 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 以下窗口宽度检查卡片和弹窗是否正常显示