feat(auth): 设计师角色迁移为设计业务类型

This commit is contained in:
WuYongJie 2026-05-14 18:26:06 +08:00
parent 3cd41b4921
commit d6ddac46c2
110 changed files with 2062 additions and 2891 deletions

View File

@ -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..."
}
}

View File

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

View File

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

View File

@ -932,7 +932,6 @@ enum Role {
SUPER_ADMIN // 超级管理员
ADMIN // 管理员
SUPERVISOR // 主管(按部门/小组管理锚点区分范围)
DESIGNER // 设计师
USER // 普通用户
}
@ -950,6 +949,7 @@ enum EmployeeSourceStatus {
}
enum BusinessAccountType {
DESIGN
PLANNING
PURCHASING
WAREHOUSE

View File

@ -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()

View File

@ -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' } },
});
})
);

View 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' }),
)
})
})

View File

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

View File

@ -40,6 +40,6 @@ export async function POST(
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -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 },
)
}

View File

@ -63,6 +63,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -119,6 +119,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -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 },
)
}

View File

@ -49,6 +49,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -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 },
)
}

View File

@ -50,6 +50,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -52,5 +52,5 @@ export async function POST(request: NextRequest) {
fileName,
size: file.size,
}, 201, requestId)
}, { requireAuth: true })
}, { requireAuth: true, requireInternalContentWrite: true })
}

View File

@ -40,6 +40,6 @@ export async function POST(
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -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 },
)
}

View File

@ -26,6 +26,6 @@ export async function POST(
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -95,6 +95,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

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

View File

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

View File

@ -51,6 +51,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
request,
async (ctx) => {
const designers = await userService.listAll({
role: "DESIGNER",
businessRole: "DESIGN",
status: "ACTIVE",
});

View File

@ -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'),
},
}),
)

View File

@ -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',
},
}),

View File

@ -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',
},
}),

View File

@ -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',
},
}),

View File

@ -56,6 +56,6 @@ export async function PUT(
throw error
}
},
{ requireAuth: true },
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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',
},
}),

View File

@ -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',

View File

@ -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',

View File

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

View File

@ -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(), {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 } : {}),

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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: '财神',
})

View File

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

View File

@ -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"]}
/>,
);

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -27,7 +27,6 @@ const roleLabelMap: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
DESIGNER: '设计师',
USER: '普通用户',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()}
/>,
)

View File

@ -39,6 +39,7 @@ export interface User {
department?: {
id: string;
name: string;
fullPath?: string;
} | null;
team?: {
id: string;

View File

@ -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: '仓库',

View 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: '该部门不在自动映射表内,需要激活人确认业务类型',
}
}

View File

@ -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',
},
{

View File

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

View 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)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 } },
},
})
}

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

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

View File

@ -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',

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

@ -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, '当前账号不能编辑这个字段')

View File

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

View File

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

View File

@ -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()
)

View File

@ -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',

View File

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