chenpei f8db4232ce feat: 第一阶段框架——登录落点切换为品牌中心看板首页
- 看板首页 / :Zen Minimal × Future Organic 融合(用户裁定),编号索引+柔光光晕+GSAP克制动效,白皮书气质
- 素材库首页整体迁移至 /materials(含colocated测试),全站返回素材库链接同步清扫
- 组织架构页上移为顶层 /organization(保留云采石场契约注释),旧路径 /admin/org 服务端跳转;API 迁移 /api/organization(逻辑零改动)
- 后台删除组织架构跳转按钮(白屏问题源头消失);middleware 增 /materials 登录门、/organization 管理员门
- 品牌统一:浅色顶栏用 loginLogoUrl(深色图案版),标题「由壹点·品牌中心」;字体自托管 @fontsource
- 可访问性收尾(评审fix项):全文对比度过WCAG、焦点环、加载骨架防闪灰、移动端状态可见、::selection
- PRODUCT.md/DESIGN.md 记录方向契约;测试基线 1815 通过/6 既有失败(无劣化)
2026-08-15 11:01:27 +08:00

236 lines
8.2 KiB
TypeScript
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.

/**
* 组织架构 API(品牌中心人员视图)
*
* 数据口径(决策 D-A):
* - 只统计"在素材库开过账号(User)且已建立活跃绑定(UserEmployeeBinding)"的人;
* - 花名册里有、但没有素材库账号的人(仓储/物流等约 1200 人)不纳入;
* - 无钉钉 ID 无法绑定的活跃账号单独归入"未绑定/待人工确认"组;
* - 绑定档案部门路径为"未确定"的单独归入"未确定"组。
*
* 返回:
* - tree:真实组织树(OrganizationUnit,isActive),节点带该部门直接+间接人员数;
* - employees:全部已采纳人员的明细(含 M/P 等级、归属、直属主管、在职状态);
* - unbound:未绑定账号;unclassified:部门未确定的人。
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin, isDepartmentAdmin, isAdmin } from '@/lib/department-permission'
interface OrgNode {
id: string
name: string
path: string
level: number
parentId: string | null
/** 该节点(含子孙)的人数 */
count: number
children: OrgNode[]
}
interface EmployeeRow {
userId: string
username: string | null
realName: string | null
displayName: string | null
jobNumber: string | null
departmentPath: string | null
departmentName: string | null
positionTitle: string | null
positionLevel: string | null
levelType: string | null
managerName: string | null
isLeadership: boolean
employmentState: string | null
isActive: boolean
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || !session.user || !isAdmin((session.user as any).role)) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
const url = new URL(request.url)
const includeInactive = url.searchParams.get('includeInactive') === 'true'
// 作用域:系统管理员看全部;部门管理员(DEPT_ADMIN)只看本部门分支。
// 本部门用 session 里的 managedDepartment(旧部门名)匹配组织树节点路径。
// 匹配不到时降级为看全部(不误伤),并在结果里标注 scope 提示。
const sessionUser = session.user as any
const isSysAdmin = isSystemAdmin(sessionUser.role)
let scopePrefix: string | null = null
let scopeFallback = false
if (!isSysAdmin && isDepartmentAdmin(sessionUser.role)) {
const managedName = String(sessionUser.managedDepartment || '').trim()
if (managedName) {
const scopeUnit = await prisma.organizationUnit.findFirst({
where: { isActive: true, name: managedName },
select: { path: true },
})
if (scopeUnit) {
scopePrefix = scopeUnit.path
} else {
scopeFallback = true
}
} else {
scopeFallback = true
}
}
// 1. 活跃绑定 → 人员档案(含账号信息)
const bindings = await prisma.userEmployeeBinding.findMany({
where: { status: 'ACTIVE' },
select: {
userId: true,
user: {
select: {
id: true,
username: true,
realName: true,
isActive: true,
},
},
employeeProfile: {
select: {
displayName: true,
jobNumber: true,
departmentPath: true,
departmentName: true,
positionTitle: true,
positionLevel: true,
levelType: true,
managerName: true,
isLeadership: true,
employmentState: true,
},
},
},
})
// 2. 组织树(全部有效节点)
const units = await prisma.organizationUnit.findMany({
where: { isActive: true },
select: { id: true, name: true, path: true, level: true, parentId: true },
orderBy: [{ level: 'asc' }, { name: 'asc' }],
})
// 3. 无钉钉 ID 的活跃账号(未绑定组)
const unboundUsers = await prisma.user.findMany({
where: { isActive: true, dingtalkUserId: null },
select: { id: true, username: true, realName: true, department: true },
orderBy: { createdAt: 'asc' },
})
// 组装人员行
const employees: EmployeeRow[] = bindings.map((b) => ({
userId: b.userId,
username: b.user?.username ?? null,
realName: b.user?.realName ?? null,
displayName: b.employeeProfile?.displayName ?? null,
jobNumber: b.employeeProfile?.jobNumber ?? null,
departmentPath: b.employeeProfile?.departmentPath ?? null,
departmentName: b.employeeProfile?.departmentName ?? null,
positionTitle: b.employeeProfile?.positionTitle ?? null,
positionLevel: b.employeeProfile?.positionLevel ?? null,
levelType: b.employeeProfile?.levelType ?? null,
managerName: b.employeeProfile?.managerName ?? null,
isLeadership: Boolean(b.employeeProfile?.isLeadership),
employmentState: b.employeeProfile?.employmentState ?? null,
isActive: Boolean(b.user?.isActive),
}))
// 统计每个部门路径前缀下的人数(用于树节点计数)
// 若为部门管理员,只统计本部门分支
const pathCount = new Map<string, number>()
const scopeEmployees = scopePrefix ? employees.filter((e) => e.departmentPath?.startsWith(scopePrefix)) : employees
for (const e of scopeEmployees) {
if (!e.departmentPath) continue
const parts = e.departmentPath.split('/')
let acc = ''
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part
pathCount.set(acc, (pathCount.get(acc) ?? 0) + 1)
}
}
// 构建组织树(只保留有人的分支 + 未确定组)
const unitMap = new Map<string, OrgNode>()
for (const u of units) {
unitMap.set(u.id, {
id: u.id,
name: u.name,
path: u.path,
level: u.level,
parentId: u.parentId,
count: pathCount.get(u.path) ?? 0,
children: [],
})
}
const roots: OrgNode[] = []
for (const node of Array.from(unitMap.values())) {
if (node.parentId && unitMap.has(node.parentId)) {
unitMap.get(node.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
// 统计每个节点的总人数(含子孙)
const totalCount = (node: OrgNode): number => {
let c = node.count
for (const child of node.children) c += totalCount(child)
node.count = c
return c
}
for (const root of roots) totalCount(root)
// 只保留有人的子树(count > 0),无人的分支剪掉
const prune = (node: OrgNode): OrgNode | null => {
const children = node.children.map(prune).filter((c): c is OrgNode => c !== null)
if (children.length === 0 && node.count === 0) return null
return { ...node, children }
}
const tree = roots.map(prune).filter((n): n is OrgNode => n !== null)
// 未确定组(有绑定但部门路径缺失或以"未确定"开头)
const unclassified = scopeEmployees.filter(
(e) => !e.departmentPath || e.departmentPath.startsWith('未确定'),
)
// 未绑定组(无钉钉 ID 的活跃账号)
const unbound = unboundUsers.map((u) => ({
userId: u.id,
username: u.username,
realName: u.realName,
oldDepartment: u.department,
}))
const visibleEmployees = includeInactive ? scopeEmployees : scopeEmployees.filter((e) => e.isActive)
const summary = {
totalEmployees: employees.length,
activeEmployees: employees.filter((e) => e.isActive).length,
inactiveEmployees: employees.filter((e) => !e.isActive).length,
unboundCount: unbound.length,
unclassifiedCount: unclassified.length,
visibleCount: visibleEmployees.length,
scope: scopePrefix ?? (scopeFallback ? 'fallback-all' : 'all'),
}
return NextResponse.json({
tree,
employees: visibleEmployees,
unclassified,
unbound,
summary,
})
} catch (error) {
console.error('Error fetching org data:', error)
return NextResponse.json({ error: '获取组织架构数据失败' }, { status: 500 })
}
}