超管端用户管理:「平台」更名为「运营团队」+ 子女信息适配独立账号模型
- 统计卡片和用户类型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 },
|
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
|
||||||
};
|
};
|
||||||
include._count = {
|
include._count = {
|
||||||
select: { children: true, contestRegistrations: true },
|
select: { parentRelations: true, contestRegistrations: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,8 +194,18 @@ export class UsersService {
|
|||||||
tenant: {
|
tenant: {
|
||||||
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
|
select: { id: true, name: true, code: true, tenantType: true, isSuper: true },
|
||||||
},
|
},
|
||||||
children: isSuperTenant
|
parentRelations: isSuperTenant
|
||||||
? { where: { isDeleted: 0 }, orderBy: { createTime: 'desc' } }
|
? {
|
||||||
|
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,
|
: false,
|
||||||
contestRegistrations: isSuperTenant
|
contestRegistrations: isSuperTenant
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
# 统一用户管理 — 设计方案
|
# 统一用户管理 — 设计方案
|
||||||
|
|
||||||
> 所属端:超管端
|
> 所属端:超管端
|
||||||
> 状态:已实现(待验收)
|
> 状态:已实现(迭代中)
|
||||||
> 创建日期:2026-03-27
|
> 创建日期:2026-03-27
|
||||||
> 最后更新:2026-03-27
|
> 最后更新:2026-03-30
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -101,6 +101,7 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
|
|||||||
#### 统计卡片
|
#### 统计卡片
|
||||||
|
|
||||||
- 5 张卡片横排,每张显示:类型图标 + 类型名称 + 数量
|
- 5 张卡片横排,每张显示:类型图标 + 类型名称 + 数量
|
||||||
|
- 类型命名:全部 / **运营团队** / 机构 / 评委 / 公众(~~平台~~ → 运营团队,2026-03-30 更名)
|
||||||
- 选中的卡片高亮(主色边框),点击 = 设置 userType 筛选
|
- 选中的卡片高亮(主色边框),点击 = 设置 userType 筛选
|
||||||
- 点"全部"清除类型筛选
|
- 点"全部"清除类型筛选
|
||||||
- 数据来源:`GET /api/users/stats`
|
- 数据来源:`GET /api/users/stats`
|
||||||
@ -151,8 +152,8 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
|
|||||||
|
|
||||||
**公众用户额外区域**:
|
**公众用户额外区域**:
|
||||||
```
|
```
|
||||||
子女信息(N个)
|
子女账号(N个)— 基于 UserParentChild 关系,子女为独立 User
|
||||||
├── 姓名 / 年龄 / 年级 / 城市 / 学校
|
├── 头像 / 昵称 / @用户名 / 性别 / 城市 / 关系(父亲/母亲/监护人) / 状态
|
||||||
|
|
||||||
报名记录(近20条)
|
报名记录(近20条)
|
||||||
├── 活动名称 / 报名状态 / 参与者(本人/子女名) / 报名时间
|
├── 活动名称 / 报名状态 / 参与者(本人/子女名) / 报名时间
|
||||||
@ -188,7 +189,7 @@ GET /api/public/users/:id — 公众用户详情(含子女+报名)
|
|||||||
|
|
||||||
- 返回字段增加:
|
- 返回字段增加:
|
||||||
- `tenant: { id, name, code, tenantType, isSuper }`(用于前端推导用户类型)
|
- `tenant: { id, name, code, tenantType, isSuper }`(用于前端推导用户类型)
|
||||||
- `_count: { children, contestRegistrations }`(公众用户的子女数和报名数)
|
- `_count: { parentRelations, contestRegistrations }`(公众用户的子女账号数和报名数)
|
||||||
|
|
||||||
普通租户调用时:保持现有逻辑不变。
|
普通租户调用时:保持现有逻辑不变。
|
||||||
|
|
||||||
@ -220,7 +221,7 @@ public → tenant.code = 'public'
|
|||||||
|
|
||||||
超管调用时:
|
超管调用时:
|
||||||
- 不做 tenantId 过滤
|
- 不做 tenantId 过滤
|
||||||
- 公众用户额外返回:`children`(子女列表)、`contestRegistrations`(近20条报名记录,含活动名和子女名)
|
- 公众用户额外返回:`parentRelations`(子女账号列表,含 child User 信息)、`contestRegistrations`(近20条报名记录,含活动名和子女名)
|
||||||
- 评委额外返回:`contestJudges`(参与的评审活动列表)
|
- 评委额外返回:`contestJudges`(参与的评审活动列表)
|
||||||
|
|
||||||
#### 3.3.4 新增 PATCH /api/users/:id/status
|
#### 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 路由注册正常
|
- 后端 TSC 编译通过,NestJS 启动成功,/api/users/stats 和 /api/users/:id/status 路由注册正常
|
||||||
- 前端无新增 TS 错误(原有错误均为已有代码)
|
- 前端无新增 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?: {
|
_count?: {
|
||||||
children: number;
|
parentRelations: number;
|
||||||
contestRegistrations: number;
|
contestRegistrations: number;
|
||||||
};
|
};
|
||||||
// 详情接口返回
|
// 详情接口返回 — 子女账号(独立用户)
|
||||||
children?: Array<{
|
parentRelations?: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
relationship?: string;
|
||||||
gender?: string;
|
controlMode: string;
|
||||||
birthday?: string;
|
child: {
|
||||||
grade?: string;
|
id: number;
|
||||||
city?: string;
|
username: string;
|
||||||
schoolName?: string;
|
nickname: string;
|
||||||
|
avatar?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthday?: string;
|
||||||
|
city?: string;
|
||||||
|
status?: string;
|
||||||
|
createTime?: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
contestRegistrations?: Array<{
|
contestRegistrations?: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@ -194,19 +194,29 @@
|
|||||||
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization }}</a-descriptions-item>
|
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
|
||||||
<!-- 公众用户:子女信息 -->
|
<!-- 公众用户:子女账号 -->
|
||||||
<template v-if="getUserTypeKey(detailData) === 'public'">
|
<template v-if="getUserTypeKey(detailData) === 'public'">
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h4>子女信息({{ detailData.children?.length || 0 }})</h4>
|
<h4>子女账号({{ detailData.parentRelations?.length || 0 }})</h4>
|
||||||
<a-empty v-if="!detailData.children?.length" description="暂无子女" :image="simpleImage" />
|
<a-empty v-if="!detailData.parentRelations?.length" description="暂无子女账号" :image="simpleImage" />
|
||||||
<div v-else class="children-list">
|
<div v-else class="children-list">
|
||||||
<div v-for="child in detailData.children" :key="child.id" class="child-item">
|
<div v-for="rel in detailData.parentRelations" :key="rel.id" class="child-item">
|
||||||
<span class="child-name">{{ child.name }}</span>
|
<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-space>
|
||||||
<a-tag v-if="child.gender">{{ child.gender === 'male' ? '男' : '女' }}</a-tag>
|
<a-tag v-if="rel.child.gender">{{ rel.child.gender === 'male' ? '男' : '女' }}</a-tag>
|
||||||
<a-tag v-if="child.grade">{{ child.grade }}</a-tag>
|
<a-tag v-if="rel.child.city">{{ rel.child.city }}</a-tag>
|
||||||
<a-tag v-if="child.city">{{ child.city }}</a-tag>
|
<a-tag v-if="rel.relationship" color="blue">{{ relationshipLabel(rel.relationship) }}</a-tag>
|
||||||
<a-tag v-if="child.schoolName" color="blue">{{ child.schoolName }}</a-tag>
|
<a-tag :color="rel.child.status === 'enabled' ? 'green' : 'red'">
|
||||||
|
{{ rel.child.status === 'enabled' ? '正常' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -311,7 +321,7 @@ const activeType = ref<string>('')
|
|||||||
|
|
||||||
const statsItems = computed(() => [
|
const statsItems = computed(() => [
|
||||||
{ type: '', label: '全部', count: stats.value.total, icon: TeamOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
{ 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: '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: '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)' },
|
{ 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) {
|
function getUserTypeTag(user: User) {
|
||||||
const map: Record<string, { label: string; color: string }> = {
|
const map: Record<string, { label: string; color: string }> = {
|
||||||
platform: { label: '平台', color: 'blue' },
|
platform: { label: '运营团队', color: 'blue' },
|
||||||
org: { label: '机构', color: 'green' },
|
org: { label: '机构', color: 'green' },
|
||||||
judge: { label: '评委', color: 'orange' },
|
judge: { label: '评委', color: 'orange' },
|
||||||
public: { label: '公众', color: 'purple' },
|
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 formatDate = (d?: string) => (d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-')
|
||||||
const genderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : '-')
|
const genderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : '-')
|
||||||
|
const relationshipLabel = (r?: string) =>
|
||||||
|
({ father: '父亲', mother: '母亲', guardian: '监护人' }[r || ''] || r || '-')
|
||||||
const regStateLabel = (s: string) =>
|
const regStateLabel = (s: string) =>
|
||||||
({ pending: '待审核', passed: '已通过', rejected: '已拒绝', withdrawn: '已撤回' }[s] || s)
|
({ pending: '待审核', passed: '已通过', rejected: '已拒绝', withdrawn: '已撤回' }[s] || s)
|
||||||
const regStateColor = (s: string) =>
|
const regStateColor = (s: string) =>
|
||||||
@ -721,14 +733,38 @@ $primary: #6366f1;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: 10px 12px;
|
||||||
background: #faf9fe;
|
background: #faf9fe;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
.child-name {
|
.child-info {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
color: #1e1b4b;
|
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