超管端用户管理:「平台」更名为「运营团队」+ 子女信息适配独立账号模型

- 统计卡片和用户类型Tag从「平台」改为「运营团队」,避免命名歧义
- 公众用户详情从旧版Child模型(姓名/年级/学校)改为UserParentChild关系,展示子女独立账号信息
- 后端详情接口和列表_count同步从children切换到parentRelations
- 更新统一用户管理设计文档,补充实施记录

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aid 2026-03-30 18:03:44 +08:00
parent 418aa57ea8
commit 4466e28b3b
4 changed files with 102 additions and 33 deletions

View File

@ -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
? {

View File

@ -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 错误

View File

@ -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;

View File

@ -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;
}
}
}
}
}