-
+
+
+
-
- {label}
-
-
- {value}
-
+ {label}
+ {value}
)
}
-function FooterLink({ label }: { label: string }) {
+function HealthBar({ label, progress, color = "bg-emerald-500" }: { label: string, progress: number, color?: string }) {
return (
-
+
+
+ {label}
+ {progress}%
+
+
+
+ )
+}
+
+function LogItem({ time, user, action }: { time: string, user: string, action: string }) {
+ return (
+
+
{time}
+
+ {user}
+ {action}
+
+
)
}
diff --git a/furniture-3d-system/src/app/api/admin/materials/[id]/route.ts b/furniture-3d-system/src/app/api/admin/materials/[id]/route.ts
index 25685edb..5517a473 100644
--- a/furniture-3d-system/src/app/api/admin/materials/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/admin/materials/[id]/route.ts
@@ -30,7 +30,7 @@ const updateMaterialSchema = z.object({
})
// 允许的管理角色
-const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
/**
* GET - 获取材质详情
diff --git a/furniture-3d-system/src/app/api/admin/materials/route.ts b/furniture-3d-system/src/app/api/admin/materials/route.ts
index 0396cea8..6b550de4 100644
--- a/furniture-3d-system/src/app/api/admin/materials/route.ts
+++ b/furniture-3d-system/src/app/api/admin/materials/route.ts
@@ -29,7 +29,7 @@ const createMaterialSchema = z.object({
})
// 允许的管理角色
-const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
/**
* GET - 获取所有材质(管理员视图,包含非活跃的)
diff --git a/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.ts b/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.ts
index 84edf65d..459ab5ac 100644
--- a/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.ts
+++ b/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.ts
@@ -15,7 +15,7 @@ const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
// 允许的管理角色
-const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
export async function POST(request: NextRequest) {
return handleRequest(request, async ({ requestId }) => {
diff --git a/furniture-3d-system/src/app/api/auth/register/route.ts b/furniture-3d-system/src/app/api/auth/register/route.ts
index 42289223..ce9e92b2 100644
--- a/furniture-3d-system/src/app/api/auth/register/route.ts
+++ b/furniture-3d-system/src/app/api/auth/register/route.ts
@@ -2,7 +2,7 @@
* 用户注册 API
* POST /api/auth/register
*
- * @security RISK-011 - 防止权限提升:注册只允许创建 DESIGNER 角色
+ * @security RISK-011 - 防止权限提升:注册只允许创建 USER 角色,且必须待后台激活
*/
import { NextRequest } from 'next/server'
@@ -17,13 +17,13 @@ interface RegisterRequest {
email: string
name: string
password: string
- // role 参数被忽略,注册只允许创建 DESIGNER 角色
+ // role 参数被忽略,注册只允许创建 USER 角色
departmentId?: string
teamId?: string
}
// 注册允许的默认角色(防止权限提升)
-const REGISTER_DEFAULT_ROLE = 'DESIGNER' as const
+const REGISTER_DEFAULT_ROLE = 'USER' as const
export async function POST(request: NextRequest) {
return handleRequest(
@@ -33,12 +33,13 @@ export async function POST(request: NextRequest) {
const { email, name, password, departmentId, teamId } = body
// 调用服务层创建用户
- // 强制使用 DESIGNER 角色,忽略用户传入的 role 参数,防止权限提升攻击
+ // 强制使用 USER 角色,忽略用户传入的 role 参数,防止权限提升攻击
const user = await userService.create({
email,
name,
password,
role: REGISTER_DEFAULT_ROLE,
+ status: 'PENDING',
departmentId,
teamId,
isFirstLogin: false,
@@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
})
return jsonSuccess(
- { message: '注册成功', user },
+ { message: '注册成功,请等待管理员激活', user },
201,
ctx.requestId
)
diff --git a/furniture-3d-system/src/app/api/categories/route.test.ts b/furniture-3d-system/src/app/api/categories/route.test.ts
index c092d852..56118c79 100644
--- a/furniture-3d-system/src/app/api/categories/route.test.ts
+++ b/furniture-3d-system/src/app/api/categories/route.test.ts
@@ -126,7 +126,7 @@ describe('GET/POST /api/categories', () => {
requestId: 'req-test',
user: {
id: 'viewer-1',
- role: 'VIEWER',
+ role: 'USER',
},
}),
)
diff --git a/furniture-3d-system/src/app/api/common-component-categories/route.ts b/furniture-3d-system/src/app/api/common-component-categories/route.ts
index b2e58363..9d470150 100644
--- a/furniture-3d-system/src/app/api/common-component-categories/route.ts
+++ b/furniture-3d-system/src/app/api/common-component-categories/route.ts
@@ -3,7 +3,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { commonComponentService } from '@/server/services'
-const COMPONENT_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const COMPONENT_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
export async function GET(request: NextRequest) {
return handleRequest(
diff --git a/furniture-3d-system/src/app/api/common-components/[id]/route.ts b/furniture-3d-system/src/app/api/common-components/[id]/route.ts
index fd8279b2..9dc59fc3 100644
--- a/furniture-3d-system/src/app/api/common-components/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/common-components/[id]/route.ts
@@ -3,7 +3,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { AppError } from '@/server/core/errors'
import { commonComponentService } from '@/server/services'
-const COMPONENT_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const COMPONENT_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
export async function GET(
request: NextRequest,
diff --git a/furniture-3d-system/src/app/api/common-components/route.ts b/furniture-3d-system/src/app/api/common-components/route.ts
index 545a8b77..c25b5511 100644
--- a/furniture-3d-system/src/app/api/common-components/route.ts
+++ b/furniture-3d-system/src/app/api/common-components/route.ts
@@ -4,7 +4,7 @@ import { ErrorCode } from '@/server/core/errors'
import { commonComponentService } from '@/server/services'
const MAX_MODEL_FILE_SIZE = 50 * 1024 * 1024
-const COMPONENT_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const COMPONENT_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
export async function GET(request: NextRequest) {
return handleRequest(
diff --git a/furniture-3d-system/src/app/api/feedbacks/[id]/route.ts b/furniture-3d-system/src/app/api/feedbacks/[id]/route.ts
index a9e83ce4..6456c805 100644
--- a/furniture-3d-system/src/app/api/feedbacks/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/feedbacks/[id]/route.ts
@@ -33,8 +33,8 @@ export async function GET(
role: user.role,
departmentId: user.departmentId,
teamId: user.teamId,
- managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined,
- managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined,
+ managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
+ managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
})
return jsonSuccess(result, 200, ctx.requestId)
@@ -88,8 +88,8 @@ export async function PUT(
role: user.role,
departmentId: user.departmentId,
teamId: user.teamId,
- managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined,
- managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined,
+ managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
+ managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
}
)
@@ -122,8 +122,8 @@ export async function DELETE(
role: user.role,
departmentId: user.departmentId,
teamId: user.teamId,
- managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined,
- managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined,
+ managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
+ managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
})
return jsonSuccess({ message: '反馈已删除' }, 200, ctx.requestId)
diff --git a/furniture-3d-system/src/app/api/feedbacks/route.ts b/furniture-3d-system/src/app/api/feedbacks/route.ts
index 2ce79938..5e310cd8 100644
--- a/furniture-3d-system/src/app/api/feedbacks/route.ts
+++ b/furniture-3d-system/src/app/api/feedbacks/route.ts
@@ -46,8 +46,8 @@ export async function GET(request: NextRequest) {
departmentId: user.departmentId,
teamId: user.teamId,
// 对于部门管理员,使用 departmentId 作为 managedDepartmentId
- managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined,
- managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined,
+ managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
+ managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
}
)
@@ -111,8 +111,8 @@ export async function POST(request: NextRequest) {
role: user.role,
departmentId: user.departmentId,
teamId: user.teamId,
- managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined,
- managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined,
+ managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
+ managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
}
)
diff --git a/furniture-3d-system/src/app/api/hardware/[id]/route.ts b/furniture-3d-system/src/app/api/hardware/[id]/route.ts
index c1e58bf4..4cd69027 100644
--- a/furniture-3d-system/src/app/api/hardware/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/hardware/[id]/route.ts
@@ -9,7 +9,7 @@ import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { AppError } from '@/server/core/errors'
import { hardwareService } from '@/server/services'
-const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
/**
* GET /api/hardware/[id]
diff --git a/furniture-3d-system/src/app/api/hardware/mapping/route.ts b/furniture-3d-system/src/app/api/hardware/mapping/route.ts
index 400f5404..5a5ec6a7 100644
--- a/furniture-3d-system/src/app/api/hardware/mapping/route.ts
+++ b/furniture-3d-system/src/app/api/hardware/mapping/route.ts
@@ -11,7 +11,7 @@ import { z } from 'zod'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { hardwareService } from '@/server/services'
-const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
// 创建映射请求验证
const CreateMappingSchema = z.object({
diff --git a/furniture-3d-system/src/app/api/hardware/route.ts b/furniture-3d-system/src/app/api/hardware/route.ts
index fb21c3ff..0abdd089 100644
--- a/furniture-3d-system/src/app/api/hardware/route.ts
+++ b/furniture-3d-system/src/app/api/hardware/route.ts
@@ -11,7 +11,7 @@ import { hardwareService } from '@/server/services'
// 文件大小限制(50MB)
const MAX_MODEL_FILE_SIZE = 50 * 1024 * 1024
-const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
/**
* GET /api/hardware
diff --git a/furniture-3d-system/src/app/api/hardware/sync/route.ts b/furniture-3d-system/src/app/api/hardware/sync/route.ts
index 7ecb3419..940a817b 100644
--- a/furniture-3d-system/src/app/api/hardware/sync/route.ts
+++ b/furniture-3d-system/src/app/api/hardware/sync/route.ts
@@ -15,7 +15,7 @@ import { z } from 'zod'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { hardwareService } from '@/server/services'
-const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+const HARDWARE_WRITE_ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] as const
// ==================== 请求验证 Schema ====================
diff --git a/furniture-3d-system/src/app/api/logs/route.ts b/furniture-3d-system/src/app/api/logs/route.ts
index 784682bb..902962c5 100644
--- a/furniture-3d-system/src/app/api/logs/route.ts
+++ b/furniture-3d-system/src/app/api/logs/route.ts
@@ -34,7 +34,7 @@ export async function GET(request: NextRequest) {
const pageSize = parseInt(searchParams.get('pageSize') || '20')
// 部门管理员只能查看本部门的日志
- const departmentId = user.role === 'DEPARTMENT_MANAGER' && user.departmentId
+ const departmentId = user.role === 'SUPERVISOR' && user.departmentId
? user.departmentId
: undefined
diff --git a/furniture-3d-system/src/app/api/products/[id]/packaging/route.test.ts b/furniture-3d-system/src/app/api/products/[id]/packaging/route.test.ts
index b9b0abcf..4197f334 100644
--- a/furniture-3d-system/src/app/api/products/[id]/packaging/route.test.ts
+++ b/furniture-3d-system/src/app/api/products/[id]/packaging/route.test.ts
@@ -39,7 +39,7 @@ describe('GET /api/products/[id]/packaging', () => {
id: 'user-1',
email: 'viewer@test.com',
name: '查看协作者',
- role: 'VIEWER',
+ role: 'USER',
status: 'ACTIVE',
},
}),
diff --git a/furniture-3d-system/src/app/api/reports/product-changes/route.ts b/furniture-3d-system/src/app/api/reports/product-changes/route.ts
index 6f2d03fc..ccfde77c 100644
--- a/furniture-3d-system/src/app/api/reports/product-changes/route.ts
+++ b/furniture-3d-system/src/app/api/reports/product-changes/route.ts
@@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
throw error
}
},
- { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
+ { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}
diff --git a/furniture-3d-system/src/app/api/reports/sku-transfers/route.ts b/furniture-3d-system/src/app/api/reports/sku-transfers/route.ts
index 8bb4132f..5620dcb4 100644
--- a/furniture-3d-system/src/app/api/reports/sku-transfers/route.ts
+++ b/furniture-3d-system/src/app/api/reports/sku-transfers/route.ts
@@ -30,6 +30,6 @@ export async function GET(request: NextRequest) {
throw error
}
},
- { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
+ { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}
diff --git a/furniture-3d-system/src/app/api/reports/user-activity/route.ts b/furniture-3d-system/src/app/api/reports/user-activity/route.ts
index 346e4a36..192cdaf1 100644
--- a/furniture-3d-system/src/app/api/reports/user-activity/route.ts
+++ b/furniture-3d-system/src/app/api/reports/user-activity/route.ts
@@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
throw error
}
},
- { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
+ { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}
diff --git a/furniture-3d-system/src/app/api/teams/[id]/route.ts b/furniture-3d-system/src/app/api/teams/[id]/route.ts
index 3730ebc9..80f36abb 100644
--- a/furniture-3d-system/src/app/api/teams/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/teams/[id]/route.ts
@@ -64,7 +64,7 @@ export async function PUT(
throw error
}
},
- { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
+ { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}
@@ -91,6 +91,6 @@ export async function DELETE(
throw error
}
},
- { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
+ { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}
diff --git a/furniture-3d-system/src/app/api/teams/route.ts b/furniture-3d-system/src/app/api/teams/route.ts
index cf77b95e..d7da23c7 100644
--- a/furniture-3d-system/src/app/api/teams/route.ts
+++ b/furniture-3d-system/src/app/api/teams/route.ts
@@ -49,6 +49,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
+ { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}
diff --git a/furniture-3d-system/src/app/api/users/[id]/handover/route.test.ts b/furniture-3d-system/src/app/api/users/[id]/handover/route.test.ts
index 7c658fb5..e0cdec6f 100644
--- a/furniture-3d-system/src/app/api/users/[id]/handover/route.test.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/handover/route.test.ts
@@ -75,7 +75,7 @@ describe('GET/POST /api/users/[id]/handover', () => {
expect.anything(),
expect.any(Function),
expect.objectContaining({
- allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER'],
+ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'],
}),
)
expect(response).toEqual(
@@ -128,7 +128,7 @@ describe('GET/POST /api/users/[id]/handover', () => {
expect.anything(),
expect.any(Function),
expect.objectContaining({
- allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER'],
+ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'],
}),
)
expect(response).toEqual(
diff --git a/furniture-3d-system/src/app/api/users/[id]/handover/route.ts b/furniture-3d-system/src/app/api/users/[id]/handover/route.ts
index ad2a2cae..4e7dc74b 100644
--- a/furniture-3d-system/src/app/api/users/[id]/handover/route.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/handover/route.ts
@@ -9,12 +9,13 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { userHandoverService } from '@/server/services/user-handover.service'
+import type { AllowedRole } from '@/lib/role-scope'
-const ALLOWED_HANDOVER_ROLES = [
+const ALLOWED_HANDOVER_ROLES: AllowedRole[] = [
'SUPER_ADMIN',
'ADMIN',
- 'DEPARTMENT_MANAGER',
- 'TEAM_LEADER',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
]
/**
diff --git a/furniture-3d-system/src/app/api/users/[id]/route.test.ts b/furniture-3d-system/src/app/api/users/[id]/route.test.ts
index 42a341f7..8e4837f1 100644
--- a/furniture-3d-system/src/app/api/users/[id]/route.test.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/route.test.ts
@@ -98,7 +98,7 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
mockUpdateUser.mockResolvedValue({
id: 'user-1',
name: '新名字',
- role: 'TEAM_LEADER',
+ role: 'SUPERVISOR',
departmentId: 'dept-2',
teamId: null,
status: 'ACTIVE',
@@ -107,7 +107,7 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
const response = await PUT(
createRequest({
name: '新名字',
- role: 'TEAM_LEADER',
+ role: 'SUPERVISOR',
departmentId: 'dept-2',
teamId: null,
phone: null,
@@ -124,7 +124,7 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
email: undefined,
name: '新名字',
password: undefined,
- role: 'TEAM_LEADER',
+ role: 'SUPERVISOR',
departmentId: 'dept-2',
teamId: null,
phone: null,
diff --git a/furniture-3d-system/src/app/api/users/organization-tree/route.ts b/furniture-3d-system/src/app/api/users/organization-tree/route.ts
index 5b69d971..b805e966 100644
--- a/furniture-3d-system/src/app/api/users/organization-tree/route.ts
+++ b/furniture-3d-system/src/app/api/users/organization-tree/route.ts
@@ -8,8 +8,9 @@ import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client'
+import type { AllowedRole } from '@/lib/role-scope'
-const ALLOWED_ROLES: Role[] = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']
+const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
export async function GET(request: NextRequest) {
return handleRequest(
diff --git a/furniture-3d-system/src/app/api/users/route.ts b/furniture-3d-system/src/app/api/users/route.ts
index 678a1cf6..b253f082 100644
--- a/furniture-3d-system/src/app/api/users/route.ts
+++ b/furniture-3d-system/src/app/api/users/route.ts
@@ -10,8 +10,9 @@ import { handleRequest, jsonSuccess, jsonSuccessWithPagination, jsonError } from
import { AppError, ErrorCode } from '@/server/core/errors'
import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client'
+import type { AllowedRole } from '@/lib/role-scope'
-const ALLOWED_ROLES: Role[] = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']
+const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
export async function GET(request: NextRequest) {
return handleRequest(
diff --git a/furniture-3d-system/src/app/api/warehouse/bindings/[variantId]/[locationId]/route.ts b/furniture-3d-system/src/app/api/warehouse/bindings/[variantId]/[locationId]/route.ts
new file mode 100644
index 00000000..21e206dc
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/bindings/[variantId]/[locationId]/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest } from 'next/server'
+import { AppError } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ variantId: string; locationId: string }> },
+) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const { variantId, locationId } = await params
+ try {
+ await warehouseService.unbindVariantFromLocation({ variantId, locationId })
+ return jsonSuccess({ unbound: true }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/warehouse/bindings/route.ts b/furniture-3d-system/src/app/api/warehouse/bindings/route.ts
new file mode 100644
index 00000000..7e6ededc
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/bindings/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { AppError, ErrorCode } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+const bindVariantSchema = z.object({
+ variantId: z.string().trim().min(1),
+ locationIds: z.array(z.string().trim().min(1)).min(1),
+})
+
+export async function GET(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const rawVariantIds = request.nextUrl.searchParams.get('variantIds') ?? ''
+ const variantIds = rawVariantIds
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+
+ if (variantIds.length === 0) {
+ return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'variantIds 不能为空', 400, ctx.requestId)
+ }
+
+ try {
+ const bindings = await warehouseService.getBindingsForVariants(variantIds)
+ return jsonSuccess({ bindings }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ { requireAuth: true },
+ )
+}
+
+export async function POST(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const rawBody = await request.json().catch(() => null)
+ const parsed = bindVariantSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return jsonError(
+ ErrorCode.VALIDATION_FAILED,
+ `配件库位绑定参数不正确: ${parsed.error.message}`,
+ 400,
+ ctx.requestId,
+ )
+ }
+
+ try {
+ const bindings = await warehouseService.bindVariantToLocations(parsed.data)
+ return jsonSuccess({ bindings }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/warehouse/brand-materials/route.ts b/furniture-3d-system/src/app/api/warehouse/brand-materials/route.ts
new file mode 100644
index 00000000..e68a1e7d
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/brand-materials/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { AppError, ErrorCode } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+const bindBrandMaterialSchema = z.object({
+ brand: z.string().trim().min(1),
+ materialType: z.string().trim().min(1),
+ warehouseLocationId: z.string().trim().min(1).nullable().optional(),
+})
+
+export async function GET(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const brand = request.nextUrl.searchParams.get('brand')?.trim() ?? ''
+ if (!brand) {
+ return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'brand 不能为空', 400, ctx.requestId)
+ }
+
+ try {
+ const materials = await warehouseService.getBrandMaterials(brand)
+ return jsonSuccess({ materials }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ { requireAuth: true },
+ )
+}
+
+export async function POST(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const rawBody = await request.json().catch(() => null)
+ const parsed = bindBrandMaterialSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return jsonError(
+ ErrorCode.VALIDATION_FAILED,
+ `品牌物料库位绑定参数不正确: ${parsed.error.message}`,
+ 400,
+ ctx.requestId,
+ )
+ }
+
+ try {
+ const binding = await warehouseService.bindBrandMaterialLocation({
+ brand: parsed.data.brand,
+ materialType: parsed.data.materialType,
+ warehouseLocationId: parsed.data.warehouseLocationId ?? null,
+ })
+ return jsonSuccess({ binding }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/warehouse/brand-materials/upload/route.ts b/furniture-3d-system/src/app/api/warehouse/brand-materials/upload/route.ts
new file mode 100644
index 00000000..22ff8d89
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/brand-materials/upload/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest } from 'next/server'
+import { AppError, ErrorCode } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+export async function POST(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const formData = await request.formData()
+ const brand = String(formData.get('brand') ?? '').trim()
+ const materialType = String(formData.get('materialType') ?? '').trim()
+ const file = formData.get('file')
+
+ if (!brand || !materialType) {
+ return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '品牌和物料类型不能为空', 400, ctx.requestId)
+ }
+
+ if (!(file instanceof File)) {
+ return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请上传有效文件', 400, ctx.requestId)
+ }
+
+ try {
+ const binding = await warehouseService.uploadBrandMaterialFile(brand, materialType, file)
+ return jsonSuccess({ binding }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/warehouse/locations/[id]/route.ts b/furniture-3d-system/src/app/api/warehouse/locations/[id]/route.ts
new file mode 100644
index 00000000..9e2041cd
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/locations/[id]/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { AppError, ErrorCode } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+const warehouseLocationUpdateSchema = z
+ .object({
+ code: z.string().trim().min(1).optional(),
+ name: z.string().trim().min(1).optional(),
+ zone: z.string().optional().nullable(),
+ aisle: z.string().optional().nullable(),
+ shelf: z.string().optional().nullable(),
+ bin: z.string().optional().nullable(),
+ notes: z.string().optional().nullable(),
+ isActive: z.boolean().optional(),
+ })
+ .refine((value) => Object.keys(value).length > 0, {
+ message: '至少提供一个要更新的字段',
+ })
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const { id } = await params
+ const rawBody = await request.json().catch(() => null)
+ const parsed = warehouseLocationUpdateSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return jsonError(
+ ErrorCode.VALIDATION_FAILED,
+ `库位更新参数不正确: ${parsed.error.message}`,
+ 400,
+ ctx.requestId,
+ )
+ }
+
+ try {
+ const location = await warehouseService.updateLocation(id, parsed.data)
+ return jsonSuccess({ location }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const { id } = await params
+ try {
+ await warehouseService.deleteLocation(id)
+ return jsonSuccess({ deleted: true }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/warehouse/locations/route.ts b/furniture-3d-system/src/app/api/warehouse/locations/route.ts
index 45d6efde..9980a728 100644
--- a/furniture-3d-system/src/app/api/warehouse/locations/route.ts
+++ b/furniture-3d-system/src/app/api/warehouse/locations/route.ts
@@ -18,7 +18,10 @@ export async function GET(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ {
+ requireAuth: true,
+ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM', 'DESIGNER'],
+ },
)
}
diff --git a/furniture-3d-system/src/app/api/warehouse/pre-sku-bindings/[preSkuId]/[locationId]/route.ts b/furniture-3d-system/src/app/api/warehouse/pre-sku-bindings/[preSkuId]/[locationId]/route.ts
new file mode 100644
index 00000000..65bb0e38
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/pre-sku-bindings/[preSkuId]/[locationId]/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest } from 'next/server'
+import { AppError } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ preSkuId: string; locationId: string }> },
+) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const { preSkuId, locationId } = await params
+ try {
+ await warehouseService.unbindPreSkuFromLocation({ preSkuId, locationId })
+ return jsonSuccess({ unbound: true }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/warehouse/pre-sku-bindings/route.ts b/furniture-3d-system/src/app/api/warehouse/pre-sku-bindings/route.ts
new file mode 100644
index 00000000..29e58045
--- /dev/null
+++ b/furniture-3d-system/src/app/api/warehouse/pre-sku-bindings/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { AppError, ErrorCode } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { warehouseService } from '@/server/services'
+
+const WAREHOUSE_WRITE_ROLES = [
+ 'SUPER_ADMIN',
+ 'ADMIN',
+ 'SUPERVISOR_DEPARTMENT',
+ 'SUPERVISOR_TEAM',
+ 'DESIGNER',
+] as const
+
+const bindPreSkuSchema = z.object({
+ preSkuId: z.string().trim().min(1),
+ locationIds: z.array(z.string().trim().min(1)).min(1),
+})
+
+export async function GET(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const rawPreSkuIds = request.nextUrl.searchParams.get('preSkuIds') ?? ''
+ const preSkuIds = rawPreSkuIds
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+
+ if (preSkuIds.length === 0) {
+ return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'preSkuIds 不能为空', 400, ctx.requestId)
+ }
+
+ try {
+ const bindings = await warehouseService.getBindingsForPreSkus(preSkuIds)
+ return jsonSuccess({ bindings }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ { requireAuth: true },
+ )
+}
+
+export async function POST(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const rawBody = await request.json().catch(() => null)
+ const parsed = bindPreSkuSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return jsonError(
+ ErrorCode.VALIDATION_FAILED,
+ `成品 SKU 库位绑定参数不正确: ${parsed.error.message}`,
+ 400,
+ ctx.requestId,
+ )
+ }
+
+ try {
+ const bindings = await warehouseService.bindPreSkuToLocations(parsed.data)
+ return jsonSuccess({ bindings }, 200, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ {
+ requireAuth: true,
+ allowedRoles: [...WAREHOUSE_WRITE_ROLES],
+ },
+ )
+}
diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx
index 774c7956..3b5525bb 100644
--- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx
+++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx
@@ -612,8 +612,8 @@ function buildCadImportChecklist(assetType: ImportableAssetType): string[] {
const ASSET_REVIEW_ROLES = new Set([
'SUPER_ADMIN',
'ADMIN',
- 'DEPARTMENT_MANAGER',
- 'TEAM_LEADER',
+ 'SUPERVISOR',
+ 'SUPERVISOR',
])
function canReviewAssets(role: string): boolean {
@@ -2273,8 +2273,11 @@ export default function AssetCenterClient({
<>
{
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
- role: 'TEAM_LEADER',
+ role: 'SUPERVISOR',
avatar: null,
department: null,
}}
diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx
index a789ed95..9782a66e 100644
--- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx
+++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx
@@ -31,7 +31,21 @@ jest.mock('@/components/ui/Toast', () => ({
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true,
- PlatformSplitLayout: ({ leftContent, rightContent }: { leftContent: ReactNode; rightContent: ReactNode }) => {leftContent}
{rightContent}
,
+ PlatformSplitLayout: ({
+ leftContent,
+ mainContent,
+ rightContent,
+ }: {
+ leftContent: ReactNode
+ mainContent?: ReactNode
+ rightContent: ReactNode
+ }) => (
+
+
{leftContent}
+
{mainContent}
+
{rightContent}
+
+ ),
}))
jest.mock('@/lib/open-product/platform-api', () => ({
@@ -102,8 +116,8 @@ function renderClient() {
updatedAt: new Date(),
}] as SubstrateDTO[]}
initialFinishes={[] as MaterialDTO[]}
- initialMaterialCategories={materialCategories}
- initialSubstrateDictionaries={dictionaries}
+ initialCategories={materialCategories}
+ initialDictionaries={dictionaries}
metrics={[]}
/>,
)
diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx
index cb8e9541..1afc89d5 100644
--- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx
+++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx
@@ -1,48 +1,25 @@
'use client'
-import { startTransition, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
+/**
+ * 基础资料库 (Material Library)
+ * Precision Atelier - 资料工作台重构版
+ * 布局标准:左侧分类 - 中间网格 - 右侧实时工作区
+ */
+
+import { useState, useTransition, useCallback, ReactNode, useEffect, useMemo } from 'react'
import {
- ArrowLeft,
- Check,
- ChevronDown,
- ImagePlus,
- Layers3,
- Package2,
- Pencil,
- Plus,
- Search,
- Settings2,
- SwatchBook,
- Trash2,
- X,
+ Package2, SwatchBook, Search, Plus, Pencil, Trash2, Check,
+ ChevronDown, ArrowLeft, Filter, Grid, LayoutGrid, Info,
+ Settings, Database, Maximize2, Zap, Palette, Layers,
+ LayoutPanelLeft, ArrowRight
} from 'lucide-react'
import Button from '@/components/ui/Button'
-import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
-import Modal from '@/components/ui/Modal'
-import Input from '@/components/ui/Input'
-import Select from '@/components/ui/Select'
-import Slider from '@/components/ui/Slider'
-import { ToastContainer, useToast } from '@/components/ui/Toast'
+import { Select } from '@/components/ui/Select'
+import { useToast, ToastContainer } from '@/components/ui/Toast'
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
-import type { PlatformMetricItem } from '@/components/platform/types'
-import { buildSubstrateDisplayName } from '@/lib/open-product/substrate-rules'
import { cn } from '@/lib/utils'
+
import {
- createMaterial,
- createMaterialCategory,
- createSubstrate,
- createSubstrateDictionary,
- deleteMaterial,
- deleteMaterialCategory,
- deleteSubstrate,
- deleteSubstrateDictionary,
- updateMaterial,
- updateMaterialCategory,
- updateSubstrate,
- updateSubstrateDictionary,
- uploadMaterialAsset,
-} from '@/lib/open-product/platform-api'
-import type {
MaterialCategoryDTO,
MaterialDTO,
SubstrateDTO,
@@ -51,20 +28,38 @@ import type {
SubstrateDictionaryType,
} from '@/server/services'
-type LibraryTab = 'finish' | 'substrate'
+import {
+ createMaterial,
+ updateMaterial,
+ deleteMaterial,
+ createMaterialCategory,
+ updateMaterialCategory,
+ deleteMaterialCategory,
+ createSubstrate,
+ updateSubstrate,
+ deleteSubstrate,
+ createSubstrateDictionary,
+ updateSubstrateDictionary,
+ deleteSubstrateDictionary,
+ uploadMaterialAsset,
+} from '@/lib/open-product/platform-api'
-type RightPanel =
- | { mode: 'empty' }
- | { mode: 'create-finish' }
- | { mode: 'view-finish'; id: string }
- | { mode: 'edit-finish'; id: string }
- | { mode: 'create-substrate' }
- | { mode: 'view-substrate'; id: string }
- | { mode: 'edit-substrate'; id: string }
+interface FinishForm {
+ name: string
+ categoryId: string
+ color: string
+ metalness: number
+ roughness: number
+ opacity: number
+ textureRepeat: number
+ textureRotation: number
+ textureSizeMm: number
+ thumbnailUrl: string | null
+ textureUrl: string | null
+ normalMapUrl: string | null
+}
-type MaterialAssetKind = 'thumbnail' | 'texture' | 'normal'
-
-type SubstrateFormState = {
+interface SubstrateForm {
categoryId: string
categoryText: string
environmentGradeId: string
@@ -77,115 +72,93 @@ type SubstrateFormState = {
newFinishName: string
}
-type CategoryFormState = {
- name: string
- icon: string
- sortOrder: string
-}
-
-type FinishFormState = {
- name: string
- categoryId: string
- thumbnailUrl: string
- thumbnailPreview: string
- textureUrl: string
- texturePreview: string
- normalMapUrl: string
- normalMapPreview: string
- color: string
- metalness: string
- roughness: string
- opacity: string
- textureRepeat: string
- textureRotation: string
- textureSizeMm: string
-}
-
-type DictionaryFormState = {
- name: string
- sortOrder: string
- thicknessMm: string
-}
-
-type DeleteTarget =
- | { type: 'substrate'; id: string; name: string }
- | { type: 'finish'; id: string; name: string }
- | { type: 'material-category'; id: string; name: string }
- | { type: 'dictionary'; id: string; name: string; dictionaryType: SubstrateDictionaryType }
-
-interface MaterialLibraryClientProps {
- initialSubstrates: SubstrateDTO[]
- initialFinishes: MaterialDTO[]
- initialMaterialCategories: MaterialCategoryDTO[]
- initialSubstrateDictionaries: SubstrateDictionaryBundleDTO
- metrics: PlatformMetricItem[]
-}
+// --- Utility Functions ---
function sortByName(items: T[]): T[] {
- return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
+ return [...items].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
}
-function sortCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] {
- return [...items].sort((left, right) => {
- const orderDiff = (left.sortOrder ?? 0) - (right.sortOrder ?? 0)
- return orderDiff !== 0 ? orderDiff : left.name.localeCompare(right.name, 'zh-CN')
- })
+function dictionaryOptionsByType(
+ bundle: SubstrateDictionaryBundleDTO,
+ type: SubstrateDictionaryType,
+): SubstrateDictionaryOptionDTO[] {
+ switch (type) {
+ case 'CATEGORY': return bundle.categories
+ case 'ENVIRONMENT_GRADE': return bundle.environmentGrades
+ case 'THICKNESS_SPEC': return bundle.thicknessSpecs
+ case 'SURFACE_OPTION': return bundle.surfaceOptions
+ case 'SYSTEM_NAME_OPTION': return bundle.systemNameOptions
+ default: return []
+ }
}
-function sortDictionaryOptions(items: SubstrateDictionaryOptionDTO[]): SubstrateDictionaryOptionDTO[] {
- return [...items].sort((left, right) => {
- const orderDiff = left.sortOrder - right.sortOrder
- if (orderDiff !== 0) return orderDiff
- if (left.type === 'THICKNESS_SPEC' && right.type === 'THICKNESS_SPEC') {
- return (left.thicknessMm ?? 0) - (right.thicknessMm ?? 0)
- }
- return left.name.localeCompare(right.name, 'zh-CN')
- })
+function patchDictionaryBundle(
+ bundle: SubstrateDictionaryBundleDTO,
+ type: SubstrateDictionaryType,
+ patch: (items: SubstrateDictionaryOptionDTO[]) => SubstrateDictionaryOptionDTO[],
+): SubstrateDictionaryBundleDTO {
+ const next = { ...bundle }
+ switch (type) {
+ case 'CATEGORY': next.categories = patch(next.categories); break
+ case 'ENVIRONMENT_GRADE': next.environmentGrades = patch(next.environmentGrades); break
+ case 'THICKNESS_SPEC': next.thicknessSpecs = patch(next.thicknessSpecs); break
+ case 'SURFACE_OPTION': next.surfaceOptions = patch(next.surfaceOptions); break
+ case 'SYSTEM_NAME_OPTION': next.systemNameOptions = patch(next.systemNameOptions); break
+ }
+ return next
}
function sortDictionaryBundle(bundle: SubstrateDictionaryBundleDTO): SubstrateDictionaryBundleDTO {
return {
- categories: sortDictionaryOptions(bundle.categories),
- environmentGrades: sortDictionaryOptions(bundle.environmentGrades),
- thicknessSpecs: sortDictionaryOptions(bundle.thicknessSpecs),
- surfaceOptions: sortDictionaryOptions(bundle.surfaceOptions),
- systemNameOptions: sortDictionaryOptions(bundle.systemNameOptions),
+ categories: sortByName(bundle.categories),
+ environmentGrades: sortByName(bundle.environmentGrades),
+ thicknessSpecs: [...bundle.thicknessSpecs].sort((a, b) => (a.thicknessMm ?? 0) - (b.thicknessMm ?? 0)),
+ surfaceOptions: sortByName(bundle.surfaceOptions),
+ systemNameOptions: sortByName(bundle.systemNameOptions),
}
}
-function toOptionalNumber(value: string): number | null {
- if (!value.trim()) return null
- const next = Number(value)
- return Number.isFinite(next) ? next : null
-}
-
-function normalizeSliderValue(value: number): string {
- return value.toFixed(2).replace(/\.?0+$/, '')
-}
-
-function toClampedUnitValue(value: string, fallback: number): number {
- const numericValue = Number(value)
- if (!Number.isFinite(numericValue)) {
- return fallback
- }
- return Math.min(1, Math.max(0, numericValue))
-}
-
-function formatDecimalValue(value: string | number, digits = 2): string {
- const numericValue = typeof value === 'number' ? value : Number(value)
- if (!Number.isFinite(numericValue)) {
- return '--'
- }
- return numericValue.toFixed(digits).replace(/\.?0+$/, '')
-}
-
-function emptySubstrateForm(dict: SubstrateDictionaryBundleDTO): SubstrateFormState {
+function emptyFinishForm(categoryId: string = ''): FinishForm {
return {
- categoryId: dict.categories[0]?.id ?? '',
- categoryText: dict.categories[0]?.name ?? '',
- environmentGradeId: dict.environmentGrades[0]?.id ?? '',
- thicknessSpecId: dict.thicknessSpecs[0]?.id ?? '',
- surfaceOptionId: dict.surfaceOptions[0]?.id ?? '',
+ name: '',
+ categoryId,
+ color: '#cccccc',
+ metalness: 0,
+ roughness: 0.5,
+ opacity: 1,
+ textureRepeat: 1,
+ textureRotation: 0,
+ textureSizeMm: 600,
+ thumbnailUrl: null,
+ textureUrl: null,
+ normalMapUrl: null,
+ }
+}
+
+function materialToFinishForm(m: MaterialDTO): FinishForm {
+ return {
+ name: m.name,
+ categoryId: m.categoryId,
+ color: m.color,
+ metalness: m.metalness,
+ roughness: m.roughness,
+ opacity: m.opacity,
+ textureRepeat: m.textureRepeat,
+ textureRotation: m.textureRotation,
+ textureSizeMm: m.textureSizeMm,
+ thumbnailUrl: m.thumbnailUrl,
+ textureUrl: m.textureUrl,
+ normalMapUrl: m.normalMapUrl,
+ }
+}
+
+function emptySubstrateForm(bundle: SubstrateDictionaryBundleDTO): SubstrateForm {
+ return {
+ categoryId: bundle.categories[0]?.id ?? '',
+ categoryText: bundle.categories[0]?.name ?? '',
+ environmentGradeId: bundle.environmentGrades[0]?.id ?? '',
+ thicknessSpecId: bundle.thicknessSpecs[0]?.id ?? '',
+ surfaceOptionId: bundle.surfaceOptions[0]?.id ?? '',
systemNameOptionId: '',
suggestedPrice: '',
weightKg: '',
@@ -194,765 +167,284 @@ function emptySubstrateForm(dict: SubstrateDictionaryBundleDTO): SubstrateFormSt
}
}
-function emptyCategoryForm(): CategoryFormState {
- return {
- name: '',
- icon: '',
- sortOrder: '',
- }
-}
-
-function emptyFinishForm(defaultCategoryId: string): FinishFormState {
- return {
- name: '',
- categoryId: defaultCategoryId,
- thumbnailUrl: '',
- thumbnailPreview: '',
- textureUrl: '',
- texturePreview: '',
- normalMapUrl: '',
- normalMapPreview: '',
- color: '#cccccc',
- metalness: '0',
- roughness: '0.5',
- opacity: '1',
- textureRepeat: '1',
- textureRotation: '0',
- textureSizeMm: '600',
- }
-}
-
-function emptyDictionaryForm(): DictionaryFormState {
- return {
- name: '',
- sortOrder: '0',
- thicknessMm: '',
- }
-}
-
-function dictionaryOptionsByType(
- bundle: SubstrateDictionaryBundleDTO,
- type: SubstrateDictionaryType,
-): SubstrateDictionaryOptionDTO[] {
- switch (type) {
- case 'CATEGORY':
- return bundle.categories
- case 'ENVIRONMENT_GRADE':
- return bundle.environmentGrades
- case 'THICKNESS_SPEC':
- return bundle.thicknessSpecs
- case 'SURFACE_OPTION':
- return bundle.surfaceOptions
- case 'SYSTEM_NAME_OPTION':
- return bundle.systemNameOptions
- }
-}
-
-function patchDictionaryBundle(
- bundle: SubstrateDictionaryBundleDTO,
- type: SubstrateDictionaryType,
- updater: (items: SubstrateDictionaryOptionDTO[]) => SubstrateDictionaryOptionDTO[],
-): SubstrateDictionaryBundleDTO {
- switch (type) {
- case 'CATEGORY':
- return { ...bundle, categories: updater(bundle.categories) }
- case 'ENVIRONMENT_GRADE':
- return { ...bundle, environmentGrades: updater(bundle.environmentGrades) }
- case 'THICKNESS_SPEC':
- return { ...bundle, thicknessSpecs: updater(bundle.thicknessSpecs) }
- case 'SURFACE_OPTION':
- return { ...bundle, surfaceOptions: updater(bundle.surfaceOptions) }
- case 'SYSTEM_NAME_OPTION':
- return { ...bundle, systemNameOptions: updater(bundle.systemNameOptions) }
- }
+function toOptionalNumber(val: string | number): number | undefined {
+ if (typeof val === 'number') return val
+ const n = parseFloat(val)
+ return Number.isFinite(n) ? n : undefined
}
function dictionaryLabel(type: SubstrateDictionaryType): string {
switch (type) {
- case 'CATEGORY':
- return '材质主类'
- case 'ENVIRONMENT_GRADE':
- return '环保等级'
- case 'THICKNESS_SPEC':
- return '厚度规格'
- case 'SURFACE_OPTION':
- return '贴面类型'
- case 'SYSTEM_NAME_OPTION':
- return '系统名称'
+ case 'CATEGORY': return '材质主类'
+ case 'ENVIRONMENT_GRADE': return '环保等级'
+ case 'THICKNESS_SPEC': return '厚度规格'
+ case 'SURFACE_OPTION': return '贴面类型'
+ case 'SYSTEM_NAME_OPTION': return 'ERP系统名'
+ default: return ''
}
}
-function isLegacySeedOptionId(id: string) {
- return id.startsWith('seed_substrate_')
-}
-
-function materialPreviewUrl(material: MaterialDTO): string | null {
- return material.thumbnailUrl ?? material.textureUrl ?? material.previewUrl ?? null
-}
-
-function materialToFinishForm(item: MaterialDTO): FinishFormState {
- return {
- name: item.name,
- categoryId: item.categoryId,
- thumbnailUrl: item.thumbnailUrl ?? '',
- thumbnailPreview: item.thumbnailUrl ?? '',
- textureUrl: item.textureUrl ?? '',
- texturePreview: item.textureUrl ?? item.previewUrl ?? '',
- normalMapUrl: item.normalMapUrl ?? '',
- normalMapPreview: item.normalMapUrl ?? '',
- color: item.color ?? '#cccccc',
- metalness: String(item.metalness ?? 0),
- roughness: String(item.roughness ?? 0.5),
- opacity: String(item.opacity ?? 1),
- textureRepeat: String(item.textureRepeat ?? 1),
- textureRotation: String(item.textureRotation ?? 0),
- textureSizeMm: String(item.textureSizeMm ?? 600),
- }
-}
-
-function FinishParameterSliderField({
- id,
- label,
- value,
- helper,
- disabled = false,
- onChange,
-}: {
- id: string
- label: string
- value: string
- helper: string
- disabled?: boolean
- onChange: (nextValue: string) => void
-}) {
- const fallback = label === '粗糙度' ? 0.5 : label === '透明度' ? 1 : 0
- const currentValue = toClampedUnitValue(value, fallback)
+// --- Internal Components ---
+function FinishWorkbenchPreviewCard({ form, categoryName, code, statusLabel }: { form: FinishForm, categoryName: string, code?: string, statusLabel: string }) {
return (
-
-
-
-
-
{helper}
+
+ {/* 实时预览内容 */}
+
+ {form.textureUrl ? (
+
+ ) : (
+
+ )}
+
+ {/* 反光效果叠加层 (Mock) */}
+
+
+
+
+ {/* 悬浮标签 */}
+
+
+ Real-time Preview
+
+
+
{form.name || 'Untitled Finish'}
+ {code && {code}}
-
- {formatDecimalValue(currentValue)}
+
+
+ {/* 状态徽章 */}
+
+
+ )
+}
+
+function FinishParameterSliderField({
+ id, label, value, min = 0, max = 1, step = 0.01, helper, disabled, onChange
+}: {
+ id: string, label: string, value: number, min?: number, max?: number, step?: number, helper?: string, disabled?: boolean, onChange: (val: number) => void
+}) {
+ return (
+
+
+
+
+ {Math.round(value * 100)}%
-
-
onChange(normalizeSliderValue(nextValue))}
+ onChange={(e) => onChange(parseFloat(e.target.value))}
+ className={cn(
+ "h-1.5 w-full cursor-pointer appearance-none rounded-full bg-apple-separator/20 transition-all accent-apple-blue",
+ disabled && "cursor-not-allowed opacity-40 grayscale"
+ )}
/>
-
-
- 0
- 1
-
)
}
-function FinishWorkbenchPreviewCard({
- form,
- categoryName,
- code,
- statusLabel,
-}: {
- form: FinishFormState
- categoryName: string
- code: string
- statusLabel: string
-}) {
- const displayColor = form.color || '#cccccc'
+// --- Main Page Component ---
- const sphereSurfaceStyle = useMemo
(() => ({
- backgroundColor: displayColor,
- backgroundImage: form.texturePreview
- ? `url(${form.texturePreview})`
- : form.textureUrl
- ? `url(${form.textureUrl})`
- : form.thumbnailPreview
- ? `url(${form.thumbnailPreview})`
- : 'none',
- backgroundSize: 'cover',
- backgroundPosition: 'center',
- }), [displayColor, form.texturePreview, form.textureUrl, form.thumbnailPreview])
-
- return (
-
-
-
-
-
- {statusLabel}
-
-
- {form.name.trim() || '未命名材质'}
-
-
- {categoryName} · {code}
-
-
-
- 实时预览
-
-
-
-
-
-
- {[
- { label: '金属度', value: form.metalness },
- { label: '粗糙度', value: form.roughness },
- { label: '透明度', value: form.opacity },
- ].map((item) => (
-
-
- {item.label}
-
-
- {formatDecimalValue(item.value)}
-
-
- ))}
-
-
- )
-}
-
-function FinishPreviewHeader({
- form,
- categoryName,
- isEdit = false,
-}: {
- form: FinishFormState
- categoryName: string
- isEdit?: boolean
-}) {
- const displayColor = form.color || '#cccccc'
-
- const sphereSurfaceStyle = useMemo(() => ({
- backgroundColor: displayColor,
- backgroundImage: form.texturePreview
- ? `url(${form.texturePreview})`
- : form.textureUrl
- ? `url(${form.textureUrl})`
- : form.thumbnailPreview
- ? `url(${form.thumbnailPreview})`
- : 'none',
- backgroundSize: 'cover',
- backgroundPosition: 'center',
- }), [displayColor, form.texturePreview, form.textureUrl, form.thumbnailPreview])
-
- const sphereHighlightStyle = useMemo(() => ({
- background:
- 'radial-gradient(circle at 30% 30%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 25%, transparent 60%)',
- }), [])
-
- return (
-
-
-
-
-
-
-
- {isEdit ? '材质编辑中' : '花色详细规格'}
-
-
- {categoryName}
-
-
-
- {form.name.trim() || '未命名材质'}
-
-
-
-
-
- STUDIO VERIFIED
-
-
-
-
-
- )
-}
-
-function SubstratePreviewHeader({
- title,
- badge,
- subtitle,
-}: {
- title: string
- badge: string
- subtitle: string
-}) {
- return (
-
-
-
-
-
-
- {badge}
-
-
-
- {title}
-
-
{subtitle}
-
-
-
- )
-}
-
-function buildLiveMetrics(
- metrics: PlatformMetricItem[],
- counts: { substrates: number; finishes: number; categories: number },
-): PlatformMetricItem[] {
- return metrics.map((item) => {
- if (item.label === '板材基材') {
- return { ...item, value: counts.substrates }
- }
- if (item.label === '饰面花色') {
- return { ...item, value: counts.finishes }
- }
- if (item.label === '花色分类') {
- return { ...item, value: counts.categories }
- }
- return item
- })
+interface MaterialLibraryClientProps {
+ initialCategories: MaterialCategoryDTO[]
+ initialFinishes: MaterialDTO[]
+ initialSubstrates: SubstrateDTO[]
+ initialDictionaries: SubstrateDictionaryBundleDTO
+ metrics?: Array<{ label: string; value: string | number; icon?: ReactNode }>
}
export default function MaterialLibraryClient({
- initialSubstrates,
+ initialCategories,
initialFinishes,
- initialMaterialCategories,
- initialSubstrateDictionaries,
- metrics,
+ initialSubstrates,
+ initialDictionaries,
+ metrics = [],
}: MaterialLibraryClientProps) {
const toast = useToast()
- const [activeTab, setActiveTab] = useState('substrate')
- const [searchText, setSearchText] = useState('')
- const [selectedCategoryId, setSelectedCategoryId] = useState('all')
- const [substrates, setSubstrates] = useState(sortByName(initialSubstrates))
- const [finishes, setFinishes] = useState(sortByName(initialFinishes))
- const [categories, setCategories] = useState(sortCategories(initialMaterialCategories))
- const [dictionaries, setDictionaries] = useState(
- sortDictionaryBundle(initialSubstrateDictionaries),
- )
- const [panel, setPanel] = useState({ mode: 'empty' })
+ const [activeTab, setActiveTab] = useState<'finish' | 'substrate'>('finish')
+ const [categories, setCategories] = useState(initialCategories)
+ const [finishes, setFinishes] = useState(initialFinishes)
+ const [substrates, setSubstrates] = useState(initialSubstrates)
+ const [dictionaries, setDictionaries] = useState(initialDictionaries)
+
+ const [selectedCategoryId, setSelectedCategoryId] = useState('all')
+ const [searchQuery, setSearchQuery] = useState('')
+ const [isPending, startTransition] = useTransition()
const [submitting, setSubmitting] = useState(false)
- const [deleteTarget, setDeleteTarget] = useState(null)
- const [previewUrl, setPreviewUrl] = useState(null)
- const [uploadingAsset, setUploadingAsset] = useState(null)
- const thumbnailInputRef = useRef(null)
- const textureInputRef = useRef(null)
- const normalMapInputRef = useRef(null)
+ type PanelState =
+ | { mode: 'empty' }
+ | { mode: 'view-finish'; id: string }
+ | { mode: 'edit-finish'; id: string }
+ | { mode: 'create-finish' }
+ | { mode: 'view-substrate'; id: string }
+ | { mode: 'edit-substrate'; id: string }
+ | { mode: 'create-substrate' }
- const [finishForm, setFinishForm] = useState(() =>
- emptyFinishForm(initialMaterialCategories[0]?.id ?? ''),
- )
- const [substrateForm, setSubstrateForm] = useState(() =>
- emptySubstrateForm(sortDictionaryBundle(initialSubstrateDictionaries)),
- )
- const [finishSearch, setFinishSearch] = useState('')
- const [finishDropdownOpen, setFinishDropdownOpen] = useState(false)
+ const [panel, setPanel] = useState({ mode: 'empty' })
+
+ const [finishForm, setFinishForm] = useState(emptyFinishForm())
+ const [substrateForm, setSubstrateForm] = useState(emptySubstrateForm(initialDictionaries))
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [categoryModalMode, setCategoryModalMode] = useState<'create' | 'edit'>('create')
const [editingCategoryId, setEditingCategoryId] = useState(null)
- const [categoryForm, setCategoryForm] = useState(emptyCategoryForm())
+ const [categoryForm, setCategoryForm] = useState({ name: '', icon: '', sortOrder: '' })
const [dictionaryModalOpen, setDictionaryModalOpen] = useState(false)
+ const [activeDictionaryType, setActiveDictionaryType] = useState('CATEGORY')
const [dictionaryModalMode, setDictionaryModalMode] = useState<'create' | 'edit'>('create')
- const [activeDictionaryType, setActiveDictionaryType] =
- useState('CATEGORY')
const [editingDictionaryId, setEditingDictionaryId] = useState(null)
- const [dictionaryForm, setDictionaryForm] = useState(emptyDictionaryForm())
+ const [dictionaryForm, setDictionaryForm] = useState({ name: '', sortOrder: '', thicknessMm: '' })
- const categoryUsageCountMap = useMemo(() => {
- const next = new Map()
- for (const finish of finishes) {
- next.set(finish.categoryId, (next.get(finish.categoryId) ?? 0) + 1)
- }
- return next
- }, [finishes])
+ const [deleteTarget, setDeleteTarget] = useState<{ type: 'finish' | 'substrate' | 'material-category' | 'substrate-dictionary'; id: string; name: string; dictionaryType?: SubstrateDictionaryType } | null>(null)
+ const [uploadingAsset, setUploadingAsset] = useState<{ id: string; type: 'finish' | 'substrate' } | null>(null)
- const dictById = useMemo(() => {
- const all = [
- ...dictionaries.categories,
- ...dictionaries.environmentGrades,
- ...dictionaries.thicknessSpecs,
- ...dictionaries.surfaceOptions,
- ...dictionaries.systemNameOptions,
- ]
- const map = new Map()
- for (const item of all) {
- map.set(item.id, item)
- }
- return map
- }, [dictionaries])
-
- const usedDictionaryIds = useMemo(() => {
- return {
- CATEGORY: new Set(substrates.map((item) => item.categoryId).filter(Boolean)),
- ENVIRONMENT_GRADE: new Set(substrates.map((item) => item.environmentGradeId).filter(Boolean)),
- THICKNESS_SPEC: new Set(substrates.map((item) => item.thicknessSpecId).filter(Boolean)),
- SURFACE_OPTION: new Set(substrates.map((item) => item.surfaceOptionId).filter(Boolean)),
- SYSTEM_NAME_OPTION: new Set(substrates.map((item) => item.systemNameOptionId).filter(Boolean)),
- } as Record>
- }, [substrates])
-
- const activeDictionaryOptions = useMemo(
- () => dictionaryOptionsByType(dictionaries, activeDictionaryType),
- [activeDictionaryType, dictionaries],
- )
+ const defaultFinishCategoryId = categories.length > 0 ? categories[0].id : ''
+ // Filtered Finishes
const filteredFinishes = useMemo(() => {
- const keyword = searchText.trim().toLowerCase()
- return finishes.filter((item) => {
- if (selectedCategoryId !== 'all' && item.categoryId !== selectedCategoryId) return false
- if (!keyword) return true
- return [
- item.name,
- item.code,
- item.categoryName ?? '',
- ].some((value) => value.toLowerCase().includes(keyword))
- })
- }, [finishes, searchText, selectedCategoryId])
+ let result = finishes
+ if (selectedCategoryId !== 'all') {
+ result = result.filter((item) => item.categoryId === selectedCategoryId)
+ }
+ if (searchQuery.trim()) {
+ const q = searchQuery.toLowerCase()
+ result = result.filter((item) => item.name.toLowerCase().includes(q) || item.code.toLowerCase().includes(q))
+ }
+ return result
+ }, [finishes, selectedCategoryId, searchQuery])
+ // Filtered Substrates
const filteredSubstrates = useMemo(() => {
- const keyword = searchText.trim().toLowerCase()
- if (!keyword) return substrates
-
- return substrates.filter((item) => {
- const haystack = [
- item.name,
- item.code,
- item.systemNameOptionName ?? '',
- item.categoryName,
- item.environmentGradeName,
- item.surfaceOptionName ?? '',
- item.finishes.map((finish) => finish.name).join(' '),
- ]
- return haystack.some((value) => value.toLowerCase().includes(keyword))
- })
- }, [substrates, searchText])
-
- const isSolidWood = useMemo(() => {
- const categoryName = dictById.get(substrateForm.categoryId)?.name ?? substrateForm.categoryText
- if (!categoryName) return false
- const solidKeywords = ['实木', '橡胶木', '橡木', '黑胡桃', '白蜡', '榉木', '桦木', '松木', '柞木']
- return solidKeywords.some((keyword) => categoryName.includes(keyword))
- }, [dictById, substrateForm.categoryId, substrateForm.categoryText])
-
- const filteredFinishesForSubstrate = useMemo(() => {
- const excludedCategories = ['五金油漆', '漆面颜色', '五金漆面色']
- if (isSolidWood) {
- return finishes.filter((item) =>
- item.categoryName === '实木花色' && !excludedCategories.includes(item.categoryName ?? ''),
- )
+ let result = substrates
+ if (searchQuery.trim()) {
+ const q = searchQuery.toLowerCase()
+ result = result.filter((item) => item.name.toLowerCase().includes(q) || item.code.toLowerCase().includes(q))
}
- return finishes.filter((item) =>
- item.categoryName !== '实木花色' && !excludedCategories.includes(item.categoryName ?? ''),
- )
- }, [finishes, isSolidWood])
+ return result
+ }, [substrates, searchQuery])
- const defaultFinishCategoryId = useMemo(() => {
- if (isSolidWood) {
- return categories.find((item) => item.name === '实木花色')?.id ?? categories[0]?.id ?? ''
- }
- return categories.find((item) => item.name === '木纹贴面花色')?.id ?? categories[0]?.id ?? ''
- }, [categories, isSolidWood])
-
- const finishAutoSuggestions = useMemo(() => {
- const keyword = finishSearch.trim().toLowerCase()
- if (!keyword) return []
- return filteredFinishesForSubstrate
- .filter((item) => item.name.toLowerCase().includes(keyword))
- .slice(0, 10)
- }, [filteredFinishesForSubstrate, finishSearch])
-
- const selectedFinishItems = useMemo(
- () =>
- substrateForm.finishIds
- .map((id) => filteredFinishesForSubstrate.find((item) => item.id === id) ?? finishes.find((item) => item.id === id))
- .filter((item): item is MaterialDTO => Boolean(item)),
- [filteredFinishesForSubstrate, finishes, substrateForm.finishIds],
- )
-
- const previewSubstrateName = useMemo(() => {
- const categoryName = dictById.get(substrateForm.categoryId)?.name ?? substrateForm.categoryText
- const environmentName = dictById.get(substrateForm.environmentGradeId)?.name ?? ''
- const thicknessSpec = dictionaries.thicknessSpecs.find(
- (item) => item.id === substrateForm.thicknessSpecId,
- )
- if (!categoryName || !environmentName || !thicknessSpec?.thicknessMm) return ''
-
- const finishLabels = selectedFinishItems.map((item) => item.name)
- if (substrateForm.newFinishName.trim()) {
- finishLabels.push(substrateForm.newFinishName.trim())
- }
-
- return buildSubstrateDisplayName({
- thicknessMm: thicknessSpec.thicknessMm,
- environmentGradeLabel: environmentName,
- categoryLabel: categoryName,
- finishLabels,
- surfaceLabel: isSolidWood
- ? ''
- : (dictById.get(substrateForm.surfaceOptionId)?.name ?? ''),
- })
- }, [
- dictById,
- dictionaries.thicknessSpecs,
- isSolidWood,
- selectedFinishItems,
- substrateForm.categoryId,
- substrateForm.categoryText,
- substrateForm.environmentGradeId,
- substrateForm.newFinishName,
- substrateForm.surfaceOptionId,
- substrateForm.thicknessSpecId,
- ])
-
- const previewSystemName = useMemo(
- () => dictById.get(substrateForm.systemNameOptionId)?.name ?? '',
- [dictById, substrateForm.systemNameOptionId],
- )
-
- const selectedFinish = useMemo(() => {
- if (panel.mode === 'view-finish' || panel.mode === 'edit-finish') {
- return finishes.find((item) => item.id === panel.id) ?? null
- }
- return null
- }, [finishes, panel])
-
- const selectedSubstrate = useMemo(() => {
- if (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') {
- return substrates.find((item) => item.id === panel.id) ?? null
- }
- return null
- }, [panel, substrates])
-
- const finishWorkspaceForm =
- panel.mode === 'create-finish' || panel.mode === 'edit-finish'
- ? finishForm
- : panel.mode === 'view-finish' && selectedFinish
- ? materialToFinishForm(selectedFinish)
- : null
-
- const finishWorkspaceCategoryName = finishWorkspaceForm
- ? categories.find((item) => item.id === finishWorkspaceForm.categoryId)?.name
- ?? selectedFinish?.categoryName
- ?? '未分类'
- : '未分类'
-
- const finishWorkspaceCode =
- panel.mode === 'create-finish'
- ? '保存后生成'
- : selectedFinish?.code ?? '系统生成'
-
- const finishSpotlightPreviewUrl = finishWorkspaceForm
- ? finishWorkspaceForm.texturePreview
- || finishWorkspaceForm.textureUrl
- || finishWorkspaceForm.thumbnailPreview
- || finishWorkspaceForm.thumbnailUrl
- || null
+ const selectedFinish = panel.mode === 'view-finish' || panel.mode === 'edit-finish'
+ ? finishes.find((item) => item.id === (panel as any).id)
: null
- const liveMetrics = useMemo(
- () =>
- buildLiveMetrics(metrics, {
- substrates: substrates.length,
- finishes: finishes.length,
- categories: categories.length,
- }),
- [categories.length, finishes.length, metrics, substrates.length],
- )
+ const selectedSubstrate = panel.mode === 'view-substrate' || panel.mode === 'edit-substrate'
+ ? substrates.find((item) => item.id === (panel as any).id)
+ : null
- function selectFinish(item: MaterialDTO) {
- setPanel({ mode: 'view-finish', id: item.id })
- }
+ // --- Handlers ---
- function selectSubstrate(item: SubstrateDTO) {
- setPanel({ mode: 'view-substrate', id: item.id })
- }
-
- function startCreateFinish() {
- const defaultCategoryId = selectedCategoryId !== 'all'
- ? selectedCategoryId
- : categories[0]?.id ?? ''
-
- if (!defaultCategoryId) {
- setCategoryModalMode('create')
- setEditingCategoryId(null)
- setCategoryForm(emptyCategoryForm())
- setCategoryModalOpen(true)
- toast.error('请先创建花色分类')
- return
- }
-
- setFinishForm(emptyFinishForm(defaultCategoryId))
+ const startCreateFinish = useCallback(() => {
+ setFinishForm(emptyFinishForm(selectedCategoryId === 'all' ? defaultFinishCategoryId : selectedCategoryId))
setPanel({ mode: 'create-finish' })
- }
+ }, [selectedCategoryId, defaultFinishCategoryId])
- function startEditFinish(item: MaterialDTO) {
- setFinishForm(materialToFinishForm(item))
- setPanel({ mode: 'edit-finish', id: item.id })
- }
-
- function startCreateSubstrate() {
+ const startCreateSubstrate = useCallback(() => {
setSubstrateForm(emptySubstrateForm(dictionaries))
- setFinishSearch('')
- setFinishDropdownOpen(false)
setPanel({ mode: 'create-substrate' })
+ }, [dictionaries])
+
+ const startEditFinish = useCallback((m: MaterialDTO) => {
+ setFinishForm(materialToFinishForm(m))
+ setPanel({ mode: 'edit-finish', id: m.id })
+ }, [])
+
+ const openCategoryModal = useCallback(() => {
+ setCategoryModalMode('create')
+ setEditingCategoryId(null)
+ setCategoryForm({ name: '', icon: '', sortOrder: '' })
+ setCategoryModalOpen(true)
+ }, [])
+
+ const handleFinishSliderChange = (key: keyof FinishForm, val: number) => {
+ setFinishForm(prev => ({ ...prev, [key]: val }))
}
- function startEditSubstrate(item: SubstrateDTO) {
- setSubstrateForm({
- categoryId: item.categoryId,
- categoryText: item.categoryName,
- environmentGradeId: item.environmentGradeId,
- thicknessSpecId: item.thicknessSpecId,
- surfaceOptionId: item.surfaceOptionId ?? '',
- systemNameOptionId: item.systemNameOptionId ?? '',
- suggestedPrice: item.suggestedPrice != null ? String(item.suggestedPrice) : '',
- weightKg: item.weightKg != null ? String(item.weightKg) : '',
- finishIds: item.finishes.map((finish) => finish.id),
- newFinishName: '',
- })
- setFinishSearch('')
- setFinishDropdownOpen(false)
- setPanel({ mode: 'edit-substrate', id: item.id })
- }
-
- async function handleSaveCategory() {
- const name = categoryForm.name.trim()
- if (!name) {
- toast.error('请填写花色分类名称')
- return
- }
-
- const duplicated = categories.find(
- (item) => item.name === name && item.id !== editingCategoryId,
- )
- if (duplicated) {
- toast.error(`已存在同名分类「${name}」`)
- return
- }
-
- setSubmitting(true)
+ async function handleFileUpload(file: File, kind: 'texture' | 'normal' | 'thumbnail') {
+ if (!file) return
+ setUploadingAsset({ id: 'current', type: 'finish' })
try {
- if (categoryModalMode === 'create') {
- const { category } = await createMaterialCategory({
- name,
- icon: categoryForm.icon.trim() || null,
- sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined,
- })
- startTransition(() => {
- setCategories((current) => sortCategories([...current, category]))
- setCategoryModalOpen(false)
- if (finishForm.categoryId === '') {
- setFinishForm((current) => ({ ...current, categoryId: category.id }))
- }
- toast.success('花色分类已创建')
- })
- return
- }
-
- if (!editingCategoryId) return
-
- const { category } = await updateMaterialCategory(editingCategoryId, {
- name,
- icon: categoryForm.icon.trim() || null,
- sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined,
+ const url = await uploadMaterialAsset(file, {
+ type: kind,
+ category: activeTab === 'finish' ? 'material-library' : 'substrate-library',
})
- startTransition(() => {
- setCategories((current) =>
- sortCategories(current.map((item) => (item.id === editingCategoryId ? category : item))),
- )
- setCategoryModalOpen(false)
- setSelectedCategoryId((current) => (current === editingCategoryId ? category.id : current))
- setFinishForm((current) => (
- current.categoryId === editingCategoryId
- ? { ...current, categoryId: category.id }
- : current
- ))
- toast.success('花色分类已更新')
+ setFinishForm((current) => {
+ if (kind === 'thumbnail') {
+ return { ...current, thumbnailUrl: url }
+ }
+ if (kind === 'texture') {
+ return { ...current, textureUrl: url }
+ }
+ return { ...current, normalMapUrl: url }
})
+ toast.success('贴图已同步到预览工作区')
} catch (error) {
- toast.error(error instanceof Error ? error.message : '花色分类保存失败')
+ toast.error('上传失败')
} finally {
- setSubmitting(false)
+ setUploadingAsset(null)
}
}
async function handleSaveFinish() {
- const name = finishForm.name.trim()
- if (!name) {
- toast.error('请填写花色名称')
+ if (!finishForm.name.trim() || !finishForm.categoryId) {
+ toast.error('请填写完整必填字段')
return
}
- if (!finishForm.categoryId) {
- toast.error('请选择花色分类')
- return
- }
-
- const payload = {
- name,
- categoryId: finishForm.categoryId,
- thumbnailUrl: finishForm.thumbnailUrl.trim() || null,
- textureUrl: finishForm.textureUrl.trim() || null,
- normalMapUrl: finishForm.normalMapUrl.trim() || null,
- color: finishForm.color || '#cccccc',
- metalness: toOptionalNumber(finishForm.metalness) ?? 0,
- roughness: toOptionalNumber(finishForm.roughness) ?? 0.5,
- opacity: toOptionalNumber(finishForm.opacity) ?? 1,
- textureRepeat: toOptionalNumber(finishForm.textureRepeat) ?? 1,
- textureRotation: toOptionalNumber(finishForm.textureRotation) ?? 0,
- textureSizeMm: toOptionalNumber(finishForm.textureSizeMm) ?? 600,
- }
setSubmitting(true)
try {
+ const payload = {
+ name: finishForm.name.trim(),
+ categoryId: finishForm.categoryId,
+ color: finishForm.color,
+ metalness: finishForm.metalness,
+ roughness: finishForm.roughness,
+ opacity: finishForm.opacity,
+ textureRepeat: finishForm.textureRepeat,
+ textureRotation: finishForm.textureRotation,
+ textureSizeMm: finishForm.textureSizeMm,
+ thumbnailUrl: finishForm.thumbnailUrl,
+ textureUrl: finishForm.textureUrl,
+ normalMapUrl: finishForm.normalMapUrl,
+ }
+
if (panel.mode === 'create-finish') {
const result = await createMaterial(payload)
startTransition(() => {
@@ -960,1837 +452,637 @@ export default function MaterialLibraryClient({
setPanel({ mode: 'view-finish', id: result.id })
toast.success('花色已创建')
})
- return
+ } else if (panel.mode === 'edit-finish') {
+ const result = await updateMaterial(panel.id, payload)
+ startTransition(() => {
+ setFinishes((current) =>
+ sortByName(current.map((item) => (item.id === panel.id ? result : item))),
+ )
+ setPanel({ mode: 'view-finish', id: panel.id })
+ toast.success('更改已应用')
+ })
}
-
- if (panel.mode !== 'edit-finish') return
-
- const result = await updateMaterial(panel.id, payload)
- startTransition(() => {
- setFinishes((current) =>
- sortByName(current.map((item) => (item.id === panel.id ? result : item))),
- )
- setPanel({ mode: 'view-finish', id: panel.id })
- toast.success('花色已更新')
- })
} catch (error) {
- toast.error(error instanceof Error ? error.message : '花色保存失败')
+ toast.error('保存失败')
} finally {
setSubmitting(false)
}
}
- async function handleMaterialAssetUpload(kind: MaterialAssetKind, file: File) {
- if (!file) return
-
- const categoryName = categories.find((item) => item.id === finishForm.categoryId)?.name
- setUploadingAsset(kind)
- try {
- const url = await uploadMaterialAsset(file, {
- type: kind,
- category: categoryName,
- })
- setFinishForm((current) => {
- if (kind === 'thumbnail') {
- return {
- ...current,
- thumbnailUrl: url,
- thumbnailPreview: url,
- }
- }
- if (kind === 'texture') {
- return {
- ...current,
- textureUrl: url,
- texturePreview: url,
- }
- }
- return {
- ...current,
- normalMapUrl: url,
- normalMapPreview: url,
- }
- })
- toast.success('贴图已上传')
- } catch (error) {
- toast.error(error instanceof Error ? error.message : '贴图上传失败')
- } finally {
- setUploadingAsset(null)
- }
- }
-
async function handleSaveSubstrate() {
- const isSolid = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText.trim() === '实木'
- if (!substrateForm.categoryText.trim() || !substrateForm.environmentGradeId || !substrateForm.thicknessSpecId) {
- toast.error('请补齐基材必填字段')
- return
- }
- if (!isSolid && !substrateForm.surfaceOptionId) {
- toast.error('请选择贴面类型')
+ if (
+ !substrateForm.categoryId ||
+ !substrateForm.environmentGradeId ||
+ !substrateForm.thicknessSpecId
+ ) {
+ toast.error('请填写完整必填字段')
return
}
setSubmitting(true)
try {
- let categoryId = substrateForm.categoryId
- if (!categoryId && substrateForm.categoryText.trim()) {
- const option = await createSubstrateDictionary({
- type: 'CATEGORY',
- name: substrateForm.categoryText.trim(),
- })
- categoryId = option.id
- startTransition(() => {
- setDictionaries((current) =>
- sortDictionaryBundle(
- patchDictionaryBundle(current, 'CATEGORY', (items) => [...items, option]),
- ),
- )
- })
- }
-
- if (!categoryId) {
- toast.error('请输入或选择材质主类')
- return
- }
-
- let finishIds = [...substrateForm.finishIds]
- const newFinishName = substrateForm.newFinishName.trim()
- if (newFinishName) {
- const existingFinish = finishes.find((item) => item.name === newFinishName)
- if (existingFinish) {
- if (!finishIds.includes(existingFinish.id)) {
- finishIds = [...finishIds, existingFinish.id]
- }
- } else {
- if (!defaultFinishCategoryId) {
- toast.error('请先创建可用的花色分类,再新增花色')
- return
- }
-
- const newFinish = await createMaterial({
- name: newFinishName,
- categoryId: defaultFinishCategoryId,
- color: '#cccccc',
- metalness: 0,
- roughness: 0.5,
- opacity: 1,
- textureRepeat: 1,
- textureRotation: 0,
- textureSizeMm: 600,
- })
- startTransition(() => {
- setFinishes((current) => sortByName([...current, newFinish]))
- })
- finishIds = [...finishIds, newFinish.id]
- }
- }
-
- const payload = {
- categoryId,
+ const result = await createSubstrate({
+ categoryId: substrateForm.categoryId,
environmentGradeId: substrateForm.environmentGradeId,
thicknessSpecId: substrateForm.thicknessSpecId,
- surfaceOptionId: isSolid ? null : (substrateForm.surfaceOptionId || null),
- systemNameOptionId: substrateForm.systemNameOptionId || null,
- suggestedPrice: toOptionalNumber(substrateForm.suggestedPrice),
- weightKg: toOptionalNumber(substrateForm.weightKg),
- finishIds: finishIds.length > 0 ? finishIds : undefined,
- }
+ surfaceOptionId: substrateForm.surfaceOptionId || undefined,
+ systemNameOptionId: substrateForm.systemNameOptionId || undefined,
+ suggestedPrice: toOptionalNumber(substrateForm.suggestedPrice) ?? null,
+ weightKg: toOptionalNumber(substrateForm.weightKg) ?? null,
+ finishIds: substrateForm.finishIds,
+ metadata: substrateForm.categoryText ? { categoryText: substrateForm.categoryText } : null,
+ })
- if (panel.mode === 'create-substrate') {
- const result = await createSubstrate(payload)
- startTransition(() => {
- setSubstrates((current) => sortByName([...current, result.substrate]))
- setPanel({ mode: 'view-substrate', id: result.substrate.id })
- setSubstrateForm(emptySubstrateForm(dictionaries))
- toast.success('基材已创建')
- })
- return
- }
-
- if (panel.mode !== 'edit-substrate') return
-
- const result = await updateSubstrate(panel.id, payload)
startTransition(() => {
- setSubstrates((current) =>
- sortByName(current.map((item) => (item.id === panel.id ? result.substrate : item))),
- )
- setPanel({ mode: 'view-substrate', id: panel.id })
- toast.success('基材已更新')
+ setSubstrates((current) => sortByName([...current, result.substrate]))
+ setPanel({ mode: 'view-substrate', id: result.substrate.id })
+ toast.success('基材已创建')
})
} catch (error) {
- toast.error(error instanceof Error ? error.message : '基材保存失败')
+ toast.error(error instanceof Error ? error.message : '保存失败')
} finally {
setSubmitting(false)
}
}
- function openCategoryModal(category?: MaterialCategoryDTO) {
- setCategoryModalMode(category ? 'edit' : 'create')
- setEditingCategoryId(category?.id ?? null)
- setCategoryForm({
- name: category?.name ?? '',
- icon: category?.icon ?? '',
- sortOrder: category?.sortOrder != null ? String(category.sortOrder) : '',
- })
- setCategoryModalOpen(true)
- }
+ // --- UI Components ---
- function openDictionaryModal(
- type: SubstrateDictionaryType,
- option?: SubstrateDictionaryOptionDTO,
- ) {
- setActiveDictionaryType(type)
- setDictionaryModalMode(option ? 'edit' : 'create')
- setEditingDictionaryId(option?.id ?? null)
- setDictionaryForm({
- name: option?.name ?? '',
- sortOrder: String(option?.sortOrder ?? 0),
- thicknessMm: option?.thicknessMm != null ? String(option.thicknessMm) : '',
- })
- setDictionaryModalOpen(true)
- }
-
- function beginCreateDictionaryOption() {
- setDictionaryModalMode('create')
- setEditingDictionaryId(null)
- setDictionaryForm(emptyDictionaryForm())
- }
-
- async function handleSaveDictionary() {
- if (activeDictionaryType === 'THICKNESS_SPEC') {
- if (!dictionaryForm.thicknessMm.trim() || !Number.isFinite(Number(dictionaryForm.thicknessMm))) {
- toast.error('请填写有效的厚度数值')
- return
- }
- } else if (!dictionaryForm.name.trim()) {
- toast.error(`请填写${dictionaryLabel(activeDictionaryType)}名称`)
- return
+ const renderPanelContent = () => {
+ if (panel.mode === 'empty') {
+ return (
+
+
+
+
+
Precision Workspace
+
等待选择
+
+ 请从中间网格选择一个项目进行编辑,或点击顶部的「新建」开始定义。
+
+
+ )
}
- const existingOptions = dictionaryOptionsByType(dictionaries, activeDictionaryType)
- const targetName = activeDictionaryType === 'THICKNESS_SPEC'
- ? dictionaryForm.thicknessMm.trim()
- : dictionaryForm.name.trim()
- const duplicated = existingOptions.find(
- (item) => item.name === targetName && item.id !== editingDictionaryId,
- )
- if (duplicated) {
- toast.error(`已存在同名${dictionaryLabel(activeDictionaryType)}「${targetName}」`)
- return
- }
-
- setSubmitting(true)
- try {
- if (dictionaryModalMode === 'create') {
- const option = await createSubstrateDictionary({
- type: activeDictionaryType,
- name: targetName,
- sortOrder: toOptionalNumber(dictionaryForm.sortOrder) ?? 0,
- thicknessMm:
- activeDictionaryType === 'THICKNESS_SPEC'
- ? toOptionalNumber(dictionaryForm.thicknessMm)
- : null,
- })
- startTransition(() => {
- setDictionaries((current) =>
- sortDictionaryBundle(
- patchDictionaryBundle(current, activeDictionaryType, (items) => [...items, option]),
- ),
- )
- setDictionaryModalOpen(false)
- toast.success(`${dictionaryLabel(activeDictionaryType)}已创建`)
- })
- return
- }
-
- if (!editingDictionaryId) return
-
- const option = await updateSubstrateDictionary(editingDictionaryId, {
- type: activeDictionaryType,
- name: activeDictionaryType === 'THICKNESS_SPEC' ? targetName : dictionaryForm.name.trim(),
- sortOrder: toOptionalNumber(dictionaryForm.sortOrder) ?? 0,
- thicknessMm:
- activeDictionaryType === 'THICKNESS_SPEC'
- ? toOptionalNumber(dictionaryForm.thicknessMm)
- : null,
- })
-
- startTransition(() => {
- setDictionaries((current) =>
- sortDictionaryBundle(
- patchDictionaryBundle(current, activeDictionaryType, (items) =>
- items.map((item) => (item.id === editingDictionaryId ? option : item)),
- ),
- ),
- )
- setDictionaryModalOpen(false)
- toast.success(`${dictionaryLabel(activeDictionaryType)}已更新`)
- })
- } catch (error) {
- toast.error(error instanceof Error ? error.message : '字典保存失败')
- } finally {
- setSubmitting(false)
- }
- }
-
- async function handleConfirmDelete() {
- if (!deleteTarget) return
-
- setSubmitting(true)
- try {
- if (deleteTarget.type === 'substrate') {
- await deleteSubstrate(deleteTarget.id)
- startTransition(() => {
- setSubstrates((current) => current.filter((item) => item.id !== deleteTarget.id))
- setSubstrateForm((current) =>
- panel.mode === 'edit-substrate' && panel.id === deleteTarget.id
- ? emptySubstrateForm(dictionaries)
- : current,
- )
- if (
- (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') &&
- panel.id === deleteTarget.id
- ) {
- setPanel({ mode: 'empty' })
- }
- setDeleteTarget(null)
- toast.success('基材已删除')
- })
- return
- }
-
- if (deleteTarget.type === 'finish') {
- await deleteMaterial(deleteTarget.id)
- startTransition(() => {
- setFinishes((current) => current.filter((item) => item.id !== deleteTarget.id))
- setSubstrates((current) =>
- current.map((item) => ({
- ...item,
- finishes: item.finishes.filter((finish) => finish.id !== deleteTarget.id),
- })),
- )
- setSubstrateForm((current) => ({
- ...current,
- finishIds: current.finishIds.filter((id) => id !== deleteTarget.id),
- }))
- if (
- (panel.mode === 'view-finish' || panel.mode === 'edit-finish') &&
- panel.id === deleteTarget.id
- ) {
- setPanel({ mode: 'empty' })
- }
- setDeleteTarget(null)
- toast.success('花色已删除')
- })
- return
- }
-
- if (deleteTarget.type === 'material-category') {
- await deleteMaterialCategory(deleteTarget.id)
- startTransition(() => {
- setCategories((current) => current.filter((item) => item.id !== deleteTarget.id))
- setSelectedCategoryId((current) => (current === deleteTarget.id ? 'all' : current))
- setFinishForm((current) => ({
- ...current,
- categoryId: current.categoryId === deleteTarget.id ? '' : current.categoryId,
- }))
- setDeleteTarget(null)
- toast.success('花色分类已删除')
- })
- return
- }
-
- await deleteSubstrateDictionary(deleteTarget.id, deleteTarget.dictionaryType)
- startTransition(() => {
- setDictionaries((current) =>
- sortDictionaryBundle(
- patchDictionaryBundle(current, deleteTarget.dictionaryType, (items) =>
- items.filter((item) => item.id !== deleteTarget.id),
- ),
- ),
- )
- setSubstrateForm((current) => {
- if (deleteTarget.dictionaryType === 'CATEGORY' && current.categoryId === deleteTarget.id) {
- return { ...current, categoryId: '', categoryText: '' }
- }
- if (
- deleteTarget.dictionaryType === 'ENVIRONMENT_GRADE' &&
- current.environmentGradeId === deleteTarget.id
- ) {
- return { ...current, environmentGradeId: '' }
- }
- if (
- deleteTarget.dictionaryType === 'THICKNESS_SPEC' &&
- current.thicknessSpecId === deleteTarget.id
- ) {
- return { ...current, thicknessSpecId: '' }
- }
- if (
- deleteTarget.dictionaryType === 'SURFACE_OPTION' &&
- current.surfaceOptionId === deleteTarget.id
- ) {
- return { ...current, surfaceOptionId: '' }
- }
- if (
- deleteTarget.dictionaryType === 'SYSTEM_NAME_OPTION' &&
- current.systemNameOptionId === deleteTarget.id
- ) {
- return { ...current, systemNameOptionId: '' }
- }
- return current
- })
- setDeleteTarget(null)
- toast.success(`${dictionaryLabel(deleteTarget.dictionaryType)}已删除`)
- })
- } catch (error) {
- toast.error(error instanceof Error ? error.message : '删除失败')
- } finally {
- setSubmitting(false)
- }
- }
-
- function isFinishSelected(id: string) {
- return (panel.mode === 'view-finish' || panel.mode === 'edit-finish') && panel.id === id
- }
-
- function isSubstrateSelected(id: string) {
- return (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') && panel.id === id
- }
-
- let rightContent: ReactNode
- let inspectorHeader: ReactNode = null
-
- if (panel.mode === 'empty') {
if (activeTab === 'finish') {
- rightContent = (
-
-
-
-
-
-
-
- 实时预览区
-
-
- 先从左侧选一个花色,右侧上半区会立即显示材质预览,下半区进入参数维护。
-
-
-
+ const isCreate = panel.mode === 'create-finish'
+ const isEdit = panel.mode === 'edit-finish'
+ const isView = panel.mode === 'view-finish'
+ const currentForm = (isEdit || isCreate) ? finishForm : (selectedFinish ? materialToFinishForm(selectedFinish) : finishForm)
-
-
-
- Material Controls
-
-
参数工作区待激活
-
- 这里会显示金属度、粗糙度、透明度滑块,以及贴图、颜色和纹理参数。
-
+ return (
+
+ {/* 实时预览区 (Top) */}
+
+
+ {/* 参数控制区 (Bottom) */}
+
+
+
+
Material Definition
+
参数工作区
+
+
+ {isView && (
+ <>
+
+
+ >
+ )}
+ {(isCreate || isEdit) && (
+ <>
+
+
+ >
+ )}
+
-
-
- )
- } else {
- rightContent = (
-
-
-
-
+
+
+
+
+
+ setFinishForm(prev => ({ ...prev, name: e.target.value }))}
+ className="w-full rounded-2xl border-none bg-white/60 p-4 text-sm font-apple-bold text-apple-label-primary shadow-apple-xs focus:ring-4 focus:ring-apple-blue/5"
+ placeholder="输入花色名称..."
+ />
+
+
+
+
+
+
+ {/* 核心物理参数 - 滑块 */}
+
+ handleFinishSliderChange('metalness', v)}
+ />
+ handleFinishSliderChange('roughness', v)}
+ />
+ handleFinishSliderChange('opacity', v)}
+ />
+
+
+
+
+
外观资产 Appearance
+
+
+
基础色 (HEX)
+
+ setFinishForm(prev => ({ ...prev, color: e.target.value }))}
+ className="h-8 w-8 cursor-pointer rounded-lg border-none bg-transparent"
+ />
+ {currentForm.color.toUpperCase()}
+
+
+
+
纹理贴图
+
+ {currentForm.textureUrl ? (
+
+ ) : (
+
未绑定
+ )}
+ {(isCreate || isEdit) && (
+
+ )}
+
+
+
+
+
+
+
映射物理映射 Mapping
+
+
+
纹理实物尺寸
+
+ setFinishForm(prev => ({ ...prev, textureSizeMm: parseInt(e.target.value) || 0 }))}
+ className="w-20 rounded-lg border-none bg-white px-2 py-1 text-right text-xs font-apple-black text-apple-blue"
+ />
+ MM
+
+
+
+
重复率
+
+ setFinishForm(prev => ({ ...prev, textureRepeat: parseFloat(e.target.value) || 1 }))}
+ className="w-20 rounded-lg border-none bg-white px-2 py-1 text-right text-xs font-apple-black text-apple-blue"
+ />
+ RATIO
+
+
+
+
+
-
- 选择资料
-
-
- 从左侧选择一个基材或饰面查看详情,也可以直接点击「新建」开始维护。
-
)
}
- } else if (panel.mode === 'view-finish' && selectedFinish) {
- const viewForm = materialToFinishForm(selectedFinish)
- rightContent = (
-
-
-
-
-
-
- Material Controls
-
-
- 花色参数概览
-
-
- 右上是实时预览,右下固定展示材质参数;如需调整,可直接进入编辑。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 基础颜色
-
-
-
-
- {selectedFinish.color}
-
-
-
-
-
-
-
-
- {}}
- />
- {}}
- />
- {}}
- />
-
-
-
-
-
-
-
-
- {[
- { title: '缩略图', preview: viewForm.thumbnailPreview },
- { title: '颜色纹理', preview: viewForm.texturePreview },
- { title: '法线贴图', preview: viewForm.normalMapPreview },
- ].map((asset) => (
-
-
-
- {asset.title}
-
- {asset.preview && (
-
- )}
-
-
- {asset.preview ? (
-

- ) : (
-
-
-
- )}
-
- ))}
-
-
-
-
- )
- } else if (panel.mode === 'create-finish' || panel.mode === 'edit-finish') {
- const isCreate = panel.mode === 'create-finish'
- rightContent = (
-
-
-
-
-
-
-
- Material Controls
-
-
- {isCreate ? '新建花色工作台' : '编辑材质参数'}
-
-
- 预览始终在上面,参数始终在下面,调整时不会再被旧布局打散。
-
-
-
-
-
-
-
-
- setFinishForm((current) => ({ ...current, name: event.target.value }))}
- placeholder="例如 北美橡木"
- />
-
-
-
-
-
-
- 基础颜色
-
-
-
- setFinishForm((current) => ({ ...current, color: event.target.value }))
- }
- className="h-11 w-12 rounded-xl border border-apple-separator bg-transparent"
- />
-
-
- {finishForm.color}
-
-
-
-
-
- setFinishForm((current) => ({ ...current, textureSizeMm: event.target.value }))
- }
- />
-
-
-
-
- setFinishForm((current) => ({ ...current, metalness: nextValue }))
- }
- />
-
- setFinishForm((current) => ({ ...current, roughness: nextValue }))
- }
- />
-
- setFinishForm((current) => ({ ...current, opacity: nextValue }))
- }
- />
-
-
-
-
- setFinishForm((current) => ({ ...current, textureRepeat: event.target.value }))
- }
- />
-
- setFinishForm((current) => ({ ...current, textureRotation: event.target.value }))
- }
- />
-
-
-
- {[
- {
- key: 'thumbnail' as const,
- title: '缩略图',
- preview: finishForm.thumbnailPreview,
- inputRef: thumbnailInputRef,
- onClear: () =>
- setFinishForm((current) => ({
- ...current,
- thumbnailUrl: '',
- thumbnailPreview: '',
- })),
- },
- {
- key: 'texture' as const,
- title: '颜色纹理',
- preview: finishForm.texturePreview,
- inputRef: textureInputRef,
- onClear: () =>
- setFinishForm((current) => ({
- ...current,
- textureUrl: '',
- texturePreview: '',
- })),
- },
- {
- key: 'normal' as const,
- title: '法线贴图',
- preview: finishForm.normalMapPreview,
- inputRef: normalMapInputRef,
- onClear: () =>
- setFinishForm((current) => ({
- ...current,
- normalMapUrl: '',
- normalMapPreview: '',
- })),
- },
- ].map((asset) => (
-
-
-
- {asset.title}
-
- {asset.preview && (
-
- )}
-
-
- {asset.preview ? (
-

- ) : (
-
-
-
- )}
-
-
-
- {asset.preview && (
-
- )}
-
-
- ))}
-
-
-
-
-
-
-
-
- )
- } else if (panel.mode === 'view-substrate' && selectedSubstrate) {
- inspectorHeader = (
-
- )
- rightContent = (
-
-
-
基材详情
-
-
-
-
-
-
-
-
-
基材名称
-
{selectedSubstrate.name}
-
-
-
-
-
系统名称
-
{selectedSubstrate.systemNameOptionName ?? '-'}
-
-
-
编码
-
{selectedSubstrate.code}
-
-
-
材质主类
-
{selectedSubstrate.categoryName}
-
-
-
环保等级
-
{selectedSubstrate.environmentGradeName}
-
-
-
厚度规格
-
{selectedSubstrate.thicknessMm}MM
-
-
-
贴面类型
-
{selectedSubstrate.surfaceOptionName ?? '-'}
-
-
-
大板尺寸
-
- {selectedSubstrate.sheetLengthMm} × {selectedSubstrate.sheetWidthMm}
-
-
-
-
采购价
-
- {selectedSubstrate.suggestedPrice != null ? `¥${selectedSubstrate.suggestedPrice}` : '-'}
-
-
-
-
整板重量
-
- {selectedSubstrate.weightKg != null ? `${selectedSubstrate.weightKg} kg` : '-'}
-
-
-
-
-
-
关联花色
- {selectedSubstrate.finishes.length === 0 ? (
-
未关联花色
- ) : (
-
- {selectedSubstrate.finishes.map((finish) => (
-
- {finish.thumbnailUrl && (
-
- )}
- {finish.name}
-
- ))}
-
- )}
-
-
-
- )
- } else if (panel.mode === 'create-substrate' || panel.mode === 'edit-substrate') {
const isCreate = panel.mode === 'create-substrate'
- inspectorHeader = (
-
- )
+ const isView = panel.mode === 'view-substrate'
+ const activeSubstrate = isView && selectedSubstrate ? selectedSubstrate : null
- rightContent = (
-
-
-
- {isCreate ? '新建基材' : '编辑基材'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {selectedFinishItems.map((item) => (
-
- {item.thumbnailUrl && (
-
- )}
- {item.name}
-
-
- ))}
-
- {substrateForm.newFinishName.trim() && (
-
-
- {substrateForm.newFinishName.trim()}(新建)
-
-
- )}
-
-
-
- {
- setFinishSearch(event.target.value)
- setFinishDropdownOpen(true)
- }}
- onFocus={() => setFinishDropdownOpen(true)}
- onKeyDown={(event) => {
- if (event.key !== 'Enter') return
- event.preventDefault()
- const name = finishSearch.trim()
- if (!name) return
-
- const existing = filteredFinishesForSubstrate.find((item) => item.name === name)
- if (existing) {
- setSubstrateForm((current) => ({
- ...current,
- finishIds: current.finishIds.includes(existing.id)
- ? current.finishIds
- : [...current.finishIds, existing.id],
- newFinishName: '',
- }))
- } else {
- setSubstrateForm((current) => ({
- ...current,
- newFinishName: name,
- }))
- }
- setFinishSearch('')
- setFinishDropdownOpen(false)
- }}
- placeholder="输入花色名搜索或直接回车临时新建"
- className="flex-1 bg-transparent text-sm text-apple-label-primary placeholder:text-apple-label-tertiary focus:outline-none"
- />
-
-
-
- {finishDropdownOpen && (
-
-
- {finishSearch.trim() ? (
- finishAutoSuggestions.length > 0 ? (
- finishAutoSuggestions.map((item) => {
- const selected = substrateForm.finishIds.includes(item.id)
- return (
-
- )
- })
- ) : (
-
- )
- ) : (
- filteredFinishesForSubstrate.slice(0, 16).map((item) => {
- const selected = substrateForm.finishIds.includes(item.id)
- return (
-
- )
- })
- )}
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- )
- } else {
- rightContent = (
-
- )
- }
-
- return (
- <>
-
window.history.back()} className="rounded-apple-lg border border-apple-separator/20">
- 返回中心
-
- }
- metrics={liveMetrics}
- rightPanelClassName={activeTab === 'finish' ? 'w-[520px] max-w-[42vw]' : 'w-[760px] max-w-[65vw]'}
- leftContent={
-
-
-
- 资料分区 Library Sections
+ return (
+
+
+
+
+
+ Substrate Definition
-
-
+
+ {isCreate ? '新建基材' : activeSubstrate ? activeSubstrate.name : '基材详情'}
+
-
-
-
- {activeTab === 'finish' && (
-
-
- 花色分类 Finish Categories
-
-
-
- {categories.map((category) => (
-
- ))}
-
-
-
- )}
-
- {activeTab === 'substrate' && (
-
-
- 标准字典 Technical Dictionaries
-
-
- {(
- ['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]
- ).map((type) => (
-
- ))}
-
-
- )}
-
-
- {activeTab === 'finish' ? (
-
-
-
- Precision Material Definition Workspace
-
-
- 物料定义
-
-
- 左侧挑选花色,右侧上半区实时预览,下半区集中调金属度、粗糙度、透明度和纹理参数。
-
-
-
-
-
- {finishSpotlightPreviewUrl ? (
-

- ) : (
-
- )}
-
-
-
-
- {finishWorkspaceForm?.name || '等待选择花色'}
-
-
- {finishWorkspaceCategoryName} · {finishWorkspaceCode}
-
-
- {[
- { label: '金属度', value: finishWorkspaceForm?.metalness ?? '0' },
- { label: '粗糙度', value: finishWorkspaceForm?.roughness ?? '0.5' },
- { label: '透明度', value: finishWorkspaceForm?.opacity ?? '1' },
- ].map((item) => (
-
-
- {item.label}
-
-
- {formatDecimalValue(item.value)}
-
-
- ))}
-
-
-
-
- ) : (
-
-
-
- Technical Dictionary Workspace
-
-
- 基材定义
-
-
- 这里维护基材主类、环保等级、厚度规格和系统名称,下面的卡片区继续负责资料查找与新增。
-
-
-
-
- {[
- { label: '基材总数', value: substrates.length },
- { label: '花色总数', value: finishes.length },
- { label: '字典项', value: dictionaries.categories.length + dictionaries.environmentGrades.length + dictionaries.thicknessSpecs.length + dictionaries.surfaceOptions.length + dictionaries.systemNameOptions.length },
- { label: '当前系统名', value: previewSystemName || '未设置' },
- ].map((item) => (
-
-
- {item.label}
-
-
- {item.value}
-
-
- ))}
-
-
- )}
+
+ {isCreate ? (
+ <>
+
+
+ >
+ ) : null}
-
-
-
-
- setSearchText(event.target.value)}
- placeholder={activeTab === 'finish' ? '快捷搜索花色名称、分类、编码...' : '快捷搜索基材名称、编码、系统名称...'}
- className="w-full rounded-apple-xl border border-apple-separator/30 bg-white/60 py-2.5 pl-10 pr-10 text-sm text-apple-label-primary outline-none transition-all focus:bg-white focus:ring-2 focus:ring-apple-blue/5"
- />
- {searchText && (
-
- )}
-
-
-
-
- {activeTab === 'finish' ? '饰面列表 Finishes' : '基材列表 Substrates'} (
- {activeTab === 'finish' ? filteredFinishes.length : filteredSubstrates.length})
-
-
-
-
-
-
- {activeTab === 'finish' ? (
- filteredFinishes.length === 0 ? (
-
未找到花色
- ) : (
-
- {filteredFinishes.map((item) => (
-
- ))}
-
- )
- ) : filteredSubstrates.length === 0 ? (
-
未找到基材
- ) : (
-
- {filteredSubstrates.map((item) => (
-
- ))}
-
- )}
-
-
- }
- rightContent={
-
-
- {inspectorHeader}
-
- {rightContent}
-
-
-
- }
- />
-
-
setCategoryModalOpen(false)}
- title="管理花色分类"
- size="medium"
- footer={
-
-
-
-
- }
- >
-
-
-
花色分类只做归类,不承载纹理或 PBR 参数。
-
-
-
- {categories.length === 0 ? (
-
暂无分类
- ) : (
- categories.map((category) => {
- const usageCount = categoryUsageCountMap.get(category.id) ?? 0
- return (
-
-
-
{category.name}
-
- 排序 {category.sortOrder} · 已用 {usageCount}
-
-
-
-
-
- )
- })
- )}
-
-
setCategoryForm((current) => ({ ...current, name: event.target.value }))}
- />
-
- setCategoryForm((current) => ({ ...current, icon: event.target.value }))}
- />
- setCategoryForm((current) => ({ ...current, sortOrder: event.target.value }))}
- />
-
-
setDictionaryModalOpen(false)}
- title={`管理${dictionaryLabel(activeDictionaryType)}`}
- size="medium"
- footer={
-
-
-
-
- }
- >
-
-
-
字典支持新增、编辑、删除;已被基材引用时后端会拦截删除。
-
-
-
- {activeDictionaryOptions.length === 0 ? (
-
暂无选项
- ) : (
- activeDictionaryOptions.map((item) => (
-
-
-
- {item.type === 'THICKNESS_SPEC' && item.thicknessMm != null
- ? `${item.thicknessMm}MM`
- : item.name}
- {isLegacySeedOptionId(item.id) ? '(历史默认)' : ''}
-
-
- 排序 {item.sortOrder}
- {usedDictionaryIds[activeDictionaryType].has(item.id) ? ' · 已使用' : ''}
-
-
-
-
+
+ {isCreate ? (
+
+
+
+
+
+ setSubstrateForm((prev) => ({
+ ...prev,
+ categoryId: e.target.value,
+ categoryText: dictionaries.categories.find((item) => item.id === e.target.value)?.name ?? '',
+ }))
+ }
+ options={dictionaries.categories.map((item) => ({ value: item.id, label: item.name }))}
+ className="h-13.5 w-full rounded-2xl border-none bg-white/60 shadow-apple-xs"
+ />
- ))
- )}
-
- {activeDictionaryType === 'THICKNESS_SPEC' ? (
- <>
-
- setDictionaryForm((current) => ({
- ...current,
- thicknessMm: event.target.value,
- name: event.target.value,
- }))
- }
- placeholder="例如 12、15、18"
- />
-
- setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))
- }
- />
- >
+
+
+
+ setSubstrateForm((prev) => ({ ...prev, environmentGradeId: e.target.value }))
+ }
+ options={dictionaries.environmentGrades.map((item) => ({ value: item.id, label: item.name }))}
+ className="h-13.5 w-full rounded-2xl border-none bg-white/60 shadow-apple-xs"
+ />
+
+
+
+
+ setSubstrateForm((prev) => ({ ...prev, thicknessSpecId: e.target.value }))
+ }
+ options={dictionaries.thicknessSpecs.map((item) => ({ value: item.id, label: item.name }))}
+ className="h-13.5 w-full rounded-2xl border-none bg-white/60 shadow-apple-xs"
+ />
+
+
+
+
+ setSubstrateForm((prev) => ({ ...prev, surfaceOptionId: e.target.value }))
+ }
+ options={dictionaries.surfaceOptions.map((item) => ({ value: item.id, label: item.name }))}
+ className="h-13.5 w-full rounded-2xl border-none bg-white/60 shadow-apple-xs"
+ />
+
+
+
+
+ setSubstrateForm((prev) => ({ ...prev, systemNameOptionId: e.target.value }))
+ }
+ options={dictionaries.systemNameOptions.map((item) => ({ value: item.id, label: item.name }))}
+ className="h-13.5 w-full rounded-2xl border-none bg-white/60 shadow-apple-xs"
+ />
+
+
+
+
+ setSubstrateForm((prev) => ({ ...prev, suggestedPrice: e.target.value }))
+ }
+ className="w-full rounded-2xl border-none bg-white/60 p-4 text-sm font-apple-bold text-apple-label-primary shadow-apple-xs focus:ring-4 focus:ring-apple-blue/5"
+ placeholder="输入采购价"
+ />
+
+
+
+ ) : activeSubstrate ? (
+
+
+ 名称
+ {activeSubstrate.name}
+
+
+ 编码
+ {activeSubstrate.code}
+
+
+ 采购价
+
+ {activeSubstrate.suggestedPrice ?? '--'}
+
+
+
) : (
- <>
-
- setDictionaryForm((current) => ({ ...current, name: event.target.value }))
- }
- />
-
- setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))
- }
- />
- >
+
请选择一个基材查看详情。
)}
-
+
+ )
+ }
-
setDeleteTarget(null)}
- onConfirm={handleConfirmDelete}
- title={
- deleteTarget?.type === 'substrate'
- ? '删除基材'
- : deleteTarget?.type === 'finish'
- ? '删除花色'
- : deleteTarget?.type === 'material-category'
- ? '删除花色分类'
- : deleteTarget?.type === 'dictionary'
- ? `删除${dictionaryLabel(deleteTarget.dictionaryType)}`
- : '删除'
- }
- description={`确认删除「${deleteTarget?.name ?? ''}」吗?`}
- confirmText="确认删除"
- />
+ // --- Render Layout ---
- {previewUrl && (
- setPreviewUrl(null)}>
-
-
-

event.stopPropagation()}
- />
-
-
- )}
+ return (
+
+
- >
+ leftContent={
+
+ {/* Section 1: Library Tabs */}
+
+
+ Library Sections
+
+
+ {[
+ { id: 'substrate', label: '基材录入', icon: Layers },
+ { id: 'finish', label: '饰面花色', icon: Palette }
+ ].map(tab => (
+
+ ))}
+
+
+
+ {/* Section 2: Categories (For Finish) */}
+ {activeTab === 'finish' && (
+
+
+
+ FINISH CATEGORIES
+
+
+
+
+
+ {categories.map(cat => (
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Section 3: Dictionary Stats */}
+
+
Global Dictionaries
+
+
+
+
+
+
+
+ }
+ mainContent={
+
+ {/* Toolbar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-11 w-full rounded-2xl border-none bg-white/80 pl-11 pr-4 text-apple-sm font-apple-medium text-apple-label-primary outline-none focus:bg-white focus:ring-4 focus:ring-apple-blue/5"
+ />
+
+
+
+
+
+
+ {/* Grid Content */}
+
+ {activeTab === 'finish' ? (
+
+ {filteredFinishes.map(finish => (
+
setPanel({ mode: 'view-finish', id: finish.id })}
+ />
+ ))}
+ {filteredFinishes.length === 0 && (
+
+ )}
+
+ ) : (
+
+ {filteredSubstrates.map((substrate) => (
+
+ ))}
+ {filteredSubstrates.length === 0 ? (
+
当前没有匹配的基材。
+ ) : null}
+
+ )}
+
+
+ }
+ rightContent={renderPanelContent()}
+ />
+
+
+ )
+}
+
+function DictionaryStat({ label, count }: { label: string, count: number }) {
+ return (
+
+ {label}
+ {count}
+
+ )
+}
+
+function MaterialCard({ item, isSelected, onClick }: { item: MaterialDTO, isSelected: boolean, onClick: () => void }) {
+ return (
+
+ {/* 缩略图区 */}
+
+ {item.thumbnailUrl || item.textureUrl ? (
+

+ ) : (
+
+ )}
+
+
+
+
+ {item.categoryName}
+
+
+
+
+ {/* 文本内容 */}
+
+
+
+ {item.name}
+
+ {isSelected &&
}
+
+
+ {item.code}
+ |
+ {item.textureSizeMm}MM
+
+
+
+ {/* 背景装饰 */}
+
+ {item.code.split('-').pop()}
+
+
)
}
diff --git a/furniture-3d-system/src/app/asset-center/material-library/page.tsx b/furniture-3d-system/src/app/asset-center/material-library/page.tsx
index 4762c779..b0d4e07d 100644
--- a/furniture-3d-system/src/app/asset-center/material-library/page.tsx
+++ b/furniture-3d-system/src/app/asset-center/material-library/page.tsx
@@ -17,8 +17,8 @@ export default async function MaterialLibraryPage() {
{
expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument()
expect(screen.getByText('库存:2')).toBeInTheDocument()
- await actor.click(screen.getByRole('checkbox', { name: /桌腿A/ }))
+ await actor.click(screen.getByRole('button', { name: '支撑组件' }))
+ await actor.click(screen.getByRole('button', { name: /桌腿A/ }))
await waitFor(() => {
expect(screen.getByText('库存:1')).toBeInTheDocument()
})
- await actor.click(screen.getByRole('checkbox', { name: /桌腿A/ }))
+ await actor.click(screen.getByRole('button', { name: /桌腿A/ }))
await waitFor(() => {
expect(screen.getByText('库存:2')).toBeInTheDocument()
@@ -212,7 +213,7 @@ describe('AssistedAssemblyClient', () => {
await actor.click(screen.getByRole('button', { name: /准备可保存工作台/ }))
await actor.clear(screen.getByLabelText('草稿组名称'))
await actor.type(screen.getByLabelText('草稿组名称'), '测试草稿组')
- await actor.click(screen.getByRole('button', { name: /保存草稿组/ }))
+ await actor.click(screen.getByRole('button', { name: /仅保存草稿/ }))
await waitFor(() => {
expect(createDraftGroup).toHaveBeenCalledWith(
@@ -230,6 +231,8 @@ describe('AssistedAssemblyClient', () => {
})
it('资产中心带料进入时,会保留来源资产族并保留草稿规格款作为默认选择', async () => {
+ const actor = userEvent.setup()
+
mockSearchParams = new URLSearchParams(
'entryMode=asset-center&source=asset-center&desktopFamilyIds=family-board-draft',
)
@@ -268,14 +271,21 @@ describe('AssistedAssemblyClient', () => {
)
await waitFor(() => {
- expect(screen.getByRole('checkbox', { name: /草稿桌面/ })).toBeChecked()
expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument()
expect(screen.getByText('库存:1')).toBeInTheDocument()
expect(screen.getByText('首个规格款:variant-board-draft')).toBeInTheDocument()
})
+
+ await actor.click(screen.getByRole('button', { name: '板件资产' }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /草稿桌面/ })).toHaveAttribute('aria-pressed', 'true')
+ })
})
it('源 SKU 新增进入时,只预选来源装配涉及的资产族,不会回退成全量默认选中', async () => {
+ const actor = userEvent.setup()
+
mockSearchParams = new URLSearchParams(
'entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&desktopFamilyIds=family-board-source',
)
@@ -339,8 +349,13 @@ describe('AssistedAssemblyClient', () => {
await waitFor(() => {
expect(screen.getByText('库存:1')).toBeInTheDocument()
expect(screen.getByText('首个规格款:variant-board-source')).toBeInTheDocument()
- expect(screen.getByRole('checkbox', { name: /源桌面/ })).toBeChecked()
- expect(screen.getByRole('checkbox', { name: /额外桌面/ })).not.toBeChecked()
+ })
+
+ await actor.click(screen.getByRole('button', { name: '板件资产' }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /源桌面/ })).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByRole('button', { name: /额外桌面/ })).toHaveAttribute('aria-pressed', 'false')
})
})
})
diff --git a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx
index bf2c5743..6aaad82a 100644
--- a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx
+++ b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx
@@ -2,6 +2,17 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
+import {
+ AlertCircle,
+ Box,
+ Layers3,
+ Link2,
+ Save,
+ Shapes,
+ ShieldCheck,
+ Sparkles,
+ Zap,
+} from 'lucide-react'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import { AssemblyWorkbenchV2 } from '@/components/platform/AssemblyWorkbenchV2'
@@ -22,6 +33,7 @@ import {
} from '@/lib/open-product/manual-assembly-launch'
import type { OpenProductAssetFamily } from '@/lib/open-product/types'
import type { AssetFamilyDTO } from '@/server/services'
+import { cn } from '@/lib/utils'
interface AssistedAssemblyClientProps {
families: AssetFamilyDTO[]
@@ -36,8 +48,9 @@ interface AssistedAssemblyClientProps {
} | null
}
-interface CompactFamilyListProps {
+interface FloatingDockProps {
title: string
+ icon: typeof Layers3
families: AssetFamilyDTO[]
selectedIds: string[]
availableVariantMap: Map
@@ -51,56 +64,6 @@ function toggleSelection(list: string[], value: string) {
return [...list, value]
}
-function CompactFamilyList({
- title,
- families,
- selectedIds,
- availableVariantMap,
- onToggle,
-}: CompactFamilyListProps) {
- if (families.length === 0) return null
-
- return (
-
-
{title}
-
- {families.map((family) => {
- const isSelected = selectedIds.includes(family.id)
- const variantCount = availableVariantMap.get(family.id)?.length ?? 0
- return (
-
-
onToggle(family.id)}
- className="h-3.5 w-3.5 rounded border-apple-separator text-apple-green focus:ring-apple-green"
- />
-
-
- )
- })}
-
-
- )
-}
-
function getVariantStatusPriority(status: AssistedAssemblyVariant['status']) {
if (status === 'AVAILABLE') return 0
if (status === 'DRAFT') return 1
@@ -202,6 +165,123 @@ function resolveWeakCategoryId(selectedFamilies: AssetFamilyDTO[]) {
return categoryIds.length === 1 ? categoryIds[0] : null
}
+function FloatingDock({
+ title,
+ icon: Icon,
+ families,
+ selectedIds,
+ availableVariantMap,
+ onToggle,
+}: FloatingDockProps) {
+ const [expanded, setExpanded] = useState(false)
+
+ if (families.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {families.map((family) => {
+ const isSelected = selectedIds.includes(family.id)
+ const variantCount = availableVariantMap.get(family.id)?.length ?? 0
+
+ return (
+
+ )
+ })}
+
+
+
+
+
+ {!expanded ? (
+
+ {title}
+
+ ) : null}
+
+
+ )
+}
+
+function StatCard({
+ label,
+ value,
+ icon: Icon,
+ color = 'text-white/60',
+}: {
+ label: string
+ value: number
+ icon: typeof Box
+ color?: string
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+
{value}
+
+ )
+}
+
export default function AssistedAssemblyClient({
families,
variants,
@@ -300,14 +380,17 @@ export default function AssistedAssemblyClient({
}),
[connectorFamilies, connectorIds, desktopFamilies, desktopIds, legFamilies, legIds],
)
+
const selectedFamilyCount =
selectedFamilyMap.desktops.length +
selectedFamilyMap.legs.length +
selectedFamilyMap.connectors.length
+
const workspaceTitle = useMemo(
() => buildAssemblyDefaultName(selectedFamilyMap),
[selectedFamilyMap],
)
+
const weakCategoryId = useMemo(
() =>
resolveWeakCategoryId([
@@ -317,25 +400,35 @@ export default function AssistedAssemblyClient({
]),
[selectedFamilyMap],
)
+
const saveValidation = workspace ? deriveAssemblySaveValidation(workspace) : null
const canSave =
Boolean(workspace) &&
Boolean(draftGroupName.trim()) &&
selectedFamilyCount > 0 &&
saveValidation?.level !== 'BLOCKED'
- const workspaceMainNodes = workspace?.nodes.filter((node) => node.zone === 'main') ?? []
- const workspaceConnectedNodeIdSet = useMemo(
- () =>
- new Set(
- (workspace?.relations ?? [])
- .filter((relation) => relation.status === 'CONNECTED')
- .map((relation) => relation.sourceNodeId),
- ),
- [workspace?.relations],
- )
- const workspacePendingMainNodeCount = workspaceMainNodes.filter(
- (node) => node.source !== 'desktop' && !workspaceConnectedNodeIdSet.has(node.nodeId),
- ).length
+
+ const workspaceStats = useMemo(() => {
+ if (!workspace) {
+ return { nodeCount: 0, relationCount: 0, pendingMainNodeCount: 0 }
+ }
+
+ const connectedNodeIdSet = new Set(
+ workspace.relations
+ .filter((relation) => relation.status === 'CONNECTED')
+ .map((relation) => relation.sourceNodeId),
+ )
+ const mainNodes = workspace.nodes.filter((node) => node.zone === 'main')
+
+ return {
+ nodeCount: workspace.nodes.length,
+ relationCount: workspace.relations.filter((relation) => relation.status === 'CONNECTED').length,
+ pendingMainNodeCount: mainNodes.filter(
+ (node) => node.source !== 'desktop' && !connectedNodeIdSet.has(node.nodeId),
+ ).length,
+ }
+ }, [workspace])
+
const stageStatusLabel =
selectedFamilyCount === 0 ? '待选料' : canSave ? '可保存' : '装配中'
@@ -492,146 +585,227 @@ export default function AssistedAssemblyClient({
}
}
- const inspectorContent = (
-
-
-
-
-
- {entryMode === 'source-sku'
- ? '源 SKU 辅助新增'
- : entryMode === 'asset-center'
- ? '资产中心带料进入'
- : '直接进入辅助装配'}
-
-
- 当前物料区会优先展示来源资产族,其余资产族按最新时间补在后面。
-
-
-
- {stageStatusLabel}
-
-
- {feedback ? (
-
- {feedback.text}
-
- ) : null}
-
-
-
- setDesktopIds(toggleSelection(desktopIds, id))}
- />
- setLegIds(toggleSelection(legIds, id))}
- />
- setConnectorIds(toggleSelection(connectorIds, id))}
- />
-
-
- {selectedFamilyCount > 0 ? (
-
- ) : null}
-
- )
+ const workbenchWarnings = workspace
+ ? [
+ ...workspace.validation.warnings,
+ ...(workspaceStats.pendingMainNodeCount > 0
+ ? [`主装配区还有 ${workspaceStats.pendingMainNodeCount} 个节点待完成连接`]
+ : []),
+ ]
+ : []
return (
- item.inventoryId).join('|'),
- ].join(':')}
- candidateName={workspaceTitle}
- title="辅助装配"
- statusTag="辅助拼装工作台"
- warnings={[
- ...workspace.validation.warnings,
- ...(workspacePendingMainNodeCount > 0
- ? [`主装配区还有 ${workspacePendingMainNodeCount} 个节点待完成连接`]
- : []),
- ]}
- availableVariants={variants}
- onChange={setWorkspace}
- rightDrawerContent={inspectorContent}
- />
- ) : (
-
-
- 正在准备工作台
+
+
+ item.inventoryId).join('|'),
+ ].join(':')}
+ candidateName={workspaceTitle}
+ statusTag="辅助装配工作台"
+ warnings={workbenchWarnings}
+ availableVariants={variants}
+ onChange={setWorkspace}
+ className="h-full w-full"
+ title="辅助装配工作台"
+ />
+
+
+ setDesktopIds((current) => toggleSelection(current, id))}
+ />
+ setLegIds((current) => toggleSelection(current, id))}
+ />
+ setConnectorIds((current) => toggleSelection(current, id))}
+ />
+
+
+
+
+ 孔位捕捉已就绪 · 拖拽零件进行吸附
+
+
+ ) : (
+
+ )
+ }
+ rightContent={
+
+
+
+
+
+ Real-time Inspector
+
+
+
+ 装配定义报告
+
+
+
+
+
+
+
+
+ {entryMode === 'source-sku'
+ ? '源 SKU 辅助新增'
+ : entryMode === 'asset-center'
+ ? '资产中心带料进入'
+ : '直接进入辅助装配'}
+
+
+ 当前物料区会优先展示来源资产族,其余资产族按最新时间补在后面。
+
+
+
+ {stageStatusLabel}
+
+
+
+ {feedback ? (
+
+ {feedback.text}
+
+ ) : null}
+
+
+
+
+
+
+ 装配拓扑进度 Topology
+
+
+
+
+
+
+
+
+
+ 结构稳固性推演
+
+
+ {workspaceStats.relationCount > 0 ? '85%' : '0%'}
+
+
+
+
0 ? '85%' : '0%' }}
+ />
+
+
+
+
+ {saveValidation?.level === 'BLOCKED' ? (
+
+
+
+ {saveValidation.blockingIssues[0]}
+
+
+ ) : null}
+
+
+
+
+
+
+
- )
- }
- />
+ }
+ />
+
)
}
diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx
index fd9685d7..81ea212b 100644
--- a/furniture-3d-system/src/app/intake/IntakeClient.tsx
+++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx
@@ -188,7 +188,13 @@ function ImagePreviewDialog({
)
}
-function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
+function PendingSkuDetail({
+ skuList,
+ canWrite,
+}: {
+ skuList: WorkbenchSku[]
+ canWrite: boolean
+}) {
const [activeTab, setActiveTab] = useState
('pending')
const [spuKeyword, setSpuKeyword] = useState('')
const [skuKeyword, setSkuKeyword] = useState('')
@@ -321,7 +327,7 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
| 编号 |
图片 |
- 上传 |
+ {canWrite ? 上传 | : null}
SPU |
SKU |
系列 |
@@ -332,7 +338,10 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
{filteredList.length === 0 ? (
- |
+ |
当前筛选条件下没有找到 SKU。
|
@@ -347,11 +356,13 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
SKU 图
-
-
- |
+ {canWrite ? (
+
+
+ |
+ ) : null}
{sku.spuCode} |
{sku.code}
@@ -366,9 +377,11 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
-
+ {canWrite ? (
+
+ ) : null}
|
@@ -412,8 +425,10 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
function NonIntegratedPendingDetail({
initialProducts,
+ canWrite,
}: {
initialProducts: NonIntegratedProductRecord[]
+ canWrite: boolean
}) {
const [products, setProducts] = useState(() =>
mergeNonIntegratedProducts(initialProducts, listNonIntegratedProducts()),
@@ -433,6 +448,11 @@ function NonIntegratedPendingDetail({
})
async function handleDelete(productId: string) {
+ if (!canWrite) {
+ window.alert('普通用户只有查看和下载权限,不能删除正式入库资料。')
+ return
+ }
+
if (!window.confirm('确认删除这条非集成产品测试数据吗?')) {
return
}
@@ -477,7 +497,11 @@ function NonIntegratedPendingDetail({
-
+
-
-
-
-
-
+ {canWrite ? (
+
+
+
+
+
+ ) : null}
@@ -565,7 +591,9 @@ function NonIntegratedPendingDetail({
|
{products.length === 0
- ? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。'
+ ? canWrite
+ ? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。'
+ : '当前还没有非集成产品。'
: '当前查询条件下没有找到资料。'}
|
@@ -605,15 +633,17 @@ function NonIntegratedPendingDetail({
-
+
-
+ {canWrite ? (
+
+ ) : null}
|
@@ -705,6 +735,7 @@ function FormalIntakeWorkbench({
export default function IntakeClient(props: IntakeClientProps) {
const [activeSection, setActiveSection] = useState
('pending')
const [pendingEntryTab, setPendingEntryTab] = useState('nonIntegrated')
+ const canWriteIntake = props.user.role !== 'USER'
if (!isEditorMode(props)) {
const skuList =
@@ -725,9 +756,12 @@ export default function IntakeClient(props: IntakeClientProps) {
if (activeSection === 'pending') {
leftContent =
pendingEntryTab === 'integrated' ? (
-
+
) : (
-
+
)
} else if (activeSection === 'bom') {
leftContent =
@@ -779,9 +813,9 @@ export default function IntakeClient(props: IntakeClientProps) {
if (activeSection === 'pending') {
leftContent =
pendingEntryTab === 'integrated' ? (
-
+
) : (
-
+
)
} else if (activeSection === 'bom') {
leftContent =
diff --git a/furniture-3d-system/src/app/products/[id]/page.tsx b/furniture-3d-system/src/app/products/[id]/page.tsx
index 2d5df74e..f39e7020 100644
--- a/furniture-3d-system/src/app/products/[id]/page.tsx
+++ b/furniture-3d-system/src/app/products/[id]/page.tsx
@@ -65,7 +65,7 @@ export default async function ProductDetailPage({ params }: ProductPageProps) {
redirect('/products')
}
- const canViewSensitive = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(authUser.role)
+ const canViewSensitive = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'].includes(authUser.role)
// 获取产品详情
const product = await prisma.product.findUnique({
diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx
index 70f0be81..de96d364 100644
--- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx
+++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx
@@ -239,7 +239,7 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) {
redirect('/products')
}
- const canViewSensitive = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(authUser.role)
+ const canViewSensitive = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'].includes(authUser.role)
// 获取产品信息(包含完整 SKU 数据)
const product = await prisma.product.findUnique({
diff --git a/furniture-3d-system/src/app/register/page.tsx b/furniture-3d-system/src/app/register/page.tsx
index a96300f1..ded023fd 100644
--- a/furniture-3d-system/src/app/register/page.tsx
+++ b/furniture-3d-system/src/app/register/page.tsx
@@ -73,7 +73,7 @@ export default function RegisterPage() {
return;
}
- toast.success('注册成功,正在返回登录页...');
+ toast.success('注册成功,请等待管理员激活');
router.push(`/login?registered=1&identifier=${encodeURIComponent(email)}`);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
@@ -109,7 +109,7 @@ export default function RegisterPage() {
- 创建账号后即可进入系统开始试用
+ 注册后先等待后台激活,再进入系统
@@ -132,7 +132,7 @@ export default function RegisterPage() {
注册
- 创建一个普通设计账号,注册后可直接返回登录
+ 所有新账号都会先进入待激活状态,管理员审核通过后才能登录
@@ -203,6 +203,12 @@ export default function RegisterPage() {
注册账号
+
+
+ 注册成功后不会自动放行登录。管理员在后台激活并分配角色后,你才能正式进入系统。
+
+
+
已有账号?
diff --git a/furniture-3d-system/src/app/users/page.tsx b/furniture-3d-system/src/app/users/page.tsx
index 5a922c3b..77536c3e 100644
--- a/furniture-3d-system/src/app/users/page.tsx
+++ b/furniture-3d-system/src/app/users/page.tsx
@@ -2,14 +2,18 @@
/**
* 身份控制中心 (Identity Control Center)
- * Precision Atelier - 管理基础设施重构版
+ * Precision Atelier - 三栏式管理工作台重构版
*/
-import { useCallback, useState } from 'react';
-import { Search, ShieldCheck, Filter, Users, UserPlus, Fingerprint } from 'lucide-react';
+import { useCallback, useState, useMemo } from 'react';
+import {
+ Plus, Search, ShieldCheck, Filter, Users, UserPlus,
+ Fingerprint, ArrowLeft, LayoutGrid, Info, ShieldAlert,
+ Mail, Phone, Briefcase, Calendar, Shield, Activity
+} from 'lucide-react';
import { AuthProvider, useAuth } from '@/hooks/useAuth';
import { RequireAuth } from '@/components/auth/RequireAuth';
-import { AdminGuard } from '@/components/auth/PermissionGuard';
+import { PermissionGuard } from '@/components/auth/PermissionGuard';
import Button from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Alert } from '@/components/ui/Alert';
@@ -17,19 +21,17 @@ import { CreateUserModal } from '@/components/users/CreateUserModal';
import { EditUserModal } from '@/components/users/EditUserModal';
import { HandoverModal } from '@/components/users/HandoverModal';
import { DepartmentAccessTree, type UserTreeItem } from '@/components/users/DepartmentAccessTree';
-import { getApiErrorMessage } from '@/lib/utils';
+import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout';
+import { getApiErrorMessage, cn } from '@/lib/utils';
import { useDebounce } from '@/hooks/usePerformance';
-type ActionError = string | null;
-
const ROLE_OPTIONS = [
{ value: '', label: '所有权限角色' },
{ value: 'SUPER_ADMIN', label: '超级管理员' },
- { value: 'ADMIN', label: '系统管理员' },
- { value: 'DEPARTMENT_MANAGER', label: '部门主管' },
- { value: 'TEAM_LEADER', label: '项目组长' },
- { value: 'DESIGNER', label: '核心设计师' },
- { value: 'VIEWER', label: '受限访客' },
+ { value: 'ADMIN', label: '管理员' },
+ { value: 'SUPERVISOR', label: '主管' },
+ { value: 'DESIGNER', label: '设计师' },
+ { value: 'USER', label: '普通用户' },
];
const STATUS_OPTIONS = [
@@ -44,9 +46,9 @@ export default function UsersPage() {
return (
-
+
-
+
);
@@ -59,9 +61,9 @@ function UsersPageContent() {
const [statusFilter, setStatusFilter] = useState('');
const [refreshToken, setRefreshToken] = useState(0);
const [showCreateModal, setShowCreateModal] = useState(false);
- const [editingUser, setEditingUser] = useState(null);
+ const [selectedUser, setSelectedUser] = useState(null);
const [handoverUser, setHandoverUser] = useState(null);
- const [actionError, setActionError] = useState(null);
+ const [actionError, setActionError] = useState(null);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
@@ -69,17 +71,9 @@ function UsersPageContent() {
setRefreshToken((current) => current + 1);
}, []);
- const closeCreateModal = useCallback(() => {
- setShowCreateModal(false);
- }, []);
-
- const closeEditModal = useCallback(() => {
- setEditingUser(null);
- }, []);
-
- const closeHandoverModal = useCallback(() => {
- setHandoverUser(null);
- }, []);
+ const closeCreateModal = useCallback(() => setShowCreateModal(false), []);
+ const closeEditModal = useCallback(() => setSelectedUser(null), []);
+ const closeHandoverModal = useCallback(() => setHandoverUser(null), []);
const handleCreateSuccess = useCallback(() => {
closeCreateModal();
@@ -91,216 +85,228 @@ function UsersPageContent() {
triggerRefresh();
}, [closeEditModal, triggerRefresh]);
- const handleHandoverSuccess = useCallback((_message: string) => {
+ const handleHandoverSuccess = useCallback(() => {
closeHandoverModal();
triggerRefresh();
}, [closeHandoverModal, triggerRefresh]);
- const handleDeleteUser = useCallback(
- async (userId: string) => {
- try {
- setActionError(null);
- const confirmed = window.confirm('确定要移除此访问权限吗?此操作不可撤销。');
- if (!confirmed) {
- return;
- }
+ const handleDeleteUser = useCallback(async (userId: string) => {
+ try {
+ setActionError(null);
+ if (!window.confirm('确定要移除此访问权限吗?此操作不可撤销。')) return;
+ const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', credentials: 'include' });
+ if (!response.ok) throw new Error('删除用户失败');
+ triggerRefresh();
+ } catch (error) {
+ setActionError(error instanceof Error ? error.message : '删除失败');
+ }
+ }, [triggerRefresh]);
- const response = await fetch(`/api/users/${userId}`, {
- method: 'DELETE',
- credentials: 'include',
- });
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}));
- throw new Error(getApiErrorMessage(data.error, '删除用户失败'));
- }
-
- triggerRefresh();
- } catch (error) {
- setActionError(error instanceof Error ? error.message : '删除用户失败');
- }
- },
- [triggerRefresh]
- );
-
- const handleActivateUser = useCallback(
- async (userId: string) => {
- try {
- setActionError(null);
- const response = await fetch(`/api/users/${userId}/activate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({}),
- });
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}));
- throw new Error(getApiErrorMessage(data.error, '激活用户失败'));
- }
-
- triggerRefresh();
- } catch (error) {
- setActionError(error instanceof Error ? error.message : '激活用户失败');
- }
- },
- [triggerRefresh]
- );
+ const handleActivateUser = useCallback(async (userId: string) => {
+ try {
+ setActionError(null);
+ const response = await fetch(`/api/users/${userId}/activate`, { method: 'POST', credentials: 'include' });
+ if (!response.ok) throw new Error('激活用户失败');
+ triggerRefresh();
+ } catch (error) {
+ setActionError(error instanceof Error ? error.message : '激活失败');
+ }
+ }, [triggerRefresh]);
return (
-
- {/* 背景光晕装饰 */}
-
+
+
-
-
-
-
-
-
+ leftContent={
+
+
+
Quick Filters
+
+ {ROLE_OPTIONS.map(opt => (
+
+ ))}
+
-
-
-
-
- Identity & Access Management
-
+
+
+
+
+ Security Status
+
+
+
+
+
+
-
- 身份控制中心
-
-
- 配置企业组织架构与原子化权限策略
-
-
-
-
-
-
- 当前操作者
- {currentUser?.name || 'Administrator'}
-
-
-
-
-
-
- {/* 核心交互区 */}
-
- {/* 精密控制条 */}
-
-
-
- setSearchQuery(e.target.value)}
- className="h-14 w-full rounded-[1.25rem] border-none bg-white/80 pl-14 pr-6 text-apple-sm font-apple-medium text-apple-label-primary shadow-apple-xs outline-none transition-all placeholder:text-apple-label-tertiary/60 focus:bg-white focus:ring-4 focus:ring-apple-blue/5"
- />
-
-
-
-
-
-
+
+ }
+ mainContent={
+
+ {/* 精密搜索栏 */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-12 w-full rounded-2xl border-none bg-white/80 pl-14 pr-6 text-apple-sm font-apple-medium text-apple-label-primary outline-none focus:bg-white focus:ring-4 focus:ring-apple-blue/5"
+ />
+
+
+ setStatusFilter(e.target.value)}
+ options={STATUS_OPTIONS}
+ className="h-12 min-w-[140px] rounded-2xl border-none bg-white/80 shadow-apple-xs"
+ />
+
+
-
setRoleFilter(e.target.value)}
- options={ROLE_OPTIONS}
- className="h-14 min-w-[180px] rounded-[1.25rem] border-none bg-white/80 pl-11 pr-4 text-apple-xs font-apple-bold shadow-apple-xs transition-all hover:bg-white focus:ring-4 focus:ring-apple-blue/5"
- />
-
-
-
-
+
+ {actionError &&
}
+
+ {/* 资源树容器 */}
+
-
setStatusFilter(e.target.value)}
- options={STATUS_OPTIONS}
- className="h-14 min-w-[180px] rounded-[1.25rem] border-none bg-white/80 pl-11 pr-4 text-apple-xs font-apple-bold shadow-apple-xs transition-all hover:bg-white focus:ring-4 focus:ring-apple-blue/5"
- />
-
-
-
+
+ }
+ rightContent={
+ selectedUser ? (
+
+ {/* User Header Profile */}
+
+
+
+
+
+
+
{selectedUser.name}
+
{selectedUser.role}
+
+
- {actionError && (
-
- )}
+ {/* Details Inspector */}
+
+
+
Identity Inspector
+
+
+
+
- {/* 资源树容器 */}
-
-
+
+
+
+
+
- {/* 底部装饰 */}
-
+
+
+
+
- {showCreateModal && (
-
- )}
+
+
+
+
+ 权限状态:已授权
+
+
+ 该账户已绑定硬件指纹令牌,最近一次成功登录发生在 2 小时前。
+
+
+
+
+
- {editingUser && (
-
- )}
+
+
+
+
+ ) : (
+
+
+
+
+
Identity Inspector
+
等待选择用户
+
+ 从左侧列表中选择一名员工,即可在此处查看详细的权限矩阵与安全审计报告。
+
+
+ )
+ }
+ />
- {handoverUser && (
-
- )}
+ {showCreateModal &&
}
+ {handoverUser &&
}
);
}
+
+function SecurityMetric({ label, value, color }: { label: string, value: string, color?: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+function InspectorGroup({ title, children }: { title: string, children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function InspectorItem({ icon: Icon, label, value }: { icon: any, label: string, value: string }) {
+ return (
+
+
+
+
+
+ {label}
+ {value}
+
+
+ )
+}
diff --git a/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx b/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx
index 29981078..8a0f27ba 100644
--- a/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx
+++ b/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx
@@ -339,8 +339,17 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
const [selectedLocationIds, setSelectedLocationIds] = useState([])
const [bindingSubmitting, setBindingSubmitting] = useState(false)
const [bindingSearch, setBindingSearch] = useState('')
+ const canWriteWarehouse = user.role !== 'USER'
+
+ function notifyReadOnlyWarehouse(message = '普通用户只有查看和下载权限,不能修改仓库数据。') {
+ setFeedback({ tone: 'error', text: message })
+ }
async function handleCreateLocation() {
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setSubmitting(true)
setFeedback(null)
try {
@@ -363,6 +372,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleUpdateLocation() {
if (!editingLocationId) return
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setSubmitting(true)
setFeedback(null)
try {
@@ -384,6 +397,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
}
async function handleToggleLocation(id: string, currentActive: boolean) {
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setFeedback(null)
try {
const updated = await updateWarehouseLocation(id, { isActive: !currentActive })
@@ -401,6 +418,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleDeleteLocation() {
if (!deleteConfirmId) return
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setFeedback(null)
try {
await deleteWarehouseLocation(deleteConfirmId)
@@ -419,6 +440,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
}
function openEditModal(location: { id: string; code: string; name: string; zone: string | null; aisle: string | null; shelf: string | null; bin: string | null; notes: string | null }) {
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setEditingLocationId(location.id)
setLocationForm({
code: location.code,
@@ -434,6 +459,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleBindLocations() {
if (!bindingVariantId || selectedLocationIds.length === 0) return
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setBindingSubmitting(true)
setFeedback(null)
try {
@@ -467,6 +496,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
}
async function handleUnbindLocation(variantId: string, locationId: string) {
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setFeedback(null)
try {
await unbindVariantFromLocation(variantId, locationId)
@@ -504,6 +537,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleBindBrandMaterialLocation() {
if (!brandBindingTarget) return
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setBrandBindingSubmitting(true)
setFeedback(null)
try {
@@ -533,6 +570,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
}
async function handleUploadBrandFile(brand: string, materialType: string, file: File) {
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setBrandFileUploading(true)
setFeedback(null)
try {
@@ -556,6 +597,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleBindPreSkuLocations() {
if (!bindingPreSkuId || selectedPreSkuLocationIds.length === 0) return
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setPreSkuBindingSubmitting(true)
setFeedback(null)
try {
@@ -591,6 +636,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
}
async function handleUnbindPreSkuLocation(preSkuId: string, locationId: string) {
+ if (!canWriteWarehouse) {
+ notifyReadOnlyWarehouse()
+ return
+ }
setFeedback(null)
try {
await unbindPreSkuFromLocation(preSkuId, locationId)
@@ -673,17 +722,19 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
{/* 标题 + 新增 */}
库位列表
-
+ {canWriteWarehouse ? (
+
+ ) : null}
{/* 搜索 + 排序 */}
@@ -717,7 +768,9 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
{filteredLocations.length === 0 ? (
{overview.locations.length === 0
- ? '还没有库位。点击「新增库位」创建一个正式库位,后续入库即可挂接。'
+ ? canWriteWarehouse
+ ? '还没有库位。点击「新增库位」创建一个正式库位,后续入库即可挂接。'
+ : '还没有库位。'
: '没有匹配的库位,试试调整搜索条件。'}
) : (
@@ -737,30 +790,34 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
{location.isActive ? '可用' : '停用'}
-
-
-
-
+ {canWriteWarehouse ? (
+ <>
+
+
+
+
+ >
+ ) : null}
))}
@@ -867,12 +924,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
) : (
待绑定
)}
-
+ {canWriteWarehouse ? (
+
+ ) : null}
{variant.boundLocations.length > 0 && (
@@ -883,13 +942,15 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
className="inline-flex items-center gap-1 rounded-apple-md bg-apple-bg-tertiary px-1.5 py-0.5 text-xs text-apple-label-secondary"
>
{loc.code}
-
+ {canWriteWarehouse ? (
+
+ ) : null}
))}
@@ -981,12 +1042,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
) : (
待绑定
)}
-
+ {canWriteWarehouse ? (
+
+ ) : null}
{ps.boundLocations.length > 0 && (
@@ -997,13 +1060,15 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
className="inline-flex items-center gap-1 rounded-apple-md bg-apple-bg-tertiary px-1.5 py-0.5 text-xs text-apple-label-secondary"
>
{loc.code}
-
+ {canWriteWarehouse ? (
+
+ ) : null}
))}
@@ -1069,13 +1134,15 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
)}
-
- {opt.value === 'CARTON_PRINT' && (
+ {canWriteWarehouse ? (
+
+ ) : null}
+ {canWriteWarehouse && opt.value === 'CARTON_PRINT' && (
{/* 新增/编辑库位弹窗 */}
-
{ setLocationModalOpen(false); setEditingLocationId(null) }}
- title={editingLocationId ? '编辑库位' : '新增库位'}
- size="medium"
- footer={
-
-
-
-
- }
- >
+ {canWriteWarehouse ? (
+ { setLocationModalOpen(false); setEditingLocationId(null) }}
+ title={editingLocationId ? '编辑库位' : '新增库位'}
+ size="medium"
+ footer={
+
+
+
+
+ }
+ >
setLocationForm((current) => ({ ...current, notes: event.target.value }))}
/>
-
+
+ ) : null}
{/* 删除确认弹窗 */}
+ {canWriteWarehouse ? (
setDeleteConfirmId(null)}
title="确认删除"
size="small"
@@ -1258,10 +1328,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
>
确定要删除这个库位吗?此操作不可恢复。
+ ) : null}
{/* 绑定库位弹窗 */}
+ {canWriteWarehouse ? (
{ setBindingModalOpen(false); setBindingVariantId(null); setSelectedLocationIds([]); setBindingSearch('') }}
title="绑定库位"
size="medium"
@@ -1337,10 +1409,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
})}
+ ) : null}
{/* 品牌物料绑定库位弹窗 */}
+ {canWriteWarehouse ? (
{ setBrandBindingModalOpen(false); setBrandBindingTarget(null); setBrandBindingLocationId(null) }}
title="绑定库位"
size="medium"
@@ -1428,10 +1502,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
})}
+ ) : null}
{/* 成品 SKU 绑定库位弹窗 */}
+ {canWriteWarehouse ? (
{ setPreSkuBindingModalOpen(false); setBindingPreSkuId(null); setSelectedPreSkuLocationIds([]) }}
title="绑定库位"
size="medium"
@@ -1507,6 +1583,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
})}
+ ) : null}
>
)
}
diff --git a/furniture-3d-system/src/components/auth/PermissionGuard.tsx b/furniture-3d-system/src/components/auth/PermissionGuard.tsx
index d245ebe4..7e4ee8d5 100644
--- a/furniture-3d-system/src/components/auth/PermissionGuard.tsx
+++ b/furniture-3d-system/src/components/auth/PermissionGuard.tsx
@@ -6,15 +6,15 @@
'use client';
import { ReactNode } from 'react';
-import { Role } from '@prisma/client';
import { usePermissions } from '@/hooks/useAuth';
+import type { AllowedRole } from '@/lib/role-scope';
/**
* 权限守卫属性
*/
interface PermissionGuardProps {
children: ReactNode;
- roles?: Role[];
+ roles?: AllowedRole[];
fallback?: ReactNode;
requireAll?: boolean;
}
@@ -80,7 +80,7 @@ export function DepartmentManagerGuard({
}) {
return (
{children}
@@ -100,7 +100,7 @@ export function TeamLeaderGuard({
}) {
return (
{children}
@@ -120,7 +120,7 @@ export function CreatorGuard({
}) {
return (
{children}
diff --git a/furniture-3d-system/src/components/auth/RoleBadge.tsx b/furniture-3d-system/src/components/auth/RoleBadge.tsx
index 12fb9404..b7abec16 100644
--- a/furniture-3d-system/src/components/auth/RoleBadge.tsx
+++ b/furniture-3d-system/src/components/auth/RoleBadge.tsx
@@ -39,26 +39,20 @@ const ROLE_CONFIG: Record<
bgColor: 'bg-apple-orange/10',
borderColor: 'border-apple-orange/20',
},
- DEPARTMENT_MANAGER: {
- label: '部门管理员',
+ SUPERVISOR: {
+ label: '主管',
color: 'text-apple-purple dark:text-apple-purple',
bgColor: 'bg-apple-purple/10',
borderColor: 'border-apple-purple/20',
},
- TEAM_LEADER: {
- label: '组长',
- color: 'text-apple-blue dark:text-apple-blue',
- bgColor: 'bg-apple-blue/10',
- borderColor: 'border-apple-blue/20',
- },
DESIGNER: {
label: '设计师',
color: 'text-apple-green dark:text-apple-green',
bgColor: 'bg-apple-green/10',
borderColor: 'border-apple-green/20',
},
- VIEWER: {
- label: '查看者',
+ USER: {
+ label: '普通用户',
color: 'text-apple-label-secondary dark:text-apple-label-tertiary',
bgColor: 'bg-apple-bg-secondary/10',
borderColor: 'border-apple-separator/20',
diff --git a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx
index 8b5a4f14..febd5602 100644
--- a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx
+++ b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx
@@ -26,10 +26,9 @@ interface AuthMeResponse {
const roleLabelMap: Record = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
- DEPARTMENT_MANAGER: '负责人',
- TEAM_LEADER: '组长',
+ SUPERVISOR: '主管',
DESIGNER: '设计师',
- VIEWER: '查看者',
+ USER: '普通用户',
}
export function SidebarUserCenter() {
@@ -39,7 +38,10 @@ export function SidebarUserCenter() {
const [isOpen, setIsOpen] = useState(false)
const [isLoggingOut, setIsLoggingOut] = useState(false)
- const isAdmin = currentUser?.role === 'SUPER_ADMIN' || currentUser?.role === 'ADMIN'
+ const isAdmin =
+ currentUser?.role === 'SUPER_ADMIN' ||
+ currentUser?.role === 'ADMIN'
+ const canManageUsers = isAdmin || currentUser?.role === 'SUPERVISOR'
useEffect(() => {
let active = true
@@ -172,7 +174,10 @@ export function SidebarUserCenter() {
管理总控制台
-
+ )}
+ {canManageUsers && (
+ setIsOpen(false)}
className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group"
@@ -180,7 +185,6 @@ export function SidebarUserCenter() {
用户权限管理
- >
)}