超管端用户管理:「平台」更名为「运营团队」+ 子女信息适配独立账号模型
- 统计卡片和用户类型Tag从「平台」改为「运营团队」,避免命名歧义 - 公众用户详情从旧版Child模型(姓名/年级/学校)改为UserParentChild关系,展示子女独立账号信息 - 后端详情接口和列表_count同步从children切换到parentRelations - 更新统一用户管理设计文档,补充实施记录 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
418aa57ea8
commit
4466e28b3b
@ -125,7 +125,7 @@ export class UsersService {
|
||||
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
|
||||
};
|
||||
include._count = {
|
||||
select: { children: true, contestRegistrations: true },
|
||||
select: { parentRelations: true, contestRegistrations: true },
|
||||
};
|
||||
}
|
||||
|
||||
@ -194,8 +194,18 @@ export class UsersService {
|
||||
tenant: {
|
||||
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
|
||||
},
|
||||
children: isSuperTenant
|
||||
? { where: { isDeleted: 0 }, orderBy: { createTime: 'desc' } }
|
||||
parentRelations: isSuperTenant
|
||||
? {
|
||||
include: {
|
||||
child: {
|
||||
select: {
|
||||
id: true, username: true, nickname: true, avatar: true,
|
||||
gender: true, birthday: true, city: true, status: true, createTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createTime: 'desc' },
|
||||
}
|
||||
: false,
|
||||
contestRegistrations: isSuperTenant
|
||||
? {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# 统一用户管理 — 设计方案
|
||||
|
||||
> 所属端:超管端
|
||||
> 状态:已实现(待验收)
|
||||
> 状态:已实现(迭代中)
|
||||
> 创建日期:2026-03-27
|
||||
> 最后更新:2026-03-27
|
||||
> 最后更新:2026-03-30
|
||||
|
||||
---
|
||||
|
||||
@ -101,6 +101,7 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
|
||||
#### 统计卡片
|
||||
|
||||
- 5 张卡片横排,每张显示:类型图标 + 类型名称 + 数量
|
||||
- 类型命名:全部 / **运营团队** / 机构 / 评委 / 公众(~~平台~~ → 运营团队,2026-03-30 更名)
|
||||
- 选中的卡片高亮(主色边框),点击 = 设置 userType 筛选
|
||||
- 点"全部"清除类型筛选
|
||||
- 数据来源:`GET /api/users/stats`
|
||||
@ -151,8 +152,8 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
|
||||
|
||||
**公众用户额外区域**:
|
||||
```
|
||||
子女信息(N个)
|
||||
├── 姓名 / 年龄 / 年级 / 城市 / 学校
|
||||
子女账号(N个)— 基于 UserParentChild 关系,子女为独立 User
|
||||
├── 头像 / 昵称 / @用户名 / 性别 / 城市 / 关系(父亲/母亲/监护人) / 状态
|
||||
|
||||
报名记录(近20条)
|
||||
├── 活动名称 / 报名状态 / 参与者(本人/子女名) / 报名时间
|
||||
@ -188,7 +189,7 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
|
||||
|
||||
- 返回字段增加:
|
||||
- `tenant: { id, name, code, tenantType, isSuper }`(用于前端推导用户类型)
|
||||
- `_count: { children, contestRegistrations }`(公众用户的子女数和报名数)
|
||||
- `_count: { parentRelations, contestRegistrations }`(公众用户的子女账号数和报名数)
|
||||
|
||||
普通租户调用时:保持现有逻辑不变。
|
||||
|
||||
@ -220,7 +221,7 @@ public → tenant.code = 'public'
|
||||
|
||||
超管调用时:
|
||||
- 不做 tenantId 过滤
|
||||
- 公众用户额外返回:`children`(子女列表)、`contestRegistrations`(近20条报名记录,含活动名和子女名)
|
||||
- 公众用户额外返回:`parentRelations`(子女账号列表,含 child User 信息)、`contestRegistrations`(近20条报名记录,含活动名和子女名)
|
||||
- 评委额外返回:`contestJudges`(参与的评审活动列表)
|
||||
|
||||
#### 3.3.4 新增 PATCH /api/users/:id/status
|
||||
@ -292,3 +293,18 @@ public → tenant.code = 'public'
|
||||
**验证结果:**
|
||||
- 后端 TSC 编译通过,NestJS 启动成功,/api/users/stats 和 /api/users/:id/status 路由注册正常
|
||||
- 前端无新增 TS 错误(原有错误均为已有代码)
|
||||
|
||||
### 2026-03-30 — 命名优化 + 子女账号独立化适配
|
||||
|
||||
**问题:**
|
||||
1. 统计卡片和 Tag 中「平台」命名易误解为"平台全部用户",实际指运营管理人员
|
||||
2. 公众用户详情仍展示旧版 `Child` 模型(姓名/年级/学校),子女已独立为 `User` 后应使用 `UserParentChild` 关系
|
||||
|
||||
**改动(3 个文件):**
|
||||
- `backend/src/users/users.service.ts` — findOne 详情查询:`children`(旧 Child 表)→ `parentRelations`(UserParentChild + child User);列表 `_count.children` → `_count.parentRelations`
|
||||
- `frontend/src/api/users.ts` — User 类型定义:`children` 数组 → `parentRelations` 数组(含 child 独立用户信息 + relationship + controlMode)
|
||||
- `frontend/src/views/system/users/Index.vue` — 统计卡片和 Tag 标签:「平台」→「运营团队」;详情 Drawer 子女区域:旧版姓名/年级/学校列表 → 新版子女账号卡片(头像+昵称+用户名+关系+状态)
|
||||
|
||||
**验证结果:**
|
||||
- 后端重启成功,编译无错误
|
||||
- 前端 HMR 热更新生效,无新增 TS 错误
|
||||
|
||||
@ -48,18 +48,25 @@ export interface User {
|
||||
};
|
||||
}>;
|
||||
_count?: {
|
||||
children: number;
|
||||
parentRelations: number;
|
||||
contestRegistrations: number;
|
||||
};
|
||||
// 详情接口返回
|
||||
children?: Array<{
|
||||
// 详情接口返回 — 子女账号(独立用户)
|
||||
parentRelations?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
grade?: string;
|
||||
city?: string;
|
||||
schoolName?: string;
|
||||
relationship?: string;
|
||||
controlMode: string;
|
||||
child: {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
city?: string;
|
||||
status?: string;
|
||||
createTime?: string;
|
||||
};
|
||||
}>;
|
||||
contestRegistrations?: Array<{
|
||||
id: number;
|
||||
|
||||
@ -194,19 +194,29 @@
|
||||
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 公众用户:子女信息 -->
|
||||
<!-- 公众用户:子女账号 -->
|
||||
<template v-if="getUserTypeKey(detailData) === 'public'">
|
||||
<div class="detail-section">
|
||||
<h4>子女信息({{ detailData.children?.length || 0 }})</h4>
|
||||
<a-empty v-if="!detailData.children?.length" description="暂无子女" :image="simpleImage" />
|
||||
<h4>子女账号({{ detailData.parentRelations?.length || 0 }})</h4>
|
||||
<a-empty v-if="!detailData.parentRelations?.length" description="暂无子女账号" :image="simpleImage" />
|
||||
<div v-else class="children-list">
|
||||
<div v-for="child in detailData.children" :key="child.id" class="child-item">
|
||||
<span class="child-name">{{ child.name }}</span>
|
||||
<div v-for="rel in detailData.parentRelations" :key="rel.id" class="child-item">
|
||||
<div class="child-info">
|
||||
<a-avatar :size="28" :src="rel.child.avatar" class="child-avatar">
|
||||
{{ rel.child.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
<div class="child-detail">
|
||||
<span class="child-name">{{ rel.child.nickname }}</span>
|
||||
<span class="child-username">@{{ rel.child.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-tag v-if="child.gender">{{ child.gender === 'male' ? '男' : '女' }}</a-tag>
|
||||
<a-tag v-if="child.grade">{{ child.grade }}</a-tag>
|
||||
<a-tag v-if="child.city">{{ child.city }}</a-tag>
|
||||
<a-tag v-if="child.schoolName" color="blue">{{ child.schoolName }}</a-tag>
|
||||
<a-tag v-if="rel.child.gender">{{ rel.child.gender === 'male' ? '男' : '女' }}</a-tag>
|
||||
<a-tag v-if="rel.child.city">{{ rel.child.city }}</a-tag>
|
||||
<a-tag v-if="rel.relationship" color="blue">{{ relationshipLabel(rel.relationship) }}</a-tag>
|
||||
<a-tag :color="rel.child.status === 'enabled' ? 'green' : 'red'">
|
||||
{{ rel.child.status === 'enabled' ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
@ -311,7 +321,7 @@ const activeType = ref<string>('')
|
||||
|
||||
const statsItems = computed(() => [
|
||||
{ type: '', label: '全部', count: stats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
||||
{ type: 'platform', label: '平台', count: stats.value.platform, icon: CrownOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
{ type: 'platform', label: '运营团队', count: stats.value.platform, icon: CrownOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
||||
{ type: 'org', label: '机构', count: stats.value.org, icon: BankOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
||||
{ type: 'judge', label: '评委', count: stats.value.judge, icon: AuditOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
||||
{ type: 'public', label: '公众', count: stats.value.public, icon: UserOutlined, color: '#8b5cf6', bgColor: 'rgba(139,92,246,0.1)' },
|
||||
@ -433,7 +443,7 @@ function getUserTypeKey(user: User): string {
|
||||
|
||||
function getUserTypeTag(user: User) {
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
platform: { label: '平台', color: 'blue' },
|
||||
platform: { label: '运营团队', color: 'blue' },
|
||||
org: { label: '机构', color: 'green' },
|
||||
judge: { label: '评委', color: 'orange' },
|
||||
public: { label: '公众', color: 'purple' },
|
||||
@ -531,6 +541,8 @@ const handlePasswordSubmit = async () => {
|
||||
// ========== 工具函数 ==========
|
||||
const formatDate = (d?: string) => (d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-')
|
||||
const genderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : '-')
|
||||
const relationshipLabel = (r?: string) =>
|
||||
({ father: '父亲', mother: '母亲', guardian: '监护人' }[r || ''] || r || '-')
|
||||
const regStateLabel = (s: string) =>
|
||||
({ pending: '待审核', passed: '已通过', rejected: '已拒绝', withdrawn: '已撤回' }[s] || s)
|
||||
const regStateColor = (s: string) =>
|
||||
@ -721,14 +733,38 @@ $primary: #6366f1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
padding: 10px 12px;
|
||||
background: #faf9fe;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
color: #1e1b4b;
|
||||
.child-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.child-avatar {
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
color: #1e1b4b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.child-username {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user