feat(auth): 设计师角色迁移为设计业务类型
This commit is contained in:
parent
3cd41b4921
commit
d6ddac46c2
@ -144,10 +144,10 @@ npm run dev
|
||||
curl -X POST http://localhost:3000/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "designer@company.com",
|
||||
"email": "design@company.com",
|
||||
"name": "设计师张三",
|
||||
"password": "Designer@123456",
|
||||
"role": "DESIGNER"
|
||||
"jobNumber": "JS0001"
|
||||
}'
|
||||
```
|
||||
|
||||
@ -157,10 +157,11 @@ curl -X POST http://localhost:3000/api/auth/register \
|
||||
"message": "注册成功",
|
||||
"user": {
|
||||
"id": "xxx",
|
||||
"email": "designer@company.com",
|
||||
"email": "design@company.com",
|
||||
"name": "设计师张三",
|
||||
"role": "DESIGNER",
|
||||
"status": "ACTIVE",
|
||||
"role": "USER",
|
||||
"businessRoles": ["DESIGN"],
|
||||
"status": "PENDING",
|
||||
"createdAt": "2025-12-12T..."
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ model User {
|
||||
phone String?
|
||||
|
||||
// 角色与权限
|
||||
role Role @default(DESIGNER)
|
||||
role Role @default(USER)
|
||||
|
||||
// 组织架构
|
||||
departmentId String?
|
||||
@ -124,7 +124,7 @@ async function registerUser(data: {
|
||||
name: data.name,
|
||||
password: await hashPassword(data.password),
|
||||
phone: data.phone,
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',\n businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
isFirstLogin: false // 普通用户注册后不需要强制修改
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
||||
CREATE TYPE "BusinessAccountType_new" AS ENUM ('DESIGN', 'PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER');
|
||||
|
||||
UPDATE "User" SET "businessRoles" = ARRAY[]::"BusinessAccountType"[] WHERE "businessRoles" IS NULL;
|
||||
UPDATE "AccountTemplate" SET "businessRoles" = ARRAY[]::"BusinessAccountType"[] WHERE "businessRoles" IS NULL;
|
||||
UPDATE "ApprovalWorkflow" SET "businessRoles" = ARRAY[]::"BusinessAccountType"[] WHERE "businessRoles" IS NULL;
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "businessRoles" DROP DEFAULT;
|
||||
ALTER TABLE "AccountTemplate" ALTER COLUMN "businessRoles" DROP DEFAULT;
|
||||
ALTER TABLE "ApprovalWorkflow" ALTER COLUMN "businessRoles" DROP DEFAULT;
|
||||
|
||||
ALTER TABLE "User"
|
||||
ALTER COLUMN "businessRoles" TYPE "BusinessAccountType_new"[]
|
||||
USING "businessRoles"::text[]::"BusinessAccountType_new"[];
|
||||
|
||||
ALTER TABLE "AccountTemplate"
|
||||
ALTER COLUMN "businessRoles" TYPE "BusinessAccountType_new"[]
|
||||
USING "businessRoles"::text[]::"BusinessAccountType_new"[];
|
||||
|
||||
ALTER TABLE "ApprovalWorkflow"
|
||||
ALTER COLUMN "businessRoles" TYPE "BusinessAccountType_new"[]
|
||||
USING "businessRoles"::text[]::"BusinessAccountType_new"[];
|
||||
|
||||
DROP TYPE "BusinessAccountType";
|
||||
ALTER TYPE "BusinessAccountType_new" RENAME TO "BusinessAccountType";
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "businessRoles" SET DEFAULT ARRAY[]::"BusinessAccountType"[];
|
||||
ALTER TABLE "AccountTemplate" ALTER COLUMN "businessRoles" SET DEFAULT ARRAY[]::"BusinessAccountType"[];
|
||||
ALTER TABLE "ApprovalWorkflow" ALTER COLUMN "businessRoles" SET DEFAULT ARRAY[]::"BusinessAccountType"[];
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "businessRoles" SET NOT NULL;
|
||||
ALTER TABLE "AccountTemplate" ALTER COLUMN "businessRoles" SET NOT NULL;
|
||||
ALTER TABLE "ApprovalWorkflow" ALTER COLUMN "businessRoles" SET NOT NULL;
|
||||
|
||||
UPDATE "User"
|
||||
SET
|
||||
"role" = 'USER',
|
||||
"businessRoles" = CASE
|
||||
WHEN 'DESIGN'::"BusinessAccountType" = ANY("businessRoles") THEN "businessRoles"
|
||||
ELSE array_append(COALESCE("businessRoles", ARRAY[]::"BusinessAccountType"[]), 'DESIGN'::"BusinessAccountType")
|
||||
END
|
||||
WHERE "role"::text = 'DESIGNER';
|
||||
|
||||
UPDATE "AccountTemplate"
|
||||
SET
|
||||
"role" = 'USER',
|
||||
"businessRoles" = CASE
|
||||
WHEN 'DESIGN'::"BusinessAccountType" = ANY("businessRoles") THEN "businessRoles"
|
||||
ELSE array_append(COALESCE("businessRoles", ARRAY[]::"BusinessAccountType"[]), 'DESIGN'::"BusinessAccountType")
|
||||
END
|
||||
WHERE "role"::text = 'DESIGNER';
|
||||
|
||||
CREATE TYPE "Role_new" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER');
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "role" DROP DEFAULT;
|
||||
|
||||
ALTER TABLE "User"
|
||||
ALTER COLUMN "role" TYPE "Role_new"
|
||||
USING (
|
||||
CASE WHEN "role"::text = 'DESIGNER' THEN 'USER' ELSE "role"::text END
|
||||
)::"Role_new";
|
||||
|
||||
ALTER TABLE "AccountTemplate"
|
||||
ALTER COLUMN "role" TYPE "Role_new"
|
||||
USING (
|
||||
CASE WHEN "role"::text = 'DESIGNER' THEN 'USER' ELSE "role"::text END
|
||||
)::"Role_new";
|
||||
|
||||
DROP TYPE "Role";
|
||||
ALTER TYPE "Role_new" RENAME TO "Role";
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'USER';
|
||||
@ -932,7 +932,6 @@ enum Role {
|
||||
SUPER_ADMIN // 超级管理员
|
||||
ADMIN // 管理员
|
||||
SUPERVISOR // 主管(按部门/小组管理锚点区分范围)
|
||||
DESIGNER // 设计师
|
||||
USER // 普通用户
|
||||
}
|
||||
|
||||
@ -950,6 +949,7 @@ enum EmployeeSourceStatus {
|
||||
}
|
||||
|
||||
enum BusinessAccountType {
|
||||
DESIGN
|
||||
PLANNING
|
||||
PURCHASING
|
||||
WAREHOUSE
|
||||
|
||||
@ -397,7 +397,7 @@ async function main() {
|
||||
data: { leaderId: teamLeader.id },
|
||||
});
|
||||
|
||||
// 创建设计师
|
||||
// 创建设计业务用户
|
||||
const designers = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const designer = await prisma.user.create({
|
||||
@ -405,7 +405,8 @@ async function main() {
|
||||
email: `designer${i}@furniture3d.com`,
|
||||
name: `设计师${i}号`,
|
||||
password: hashedPassword,
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: design3DDept.id,
|
||||
teamId: i <= 3 ? modelingTeam.id : animationTeam.id,
|
||||
isFirstLogin: false,
|
||||
@ -771,7 +772,7 @@ async function main() {
|
||||
console.log(' - 超级管理员:admin@furniture3d.com / Admin123456!');
|
||||
console.log(' - 部门管理员:design.manager@furniture3d.com / Admin123456!');
|
||||
console.log(' - 组长:modeling.leader@furniture3d.com / Admin123456!');
|
||||
console.log(' - 设计师:designer1@furniture3d.com / Admin123456!');
|
||||
console.log(' - 设计业务用户:designer1@furniture3d.com / Admin123456!');
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@ -149,9 +149,9 @@ async function main() {
|
||||
);
|
||||
|
||||
results.push(
|
||||
await measureQuery('用户列表(按角色)', async () => {
|
||||
await measureQuery('用户列表(按设计业务类型)', async () => {
|
||||
return await prisma.user.findMany({
|
||||
where: { role: 'DESIGNER' },
|
||||
where: { businessRoles: { has: 'DESIGN' } },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
104
furniture-3d-system/src/app/api/approval-workflows/route.test.ts
Normal file
104
furniture-3d-system/src/app/api/approval-workflows/route.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export {}
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
|
||||
const mockHandleRequest: any = jest.fn()
|
||||
const mockJsonSuccess: any = jest.fn()
|
||||
const mockJsonError: any = jest.fn()
|
||||
const mockGetById: any = jest.fn()
|
||||
const mockCreateWorkflow: any = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
|
||||
jsonError: (...args: unknown[]) => mockJsonError(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/approval-workflow.service', () => ({
|
||||
approvalWorkflowService: {
|
||||
create: (...args: unknown[]) => mockCreateWorkflow(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services', () => ({
|
||||
intakeNonIntegratedProductService: {
|
||||
getById: (...args: unknown[]) => mockGetById(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = require('./route')
|
||||
|
||||
function createRequest(body: unknown) {
|
||||
return {
|
||||
json: jest.fn(async () => body),
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('POST /api/approval-workflows', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
|
||||
handler({
|
||||
requestId: 'req-test',
|
||||
user: {
|
||||
id: 'design-1',
|
||||
email: 'design@example.com',
|
||||
name: '设计用户',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
)
|
||||
mockJsonSuccess.mockImplementation((data, status, requestId) => ({ success: true, data, status, requestId }))
|
||||
mockJsonError.mockImplementation((code, message, status, requestId) => ({ success: false, code, message, status, requestId }))
|
||||
})
|
||||
|
||||
it('写入口要求内部业务写权限', async () => {
|
||||
mockGetById.mockResolvedValue({ id: 'product-1' })
|
||||
mockCreateWorkflow.mockResolvedValue({ id: 'workflow-1' })
|
||||
|
||||
await POST(createRequest({
|
||||
productId: 'product-1',
|
||||
approvalNumber: 'PD001',
|
||||
title: '审批单',
|
||||
}))
|
||||
|
||||
expect(mockHandleRequest.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({ requireInternalContentWrite: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('拒绝前端直接提交 businessRoles', async () => {
|
||||
const response = await POST(createRequest({
|
||||
productId: 'product-1',
|
||||
approvalNumber: 'PD001',
|
||||
title: '审批单',
|
||||
businessRoles: ['DESIGN'],
|
||||
}))
|
||||
|
||||
expect(mockCreateWorkflow).not.toHaveBeenCalled()
|
||||
expect(response).toEqual(expect.objectContaining({
|
||||
success: false,
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
status: 400,
|
||||
}))
|
||||
})
|
||||
|
||||
it('创建时不把 businessRoles 透传给服务层', async () => {
|
||||
mockGetById.mockResolvedValue({ id: 'product-1' })
|
||||
mockCreateWorkflow.mockResolvedValue({ id: 'workflow-1' })
|
||||
|
||||
await POST(createRequest({
|
||||
productId: 'product-1',
|
||||
approvalNumber: 'PD001',
|
||||
title: '审批单',
|
||||
}))
|
||||
|
||||
expect(mockCreateWorkflow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ businessRoles: expect.anything() }),
|
||||
expect.objectContaining({ id: 'design-1' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
|
||||
import { Prisma, type ApprovalWorkflowStatus } from '@prisma/client'
|
||||
import { approvalWorkflowService } from '@/server/services/approval-workflow.service'
|
||||
import { intakeNonIntegratedProductService } from '@/server/services'
|
||||
|
||||
@ -32,24 +32,34 @@ export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const body = await request.json()
|
||||
const body = await request.json().catch(() => null)
|
||||
try {
|
||||
await intakeNonIntegratedProductService.getById(body.productId, ctx.user)
|
||||
if (!body || typeof body !== 'object') {
|
||||
return jsonError(ErrorCode.VALIDATION_FAILED, '流程参数不正确', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
if ('businessRoles' in body) {
|
||||
return jsonError(ErrorCode.VALIDATION_FAILED, '参与业务类型由审批字段矩阵自动推导,不能由前端直接提交', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>
|
||||
const productId = typeof payload.productId === 'string' ? payload.productId : ''
|
||||
const approvalNumber = typeof payload.approvalNumber === 'string' ? payload.approvalNumber : ''
|
||||
const title = typeof payload.title === 'string' ? payload.title : ''
|
||||
|
||||
await intakeNonIntegratedProductService.getById(productId, ctx.user)
|
||||
const workflow = await approvalWorkflowService.create(
|
||||
{
|
||||
approvalNumber: body.approvalNumber,
|
||||
title: body.title,
|
||||
productId: body.productId,
|
||||
status: body.status as ApprovalWorkflowStatus | undefined,
|
||||
businessRoles: Array.isArray(body.businessRoles)
|
||||
? (body.businessRoles as BusinessAccountType[])
|
||||
approvalNumber,
|
||||
title,
|
||||
productId,
|
||||
status: payload.status as ApprovalWorkflowStatus | undefined,
|
||||
participantUserIds: Array.isArray(payload.participantUserIds)
|
||||
? (payload.participantUserIds as string[])
|
||||
: undefined,
|
||||
participantUserIds: Array.isArray(body.participantUserIds)
|
||||
? (body.participantUserIds as string[])
|
||||
: undefined,
|
||||
fieldMatrix: body.fieldMatrix,
|
||||
summary: body.summary,
|
||||
metadata: body.metadata,
|
||||
fieldMatrix: payload.fieldMatrix as Prisma.InputJsonValue | null | undefined,
|
||||
summary: payload.summary as Prisma.InputJsonValue | null | undefined,
|
||||
metadata: payload.metadata as Prisma.InputJsonValue | null | undefined,
|
||||
},
|
||||
ctx.user
|
||||
)
|
||||
@ -61,6 +71,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true }
|
||||
{ requireAuth: true, requireInternalContentWrite: true }
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,6 +40,6 @@ export async function POST(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ export async function PUT(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -58,6 +58,6 @@ export async function DELETE(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -63,6 +63,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -119,6 +119,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ export async function PUT(request: NextRequest, context: RouteParams) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -63,6 +63,6 @@ export async function DELETE(request: NextRequest, context: RouteParams) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -49,6 +49,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ export async function PUT(request: NextRequest, context: RouteParams) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -55,6 +55,6 @@ export async function DELETE(request: NextRequest, context: RouteParams) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -50,6 +50,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,5 +52,5 @@ export async function POST(request: NextRequest) {
|
||||
fileName,
|
||||
size: file.size,
|
||||
}, 201, requestId)
|
||||
}, { requireAuth: true })
|
||||
}, { requireAuth: true, requireInternalContentWrite: true })
|
||||
}
|
||||
|
||||
@ -40,6 +40,6 @@ export async function POST(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ export async function PUT(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -66,6 +66,6 @@ export async function DELETE(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -26,6 +26,6 @@ export async function POST(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -95,6 +95,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,9 +6,11 @@ import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
const mockHandleRequest: any = jest.fn()
|
||||
const mockJsonSuccess: any = jest.fn()
|
||||
const mockCreateUser: any = jest.fn()
|
||||
const mockFindByEmployeeIdentity: any = jest.fn()
|
||||
const mockCreateLog: any = jest.fn()
|
||||
const mockMatchByJobNumber: any = jest.fn()
|
||||
const mockResolveDepartmentIdByExternalId: any = jest.fn()
|
||||
const mockFindDepartmentById: any = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
@ -18,6 +20,7 @@ jest.mock('@/server/core/response', () => ({
|
||||
jest.mock('@/server/services/user.service', () => ({
|
||||
userService: {
|
||||
create: (...args: unknown[]) => mockCreateUser(...args),
|
||||
findByEmployeeIdentity: (...args: unknown[]) => mockFindByEmployeeIdentity(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
@ -34,6 +37,12 @@ jest.mock('@/server/services/external-employee-directory.service', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/department.service', () => ({
|
||||
departmentService: {
|
||||
findById: (...args: unknown[]) => mockFindDepartmentById(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = require('./route')
|
||||
|
||||
function createRequest(body: unknown) {
|
||||
@ -54,6 +63,8 @@ describe('POST /api/auth/register', () => {
|
||||
status,
|
||||
requestId,
|
||||
}))
|
||||
mockFindByEmployeeIdentity.mockResolvedValue(null)
|
||||
mockFindDepartmentById.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('工号匹配到在职员工后创建待激活账号', async () => {
|
||||
@ -68,27 +79,38 @@ describe('POST /api/auth/register', () => {
|
||||
primaryDepartmentExternalId: 'dept-ext-1',
|
||||
})
|
||||
mockResolveDepartmentIdByExternalId.mockResolvedValue('dept-1')
|
||||
mockFindDepartmentById.mockResolvedValue({
|
||||
id: 'dept-1',
|
||||
name: '产品研发部',
|
||||
fullPath: '江苏简尚品牌管理有限公司 > 产品研发部',
|
||||
})
|
||||
mockCreateUser.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'zhangsan',
|
||||
name: '张三',
|
||||
role: 'USER',
|
||||
employeeDirectoryId: 'emp-1',
|
||||
})
|
||||
|
||||
const response = await POST(createRequest({
|
||||
email: 'user@example.com',
|
||||
name: 'zhangsan',
|
||||
name: ' 张三 ',
|
||||
jobNumber: '1001',
|
||||
password: '123456',
|
||||
}))
|
||||
|
||||
expect(mockMatchByJobNumber).toHaveBeenCalledWith('1001')
|
||||
expect(mockFindByEmployeeIdentity).toHaveBeenCalledWith({
|
||||
employeeDirectoryId: 'emp-1',
|
||||
dingtalkUserId: 'dt-1',
|
||||
dingtalkUnionId: 'union-1',
|
||||
})
|
||||
expect(mockCreateUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
name: 'zhangsan',
|
||||
name: '张三',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'PENDING',
|
||||
departmentId: 'dept-1',
|
||||
employeeDirectoryId: 'emp-1',
|
||||
@ -99,6 +121,70 @@ describe('POST /api/auth/register', () => {
|
||||
expect(response).toEqual(expect.objectContaining({ success: true, status: 201 }))
|
||||
})
|
||||
|
||||
it('姓名与员工库不一致时拒绝注册,不绑定员工身份', async () => {
|
||||
mockMatchByJobNumber.mockResolvedValue({
|
||||
id: 'emp-1',
|
||||
jobNumber: '1001',
|
||||
name: '张三',
|
||||
mobile: '13800000000',
|
||||
sourceStatus: 'EMPLOYED',
|
||||
dingtalkUserId: 'dt-1',
|
||||
dingtalkUnionId: 'union-1',
|
||||
primaryDepartmentExternalId: 'dept-ext-1',
|
||||
})
|
||||
|
||||
await expect(POST(createRequest({
|
||||
email: 'user@example.com',
|
||||
name: '李四',
|
||||
jobNumber: '1001',
|
||||
password: '123456',
|
||||
}))).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
message: '姓名与员工库不一致,请核对工号和姓名',
|
||||
}) as AppError,
|
||||
)
|
||||
|
||||
expect(mockFindByEmployeeIdentity).not.toHaveBeenCalled()
|
||||
expect(mockResolveDepartmentIdByExternalId).not.toHaveBeenCalled()
|
||||
expect(mockCreateUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('同一员工身份已注册时返回业务错误,不创建账号', async () => {
|
||||
mockMatchByJobNumber.mockResolvedValue({
|
||||
id: 'emp-1',
|
||||
jobNumber: '1001',
|
||||
name: '张三',
|
||||
mobile: '13800000000',
|
||||
sourceStatus: 'EMPLOYED',
|
||||
dingtalkUserId: 'dt-1',
|
||||
dingtalkUnionId: 'union-1',
|
||||
primaryDepartmentExternalId: 'dept-ext-1',
|
||||
})
|
||||
mockFindByEmployeeIdentity.mockResolvedValue({
|
||||
id: 'user-existing',
|
||||
email: 'existing@example.com',
|
||||
name: '张三',
|
||||
role: 'USER',
|
||||
employeeDirectoryId: 'emp-1',
|
||||
})
|
||||
|
||||
await expect(POST(createRequest({
|
||||
email: 'user@example.com',
|
||||
name: '张三',
|
||||
jobNumber: '1001',
|
||||
password: '123456',
|
||||
}))).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
message: '该员工已注册账号,请直接登录或联系管理员',
|
||||
}) as AppError,
|
||||
)
|
||||
|
||||
expect(mockResolveDepartmentIdByExternalId).not.toHaveBeenCalled()
|
||||
expect(mockCreateUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('工号未匹配时拒绝注册,不创建账号', async () => {
|
||||
mockMatchByJobNumber.mockResolvedValue(null)
|
||||
|
||||
|
||||
@ -11,6 +11,8 @@ import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { userService } from '@/server/services/user.service'
|
||||
import { logService } from '@/server/services/log.service'
|
||||
import { externalEmployeeDirectoryService } from '@/server/services/external-employee-directory.service'
|
||||
import { departmentService } from '@/server/services/department.service'
|
||||
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference'
|
||||
|
||||
/**
|
||||
* 注册请求体
|
||||
@ -28,6 +30,10 @@ interface RegisterRequest {
|
||||
// 注册允许的默认角色(防止权限提升)
|
||||
const REGISTER_DEFAULT_ROLE = 'USER' as const
|
||||
|
||||
function normalizePersonName(name: string | null | undefined): string {
|
||||
return String(name ?? '').trim()
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
@ -43,17 +49,43 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const submittedName = normalizePersonName(name)
|
||||
const employeeName = normalizePersonName(employee.name)
|
||||
if (submittedName !== employeeName) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'姓名与员工库不一致,请核对工号和姓名'
|
||||
)
|
||||
}
|
||||
|
||||
const existingEmployeeUser = await userService.findByEmployeeIdentity({
|
||||
employeeDirectoryId: employee.id,
|
||||
dingtalkUserId: employee.dingtalkUserId,
|
||||
dingtalkUnionId: employee.dingtalkUnionId,
|
||||
})
|
||||
if (existingEmployeeUser) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'该员工已注册账号,请直接登录或联系管理员'
|
||||
)
|
||||
}
|
||||
|
||||
const departmentId = await externalEmployeeDirectoryService.resolveDepartmentIdByExternalId(
|
||||
employee.primaryDepartmentExternalId
|
||||
)
|
||||
const department = departmentId
|
||||
? await departmentService.findById(departmentId).catch(() => null)
|
||||
: null
|
||||
const businessRoles = inferBusinessRolesFromDepartmentPath(department?.fullPath).businessRoles
|
||||
|
||||
// 调用服务层创建用户
|
||||
// 强制使用 USER 角色,忽略用户传入的 role 参数,防止权限提升攻击
|
||||
const user = await userService.create({
|
||||
email,
|
||||
name,
|
||||
name: employeeName,
|
||||
password,
|
||||
role: REGISTER_DEFAULT_ROLE,
|
||||
businessRoles,
|
||||
status: 'PENDING',
|
||||
departmentId: departmentId ?? undefined,
|
||||
isFirstLogin: false,
|
||||
|
||||
@ -51,6 +51,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
||||
request,
|
||||
async (ctx) => {
|
||||
const designers = await userService.listAll({
|
||||
role: "DESIGNER",
|
||||
businessRole: "DESIGN",
|
||||
status: "ACTIVE",
|
||||
});
|
||||
|
||||
|
||||
@ -3,9 +3,30 @@ export {}
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockJsonSuccess = jest.fn()
|
||||
const mockJsonError = jest.fn()
|
||||
const mockResolveUploadFileForPreview = jest.fn()
|
||||
const mockSavePresentationPreview = jest.fn()
|
||||
const mockExecFile = jest.fn()
|
||||
const mockExistsSync = jest.fn()
|
||||
const mockCopyFile = jest.fn()
|
||||
const mockMkdir = jest.fn()
|
||||
const mockMkdtemp = jest.fn()
|
||||
const mockRm = jest.fn()
|
||||
const mockStat = jest.fn()
|
||||
const mockResolveNonIntegratedAssetFilePath = jest.fn()
|
||||
const mockBuildNonIntegratedPresentationPreviewUrl = jest.fn(
|
||||
() => '/non-integrated/presentation-previews/cache.pdf',
|
||||
)
|
||||
const mockGetNonIntegratedAssetDirectory = jest.fn(() => 'D:/mock/non-integrated')
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
}))
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
copyFile: (...args: unknown[]) => mockCopyFile(...args),
|
||||
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
||||
mkdtemp: (...args: unknown[]) => mockMkdtemp(...args),
|
||||
rm: (...args: unknown[]) => mockRm(...args),
|
||||
stat: (...args: unknown[]) => mockStat(...args),
|
||||
}))
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
execFile: (...args: unknown[]) => mockExecFile(...args),
|
||||
@ -17,18 +38,16 @@ jest.mock('@/server/core/response', () => ({
|
||||
jsonError: (...args: unknown[]) => mockJsonError(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services', () => ({
|
||||
intakeNonIntegratedProductService: {
|
||||
resolveUploadFileForPreview: (...args: unknown[]) => mockResolveUploadFileForPreview(...args),
|
||||
savePresentationPreview: (...args: unknown[]) => mockSavePresentationPreview(...args),
|
||||
},
|
||||
jest.mock('@/app/intake/non-integrated/nonIntegratedAssetServer', () => ({
|
||||
buildNonIntegratedPresentationPreviewUrl: (...args: unknown[]) =>
|
||||
mockBuildNonIntegratedPresentationPreviewUrl(...args),
|
||||
getNonIntegratedAssetDirectory: (...args: unknown[]) => mockGetNonIntegratedAssetDirectory(...args),
|
||||
resolveNonIntegratedAssetFilePath: (...args: unknown[]) =>
|
||||
mockResolveNonIntegratedAssetFilePath(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
doesS3ObjectExist: jest.fn(),
|
||||
getStorageFilePathPrefix: jest.fn(() => ''),
|
||||
getStorageInternalEndpoint: jest.fn(() => 'http://127.0.0.1:9000'),
|
||||
uploadBufferToS3: jest.fn(),
|
||||
jest.mock('@/app/intake/non-integrated/nonIntegratedAssetUrl', () => ({
|
||||
NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY: 'presentation-previews',
|
||||
}))
|
||||
|
||||
const { POST } = require('./route')
|
||||
@ -55,7 +74,8 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
|
||||
id: 'designer-1',
|
||||
email: 'designer@example.com',
|
||||
name: '设计师',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
@ -74,18 +94,21 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
|
||||
status,
|
||||
requestId,
|
||||
}))
|
||||
|
||||
mockGetNonIntegratedAssetDirectory.mockReturnValue('D:/mock/non-integrated')
|
||||
mockResolveNonIntegratedAssetFilePath.mockReturnValue('D:/mock/non-integrated/uploads/slides.pptx')
|
||||
mockBuildNonIntegratedPresentationPreviewUrl.mockReturnValue('/non-integrated/presentation-previews/cache.pdf')
|
||||
mockExistsSync.mockImplementation((filePath: string) =>
|
||||
filePath === 'D:/mock/non-integrated/uploads/slides.pptx',
|
||||
)
|
||||
mockStat.mockResolvedValue({ size: 1024, mtimeMs: 1770000000000 })
|
||||
mockMkdtemp.mockResolvedValue('D:/temp/non-integrated-presentation-preview-1')
|
||||
mockCopyFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('没有 soffice 时应优雅降级,不写回静态目录或共享预览记录', async () => {
|
||||
mockResolveUploadFileForPreview.mockResolvedValue({
|
||||
file: {
|
||||
id: 'file-1',
|
||||
name: 'slides.pptx',
|
||||
dataUrl: 'https://s3.example.com/file-1.pptx',
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
storageKey: 'intake/non-integrated/product-1/uploads/file-1.pptx',
|
||||
},
|
||||
})
|
||||
it('没有 soffice 时应返回受控错误,不写回缓存预览文件', async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_command: unknown, _args: unknown, _options: unknown, callback: (error: Error) => void) =>
|
||||
callback(new Error('soffice not found')),
|
||||
@ -93,26 +116,32 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
productId: 'product-1',
|
||||
fileId: 'file-1',
|
||||
fileUrl: '/intake/non-integrated/uploads/slides.pptx',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mockResolveUploadFileForPreview).toHaveBeenCalledWith(
|
||||
'product-1',
|
||||
{ fileId: 'file-1', fileUrl: '' },
|
||||
expect.objectContaining({ id: 'designer-1' }),
|
||||
expect(mockResolveNonIntegratedAssetFilePath).toHaveBeenCalledWith(
|
||||
'/intake/non-integrated/uploads/slides.pptx',
|
||||
)
|
||||
expect(mockCopyFile).toHaveBeenCalledWith(
|
||||
'D:/mock/non-integrated/uploads/slides.pptx',
|
||||
expect.stringContaining('source.pptx'),
|
||||
)
|
||||
expect(mockCopyFile).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('source.pdf'),
|
||||
expect.stringContaining('presentation-previews'),
|
||||
)
|
||||
expect(mockRm).toHaveBeenCalledWith(
|
||||
'D:/temp/non-integrated-presentation-preview-1',
|
||||
{ recursive: true, force: true },
|
||||
)
|
||||
expect(mockSavePresentationPreview).not.toHaveBeenCalled()
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: {
|
||||
supported: false,
|
||||
pdfUrl: null,
|
||||
cached: false,
|
||||
reason: expect.stringContaining('soffice'),
|
||||
success: false,
|
||||
status: 500,
|
||||
error: {
|
||||
code: 'FILE_001',
|
||||
message: expect.stringContaining('soffice not found'),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@ -31,7 +31,8 @@ describe('pre-sku packaging material-options route', () => {
|
||||
id: 'user-1',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -30,7 +30,8 @@ describe('pre-sku packaging preview route', () => {
|
||||
id: 'user-1',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -32,7 +32,8 @@ describe('pre-sku packaging route', () => {
|
||||
id: 'user-1',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -56,6 +56,6 @@ export async function PUT(
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -34,7 +34,8 @@ describe('POST /api/products/[id]/assembly/generate-from-order', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
|
||||
@ -23,7 +23,8 @@ describe('GET /api/products/[id]/assembly/pdf', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,8 @@ describe('POST /api/products/[id]/assembly/versions/[versionId]/restore', () =>
|
||||
id: 'user-1',
|
||||
email: 'editor@test.com',
|
||||
name: '编辑协作者',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -47,7 +47,8 @@ describe('GET/POST /api/products/[id]/assembly/versions', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@test.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -65,13 +65,15 @@ describe('POST /api/users/[id]/activate', () => {
|
||||
mockActivateUser.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
status: 'ACTIVE',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: null,
|
||||
teamId: 'team-1',
|
||||
teamId: null,
|
||||
})
|
||||
|
||||
const response = await POST(createRequest({
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: null,
|
||||
teamId: 'team-1',
|
||||
}), {
|
||||
@ -81,9 +83,10 @@ describe('POST /api/users/[id]/activate', () => {
|
||||
expect(mockActivateUser).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
{
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: null,
|
||||
teamId: 'team-1',
|
||||
teamId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
id: 'admin-1',
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import type { Role } from '@prisma/client'
|
||||
import { AppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { userManagementService } from '@/server/services/user-management.service'
|
||||
import { normalizeBusinessRoles } from '@/lib/business-account-types'
|
||||
import {
|
||||
parseBusinessRolesInput,
|
||||
parseUserRoleInput,
|
||||
} from '@/server/validators/business-role.validator'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@ -12,17 +14,16 @@ export async function POST(
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const { id } = await params
|
||||
const body = await request.json().catch(() => ({} as Record<string, unknown>))
|
||||
const role = typeof body.role === 'string' ? (body.role as Role) : undefined
|
||||
const businessRoles = Array.isArray(body.businessRoles)
|
||||
? normalizeBusinessRoles(body.businessRoles)
|
||||
const role = parseUserRoleInput(body.role)
|
||||
const businessRoles = 'businessRoles' in body
|
||||
? parseBusinessRolesInput(body.businessRoles, { optional: true })
|
||||
: undefined
|
||||
const departmentId = typeof body.departmentId === 'string' ? body.departmentId : body.departmentId === null ? null : undefined
|
||||
const teamId = typeof body.teamId === 'string' ? body.teamId : body.teamId === null ? null : undefined
|
||||
|
||||
try {
|
||||
const user = await userManagementService.activateUser(
|
||||
id,
|
||||
{ role, businessRoles, departmentId, teamId },
|
||||
{ role, businessRoles, departmentId, teamId: null },
|
||||
ctx.user
|
||||
)
|
||||
return jsonSuccess({ message: '用户激活成功', user }, 200, ctx.requestId)
|
||||
|
||||
@ -69,7 +69,8 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
|
||||
mockGetUserDetail.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
})
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
|
||||
@ -10,8 +10,11 @@ import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
|
||||
import { AppError } from '@/server/core/errors'
|
||||
import { userManagementService } from '@/server/services/user-management.service'
|
||||
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
|
||||
import { normalizeBusinessRoles } from '@/lib/business-account-types'
|
||||
import type { BusinessAccountType, UserStatus } from '@prisma/client'
|
||||
import {
|
||||
parseBusinessRolesInput,
|
||||
parseUserRoleInput,
|
||||
} from '@/server/validators/business-role.validator'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@ -38,24 +41,25 @@ export async function PUT(
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { email, name, password, role, departmentId, teamId, phone, status, accountTemplateId } = body as {
|
||||
const { email, name, password, departmentId, phone, status, accountTemplateId } = body as {
|
||||
email?: string
|
||||
name?: string
|
||||
password?: string
|
||||
role?: Role
|
||||
businessRoles?: BusinessAccountType[]
|
||||
departmentId?: string | null
|
||||
teamId?: string | null
|
||||
phone?: string | null
|
||||
status?: UserStatus
|
||||
accountTemplateId?: string | null
|
||||
}
|
||||
const businessRoles = 'businessRoles' in body ? normalizeBusinessRoles(body.businessRoles) : undefined
|
||||
const role = parseUserRoleInput(body.role)
|
||||
const businessRoles = 'businessRoles' in body
|
||||
? parseBusinessRolesInput(body.businessRoles, { optional: true })
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const updatedUser = await userManagementService.updateUser(
|
||||
id,
|
||||
{ email, name, password, role, businessRoles, departmentId, teamId, phone, status, accountTemplateId },
|
||||
{ email, name, password, role, businessRoles, departmentId, teamId: null, phone, status, accountTemplateId },
|
||||
ctx.user
|
||||
)
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export {}
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { AppError } from '@/server/core/errors'
|
||||
|
||||
const mockHandleRequest: any = jest.fn()
|
||||
const mockJsonSuccess: any = jest.fn()
|
||||
@ -30,18 +31,29 @@ describe('GET /api/users/organization-tree', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
|
||||
handler({
|
||||
requestId: 'req-tree',
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
name: '管理员',
|
||||
role: 'ADMIN',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
)
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => {
|
||||
try {
|
||||
return await handler({
|
||||
requestId: 'req-tree',
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
name: '管理员',
|
||||
role: 'ADMIN',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return {
|
||||
success: false,
|
||||
status: error.statusCode,
|
||||
error: { message: error.message },
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
|
||||
success: true,
|
||||
@ -74,13 +86,14 @@ describe('GET /api/users/organization-tree', () => {
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createGetRequest('http://localhost:3000/api/users/organization-tree?search=alice&role=DESIGNER&status=ACTIVE'),
|
||||
createGetRequest('http://localhost:3000/api/users/organization-tree?search=alice&role=USER&businessRole=DESIGN&status=ACTIVE'),
|
||||
)
|
||||
|
||||
expect(mockGetOrganizationTree).toHaveBeenCalledWith(
|
||||
{
|
||||
search: 'alice',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRole: 'DESIGN',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
expect.objectContaining({
|
||||
@ -103,4 +116,33 @@ describe('GET /api/users/organization-tree', () => {
|
||||
)
|
||||
expect(response.data.departments[0]?.teams).toEqual(expect.any(Array))
|
||||
})
|
||||
|
||||
it('拒绝旧 DESIGNER 筛选口径', async () => {
|
||||
const roleResponse = await GET(
|
||||
createGetRequest('http://localhost:3000/api/users/organization-tree?role=DESIGNER'),
|
||||
)
|
||||
const businessRoleResponse = await GET(
|
||||
createGetRequest('http://localhost:3000/api/users/organization-tree?businessRole=DESIGNER'),
|
||||
)
|
||||
|
||||
expect(roleResponse).toEqual(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
status: 400,
|
||||
error: expect.objectContaining({
|
||||
message: '设计师已迁移为业务类型:设计',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(businessRoleResponse).toEqual(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
status: 400,
|
||||
error: expect.objectContaining({
|
||||
message: '设计师已迁移为业务类型:设计',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockGetOrganizationTree).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,8 +7,12 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { userManagementService } from '@/server/services/user-management.service'
|
||||
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
|
||||
import type { UserStatus } from '@prisma/client'
|
||||
import type { AllowedRole } from '@/lib/role-scope'
|
||||
import {
|
||||
parseBusinessRoleFilter,
|
||||
parseUserRoleInput,
|
||||
} from '@/server/validators/business-role.validator'
|
||||
|
||||
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
|
||||
|
||||
@ -18,8 +22,8 @@ export async function GET(request: NextRequest) {
|
||||
async (ctx) => {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const search = searchParams.get('search') || undefined
|
||||
const role = (searchParams.get('role') as Role | null) || undefined
|
||||
const businessRole = (searchParams.get('businessRole') as BusinessAccountType | null) || undefined
|
||||
const role = parseUserRoleInput(searchParams.get('role'))
|
||||
const businessRole = parseBusinessRoleFilter(searchParams.get('businessRole'))
|
||||
const status = (searchParams.get('status') as UserStatus | null) || undefined
|
||||
|
||||
const tree = await userManagementService.getOrganizationTree(
|
||||
|
||||
@ -92,7 +92,7 @@ describe('GET/POST /api/users', () => {
|
||||
|
||||
const response = await GET(
|
||||
createGetRequest(
|
||||
'http://localhost:3000/api/users?page=2&limit=10&search=alice&role=DESIGNER&status=PENDING&departmentId=dept-9',
|
||||
'http://localhost:3000/api/users?page=2&limit=10&search=alice&role=USER&businessRole=DESIGN&status=PENDING&departmentId=dept-9',
|
||||
),
|
||||
)
|
||||
|
||||
@ -101,7 +101,8 @@ describe('GET/POST /api/users', () => {
|
||||
page: 2,
|
||||
limit: 10,
|
||||
search: 'alice',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRole: 'DESIGN',
|
||||
status: 'PENDING',
|
||||
departmentId: 'dept-9',
|
||||
},
|
||||
@ -132,7 +133,8 @@ describe('GET/POST /api/users', () => {
|
||||
id: 'user-2',
|
||||
email: 'designer@example.com',
|
||||
name: '设计师',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
|
||||
@ -141,7 +143,8 @@ describe('GET/POST /api/users', () => {
|
||||
email: 'designer@example.com',
|
||||
name: '设计师',
|
||||
password: '123456',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: 'dept-2',
|
||||
teamId: 'team-3',
|
||||
phone: '13800000000',
|
||||
@ -149,19 +152,20 @@ describe('GET/POST /api/users', () => {
|
||||
)
|
||||
|
||||
expect(mockCreateUser).toHaveBeenCalledWith(
|
||||
{
|
||||
expect.objectContaining({
|
||||
email: 'designer@example.com',
|
||||
name: '设计师',
|
||||
password: '123456',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: 'dept-2',
|
||||
teamId: 'team-3',
|
||||
phone: '13800000000',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'admin-1',
|
||||
}),
|
||||
)
|
||||
expect(mockCreateUser.mock.calls[0][0]).not.toHaveProperty('teamId')
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
|
||||
@ -9,9 +9,13 @@ import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess, jsonSuccessWithPagination, jsonError } from '@/server/core/response'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { userManagementService } from '@/server/services/user-management.service'
|
||||
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
|
||||
import type { UserStatus } from '@prisma/client'
|
||||
import type { AllowedRole } from '@/lib/role-scope'
|
||||
import { normalizeBusinessRoles } from '@/lib/business-account-types'
|
||||
import {
|
||||
parseBusinessRoleFilter,
|
||||
parseBusinessRolesInput,
|
||||
parseUserRoleInput,
|
||||
} from '@/server/validators/business-role.validator'
|
||||
|
||||
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
|
||||
|
||||
@ -23,9 +27,9 @@ export async function GET(request: NextRequest) {
|
||||
const page = parseInt(searchParams.get('page') || '1', 10)
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
||||
const search = searchParams.get('search') || undefined
|
||||
const role = searchParams.get('role') as Role | undefined
|
||||
const role = parseUserRoleInput(searchParams.get('role'))
|
||||
const status = searchParams.get('status') as UserStatus | undefined
|
||||
const businessRole = (searchParams.get('businessRole') as BusinessAccountType | null) || undefined
|
||||
const businessRole = parseBusinessRoleFilter(searchParams.get('businessRole'))
|
||||
const departmentId = searchParams.get('departmentId') || undefined
|
||||
|
||||
const result = await userManagementService.listUsers(
|
||||
@ -52,9 +56,12 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const body = await request.json()
|
||||
const { email, name, password, role, departmentId, teamId, phone, accountTemplateId } = body
|
||||
const { email, name, password, departmentId, phone, accountTemplateId } = body
|
||||
const role = parseUserRoleInput(body.role)
|
||||
const jobNumber = typeof body.jobNumber === 'string' ? body.jobNumber : undefined
|
||||
const businessRoles = 'businessRoles' in body ? normalizeBusinessRoles(body.businessRoles) : undefined
|
||||
const businessRoles = 'businessRoles' in body
|
||||
? parseBusinessRolesInput(body.businessRoles, { optional: true })
|
||||
: undefined
|
||||
|
||||
if (!email || !name || !password) {
|
||||
return jsonError(
|
||||
@ -66,7 +73,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const createInput = { email, name, password, role, departmentId, teamId, phone, accountTemplateId }
|
||||
const createInput = { email, name, password, role, departmentId, phone, accountTemplateId }
|
||||
const normalizedCreateInput = {
|
||||
...createInput,
|
||||
...(businessRoles ? { businessRoles } : {}),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
|
||||
import { hasBusinessRole, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { jsonError } from '@/server/core/response'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
@ -7,11 +7,15 @@ export const WAREHOUSE_OPERATOR_ROLES = [
|
||||
'SUPER_ADMIN',
|
||||
'ADMIN',
|
||||
'SUPERVISOR',
|
||||
'DESIGNER',
|
||||
'USER',
|
||||
] as const
|
||||
|
||||
export function hasWarehouseManagedAccess(user: AuthUser): boolean {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN' || user.role === 'DESIGNER') {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (hasBusinessRole(user, 'WAREHOUSE')) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ describe('warehouse API access control', () => {
|
||||
email: 'user@example.com',
|
||||
name: '仓库用户',
|
||||
role: 'ADMIN',
|
||||
businessRoles: [],
|
||||
status: 'ACTIVE',
|
||||
departmentId: null,
|
||||
teamId: null,
|
||||
@ -79,7 +80,7 @@ describe('warehouse API access control', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('库位列表路由使用当前 5 级角色口径', async () => {
|
||||
it('库位列表路由使用权限角色加仓库业务类型口径', async () => {
|
||||
mockListLocations.mockResolvedValue([])
|
||||
|
||||
await getLocations(createRequest('http://localhost/api/warehouse/locations'))
|
||||
@ -89,7 +90,7 @@ describe('warehouse API access control', () => {
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER'],
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
@ -138,7 +139,7 @@ describe('warehouse API access control', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('配件库位绑定路由也使用当前 5 级角色口径', async () => {
|
||||
it('配件库位绑定路由也使用权限角色加仓库业务类型口径', async () => {
|
||||
mockBindVariantToLocations.mockResolvedValue([])
|
||||
|
||||
await postBindings(
|
||||
@ -153,7 +154,7 @@ describe('warehouse API access control', () => {
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER'],
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
@ -181,6 +182,38 @@ describe('warehouse API access control', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('仓库业务类型用户可以做配件库位绑定', async () => {
|
||||
currentUser = {
|
||||
...currentUser,
|
||||
role: 'USER',
|
||||
businessRoles: ['WAREHOUSE'],
|
||||
}
|
||||
mockBindVariantToLocations.mockResolvedValue([
|
||||
{
|
||||
id: 'binding-warehouse',
|
||||
assetVariantId: 'variant-1',
|
||||
warehouseLocation: {
|
||||
id: 'loc-1',
|
||||
code: 'A-01',
|
||||
name: '成品区 A01',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const response = await postBindings(
|
||||
createRequest('http://localhost/api/warehouse/bindings', {
|
||||
variantId: 'variant-1',
|
||||
locationIds: ['loc-1'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mockBindVariantToLocations).toHaveBeenCalledWith({
|
||||
variantId: 'variant-1',
|
||||
locationIds: ['loc-1'],
|
||||
})
|
||||
expect(response).toEqual(expect.objectContaining({ success: true, status: 200 }))
|
||||
})
|
||||
|
||||
it('带小组锚点的主管可以做配件库位绑定', async () => {
|
||||
currentUser = {
|
||||
...currentUser,
|
||||
|
||||
@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
|
||||
import { AlertCircle, Clock3, FileStack, Lock, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { AuthProvider, useAuth } from '@/hooks/useAuth'
|
||||
import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types'
|
||||
import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
|
||||
|
||||
type FieldMatrixItem = {
|
||||
key: string
|
||||
@ -59,6 +59,8 @@ const STATUS_LABELS: Record<ApprovalWorkflowStatus, string> = {
|
||||
ARCHIVED: '已归档',
|
||||
}
|
||||
|
||||
const APPROVAL_BUSINESS_ROLE_ORDER = BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value)
|
||||
|
||||
export function ApprovalWorkflowPageClient({ accessKey }: { accessKey: string }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
@ -112,7 +114,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
|
||||
const groupedFields = useMemo(() => {
|
||||
const groups = new Map<BusinessAccountType, FieldMatrixItem[]>()
|
||||
for (const role of ['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]) {
|
||||
for (const role of APPROVAL_BUSINESS_ROLE_ORDER) {
|
||||
groups.set(role, [])
|
||||
}
|
||||
for (const item of workflow?.fieldMatrix || []) {
|
||||
@ -131,7 +133,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
|
||||
const groupedFieldSections = useMemo(() => {
|
||||
const sections = new Map<BusinessAccountType, Array<{ title: string; fields: FieldMatrixItem[] }>>()
|
||||
for (const role of ['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]) {
|
||||
for (const role of APPROVAL_BUSINESS_ROLE_ORDER) {
|
||||
const roleFields = groupedFields.get(role) || []
|
||||
const bySection = new Map<string, FieldMatrixItem[]>()
|
||||
for (const field of roleFields) {
|
||||
@ -254,7 +256,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 xl:grid-cols-2">
|
||||
{(['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]).map((role) => {
|
||||
{APPROVAL_BUSINESS_ROLE_ORDER.map((role) => {
|
||||
const sections = groupedFieldSections.get(role) || []
|
||||
if (sections.length === 0) return null
|
||||
return (
|
||||
@ -278,7 +280,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]).map((role) => {
|
||||
{APPROVAL_BUSINESS_ROLE_ORDER.map((role) => {
|
||||
const sections = groupedFieldSections.get(role) || []
|
||||
if (sections.length === 0) return null
|
||||
const mine = myBusinessRoles.includes(role)
|
||||
@ -316,7 +318,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{section.fields.map((field) => {
|
||||
const readOnly = !mine && user?.role !== 'SUPER_ADMIN' && user?.role !== 'ADMIN'
|
||||
const readOnly = !mine
|
||||
return (
|
||||
<div key={field.key} className="rounded-[6px] bg-white px-4 py-4 shadow-[inset_0_0_0_1px_rgba(221,229,229,0.45)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@ -82,7 +82,6 @@ export async function createApprovalWorkflow(input: {
|
||||
approvalNumber: string
|
||||
title: string
|
||||
productId: string
|
||||
businessRoles: BusinessAccountType[]
|
||||
}) {
|
||||
const response = await fetch('/api/approval-workflows', {
|
||||
method: 'POST',
|
||||
|
||||
@ -1817,7 +1817,8 @@ describe('IntakeClient', () => {
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'DESIGNER',
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
userName: 'Designer A',
|
||||
})
|
||||
|
||||
@ -1869,7 +1870,8 @@ describe('IntakeClient', () => {
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'DESIGNER',
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
userName: 'Designer B',
|
||||
})
|
||||
|
||||
@ -1896,7 +1898,8 @@ describe('IntakeClient', () => {
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'DESIGNER',
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
userName: 'Structure A',
|
||||
})
|
||||
|
||||
@ -1925,7 +1928,8 @@ describe('IntakeClient', () => {
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'DESIGNER',
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
userId: 'creator-user-id',
|
||||
userName: 'Creator',
|
||||
})
|
||||
@ -1959,7 +1963,8 @@ describe('IntakeClient', () => {
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'DESIGNER',
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
userName: '财神',
|
||||
})
|
||||
|
||||
|
||||
@ -200,10 +200,10 @@ function canEditNonIntegratedProduct(
|
||||
product.openingReference?.structureDesigner,
|
||||
]);
|
||||
|
||||
return (
|
||||
user.role === "DESIGNER" &&
|
||||
return Boolean(
|
||||
user.businessRoles?.includes("DESIGN") &&
|
||||
userName.length > 0 &&
|
||||
designerNames.includes(userName)
|
||||
designerNames.includes(userName),
|
||||
);
|
||||
}
|
||||
|
||||
@ -4408,7 +4408,7 @@ const PATENT_FEE_STATUS_OPTIONS: ProductPatentFeeStatus[] = [
|
||||
"不缴",
|
||||
];
|
||||
const PATENT_SA_REQUIRED_OPTIONS: ProductPatentSaRequired[] = ["否", "是"];
|
||||
const PATENT_DESIGNER_MANAGER_ROLES = new Set([
|
||||
const PATENT_OWNER_MANAGER_ROLES = new Set([
|
||||
"SUPER_ADMIN",
|
||||
"ADMIN",
|
||||
"SUPERVISOR",
|
||||
@ -6002,7 +6002,7 @@ export function ProductPatentDashboard({
|
||||
const sortedOptions = Array.from(options).sort((a, b) =>
|
||||
a.localeCompare(b, "zh-CN"),
|
||||
);
|
||||
if (PATENT_DESIGNER_MANAGER_ROLES.has(user.role)) {
|
||||
if (PATENT_OWNER_MANAGER_ROLES.has(user.role)) {
|
||||
return sortedOptions;
|
||||
}
|
||||
|
||||
|
||||
@ -447,7 +447,8 @@ describe("NonIntegratedDetailClient", () => {
|
||||
const { container } = render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="create"
|
||||
currentUserRole="DESIGNER"
|
||||
currentUserRole="USER"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
currentUserId="creator-user-id"
|
||||
currentUserName="Creator"
|
||||
/>,
|
||||
@ -496,7 +497,6 @@ describe("NonIntegratedDetailClient", () => {
|
||||
productId: savedProducts[0]?.id,
|
||||
approvalNumber: "PD20260505175418001",
|
||||
title: "测试开品",
|
||||
businessRoles: [],
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
`/intake/non-integrated/${encodeURIComponent(
|
||||
@ -682,6 +682,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="DNZ0001-bom-barcode-migration"
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -823,6 +824,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="DNZ0001-bom-same-style-copy"
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -878,6 +880,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
productId={product.id}
|
||||
initialProduct={product}
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -898,6 +901,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
productId={product.id}
|
||||
initialProduct={product}
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -1733,7 +1737,8 @@ describe("NonIntegratedDetailClient", () => {
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="SZ0230-structure-designer-edit"
|
||||
currentUserRole="DESIGNER"
|
||||
currentUserRole="USER"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
currentUserName="结构A"
|
||||
/>,
|
||||
);
|
||||
@ -1780,7 +1785,8 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="SZ0230-creator-edit"
|
||||
initialDocumentWorkspaceCategory={openingReferenceCategory}
|
||||
currentUserRole="DESIGNER"
|
||||
currentUserRole="USER"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
currentUserId="creator-user-id"
|
||||
currentUserName="Creator"
|
||||
/>,
|
||||
@ -2233,6 +2239,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="SZ0230-basic-material-save"
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -2258,6 +2265,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="SZ0230-basic-material-save"
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -2301,6 +2309,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="SZ0230-basic-material-reconfirm"
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -2503,7 +2512,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
initialDocumentWorkspaceCategory={openingReferenceCategory}
|
||||
currentUserRole="USER"
|
||||
currentUserName="企划同事"
|
||||
currentUserDepartmentName="品牌中心"
|
||||
currentUserBusinessRoles={["PLANNING"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -2575,6 +2584,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
initialDocumentWorkspaceCategory={openingReferenceCategory}
|
||||
currentUserRole="SUPERVISOR"
|
||||
currentUserName="主管同事"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -3447,6 +3457,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
mode="edit"
|
||||
productId="DNZ0001-sku-independent-confirmation"
|
||||
currentUserRole="SUPER_ADMIN"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@ -741,6 +741,12 @@ const NON_INTEGRATED_FULL_DETAIL_EDIT_ROLES = new Set([
|
||||
"SUPER_ADMIN",
|
||||
"SUPERVISOR",
|
||||
]);
|
||||
const INTERNAL_CONTENT_BUSINESS_ROLES = new Set([
|
||||
"DESIGN",
|
||||
"PLANNING",
|
||||
"PURCHASING",
|
||||
"WAREHOUSE",
|
||||
]);
|
||||
const PURCHASING_EDITABLE_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>([
|
||||
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
@ -9165,8 +9171,14 @@ export default function NonIntegratedDetailClient({
|
||||
const hasCurrentUserContext = Boolean(currentUserRole);
|
||||
const isCurrentUserAdmin =
|
||||
currentUserRole === "ADMIN" || currentUserRole === "SUPER_ADMIN";
|
||||
const hasCurrentUserInternalBusinessRole = Boolean(
|
||||
currentUserBusinessRoles?.some((role) =>
|
||||
INTERNAL_CONTENT_BUSINESS_ROLES.has(role),
|
||||
),
|
||||
);
|
||||
const isCurrentUserFullDetailEditor = currentUserRole
|
||||
? NON_INTEGRATED_FULL_DETAIL_EDIT_ROLES.has(currentUserRole)
|
||||
? NON_INTEGRATED_FULL_DETAIL_EDIT_ROLES.has(currentUserRole) &&
|
||||
hasCurrentUserInternalBusinessRole
|
||||
: false;
|
||||
const currentProductDesignerNames = useMemo(
|
||||
() =>
|
||||
@ -9184,8 +9196,11 @@ export default function NonIntegratedDetailClient({
|
||||
const currentUserNameNormalized = currentUserName?.trim() ?? "";
|
||||
const currentUserIdNormalized = currentUserId?.trim() ?? "";
|
||||
const currentProductCreator = existingProduct?.createdBy?.trim() ?? "";
|
||||
const isCurrentUserDesign = Boolean(
|
||||
currentUserBusinessRoles?.includes("DESIGN"),
|
||||
);
|
||||
const isCurrentProductDesigner = Boolean(
|
||||
currentUserRole === "DESIGNER" &&
|
||||
isCurrentUserDesign &&
|
||||
currentUserNameNormalized &&
|
||||
currentProductDesignerNames.includes(currentUserNameNormalized),
|
||||
);
|
||||
@ -9195,18 +9210,12 @@ export default function NonIntegratedDetailClient({
|
||||
(currentUserNameNormalized &&
|
||||
currentProductCreator === currentUserNameNormalized)),
|
||||
);
|
||||
const isCurrentUserPlanning =
|
||||
currentUserBusinessRoles?.includes("PLANNING") ||
|
||||
Boolean(
|
||||
currentUserDepartmentName?.includes("企划") ||
|
||||
currentUserDepartmentName?.includes("品牌中心"),
|
||||
);
|
||||
const isCurrentUserPurchasing =
|
||||
currentUserBusinessRoles?.includes("PURCHASING") ||
|
||||
Boolean(
|
||||
currentUserDepartmentName?.includes("供应链") ||
|
||||
currentUserDepartmentName?.includes("采购"),
|
||||
);
|
||||
const isCurrentUserPlanning = Boolean(
|
||||
currentUserBusinessRoles?.includes("PLANNING"),
|
||||
);
|
||||
const isCurrentUserPurchasing = Boolean(
|
||||
currentUserBusinessRoles?.includes("PURCHASING"),
|
||||
);
|
||||
const linkEntryProductPatentDashboardUser =
|
||||
useMemo<LinkEntryProductPatentDashboardUser>(
|
||||
() => ({
|
||||
@ -9251,7 +9260,7 @@ export default function NonIntegratedDetailClient({
|
||||
const isReadOnly =
|
||||
mode === "view" || isFlowAbandoned || !canEditActiveWorkspace;
|
||||
const canAbandonProduct =
|
||||
currentUserRole === "DESIGNER" && canEditCurrentProduct;
|
||||
isCurrentUserDesign && canEditCurrentProduct;
|
||||
const createApprovalNumberRef = useRef(
|
||||
mode === "create" ? buildApprovalNumber() : "",
|
||||
);
|
||||
@ -16428,7 +16437,6 @@ export default function NonIntegratedDetailClient({
|
||||
productId: savedProduct.id,
|
||||
approvalNumber: draftTemporaryCode,
|
||||
title: draftProductName,
|
||||
businessRoles: [],
|
||||
});
|
||||
await navigator.clipboard.writeText(
|
||||
new URL(nextProductDetailUrl, window.location.origin).toString(),
|
||||
|
||||
@ -31,7 +31,7 @@ export default async function NonIntegratedDetailPage({
|
||||
const linkEntryView = resolvedSearchParams.workspace === 'opening-reference'
|
||||
const isFullDetailEditor = FULL_DETAIL_EDIT_ROLES.has(authUser.role)
|
||||
const isCurrentProductDesigner =
|
||||
authUser.role === 'DESIGNER' &&
|
||||
authUser.businessRoles?.includes('DESIGN') &&
|
||||
Boolean(authUser.name.trim()) &&
|
||||
initialProduct?.designer.trim() === authUser.name.trim()
|
||||
const isCurrentProductCreator =
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Activity, Building2, CalendarClock, Fingerprint, KeyRound, LogOut, ShieldCheck, UserRound, Users } from 'lucide-react'
|
||||
import { BriefcaseBusiness, Building2, CalendarClock, Fingerprint, KeyRound, LogOut, ShieldCheck, UserRound, Users, type LucideIcon } from 'lucide-react'
|
||||
import { AuthProvider, useAuth } from '@/hooks/useAuth'
|
||||
import { RequireAuth } from '@/components/auth/RequireAuth'
|
||||
import Button from '@/components/ui/Button'
|
||||
@ -13,6 +13,7 @@ import {
|
||||
ObsidianRailButton,
|
||||
ObsidianRailGroup,
|
||||
} from '@/components/platform/ObsidianControlShell'
|
||||
import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types'
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
@ -20,7 +21,6 @@ const roleLabels: Record<string, string> = {
|
||||
SUPERVISOR: '主管',
|
||||
SUPERVISOR_DEPARTMENT: '部门主管',
|
||||
SUPERVISOR_TEAM: '小组主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户',
|
||||
}
|
||||
|
||||
@ -47,6 +47,10 @@ function ProfileContent() {
|
||||
const canAccessAdmin = Boolean(user && ['SUPER_ADMIN', 'ADMIN'].includes(user.role))
|
||||
const roleLabel = user ? roleLabels[user.role] ?? user.role : '-'
|
||||
const statusLabel = user ? statusLabels[user.status] ?? user.status : '-'
|
||||
const businessRoleText = user?.businessRoles?.length
|
||||
? user.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role] ?? role).join(' / ')
|
||||
: '普通用户'
|
||||
const departmentText = user?.department?.fullPath || user?.department?.name || '未归属'
|
||||
const lastLoginText = user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '暂无记录'
|
||||
|
||||
return (
|
||||
@ -82,15 +86,15 @@ function ProfileContent() {
|
||||
</div>
|
||||
}
|
||||
inspector={
|
||||
<div className="flex h-full flex-col bg-[#111719]">
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<ObsidianInspectorSection title="身份摘要">
|
||||
<ObsidianDataRow label="账号状态" value={statusLabel} tone={user?.status === 'ACTIVE' ? 'success' : 'warning'} />
|
||||
<ObsidianDataRow label="权限角色" value={roleLabel} />
|
||||
<ObsidianDataRow label="业务类型" value={businessRoleText} />
|
||||
<ObsidianDataRow label="登录来源" value={user?.loginSource || '系统账号'} />
|
||||
</ObsidianInspectorSection>
|
||||
<ObsidianInspectorSection title="组织归属">
|
||||
<ObsidianDataRow label="部门" value={user?.department?.name || '未归属'} />
|
||||
<ObsidianDataRow label="小组" value={user?.team?.name || '未归属'} />
|
||||
<ObsidianDataRow label="部门" value={departmentText} />
|
||||
</ObsidianInspectorSection>
|
||||
<ObsidianInspectorSection title="安全记录">
|
||||
<ObsidianDataRow label="最后登录" value={lastLoginText} />
|
||||
@ -99,16 +103,16 @@ function ProfileContent() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#0f1416]">
|
||||
<header className="border-b border-white/10 bg-[#101719] px-5 py-4">
|
||||
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#f6f8f8]">
|
||||
<header className="border-b border-zinc-200 bg-white px-5 py-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-tight text-[#edf4f2]">{user?.name || '当前用户'}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-[#8f9c9d]">{user?.email}</p>
|
||||
<h2 className="text-xl font-black tracking-tight text-zinc-950">{user?.name || '当前用户'}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-zinc-500">{user?.email}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-white/10 bg-[#151d1f] px-4 py-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7f8d8e]">角色</p>
|
||||
<p className="mt-1 text-sm font-black text-[#edf4f2]">{roleLabel}</p>
|
||||
<div className="rounded-md border border-zinc-200 bg-[#f9fbfb] px-4 py-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">业务类型</p>
|
||||
<p className="mt-1 text-sm font-black text-zinc-950">{businessRoleText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@ -119,14 +123,13 @@ function ProfileContent() {
|
||||
<div className="grid gap-4 p-5 md:grid-cols-3">
|
||||
<ProfileMetric icon={Fingerprint} label="账号 ID" value={user?.id.slice(0, 8) ?? '-'} />
|
||||
<ProfileMetric icon={ShieldCheck} label="权限角色" value={roleLabel} />
|
||||
<ProfileMetric icon={Activity} label="状态" value={statusLabel} />
|
||||
<ProfileMetric icon={BriefcaseBusiness} label="业务类型" value={businessRoleText} />
|
||||
</div>
|
||||
</ObsidianPanel>
|
||||
|
||||
<ObsidianPanel title="组织坐标">
|
||||
<div className="space-y-3 p-5">
|
||||
<ProfileLine icon={Building2} label="部门" value={user?.department?.name || '未归属'} />
|
||||
<ProfileLine icon={Users} label="小组" value={user?.team?.name || '未归属'} />
|
||||
<ProfileLine icon={Building2} label="部门" value={departmentText} />
|
||||
<ProfileLine icon={CalendarClock} label="最后登录" value={lastLoginText} />
|
||||
</div>
|
||||
</ObsidianPanel>
|
||||
@ -147,23 +150,23 @@ function ProfileContent() {
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileMetric({ icon: Icon, label, value }: { icon: typeof Fingerprint; label: string; value: string }) {
|
||||
function ProfileMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-white/10 bg-[#151d1f] p-4">
|
||||
<Icon className="h-5 w-5 text-[#aeb8bb]" />
|
||||
<p className="mt-4 text-[10px] font-black uppercase tracking-[0.2em] text-[#7f8d8e]">{label}</p>
|
||||
<p className="mt-1 truncate text-lg font-black text-[#edf4f2]">{value}</p>
|
||||
<div className="rounded-md border border-zinc-200 bg-white p-4">
|
||||
<Icon className="h-5 w-5 text-zinc-500" />
|
||||
<p className="mt-4 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">{label}</p>
|
||||
<p className="mt-1 truncate text-lg font-black text-zinc-950">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileLine({ icon: Icon, label, value }: { icon: typeof Building2; label: string; value: string }) {
|
||||
function ProfileLine({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-white/10 bg-[#151d1f] px-3 py-3">
|
||||
<Icon className="h-4 w-4 text-[#7f8d8e]" />
|
||||
<div className="flex items-center gap-3 rounded-md border border-zinc-200 bg-white px-3 py-3">
|
||||
<Icon className="h-4 w-4 text-zinc-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#7f8d8e]">{label}</p>
|
||||
<p className="truncate text-sm font-bold text-[#edf4f2]">{value}</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-zinc-500">{label}</p>
|
||||
<p className="truncate text-sm font-bold text-zinc-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -171,8 +174,8 @@ function ProfileLine({ icon: Icon, label, value }: { icon: typeof Building2; lab
|
||||
|
||||
function ProfileAction({ href, title }: { href: string; title: string }) {
|
||||
return (
|
||||
<Link href={href} className="block rounded-md border border-white/10 bg-[#111719] p-4 transition hover:border-white/20 hover:bg-[#151d1f]">
|
||||
<p className="text-sm font-black text-[#edf4f2]">{title}</p>
|
||||
<Link href={href} className="block rounded-md border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 hover:bg-[#f9fbfb]">
|
||||
<p className="text-sm font-black text-zinc-950">{title}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import { RequireAuth } from '@/components/auth/RequireAuth'
|
||||
import { PermissionGuard } from '@/components/auth/PermissionGuard'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
|
||||
import { ActivateUserModal } from '@/components/users/ActivateUserModal'
|
||||
import { CreateUserModal } from '@/components/users/CreateUserModal'
|
||||
import { EditUserModal } from '@/components/users/EditUserModal'
|
||||
@ -43,7 +44,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'SUPER_ADMIN', label: '超级管理员', icon: KeyRound },
|
||||
{ value: 'ADMIN', label: '管理员', icon: ShieldCheck },
|
||||
{ value: 'SUPERVISOR', label: '主管', icon: Briefcase },
|
||||
{ value: 'DESIGNER', label: '设计师', icon: Fingerprint },
|
||||
{ value: 'USER', label: '普通用户', icon: Users },
|
||||
] as const
|
||||
|
||||
@ -61,7 +61,6 @@ const roleLabels: Record<string, string> = {
|
||||
SUPERVISOR: '主管',
|
||||
SUPERVISOR_DEPARTMENT: '部门主管',
|
||||
SUPERVISOR_TEAM: '小组主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户',
|
||||
}
|
||||
|
||||
@ -97,6 +96,7 @@ function UsersPageContent() {
|
||||
const [editingUser, setEditingUser] = useState<UserTreeItem | null>(null)
|
||||
const [activationUser, setActivationUser] = useState<UserTreeItem | null>(null)
|
||||
const [handoverUser, setHandoverUser] = useState<UserTreeItem | null>(null)
|
||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
@ -156,6 +156,7 @@ function UsersPageContent() {
|
||||
const closeEditModal = useCallback(() => setEditingUser(null), [])
|
||||
const closeActivateModal = useCallback(() => setActivationUser(null), [])
|
||||
const closeHandoverModal = useCallback(() => setHandoverUser(null), [])
|
||||
const closeDeleteDialog = useCallback(() => setDeleteUserId(null), [])
|
||||
|
||||
const handleCreateSuccess = useCallback(() => {
|
||||
closeCreateModal()
|
||||
@ -204,20 +205,31 @@ function UsersPageContent() {
|
||||
}, [])
|
||||
|
||||
const handleDeleteUser = useCallback(
|
||||
async (userId: string) => {
|
||||
(userId: string) => {
|
||||
setActionError(null)
|
||||
setDeleteUserId(userId)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const confirmDeleteUser = useCallback(
|
||||
async () => {
|
||||
if (!deleteUserId) return
|
||||
|
||||
try {
|
||||
setActionError(null)
|
||||
if (!window.confirm('确定要移除此访问权限吗?此操作不可撤销。')) return
|
||||
const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!response.ok) throw new Error('删除用户失败')
|
||||
if (selectedUser?.id === userId) setSelectedUser(null)
|
||||
if (editingUser?.id === userId) setEditingUser(null)
|
||||
const response = await fetch(`/api/users/${deleteUserId}`, { method: 'DELETE', credentials: 'include' })
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload.error?.message || '删除用户失败')
|
||||
if (selectedUser?.id === deleteUserId) setSelectedUser(null)
|
||||
if (editingUser?.id === deleteUserId) setEditingUser(null)
|
||||
triggerRefresh()
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : '删除失败')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[editingUser?.id, selectedUser?.id, triggerRefresh],
|
||||
[deleteUserId, editingUser?.id, selectedUser?.id, triggerRefresh],
|
||||
)
|
||||
|
||||
const handleActivateUser = useCallback(
|
||||
@ -356,8 +368,11 @@ function UsersPageContent() {
|
||||
<InspectorItem icon={Phone} label="联系电话" value={selectedUser.phone || '未配置'} />
|
||||
</ObsidianInspectorSection>
|
||||
<ObsidianInspectorSection title="组织架构">
|
||||
<InspectorItem icon={Briefcase} label="所属部门" value={selectedUser.department?.name || '未归属'} />
|
||||
<InspectorItem icon={Users} label="所属小组" value={selectedUser.team?.name || '未归属'} />
|
||||
<InspectorItem
|
||||
icon={Briefcase}
|
||||
label="所属部门"
|
||||
value={selectedUser.department?.fullPath || selectedUser.department?.name || '未归属'}
|
||||
/>
|
||||
<InspectorItem
|
||||
icon={Briefcase}
|
||||
label="业务类型"
|
||||
@ -479,6 +494,15 @@ function UsersPageContent() {
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
) : null}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={Boolean(deleteUserId)}
|
||||
onClose={closeDeleteDialog}
|
||||
onConfirm={confirmDeleteUser}
|
||||
title="移除访问权限"
|
||||
description="确认移除此账号的访问权限?移除后该账号将不能继续登录,此操作不可撤销。"
|
||||
confirmText="移除"
|
||||
warningItems={['账号访问权限会立即失效', '已产生的操作记录仍会保留用于审计']}
|
||||
/>
|
||||
</ObsidianControlShell>
|
||||
)
|
||||
}
|
||||
|
||||
@ -118,12 +118,6 @@ export function CreatorGuard({
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM', 'DESIGNER']}
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</PermissionGuard>
|
||||
);
|
||||
const { canCreateProduct } = usePermissions();
|
||||
return canCreateProduct() ? <>{children}</> : <>{fallback ?? null}</>;
|
||||
}
|
||||
|
||||
@ -45,12 +45,6 @@ const ROLE_CONFIG: Record<
|
||||
bgColor: 'bg-apple-purple/10',
|
||||
borderColor: 'border-apple-purple/20',
|
||||
},
|
||||
DESIGNER: {
|
||||
label: '设计师',
|
||||
color: 'text-apple-green dark:text-apple-green',
|
||||
bgColor: 'bg-apple-green/10',
|
||||
borderColor: 'border-apple-green/20',
|
||||
},
|
||||
USER: {
|
||||
label: '普通用户',
|
||||
color: 'text-apple-label-secondary dark:text-apple-label-tertiary',
|
||||
|
||||
@ -27,7 +27,6 @@ const roleLabelMap: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPERVISOR: '主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户',
|
||||
}
|
||||
|
||||
|
||||
@ -124,7 +124,6 @@ export default function UserMenu({ user }: UserMenuProps) {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPERVISOR: '主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户',
|
||||
}
|
||||
const isAdmin = resolvedUser.role === 'SUPER_ADMIN' || resolvedUser.role === 'ADMIN'
|
||||
|
||||
@ -6,20 +6,10 @@ import { AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
|
||||
interface DepartmentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
level?: number;
|
||||
children?: DepartmentItem[];
|
||||
}
|
||||
|
||||
interface TeamItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
|
||||
|
||||
interface ActivationUser {
|
||||
id: string;
|
||||
@ -27,7 +17,11 @@ interface ActivationUser {
|
||||
role: string;
|
||||
businessRoles?: BusinessAccountType[];
|
||||
departmentId?: string | null;
|
||||
teamId?: string | null;
|
||||
department?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ActivateUserModalProps {
|
||||
@ -42,10 +36,21 @@ const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPERVISOR: '主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户(只看 / 下载)',
|
||||
};
|
||||
|
||||
function findDepartmentById(
|
||||
departments: DepartmentTreeOption[],
|
||||
departmentId: string,
|
||||
): DepartmentTreeOption | null {
|
||||
for (const department of departments) {
|
||||
if (department.id === departmentId) return department;
|
||||
const child = findDepartmentById(department.children ?? [], departmentId);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ActivateUserModal({
|
||||
isOpen,
|
||||
user,
|
||||
@ -57,12 +62,13 @@ export function ActivateUserModal({
|
||||
role: (user.role as Role) || allowedRoles[0] || 'USER',
|
||||
businessRoles: user.businessRoles || [],
|
||||
departmentId: user.departmentId || '',
|
||||
teamId: user.teamId || '',
|
||||
});
|
||||
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
|
||||
const [teams, setTeams] = useState<TeamItem[]>([]);
|
||||
const [departments, setDepartments] = useState<DepartmentTreeOption[]>([]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [businessRolesTouched, setBusinessRolesTouched] = useState(false);
|
||||
const [emptyBusinessRoleWarningAccepted, setEmptyBusinessRoleWarningAccepted] = useState(false);
|
||||
const [emptyBusinessRoleWarning, setEmptyBusinessRoleWarning] = useState<string | null>(null);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
@ -81,12 +87,48 @@ export function ActivateUserModal({
|
||||
role: nextRole,
|
||||
businessRoles: user.businessRoles || [],
|
||||
departmentId: user.departmentId || '',
|
||||
teamId: user.teamId || '',
|
||||
});
|
||||
setErrors({});
|
||||
setBusinessRolesTouched(false);
|
||||
setEmptyBusinessRoleWarningAccepted(false);
|
||||
setEmptyBusinessRoleWarning(null);
|
||||
}, [allowedRoles, user]);
|
||||
|
||||
const selectedDepartment = useMemo(() => {
|
||||
if (!formData.departmentId) return null;
|
||||
return (
|
||||
findDepartmentById(departments, formData.departmentId) ||
|
||||
(user.department?.id === formData.departmentId
|
||||
? {
|
||||
id: user.department.id,
|
||||
name: user.department.name,
|
||||
fullPath: user.department.fullPath || user.department.name,
|
||||
}
|
||||
: null)
|
||||
);
|
||||
}, [departments, formData.departmentId, user.department]);
|
||||
|
||||
const departmentBusinessRoleInference = useMemo(
|
||||
() => inferBusinessRolesFromDepartmentPath(selectedDepartment?.fullPath),
|
||||
[selectedDepartment?.fullPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (businessRolesTouched || !formData.departmentId) return;
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
businessRoles: departmentBusinessRoleInference.businessRoles,
|
||||
}));
|
||||
}, [
|
||||
businessRolesTouched,
|
||||
departmentBusinessRoleInference.businessRoles,
|
||||
formData.departmentId,
|
||||
]);
|
||||
|
||||
const toggleBusinessRole = (role: BusinessAccountType) => {
|
||||
setBusinessRolesTouched(true);
|
||||
setEmptyBusinessRoleWarningAccepted(false);
|
||||
setEmptyBusinessRoleWarning(null);
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
businessRoles: current.businessRoles.includes(role)
|
||||
@ -103,16 +145,7 @@ export function ActivateUserModal({
|
||||
|
||||
const data = await response.json();
|
||||
const departmentTree = data.data?.departments || data.departments || [];
|
||||
const flattenDepts = (items: DepartmentItem[], level = 0): DepartmentItem[] =>
|
||||
items.reduce<DepartmentItem[]>((acc, item) => {
|
||||
acc.push({ ...item, level });
|
||||
if (item.children?.length) {
|
||||
acc.push(...flattenDepts(item.children, level + 1));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
setDepartments(flattenDepts(departmentTree));
|
||||
setDepartments(departmentTree);
|
||||
} catch (error) {
|
||||
console.error('加载部门列表失败:', error);
|
||||
}
|
||||
@ -123,35 +156,6 @@ export function ActivateUserModal({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTeams = async () => {
|
||||
if (!formData.departmentId) {
|
||||
setTeams([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
setTeams([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTeams(data.data?.teams || data.teams || []);
|
||||
} catch (error) {
|
||||
console.error('加载小组列表失败:', error);
|
||||
setTeams([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
void loadTeams();
|
||||
}
|
||||
}, [formData.departmentId, isOpen]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrors({});
|
||||
@ -161,6 +165,19 @@ export function ActivateUserModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
formData.businessRoles.length === 0 &&
|
||||
departmentBusinessRoleInference.shouldWarnWhenEmpty &&
|
||||
!departmentBusinessRoleInference.excluded &&
|
||||
!emptyBusinessRoleWarningAccepted
|
||||
) {
|
||||
setEmptyBusinessRoleWarning(
|
||||
`${departmentBusinessRoleInference.reason}。继续激活会按普通用户处理,仅保留查看和下载权限。`,
|
||||
);
|
||||
setEmptyBusinessRoleWarningAccepted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/users/${user.id}/activate`, {
|
||||
@ -171,7 +188,7 @@ export function ActivateUserModal({
|
||||
role: formData.role,
|
||||
businessRoles: formData.businessRoles,
|
||||
departmentId: formData.departmentId || null,
|
||||
teamId: formData.teamId || null,
|
||||
teamId: null,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -222,33 +239,19 @@ export function ActivateUserModal({
|
||||
errorText={errors.role}
|
||||
className="bg-white"
|
||||
/>
|
||||
<Select
|
||||
<DepartmentTreeSelect
|
||||
label="所属部门"
|
||||
value={formData.departmentId}
|
||||
onChange={(event) =>
|
||||
departments={departments}
|
||||
onChange={(departmentId) => {
|
||||
setBusinessRolesTouched(false);
|
||||
setEmptyBusinessRoleWarningAccepted(false);
|
||||
setEmptyBusinessRoleWarning(null);
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
departmentId: event.target.value,
|
||||
teamId: '',
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ value: '', label: '未分配部门' },
|
||||
...departments.map((dept) => ({
|
||||
value: dept.id,
|
||||
label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}`,
|
||||
})),
|
||||
]}
|
||||
className="bg-white"
|
||||
/>
|
||||
<Select
|
||||
label="所属小组"
|
||||
value={formData.teamId}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, teamId: event.target.value }))}
|
||||
options={[
|
||||
{ value: '', label: '未分配小组' },
|
||||
...teams.map((team) => ({ value: team.id, label: team.name })),
|
||||
]}
|
||||
departmentId,
|
||||
}));
|
||||
}}
|
||||
className="bg-white"
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
@ -267,6 +270,11 @@ export function ActivateUserModal({
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{emptyBusinessRoleWarning ? (
|
||||
<div className="mt-3 rounded-md border border-[#d7e0e1] bg-[#f7f9f9] px-3 py-2 text-xs font-semibold leading-5 text-[#4f6064]">
|
||||
{emptyBusinessRoleWarning}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -11,7 +11,9 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
|
||||
interface CreateUserModalProps {
|
||||
@ -21,18 +23,6 @@ interface CreateUserModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface DepartmentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
level?: number;
|
||||
children?: DepartmentItem[];
|
||||
}
|
||||
|
||||
interface TeamItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AccountTemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -45,10 +35,21 @@ const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPERVISOR: '主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户(只看 / 下载)',
|
||||
};
|
||||
|
||||
function findDepartmentById(
|
||||
departments: DepartmentTreeOption[],
|
||||
departmentId: string,
|
||||
): DepartmentTreeOption | null {
|
||||
for (const department of departments) {
|
||||
if (department.id === departmentId) return department;
|
||||
const child = findDepartmentById(department.children ?? [], departmentId);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: CreateUserModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
@ -60,15 +61,14 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
businessRoles: [] as BusinessAccountType[],
|
||||
phone: '',
|
||||
departmentId: '',
|
||||
teamId: '',
|
||||
accountTemplateId: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
|
||||
const [teams, setTeams] = useState<TeamItem[]>([]);
|
||||
const [departments, setDepartments] = useState<DepartmentTreeOption[]>([]);
|
||||
const [templates, setTemplates] = useState<AccountTemplateItem[]>([]);
|
||||
const [businessRolesTouched, setBusinessRolesTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((current) => ({
|
||||
@ -85,16 +85,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const departmentTree = data.data?.departments || data.departments || [];
|
||||
const flattenDepts = (depts: DepartmentItem[], level = 0): DepartmentItem[] => {
|
||||
return depts.reduce((acc, dept) => {
|
||||
acc.push({ ...dept, level });
|
||||
if (dept.children && dept.children.length > 0) {
|
||||
acc.push(...flattenDepts(dept.children, level + 1));
|
||||
}
|
||||
return acc;
|
||||
}, [] as DepartmentItem[]);
|
||||
};
|
||||
setDepartments(flattenDepts(departmentTree));
|
||||
setDepartments(departmentTree);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门列表失败:', error);
|
||||
@ -106,34 +97,6 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTeams = async () => {
|
||||
if (!formData.departmentId) {
|
||||
setTeams([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
setTeams([]);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setTeams(data.data?.teams || data.teams || []);
|
||||
} catch (error) {
|
||||
console.error('加载小组列表失败:', error);
|
||||
setTeams([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
void loadTeams();
|
||||
}
|
||||
}, [formData.departmentId, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
@ -155,7 +118,19 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (businessRolesTouched || !formData.departmentId) return;
|
||||
const department = findDepartmentById(departments, formData.departmentId);
|
||||
if (!department) return;
|
||||
const inferred = inferBusinessRolesFromDepartmentPath(department.fullPath);
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
businessRoles: inferred.businessRoles,
|
||||
}));
|
||||
}, [businessRolesTouched, departments, formData.departmentId]);
|
||||
|
||||
const toggleBusinessRole = (role: BusinessAccountType) => {
|
||||
setBusinessRolesTouched(true);
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
businessRoles: current.businessRoles.includes(role)
|
||||
@ -172,6 +147,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
role: template?.role || current.role,
|
||||
businessRoles: template?.businessRoles || current.businessRoles,
|
||||
}));
|
||||
if (template) setBusinessRolesTouched(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@ -207,7 +183,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
businessRoles: formData.businessRoles,
|
||||
phone: formData.phone || null,
|
||||
departmentId: formData.departmentId || null,
|
||||
teamId: formData.teamId || null,
|
||||
teamId: null,
|
||||
accountTemplateId: formData.accountTemplateId || null,
|
||||
}),
|
||||
});
|
||||
@ -270,14 +246,16 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
]}
|
||||
className="bg-white"
|
||||
/>
|
||||
<Select label="所属部门" value={formData.departmentId} onChange={(e) => setFormData({ ...formData, departmentId: e.target.value, teamId: '' })} options={[
|
||||
{ value: '', label: '未分配部门' },
|
||||
...departments.map((dept) => ({ value: dept.id, label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}` })),
|
||||
]} className="bg-white" />
|
||||
<Select label="所属小组" value={formData.teamId} onChange={(e) => setFormData({ ...formData, teamId: e.target.value })} options={[
|
||||
{ value: '', label: '未分配小组' },
|
||||
...teams.map((team) => ({ value: team.id, label: team.name })),
|
||||
]} className="bg-white" />
|
||||
<DepartmentTreeSelect
|
||||
label="所属部门"
|
||||
value={formData.departmentId}
|
||||
departments={departments}
|
||||
onChange={(departmentId) => {
|
||||
setBusinessRolesTouched(false);
|
||||
setFormData({ ...formData, departmentId });
|
||||
}}
|
||||
className="bg-white"
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">业务类型</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
ShieldCheck,
|
||||
UserCircle2,
|
||||
Users,
|
||||
Users2,
|
||||
} from 'lucide-react';
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { UserListItem } from '@/components/users/UserListItem';
|
||||
@ -55,6 +54,7 @@ export interface UserTreeItem {
|
||||
department?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath?: string;
|
||||
};
|
||||
team?: {
|
||||
id: string;
|
||||
@ -174,101 +174,6 @@ function UserLedgerHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
function TeamNodeView({
|
||||
team,
|
||||
currentUserId,
|
||||
selectedUserId,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onActivate,
|
||||
onHandover,
|
||||
onEmployeeMatch,
|
||||
canEditUser,
|
||||
canActivateUser,
|
||||
}: {
|
||||
team: TeamNode;
|
||||
currentUserId?: string;
|
||||
selectedUserId?: string;
|
||||
onSelect?: (user: UserTreeItem) => void;
|
||||
onEdit: (user: UserTreeItem) => void;
|
||||
onDelete: (userId: string) => Promise<void> | void;
|
||||
onActivate: (user: UserTreeItem) => Promise<void> | void;
|
||||
onHandover: (user: UserTreeItem) => void;
|
||||
onEmployeeMatch: (user: UserTreeItem) => Promise<void> | void;
|
||||
canEditUser?: (user: UserTreeItem) => boolean;
|
||||
canActivateUser?: (user: UserTreeItem) => boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-l border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] pl-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
className="flex min-h-[42px] w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-[var(--obs-shell-hover-bg)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--obs-shell-border)]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-black text-[var(--obs-shell-title)]">{team.name}</span>
|
||||
<span className="rounded-[4px] bg-[var(--obs-shell-muted-bg)] px-1.5 py-0.5 text-[10px] font-black text-[var(--obs-shell-muted)]">
|
||||
{team.summary.memberCount} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-[var(--obs-shell-muted)]">
|
||||
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[var(--obs-shell-muted-bg)] px-1.5 py-0.5 font-bold text-[var(--obs-shell-control-text)]">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
主管
|
||||
</span>
|
||||
<span className="font-bold text-[var(--obs-shell-title)]">{team.leader?.name || '未设置'}</span>
|
||||
{team.description ? <span className="truncate text-[var(--obs-shell-muted)]">{team.description}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-[var(--obs-shell-muted)] transition-transform duration-150',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)]">
|
||||
{team.users.length > 0 ? (
|
||||
<div>
|
||||
<UserLedgerHeader />
|
||||
<div className="divide-y divide-[var(--obs-shell-border)]">
|
||||
{team.users.map((user) => (
|
||||
<UserListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
selected={selectedUserId === user.id}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onActivate={onActivate}
|
||||
onHandover={onHandover}
|
||||
onEmployeeMatch={onEmployeeMatch}
|
||||
currentUserId={currentUserId}
|
||||
canEdit={canEditUser ? canEditUser(user) : true}
|
||||
canActivate={canActivateUser ? canActivateUser(user) : true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-2 flex items-center gap-2 rounded-[6px] bg-[var(--obs-shell-muted-bg)] px-4 py-3 text-sm text-[var(--obs-shell-muted)]">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>该小组当前没有匹配的成员</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DepartmentNodeView({
|
||||
department,
|
||||
depth,
|
||||
@ -298,9 +203,7 @@ function DepartmentNodeView({
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(depth === 0);
|
||||
const [directUsersOpen, setDirectUsersOpen] = useState(true);
|
||||
const [teamsOpen, setTeamsOpen] = useState(true);
|
||||
const hasDirectUsers = department.users.length > 0;
|
||||
const hasTeams = department.teams.length > 0;
|
||||
const hasChildren = department.children.length > 0;
|
||||
|
||||
return (
|
||||
@ -342,7 +245,6 @@ function DepartmentNodeView({
|
||||
<SummaryBadge label="直属人员" value={`${department.summary.directUserCount} 人`} />
|
||||
<SummaryBadge label="总人数" value={`${department.summary.totalUserCount} 人`} />
|
||||
<SummaryBadge label="子部门" value={`${department.summary.childDepartmentCount} 个`} />
|
||||
<SummaryBadge label="小组" value={`${department.summary.teamCount} 个`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -392,38 +294,6 @@ function DepartmentNodeView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTeams && (
|
||||
<div className="mb-3">
|
||||
<SectionHeader
|
||||
icon={<Users2 size={15} />}
|
||||
title="小组"
|
||||
count={department.teams.length}
|
||||
isOpen={teamsOpen}
|
||||
onToggle={() => setTeamsOpen((current) => !current)}
|
||||
/>
|
||||
{teamsOpen && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{department.teams.map((team) => (
|
||||
<TeamNodeView
|
||||
key={team.id}
|
||||
team={team}
|
||||
currentUserId={currentUserId}
|
||||
selectedUserId={selectedUserId}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onActivate={onActivate}
|
||||
onHandover={onHandover}
|
||||
onEmployeeMatch={onEmployeeMatch}
|
||||
canEditUser={canEditUser}
|
||||
canActivateUser={canActivateUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mt-3 border-l border-[var(--obs-shell-border)] pl-3">
|
||||
<div className="mb-2 text-[11px] font-black text-[var(--obs-shell-muted)]">
|
||||
@ -451,7 +321,7 @@ function DepartmentNodeView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasDirectUsers && !hasTeams && !hasChildren && (
|
||||
{!hasDirectUsers && !hasChildren && (
|
||||
<div className="flex items-center gap-2 rounded-[6px] bg-[var(--obs-shell-muted-bg)] px-4 py-3 text-sm text-[var(--obs-shell-muted)]">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>该部门当前没有匹配的组织成员</span>
|
||||
|
||||
@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Building2, Check, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DepartmentTreeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
parentId?: string | null;
|
||||
children?: DepartmentTreeOption[];
|
||||
}
|
||||
|
||||
interface DepartmentTreeSelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
departments: DepartmentTreeOption[];
|
||||
onChange: (departmentId: string) => void;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
errorText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function walkDepartments(
|
||||
items: DepartmentTreeOption[],
|
||||
visit: (department: DepartmentTreeOption, ancestors: string[]) => void,
|
||||
ancestors: string[] = [],
|
||||
) {
|
||||
for (const item of items) {
|
||||
visit(item, ancestors);
|
||||
if (item.children?.length) {
|
||||
walkDepartments(item.children, visit, [...ancestors, item.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterDepartments(items: DepartmentTreeOption[], searchText: string): DepartmentTreeOption[] {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
if (!keyword) return items;
|
||||
|
||||
return items.reduce<DepartmentTreeOption[]>((result, item) => {
|
||||
const children = filterDepartments(item.children ?? [], keyword);
|
||||
const matched =
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.fullPath.toLowerCase().includes(keyword);
|
||||
|
||||
if (!matched && children.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
children,
|
||||
});
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function DepartmentTreeSelect({
|
||||
label,
|
||||
value,
|
||||
departments,
|
||||
onChange,
|
||||
placeholder = '未分配部门',
|
||||
emptyText = '暂无部门数据',
|
||||
required = false,
|
||||
disabled = false,
|
||||
errorText,
|
||||
className,
|
||||
}: DepartmentTreeSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const departmentMeta = useMemo(() => {
|
||||
const byId = new Map<string, DepartmentTreeOption>();
|
||||
const ancestorsById = new Map<string, string[]>();
|
||||
|
||||
walkDepartments(departments, (department, ancestors) => {
|
||||
byId.set(department.id, department);
|
||||
ancestorsById.set(department.id, ancestors);
|
||||
});
|
||||
|
||||
return { byId, ancestorsById };
|
||||
}, [departments]);
|
||||
|
||||
const selectedDepartment = value ? departmentMeta.byId.get(value) ?? null : null;
|
||||
const filteredDepartments = useMemo(
|
||||
() => filterDepartments(departments, searchText),
|
||||
[departments, searchText],
|
||||
);
|
||||
const hasError = Boolean(errorText);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchText('');
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((current) => {
|
||||
const nextExpanded = new Set(current);
|
||||
if (value) {
|
||||
for (const ancestorId of departmentMeta.ancestorsById.get(value) ?? []) {
|
||||
nextExpanded.add(ancestorId);
|
||||
}
|
||||
}
|
||||
if (departments.length === 1) {
|
||||
nextExpanded.add(departments[0].id);
|
||||
}
|
||||
return nextExpanded;
|
||||
});
|
||||
window.setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}, [open, value, departmentMeta.ancestorsById, departments]);
|
||||
|
||||
function toggleExpanded(departmentId: string) {
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(departmentId)) {
|
||||
next.delete(departmentId);
|
||||
} else {
|
||||
next.add(departmentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelect(departmentId: string) {
|
||||
onChange(departmentId);
|
||||
setOpen(false);
|
||||
setSearchText('');
|
||||
}
|
||||
|
||||
function renderDepartment(department: DepartmentTreeOption, depth: number) {
|
||||
const children = department.children ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const expanded = Boolean(searchText.trim()) || expandedIds.has(department.id);
|
||||
const selected = value === department.id;
|
||||
|
||||
return (
|
||||
<div key={department.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-9 grid-cols-[24px_minmax(0,1fr)] items-center gap-1 rounded-[5px] pr-2 text-left',
|
||||
selected ? 'bg-[var(--obs-shell-control-bg)]' : 'hover:bg-[var(--obs-shell-hover-bg)]',
|
||||
)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? '收起部门' : '展开部门'}
|
||||
disabled={!hasChildren}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleExpanded(department.id);
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--obs-shell-muted)]',
|
||||
hasChildren && 'hover:bg-[var(--obs-shell-muted-bg)]',
|
||||
!hasChildren && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<ChevronRight className={cn('h-3.5 w-3.5 transition-transform', expanded && 'rotate-90')} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={department.fullPath}
|
||||
onClick={() => handleSelect(department.id)}
|
||||
className="grid min-w-0 grid-cols-[minmax(0,1fr)_16px] items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[12px] font-black text-[var(--obs-shell-title)]">
|
||||
{department.name}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] font-semibold text-[var(--obs-shell-muted)]">
|
||||
{department.fullPath}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? <Check className="h-3.5 w-3.5 text-[var(--obs-shell-title)]" /> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasChildren && expanded ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
{children.map((child) => renderDepartment(child, depth + 1))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative space-y-2', className)}>
|
||||
<label className="block text-xs font-bold text-[var(--obs-shell-muted)]">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={cn(
|
||||
'flex min-h-11 w-full items-center gap-3 rounded-[6px] border bg-white px-3 text-left transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--obs-shell-border)]',
|
||||
hasError
|
||||
? 'border-red-300'
|
||||
: open
|
||||
? 'border-[var(--obs-shell-title)]'
|
||||
: 'border-[var(--obs-shell-border)] hover:bg-[var(--obs-shell-hover-bg)]',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
<Building2 className="h-4 w-4 shrink-0 text-[var(--obs-shell-muted)]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-sm font-black',
|
||||
selectedDepartment ? 'text-[var(--obs-shell-title)]' : 'text-[var(--obs-shell-muted)]',
|
||||
)}
|
||||
>
|
||||
{selectedDepartment?.fullPath || placeholder}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 shrink-0 text-[var(--obs-shell-muted)] transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{errorText ? <p className="text-[11px] font-semibold text-red-600">{errorText}</p> : null}
|
||||
|
||||
{open ? (
|
||||
<div className="absolute left-0 right-0 z-50 mt-1 overflow-hidden rounded-[6px] border border-[var(--obs-shell-border)] bg-white shadow-lg">
|
||||
<div className="border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] p-2">
|
||||
<div className="flex h-8 items-center gap-2 rounded-[5px] border border-[var(--obs-shell-border)] bg-white px-2">
|
||||
<Search className="h-3.5 w-3.5 text-[var(--obs-shell-muted)]" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
placeholder="搜索组织路径"
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-xs font-semibold text-[var(--obs-shell-title)] outline-none placeholder:text-[var(--obs-shell-subtle)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect('')}
|
||||
className={cn(
|
||||
'mb-1 flex h-9 w-full items-center rounded-[5px] px-2 text-left text-[12px] font-black',
|
||||
value === '' ? 'bg-[var(--obs-shell-control-bg)] text-[var(--obs-shell-title)]' : 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)]',
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</button>
|
||||
|
||||
{filteredDepartments.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{filteredDepartments.map((department) => renderDepartment(department, 0))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-6 text-center text-xs font-semibold text-[var(--obs-shell-muted)]">
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
|
||||
@ -24,25 +25,12 @@ interface UserData {
|
||||
status: string;
|
||||
phone?: string | null;
|
||||
departmentId?: string | null;
|
||||
teamId?: string | null;
|
||||
accountTemplateId?: string | null;
|
||||
accountTemplateOverrides?: {
|
||||
overrideFields?: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface DepartmentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
level?: number;
|
||||
children?: DepartmentItem[];
|
||||
}
|
||||
|
||||
interface TeamItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AccountTemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -64,7 +52,6 @@ const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPERVISOR: '主管',
|
||||
DESIGNER: '设计师',
|
||||
USER: '普通用户(只看 / 下载)',
|
||||
};
|
||||
|
||||
@ -86,14 +73,12 @@ export function EditUserModal({
|
||||
status: user.status,
|
||||
phone: user.phone || '',
|
||||
departmentId: user.departmentId || '',
|
||||
teamId: user.teamId || '',
|
||||
accountTemplateId: user.accountTemplateId || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
|
||||
const [teams, setTeams] = useState<TeamItem[]>([]);
|
||||
const [departments, setDepartments] = useState<DepartmentTreeOption[]>([]);
|
||||
const [templates, setTemplates] = useState<AccountTemplateItem[]>([]);
|
||||
|
||||
// 加载部门列表
|
||||
@ -107,16 +92,7 @@ export function EditUserModal({
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const departmentTree = data.data?.departments || data.departments || [];
|
||||
const flattenDepts = (depts: DepartmentItem[], level = 0): DepartmentItem[] => {
|
||||
return depts.reduce<DepartmentItem[]>((acc, dept) => {
|
||||
acc.push({ ...dept, level });
|
||||
if (dept.children && dept.children.length > 0) {
|
||||
acc.push(...flattenDepts(dept.children, level + 1));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
setDepartments(flattenDepts(departmentTree));
|
||||
setDepartments(departmentTree);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门列表失败:', error);
|
||||
@ -128,34 +104,6 @@ export function EditUserModal({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTeams = async () => {
|
||||
if (!formData.departmentId) {
|
||||
setTeams([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
setTeams([]);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setTeams(data.data?.teams || data.teams || []);
|
||||
} catch (error) {
|
||||
console.error('加载小组列表失败:', error);
|
||||
setTeams([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
void loadTeams();
|
||||
}
|
||||
}, [formData.departmentId, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
@ -191,7 +139,6 @@ export function EditUserModal({
|
||||
status: user.status,
|
||||
phone: user.phone || '',
|
||||
departmentId: user.departmentId || '',
|
||||
teamId: user.teamId || '',
|
||||
accountTemplateId: user.accountTemplateId || '',
|
||||
});
|
||||
}, [user]);
|
||||
@ -281,7 +228,7 @@ export function EditUserModal({
|
||||
status: formData.status,
|
||||
phone: formData.phone || null,
|
||||
departmentId: formData.departmentId || null,
|
||||
teamId: formData.teamId || null,
|
||||
teamId: null,
|
||||
accountTemplateId: formData.accountTemplateId || null,
|
||||
};
|
||||
|
||||
@ -407,27 +354,11 @@ export function EditUserModal({
|
||||
disabled={disablePrivilegeFields}
|
||||
className="bg-white"
|
||||
/>
|
||||
<Select
|
||||
<DepartmentTreeSelect
|
||||
label="所属部门"
|
||||
value={formData.departmentId}
|
||||
onChange={(e) => setFormData({ ...formData, departmentId: e.target.value, teamId: '' })}
|
||||
options={[
|
||||
{ value: '', label: '未分配部门' },
|
||||
...departments.map((dept) => ({
|
||||
value: dept.id,
|
||||
label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}`,
|
||||
})),
|
||||
]}
|
||||
className="bg-white"
|
||||
/>
|
||||
<Select
|
||||
label="所属小组"
|
||||
value={formData.teamId}
|
||||
onChange={(e) => setFormData({ ...formData, teamId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: '未分配小组' },
|
||||
...teams.map((team) => ({ value: team.id, label: team.name })),
|
||||
]}
|
||||
departments={departments}
|
||||
onChange={(departmentId) => setFormData({ ...formData, departmentId })}
|
||||
className="bg-white"
|
||||
/>
|
||||
<div>
|
||||
|
||||
@ -8,15 +8,19 @@ import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useDebounce } from '@/hooks/usePerformance';
|
||||
import { cn, getApiErrorMessage } from '@/lib/utils';
|
||||
import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types';
|
||||
import type { BusinessAccountType } from '@prisma/client';
|
||||
|
||||
interface HandoverModalUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
businessRoles?: BusinessAccountType[];
|
||||
status: string;
|
||||
department?: {
|
||||
name: string;
|
||||
fullPath?: string;
|
||||
} | null;
|
||||
team?: {
|
||||
name: string;
|
||||
@ -31,6 +35,7 @@ interface CandidateUser {
|
||||
department?: {
|
||||
id?: string;
|
||||
name: string;
|
||||
fullPath?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@ -234,10 +239,6 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
|
||||
warnings.push('该用户当前仍挂着部门负责人职责,系统不会自动改派负责人。');
|
||||
}
|
||||
|
||||
if (checklist?.summary.hasManagedTeam) {
|
||||
warnings.push('该用户当前仍挂着小组负责人职责,系统不会自动改派组长。');
|
||||
}
|
||||
|
||||
if ((checklist?.summary.totalProducts ?? 0) > 0) {
|
||||
warnings.push('当前交接流程不会改写“创建人”归属,只处理 SKU 所有权和协作关系。');
|
||||
}
|
||||
@ -250,7 +251,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
|
||||
{ value: '', label: loadingCandidates ? '正在加载接收人...' : '请选择接收人' },
|
||||
...candidateUsers.map((candidate) => ({
|
||||
value: candidate.id,
|
||||
label: `${candidate.name}${candidate.department?.name ? ` · ${candidate.department.name}` : ''}`,
|
||||
label: `${candidate.name}${candidate.department ? ` · ${candidate.department.fullPath || candidate.department.name}` : ''}`,
|
||||
})),
|
||||
],
|
||||
[candidateUsers, loadingCandidates],
|
||||
@ -313,6 +314,16 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
|
||||
};
|
||||
|
||||
const ownedSkus = checklist?.handoverItems.ownedSKUs.items ?? [];
|
||||
const userBusinessRoleText = checklist?.user.businessRoles?.length
|
||||
? checklist.user.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role] ?? role).join(' / ')
|
||||
: '普通用户';
|
||||
const roleText = checklist?.user.role === 'SUPER_ADMIN'
|
||||
? '超级管理员'
|
||||
: checklist?.user.role === 'ADMIN'
|
||||
? '管理员'
|
||||
: checklist?.user.role === 'SUPERVISOR'
|
||||
? '主管'
|
||||
: '普通用户';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -342,7 +353,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
|
||||
交接对象
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
|
||||
姓名
|
||||
@ -356,16 +367,23 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
|
||||
所属
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-apple-medium text-apple-label-secondary">
|
||||
{checklist.user.department?.name || '未分配部门'}
|
||||
{checklist.user.team?.name ? ` · ${checklist.user.team.name}` : ''}
|
||||
{checklist.user.department?.fullPath || checklist.user.department?.name || '未分配部门'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
|
||||
当前角色
|
||||
权限角色
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-apple-medium text-apple-label-secondary">
|
||||
{checklist.user.role}
|
||||
{roleText}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
|
||||
业务类型
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-apple-medium text-apple-label-secondary">
|
||||
{userBusinessRoleText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,47 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, Building2, Users2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircle, Building2 } from 'lucide-react'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect'
|
||||
import { getApiErrorMessage } from '@/lib/utils'
|
||||
|
||||
interface DepartmentItem {
|
||||
id: string
|
||||
name: string
|
||||
level?: number
|
||||
children?: DepartmentItem[]
|
||||
}
|
||||
|
||||
interface OrganizationManageModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function flattenDepartments(items: DepartmentItem[], level = 0): DepartmentItem[] {
|
||||
return items.reduce<DepartmentItem[]>((acc, item) => {
|
||||
acc.push({ ...item, level })
|
||||
if (item.children?.length) {
|
||||
acc.push(...flattenDepartments(item.children, level + 1))
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function OrganizationManageModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: OrganizationManageModalProps) {
|
||||
const [departments, setDepartments] = useState<DepartmentItem[]>([])
|
||||
const [departments, setDepartments] = useState<DepartmentTreeOption[]>([])
|
||||
const [departmentName, setDepartmentName] = useState('')
|
||||
const [parentId, setParentId] = useState('')
|
||||
const [teamName, setTeamName] = useState('')
|
||||
const [teamDepartmentId, setTeamDepartmentId] = useState('')
|
||||
const [teamDescription, setTeamDescription] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -55,34 +35,12 @@ export function OrganizationManageModal({
|
||||
if (!response.ok) return
|
||||
|
||||
const payload = await response.json()
|
||||
setDepartments(flattenDepartments(payload.data?.departments || payload.departments || []))
|
||||
setDepartments(payload.data?.departments || payload.departments || [])
|
||||
}
|
||||
|
||||
void loadDepartments()
|
||||
}, [isOpen, reloadToken])
|
||||
|
||||
const departmentOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: '作为一级部门' },
|
||||
...departments.map((department) => ({
|
||||
value: department.id,
|
||||
label: `${' '.repeat((department.level || 0) * 2)}${department.name}`,
|
||||
})),
|
||||
],
|
||||
[departments]
|
||||
)
|
||||
|
||||
const teamDepartmentOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: '选择所属部门' },
|
||||
...departments.map((department) => ({
|
||||
value: department.id,
|
||||
label: `${' '.repeat((department.level || 0) * 2)}${department.name}`,
|
||||
})),
|
||||
],
|
||||
[departments]
|
||||
)
|
||||
|
||||
const createDepartment = async () => {
|
||||
if (!departmentName.trim()) {
|
||||
setError('请输入部门名称')
|
||||
@ -109,7 +67,7 @@ export function OrganizationManageModal({
|
||||
}
|
||||
setDepartmentName('')
|
||||
setParentId('')
|
||||
setMessage('部门已创建')
|
||||
setMessage('组织节点已创建')
|
||||
setReloadToken((current) => current + 1)
|
||||
onSuccess()
|
||||
} finally {
|
||||
@ -117,44 +75,6 @@ export function OrganizationManageModal({
|
||||
}
|
||||
}
|
||||
|
||||
const createTeam = async () => {
|
||||
if (!teamName.trim()) {
|
||||
setError('请输入小组名称')
|
||||
return
|
||||
}
|
||||
if (!teamDepartmentId) {
|
||||
setError('请选择小组所属部门')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
try {
|
||||
const response = await fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: teamName.trim(),
|
||||
departmentId: teamDepartmentId,
|
||||
description: teamDescription.trim() || null,
|
||||
}),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
setError(getApiErrorMessage(payload.error, '创建小组失败'))
|
||||
return
|
||||
}
|
||||
setTeamName('')
|
||||
setTeamDescription('')
|
||||
setMessage('小组已创建')
|
||||
onSuccess()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="组织管理" size="medium">
|
||||
<div className="space-y-5 p-2">
|
||||
@ -173,34 +93,21 @@ export function OrganizationManageModal({
|
||||
<section className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-[#405054]" />
|
||||
<h3 className="text-[11px] font-black text-[#152024]">部门</h3>
|
||||
<h3 className="text-[11px] font-black text-[#152024]">组织节点</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Input label="部门名称" value={departmentName} onChange={(event) => setDepartmentName(event.target.value)} />
|
||||
<Select label="上级部门" value={parentId} onChange={(event) => setParentId(event.target.value)} options={departmentOptions} />
|
||||
<Input label="节点名称" value={departmentName} onChange={(event) => setDepartmentName(event.target.value)} />
|
||||
<DepartmentTreeSelect
|
||||
label="上级组织"
|
||||
value={parentId}
|
||||
departments={departments}
|
||||
onChange={setParentId}
|
||||
placeholder="作为一级组织"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="button" variant="plain" size="small" loading={loading} onClick={createDepartment}>
|
||||
创建部门
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Users2 className="h-4 w-4 text-[#405054]" />
|
||||
<h3 className="text-[11px] font-black text-[#152024]">小组</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Input label="小组名称" value={teamName} onChange={(event) => setTeamName(event.target.value)} />
|
||||
<Select label="所属部门" value={teamDepartmentId} onChange={(event) => setTeamDepartmentId(event.target.value)} options={teamDepartmentOptions} />
|
||||
<div className="md:col-span-2">
|
||||
<Input label="说明" value={teamDescription} onChange={(event) => setTeamDescription(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="button" variant="plain" size="small" loading={loading} onClick={createTeam}>
|
||||
创建小组
|
||||
创建组织节点
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -27,7 +27,6 @@ import {
|
||||
Phone,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { BusinessAccountType } from '@prisma/client';
|
||||
@ -61,6 +60,7 @@ interface UserListItemProps {
|
||||
department?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath?: string;
|
||||
};
|
||||
team?: {
|
||||
id: string;
|
||||
@ -126,11 +126,6 @@ const roleConfig: Record<string, { label: string; color: string; avatar: string
|
||||
color: 'border-[var(--obs-shell-border)] bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-control-text)]',
|
||||
avatar: 'border-[var(--obs-shell-border)] bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-control-text)]',
|
||||
},
|
||||
DESIGNER: {
|
||||
label: '设计师',
|
||||
color: 'border-[var(--obs-shell-border)] bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-control-text)]',
|
||||
avatar: 'border-[var(--obs-shell-border)] bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-control-text)]',
|
||||
},
|
||||
USER: {
|
||||
label: '普通用户',
|
||||
color: 'border-[var(--obs-shell-border)] bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-muted)]',
|
||||
@ -379,11 +374,9 @@ export const UserListItem = memo(function UserListItem({
|
||||
<div className="grid min-w-0 gap-1 text-[11px] font-medium text-[var(--obs-shell-muted)]">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-[var(--obs-shell-subtle)]" />
|
||||
<span className="truncate">{user.department?.name || '未归属部门'}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-[var(--obs-shell-subtle)]" />
|
||||
<span className="truncate">{user.team?.name || '未归属小组'}</span>
|
||||
<span className="truncate" title={user.department?.fullPath || user.department?.name}>
|
||||
{user.department?.fullPath || user.department?.name || '未归属部门'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -39,41 +39,23 @@ describe('DepartmentAccessTree', () => {
|
||||
orderIndex: 0,
|
||||
manager: { id: 'manager-1', name: '林部长' },
|
||||
summary: {
|
||||
directUserCount: 1,
|
||||
totalUserCount: 3,
|
||||
childDepartmentCount: 1,
|
||||
teamCount: 1,
|
||||
},
|
||||
directUserCount: 1,
|
||||
totalUserCount: 3,
|
||||
childDepartmentCount: 1,
|
||||
teamCount: 0,
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 'user-root',
|
||||
name: '部门直属成员',
|
||||
email: 'root@example.com',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: '执行组',
|
||||
description: '执行小组',
|
||||
departmentId: 'dept-root',
|
||||
leader: { id: 'leader-1', name: '组长甲' },
|
||||
summary: { memberCount: 1 },
|
||||
users: [
|
||||
{
|
||||
id: 'team-user-1',
|
||||
name: '小组成员A',
|
||||
email: 'team@example.com',
|
||||
role: 'DESIGNER',
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [],
|
||||
children: [
|
||||
{
|
||||
id: 'dept-child',
|
||||
@ -83,12 +65,22 @@ describe('DepartmentAccessTree', () => {
|
||||
orderIndex: 1,
|
||||
manager: { id: 'manager-2', name: '顾主管' },
|
||||
summary: {
|
||||
directUserCount: 0,
|
||||
directUserCount: 1,
|
||||
totalUserCount: 1,
|
||||
childDepartmentCount: 0,
|
||||
teamCount: 0,
|
||||
},
|
||||
users: [],
|
||||
users: [
|
||||
{
|
||||
id: 'dept-child-user-1',
|
||||
name: '策划组成员A',
|
||||
email: 'team@example.com',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
teams: [],
|
||||
children: [],
|
||||
},
|
||||
@ -111,19 +103,21 @@ describe('DepartmentAccessTree', () => {
|
||||
) as jest.Mock
|
||||
})
|
||||
|
||||
it('应渲染部门、小组和人员三层结构,并保持默认展开策略', async () => {
|
||||
it('应渲染部门树和人员归属,并保持默认展开策略', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
<DepartmentAccessTree
|
||||
searchQuery=""
|
||||
roleFilter=""
|
||||
businessRoleFilter=""
|
||||
statusFilter=""
|
||||
refreshToken={0}
|
||||
onEdit={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onActivate={jest.fn()}
|
||||
onHandover={jest.fn()}
|
||||
onEmployeeMatch={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -136,17 +130,15 @@ describe('DepartmentAccessTree', () => {
|
||||
expect(await screen.findAllByText('品牌中心')).not.toHaveLength(0)
|
||||
expect(screen.getByText('林部长')).toBeInTheDocument()
|
||||
expect(screen.getByText('直属成员')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('小组').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('小组')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('策划部')).toBeInTheDocument()
|
||||
expect(screen.getByText('部门直属成员')).toBeInTheDocument()
|
||||
expect(screen.getByText('执行组')).toBeInTheDocument()
|
||||
expect(screen.getByText('组长甲')).toBeInTheDocument()
|
||||
expect(screen.queryByText('小组成员A')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('策划组成员A')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('未分配成员')).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /执行组/i }))
|
||||
await actor.click(screen.getByRole('button', { name: /策划部/i }))
|
||||
|
||||
expect(screen.getByText('小组成员A')).toBeInTheDocument()
|
||||
expect(screen.getByText('策划组成员A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('未分配人员区应默认展开,并允许折叠', async () => {
|
||||
@ -156,12 +148,14 @@ describe('DepartmentAccessTree', () => {
|
||||
<DepartmentAccessTree
|
||||
searchQuery=""
|
||||
roleFilter=""
|
||||
businessRoleFilter=""
|
||||
statusFilter=""
|
||||
refreshToken={0}
|
||||
onEdit={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onActivate={jest.fn()}
|
||||
onHandover={jest.fn()}
|
||||
onEmployeeMatch={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -180,6 +174,7 @@ describe('DepartmentAccessTree', () => {
|
||||
<DepartmentAccessTree
|
||||
searchQuery=""
|
||||
roleFilter=""
|
||||
businessRoleFilter=""
|
||||
statusFilter=""
|
||||
refreshToken={0}
|
||||
selectedUserId="user-root"
|
||||
@ -188,6 +183,7 @@ describe('DepartmentAccessTree', () => {
|
||||
onDelete={jest.fn()}
|
||||
onActivate={jest.fn()}
|
||||
onHandover={jest.fn()}
|
||||
onEmployeeMatch={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ export interface User {
|
||||
department?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath?: string;
|
||||
} | null;
|
||||
team?: {
|
||||
id: string;
|
||||
|
||||
@ -4,6 +4,7 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
|
||||
value: BusinessAccountType
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'DESIGN', label: '设计' },
|
||||
{ value: 'PLANNING', label: '企划' },
|
||||
{ value: 'PURCHASING', label: '采购' },
|
||||
{ value: 'WAREHOUSE', label: '仓库' },
|
||||
@ -11,6 +12,7 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
|
||||
]
|
||||
|
||||
export const BUSINESS_ACCOUNT_TYPE_LABELS: Record<BusinessAccountType, string> = {
|
||||
DESIGN: '设计',
|
||||
PLANNING: '企划',
|
||||
PURCHASING: '采购',
|
||||
WAREHOUSE: '仓库',
|
||||
|
||||
195
furniture-3d-system/src/lib/business-role-inference.ts
Normal file
195
furniture-3d-system/src/lib/business-role-inference.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import type { BusinessAccountType } from '@prisma/client'
|
||||
|
||||
export interface BusinessRoleInferenceResult {
|
||||
businessRoles: BusinessAccountType[]
|
||||
excluded: boolean
|
||||
shouldWarnWhenEmpty: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
const EXCLUDED_ROOTS = new Set([
|
||||
'客服中心',
|
||||
'外贸部',
|
||||
'财务中心',
|
||||
'总经理室',
|
||||
'直播中心',
|
||||
'商务中心',
|
||||
'分销部',
|
||||
'业务支持',
|
||||
'公共视觉部',
|
||||
'经营管理中心',
|
||||
'摄影中心',
|
||||
'广州项目组',
|
||||
'摄影需求',
|
||||
'快递公司',
|
||||
'人力行政中心',
|
||||
'外部交流',
|
||||
])
|
||||
|
||||
const LONGCHEN_EXCLUDED_CHILDREN = [
|
||||
/^客服中心/,
|
||||
/^财务中心/,
|
||||
/^运营中心/,
|
||||
/^总经理室/,
|
||||
/^直播中心/,
|
||||
/^商务中心/,
|
||||
]
|
||||
|
||||
function splitDepartmentPath(fullPath: string | null | undefined): string[] {
|
||||
return String(fullPath ?? '')
|
||||
.split('>')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getBusinessParts(fullPath: string | null | undefined): string[] {
|
||||
const parts = splitDepartmentPath(fullPath)
|
||||
if (parts[0] && /公司|有限公司/.test(parts[0])) {
|
||||
return parts.slice(1)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function includesSegment(parts: string[], pattern: RegExp): boolean {
|
||||
return parts.some((part) => pattern.test(part))
|
||||
}
|
||||
|
||||
function isBrandException(parts: string[]): boolean {
|
||||
return includesSegment(parts, /空间视觉部|商品企划部/)
|
||||
}
|
||||
|
||||
function uniqRoles(roles: BusinessAccountType[]): BusinessAccountType[] {
|
||||
return [...new Set(roles)]
|
||||
}
|
||||
|
||||
export function inferBusinessRolesFromDepartmentPath(
|
||||
fullPath: string | null | undefined,
|
||||
): BusinessRoleInferenceResult {
|
||||
const parts = getBusinessParts(fullPath)
|
||||
const root = parts[0] ?? ''
|
||||
const child = parts[1] ?? ''
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
businessRoles: [],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: false,
|
||||
reason: '未分配部门',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '品牌中心' && !isBrandException(parts)) {
|
||||
return {
|
||||
businessRoles: [],
|
||||
excluded: true,
|
||||
shouldWarnWhenEmpty: false,
|
||||
reason: '品牌中心默认不参与业务类型,空间视觉部/商品企划部除外',
|
||||
}
|
||||
}
|
||||
|
||||
if (EXCLUDED_ROOTS.has(root) || /^运营中心/.test(root) || /^效率部/.test(root)) {
|
||||
return {
|
||||
businessRoles: [],
|
||||
excluded: true,
|
||||
shouldWarnWhenEmpty: false,
|
||||
reason: `${root} 不参与业务类型`,
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '龙晨品牌中心') {
|
||||
if (LONGCHEN_EXCLUDED_CHILDREN.some((pattern) => pattern.test(child))) {
|
||||
return {
|
||||
businessRoles: [],
|
||||
excluded: true,
|
||||
shouldWarnWhenEmpty: false,
|
||||
reason: `龙晨品牌中心 > ${child} 不参与业务类型`,
|
||||
}
|
||||
}
|
||||
|
||||
if (/^供应链中心/.test(child)) {
|
||||
return {
|
||||
businessRoles: ['PURCHASING'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '龙晨品牌中心供应链节点默认归采购',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^营销策划中心/.test(child)) {
|
||||
return {
|
||||
businessRoles: ['PLANNING'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '龙晨品牌中心营销策划节点默认归企划',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
businessRoles: [],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '龙晨品牌中心需要按内部节点确认业务类型',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '供应商') {
|
||||
return {
|
||||
businessRoles: ['SUPPLIER'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '供应商节点默认归供应商',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '仓储中心') {
|
||||
return {
|
||||
businessRoles: ['WAREHOUSE'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '仓储中心默认归仓库',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '供应链中心') {
|
||||
return {
|
||||
businessRoles: ['PURCHASING'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '供应链中心默认归采购',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '产品研发部') {
|
||||
return {
|
||||
businessRoles: ['DESIGN'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '产品研发部默认归设计',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '品牌中心' && includesSegment(parts, /空间视觉部/)) {
|
||||
return {
|
||||
businessRoles: ['DESIGN'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '品牌中心空间视觉部默认归设计',
|
||||
}
|
||||
}
|
||||
|
||||
if (root === '品牌中心' && includesSegment(parts, /商品企划部/)) {
|
||||
return {
|
||||
businessRoles: ['PLANNING'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '品牌中心商品企划部默认归企划',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
businessRoles: uniqRoles([]),
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '该部门不在自动映射表内,需要激活人确认业务类型',
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,8 @@ describe('open-platform-demo helpers', () => {
|
||||
id: 'user-designer',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* 实现细粒度的权限控制逻辑
|
||||
*/
|
||||
|
||||
import { Role, UserStatus } from '@prisma/client'
|
||||
import { Role, UserStatus, type BusinessAccountType } from '@prisma/client'
|
||||
import { departmentRepository } from '@/server/repositories/department.repository'
|
||||
import { userRepository } from '@/server/repositories/user.repository'
|
||||
import {
|
||||
@ -14,7 +14,6 @@ import {
|
||||
getManagedTeamId,
|
||||
isAdmin,
|
||||
isDepartmentSupervisor,
|
||||
isReadOnlyUser,
|
||||
isSupervisor,
|
||||
isTeamSupervisor,
|
||||
} from '@/lib/role-scope'
|
||||
@ -27,6 +26,7 @@ export interface UserInfo {
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
businessRoles?: BusinessAccountType[]
|
||||
status: UserStatus
|
||||
departmentId?: string
|
||||
teamId?: string
|
||||
@ -199,7 +199,7 @@ export function canCreateProduct(user: UserInfo): boolean {
|
||||
export function canEditProduct(user: UserInfo, product: ProductInfo): boolean {
|
||||
const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers)
|
||||
|
||||
if (isReadOnlyUser(user)) return false
|
||||
if (!canCreateContent(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
@ -219,7 +219,7 @@ export function canEditProduct(user: UserInfo, product: ProductInfo): boolean {
|
||||
* 检查是否可以删除产品
|
||||
*/
|
||||
export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean {
|
||||
if (isReadOnlyUser(user)) return false
|
||||
if (!canCreateContent(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
@ -233,6 +233,7 @@ export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean
|
||||
* 检查是否可以添加协作者
|
||||
*/
|
||||
export function canAddCollaborator(user: UserInfo, product: ProductInfo): boolean {
|
||||
if (!canCreateContent(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
@ -266,7 +267,7 @@ export function canCreateSKU(user: UserInfo): boolean {
|
||||
export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo): boolean {
|
||||
const collaboratorUsers = product ? getCollaboratorUsers(product.collaboratorUsers) : []
|
||||
|
||||
if (isReadOnlyUser(user)) return false
|
||||
if (!canCreateContent(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
if (sku.ownerId === user.id) return true
|
||||
|
||||
@ -289,8 +290,8 @@ export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo):
|
||||
* 检查是否可以删除 SKU
|
||||
*/
|
||||
export function canDeleteSKU(user: UserInfo, sku: SKUInfo): boolean {
|
||||
if (!canCreateContent(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
if (isReadOnlyUser(user)) return false
|
||||
return sku.ownerId === user.id
|
||||
}
|
||||
|
||||
@ -426,7 +427,7 @@ export function canViewActivityLogs(user: UserInfo): boolean {
|
||||
* 检查是否可以导出数据
|
||||
*/
|
||||
export function canExportData(user: UserInfo): boolean {
|
||||
return isAdmin(user) || isDepartmentSupervisor(user) || isSupervisor(user) || user.role === 'DESIGNER' || user.role === 'USER'
|
||||
return isAdmin(user) || isDepartmentSupervisor(user) || isSupervisor(user) || user.role === 'USER'
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -463,6 +464,7 @@ export async function getUserInfo(userId: string): Promise<UserInfo | null> {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
status: user.status,
|
||||
departmentId: user.departmentId || undefined,
|
||||
teamId: user.teamId || undefined,
|
||||
|
||||
24
furniture-3d-system/src/lib/role-scope.test.ts
Normal file
24
furniture-3d-system/src/lib/role-scope.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { canCreateContent, hasBusinessRole, isReadOnlyUser } from './role-scope'
|
||||
|
||||
describe('role-scope business roles', () => {
|
||||
it('内部业务类型用户可以写入主业务', () => {
|
||||
expect(canCreateContent({ role: 'USER', businessRoles: ['DESIGN'] })).toBe(true)
|
||||
expect(canCreateContent({ role: 'USER', businessRoles: ['PLANNING'] })).toBe(true)
|
||||
expect(canCreateContent({ role: 'USER', businessRoles: ['PURCHASING'] })).toBe(true)
|
||||
expect(canCreateContent({ role: 'USER', businessRoles: ['WAREHOUSE'] })).toBe(true)
|
||||
})
|
||||
|
||||
it('管理角色本身不自动获得业务写权限', () => {
|
||||
expect(canCreateContent({ role: 'ADMIN', businessRoles: [] })).toBe(false)
|
||||
expect(canCreateContent({ role: 'SUPERVISOR', businessRoles: [] })).toBe(false)
|
||||
})
|
||||
|
||||
it('供应商和无业务类型普通用户保持只读主链', () => {
|
||||
expect(canCreateContent({ role: 'USER', businessRoles: ['SUPPLIER'] })).toBe(false)
|
||||
expect(isReadOnlyUser({ role: 'USER', businessRoles: [] })).toBe(true)
|
||||
})
|
||||
|
||||
it('设计业务类型取代旧设计师权限角色', () => {
|
||||
expect(hasBusinessRole({ role: 'USER', businessRoles: ['DESIGN'] }, 'DESIGN')).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,16 +1,25 @@
|
||||
import type { UserRole } from '@/server/core/types'
|
||||
import type { BusinessAccountType } from '@prisma/client'
|
||||
|
||||
export type AllowedRole = UserRole | 'SUPERVISOR_DEPARTMENT' | 'SUPERVISOR_TEAM'
|
||||
|
||||
export interface ScopedRoleUser {
|
||||
role: UserRole
|
||||
businessRoles?: BusinessAccountType[] | string[] | null
|
||||
departmentId?: string | null
|
||||
teamId?: string | null
|
||||
managedDepartmentId?: string | null
|
||||
managedTeamId?: string | null
|
||||
}
|
||||
|
||||
const ROLE_HIERARCHY: UserRole[] = ['USER', 'DESIGNER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN']
|
||||
const ROLE_HIERARCHY: UserRole[] = ['USER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN']
|
||||
const USER_ROLE_SET = new Set<string>(ROLE_HIERARCHY)
|
||||
const INTERNAL_CONTENT_BUSINESS_ROLES = new Set<BusinessAccountType>([
|
||||
'DESIGN',
|
||||
'PLANNING',
|
||||
'PURCHASING',
|
||||
'WAREHOUSE',
|
||||
])
|
||||
|
||||
export function getRoleRank(role: UserRole): number {
|
||||
return ROLE_HIERARCHY.indexOf(role)
|
||||
@ -28,6 +37,11 @@ export function getAssignableRolesForRole(operatorRole: UserRole): UserRole[] {
|
||||
return ROLE_HIERARCHY.filter((role) => canAssignRoleByHierarchy(operatorRole, role))
|
||||
}
|
||||
|
||||
export function normalizeUserRole(value: unknown): UserRole | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
return USER_ROLE_SET.has(value) ? (value as UserRole) : undefined
|
||||
}
|
||||
|
||||
export function getManagedDepartmentId(user: ScopedRoleUser): string | undefined {
|
||||
return user.managedDepartmentId ?? user.departmentId ?? undefined
|
||||
}
|
||||
@ -57,15 +71,27 @@ export function isTeamSupervisor(user: ScopedRoleUser): boolean {
|
||||
}
|
||||
|
||||
export function isDesigner(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'DESIGNER'
|
||||
return hasBusinessRole(user, 'DESIGN')
|
||||
}
|
||||
|
||||
export function isReadOnlyUser(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'USER'
|
||||
return user.role === 'USER' && !hasInternalContentBusinessRole(user)
|
||||
}
|
||||
|
||||
export function canCreateContent(user: ScopedRoleUser): boolean {
|
||||
return !isReadOnlyUser(user)
|
||||
return hasInternalContentBusinessRole(user)
|
||||
}
|
||||
|
||||
export function hasBusinessRole(user: ScopedRoleUser, role: BusinessAccountType): boolean {
|
||||
return Boolean(user.businessRoles?.includes(role))
|
||||
}
|
||||
|
||||
export function hasInternalContentBusinessRole(user: ScopedRoleUser): boolean {
|
||||
return Boolean(
|
||||
user.businessRoles?.some((role) =>
|
||||
INTERNAL_CONTENT_BUSINESS_ROLES.has(role as BusinessAccountType),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAllowedRole(user: ScopedRoleUser, allowedRoles: readonly AllowedRole[]): boolean {
|
||||
|
||||
@ -29,6 +29,7 @@ export async function requireSession(redirectPath = '/login'): Promise<{
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
status: user.status,
|
||||
departmentId: user.department?.id || undefined,
|
||||
teamId: user.team?.id || undefined,
|
||||
|
||||
@ -10,7 +10,7 @@ import type { ApiResponse, PaginationMeta, ResponseMeta, AuthUser, RequestContex
|
||||
import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors'
|
||||
import { HTTP_STATUS, ENV } from './constants'
|
||||
import type { AllowedRole } from '@/lib/role-scope'
|
||||
import { hasAllowedRole } from '@/lib/role-scope'
|
||||
import { canCreateContent, hasAllowedRole } from '@/lib/role-scope'
|
||||
|
||||
// ============ 响应构建器 ============
|
||||
|
||||
@ -145,6 +145,8 @@ interface HandleRequestOptions {
|
||||
requireAuth?: boolean
|
||||
/** 允许的角色 */
|
||||
allowedRoles?: AllowedRole[]
|
||||
/** 是否需要内部业务写入权限 */
|
||||
requireInternalContentWrite?: boolean
|
||||
}
|
||||
|
||||
// 函数重载:requireAuth: false 时使用 OptionalAuthRequestContext
|
||||
@ -195,6 +197,15 @@ export async function handleRequest<T>(
|
||||
)
|
||||
}
|
||||
|
||||
if (options.requireInternalContentWrite && !canCreateContent(user)) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'当前账号没有内部业务写入权限',
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
requestId
|
||||
)
|
||||
}
|
||||
|
||||
// 构建认证上下文(user 必填)
|
||||
const ctx: RequestContext = {
|
||||
request,
|
||||
@ -259,6 +270,7 @@ async function getUserFromRequest(request: NextRequest): Promise<AuthUser | unde
|
||||
email: result.userInfo.email,
|
||||
name: result.userInfo.name,
|
||||
role: result.userInfo.role as AuthUser['role'],
|
||||
businessRoles: result.userInfo.businessRoles,
|
||||
status: result.userInfo.status as AuthUser['status'],
|
||||
departmentId: result.userInfo.departmentId,
|
||||
teamId: result.userInfo.teamId,
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import type { BusinessAccountType } from '@prisma/client'
|
||||
|
||||
// ============ API 响应类型 ============
|
||||
|
||||
@ -57,7 +58,6 @@ export type UserRole =
|
||||
| 'SUPER_ADMIN'
|
||||
| 'ADMIN'
|
||||
| 'SUPERVISOR'
|
||||
| 'DESIGNER'
|
||||
| 'USER'
|
||||
|
||||
/**
|
||||
@ -68,6 +68,7 @@ export interface AuthUser {
|
||||
email: string
|
||||
name: string
|
||||
role: UserRole
|
||||
businessRoles?: BusinessAccountType[]
|
||||
status: 'ACTIVE' | 'PENDING' | 'INACTIVE' | 'SUSPENDED'
|
||||
departmentId?: string
|
||||
teamId?: string
|
||||
|
||||
@ -30,6 +30,7 @@ export interface UserListItemDTO {
|
||||
department?: {
|
||||
id: string
|
||||
name: string
|
||||
fullPath?: string
|
||||
} | null
|
||||
team?: {
|
||||
id: string
|
||||
@ -88,6 +89,7 @@ export interface CurrentUserDTO {
|
||||
department?: {
|
||||
id: string
|
||||
name: string
|
||||
fullPath: string
|
||||
} | null
|
||||
team?: {
|
||||
id: string
|
||||
@ -178,7 +180,7 @@ export function toUserListItemDTO(
|
||||
user: User & {
|
||||
departmentId?: string | null
|
||||
teamId?: string | null
|
||||
department?: Pick<Department, 'id' | 'name'> | null
|
||||
department?: Pick<Department, 'id' | 'name' | 'fullPath'> | null
|
||||
team?: Pick<Team, 'id' | 'name'> | null
|
||||
employeeDirectory?: Parameters<typeof toEmployeeDirectoryDTO>[0]
|
||||
accountTemplateId?: string | null
|
||||
@ -203,7 +205,7 @@ export function toUserListItemDTO(
|
||||
departmentId: user.departmentId,
|
||||
teamId: user.teamId,
|
||||
department: user.department
|
||||
? { id: user.department.id, name: user.department.name }
|
||||
? { id: user.department.id, name: user.department.name, fullPath: user.department.fullPath }
|
||||
: null,
|
||||
team: user.team
|
||||
? { id: user.team.id, name: user.team.name }
|
||||
@ -266,7 +268,7 @@ export function toUserDetailDTO(
|
||||
*/
|
||||
export function toCurrentUserDTO(
|
||||
user: User & {
|
||||
department?: Pick<Department, 'id' | 'name'> | null
|
||||
department?: Pick<Department, 'id' | 'name' | 'fullPath'> | null
|
||||
team?: Pick<Team, 'id' | 'name'> | null
|
||||
},
|
||||
permissions: string[]
|
||||
@ -280,7 +282,7 @@ export function toCurrentUserDTO(
|
||||
businessRoles: user.businessRoles,
|
||||
permissions,
|
||||
department: user.department
|
||||
? { id: user.department.id, name: user.department.name }
|
||||
? { id: user.department.id, name: user.department.name, fullPath: user.department.fullPath }
|
||||
: null,
|
||||
team: user.team
|
||||
? { id: user.team.id, name: user.team.name }
|
||||
|
||||
@ -13,6 +13,7 @@ export type AuthUserWithRelations = Prisma.UserGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
name: true
|
||||
fullPath: true
|
||||
}
|
||||
}
|
||||
team: {
|
||||
@ -25,6 +26,7 @@ export type AuthUserWithRelations = Prisma.UserGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
name: true
|
||||
fullPath: true
|
||||
}
|
||||
}
|
||||
managedTeam: {
|
||||
@ -61,9 +63,9 @@ class AuthRepository {
|
||||
return prisma.user.findFirst({
|
||||
where: isEmail ? { email: identifier } : { name: identifier },
|
||||
include: {
|
||||
department: { select: { id: true, name: true } },
|
||||
department: { select: { id: true, name: true, fullPath: true } },
|
||||
team: { select: { id: true, name: true } },
|
||||
managedDepartment: { select: { id: true, name: true } },
|
||||
managedDepartment: { select: { id: true, name: true, fullPath: true } },
|
||||
managedTeam: { select: { id: true, name: true } },
|
||||
employeeDirectory: { select: { id: true, sourceStatus: true } },
|
||||
},
|
||||
@ -155,9 +157,9 @@ class AuthRepository {
|
||||
return prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { select: { id: true, name: true } },
|
||||
department: { select: { id: true, name: true, fullPath: true } },
|
||||
team: { select: { id: true, name: true } },
|
||||
managedDepartment: { select: { id: true, name: true } },
|
||||
managedDepartment: { select: { id: true, name: true, fullPath: true } },
|
||||
managedTeam: { select: { id: true, name: true } },
|
||||
employeeDirectory: { select: { id: true, sourceStatus: true } },
|
||||
},
|
||||
|
||||
@ -23,9 +23,9 @@ class HandoverRepository {
|
||||
return prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { select: { name: true } },
|
||||
department: { select: { name: true, fullPath: true } },
|
||||
team: { select: { name: true } },
|
||||
managedDepartment: { select: { id: true, name: true } },
|
||||
managedDepartment: { select: { id: true, name: true, fullPath: true } },
|
||||
managedTeam: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
@ -71,7 +71,6 @@ class HandoverRepository {
|
||||
where: { id: departmentId },
|
||||
include: {
|
||||
members: { select: { id: true, name: true } },
|
||||
teams: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ class UserRepositoryClass extends BaseRepository<
|
||||
where: { email },
|
||||
include: {
|
||||
department: {
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, fullPath: true },
|
||||
},
|
||||
team: {
|
||||
select: { id: true, name: true },
|
||||
@ -73,7 +73,7 @@ class UserRepositoryClass extends BaseRepository<
|
||||
where: { name },
|
||||
include: {
|
||||
department: {
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, fullPath: true },
|
||||
},
|
||||
team: {
|
||||
select: { id: true, name: true },
|
||||
@ -96,6 +96,32 @@ class UserRepositoryClass extends BaseRepository<
|
||||
})
|
||||
}
|
||||
|
||||
async findByEmployeeIdentity(input: {
|
||||
employeeDirectoryId?: string | null
|
||||
dingtalkUserId?: string | null
|
||||
dingtalkUnionId?: string | null
|
||||
}): Promise<User | null> {
|
||||
const orConditions: Prisma.UserWhereInput[] = []
|
||||
|
||||
if (input.employeeDirectoryId) {
|
||||
orConditions.push({ employeeDirectoryId: input.employeeDirectoryId })
|
||||
}
|
||||
if (input.dingtalkUserId) {
|
||||
orConditions.push({ dingtalkUserId: input.dingtalkUserId })
|
||||
}
|
||||
if (input.dingtalkUnionId) {
|
||||
orConditions.push({ dingtalkUnionId: input.dingtalkUnionId })
|
||||
}
|
||||
|
||||
if (orConditions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.prisma.user.findFirst({
|
||||
where: { OR: orConditions },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查邮箱是否已存在
|
||||
*/
|
||||
@ -144,7 +170,7 @@ class UserRepositoryClass extends BaseRepository<
|
||||
take,
|
||||
include: {
|
||||
department: {
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, fullPath: true },
|
||||
},
|
||||
team: {
|
||||
select: { id: true, name: true },
|
||||
@ -231,7 +257,7 @@ class UserRepositoryClass extends BaseRepository<
|
||||
email: true,
|
||||
avatar: true,
|
||||
role: true,
|
||||
department: { select: { name: true } },
|
||||
department: { select: { name: true, fullPath: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
@ -250,7 +276,7 @@ class UserRepositoryClass extends BaseRepository<
|
||||
email: true,
|
||||
avatar: true,
|
||||
role: true,
|
||||
department: { select: { name: true } },
|
||||
department: { select: { name: true, fullPath: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
@ -264,6 +290,7 @@ class UserRepositoryClass extends BaseRepository<
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
businessRoles: true,
|
||||
status: true,
|
||||
departmentId: true,
|
||||
teamId: true,
|
||||
|
||||
@ -63,7 +63,8 @@ describe('AssemblyServiceClass.saveInstallOrderAndGenerateStepsForUser', () => {
|
||||
id: 'user-1',
|
||||
email: 'user-1@example.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
|
||||
@ -61,7 +61,8 @@ describe('AssemblyServiceClass.getAssemblyPdfData', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
|
||||
@ -40,7 +40,8 @@ describe('assetCenterService review workflow', () => {
|
||||
id: 'user-designer',
|
||||
email: 'designer@example.com',
|
||||
name: '设计师',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -168,7 +168,8 @@ describe('draftGroupService manual assembly', () => {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
@ -212,7 +213,8 @@ describe('draftGroupService manual assembly', () => {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: '测试用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -215,7 +215,8 @@ describe('draftGroupService', () => {
|
||||
id: 'user-1',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -23,7 +23,8 @@ const baseUser = {
|
||||
id: 'creator-1',
|
||||
email: 'creator@example.com',
|
||||
name: 'Creator',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -64,7 +64,8 @@ describe('intakeService', () => {
|
||||
id: 'user-1',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -268,7 +268,8 @@ describe('openPlatformDemoSeedService', () => {
|
||||
id: 'user-planner',
|
||||
email: 'planner@example.com',
|
||||
name: '开放平台录入员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@ -62,7 +62,8 @@ describe('planningService', () => {
|
||||
id: 'user-1',
|
||||
email: 'planner@example.com',
|
||||
name: '规划员',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -55,7 +55,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '待离职员工',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'INACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -77,7 +78,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-1',
|
||||
name: '待离职员工',
|
||||
email: 'user@example.com',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'INACTIVE',
|
||||
department: { name: '设计部' },
|
||||
team: { name: 'A 组' },
|
||||
@ -126,7 +128,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '待离职员工',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'INACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -144,7 +147,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-2',
|
||||
email: 'target@example.com',
|
||||
name: '接收人',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'PENDING',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: true,
|
||||
@ -185,7 +189,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '待离职员工',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'INACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -203,7 +208,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-2',
|
||||
email: 'receiver@example.com',
|
||||
name: '接收人',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -282,7 +288,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-outside',
|
||||
email: 'outside@example.com',
|
||||
name: '外部用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -315,7 +322,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-1',
|
||||
name: '待离职员工',
|
||||
email: 'user@example.com',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'INACTIVE',
|
||||
department: { name: '设计部' },
|
||||
team: { name: 'A 组' },
|
||||
@ -344,7 +352,8 @@ describe('userHandoverService', () => {
|
||||
id: 'user-outside-team',
|
||||
email: 'outside-team@example.com',
|
||||
name: '外组成员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { userManagementService } from '../user-management.service'
|
||||
import { departmentService } from '../department.service'
|
||||
import { teamService } from '../team.service'
|
||||
import { userService } from '../user.service'
|
||||
|
||||
jest.mock('../department.service', () => ({
|
||||
@ -10,12 +9,6 @@ jest.mock('../department.service', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../team.service', () => ({
|
||||
teamService: {
|
||||
findAll: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../user.service', () => ({
|
||||
userService: {
|
||||
listAll: jest.fn(),
|
||||
@ -25,7 +18,6 @@ jest.mock('../user.service', () => ({
|
||||
|
||||
describe('userManagementService.getOrganizationTree', () => {
|
||||
const mockedDepartmentService = jest.mocked(departmentService)
|
||||
const mockedTeamService = jest.mocked(teamService)
|
||||
const mockedUserService = jest.mocked(userService)
|
||||
|
||||
beforeEach(() => {
|
||||
@ -56,23 +48,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
orderIndex: 1,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
_count: { members: 1, teams: 1, children: 0 },
|
||||
},
|
||||
] as never)
|
||||
|
||||
mockedTeamService.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'team-child',
|
||||
name: '策划执行组',
|
||||
description: '品牌中心执行小组',
|
||||
departmentId: 'dept-child',
|
||||
department: { id: 'dept-child', name: '策划一组' },
|
||||
leaderId: 'leader-1',
|
||||
leader: { id: 'leader-1', name: '组长甲', email: 'leader@example.com' },
|
||||
orderIndex: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
_count: { members: 1 },
|
||||
_count: { members: 1, teams: 0, children: 0 },
|
||||
},
|
||||
] as never)
|
||||
|
||||
@ -81,7 +57,8 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
id: 'user-root',
|
||||
email: 'root@example.com',
|
||||
name: '部门成员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -89,7 +66,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
avatar: null,
|
||||
phone: '13800000000',
|
||||
departmentId: 'dept-root',
|
||||
department: { id: 'dept-root', name: '品牌中心' },
|
||||
department: { id: 'dept-root', name: '品牌中心', fullPath: '品牌中心' },
|
||||
teamId: null,
|
||||
team: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
@ -107,9 +84,9 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
avatar: null,
|
||||
phone: null,
|
||||
departmentId: 'dept-child',
|
||||
department: { id: 'dept-child', name: '策划一组' },
|
||||
teamId: 'team-child',
|
||||
team: { id: 'team-child', name: '策划执行组' },
|
||||
department: { id: 'dept-child', name: '策划一组', fullPath: '品牌中心 > 策划一组' },
|
||||
teamId: null,
|
||||
team: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
lastLoginAt: null,
|
||||
},
|
||||
@ -147,6 +124,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
expect(mockedUserService.listAll).toHaveBeenCalledWith({
|
||||
search: '成员',
|
||||
role: undefined,
|
||||
businessRole: undefined,
|
||||
status: undefined,
|
||||
departmentIds: undefined,
|
||||
})
|
||||
@ -159,10 +137,8 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
expect(result.departments[0]?.users).toHaveLength(1)
|
||||
expect(result.departments[0]?.summary.directUserCount).toBe(1)
|
||||
expect(result.departments[0]?.summary.totalUserCount).toBe(2)
|
||||
expect(result.departments[0]?.children[0]?.users).toHaveLength(0)
|
||||
expect(result.departments[0]?.children[0]?.teams).toHaveLength(1)
|
||||
expect(result.departments[0]?.children[0]?.teams[0]?.summary.memberCount).toBe(1)
|
||||
expect(result.departments[0]?.children[0]?.teams[0]?.users).toHaveLength(1)
|
||||
expect(result.departments[0]?.children[0]?.users).toHaveLength(1)
|
||||
expect(result.departments[0]?.children[0]?.teams).toHaveLength(0)
|
||||
expect(result.unassignedUsers).toHaveLength(1)
|
||||
})
|
||||
|
||||
@ -190,7 +166,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
orderIndex: 1,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
_count: { members: 1, teams: 1, children: 0 },
|
||||
_count: { members: 1, teams: 0, children: 0 },
|
||||
},
|
||||
{
|
||||
id: 'dept-c',
|
||||
@ -206,29 +182,14 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
},
|
||||
] as never)
|
||||
|
||||
mockedTeamService.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'team-b',
|
||||
name: '运营推进组',
|
||||
description: null,
|
||||
departmentId: 'dept-b',
|
||||
department: { id: 'dept-b', name: '事业二部' },
|
||||
leaderId: 'leader-b',
|
||||
leader: { id: 'leader-b', name: '小组长', email: 'leader-b@example.com' },
|
||||
orderIndex: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
_count: { members: 1 },
|
||||
},
|
||||
] as never)
|
||||
|
||||
mockedUserService.getDepartmentIdsForManager.mockResolvedValue(['dept-a', 'dept-b'] as never)
|
||||
mockedUserService.listAll.mockResolvedValue([
|
||||
{
|
||||
id: 'user-b',
|
||||
email: 'member@example.com',
|
||||
name: '部门负责人下属',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: false,
|
||||
@ -236,9 +197,9 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
avatar: null,
|
||||
phone: '13812345678',
|
||||
departmentId: 'dept-b',
|
||||
department: { id: 'dept-b', name: '事业二部' },
|
||||
teamId: 'team-b',
|
||||
team: { id: 'team-b', name: '运营推进组' },
|
||||
department: { id: 'dept-b', name: '事业二部', fullPath: '运营中心 > 事业二部' },
|
||||
teamId: null,
|
||||
team: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
lastLoginAt: new Date('2026-01-03T00:00:00Z'),
|
||||
},
|
||||
@ -260,19 +221,20 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
expect(mockedUserService.listAll).toHaveBeenCalledWith({
|
||||
search: undefined,
|
||||
role: undefined,
|
||||
businessRole: undefined,
|
||||
status: undefined,
|
||||
departmentIds: ['dept-a', 'dept-b'],
|
||||
})
|
||||
expect(result.departments).toHaveLength(1)
|
||||
expect(result.departments[0]?.children).toHaveLength(1)
|
||||
expect(result.departments[0]?.children[0]?.teams).toHaveLength(1)
|
||||
expect(result.departments[0]?.children[0]?.teams[0]?.users[0]).toEqual(
|
||||
expect(result.departments[0]?.children[0]?.teams).toHaveLength(0)
|
||||
expect(result.departments[0]?.children[0]?.users[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
email: 'm***r@example.com',
|
||||
phone: '138****5678',
|
||||
})
|
||||
)
|
||||
expect(result.departments[0]?.children[0]?.teams[0]?.users[0]).not.toHaveProperty('lastLoginAt')
|
||||
expect(result.departments[0]?.children[0]?.users[0]).not.toHaveProperty('lastLoginAt')
|
||||
})
|
||||
|
||||
it('无权限角色不能读取组织树', async () => {
|
||||
|
||||
@ -16,6 +16,12 @@ jest.mock('../user.service', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../department.service', () => ({
|
||||
departmentService: {
|
||||
findById: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('userManagementService.activateUser', () => {
|
||||
const mockedLogRepository = jest.mocked(logRepository)
|
||||
const mockedUserService = jest.mocked(userService)
|
||||
@ -28,11 +34,12 @@ describe('userManagementService.activateUser', () => {
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
const designerUser = {
|
||||
const designBusinessUser = {
|
||||
id: 'designer-1',
|
||||
email: 'designer@example.com',
|
||||
name: '设计师',
|
||||
role: 'DESIGNER' as const,
|
||||
role: 'USER' as const,
|
||||
businessRoles: ['DESIGN'] as const,
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
@ -64,7 +71,8 @@ describe('userManagementService.activateUser', () => {
|
||||
id: 'user-pending',
|
||||
email: 'pending@example.com',
|
||||
name: '待激活用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: [],
|
||||
status: 'PENDING',
|
||||
departmentId: 'dept-a',
|
||||
teamId: 'team-a',
|
||||
@ -73,23 +81,25 @@ describe('userManagementService.activateUser', () => {
|
||||
id: 'user-pending',
|
||||
email: 'pending@example.com',
|
||||
name: '待激活用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-a',
|
||||
teamId: 'team-a',
|
||||
teamId: null,
|
||||
} as never)
|
||||
|
||||
const result = await userManagementService.activateUser(
|
||||
'user-pending',
|
||||
{},
|
||||
{ businessRoles: ['DESIGN'] },
|
||||
adminUser,
|
||||
)
|
||||
|
||||
expect(mockedUserService.getById).toHaveBeenCalledWith('user-pending')
|
||||
expect(mockedUserService.update).toHaveBeenCalledWith('user-pending', {
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: 'dept-a',
|
||||
teamId: 'team-a',
|
||||
teamId: null,
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
expect(mockedLogRepository.createLog).toHaveBeenCalledWith(
|
||||
@ -103,7 +113,7 @@ describe('userManagementService.activateUser', () => {
|
||||
expect(result.status).toBe('ACTIVE')
|
||||
})
|
||||
|
||||
it('主管可以激活本部门待激活用户,但只能分配更低角色', async () => {
|
||||
it('主管可以激活本部门待激活用户,并可设置设计业务类型', async () => {
|
||||
mockedUserService.getById.mockResolvedValue({
|
||||
id: 'user-pending',
|
||||
email: 'pending@example.com',
|
||||
@ -117,7 +127,8 @@ describe('userManagementService.activateUser', () => {
|
||||
id: 'user-pending',
|
||||
email: 'pending@example.com',
|
||||
name: '待激活设计师',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-a',
|
||||
teamId: null,
|
||||
@ -125,17 +136,19 @@ describe('userManagementService.activateUser', () => {
|
||||
|
||||
const result = await userManagementService.activateUser(
|
||||
'user-pending',
|
||||
{ role: 'DESIGNER' },
|
||||
{ role: 'USER', businessRoles: ['DESIGN'] },
|
||||
departmentSupervisor,
|
||||
)
|
||||
|
||||
expect(mockedUserService.update).toHaveBeenCalledWith('user-pending', {
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentId: 'dept-a',
|
||||
teamId: null,
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
expect(result.role).toBe('DESIGNER')
|
||||
expect(result.role).toBe('USER')
|
||||
expect(result.businessRoles).toEqual(['DESIGN'])
|
||||
})
|
||||
|
||||
it('主管不能把待激活账号激活成同级角色', async () => {
|
||||
@ -186,7 +199,7 @@ describe('userManagementService.activateUser', () => {
|
||||
|
||||
it('非管理员角色不能激活用户', async () => {
|
||||
await expect(
|
||||
userManagementService.activateUser('user-pending', {}, designerUser),
|
||||
userManagementService.activateUser('user-pending', {}, designBusinessUser),
|
||||
).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
})
|
||||
@ -200,7 +213,8 @@ describe('userManagementService.activateUser', () => {
|
||||
id: 'user-active',
|
||||
email: 'active@example.com',
|
||||
name: '已激活用户',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-a',
|
||||
teamId: 'team-a',
|
||||
@ -222,7 +236,8 @@ describe('userManagementService.activateUser', () => {
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
name: '设计师乙',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-a',
|
||||
teamId: null,
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
parseBusinessRolesInput,
|
||||
parseUserRoleInput,
|
||||
} from '@/server/validators/business-role.validator'
|
||||
import { Prisma, type BusinessAccountType, type Role } from '@prisma/client'
|
||||
|
||||
export interface AccountTemplateInput {
|
||||
@ -29,6 +33,14 @@ export interface AccountTemplateDTO {
|
||||
}
|
||||
|
||||
const DEFAULT_TEMPLATES: Array<AccountTemplateInput> = [
|
||||
{
|
||||
name: '设计岗位模板',
|
||||
code: 'design-default',
|
||||
description: '默认设计账号模板,负责产品研发、结构设计和视觉建模相关工作。',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
defaultScope: { visibility: 'workflow-and-users' },
|
||||
},
|
||||
{
|
||||
name: '企划岗位模板',
|
||||
code: 'planning-default',
|
||||
@ -139,13 +151,16 @@ class AccountTemplateService {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '模板名称和编码不能为空')
|
||||
}
|
||||
|
||||
const role = parseUserRoleInput(input.role, 'role') ?? 'USER'
|
||||
const businessRoles = parseBusinessRolesInput(input.businessRoles, { optional: true }) ?? []
|
||||
|
||||
const template = await prisma.accountTemplate.create({
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
code: input.code.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
role: input.role,
|
||||
businessRoles: input.businessRoles ?? [],
|
||||
role,
|
||||
businessRoles,
|
||||
defaultScope: input.defaultScope ?? Prisma.JsonNull,
|
||||
defaultFieldPermissions: input.defaultFieldPermissions ?? Prisma.JsonNull,
|
||||
isActive: input.isActive ?? true,
|
||||
@ -162,14 +177,19 @@ class AccountTemplateService {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '岗位模板不存在')
|
||||
}
|
||||
|
||||
const role = 'role' in input ? parseUserRoleInput(input.role, 'role') : undefined
|
||||
const businessRoles = 'businessRoles' in input
|
||||
? parseBusinessRolesInput(input.businessRoles, { optional: true })
|
||||
: undefined
|
||||
|
||||
const template = await prisma.accountTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name?.trim(),
|
||||
code: input.code?.trim(),
|
||||
description: input.description === undefined ? undefined : input.description?.trim() || null,
|
||||
role: input.role,
|
||||
businessRoles: input.businessRoles,
|
||||
role,
|
||||
businessRoles,
|
||||
defaultScope: input.defaultScope === undefined ? undefined : input.defaultScope ?? Prisma.JsonNull,
|
||||
defaultFieldPermissions:
|
||||
input.defaultFieldPermissions === undefined
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { canCreateContent } from '@/lib/role-scope'
|
||||
import { Prisma, type ApprovalWorkflowStatus, type BusinessAccountType } from '@prisma/client'
|
||||
import {
|
||||
getNonIntegratedFormalSpu,
|
||||
@ -117,7 +118,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
|
||||
label: '产品定位与设计卖点',
|
||||
section: '立项准备',
|
||||
helperText: '这里承接开品理由、目标人群、风格方向和设计卖点。',
|
||||
businessRole: 'PLANNING',
|
||||
businessRole: 'DESIGN',
|
||||
required: true,
|
||||
allowSecondEdit: true,
|
||||
placeholder: '说明为什么开这款、目标客群、风格、差异化卖点和预期定位。',
|
||||
@ -297,6 +298,14 @@ const LEGACY_FIELD_KEYS = new Set([
|
||||
'supplierNote',
|
||||
])
|
||||
|
||||
const BUSINESS_ROLE_SET = new Set<BusinessAccountType>([
|
||||
'DESIGN',
|
||||
'PLANNING',
|
||||
'PURCHASING',
|
||||
'WAREHOUSE',
|
||||
'SUPPLIER',
|
||||
])
|
||||
|
||||
class ApprovalWorkflowService {
|
||||
private toProductDTO(product: WorkflowForDTO['product']): ApprovalWorkflowProductDTO {
|
||||
const normalizedProduct = normalizeNonIntegratedProductRecord(
|
||||
@ -393,6 +402,11 @@ class ApprovalWorkflowService {
|
||||
}
|
||||
|
||||
private toDTO(workflow: WorkflowForDTO): ApprovalWorkflowDTO {
|
||||
const matrix = this.getFieldMatrix(workflow.fieldMatrix)
|
||||
const businessRoles = workflow.businessRoles.length > 0
|
||||
? workflow.businessRoles
|
||||
: this.getWorkflowBusinessRoles(matrix)
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
approvalNumber: workflow.approvalNumber,
|
||||
@ -400,11 +414,11 @@ class ApprovalWorkflowService {
|
||||
status: workflow.status,
|
||||
productId: workflow.productId,
|
||||
accessKey: workflow.accessKey,
|
||||
businessRoles: workflow.businessRoles,
|
||||
businessRoles,
|
||||
participantUserIds: workflow.participantUserIds,
|
||||
initiatorId: workflow.initiatorId,
|
||||
lastSyncedAt: workflow.lastSyncedAt?.toISOString() ?? null,
|
||||
fieldMatrix: this.getFieldMatrix(workflow.fieldMatrix) as unknown as Prisma.JsonValue,
|
||||
fieldMatrix: matrix as unknown as Prisma.JsonValue,
|
||||
summary: workflow.summary,
|
||||
metadata: workflow.metadata,
|
||||
product: this.toProductDTO(workflow.product),
|
||||
@ -420,7 +434,12 @@ class ApprovalWorkflowService {
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.key !== 'string' || typeof record.label !== 'string' || typeof record.businessRole !== 'string') {
|
||||
if (
|
||||
typeof record.key !== 'string' ||
|
||||
typeof record.label !== 'string' ||
|
||||
typeof record.businessRole !== 'string' ||
|
||||
!BUSINESS_ROLE_SET.has(record.businessRole as BusinessAccountType)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
items.push({
|
||||
@ -449,18 +468,26 @@ class ApprovalWorkflowService {
|
||||
return items
|
||||
}
|
||||
|
||||
private getWorkflowBusinessRoles(matrix: FieldMatrixItem[]): BusinessAccountType[] {
|
||||
return [...new Set(matrix.map((item) => item.businessRole))]
|
||||
}
|
||||
|
||||
private assertWorkflowAccess(
|
||||
workflow: {
|
||||
participantUserIds: string[]
|
||||
businessRoles: BusinessAccountType[]
|
||||
fieldMatrix?: Prisma.JsonValue | null
|
||||
},
|
||||
currentUser: AuthUser & { businessRoles?: BusinessAccountType[] }
|
||||
) {
|
||||
if (currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN') return
|
||||
if (workflow.participantUserIds.includes(currentUser.id)) return
|
||||
|
||||
const workflowBusinessRoles = workflow.businessRoles.length > 0
|
||||
? workflow.businessRoles
|
||||
: this.getWorkflowBusinessRoles(this.getFieldMatrix(workflow.fieldMatrix ?? null))
|
||||
const userBusinessRoles = currentUser.businessRoles ?? []
|
||||
if (workflow.businessRoles.some((role) => userBusinessRoles.includes(role))) return
|
||||
if (workflowBusinessRoles.some((role) => userBusinessRoles.includes(role))) return
|
||||
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不在该流程的参与范围内')
|
||||
}
|
||||
@ -470,12 +497,18 @@ class ApprovalWorkflowService {
|
||||
title: string
|
||||
productId: string
|
||||
status?: ApprovalWorkflowStatus
|
||||
businessRoles?: BusinessAccountType[]
|
||||
participantUserIds?: string[]
|
||||
fieldMatrix?: Prisma.InputJsonValue | null
|
||||
summary?: Prisma.InputJsonValue | null
|
||||
metadata?: Prisma.InputJsonValue | null
|
||||
}, currentUser: AuthUser): Promise<ApprovalWorkflowDTO> {
|
||||
if (!canCreateContent(currentUser)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有内部业务写入权限')
|
||||
}
|
||||
|
||||
const matrix = this.getFieldMatrix((input.fieldMatrix ?? null) as Prisma.JsonValue | null)
|
||||
const businessRoles = this.getWorkflowBusinessRoles(matrix)
|
||||
|
||||
const created = await prisma.approvalWorkflow.create({
|
||||
data: {
|
||||
approvalNumber: input.approvalNumber.trim(),
|
||||
@ -483,10 +516,10 @@ class ApprovalWorkflowService {
|
||||
productId: input.productId,
|
||||
status: input.status ?? 'DRAFT',
|
||||
accessKey: crypto.randomUUID(),
|
||||
businessRoles: input.businessRoles ?? [],
|
||||
businessRoles,
|
||||
participantUserIds: input.participantUserIds ?? [],
|
||||
initiatorId: currentUser.id,
|
||||
fieldMatrix: (input.fieldMatrix ?? DEFAULT_FIELD_MATRIX) as Prisma.InputJsonValue,
|
||||
fieldMatrix: matrix as unknown as Prisma.InputJsonValue,
|
||||
summary: input.summary ?? Prisma.JsonNull,
|
||||
metadata: input.metadata ?? Prisma.JsonNull,
|
||||
},
|
||||
@ -646,9 +679,6 @@ class ApprovalWorkflowService {
|
||||
|
||||
const userBusinessRoles = currentUser.businessRoles ?? []
|
||||
const responsible =
|
||||
currentUser.role === 'SUPER_ADMIN' ||
|
||||
currentUser.role === 'ADMIN' ||
|
||||
workflow.participantUserIds.includes(currentUser.id) ||
|
||||
userBusinessRoles.includes(targetField.businessRole)
|
||||
if (!responsible) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能编辑这个字段')
|
||||
|
||||
@ -72,9 +72,9 @@ export interface AuthUserDTO {
|
||||
activationRequired: boolean
|
||||
employeeDirectoryId: string | null
|
||||
isFirstLogin: boolean
|
||||
department: { id: string; name: string } | null
|
||||
department: { id: string; name: string; fullPath: string } | null
|
||||
team: { id: string; name: string } | null
|
||||
managedDepartment: { id: string; name: string } | null
|
||||
managedDepartment: { id: string; name: string; fullPath: string } | null
|
||||
managedTeam: { id: string; name: string } | null
|
||||
avatar: string | null
|
||||
lastLoginAt: Date | null
|
||||
|
||||
@ -7,6 +7,7 @@ import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { authService, type LoginResult } from './auth.service'
|
||||
import { resolvePostLoginRedirect } from './auth-redirect.service'
|
||||
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
|
||||
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference'
|
||||
|
||||
const DINGTALK_AUTH_URL = 'https://login.dingtalk.com/oauth2/auth'
|
||||
const DINGTALK_USER_ACCESS_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
|
||||
@ -315,6 +316,7 @@ class DingtalkAuthService {
|
||||
const departmentId = employeeDirectory?.primaryDepartmentExternalId
|
||||
? await this.resolveDepartmentIdByExternalId(employeeDirectory.primaryDepartmentExternalId)
|
||||
: null
|
||||
const businessRoles = departmentId ? await this.inferBusinessRolesByDepartmentId(departmentId) : []
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
@ -322,6 +324,7 @@ class DingtalkAuthService {
|
||||
name: loginName,
|
||||
password,
|
||||
role: 'USER',
|
||||
businessRoles,
|
||||
status: 'PENDING',
|
||||
isFirstLogin: false,
|
||||
avatar: profile.avatar || null,
|
||||
@ -353,6 +356,10 @@ class DingtalkAuthService {
|
||||
const departmentId = primaryDepartmentExternalId
|
||||
? await this.resolveDepartmentIdByExternalId(primaryDepartmentExternalId)
|
||||
: null
|
||||
const nextDepartmentId = departmentId ?? currentUser?.departmentId ?? null
|
||||
const businessRoles = nextDepartmentId
|
||||
? await this.inferBusinessRolesByDepartmentId(nextDepartmentId)
|
||||
: undefined
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
@ -364,7 +371,8 @@ class DingtalkAuthService {
|
||||
avatar: profile.avatar || undefined,
|
||||
phone: profile.mobile || undefined,
|
||||
employeeDirectoryId: employeeDirectoryId ?? undefined,
|
||||
departmentId: departmentId ?? currentUser?.departmentId ?? undefined,
|
||||
departmentId: nextDepartmentId ?? undefined,
|
||||
businessRoles,
|
||||
status: currentUser?.status ?? 'PENDING',
|
||||
},
|
||||
})
|
||||
@ -379,6 +387,15 @@ class DingtalkAuthService {
|
||||
return department?.id ?? null
|
||||
}
|
||||
|
||||
private async inferBusinessRolesByDepartmentId(departmentId: string) {
|
||||
const department = await prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { fullPath: true },
|
||||
})
|
||||
|
||||
return inferBusinessRolesFromDepartmentPath(department?.fullPath).businessRoles
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string | null | undefined): string | null {
|
||||
if (!email) {
|
||||
return null
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Prisma, type IntakeNonIntegratedProduct } from '@prisma/client'
|
||||
import { hasBusinessRole } from '@/lib/role-scope'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
|
||||
@ -91,7 +92,7 @@ class IntakeNonIntegratedScopeService {
|
||||
if (record.createdBy === user.id) return true
|
||||
|
||||
return (
|
||||
user.role === 'DESIGNER' &&
|
||||
hasBusinessRole(user, 'DESIGN') &&
|
||||
record.designer.trim().length > 0 &&
|
||||
record.designer.trim() === user.name.trim()
|
||||
)
|
||||
|
||||
@ -131,6 +131,7 @@ function toAuthUser(user: {
|
||||
email: string
|
||||
name: string
|
||||
role: AuthUser['role']
|
||||
businessRoles?: AuthUser['businessRoles']
|
||||
status: AuthUser['status']
|
||||
departmentId?: string | null
|
||||
teamId?: string | null
|
||||
@ -140,6 +141,7 @@ function toAuthUser(user: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles ?? [],
|
||||
status: user.status,
|
||||
departmentId: user.departmentId ?? undefined,
|
||||
teamId: user.teamId ?? undefined,
|
||||
@ -227,6 +229,7 @@ async function ensureSeedUser(input: {
|
||||
email: string
|
||||
name: string
|
||||
role: AuthUser['role']
|
||||
businessRoles?: AuthUser['businessRoles']
|
||||
}) {
|
||||
const hashedPassword = await hash(OPEN_PLATFORM_DEMO_PASSWORD, 10)
|
||||
return prisma.user.upsert({
|
||||
@ -235,6 +238,7 @@ async function ensureSeedUser(input: {
|
||||
name: input.name,
|
||||
password: hashedPassword,
|
||||
role: input.role,
|
||||
businessRoles: input.businessRoles ?? [],
|
||||
status: 'ACTIVE',
|
||||
isFirstLogin: false,
|
||||
},
|
||||
@ -243,6 +247,7 @@ async function ensureSeedUser(input: {
|
||||
name: input.name,
|
||||
password: hashedPassword,
|
||||
role: input.role,
|
||||
businessRoles: input.businessRoles ?? [],
|
||||
status: 'ACTIVE',
|
||||
isFirstLogin: false,
|
||||
},
|
||||
@ -992,7 +997,8 @@ class OpenPlatformDemoSeedService {
|
||||
const plannerRecord = await ensureSeedUser({
|
||||
email: 'open-platform-planner@example.com',
|
||||
name: '开放平台录入员',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
})
|
||||
const reviewerRecord = await ensureSeedUser({
|
||||
email: 'open-platform-reviewer@example.com',
|
||||
|
||||
@ -106,6 +106,7 @@ class UserHandoverService {
|
||||
name: targetUser.name,
|
||||
email: targetUser.email,
|
||||
role: targetUser.role,
|
||||
businessRoles: targetUser.businessRoles,
|
||||
status: targetUser.status,
|
||||
department: targetUser.department,
|
||||
team: targetUser.team,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user