feat: consolidate workspace release updates
This commit is contained in:
parent
87bb0e126d
commit
91584f21c6
@ -241,14 +241,40 @@
|
||||
- **背景文字装饰**:绝对定位的 `text-[80px]` 背景大字(如模块 path),设置 `opacity-[0.03]` 极低透明度,增加“空间感”。
|
||||
|
||||
**4. 状态指标看板 (System Health Dashboard)**
|
||||
- **应用场景**:`/admin` 首页。
|
||||
- **组成**:Icon 容器 + 上下两行文本(上行为微缩标签,下行为状态值)。
|
||||
- **配色**:动态使用 `emerald-500` (正常), `blue-500` (活动), `amber-500` (注意)。
|
||||
- 应用场景:`/admin` 首页。
|
||||
- 组成:Icon 容器 + 上下两行文本(上行为微缩标签,下行为状态值)。
|
||||
- 配色:动态使用 `emerald-500` (正常), `blue-500` (活动), `amber-500` (注意)。
|
||||
|
||||
**5. 三栏工作区 (Three-Column Workspace)**
|
||||
- 应用场景:`/asset-center/material-library`。
|
||||
- 定义:左侧极简分类 (Navigation) + 中间响应式网格 (Grid) + 右侧实时定义面板 (Inspector)。
|
||||
- 优势:极大降低页面跳转成本,实现“即时反馈”。
|
||||
|
||||
**6. 精密滑块交互 (Precision Sliders)**
|
||||
- 视觉表现:细条轨道 + 圆形 Thumb + 右侧数值百分比徽章。
|
||||
- 应用场景:物理参数调节 (Metallic/Roughness/Opacity)。
|
||||
- 规范:取代原有的输入框,滑动步长固定为 0.01 (1%)。
|
||||
|
||||
**7. 悬浮坞系统 (Floating Dock System)**
|
||||
- 应用场景:`/assisted-assembly`。
|
||||
- 定义:左侧布局的交互式入口,采用 `bg-black/60` + `backdrop-blur-3xl`。
|
||||
- 动效:点击后向右水平展开列表。
|
||||
- 逻辑:通过视觉发光 (Glow) 和计数徽标反馈当前分类的选择状态。
|
||||
|
||||
**8. 环境 HUD (Ambient HUD)**
|
||||
- 定义:底部的非侵入式信息条。
|
||||
- 样式:极圆胶囊型,`backdrop-blur-xl`。
|
||||
- 作用:提供当前操作环境的实时状态(如“捕捉已就绪”)。
|
||||
|
||||
### 11. 改动文件清单
|
||||
|
||||
| 文件 | 改动类型 | 备注 |
|
||||
|------|----------|------|
|
||||
| `src/app/admin/page.tsx` | 重构 | 系统总控制台首页视觉重写 |
|
||||
| `src/app/users/page.tsx` | 重构 | 身份控制中心 (用户管理) 视觉重写 |
|
||||
| `src/app/admin/page.tsx` | 重构 | 系统总控制台首页视觉重载 |
|
||||
| `src/app/users/page.tsx` | 重构 | 身份控制中心 (用户管理) 三栏重写 |
|
||||
| `src/app/asset-center/material-library/MaterialLibraryClient.tsx` | 重构 | 材质库首页视觉重写 (草稿图 12.png) |
|
||||
| `src/app/assisted-assembly/AssistedAssemblyClient.tsx` | 重构 | 拼装工作台沉浸式重写 & 引入 Floating Dock |
|
||||
| `src/components/platform/PlatformSplitLayout.tsx` | 基础重构 | 统一加装 backHref 导航标准 |
|
||||
|
||||
|
||||
|
||||
|
||||
@ -6,16 +6,42 @@
|
||||
- **重构页面**:
|
||||
- `系统总控制台 (/admin)`:引入“中枢枢纽”布局,增加系统状态看板 (Mock),优化卡片视觉张力。
|
||||
- `身份控制中心 (/users)`:引入“沉浸式页头”与“精密控制条”,整合搜索与筛选器。
|
||||
- **核心特性**:
|
||||
- **Frosted Glass (磨砂玻璃)**:全局背景与卡片采用 70% 透明度 + 30px 模糊。
|
||||
- **Visual Hierarchy (视觉层级)**:通过大面积阴影与精致边框区分功能模块。
|
||||
- **Typography (排版)**:强化了 Manrope 标题与 Inter 技术标签的对比。
|
||||
- **负责人**:Gemini UI Engineer
|
||||
- **完成日期**:2026-04-16
|
||||
|
||||
- **基础资料库重构 (Material Library Pro Refactor)**
|
||||
- **参考稿**:基于用户草稿 (12.png) 与 Precision Atelier 规范。
|
||||
- **核心改动**:
|
||||
- **三栏架构**:左侧分类 - 中间网格 - 右侧实时工作区。
|
||||
- **实时预览 (Real-time Preview)**:面板顶部集成大面积材质物理表现预览。
|
||||
- **精密滑块 (Precision Sliders)**:金属度、粗糙度、透明度全部由 Input 切换为 Slider 交互。
|
||||
- **网格升级**:优化了花色卡片的视觉尺寸与 Metadata(元数据)展示。
|
||||
- **负责人**:Gemini UI Engineer
|
||||
- **完成日期**:2026-04-16
|
||||
|
||||
- **辅助装配工作台沉浸式重构 (Assisted Assembly Workbench Immersive Refactor)**
|
||||
- **重构页面**:`辅助装配工作台 (/assisted-assembly)`。
|
||||
- **核心改动**:
|
||||
- **沉浸式 3D 画布**:移除所有边框,采用 `#0c0e10` 深邃背景,占据 100% 中心区域。
|
||||
- **悬浮 Dock 系统 (Floating Dock)**:左侧零件选择改为三个磨砂玻璃质感的悬浮坞(板件/支撑/连接件),支持平滑展开与计数反馈。
|
||||
- **实时装配报告 (Inspector)**:右侧面板重塑为技术报告风格,展示拓扑节点、已连接对数及结构稳固性指标。
|
||||
- **环境提示条 (Ambient HUD)**:底部增加半透明环境信息条,提示“孔位捕捉已就绪”。
|
||||
- **导航标准化**:加装“资产中心 / 拼装”精密返回按钮。
|
||||
- **负责人**:Gemini UI Engineer
|
||||
- **完成日期**:2026-04-17
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 进行中任务 (In Progress)
|
||||
|
||||
- assisted-assembly(辅助装配):真实样本联调与验收
|
||||
- 负责人:Codex
|
||||
- 当前进展:
|
||||
- 已补回孔位吸附的真实距离闸门,避免组件离目标过远时仍被误判为可吸附。
|
||||
- 已移除缺少孔位 / 安装图样语义时回退到旧中心点假吸附的旧逻辑。
|
||||
- 已将右侧提示收口为状态反馈卡,可区分“已进入吸附范围 / 缺少安装图样语义 / 缺少孔位语义”等状态。
|
||||
- 已补齐辅助装配工作台与装配规则层回归测试,并通过 `jest(测试)` 与 `type-check(类型检查)`。
|
||||
- 待完成:
|
||||
- 用真实样本在浏览器里完成最终点验,确认拖拽、预吸附、松手吸附与右侧提示在真实模型下都一致。
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
CREATE TYPE "Role_new" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER');
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "role" DROP DEFAULT;
|
||||
|
||||
ALTER TABLE "User"
|
||||
ALTER COLUMN "role" TYPE "Role_new"
|
||||
USING (
|
||||
CASE
|
||||
WHEN "role"::text = 'VIEWER' THEN 'USER'
|
||||
WHEN "role"::text IN ('DEPARTMENT_MANAGER', 'TEAM_LEADER') THEN 'SUPERVISOR'
|
||||
ELSE "role"::text
|
||||
END
|
||||
)::"Role_new";
|
||||
|
||||
DROP TYPE "Role";
|
||||
ALTER TYPE "Role_new" RENAME TO "Role";
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'USER';
|
||||
ALTER TABLE "User" ALTER COLUMN "status" SET DEFAULT 'PENDING';
|
||||
@ -920,10 +920,9 @@ model PackagedItem {
|
||||
enum Role {
|
||||
SUPER_ADMIN // 超级管理员
|
||||
ADMIN // 管理员
|
||||
DEPARTMENT_MANAGER // 部门管理员
|
||||
TEAM_LEADER // 组长
|
||||
SUPERVISOR // 主管(按部门/小组管理锚点区分范围)
|
||||
DESIGNER // 设计师
|
||||
VIEWER // 查看者
|
||||
USER // 普通用户
|
||||
}
|
||||
|
||||
// 用户状态枚举
|
||||
@ -998,7 +997,7 @@ model User {
|
||||
phone String?
|
||||
|
||||
// 角色与权限
|
||||
role Role @default(DESIGNER)
|
||||
role Role @default(USER)
|
||||
|
||||
// 组织架构
|
||||
departmentId String?
|
||||
@ -1012,7 +1011,7 @@ model User {
|
||||
managedTeam Team? @relation("TeamLeader")
|
||||
|
||||
// 状态
|
||||
status UserStatus @default(ACTIVE)
|
||||
status UserStatus @default(PENDING)
|
||||
|
||||
// 关联
|
||||
createdProducts Product[] @relation("ProductCreator")
|
||||
|
||||
@ -365,7 +365,7 @@ async function main() {
|
||||
email: 'design.manager@furniture3d.com',
|
||||
name: '张设计',
|
||||
password: hashedPassword,
|
||||
role: 'DEPARTMENT_MANAGER',
|
||||
role: 'SUPERVISOR',
|
||||
departmentId: designDept.id,
|
||||
isFirstLogin: false,
|
||||
status: 'ACTIVE',
|
||||
@ -383,7 +383,7 @@ async function main() {
|
||||
email: 'modeling.leader@furniture3d.com',
|
||||
name: '李组长',
|
||||
password: hashedPassword,
|
||||
role: 'TEAM_LEADER',
|
||||
role: 'SUPERVISOR',
|
||||
departmentId: design3DDept.id,
|
||||
teamId: modelingTeam.id,
|
||||
isFirstLogin: false,
|
||||
|
||||
@ -5,6 +5,7 @@ const { spawn } = require('child_process')
|
||||
const repoRoot = process.cwd()
|
||||
const nextBin = path.join(repoRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
|
||||
const tscBin = path.join(repoRoot, 'node_modules', 'typescript', 'bin', 'tsc')
|
||||
const tsconfigPath = path.join(repoRoot, 'tsconfig.json')
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -25,30 +26,6 @@ function run(command, args) {
|
||||
})
|
||||
}
|
||||
|
||||
async function collectTypeFiles(dirPath) {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return collectTypeFiles(fullPath)
|
||||
}
|
||||
|
||||
if (entry.name.endsWith('.ts') || entry.name.endsWith('.d.ts')) {
|
||||
return [fullPath]
|
||||
}
|
||||
|
||||
return []
|
||||
}),
|
||||
)
|
||||
|
||||
return files.flat()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNextCacheLifeStub() {
|
||||
const candidatePaths = [
|
||||
path.join(repoRoot, '.next', 'types', 'cache-life.d.ts'),
|
||||
@ -66,36 +43,10 @@ async function ensureNextCacheLifeStub() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createTypeCheckTsconfig() {
|
||||
const tsconfigPath = path.join(repoRoot, 'tsconfig.json')
|
||||
const rawConfig = await fs.readFile(tsconfigPath, 'utf8')
|
||||
const baseConfig = JSON.parse(rawConfig)
|
||||
const generatedTypeFiles = [
|
||||
...(await collectTypeFiles(path.join(repoRoot, '.next', 'types'))),
|
||||
...(await collectTypeFiles(path.join(repoRoot, '.next', 'dev', 'types'))),
|
||||
]
|
||||
.map((filePath) => path.relative(repoRoot, filePath).replace(/\\/g, '/'))
|
||||
.sort()
|
||||
|
||||
const tempConfigPath = path.join(repoRoot, 'tsconfig.typecheck.generated.json')
|
||||
const tempConfig = {
|
||||
...baseConfig,
|
||||
include: (baseConfig.include || []).filter(
|
||||
(entry) => typeof entry === 'string' && !entry.startsWith('.next/'),
|
||||
),
|
||||
exclude: Array.from(new Set([...(baseConfig.exclude || []), '.next'])),
|
||||
files: generatedTypeFiles.length > 0 ? generatedTypeFiles : undefined,
|
||||
}
|
||||
|
||||
await fs.writeFile(tempConfigPath, JSON.stringify(tempConfig, null, 2), 'utf8')
|
||||
return tempConfigPath
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await run(process.execPath, [nextBin, 'typegen'])
|
||||
await ensureNextCacheLifeStub()
|
||||
const tempConfigPath = await createTypeCheckTsconfig()
|
||||
await run(process.execPath, [tscBin, '-p', tempConfigPath, '--noEmit', '--incremental', 'false'])
|
||||
await run(process.execPath, [tscBin, '-p', tsconfigPath, '--noEmit', '--incremental', 'false'])
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@ -2,11 +2,13 @@ import Link from 'next/link'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import {
|
||||
Users, Palette, SwatchBook, Grid, ArrowRight,
|
||||
ShieldCheck, Activity, Database, Zap, type LucideIcon
|
||||
Users, Palette, SwatchBook, Grid, ArrowLeft, ArrowRight,
|
||||
ShieldCheck, Activity, Database, Zap, Clock,
|
||||
Cpu, Terminal, ShieldAlert, BarChart3
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { verifyToken } from '@/lib/jwt'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
const adminModules = [
|
||||
{
|
||||
@ -17,7 +19,7 @@ const adminModules = [
|
||||
icon: Users,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
accentColor: 'group-hover:bg-blue-600',
|
||||
stats: '1,248 Users Active',
|
||||
},
|
||||
{
|
||||
title: '主题视觉管理',
|
||||
@ -27,7 +29,7 @@ const adminModules = [
|
||||
icon: Palette,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
accentColor: 'group-hover:bg-indigo-600',
|
||||
stats: '12 Active Themes',
|
||||
},
|
||||
{
|
||||
title: '材质物理库',
|
||||
@ -37,7 +39,7 @@ const adminModules = [
|
||||
icon: SwatchBook,
|
||||
color: 'text-emerald-600',
|
||||
bgColor: 'bg-emerald-50',
|
||||
accentColor: 'group-hover:bg-emerald-600',
|
||||
stats: '450+ Materials',
|
||||
},
|
||||
{
|
||||
title: '分类体系定义',
|
||||
@ -47,130 +49,167 @@ const adminModules = [
|
||||
icon: Grid,
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
accentColor: 'group-hover:bg-amber-600',
|
||||
stats: '32 Categories',
|
||||
},
|
||||
]
|
||||
|
||||
export const metadata = {
|
||||
title: '管理总控制台 | Precision Atelier',
|
||||
description: '后台管理总入口',
|
||||
title: '系统总控制台 | Precision Atelier',
|
||||
}
|
||||
|
||||
export default async function AdminPortalPage() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('auth-token')?.value
|
||||
|
||||
if (!token) {
|
||||
redirect('/login?redirect=/admin')
|
||||
}
|
||||
|
||||
if (!token) redirect('/login?redirect=/admin')
|
||||
const payload = verifyToken(token)
|
||||
if (!payload) {
|
||||
redirect('/login?redirect=/admin')
|
||||
}
|
||||
if (!payload) redirect('/login?redirect=/admin')
|
||||
|
||||
const allowedRoles = ['SUPER_ADMIN', 'ADMIN']
|
||||
if (!allowedRoles.includes(payload.role)) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
if (!allowedRoles.includes(payload.role)) redirect('/?error=unauthorized')
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-x-hidden bg-[#f4f7f9] font-apple-system text-apple-label-primary">
|
||||
{/* 增强型背景装饰 */}
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#f4f7f9] font-apple-system text-apple-label-primary">
|
||||
{/* 沉浸式背景背景 */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<div className="absolute left-[-10%] top-[-10%] h-[40%] w-[40%] rounded-full bg-blue-400/10 blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] h-[40%] w-[40%] rounded-full bg-indigo-400/10 blur-[120px]" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.015] mix-blend-overlay"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-[-10%] top-[-10%] h-[50%] w-[50%] rounded-full bg-blue-500/5 blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] h-[50%] w-[50%] rounded-full bg-indigo-500/5 blur-[120px]" />
|
||||
<div className="absolute inset-0 opacity-[0.02] mix-blend-overlay" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 mx-auto max-w-6xl px-8 py-12 lg:py-24">
|
||||
{/* 顶部状态栏 Concept */}
|
||||
<div className="mb-12 flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-700">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white shadow-apple-sm ring-1 ring-apple-separator/5">
|
||||
<ShieldCheck className="h-6 w-6 text-apple-blue" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary">
|
||||
System Infrastructure
|
||||
</span>
|
||||
<h1 className="text-apple-2xl font-apple-black tracking-tight uppercase">
|
||||
管理<span className="text-apple-blue">控制中心</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<StatusIndicator icon={Activity} label="系统状态" value="运行正常" color="text-emerald-500" />
|
||||
<StatusIndicator icon={Database} label="数据库" value="已连接" color="text-blue-500" />
|
||||
<StatusIndicator icon={Zap} label="API 延迟" value="12ms" color="text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心功能网格 */}
|
||||
<div className="grid grid-cols-1 gap-8 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-150 fill-mode-both md:grid-cols-2">
|
||||
{adminModules.map((module) => (
|
||||
<Link
|
||||
key={module.href}
|
||||
href={module.href}
|
||||
className="group relative flex flex-col overflow-hidden rounded-[2rem] border border-white/60 bg-white/70 p-10 shadow-apple-sm backdrop-blur-xl transition-all duration-500 hover:-translate-y-2 hover:shadow-apple-2xl active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={cn(
|
||||
"mb-8 flex h-16 w-16 items-center justify-center rounded-2xl shadow-apple-xs transition-all duration-500 group-hover:scale-110 group-hover:shadow-apple-md",
|
||||
module.bgColor,
|
||||
module.color
|
||||
)}>
|
||||
<module.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-apple-separator/5 transition-all duration-500 group-hover:bg-apple-blue group-hover:text-white">
|
||||
<ArrowRight className="h-5 w-5 transition-transform duration-500 group-hover:translate-x-0.5" />
|
||||
<main className="relative z-10 mx-auto max-w-[1400px] px-10 py-16">
|
||||
{/* Header Section */}
|
||||
<header className="mb-16 flex items-end justify-between animate-in fade-in slide-in-from-top-4 duration-700">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/">
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
className="mt-1 h-10 min-w-0 rounded-xl border border-apple-separator/10 bg-white/50 px-3 shadow-apple-xs transition-all hover:bg-white hover:shadow-apple-sm active:scale-95"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-apple-label-tertiary" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="relative flex h-24 w-24 items-center justify-center rounded-[2.5rem] bg-black shadow-apple-2xl ring-1 ring-white/20">
|
||||
<ShieldCheck className="h-12 w-12 text-white" />
|
||||
<div className="absolute -bottom-1 -right-1 h-8 w-8 rounded-full bg-emerald-500 ring-4 ring-white flex items-center justify-center">
|
||||
<Zap size={14} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span className={cn(
|
||||
"mb-2 block text-[10px] font-apple-black uppercase tracking-[0.2em] transition-colors",
|
||||
module.color
|
||||
)}>
|
||||
{module.subtitle}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="text-[11px] font-apple-black uppercase tracking-[0.5em] text-apple-label-tertiary">
|
||||
Precision Master Console
|
||||
</span>
|
||||
<h2 className="mb-4 text-apple-xl font-apple-black tracking-tight text-apple-label-primary">
|
||||
{module.title}
|
||||
</h2>
|
||||
<p className="max-w-[85%] text-apple-sm font-apple-medium leading-relaxed text-apple-label-tertiary transition-colors group-hover:text-apple-label-secondary">
|
||||
{module.description}
|
||||
</p>
|
||||
<div className="h-[1px] w-12 bg-apple-separator/20" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-apple-black tracking-tighter uppercase">
|
||||
系统<span className="text-apple-blue">总控制台</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm font-apple-medium text-apple-label-tertiary">
|
||||
集成式基础设施管理中心 · 实时监控系统运行状态与核心资产分发
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="hidden md:flex gap-4">
|
||||
<QuickMetric icon={Cpu} label="CPU 负载" value="12.4%" />
|
||||
<QuickMetric icon={Database} label="存储余量" value="840 GB" />
|
||||
<QuickMetric icon={Activity} label="节点状态" value="ACTIVE" color="text-emerald-500" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-12">
|
||||
{/* Left: Core Modules (8 Columns) */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{adminModules.map((module, idx) => (
|
||||
<Link
|
||||
key={module.href}
|
||||
href={module.href}
|
||||
className="group relative flex flex-col overflow-hidden rounded-[2.5rem] border border-white/80 bg-white/40 p-10 shadow-apple-sm backdrop-blur-xl transition-all duration-500 hover:-translate-y-2 hover:bg-white/80 hover:shadow-apple-2xl active:scale-[0.98]"
|
||||
>
|
||||
<div className="mb-10 flex items-start justify-between">
|
||||
<div className={cn("flex h-16 w-16 items-center justify-center rounded-2xl shadow-apple-xs transition-all duration-500 group-hover:scale-110", module.bgColor, module.color)}>
|
||||
<module.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-apple-separator/5 transition-all duration-500 group-hover:bg-apple-blue group-hover:text-white">
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span className={cn("mb-2 block text-[10px] font-apple-black uppercase tracking-[0.3em]", module.color)}>
|
||||
{module.subtitle}
|
||||
</span>
|
||||
<h2 className="mb-4 text-apple-xl font-apple-black tracking-tight text-apple-label-primary">
|
||||
{module.title}
|
||||
</h2>
|
||||
<p className="text-apple-xs font-apple-medium leading-relaxed text-apple-label-tertiary">
|
||||
{module.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer Stats inside card */}
|
||||
<div className="mt-8 flex items-center justify-between border-t border-apple-separator/5 pt-6 opacity-40 transition-opacity group-hover:opacity-100">
|
||||
<span className="text-[9px] font-apple-black uppercase tracking-widest">{module.stats}</span>
|
||||
<span className="text-[10px] font-apple-bold text-apple-blue">ENTER CENTER</span>
|
||||
</div>
|
||||
|
||||
<span className="pointer-events-none absolute -bottom-6 -right-4 select-none text-[100px] font-apple-black uppercase tracking-tighter opacity-[0.02] transition-all duration-700 group-hover:opacity-[0.05]">
|
||||
{module.href.split('/').pop()}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: System Health & Logs (4 Columns) */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<div className="rounded-[2.5rem] border border-white/80 bg-black shadow-apple-2xl p-8 text-white">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h3 className="text-xs font-apple-black uppercase tracking-[0.3em] opacity-60">System Health</h3>
|
||||
<BarChart3 size={16} className="text-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<HealthBar label="Database Cluster" progress={92} />
|
||||
<HealthBar label="S3 Storage Proxy" progress={65} />
|
||||
<HealthBar label="Auth Engine" progress={100} />
|
||||
<HealthBar label="Render Farm" progress={42} color="bg-amber-500" />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 rounded-2xl bg-white/5 p-4 ring-1 ring-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-apple-black uppercase">安全提示</span>
|
||||
<span className="text-[11px] text-white/40">发现 2 个未挂载的材质分区</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 背景装饰性文字 */}
|
||||
<span className="pointer-events-none absolute -bottom-4 -right-2 select-none text-[80px] font-apple-black uppercase tracking-tighter opacity-[0.03] transition-all duration-700 group-hover:opacity-[0.06] group-hover:scale-110">
|
||||
{module.href.split('/').pop()}
|
||||
</span>
|
||||
|
||||
{/* 装饰线 */}
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 h-1.5 w-0 transition-all duration-700 ease-out group-hover:w-full",
|
||||
module.accentColor.replace('group-hover:', '')
|
||||
)} />
|
||||
</Link>
|
||||
))}
|
||||
<div className="rounded-[2.5rem] border border-white bg-white/50 shadow-apple-sm p-8 backdrop-blur-md">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h3 className="text-xs font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">Live Logs</h3>
|
||||
<Terminal size={14} className="text-apple-label-tertiary opacity-40" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<LogItem time="12:44" user="Gemini" action="重写了基础资料库 UI" />
|
||||
<LogItem time="11:30" user="System" action="同步了 24 个新花色贴图" />
|
||||
<LogItem time="09:15" user="Codex" action="更新了辅助装配逻辑" />
|
||||
<LogItem time="08:22" user="Admin" action="锁定了 2 个离职账号" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部脚注 */}
|
||||
<footer className="mt-24 flex flex-col items-center border-t border-apple-separator/10 pt-12 animate-in fade-in duration-1000 delay-500 fill-mode-both">
|
||||
<div className="mb-6 flex items-center gap-8">
|
||||
<FooterLink label="系统文档" />
|
||||
<FooterLink label="权限策略" />
|
||||
<FooterLink label="更新日志" />
|
||||
</div>
|
||||
<p className="text-[10px] font-apple-black uppercase tracking-[0.4em] text-apple-label-tertiary opacity-30">
|
||||
<footer className="mt-24 border-t border-apple-separator/10 pt-12 text-center">
|
||||
<p className="text-[10px] font-apple-black uppercase tracking-[0.5em] text-apple-label-tertiary opacity-30">
|
||||
Precision Atelier · Built for Next Generation Design
|
||||
</p>
|
||||
</footer>
|
||||
@ -179,38 +218,42 @@ export default async function AdminPortalPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIndicator({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
color: string
|
||||
}) {
|
||||
function QuickMetric({ icon: Icon, label, value, color }: { icon: any, label: string, value: string, color?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white shadow-apple-xs ring-1 ring-apple-separator/5">
|
||||
<Icon className="h-4 w-4 text-apple-label-tertiary" />
|
||||
<div className="flex items-center gap-4 rounded-3xl bg-white/60 p-5 shadow-apple-xs ring-1 ring-apple-separator/5 backdrop-blur-md">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-apple-xs">
|
||||
<Icon className="h-5 w-5 text-apple-label-tertiary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-apple-bold uppercase tracking-widest text-apple-label-tertiary opacity-60">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("text-[11px] font-apple-black uppercase", color)}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary opacity-60">{label}</span>
|
||||
<span className={cn("text-sm font-apple-black uppercase tracking-tight", color || "text-apple-label-primary")}>{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterLink({ label }: { label: string }) {
|
||||
function HealthBar({ label, progress, color = "bg-emerald-500" }: { label: string, progress: number, color?: string }) {
|
||||
return (
|
||||
<button className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary hover:text-apple-blue transition-colors">
|
||||
{label}
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] font-apple-bold uppercase tracking-widest">
|
||||
<span className="opacity-60">{label}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
|
||||
<div className={cn("h-full transition-all duration-1000", color)} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogItem({ time, user, action }: { time: string, user: string, action: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-[9px] font-apple-black text-apple-blue opacity-60 pt-0.5">{time}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-apple-black text-apple-label-secondary">{user}</span>
|
||||
<span className="text-[11px] text-apple-label-tertiary leading-tight">{action}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 - 获取材质详情
|
||||
|
||||
@ -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 - 获取所有材质(管理员视图,包含非活跃的)
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -126,7 +126,7 @@ describe('GET/POST /api/categories', () => {
|
||||
requestId: 'req-test',
|
||||
user: {
|
||||
id: 'viewer-1',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ describe('GET /api/products/[id]/packaging', () => {
|
||||
id: 'user-1',
|
||||
email: 'viewer@test.com',
|
||||
name: '查看协作者',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,6 +30,6 @@ export async function GET(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'] }
|
||||
)
|
||||
}
|
||||
|
||||
@ -49,6 +49,6 @@ export async function POST(request: NextRequest) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
|
||||
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
78
furniture-3d-system/src/app/api/warehouse/bindings/route.ts
Normal file
78
furniture-3d-system/src/app/api/warehouse/bindings/route.ts
Normal file
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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'],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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({
|
||||
<>
|
||||
<PlatformSplitLayout
|
||||
title="资产中心"
|
||||
backHref="/"
|
||||
backLabel="管理中心"
|
||||
metrics={metrics}
|
||||
rightPanelClassName="w-[360px] max-w-[32vw]"
|
||||
rightPanelClassName="w-[420px] max-w-[38vw]"
|
||||
|
||||
leftContent={
|
||||
<ModelPreviewPanel
|
||||
modelUrl={selectedVariant?.modelUrl || null}
|
||||
|
||||
@ -823,7 +823,7 @@ describe('AssetCenterClient workflow', () => {
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'TEAM_LEADER',
|
||||
role: 'SUPERVISOR',
|
||||
avatar: null,
|
||||
department: null,
|
||||
}}
|
||||
|
||||
@ -31,7 +31,21 @@ jest.mock('@/components/ui/Toast', () => ({
|
||||
|
||||
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
|
||||
__esModule: true,
|
||||
PlatformSplitLayout: ({ leftContent, rightContent }: { leftContent: ReactNode; rightContent: ReactNode }) => <div><div>{leftContent}</div><div>{rightContent}</div></div>,
|
||||
PlatformSplitLayout: ({
|
||||
leftContent,
|
||||
mainContent,
|
||||
rightContent,
|
||||
}: {
|
||||
leftContent: ReactNode
|
||||
mainContent?: ReactNode
|
||||
rightContent: ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<div>{leftContent}</div>
|
||||
<div>{mainContent}</div>
|
||||
<div>{rightContent}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,8 +17,8 @@ export default async function MaterialLibraryPage() {
|
||||
<MaterialLibraryClient
|
||||
initialSubstrates={substrates}
|
||||
initialFinishes={finishes}
|
||||
initialMaterialCategories={materialCategories}
|
||||
initialSubstrateDictionaries={substrateDictionaries}
|
||||
initialCategories={materialCategories}
|
||||
initialDictionaries={substrateDictionaries}
|
||||
metrics={[
|
||||
{
|
||||
label: '板材基材',
|
||||
|
||||
@ -158,13 +158,14 @@ describe('AssistedAssemblyClient', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, AssistedAssemblyVariant[]>
|
||||
@ -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 (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-medium text-apple-label-secondary">{title}</span>
|
||||
<div className="space-y-1">
|
||||
{families.map((family) => {
|
||||
const isSelected = selectedIds.includes(family.id)
|
||||
const variantCount = availableVariantMap.get(family.id)?.length ?? 0
|
||||
return (
|
||||
<div
|
||||
key={family.id}
|
||||
className={`flex items-center gap-2 rounded-xl px-2 py-2 transition-colors ${
|
||||
isSelected ? 'bg-white/88 shadow-[0_4px_12px_rgba(15,23,42,0.05)]' : 'hover:bg-white/55'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`family-${family.id}`}
|
||||
checked={isSelected}
|
||||
onChange={() => onToggle(family.id)}
|
||||
className="h-3.5 w-3.5 rounded border-apple-separator text-apple-green focus:ring-apple-green"
|
||||
/>
|
||||
<label htmlFor={`family-${family.id}`} className="flex-1 min-w-0 cursor-pointer">
|
||||
<p
|
||||
className={`text-sm truncate ${
|
||||
isSelected ? 'font-medium text-apple-label-primary' : 'text-apple-label-primary'
|
||||
}`}
|
||||
>
|
||||
{family.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-apple-label-tertiary truncate">
|
||||
{variantCount} 个规格款
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="group relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className={cn(
|
||||
'z-20 flex h-14 w-14 items-center justify-center rounded-[1.25rem] shadow-2xl backdrop-blur-2xl transition-all duration-500 ring-1',
|
||||
selectedIds.length > 0
|
||||
? 'bg-apple-blue text-white ring-white/20'
|
||||
: 'bg-black/60 text-white/40 ring-white/10 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-6 w-6 transition-transform duration-500', expanded && 'rotate-90')} />
|
||||
{selectedIds.length > 0 && !expanded ? (
|
||||
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white text-[10px] font-apple-black text-black shadow-lg">
|
||||
{selectedIds.length}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-16 overflow-hidden rounded-[2rem] bg-black/60 shadow-2xl backdrop-blur-3xl ring-1 ring-white/10 transition-all duration-500 ease-out',
|
||||
expanded ? 'w-72 opacity-100 scale-100' : 'w-0 opacity-0 scale-95 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-5">
|
||||
<p className="mb-4 text-[10px] font-apple-black uppercase tracking-[0.2em] text-white/30 px-1">
|
||||
{title}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{families.map((family) => {
|
||||
const isSelected = selectedIds.includes(family.id)
|
||||
const variantCount = availableVariantMap.get(family.id)?.length ?? 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={family.id}
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onToggle(family.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-white/10 text-white shadow-inner'
|
||||
: 'text-white/40 hover:bg-white/5 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
isSelected
|
||||
? 'bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.8)]'
|
||||
: 'bg-white/20',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-apple-bold">{family.name}</p>
|
||||
<p className="mt-1 text-[10px] text-white/35">{variantCount} 个规格款</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-16 opacity-0 transition-opacity pointer-events-none group-hover:opacity-100">
|
||||
{!expanded ? (
|
||||
<span className="whitespace-nowrap rounded-lg bg-black/80 px-3 py-1.5 text-[10px] font-apple-black uppercase tracking-widest text-white/60 ring-1 ring-white/10">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = 'text-white/60',
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
icon: typeof Box
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/[0.03] p-4 ring-1 ring-white/10 transition-all hover:bg-white/[0.06]">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<Icon size={14} className="text-white/20" />
|
||||
<span className="text-[9px] font-apple-black uppercase tracking-widest text-white/30">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('text-lg font-apple-black tracking-tight', color)}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-apple-semibold text-apple-label-primary">
|
||||
{entryMode === 'source-sku'
|
||||
? '源 SKU 辅助新增'
|
||||
: entryMode === 'asset-center'
|
||||
? '资产中心带料进入'
|
||||
: '直接进入辅助装配'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-apple-label-secondary">
|
||||
当前物料区会优先展示来源资产族,其余资产族按最新时间补在后面。
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${
|
||||
canSave
|
||||
? 'bg-apple-green/12 text-apple-green'
|
||||
: 'bg-apple-bg-secondary/75 text-apple-label-secondary'
|
||||
}`}
|
||||
>
|
||||
{stageStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{feedback ? (
|
||||
<p
|
||||
className={`rounded-2xl px-3 py-2 text-xs leading-5 ${
|
||||
feedback.tone === 'error'
|
||||
? 'bg-apple-orange/10 text-apple-orange'
|
||||
: 'bg-apple-green/10 text-apple-green'
|
||||
}`}
|
||||
>
|
||||
{feedback.text}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t border-apple-separator/20 pt-3">
|
||||
<CompactFamilyList
|
||||
title="板材资产族"
|
||||
families={desktopFamilies}
|
||||
selectedIds={desktopIds}
|
||||
availableVariantMap={availableVariantMap}
|
||||
onToggle={(id) => setDesktopIds(toggleSelection(desktopIds, id))}
|
||||
/>
|
||||
<CompactFamilyList
|
||||
title="组件资产族"
|
||||
families={legFamilies}
|
||||
selectedIds={legIds}
|
||||
availableVariantMap={availableVariantMap}
|
||||
onToggle={(id) => setLegIds(toggleSelection(legIds, id))}
|
||||
/>
|
||||
<CompactFamilyList
|
||||
title="五金资产族"
|
||||
families={connectorFamilies}
|
||||
selectedIds={connectorIds}
|
||||
availableVariantMap={availableVariantMap}
|
||||
onToggle={(id) => setConnectorIds(toggleSelection(connectorIds, id))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{selectedFamilyCount > 0 ? (
|
||||
<section className="space-y-3 border-t border-apple-separator/20 pt-3">
|
||||
{saveValidation?.level === 'BLOCKED' ? (
|
||||
<p className="rounded-2xl bg-apple-orange/10 px-3 py-2 text-xs leading-5 text-apple-orange">
|
||||
{saveValidation.blockingIssues[0]}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="草稿组名称"
|
||||
value={draftGroupName}
|
||||
onChange={(event) => setDraftGroupName(event.target.value)}
|
||||
placeholder="草稿组名称"
|
||||
/>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="filled"
|
||||
size="small"
|
||||
onClick={() => handlePersistDraftGroup('draft-only')}
|
||||
disabled={!canSave || loading}
|
||||
className="w-full"
|
||||
>
|
||||
保存草稿组
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
onClick={() => handlePersistDraftGroup('go-intake')}
|
||||
disabled={!canSave || loading}
|
||||
className="w-full"
|
||||
>
|
||||
保存并进入正式入库
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
const workbenchWarnings = workspace
|
||||
? [
|
||||
...workspace.validation.warnings,
|
||||
...(workspaceStats.pendingMainNodeCount > 0
|
||||
? [`主装配区还有 ${workspaceStats.pendingMainNodeCount} 个节点待完成连接`]
|
||||
: []),
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<PlatformSplitLayout
|
||||
title="辅助装配"
|
||||
metrics={metrics}
|
||||
feedback={feedback?.tone === 'error' ? feedback : null}
|
||||
leftContent={
|
||||
workspace ? (
|
||||
<AssemblyWorkbenchV2
|
||||
value={workspace}
|
||||
workspaceKey={[
|
||||
workspace.entryMode,
|
||||
workspace.sourceSkuId ?? 'none',
|
||||
workspace.sourceProductId ?? 'none',
|
||||
workspace.inventory.map((item) => item.inventoryId).join('|'),
|
||||
].join(':')}
|
||||
candidateName={workspaceTitle}
|
||||
title="辅助装配"
|
||||
statusTag="辅助拼装工作台"
|
||||
warnings={[
|
||||
...workspace.validation.warnings,
|
||||
...(workspacePendingMainNodeCount > 0
|
||||
? [`主装配区还有 ${workspacePendingMainNodeCount} 个节点待完成连接`]
|
||||
: []),
|
||||
]}
|
||||
availableVariants={variants}
|
||||
onChange={setWorkspace}
|
||||
rightDrawerContent={inspectorContent}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="rounded-full border border-white/65 bg-white/78 px-5 py-2.5 text-sm text-apple-label-secondary shadow-[0_12px_32px_rgba(55,70,78,0.1)] backdrop-blur-xl">
|
||||
正在准备工作台
|
||||
<div className="relative h-screen w-screen overflow-hidden bg-[#0c0e10] font-apple-system text-white">
|
||||
<PlatformSplitLayout
|
||||
title="辅助装配工作台"
|
||||
backHref="/asset-center"
|
||||
backLabel="资产中心"
|
||||
metrics={metrics}
|
||||
feedback={feedback?.tone === 'error' ? feedback : null}
|
||||
className="!h-full border-none bg-transparent"
|
||||
rightPanelClassName="w-[420px] max-w-[35vw] border-l-white/5 bg-black/40 backdrop-blur-3xl"
|
||||
leftContent={
|
||||
workspace ? (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[2.5rem] bg-[#16191d] shadow-2xl">
|
||||
<AssemblyWorkbenchV2
|
||||
value={workspace}
|
||||
workspaceKey={[
|
||||
workspace.entryMode,
|
||||
workspace.sourceSkuId ?? 'none',
|
||||
workspace.sourceProductId ?? 'none',
|
||||
workspace.inventory.map((item) => item.inventoryId).join('|'),
|
||||
].join(':')}
|
||||
candidateName={workspaceTitle}
|
||||
statusTag="辅助装配工作台"
|
||||
warnings={workbenchWarnings}
|
||||
availableVariants={variants}
|
||||
onChange={setWorkspace}
|
||||
className="h-full w-full"
|
||||
title="辅助装配工作台"
|
||||
/>
|
||||
|
||||
<div className="absolute left-8 top-1/2 flex -translate-y-1/2 flex-col gap-6">
|
||||
<FloatingDock
|
||||
title="板件资产"
|
||||
icon={Layers3}
|
||||
families={desktopFamilies}
|
||||
selectedIds={desktopIds}
|
||||
availableVariantMap={availableVariantMap}
|
||||
onToggle={(id) => setDesktopIds((current) => toggleSelection(current, id))}
|
||||
/>
|
||||
<FloatingDock
|
||||
title="支撑组件"
|
||||
icon={Shapes}
|
||||
families={legFamilies}
|
||||
selectedIds={legIds}
|
||||
availableVariantMap={availableVariantMap}
|
||||
onToggle={(id) => setLegIds((current) => toggleSelection(current, id))}
|
||||
/>
|
||||
<FloatingDock
|
||||
title="连接五金"
|
||||
icon={Link2}
|
||||
families={connectorFamilies}
|
||||
selectedIds={connectorIds}
|
||||
availableVariantMap={availableVariantMap}
|
||||
onToggle={(id) => setConnectorIds((current) => toggleSelection(current, id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-1/2 flex -translate-x-1/2 items-center gap-4 rounded-full bg-black/40 px-6 py-2.5 text-[10px] font-apple-black uppercase tracking-[0.2em] text-white/60 backdrop-blur-xl ring-1 ring-white/10">
|
||||
<Sparkles size={12} className="text-apple-blue" />
|
||||
孔位捕捉已就绪 · 拖拽零件进行吸附
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="rounded-full border border-white/65 bg-white/78 px-5 py-2.5 text-sm text-apple-label-secondary shadow-[0_12px_32px_rgba(55,70,78,0.1)] backdrop-blur-xl">
|
||||
正在准备工作台
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
rightContent={
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b border-white/5 bg-gradient-to-b from-white/[0.02] to-transparent p-8">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-apple-blue animate-pulse" />
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">
|
||||
Real-time Inspector
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-apple-black uppercase tracking-tight">
|
||||
装配定义<span className="ml-2 text-apple-blue">报告</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-10 overflow-y-auto p-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-apple-semibold text-white">
|
||||
{entryMode === 'source-sku'
|
||||
? '源 SKU 辅助新增'
|
||||
: entryMode === 'asset-center'
|
||||
? '资产中心带料进入'
|
||||
: '直接进入辅助装配'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-white/45">
|
||||
当前物料区会优先展示来源资产族,其余资产族按最新时间补在后面。
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||
canSave ? 'bg-apple-blue/15 text-apple-blue' : 'bg-white/10 text-white/55',
|
||||
)}
|
||||
>
|
||||
{stageStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl px-4 py-3 text-xs leading-5',
|
||||
feedback.tone === 'error'
|
||||
? 'bg-apple-red/10 text-apple-red ring-1 ring-apple-red/20'
|
||||
: 'bg-apple-blue/10 text-apple-blue ring-1 ring-apple-blue/20',
|
||||
)}
|
||||
>
|
||||
{feedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-4 px-1 text-[10px] font-apple-black uppercase tracking-[0.2em] text-white/30">
|
||||
工作区属性 Architecture
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="草稿组名称"
|
||||
value={draftGroupName}
|
||||
onChange={(event) => setDraftGroupName(event.target.value)}
|
||||
placeholder="草稿组名称"
|
||||
variant="filled"
|
||||
className="bg-white/5"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-4 px-1 text-[10px] font-apple-black uppercase tracking-[0.2em] text-white/30">
|
||||
装配拓扑进度 Topology
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="总节点数" value={workspaceStats.nodeCount} icon={Box} />
|
||||
<StatCard
|
||||
label="已连接对"
|
||||
value={workspaceStats.relationCount}
|
||||
icon={ShieldCheck}
|
||||
color="text-apple-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white/[0.03] p-5 ring-1 ring-white/10">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[10px] font-apple-bold uppercase text-white/40">
|
||||
结构稳固性推演
|
||||
</span>
|
||||
<span className="text-xs font-apple-black text-apple-blue">
|
||||
{workspaceStats.relationCount > 0 ? '85%' : '0%'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full bg-apple-blue transition-all duration-1000"
|
||||
style={{ width: workspaceStats.relationCount > 0 ? '85%' : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{saveValidation?.level === 'BLOCKED' ? (
|
||||
<div className="rounded-2xl bg-apple-red/10 p-5 ring-1 ring-apple-red/20 animate-in fade-in zoom-in-95">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<AlertCircle className="h-4 w-4 text-apple-red" />
|
||||
<span className="text-[10px] font-apple-black uppercase text-apple-red">
|
||||
阻断性异常
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-white/60">
|
||||
{saveValidation.blockingIssues[0]}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/5 bg-black/40 p-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
variant="filled"
|
||||
className="h-14 w-full rounded-2xl bg-white text-black hover:bg-zinc-200 shadow-2xl active:scale-[0.98]"
|
||||
loading={loading}
|
||||
onClick={() => handlePersistDraftGroup('go-intake')}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4 fill-current" />
|
||||
确认并进入物料清单
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
className="h-12 w-full rounded-xl border border-white/10 text-[10px] font-apple-black uppercase tracking-widest text-white/60 hover:bg-white/5"
|
||||
onClick={() => handlePersistDraftGroup('draft-only')}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<Save className="mr-2 h-3.5 w-3.5" />
|
||||
仅保存草稿
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -188,7 +188,13 @@ function ImagePreviewDialog({
|
||||
)
|
||||
}
|
||||
|
||||
function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
||||
function PendingSkuDetail({
|
||||
skuList,
|
||||
canWrite,
|
||||
}: {
|
||||
skuList: WorkbenchSku[]
|
||||
canWrite: boolean
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<PendingTabKey>('pending')
|
||||
const [spuKeyword, setSpuKeyword] = useState('')
|
||||
const [skuKeyword, setSkuKeyword] = useState('')
|
||||
@ -321,7 +327,7 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
||||
<tr className="border-b border-apple-separator">
|
||||
<th className="px-3 py-2.5 font-apple-medium">编号</th>
|
||||
<th className="px-3 py-2.5 font-apple-medium">图片</th>
|
||||
<th className="px-3 py-2.5 font-apple-medium">上传</th>
|
||||
{canWrite ? <th className="px-3 py-2.5 font-apple-medium">上传</th> : null}
|
||||
<th className="px-3 py-2.5 font-apple-medium">SPU</th>
|
||||
<th className="px-3 py-2.5 font-apple-medium">SKU</th>
|
||||
<th className="px-3 py-2.5 font-apple-medium">系列</th>
|
||||
@ -332,7 +338,10 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
||||
<tbody>
|
||||
{filteredList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-xs text-apple-label-secondary">
|
||||
<td
|
||||
colSpan={canWrite ? 8 : 7}
|
||||
className="px-4 py-8 text-center text-xs text-apple-label-secondary"
|
||||
>
|
||||
当前筛选条件下没有找到 SKU。
|
||||
</td>
|
||||
</tr>
|
||||
@ -347,11 +356,13 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
||||
SKU 图
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Button variant="plain" size="small">
|
||||
上传
|
||||
</Button>
|
||||
</td>
|
||||
{canWrite ? (
|
||||
<td className="px-3 py-3">
|
||||
<Button variant="plain" size="small">
|
||||
上传
|
||||
</Button>
|
||||
</td>
|
||||
) : null}
|
||||
<td className="px-3 py-3 text-apple-label-primary">{sku.spuCode}</td>
|
||||
<td className="px-3 py-3">
|
||||
<p className="font-apple-medium text-apple-label-primary">{sku.code}</p>
|
||||
@ -366,9 +377,11 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button size="small">查看</Button>
|
||||
<Button variant="plain" size="small">
|
||||
暂缓
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button variant="plain" size="small">
|
||||
暂缓
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -412,8 +425,10 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
|
||||
|
||||
function NonIntegratedPendingDetail({
|
||||
initialProducts,
|
||||
canWrite,
|
||||
}: {
|
||||
initialProducts: NonIntegratedProductRecord[]
|
||||
canWrite: boolean
|
||||
}) {
|
||||
const [products, setProducts] = useState<NonIntegratedProductRecord[]>(() =>
|
||||
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({
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
|
||||
<div className="border-b border-apple-separator px-4 py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid gap-2.5 md:grid-cols-[1fr_1fr_1fr_1fr_88px_112px]">
|
||||
<div
|
||||
className={`grid gap-2.5 ${
|
||||
canWrite ? 'md:grid-cols-[1fr_1fr_1fr_1fr_88px_112px]' : 'md:grid-cols-[1fr_1fr_1fr_1fr_88px]'
|
||||
}`}
|
||||
>
|
||||
<label className="space-y-1.5 text-xs text-apple-label-secondary">
|
||||
<span>SPU</span>
|
||||
<input
|
||||
@ -535,13 +559,15 @@ function NonIntegratedPendingDetail({
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<Link href="/intake/non-integrated/new" className="w-full">
|
||||
<Button size="small" className="h-9 w-full px-4 text-xs">
|
||||
新增产品
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<div className="flex items-end justify-end">
|
||||
<Link href="/intake/non-integrated/new" className="w-full">
|
||||
<Button size="small" className="h-9 w-full px-4 text-xs">
|
||||
新增产品
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -565,7 +591,9 @@ function NonIntegratedPendingDetail({
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-xs text-apple-label-secondary">
|
||||
{products.length === 0
|
||||
? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。'
|
||||
? canWrite
|
||||
? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。'
|
||||
: '当前还没有非集成产品。'
|
||||
: '当前查询条件下没有找到资料。'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -605,15 +633,17 @@ function NonIntegratedPendingDetail({
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/intake/non-integrated/${encodeURIComponent(product.id)}`}>
|
||||
<Button size="small">修改</Button>
|
||||
<Button size="small">{canWrite ? '修改' : '查看'}</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={() => handleDelete(product.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={() => handleDelete(product.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -705,6 +735,7 @@ function FormalIntakeWorkbench({
|
||||
export default function IntakeClient(props: IntakeClientProps) {
|
||||
const [activeSection, setActiveSection] = useState<IntakeSection>('pending')
|
||||
const [pendingEntryTab, setPendingEntryTab] = useState<IntakeEntryTab>('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' ? (
|
||||
<PendingSkuDetail skuList={skuList} />
|
||||
<PendingSkuDetail skuList={skuList} canWrite={canWriteIntake} />
|
||||
) : (
|
||||
<NonIntegratedPendingDetail initialProducts={props.nonIntegratedProducts ?? []} />
|
||||
<NonIntegratedPendingDetail
|
||||
initialProducts={props.nonIntegratedProducts ?? []}
|
||||
canWrite={canWriteIntake}
|
||||
/>
|
||||
)
|
||||
} else if (activeSection === 'bom') {
|
||||
leftContent = <BomDetail />
|
||||
@ -779,9 +813,9 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
if (activeSection === 'pending') {
|
||||
leftContent =
|
||||
pendingEntryTab === 'integrated' ? (
|
||||
<PendingSkuDetail skuList={skuList} />
|
||||
<PendingSkuDetail skuList={skuList} canWrite={canWriteIntake} />
|
||||
) : (
|
||||
<NonIntegratedPendingDetail initialProducts={[]} />
|
||||
<NonIntegratedPendingDetail initialProducts={[]} canWrite={canWriteIntake} />
|
||||
)
|
||||
} else if (activeSection === 'bom') {
|
||||
leftContent = <BomDetail />
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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() {
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 xl:mt-[18px] font-normal text-[20px] xl:text-[24px] text-[#4b5563]">
|
||||
创建账号后即可进入系统开始试用
|
||||
注册后先等待后台激活,再进入系统
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -132,7 +132,7 @@ export default function RegisterPage() {
|
||||
<div className="hidden lg:block">
|
||||
<LargeTitle className="text-(--color-label-primary)">注册</LargeTitle>
|
||||
<Body className="text-(--color-label-secondary) mt-2">
|
||||
创建一个普通设计账号,注册后可直接返回登录
|
||||
所有新账号都会先进入待激活状态,管理员审核通过后才能登录
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
@ -203,6 +203,12 @@ export default function RegisterPage() {
|
||||
注册账号
|
||||
</Button>
|
||||
|
||||
<div className="rounded-apple-lg border border-apple-separator bg-(--color-background-secondary) px-4 py-3">
|
||||
<Footnote className="text-(--color-label-secondary)">
|
||||
注册成功后不会自动放行登录。管理员在后台激活并分配角色后,你才能正式进入系统。
|
||||
</Footnote>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Footnote className="text-(--color-label-secondary)">
|
||||
已有账号?
|
||||
|
||||
@ -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 (
|
||||
<AuthProvider>
|
||||
<RequireAuth>
|
||||
<AdminGuard>
|
||||
<PermissionGuard roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']}>
|
||||
<UsersPageContent />
|
||||
</AdminGuard>
|
||||
</PermissionGuard>
|
||||
</RequireAuth>
|
||||
</AuthProvider>
|
||||
);
|
||||
@ -59,9 +61,9 @@ function UsersPageContent() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserTreeItem | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<UserTreeItem | null>(null);
|
||||
const [handoverUser, setHandoverUser] = useState<UserTreeItem | null>(null);
|
||||
const [actionError, setActionError] = useState<ActionError>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<div className="relative min-h-screen overflow-x-hidden bg-[#f4f7f9] pb-24 font-apple-system text-apple-label-primary">
|
||||
{/* 背景光晕装饰 */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<div className="absolute right-[-5%] top-[-5%] h-[30%] w-[30%] rounded-full bg-blue-500/5 blur-[100px]" />
|
||||
<div className="absolute left-[-5%] bottom-[-5%] h-[30%] w-[30%] rounded-full bg-indigo-500/5 blur-[100px]" />
|
||||
</div>
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#f4f7f9] font-apple-system text-apple-label-primary">
|
||||
<PlatformSplitLayout
|
||||
title="身份控制中心"
|
||||
description="定义组织架构、配置原子化权限策略及账号生命周期管理。"
|
||||
backHref="/admin"
|
||||
backLabel="管理中心"
|
||||
rightPanelClassName="w-[480px] max-w-[40vw]"
|
||||
|
||||
{/* 沉浸式页头 */}
|
||||
<header className="relative z-20 border-b border-apple-separator/5 bg-white/60 px-10 py-12 backdrop-blur-2xl">
|
||||
<div className="mx-auto flex max-w-[1400px] items-end justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="relative flex h-20 w-20 items-center justify-center rounded-[2rem] bg-black shadow-apple-xl ring-1 ring-white/20">
|
||||
<Fingerprint className="h-10 w-10 text-white" />
|
||||
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-apple-blue text-white shadow-apple-md ring-4 ring-white">
|
||||
<ShieldCheck size={16} />
|
||||
leftContent={
|
||||
<div className="flex h-full flex-col gap-8 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="px-3 text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary opacity-50">Quick Filters</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ROLE_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setRoleFilter(opt.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-2xl px-4 py-3 transition-all",
|
||||
roleFilter === opt.value ? "bg-white text-apple-blue shadow-apple-sm" : "text-apple-label-secondary hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
<span className="text-apple-xs font-apple-bold truncate">{opt.label}</span>
|
||||
{roleFilter === opt.value && <div className="h-1.5 w-1.5 rounded-full bg-apple-blue" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-[0.4em] text-apple-label-tertiary">
|
||||
Identity & Access Management
|
||||
</span>
|
||||
|
||||
<div className="mt-auto rounded-3xl bg-black/[0.03] p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<ShieldCheck className="h-5 w-5 text-apple-blue" />
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">Security Status</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SecurityMetric label="已激活账号" value="124" />
|
||||
<SecurityMetric label="待审核请求" value="3" color="text-amber-500" />
|
||||
<SecurityMetric label="异常登录" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-apple-3xl font-apple-black leading-none tracking-tighter uppercase">
|
||||
身份控制<span className="ml-2 text-apple-blue">中心</span>
|
||||
</h1>
|
||||
<p className="mt-3 text-apple-xs font-apple-medium text-apple-label-tertiary">
|
||||
配置企业组织架构与原子化权限策略
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden flex-col items-end mr-6 md:flex">
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">当前操作者</span>
|
||||
<span className="text-apple-sm font-apple-bold text-apple-blue">{currentUser?.name || 'Administrator'}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="filled"
|
||||
size="regular"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="group relative h-12 overflow-hidden rounded-2xl bg-black px-10 text-[10px] font-apple-black uppercase tracking-[0.2em] text-white shadow-apple-2xl transition-all hover:bg-zinc-800 hover:shadow-apple-blue/20 active:scale-95"
|
||||
>
|
||||
<UserPlus className="mr-3 h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
开设新账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 核心交互区 */}
|
||||
<main className="relative z-10 mx-auto max-w-[1400px] px-10 py-10">
|
||||
{/* 精密控制条 */}
|
||||
<div className="group mb-12 flex flex-col items-stretch gap-4 rounded-[2rem] border border-white/80 bg-white/40 p-4 shadow-apple-sm backdrop-blur-xl transition-all hover:shadow-apple-md md:flex-row md:items-center md:p-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 text-apple-label-tertiary transition-colors group-focus-within:text-apple-blue" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="通过姓名、UID 或 邮箱地址进行全局检索..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 z-10 text-apple-label-tertiary">
|
||||
<Users size={14} />
|
||||
</div>
|
||||
}
|
||||
mainContent={
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 精密搜索栏 */}
|
||||
<div className="mb-8 flex items-center justify-between rounded-[2rem] border border-white/60 bg-white/40 p-3 shadow-apple-sm backdrop-blur-xl">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 text-apple-label-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索姓名、邮箱或 UID 检索用户..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 flex items-center gap-3">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
options={STATUS_OPTIONS}
|
||||
className="h-12 min-w-[140px] rounded-2xl border-none bg-white/80 shadow-apple-xs"
|
||||
/>
|
||||
<Button variant="filled" size="small" onClick={() => setShowCreateModal(true)} className="h-12 rounded-2xl px-8 text-[10px] font-apple-black uppercase tracking-widest shadow-apple-blue/20">
|
||||
<UserPlus className="mr-2 h-4 w-4" /> 开设账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={roleFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 z-10 text-apple-label-tertiary">
|
||||
<Filter size={14} />
|
||||
|
||||
{actionError && <Alert type="error" variant="subtle" title="Security Alert" description={actionError} className="mb-8 rounded-3xl" />}
|
||||
|
||||
{/* 资源树容器 */}
|
||||
<div className="rounded-[2.5rem] border border-white/60 bg-white/30 p-2 shadow-apple-xl backdrop-blur-sm overflow-hidden flex-1">
|
||||
<div className="h-full overflow-y-auto rounded-[2rem] bg-white/80 shadow-apple-xs ring-1 ring-apple-separator/5">
|
||||
<DepartmentAccessTree
|
||||
searchQuery={debouncedSearchQuery}
|
||||
roleFilter={roleFilter}
|
||||
statusFilter={statusFilter}
|
||||
refreshToken={refreshToken}
|
||||
currentUserId={currentUser?.id}
|
||||
onEdit={setSelectedUser}
|
||||
onDelete={handleDeleteUser}
|
||||
onActivate={handleActivateUser}
|
||||
onHandover={setHandoverUser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightContent={
|
||||
selectedUser ? (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-white/40 backdrop-blur-3xl">
|
||||
{/* User Header Profile */}
|
||||
<div className="relative h-60 w-full overflow-hidden border-b border-apple-separator/5 bg-black p-8">
|
||||
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '24px 24px' }} />
|
||||
<div className="relative z-10 flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-[2rem] bg-white/10 ring-1 ring-white/20">
|
||||
<Fingerprint className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-apple-black text-white uppercase tracking-tight">{selectedUser.name}</h2>
|
||||
<p className="text-[10px] font-apple-black text-apple-blue uppercase tracking-[0.3em] mt-2">{selectedUser.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-10 animate-in fade-in zoom-in-95 duration-500">
|
||||
<Alert
|
||||
type="error"
|
||||
variant="subtle"
|
||||
title="SECURITY ALERT"
|
||||
description={actionError}
|
||||
className="rounded-3xl border-apple-red/20 bg-apple-red/5 p-6"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Details Inspector */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">Identity Inspector</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="plain" size="small" onClick={() => setSelectedUser(null)}>关闭</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资源树容器 */}
|
||||
<div className="rounded-[2.5rem] border border-white/60 bg-white/30 p-2 shadow-apple-xl backdrop-blur-sm">
|
||||
<div className="overflow-hidden rounded-[2rem] bg-white/80 shadow-apple-xs ring-1 ring-apple-separator/5">
|
||||
<DepartmentAccessTree
|
||||
searchQuery={debouncedSearchQuery}
|
||||
roleFilter={roleFilter}
|
||||
statusFilter={statusFilter}
|
||||
refreshToken={refreshToken}
|
||||
currentUserId={currentUser?.id}
|
||||
onEdit={setEditingUser}
|
||||
onDelete={handleDeleteUser}
|
||||
onActivate={handleActivateUser}
|
||||
onHandover={setHandoverUser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div className="space-y-8">
|
||||
<InspectorGroup title="基础通讯 Base Contact">
|
||||
<InspectorItem icon={Mail} label="工作邮箱" value={selectedUser.email} />
|
||||
<InspectorItem icon={Phone} label="联系电话" value="未配置" />
|
||||
</InspectorGroup>
|
||||
|
||||
{/* 底部装饰 */}
|
||||
<footer className="mt-12 flex justify-center opacity-20 transition-opacity hover:opacity-100">
|
||||
<p className="text-[9px] font-apple-black uppercase tracking-[0.5em] text-apple-label-tertiary">
|
||||
Precision Atelier · Structural Security Infrastructure
|
||||
</p>
|
||||
</footer>
|
||||
<InspectorGroup title="组织架构 Architecture">
|
||||
<InspectorItem icon={Briefcase} label="所属部门" value="研发中心 / 3D 实验室" />
|
||||
<InspectorItem icon={Calendar} label="入职日期" value="2025-04-12" />
|
||||
</InspectorGroup>
|
||||
|
||||
{showCreateModal && (
|
||||
<CreateUserModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={closeCreateModal}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
)}
|
||||
<InspectorGroup title="安全审计 Security Audit">
|
||||
<div className="rounded-2xl bg-black/[0.02] p-4 ring-1 ring-apple-separator/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-[11px] font-apple-bold text-apple-label-secondary">权限状态:已授权</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-apple-label-tertiary leading-relaxed">
|
||||
该账户已绑定硬件指纹令牌,最近一次成功登录发生在 2 小时前。
|
||||
</p>
|
||||
</div>
|
||||
</InspectorGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
isOpen={!!editingUser}
|
||||
user={editingUser}
|
||||
onClose={closeEditModal}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
)}
|
||||
<div className="p-8 border-t border-apple-separator/5 bg-white/20">
|
||||
<Button variant="filled" className="w-full h-12 rounded-2xl bg-black text-[10px] font-apple-black uppercase tracking-widest text-white shadow-apple-2xl" onClick={() => triggerRefresh()}>
|
||||
刷新同步状态
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center p-12 text-center">
|
||||
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-[2rem] bg-apple-bg-secondary shadow-apple-sm">
|
||||
<Fingerprint className="h-10 w-10 text-apple-label-tertiary opacity-30" />
|
||||
</div>
|
||||
<p className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">Identity Inspector</p>
|
||||
<h3 className="mt-4 text-apple-lg font-apple-bold text-apple-label-primary">等待选择用户</h3>
|
||||
<p className="mt-3 max-w-[240px] text-apple-xs leading-relaxed text-apple-label-tertiary">
|
||||
从左侧列表中选择一名员工,即可在此处查看详细的权限矩阵与安全审计报告。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{handoverUser && (
|
||||
<HandoverModal
|
||||
isOpen={!!handoverUser}
|
||||
user={handoverUser}
|
||||
onClose={closeHandoverModal}
|
||||
onSuccess={handleHandoverSuccess}
|
||||
/>
|
||||
)}
|
||||
{showCreateModal && <CreateUserModal isOpen={showCreateModal} onClose={closeCreateModal} onSuccess={handleCreateSuccess} />}
|
||||
{handoverUser && <HandoverModal isOpen={!!handoverUser} user={handoverUser} onClose={closeHandoverModal} onSuccess={handleHandoverSuccess} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityMetric({ label, value, color }: { label: string, value: string, color?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-apple-xs font-apple-bold text-apple-label-secondary">{label}</span>
|
||||
<span className={cn("text-[11px] font-apple-black uppercase", color || "text-apple-blue")}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorGroup({ title, children }: { title: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[9px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary px-1">{title}</h4>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorItem({ icon: Icon, label, value }: { icon: any, label: string, value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-apple-separator/10 bg-white/60 p-4 shadow-apple-xs">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-white shadow-apple-xs">
|
||||
<Icon className="h-4 w-4 text-apple-label-tertiary opacity-60" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary opacity-60">{label}</span>
|
||||
<span className="text-xs font-apple-bold text-apple-label-primary truncate">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -339,8 +339,17 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([])
|
||||
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
|
||||
{/* 标题 + 新增 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-apple-semibold text-apple-label-primary">库位列表</h3>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingLocationId(null)
|
||||
setLocationForm({ code: '', name: '', zone: '', aisle: '', shelf: '', bin: '', notes: '' })
|
||||
setLocationModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新增库位
|
||||
</Button>
|
||||
{canWriteWarehouse ? (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingLocationId(null)
|
||||
setLocationForm({ code: '', name: '', zone: '', aisle: '', shelf: '', bin: '', notes: '' })
|
||||
setLocationModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新增库位
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 排序 */}
|
||||
@ -717,7 +768,9 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
{filteredLocations.length === 0 ? (
|
||||
<div className="rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-secondary px-4 py-6 text-sm leading-7 text-apple-label-secondary">
|
||||
{overview.locations.length === 0
|
||||
? '还没有库位。点击「新增库位」创建一个正式库位,后续入库即可挂接。'
|
||||
? canWriteWarehouse
|
||||
? '还没有库位。点击「新增库位」创建一个正式库位,后续入库即可挂接。'
|
||||
: '还没有库位。'
|
||||
: '没有匹配的库位,试试调整搜索条件。'}
|
||||
</div>
|
||||
) : (
|
||||
@ -737,30 +790,34 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
<Badge type={location.isActive ? 'success' : 'warning'} variant="subtle">
|
||||
{location.isActive ? '可用' : '停用'}
|
||||
</Badge>
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setEditingLocationId(null)
|
||||
setLocationForm({
|
||||
code: location.code,
|
||||
name: location.name,
|
||||
zone: location.zone || '',
|
||||
aisle: location.aisle || '',
|
||||
shelf: location.shelf || '',
|
||||
bin: location.bin || '',
|
||||
notes: location.notes || '',
|
||||
})
|
||||
setLocationModalOpen(true)
|
||||
}}>
|
||||
复制
|
||||
</Button>
|
||||
<Button variant="plain" size="small" onClick={() => openEditModal(location)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="plain" size="small" onClick={() => void handleToggleLocation(location.id, location.isActive)}>
|
||||
{location.isActive ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button variant="plain" size="small" onClick={() => setDeleteConfirmId(location.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canWriteWarehouse ? (
|
||||
<>
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setEditingLocationId(null)
|
||||
setLocationForm({
|
||||
code: location.code,
|
||||
name: location.name,
|
||||
zone: location.zone || '',
|
||||
aisle: location.aisle || '',
|
||||
shelf: location.shelf || '',
|
||||
bin: location.bin || '',
|
||||
notes: location.notes || '',
|
||||
})
|
||||
setLocationModalOpen(true)
|
||||
}}>
|
||||
复制
|
||||
</Button>
|
||||
<Button variant="plain" size="small" onClick={() => openEditModal(location)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="plain" size="small" onClick={() => void handleToggleLocation(location.id, location.isActive)}>
|
||||
{location.isActive ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button variant="plain" size="small" onClick={() => setDeleteConfirmId(location.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -867,12 +924,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
) : (
|
||||
<Badge type="info" variant="subtle">待绑定</Badge>
|
||||
)}
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setBindingVariantId(variant.id)
|
||||
setSelectedLocationIds(variant.boundLocations.map((l) => l.id))
|
||||
setBindingSearch('')
|
||||
setBindingModalOpen(true)
|
||||
}}>绑定库位</Button>
|
||||
{canWriteWarehouse ? (
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setBindingVariantId(variant.id)
|
||||
setSelectedLocationIds(variant.boundLocations.map((l) => l.id))
|
||||
setBindingSearch('')
|
||||
setBindingModalOpen(true)
|
||||
}}>绑定库位</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{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}
|
||||
<button
|
||||
type="button"
|
||||
className="text-apple-label-tertiary hover:text-apple-red"
|
||||
onClick={() => void handleUnbindLocation(variant.id, loc.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{canWriteWarehouse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-apple-label-tertiary hover:text-apple-red"
|
||||
onClick={() => void handleUnbindLocation(variant.id, loc.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@ -981,12 +1042,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
) : (
|
||||
<Badge type="info" variant="subtle">待绑定</Badge>
|
||||
)}
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setBindingPreSkuId(ps.id)
|
||||
setSelectedPreSkuLocationIds(ps.boundLocations.map((l) => l.id))
|
||||
setBindingSearch('')
|
||||
setPreSkuBindingModalOpen(true)
|
||||
}}>绑定库位</Button>
|
||||
{canWriteWarehouse ? (
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setBindingPreSkuId(ps.id)
|
||||
setSelectedPreSkuLocationIds(ps.boundLocations.map((l) => l.id))
|
||||
setBindingSearch('')
|
||||
setPreSkuBindingModalOpen(true)
|
||||
}}>绑定库位</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{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}
|
||||
<button
|
||||
type="button"
|
||||
className="text-apple-label-tertiary hover:text-apple-red"
|
||||
onClick={() => void handleUnbindPreSkuLocation(ps.id, loc.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{canWriteWarehouse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-apple-label-tertiary hover:text-apple-red"
|
||||
onClick={() => void handleUnbindPreSkuLocation(ps.id, loc.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@ -1069,13 +1134,15 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0 ml-2">
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setBrandBindingTarget({ brand: selectedBrand, materialType: opt.value })
|
||||
setBrandBindingLocationId(binding?.warehouseLocation?.id ?? null)
|
||||
setBindingSearch('')
|
||||
setBrandBindingModalOpen(true)
|
||||
}}>绑定库位</Button>
|
||||
{opt.value === 'CARTON_PRINT' && (
|
||||
{canWriteWarehouse ? (
|
||||
<Button variant="plain" size="small" onClick={() => {
|
||||
setBrandBindingTarget({ brand: selectedBrand, materialType: opt.value })
|
||||
setBrandBindingLocationId(binding?.warehouseLocation?.id ?? null)
|
||||
setBindingSearch('')
|
||||
setBrandBindingModalOpen(true)
|
||||
}}>绑定库位</Button>
|
||||
) : null}
|
||||
{canWriteWarehouse && opt.value === 'CARTON_PRINT' && (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
@ -1188,28 +1255,29 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
/>
|
||||
|
||||
{/* 新增/编辑库位弹窗 */}
|
||||
<Modal
|
||||
isOpen={locationModalOpen}
|
||||
onClose={() => { setLocationModalOpen(false); setEditingLocationId(null) }}
|
||||
title={editingLocationId ? '编辑库位' : '新增库位'}
|
||||
size="medium"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<Button variant="gray" size="regular" onClick={() => { setLocationModalOpen(false); setEditingLocationId(null) }} className="flex-1">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
size="regular"
|
||||
onClick={() => void (editingLocationId ? handleUpdateLocation() : handleCreateLocation())}
|
||||
loading={submitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{editingLocationId ? '保存修改' : '创建库位'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{canWriteWarehouse ? (
|
||||
<Modal
|
||||
isOpen={locationModalOpen}
|
||||
onClose={() => { setLocationModalOpen(false); setEditingLocationId(null) }}
|
||||
title={editingLocationId ? '编辑库位' : '新增库位'}
|
||||
size="medium"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<Button variant="gray" size="regular" onClick={() => { setLocationModalOpen(false); setEditingLocationId(null) }} className="flex-1">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
size="regular"
|
||||
onClick={() => void (editingLocationId ? handleUpdateLocation() : handleCreateLocation())}
|
||||
loading={submitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{editingLocationId ? '保存修改' : '创建库位'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
label="库位编码"
|
||||
@ -1237,11 +1305,13 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
onChange={(event) => setLocationForm((current) => ({ ...current, notes: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{canWriteWarehouse ? (
|
||||
<Modal
|
||||
isOpen={deleteConfirmId !== null}
|
||||
isOpen={canWriteWarehouse && deleteConfirmId !== null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
title="确认删除"
|
||||
size="small"
|
||||
@ -1258,10 +1328,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
>
|
||||
<p className="text-sm text-apple-label-secondary">确定要删除这个库位吗?此操作不可恢复。</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{/* 绑定库位弹窗 */}
|
||||
{canWriteWarehouse ? (
|
||||
<Modal
|
||||
isOpen={bindingModalOpen}
|
||||
isOpen={canWriteWarehouse && bindingModalOpen}
|
||||
onClose={() => { setBindingModalOpen(false); setBindingVariantId(null); setSelectedLocationIds([]); setBindingSearch('') }}
|
||||
title="绑定库位"
|
||||
size="medium"
|
||||
@ -1337,10 +1409,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{/* 品牌物料绑定库位弹窗 */}
|
||||
{canWriteWarehouse ? (
|
||||
<Modal
|
||||
isOpen={brandBindingModalOpen}
|
||||
isOpen={canWriteWarehouse && brandBindingModalOpen}
|
||||
onClose={() => { setBrandBindingModalOpen(false); setBrandBindingTarget(null); setBrandBindingLocationId(null) }}
|
||||
title="绑定库位"
|
||||
size="medium"
|
||||
@ -1428,10 +1502,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{/* 成品 SKU 绑定库位弹窗 */}
|
||||
{canWriteWarehouse ? (
|
||||
<Modal
|
||||
isOpen={preSkuBindingModalOpen}
|
||||
isOpen={canWriteWarehouse && preSkuBindingModalOpen}
|
||||
onClose={() => { setPreSkuBindingModalOpen(false); setBindingPreSkuId(null); setSelectedPreSkuLocationIds([]) }}
|
||||
title="绑定库位"
|
||||
size="medium"
|
||||
@ -1507,6 +1583,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<PermissionGuard
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']}
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT']}
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
@ -100,7 +100,7 @@ export function TeamLeaderGuard({
|
||||
}) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']}
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']}
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
@ -120,7 +120,7 @@ export function CreatorGuard({
|
||||
}) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER', 'DESIGNER']}
|
||||
roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM', 'DESIGNER']}
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -26,10 +26,9 @@ interface AuthMeResponse {
|
||||
const roleLabelMap: Record<string, string> = {
|
||||
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() {
|
||||
<LayoutDashboard size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
管理总控制台
|
||||
</Link>
|
||||
<Link
|
||||
</>
|
||||
)}
|
||||
{canManageUsers && (
|
||||
<Link
|
||||
href="/users"
|
||||
onClick={() => 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() {
|
||||
<Users size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
用户权限管理
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -50,7 +50,7 @@ describe('AppSideNav', () => {
|
||||
id: 'user-1',
|
||||
name: '测试账号',
|
||||
email: 'test@example.com',
|
||||
role: 'TEAM_LEADER',
|
||||
role: 'SUPERVISOR',
|
||||
avatar: null,
|
||||
department: { id: 'dept-1', name: '研发一组' },
|
||||
},
|
||||
@ -86,7 +86,7 @@ describe('AppSideNav', () => {
|
||||
|
||||
expect(await screen.findByText('测试账号')).toBeInTheDocument()
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('组长')).toBeInTheDocument()
|
||||
expect(screen.getByText('主管')).toBeInTheDocument()
|
||||
expect(screen.getByText('工作区')).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '退出登录' }))
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
} from '@/lib/open-product/assisted-assembly-variant'
|
||||
import {
|
||||
deriveAssemblyActiveSnapCandidate,
|
||||
getAssemblyAutoSnapSupportStatus,
|
||||
deriveAssemblyAssistFeedback,
|
||||
moveAssemblyNodes,
|
||||
removeAssemblyNodes,
|
||||
instantiateAssemblyNode,
|
||||
@ -265,30 +265,13 @@ export function AssemblyWorkbenchV2({
|
||||
|
||||
const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId))
|
||||
const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null
|
||||
const selectedNodeSnapSupport = useMemo(
|
||||
() => (selectedSingleNode ? getAssemblyAutoSnapSupportStatus(selectedSingleNode) : null),
|
||||
[selectedSingleNode],
|
||||
const selectedNodeAssistFeedback = useMemo(
|
||||
() =>
|
||||
selectedSingleNode
|
||||
? deriveAssemblyAssistFeedback(presentWorkspace, selectedSingleNode.nodeId)
|
||||
: null,
|
||||
[presentWorkspace, selectedSingleNode],
|
||||
)
|
||||
const selectedNodeAutoSnapNotice = useMemo(() => {
|
||||
if (
|
||||
!selectedSingleNode ||
|
||||
selectedSingleNode.source === 'desktop' ||
|
||||
selectedNodeSnapSupport?.canAutoSnap
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedNodeSnapSupport?.missingMountPatterns && selectedNodeSnapSupport?.missingHoles) {
|
||||
return '当前资产缺少孔位与安装图样语义,无法自动吸附。'
|
||||
}
|
||||
if (selectedNodeSnapSupport?.missingMountPatterns) {
|
||||
return '当前资产缺少安装图样语义,无法自动吸附。'
|
||||
}
|
||||
if (selectedNodeSnapSupport?.missingHoles) {
|
||||
return '当前资产缺少孔位语义,无法自动吸附。'
|
||||
}
|
||||
return null
|
||||
}, [selectedNodeSnapSupport, selectedSingleNode])
|
||||
const activeSnapCandidate = useMemo(
|
||||
() =>
|
||||
selectedSingleNode
|
||||
@ -835,12 +818,22 @@ export function AssemblyWorkbenchV2({
|
||||
)}
|
||||
>
|
||||
<div className="h-full max-h-full overflow-y-auto p-4">
|
||||
{warnings.length > 0 || selectedNodeAutoSnapNotice ? (
|
||||
{warnings.length > 0 || selectedNodeAssistFeedback ? (
|
||||
<div className="mb-3 space-y-2">
|
||||
{selectedNodeAutoSnapNotice ? (
|
||||
<p className="rounded-2xl border border-amber-200/70 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
||||
{selectedNodeAutoSnapNotice}
|
||||
</p>
|
||||
{selectedNodeAssistFeedback ? (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border px-3 py-2',
|
||||
selectedNodeAssistFeedback.tone === 'warning'
|
||||
? 'border-amber-200/70 bg-amber-50 text-amber-700'
|
||||
: selectedNodeAssistFeedback.tone === 'success'
|
||||
? 'border-emerald-200/70 bg-emerald-50 text-emerald-700'
|
||||
: 'border-sky-200/70 bg-sky-50 text-sky-700',
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-medium">{selectedNodeAssistFeedback.title}</p>
|
||||
<p className="mt-1 text-xs leading-5">{selectedNodeAssistFeedback.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{warnings.map((warning) => (
|
||||
<p
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface PlatformSplitLayoutProps {
|
||||
/** 页面标题(显示在内容区顶部) */
|
||||
@ -13,6 +16,13 @@ interface PlatformSplitLayoutProps {
|
||||
/** 标题栏额外操作区 */
|
||||
headerActions?: ReactNode
|
||||
|
||||
/** 返回按钮目标路径,若不传则不显示返回按钮 */
|
||||
backHref?: string
|
||||
/** 返回按钮文案,默认为“返回” */
|
||||
backLabel?: string
|
||||
/** 点击返回的回调,若传了此项则优先执行此项而非跳转 backHref */
|
||||
onBack?: () => void
|
||||
|
||||
/** @deprecated 已由全局 AppShell 导航替代,静默忽略 */
|
||||
eyebrow?: string
|
||||
/** @deprecated 已由全局 AppShell 导航替代,静默忽略 */
|
||||
@ -20,6 +30,8 @@ interface PlatformSplitLayoutProps {
|
||||
|
||||
/** 左侧内容(3D 预览或统计面板) */
|
||||
leftContent: ReactNode
|
||||
/** 三栏布局时的中间主内容 */
|
||||
mainContent?: ReactNode
|
||||
/** 右侧面板内容 */
|
||||
rightContent?: ReactNode
|
||||
/** 右侧面板额外样式,可用于页面级宽度覆盖 */
|
||||
@ -36,38 +48,71 @@ export function PlatformSplitLayout({
|
||||
description,
|
||||
metrics,
|
||||
headerActions,
|
||||
// 旧 props 静默忽略
|
||||
eyebrow: _eyebrow,
|
||||
headerTools: _headerTools,
|
||||
backHref,
|
||||
backLabel = '返回',
|
||||
onBack,
|
||||
leftContent,
|
||||
mainContent,
|
||||
rightContent,
|
||||
rightPanelClassName,
|
||||
feedback,
|
||||
className,
|
||||
}: PlatformSplitLayoutProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (backHref) {
|
||||
router.push(backHref)
|
||||
}
|
||||
}
|
||||
|
||||
const showBackButton = !!backHref || !!onBack
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-[calc(100vh-56px)] flex-col overflow-hidden', className)}>
|
||||
{title ? (
|
||||
<header className="shrink-0 px-6 pt-5 pb-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1
|
||||
className="text-xl font-bold tracking-tight"
|
||||
style={{
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
color: 'var(--color-label-primary)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--color-label-secondary)' }}
|
||||
<header className="shrink-0 px-8 pt-6 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
className="mt-1 h-8 min-w-0 rounded-lg border border-apple-separator/10 bg-white/50 px-2.5 shadow-apple-xs transition-all hover:bg-white hover:shadow-apple-sm active:scale-95"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<ArrowLeft className="h-4 w-4 text-apple-label-tertiary" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{backLabel && showBackButton && (
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary opacity-40">
|
||||
{backLabel} /
|
||||
</span>
|
||||
)}
|
||||
<h1
|
||||
className="text-xl font-apple-black tracking-tight uppercase"
|
||||
style={{
|
||||
color: 'var(--color-label-primary)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
{description ? (
|
||||
<p
|
||||
className="text-sm font-apple-medium"
|
||||
style={{ color: 'var(--color-label-secondary)' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{headerActions ? (
|
||||
<div className="flex items-center gap-2">{headerActions}</div>
|
||||
@ -76,23 +121,23 @@ export function PlatformSplitLayout({
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
{/* Metrics, Feedback, and Main remain the same... */}
|
||||
{metrics && metrics.length > 0 ? (
|
||||
<div className="shrink-0 px-6 pb-2">
|
||||
<div className="shrink-0 px-8 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
{metrics.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs"
|
||||
className="flex items-center gap-1.5 rounded-xl border border-apple-separator/5 bg-white/40 px-3.5 py-1.5 text-[11px] font-apple-bold backdrop-blur-md shadow-apple-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-container)',
|
||||
color: 'var(--color-label-secondary)',
|
||||
}}
|
||||
>
|
||||
{m.icon}
|
||||
<span className="font-medium" style={{ color: 'var(--color-label-primary)' }}>
|
||||
<span className="font-apple-black uppercase tracking-tight" style={{ color: 'var(--color-label-primary)' }}>
|
||||
{m.value}
|
||||
</span>
|
||||
<span>{m.label}</span>
|
||||
<span className="opacity-60">{m.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -100,9 +145,9 @@ export function PlatformSplitLayout({
|
||||
) : null}
|
||||
|
||||
{feedback ? (
|
||||
<div className="shrink-0 px-6 pb-2">
|
||||
<div className="shrink-0 px-8 pb-3">
|
||||
<div
|
||||
className="rounded-lg border px-4 py-2.5 text-sm"
|
||||
className="rounded-2xl border px-5 py-3 text-sm font-apple-medium shadow-apple-sm"
|
||||
style={
|
||||
feedback.tone === 'success'
|
||||
? {
|
||||
@ -122,18 +167,33 @@ export function PlatformSplitLayout({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<main className="flex min-h-0 flex-1 overflow-hidden px-8 pb-6">
|
||||
<main className="flex min-h-0 flex-1 overflow-hidden px-8 pb-8">
|
||||
<section
|
||||
className="mx-auto flex min-h-0 w-full max-w-[1680px] flex-1 overflow-hidden rounded-lg"
|
||||
className="mx-auto flex min-h-0 w-full max-w-[1720px] flex-1 overflow-hidden rounded-[2rem] border border-white/60 shadow-apple-xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-container)',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 p-3">{leftContent}</div>
|
||||
{mainContent ? (
|
||||
<>
|
||||
<aside
|
||||
className="w-[280px] max-w-[22vw] shrink-0 overflow-y-auto border-r p-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-separator)',
|
||||
backgroundColor: 'var(--color-surface-container-lowest)',
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1 p-3">{mainContent}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 p-3">{leftContent}</div>
|
||||
)}
|
||||
{rightContent ? (
|
||||
<aside
|
||||
className={cn(
|
||||
'w-[var(--layout-right-panel-width)] max-w-[var(--layout-right-panel-max-width)] shrink-0 overflow-y-auto border-l p-4',
|
||||
'w-[var(--layout-right-panel-width)] max-w-[var(--layout-right-panel-max-width)] shrink-0 overflow-y-auto border-l p-0',
|
||||
rightPanelClassName,
|
||||
)}
|
||||
style={{
|
||||
@ -141,7 +201,7 @@ export function PlatformSplitLayout({
|
||||
backgroundColor: 'var(--color-surface-container-lowest)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">{rightContent}</div>
|
||||
<div className="flex flex-col h-full">{rightContent}</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@ -613,6 +613,26 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('选中进入孔位吸附范围的节点时,会在右侧显示实时吸附提示', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ControlledWorkbench
|
||||
initialWorkspace={buildHoleSnapWorkspace()}
|
||||
rightDrawerContent={<div>右侧业务区</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已进入吸附范围')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('桌腿A-001 已进入 桌面A-001 的孔位吸附范围,松手后会保持连接。'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('选中缺少安装图样语义的节点时,会明确提示无法自动吸附', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -626,6 +646,7 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('缺少安装图样语义')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前资产缺少安装图样语义,无法自动吸附。')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -67,10 +67,9 @@ export default function UserMenu({ user }: UserMenuProps) {
|
||||
const roleNames: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
ADMIN: '管理员',
|
||||
DEPARTMENT_MANAGER: '部门管理员',
|
||||
TEAM_LEADER: '组长',
|
||||
SUPERVISOR: '主管',
|
||||
DESIGNER: '设计师',
|
||||
VIEWER: '查看者',
|
||||
USER: '普通用户',
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -31,7 +31,7 @@ export function CreateUserModal({ isOpen, onClose, onSuccess }: CreateUserModalP
|
||||
name: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'DESIGNER',
|
||||
role: 'USER',
|
||||
phone: '',
|
||||
departmentId: '',
|
||||
teamId: '',
|
||||
@ -144,11 +144,11 @@ export function CreateUserModal({ isOpen, onClose, onSuccess }: CreateUserModalP
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-[10px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em] border-b border-apple-separator/10 pb-2">权限与归属</h4>
|
||||
<Select label="系统角色" value={formData.role} onChange={(e) => setFormData({ ...formData, role: e.target.value })} options={[
|
||||
{ value: 'USER', label: '普通用户(只看 / 下载)' },
|
||||
{ value: 'DESIGNER', label: '设计师' },
|
||||
{ value: 'VIEWER', label: '查看者' },
|
||||
{ value: 'TEAM_LEADER', label: '组长' },
|
||||
{ value: 'DEPARTMENT_MANAGER', label: '部门管理员' },
|
||||
{ value: 'SUPERVISOR', label: '主管' },
|
||||
{ value: 'ADMIN', label: '管理员' },
|
||||
{ value: 'SUPER_ADMIN', label: '超级管理员' },
|
||||
]} required className="bg-white/50" />
|
||||
<Select label="所属部门" value={formData.departmentId} onChange={(e) => setFormData({ ...formData, departmentId: e.target.value, teamId: '' })} options={[
|
||||
{ value: '', label: '未分配部门' },
|
||||
@ -162,6 +162,9 @@ export function CreateUserModal({ isOpen, onClose, onSuccess }: CreateUserModalP
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.4)]" />
|
||||
<h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">安全信息</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-apple-label-secondary">
|
||||
新账号会先进入“待激活”状态,需要后台审核通过后才能正式登录。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input label="登录密码" type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="至少6位" required errorText={errors.password} className="bg-white" />
|
||||
<Input label="确认密码" type="password" value={formData.confirmPassword} onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })} placeholder="再次输入以核对" required errorText={errors.confirmPassword} className="bg-white" />
|
||||
|
||||
@ -168,7 +168,7 @@ function TeamNodeView({
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] text-apple-label-secondary">
|
||||
<span className="inline-flex items-center gap-1 rounded-apple-sm bg-apple-blue/5 px-2 py-1 font-apple-medium text-apple-blue">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
组长
|
||||
主管
|
||||
</span>
|
||||
<span className="font-apple-bold text-apple-label-primary">{team.leader?.name || '未设置'}</span>
|
||||
{team.description ? <span className="truncate text-apple-label-tertiary">{team.description}</span> : null}
|
||||
@ -266,7 +266,7 @@ function DepartmentNodeView({
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] text-apple-label-secondary">
|
||||
<div className="inline-flex items-center gap-1 rounded-apple-sm bg-apple-blue/5 px-2 py-1 font-apple-medium text-apple-blue">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
<span>部门管理员</span>
|
||||
<span>主管</span>
|
||||
</div>
|
||||
<span className="font-apple-bold text-apple-label-primary">
|
||||
{department.manager?.name || '未设置'}
|
||||
|
||||
@ -250,10 +250,9 @@ export function EditUserModal({ isOpen, user, onClose, onSuccess }: EditUserModa
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
options={[
|
||||
{ value: 'USER', label: '普通用户(只看 / 下载)' },
|
||||
{ value: 'DESIGNER', label: '设计师' },
|
||||
{ value: 'VIEWER', label: '查看者' },
|
||||
{ value: 'TEAM_LEADER', label: '组长' },
|
||||
{ value: 'DEPARTMENT_MANAGER', label: '部门管理员' },
|
||||
{ value: 'SUPERVISOR', label: '主管' },
|
||||
{ value: 'ADMIN', label: '管理员' },
|
||||
...(user.role === 'SUPER_ADMIN' ? [{ value: 'SUPER_ADMIN', label: '超级管理员' }] : []),
|
||||
]}
|
||||
|
||||
@ -83,7 +83,7 @@ describe('DepartmentAccessTree', () => {
|
||||
id: 'user-free',
|
||||
name: '未分配成员',
|
||||
email: 'free@example.com',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'PENDING',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
|
||||
@ -13,6 +13,14 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import type { ApiResponse } from '@/lib/api';
|
||||
import {
|
||||
canCreateContent,
|
||||
hasAllowedRole,
|
||||
isAdmin as isAdminRole,
|
||||
isDepartmentSupervisor,
|
||||
isTeamSupervisor,
|
||||
type AllowedRole,
|
||||
} from '@/lib/role-scope';
|
||||
|
||||
/**
|
||||
* 用户信息接口
|
||||
@ -228,37 +236,37 @@ export function usePermissions() {
|
||||
/**
|
||||
* 检查是否有指定角色
|
||||
*/
|
||||
const hasRole = (roles: Role[]): boolean => {
|
||||
const hasRole = (roles: AllowedRole[]): boolean => {
|
||||
if (!user) return false;
|
||||
return roles.includes(user.role);
|
||||
return hasAllowedRole(user, roles);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为管理员
|
||||
*/
|
||||
const isAdmin = (): boolean => {
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN']);
|
||||
return Boolean(user && isAdminRole(user));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为部门管理员或更高
|
||||
*/
|
||||
const isDepartmentManagerOrAbove = (): boolean => {
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']);
|
||||
return Boolean(user && (isAdminRole(user) || isDepartmentSupervisor(user)));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为组长或更高
|
||||
*/
|
||||
const isTeamLeaderOrAbove = (): boolean => {
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']);
|
||||
return Boolean(user && (isAdminRole(user) || isDepartmentSupervisor(user) || isTeamSupervisor(user)));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否可以管理用户
|
||||
*/
|
||||
const canManageUsers = (): boolean => {
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']);
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -266,7 +274,7 @@ export function usePermissions() {
|
||||
*/
|
||||
const canCreateProduct = (): boolean => {
|
||||
if (!user) return false;
|
||||
return user.role !== 'VIEWER';
|
||||
return canCreateContent(user);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -280,14 +288,14 @@ export function usePermissions() {
|
||||
* 检查是否可以查看操作日志
|
||||
*/
|
||||
const canViewActivityLogs = (): boolean => {
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']);
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否可以导出数据
|
||||
*/
|
||||
const canExportData = (): boolean => {
|
||||
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']);
|
||||
return Boolean(user);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -8,6 +8,8 @@ import { extractTokenFromHeader, TokenPayload } from './jwt';
|
||||
import { Role } from '@prisma/client';
|
||||
import { getUserInfo, UserInfo } from './permissions';
|
||||
import { validateToken } from './token-validator';
|
||||
import type { AllowedRole } from './role-scope';
|
||||
import { hasAllowedRole } from './role-scope';
|
||||
|
||||
/**
|
||||
* 认证结果
|
||||
@ -86,29 +88,29 @@ export async function authenticateRequest(request: NextRequest): Promise<AuthRes
|
||||
/**
|
||||
* 检查用户是否有指定角色
|
||||
*/
|
||||
export function hasRole(payload: TokenPayload, allowedRoles: Role[]): boolean {
|
||||
return allowedRoles.includes(payload.role);
|
||||
export function hasRole(userInfo: UserInfo, allowedRoles: readonly AllowedRole[]): boolean {
|
||||
return hasAllowedRole(userInfo, allowedRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否为管理员(超级管理员或管理员)
|
||||
*/
|
||||
export function isAdmin(payload: TokenPayload): boolean {
|
||||
return hasRole(payload, ['SUPER_ADMIN', 'ADMIN']);
|
||||
return ['SUPER_ADMIN', 'ADMIN'].includes(payload.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否为部门管理员或更高权限
|
||||
*/
|
||||
export function isDepartmentManagerOrAbove(payload: TokenPayload): boolean {
|
||||
return hasRole(payload, ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']);
|
||||
return ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'].includes(payload.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否为组长或更高权限
|
||||
*/
|
||||
export function isTeamLeaderOrAbove(payload: TokenPayload): boolean {
|
||||
return hasRole(payload, ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']);
|
||||
return ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'].includes(payload.role);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,7 +140,7 @@ export function forbiddenResponse(message: string = '权限不足'): NextRespons
|
||||
export function withAuth(
|
||||
handler: (request: NextRequest, payload: TokenPayload, userInfo: UserInfo) => Promise<NextResponse>,
|
||||
options?: {
|
||||
allowedRoles?: Role[];
|
||||
allowedRoles?: AllowedRole[];
|
||||
}
|
||||
) {
|
||||
return async (request: NextRequest): Promise<NextResponse> => {
|
||||
@ -150,7 +152,7 @@ export function withAuth(
|
||||
}
|
||||
|
||||
// 检查角色权限
|
||||
if (options?.allowedRoles && !hasRole(authResult.payload, options.allowedRoles)) {
|
||||
if (options?.allowedRoles && !hasRole(authResult.userInfo, options.allowedRoles)) {
|
||||
return forbiddenResponse('您没有权限访问此资源');
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
applyAutomaticAssemblySnapPreview,
|
||||
attemptSnapNode,
|
||||
deriveAssemblyAssistFeedback,
|
||||
buildManualAssemblyWorkspace,
|
||||
deriveAssemblyActiveSnapCandidate,
|
||||
deriveAssemblyAutoSnapCandidate,
|
||||
@ -273,135 +274,77 @@ describe('assembly-workspace', () => {
|
||||
expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED')
|
||||
})
|
||||
|
||||
it('拖到目标位置附近时,会把待装区节点自动吸附进主装配区', () => {
|
||||
it('拖到兼容孔位附近并松手后,会完成自动吸附', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: {
|
||||
id: 'variant-desktop',
|
||||
assetFamilyId: 'family-desktop',
|
||||
code: 'BOARD-A-001',
|
||||
name: '桌面A',
|
||||
assetType: 'BOARD',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: { anchor: 'desktop-center' },
|
||||
},
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
id: 'variant-leg',
|
||||
assetFamilyId: 'family-leg',
|
||||
code: 'LEG-A-001',
|
||||
name: '桌腿A',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { attachTo: 'desktop' },
|
||||
},
|
||||
})
|
||||
|
||||
const legNode = withLeg.nodes[1]!
|
||||
const moved = updateAssemblyNodeTransform(withLeg, legNode.nodeId, {
|
||||
position: legNode.targetPosition!,
|
||||
})
|
||||
const snapped = attemptSnapNode(moved, legNode.nodeId)
|
||||
|
||||
expect(snapped.nodes[1]?.zone).toBe('main')
|
||||
expect(snapped.nodes[1]?.isSnapped).toBe(true)
|
||||
expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED')
|
||||
})
|
||||
|
||||
it('拖拽过程中进入吸附范围时,会实时吸附并进入主装配区', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: {
|
||||
id: 'variant-desktop',
|
||||
assetFamilyId: 'family-desktop',
|
||||
code: 'BOARD-A-001',
|
||||
name: '桌面A',
|
||||
assetType: 'BOARD',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: { anchor: 'desktop-center' },
|
||||
},
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
id: 'variant-leg',
|
||||
assetFamilyId: 'family-leg',
|
||||
code: 'LEG-A-001',
|
||||
name: '桌腿A',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { attachTo: 'desktop' },
|
||||
compatibilityRules: { allowedSlots: ['frontLeftLeg'] },
|
||||
},
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: withLeg.nodes[1]!.targetPosition!,
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
const snapped = attemptSnapNode(moved, withLeg.nodes[1]!.nodeId)
|
||||
|
||||
expect(snapped.nodes[1]?.zone).toBe('main')
|
||||
expect(snapped.nodes[1]?.isSnapped).toBe(true)
|
||||
expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap')
|
||||
expect(snapped.nodes[1]?.snapBinding).toMatchObject({
|
||||
targetNodeId: withDesktop.nodes[0]?.nodeId,
|
||||
sourceHoleId: 'leg-hole-1',
|
||||
targetHoleId: 'board-hole-1',
|
||||
sourceMountPatternId: 'leg-pattern-1',
|
||||
targetMountPatternId: 'board-pattern-1',
|
||||
})
|
||||
expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED')
|
||||
})
|
||||
|
||||
it('拖拽过程中进入孔位吸附范围时,会实时预吸附并给出提示', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
const snappedPreview = applyAutomaticAssemblySnapPreview(moved, withLeg.nodes[1]!.nodeId, {
|
||||
fallbackZone: 'staging',
|
||||
})
|
||||
const feedback = deriveAssemblyAssistFeedback(snappedPreview, snappedPreview.nodes[1]!.nodeId)
|
||||
|
||||
expect(snappedPreview.nodes[1]?.zone).toBe('main')
|
||||
expect(snappedPreview.nodes[1]?.isSnapped).toBe(true)
|
||||
expect(snappedPreview.nodes[1]?.position).toEqual(snappedPreview.nodes[1]?.targetPosition)
|
||||
expect(snappedPreview.nodes[1]?.isSnapped).toBe(false)
|
||||
expect(snappedPreview.nodes[1]?.snapStatus).toBe('hole-preview')
|
||||
expect(snappedPreview.nodes[1]?.snapBinding).toMatchObject({
|
||||
targetNodeId: withDesktop.nodes[0]?.nodeId,
|
||||
sourceHoleId: 'leg-hole-1',
|
||||
targetHoleId: 'board-hole-1',
|
||||
})
|
||||
expect(feedback?.title).toBe('已进入吸附范围')
|
||||
expect(feedback?.withinSnapRange).toBe(true)
|
||||
})
|
||||
|
||||
it('拖拽离开吸附范围时,会解除实时吸附并回到自由拖动', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: {
|
||||
id: 'variant-desktop',
|
||||
assetFamilyId: 'family-desktop',
|
||||
code: 'BOARD-A-001',
|
||||
name: '桌面A',
|
||||
assetType: 'BOARD',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: { anchor: 'desktop-center' },
|
||||
},
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
id: 'variant-leg',
|
||||
assetFamilyId: 'family-leg',
|
||||
code: 'LEG-A-001',
|
||||
name: '桌腿A',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { attachTo: 'desktop' },
|
||||
},
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
const snapped = applyAutomaticAssemblySnapPreview(
|
||||
updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: withLeg.nodes[1]!.targetPosition!,
|
||||
position: [0, 720, 0],
|
||||
}),
|
||||
withLeg.nodes[1]!.nodeId,
|
||||
{ fallbackZone: 'staging' },
|
||||
@ -416,6 +359,36 @@ describe('assembly-workspace', () => {
|
||||
|
||||
expect(released.nodes[1]?.zone).toBe('staging')
|
||||
expect(released.nodes[1]?.isSnapped).toBe(false)
|
||||
expect(released.nodes[1]?.snapBinding ?? null).toBeNull()
|
||||
expect(released.nodes[1]?.snapStatus ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('缺少孔位和安装图样语义的组件不会再回退到旧的中心点吸附', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
...legVariant,
|
||||
id: 'variant-leg-no-hole',
|
||||
assemblyBasis: { attachTo: 'desktop' },
|
||||
},
|
||||
})
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
|
||||
expect(deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)).toBeNull()
|
||||
expect(attemptSnapNode(moved, moved.nodes[1]!.nodeId)).toEqual(moved)
|
||||
|
||||
const feedback = deriveAssemblyAssistFeedback(moved, moved.nodes[1]!.nodeId)
|
||||
expect(feedback).toMatchObject({
|
||||
title: '缺少孔位与安装图样语义',
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('主装配区出现两个桌面板时会阻止保存', () => {
|
||||
|
||||
@ -108,6 +108,20 @@ export interface AssemblyValidationResult {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type AssemblyAssistFeedbackTone = 'info' | 'success' | 'warning'
|
||||
|
||||
export interface AssemblyAssistFeedback {
|
||||
nodeId: string
|
||||
title: string
|
||||
description: string
|
||||
tone: AssemblyAssistFeedbackTone
|
||||
targetNodeId: string | null
|
||||
targetLabel: string | null
|
||||
canAutoSnap: boolean
|
||||
withinSnapRange: boolean
|
||||
remainingDistanceMm: number | null
|
||||
}
|
||||
|
||||
export interface AssemblyAutoSnapCandidate {
|
||||
sourceNodeId: string
|
||||
targetNodeId: string
|
||||
@ -553,6 +567,14 @@ function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) {
|
||||
return getAssemblyAutoSnapSupportStatus(node).canAutoSnap
|
||||
}
|
||||
|
||||
function getHoleSnapThreshold(
|
||||
sourceHole: AssemblyBasisHoleRecord,
|
||||
targetHole: AssemblyBasisHoleRecord,
|
||||
) {
|
||||
const diameter = Math.max(sourceHole.diameter ?? 0, targetHole.diameter ?? 0)
|
||||
return Math.max(32, Math.min(HOLE_SNAP_DISTANCE_THRESHOLD_MM, diameter > 0 ? diameter * 6 : 72))
|
||||
}
|
||||
|
||||
function areHoleDiametersCompatible(
|
||||
sourceHole: AssemblyBasisHoleRecord,
|
||||
targetHole: AssemblyBasisHoleRecord,
|
||||
@ -698,6 +720,159 @@ export function deriveAssemblyActiveSnapCandidate(
|
||||
return buildSnapBindingDisplayCandidate(snapshot, sourceNode)
|
||||
}
|
||||
|
||||
export function deriveAssemblyAssistFeedback(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
): AssemblyAssistFeedback | null {
|
||||
const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
|
||||
if (!targetNode || targetNode.source === 'desktop') {
|
||||
return null
|
||||
}
|
||||
|
||||
const primaryAnchorNode = resolvePrimaryAnchorNode(snapshot.nodes)
|
||||
const targetLabel = primaryAnchorNode?.name ?? null
|
||||
|
||||
if (targetNode.zone === 'main' && targetNode.snapStatus === 'hole-snap') {
|
||||
return {
|
||||
nodeId,
|
||||
title: '已自动吸附',
|
||||
description: targetLabel
|
||||
? `${targetNode.name} 已连接到 ${targetLabel}。`
|
||||
: `${targetNode.name} 已进入主装配区并完成连接。`,
|
||||
tone: 'success',
|
||||
targetNodeId: primaryAnchorNode?.nodeId ?? null,
|
||||
targetLabel,
|
||||
canAutoSnap: true,
|
||||
withinSnapRange: true,
|
||||
remainingDistanceMm: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (!primaryAnchorNode) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '还不能吸附',
|
||||
description: '先放一个桌面板作为主锚点,再拖动组件或五金靠近兼容孔位。',
|
||||
tone: 'warning',
|
||||
targetNodeId: null,
|
||||
targetLabel: null,
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasNodeAssemblyRule(targetNode)) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '缺少装配规则',
|
||||
description: '当前资产缺少装配规则,只能自由摆放。',
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
const snapSupport = getAssemblyAutoSnapSupportStatus(targetNode)
|
||||
if (!snapSupport.canAutoSnap) {
|
||||
if (snapSupport.missingMountPatterns && snapSupport.missingHoles) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '缺少孔位与安装图样语义',
|
||||
description: '当前资产缺少孔位与安装图样语义,无法自动吸附。',
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (snapSupport.missingMountPatterns) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '缺少安装图样语义',
|
||||
description: '当前资产缺少安装图样语义,无法自动吸附。',
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
title: '缺少孔位语义',
|
||||
description: '当前资产缺少孔位语义,无法自动吸附。',
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesPrimaryAnchorRole(targetNode, primaryAnchorNode)) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '当前目标不兼容',
|
||||
description: `${targetNode.name} 不能装到 ${primaryAnchorNode.name},请换兼容件或确认装配规则。`,
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
const liveCandidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
|
||||
const activeCandidate = liveCandidate ?? deriveAssemblyActiveSnapCandidate(snapshot, nodeId)
|
||||
|
||||
if (!activeCandidate) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '拖近兼容孔位即可吸附',
|
||||
description: `${targetNode.name} 需要和 ${primaryAnchorNode.name} 的兼容孔位对齐后,才会自动吸附。`,
|
||||
tone: 'info',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
canAutoSnap: true,
|
||||
withinSnapRange: false,
|
||||
remainingDistanceMm: null,
|
||||
}
|
||||
}
|
||||
|
||||
const candidateTargetNode =
|
||||
snapshot.nodes.find((node) => node.nodeId === activeCandidate.targetNodeId) ?? primaryAnchorNode
|
||||
const candidateTargetLabel = candidateTargetNode?.name ?? primaryAnchorNode.name
|
||||
const remainingDistanceMm = liveCandidate
|
||||
? Math.max(Math.ceil(liveCandidate.distanceMm - HOLE_SNAP_DISTANCE_THRESHOLD_MM), 0)
|
||||
: 0
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
title: '已进入吸附范围',
|
||||
description:
|
||||
remainingDistanceMm > 0
|
||||
? `${targetNode.name} 再靠近 ${candidateTargetLabel} 的兼容孔位约 ${remainingDistanceMm} mm,就会自动对位。`
|
||||
: `${targetNode.name} 已进入 ${candidateTargetLabel} 的孔位吸附范围,松手后会保持连接。`,
|
||||
tone: remainingDistanceMm > 0 ? 'info' : 'success',
|
||||
targetNodeId: activeCandidate.targetNodeId,
|
||||
targetLabel: candidateTargetLabel,
|
||||
canAutoSnap: true,
|
||||
withinSnapRange: remainingDistanceMm === 0,
|
||||
remainingDistanceMm,
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveAssemblyAutoSnapCandidate(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
@ -764,6 +939,10 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
if (anchorNormalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) {
|
||||
return
|
||||
}
|
||||
const anchorDistanceMm = anchorSourceWorld.distanceTo(anchorTargetWorld)
|
||||
if (anchorDistanceMm > getHoleSnapThreshold(anchorSourceHole, anchorTargetHole)) {
|
||||
return
|
||||
}
|
||||
|
||||
const translation = anchorTargetWorld.clone().sub(anchorSourceWorld)
|
||||
const matchedPairs: AssemblyMatchedHolePair[] = []
|
||||
@ -785,6 +964,12 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
|
||||
const translatedSourceWorld = getNodeWorldPoint(sourceNode, sourceHole.position).add(translation)
|
||||
const targetHoleWorld = getNodeWorldPoint(targetNode, targetHole.position)
|
||||
const currentDistanceMm = getNodeWorldPoint(sourceNode, sourceHole.position).distanceTo(
|
||||
targetHoleWorld,
|
||||
)
|
||||
if (currentDistanceMm > getHoleSnapThreshold(sourceHole, targetHole)) {
|
||||
return
|
||||
}
|
||||
const sourceHoleNormal = getNodeWorldDirection(sourceNode, sourceHole.normal)
|
||||
const targetHoleNormal = getNodeWorldDirection(targetNode, targetHole.normal)
|
||||
const normalDot = sourceHoleNormal.dot(targetHoleNormal)
|
||||
@ -793,10 +978,6 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
}
|
||||
|
||||
const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld)
|
||||
if (distanceMm > HOLE_SNAP_DISTANCE_THRESHOLD_MM) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidatePair: AssemblyMatchedHolePair = {
|
||||
sourceHole,
|
||||
targetHole,
|
||||
@ -804,7 +985,7 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
targetHoleWorldPosition: tupleFromVector(targetHoleWorld),
|
||||
sourceHoleNormal: tupleFromVector(sourceHoleNormal),
|
||||
targetHoleNormal: tupleFromVector(targetHoleNormal),
|
||||
distanceMm,
|
||||
distanceMm: currentDistanceMm,
|
||||
sourceFace: facePair.sourceFace,
|
||||
targetFace: facePair.targetFace,
|
||||
}
|
||||
@ -1142,21 +1323,6 @@ function matchesPrimaryAnchorRole(
|
||||
return true
|
||||
}
|
||||
|
||||
function canAutoSnapNodeToPrimaryAnchor(
|
||||
node: AssemblyWorkspaceNode,
|
||||
primaryAnchorNode: AssemblyWorkspaceNode | null,
|
||||
) {
|
||||
if (!primaryAnchorNode || node.source === 'desktop') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!node.targetPosition || !hasNodeAssemblyRule(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return matchesPrimaryAnchorRole(node, primaryAnchorNode)
|
||||
}
|
||||
|
||||
function rebuildRelations(nodes: AssemblyWorkspaceNode[]): AssemblyRelation[] {
|
||||
const mainDesktop = nodes.find((node) => node.zone === 'main' && node.source === 'desktop') ?? null
|
||||
|
||||
@ -1704,22 +1870,6 @@ export function updateAssemblyNodePosition(
|
||||
return updateAssemblyNodeTransform(snapshot, nodeId, { position })
|
||||
}
|
||||
|
||||
function isNodeCloseEnoughToSnap(node: AssemblyWorkspaceNode) {
|
||||
if (!node.targetPosition) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.source === 'desktop') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.mountPoints.length === 0 && node.source !== 'connector') {
|
||||
return false
|
||||
}
|
||||
|
||||
return distanceBetween(node.position, node.targetPosition) <= SNAP_DISTANCE_THRESHOLD
|
||||
}
|
||||
|
||||
export function snapNodesIntoMainAssembly(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeIds: string[],
|
||||
@ -1762,35 +1912,26 @@ export function attemptSnapNode(
|
||||
}
|
||||
|
||||
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
|
||||
if (candidate?.isValid) {
|
||||
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
zone: 'main',
|
||||
position: cloneVector3(candidate.previewPosition),
|
||||
rotation: cloneVector3(candidate.previewRotation),
|
||||
isSnapped: true,
|
||||
snapStatus: 'hole-snap',
|
||||
snapBinding: buildSnapBindingFromCandidate(candidate),
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
|
||||
}
|
||||
|
||||
if (!currentNode || !isNodeCloseEnoughToSnap(currentNode)) {
|
||||
if (!candidate?.isValid) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const readyForSnap =
|
||||
currentNode.zone === 'main'
|
||||
? snapshot
|
||||
: moveAssemblyNodes(snapshot, [nodeId], 'main')
|
||||
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
zone: 'main',
|
||||
position: cloneVector3(candidate.previewPosition),
|
||||
rotation: cloneVector3(candidate.previewRotation),
|
||||
isSnapped: true,
|
||||
snapStatus: 'hole-snap',
|
||||
snapBinding: buildSnapBindingFromCandidate(candidate),
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
return snapNodesIntoMainAssembly(readyForSnap, [nodeId])
|
||||
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
|
||||
}
|
||||
|
||||
export function applyAutomaticAssemblySnapPreview(
|
||||
@ -1826,19 +1967,6 @@ export function applyAutomaticAssemblySnapPreview(
|
||||
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
|
||||
}
|
||||
|
||||
const primaryAnchorNode = resolvePrimaryAnchorNode(snapshot.nodes)
|
||||
const shouldSnap =
|
||||
canAutoSnapNodeToPrimaryAnchor(targetNode, primaryAnchorNode) &&
|
||||
isNodeCloseEnoughToSnap(targetNode)
|
||||
|
||||
if (shouldSnap) {
|
||||
const movedToMain =
|
||||
targetNode.zone === 'main'
|
||||
? snapshot
|
||||
: moveAssemblyNodes(snapshot, [nodeId], 'main')
|
||||
return snapNodesIntoMainAssembly(movedToMain, [nodeId])
|
||||
}
|
||||
|
||||
if (targetNode.zone === fallbackZone && !targetNode.isSnapped) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@ -5,8 +5,7 @@ export function canUserReviewAsset(user: Pick<AuthUser, 'role'>): boolean {
|
||||
return (
|
||||
user.role === 'SUPER_ADMIN' ||
|
||||
user.role === 'ADMIN' ||
|
||||
user.role === 'DEPARTMENT_MANAGER' ||
|
||||
user.role === 'TEAM_LEADER'
|
||||
user.role === 'SUPERVISOR'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,8 +10,8 @@ export interface SeedUserCandidate
|
||||
const REVIEWER_ROLES = new Set<AuthUser['role']>([
|
||||
'SUPER_ADMIN',
|
||||
'ADMIN',
|
||||
'DEPARTMENT_MANAGER',
|
||||
'TEAM_LEADER',
|
||||
'SUPERVISOR',
|
||||
'SUPERVISOR',
|
||||
])
|
||||
|
||||
function isActiveUser(user: SeedUserCandidate): boolean {
|
||||
|
||||
@ -3,96 +3,118 @@
|
||||
* 实现细粒度的权限控制逻辑
|
||||
*/
|
||||
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import { departmentRepository } from '@/server/repositories/department.repository';
|
||||
import { userRepository } from '@/server/repositories/user.repository';
|
||||
import { Role, UserStatus } from '@prisma/client'
|
||||
import { departmentRepository } from '@/server/repositories/department.repository'
|
||||
import { userRepository } from '@/server/repositories/user.repository'
|
||||
import {
|
||||
canCreateContent,
|
||||
getManagedDepartmentId,
|
||||
getManagedTeamId,
|
||||
isAdmin,
|
||||
isDepartmentSupervisor,
|
||||
isReadOnlyUser,
|
||||
isSupervisor,
|
||||
isTeamSupervisor,
|
||||
} from '@/lib/role-scope'
|
||||
|
||||
/**
|
||||
* 用户信息接口(从 Token 或数据库获取)
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
status: UserStatus;
|
||||
departmentId?: string;
|
||||
teamId?: string;
|
||||
managedDepartmentId?: string;
|
||||
managedTeamId?: string;
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
status: UserStatus
|
||||
departmentId?: string
|
||||
teamId?: string
|
||||
managedDepartmentId?: string
|
||||
managedTeamId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 协作者信息
|
||||
*/
|
||||
export interface CollaboratorUser {
|
||||
userId: string;
|
||||
role: 'editor' | 'viewer';
|
||||
userId: string
|
||||
role: 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
export interface ProductCreatorContext {
|
||||
departmentId?: string;
|
||||
teamId?: string;
|
||||
departmentId?: string
|
||||
teamId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品信息接口
|
||||
*/
|
||||
export interface ProductInfo {
|
||||
id: string;
|
||||
createdBy: string;
|
||||
departmentId?: string;
|
||||
collaboratorUsers?: unknown;
|
||||
id: string
|
||||
createdBy: string
|
||||
departmentId?: string
|
||||
collaboratorUsers?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* SKU 信息接口
|
||||
*/
|
||||
export interface SKUInfo {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
productId: string;
|
||||
id: string
|
||||
ownerId: string
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈信息接口
|
||||
*/
|
||||
export interface FeedbackInfo {
|
||||
id: string;
|
||||
assignedTo?: string;
|
||||
productId?: string;
|
||||
skuId?: string;
|
||||
id: string
|
||||
assignedTo?: string
|
||||
productId?: string
|
||||
skuId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 小组信息接口
|
||||
*/
|
||||
export interface TeamInfo {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
id: string
|
||||
departmentId: string
|
||||
}
|
||||
|
||||
export function normalizeCollaboratorUsers(raw: unknown): CollaboratorUser[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
|
||||
.map((item) => {
|
||||
const userId = typeof item.userId === 'string' ? item.userId.trim() : '';
|
||||
if (!userId) return null;
|
||||
const userId = typeof item.userId === 'string' ? item.userId.trim() : ''
|
||||
if (!userId) return null
|
||||
|
||||
return {
|
||||
userId,
|
||||
role: item.role === 'viewer' ? 'viewer' : 'editor',
|
||||
} satisfies CollaboratorUser;
|
||||
} satisfies CollaboratorUser
|
||||
})
|
||||
.filter((item): item is CollaboratorUser => item !== null);
|
||||
.filter((item): item is CollaboratorUser => item !== null)
|
||||
}
|
||||
|
||||
function getCollaboratorUsers(raw: unknown): CollaboratorUser[] {
|
||||
return normalizeCollaboratorUsers(raw)
|
||||
}
|
||||
|
||||
function isWithinManagedDepartment(user: UserInfo, targetDepartmentId?: string): boolean {
|
||||
const managedDepartmentId = getManagedDepartmentId(user)
|
||||
if (!managedDepartmentId || !targetDepartmentId) return false
|
||||
return managedDepartmentId === targetDepartmentId
|
||||
}
|
||||
|
||||
function isWithinManagedTeam(user: UserInfo, targetTeamId?: string): boolean {
|
||||
const managedTeamId = getManagedTeamId(user)
|
||||
if (!managedTeamId || !targetTeamId) return false
|
||||
return managedTeamId === targetTeamId
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 用户管理权限
|
||||
// ============================================
|
||||
@ -101,47 +123,53 @@ function getCollaboratorUsers(raw: unknown): CollaboratorUser[] {
|
||||
* 检查是否可以管理用户
|
||||
*/
|
||||
export function canManageUser(operator: UserInfo, targetUser: UserInfo): boolean {
|
||||
if (operator.id === targetUser.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. 超级管理员可以管理所有用户
|
||||
if (operator.role === 'SUPER_ADMIN') return true;
|
||||
if (operator.role === 'SUPER_ADMIN') return true
|
||||
|
||||
// 2. 管理员可以管理除超级管理员外的所有用户
|
||||
if (operator.role === 'ADMIN') {
|
||||
return targetUser.role !== 'SUPER_ADMIN';
|
||||
return targetUser.role !== 'SUPER_ADMIN'
|
||||
}
|
||||
|
||||
// 3. 部门管理员可以管理本部门的用户
|
||||
if (operator.role === 'DEPARTMENT_MANAGER' && operator.departmentId) {
|
||||
return targetUser.departmentId === operator.departmentId;
|
||||
// 3. 部门主管可以管理本部门用户,但不能碰管理员链路
|
||||
if (isDepartmentSupervisor(operator) && targetUser.role !== 'SUPER_ADMIN' && targetUser.role !== 'ADMIN') {
|
||||
return isWithinManagedDepartment(operator, targetUser.departmentId)
|
||||
}
|
||||
|
||||
return false;
|
||||
// 4. 小组主管只能管理本组用户,且不能碰管理员链路
|
||||
if (isTeamSupervisor(operator) && targetUser.role !== 'SUPER_ADMIN' && targetUser.role !== 'ADMIN') {
|
||||
return isWithinManagedTeam(operator, targetUser.teamId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以创建用户
|
||||
*/
|
||||
export function canCreateUser(operator: UserInfo): boolean {
|
||||
return ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(operator.role);
|
||||
return isAdmin(operator) || isDepartmentSupervisor(operator) || isTeamSupervisor(operator)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以删除用户
|
||||
*/
|
||||
export function canDeleteUser(operator: UserInfo, targetUser: UserInfo): boolean {
|
||||
// 不能删除自己
|
||||
if (operator.id === targetUser.id) return false;
|
||||
if (operator.id === targetUser.id) return false
|
||||
if (targetUser.role === 'SUPER_ADMIN') return false
|
||||
|
||||
// 不能删除超级管理员
|
||||
if (targetUser.role === 'SUPER_ADMIN') return false;
|
||||
|
||||
return canManageUser(operator, targetUser);
|
||||
return canManageUser(operator, targetUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以激活用户
|
||||
*/
|
||||
export function canActivateUser(operator: UserInfo): boolean {
|
||||
return operator.role === 'SUPER_ADMIN' || operator.role === 'ADMIN';
|
||||
return operator.role === 'SUPER_ADMIN' || operator.role === 'ADMIN'
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -152,8 +180,7 @@ export function canActivateUser(operator: UserInfo): boolean {
|
||||
* 检查是否可以创建产品
|
||||
*/
|
||||
export function canCreateProduct(user: UserInfo): boolean {
|
||||
// 查看者不能创建产品
|
||||
return user.role !== 'VIEWER';
|
||||
return canCreateContent(user)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,122 +189,54 @@ export function canCreateProduct(user: UserInfo): boolean {
|
||||
export function canEditProduct(user: UserInfo, product: ProductInfo): boolean {
|
||||
const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers)
|
||||
|
||||
// 查看者不能编辑任何产品
|
||||
if (user.role === 'VIEWER') return false;
|
||||
if (isReadOnlyUser(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
// 1. 超级管理员可编辑所有产品
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
// 2. 管理员可编辑所有产品
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 部门管理员可编辑本部门的产品
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (product.departmentId === user.managedDepartmentId) {
|
||||
return true;
|
||||
}
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. 创建者可编辑
|
||||
if (product.createdBy === user.id) return true;
|
||||
if (product.createdBy === user.id) return true
|
||||
|
||||
// 5. 协作者可编辑
|
||||
if (collaboratorUsers.some((c) => c.userId === user.id && c.role === 'editor')) {
|
||||
return true;
|
||||
if (collaboratorUsers.some((collaborator) => collaborator.userId === user.id && collaborator.role === 'editor')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 6. 组长可编辑本组成员创建的产品
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
// 需要查询创建者的 teamId,这里返回 false,在实际使用时需要查询数据库
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以删除产品
|
||||
*/
|
||||
export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean {
|
||||
// 查看者不能删除产品
|
||||
if (user.role === 'VIEWER') return false;
|
||||
if (isReadOnlyUser(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
// 1. 超级管理员可删除所有产品
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
// 2. 管理员可删除所有产品
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 部门管理员可删除本部门的产品
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (product.departmentId === user.managedDepartmentId) {
|
||||
return true;
|
||||
}
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. 创建者可以删除自己的产品
|
||||
if (product.createdBy === user.id) return true;
|
||||
|
||||
// 5. 组长可删除本组成员创建的产品
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
// 需要查询创建者的 teamId
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
return product.createdBy === user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以添加协作者
|
||||
*/
|
||||
export function canAddCollaborator(user: UserInfo, product: ProductInfo): boolean {
|
||||
// 1. 超级管理员和管理员可以添加
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true;
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
// 2. 部门管理员可以为本部门的产品添加
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (product.departmentId === user.managedDepartmentId) {
|
||||
return true;
|
||||
}
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. 创建者可以添加
|
||||
if (product.createdBy === user.id) return true;
|
||||
|
||||
// 4. 组长可以为本组产品添加
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
return false; // 需要查询
|
||||
}
|
||||
|
||||
return false;
|
||||
return product.createdBy === user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以管理产品协作者(添加/移除)
|
||||
*/
|
||||
export function canManageCollaborators(user: UserInfo, product: ProductInfo): boolean {
|
||||
// 1. 超级管理员可以管理所有产品的协作者
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
// 2. 管理员可以管理所有产品的协作者
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 部门管理员可以管理本部门产品的协作者
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (product.departmentId === user.managedDepartmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 产品创建者可以管理自己产品的协作者
|
||||
if (product.createdBy === user.id) return true;
|
||||
|
||||
// 5. 组长可以管理本组产品的协作者
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
return false; // 需要查询创建者的小组
|
||||
}
|
||||
|
||||
return false;
|
||||
return canAddCollaborator(user, product)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -288,8 +247,7 @@ export function canManageCollaborators(user: UserInfo, product: ProductInfo): bo
|
||||
* 检查是否可以创建 SKU
|
||||
*/
|
||||
export function canCreateSKU(user: UserInfo): boolean {
|
||||
// 查看者不能创建 SKU
|
||||
return user.role !== 'VIEWER';
|
||||
return canCreateContent(user)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -298,90 +256,49 @@ export function canCreateSKU(user: UserInfo): boolean {
|
||||
export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo): boolean {
|
||||
const collaboratorUsers = product ? getCollaboratorUsers(product.collaboratorUsers) : []
|
||||
|
||||
// 查看者不能编辑 SKU
|
||||
if (user.role === 'VIEWER') return false;
|
||||
if (isReadOnlyUser(user)) return false
|
||||
if (isAdmin(user)) return true
|
||||
if (sku.ownerId === user.id) return true
|
||||
|
||||
// 1. 超级管理员可编辑所有 SKU
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
// 2. 管理员可编辑所有 SKU
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. SKU 负责人可编辑
|
||||
if (sku.ownerId === user.id) return true;
|
||||
|
||||
// 4. 如果提供了产品信息,检查产品权限
|
||||
if (product) {
|
||||
// 产品创建者可以编辑产品下的 SKU
|
||||
if (product.createdBy === user.id) return true;
|
||||
if (product.createdBy === user.id) return true
|
||||
|
||||
// 产品编辑协作者可以编辑 SKU
|
||||
if (collaboratorUsers.some((c) => c.userId === user.id && c.role === 'editor')) {
|
||||
return true;
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (collaboratorUsers.some((collaborator) => collaborator.userId === user.id && collaborator.role === 'editor')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 部门管理员可编辑本部门的 SKU
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
// 需要查询 SKU 负责人的部门
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. 组长可编辑本组的 SKU
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
// 需要查询 SKU 负责人的小组
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以删除 SKU
|
||||
*/
|
||||
export function canDeleteSKU(user: UserInfo, _sku: SKUInfo): boolean {
|
||||
// 1. 超级管理员可删除所有 SKU
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
// 2. 管理员可删除所有 SKU
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 部门管理员可删除本部门的 SKU
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
return false; // 需要查询
|
||||
}
|
||||
|
||||
return false;
|
||||
export function canDeleteSKU(user: UserInfo, sku: SKUInfo): boolean {
|
||||
if (isAdmin(user)) return true
|
||||
if (isReadOnlyUser(user)) return false
|
||||
return sku.ownerId === user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以交接 SKU
|
||||
*/
|
||||
export function canTransferSKU(
|
||||
operator: UserInfo,
|
||||
sku: SKUInfo,
|
||||
owner?: UserInfo
|
||||
): boolean {
|
||||
// 1. 超级管理员可以交接任何 SKU
|
||||
if (operator.role === 'SUPER_ADMIN') return true;
|
||||
export function canTransferSKU(operator: UserInfo, sku: SKUInfo, owner?: UserInfo): boolean {
|
||||
if (isAdmin(operator)) return true
|
||||
|
||||
// 2. 管理员可以交接任何 SKU
|
||||
if (operator.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 部门管理员可以交接本部门的 SKU
|
||||
if (operator.role === 'DEPARTMENT_MANAGER' && operator.departmentId) {
|
||||
// 如果提供了 owner 信息,检查是否同部门
|
||||
if (owner && owner.departmentId === operator.departmentId) {
|
||||
return true;
|
||||
}
|
||||
if (isDepartmentSupervisor(operator) && owner?.departmentId) {
|
||||
return isWithinManagedDepartment(operator, owner.departmentId)
|
||||
}
|
||||
|
||||
// 4. SKU 负责人可以交接自己负责的 SKU
|
||||
if (sku.ownerId === operator.id) {
|
||||
return true;
|
||||
if (isTeamSupervisor(operator) && owner?.teamId) {
|
||||
return isWithinManagedTeam(operator, owner.teamId)
|
||||
}
|
||||
|
||||
return false;
|
||||
return sku.ownerId === operator.id
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -392,36 +309,16 @@ export function canTransferSKU(
|
||||
* 检查是否可以提交反馈
|
||||
*/
|
||||
export function canSubmitFeedback(_user: UserInfo): boolean {
|
||||
// 所有角色都可以提交反馈
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以处理反馈
|
||||
*/
|
||||
export function canHandleFeedback(user: UserInfo, feedback: FeedbackInfo): boolean {
|
||||
// 1. 超级管理员和管理员可处理所有反馈
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true;
|
||||
|
||||
// 2. 被分配者可以处理反馈
|
||||
if (feedback.assignedTo === user.id) return true;
|
||||
|
||||
// 3. 部门管理员可处理本部门的反馈
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
// 需要查询产品的部门
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 组长可处理本组的反馈
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
// 需要查询产品创建者的小组
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 产品创建者和协作者可处理
|
||||
// 6. SKU 负责人可处理
|
||||
// 这些需要查询数据库
|
||||
return false;
|
||||
if (isAdmin(user)) return true
|
||||
if (feedback.assignedTo === user.id) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -432,33 +329,20 @@ export function canHandleFeedback(user: UserInfo, feedback: FeedbackInfo): boole
|
||||
* 检查是否可以生成分享链接
|
||||
*/
|
||||
export function canCreateShareLink(_user: UserInfo): boolean {
|
||||
// 所有角色都可以生成分享链接
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以管理分享链接
|
||||
*/
|
||||
export function canManageShareLink(user: UserInfo, product: ProductInfo): boolean {
|
||||
// 1. 超级管理员和管理员可管理所有分享链接
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true;
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
// 2. 部门管理员可管理本部门的分享链接
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (product.departmentId === user.managedDepartmentId) {
|
||||
return true;
|
||||
}
|
||||
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. 组长可管理本组的分享链接
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
return false; // 需要查询
|
||||
}
|
||||
|
||||
// 4. 产品创建者可管理
|
||||
if (product.createdBy === user.id) return true;
|
||||
|
||||
return false;
|
||||
return product.createdBy === user.id
|
||||
}
|
||||
|
||||
/**
|
||||
@ -467,67 +351,47 @@ export function canManageShareLink(user: UserInfo, product: ProductInfo): boolea
|
||||
export function canViewProduct(
|
||||
user: UserInfo,
|
||||
product: ProductInfo & { visibility?: string },
|
||||
creator?: ProductCreatorContext,
|
||||
creator?: ProductCreatorContext
|
||||
): boolean {
|
||||
const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers)
|
||||
|
||||
// 1. 超级管理员可以查看任何产品
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
if (isAdmin(user)) return true
|
||||
if (product.visibility === 'PUBLIC') return true
|
||||
if (product.createdBy === user.id) return true
|
||||
|
||||
// 2. 管理员可以查看任何产品
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 公开产品任何人都可以查看
|
||||
if (product.visibility === 'PUBLIC') return true;
|
||||
|
||||
// 4. 创建者可以查看自己的产品
|
||||
if (product.createdBy === user.id) return true;
|
||||
|
||||
// 5. 协作者可以查看产品
|
||||
if (collaboratorUsers.some((c) => c.userId === user.id)) {
|
||||
return true;
|
||||
if (collaboratorUsers.some((collaborator) => collaborator.userId === user.id)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 6. 部门可见产品,同部门用户可以查看
|
||||
if (product.visibility === 'DEPARTMENT' && creator) {
|
||||
if (user.departmentId === creator.departmentId) {
|
||||
return true;
|
||||
}
|
||||
if (product.visibility === 'DEPARTMENT' && creator?.departmentId) {
|
||||
return user.departmentId === creator.departmentId
|
||||
}
|
||||
|
||||
// 7. 小组可见产品,同小组用户可以查看
|
||||
if (product.visibility === 'TEAM' && creator) {
|
||||
if (user.teamId === creator.teamId) {
|
||||
return true;
|
||||
}
|
||||
if (product.visibility === 'TEAM' && creator?.teamId) {
|
||||
return user.teamId === creator.teamId
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以管理部门
|
||||
*/
|
||||
export function canManageDepartment(user: UserInfo): boolean {
|
||||
return user.role === 'SUPER_ADMIN' || user.role === 'ADMIN';
|
||||
return user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以管理小组
|
||||
*/
|
||||
export function canManageTeam(user: UserInfo, team?: TeamInfo): boolean {
|
||||
// 1. 超级管理员可以管理小组
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
if (isAdmin(user)) return true
|
||||
|
||||
// 2. 管理员可以管理小组
|
||||
if (user.role === 'ADMIN') return true;
|
||||
|
||||
// 3. 部门管理员可以管理本部门的小组
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.departmentId && team) {
|
||||
return team.departmentId === user.departmentId;
|
||||
if (isDepartmentSupervisor(user) && team?.departmentId) {
|
||||
return isWithinManagedDepartment(user, team.departmentId)
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -538,21 +402,21 @@ export function canManageTeam(user: UserInfo, team?: TeamInfo): boolean {
|
||||
* 检查是否可以访问系统设置
|
||||
*/
|
||||
export function canAccessSystemSettings(user: UserInfo): boolean {
|
||||
return user.role === 'SUPER_ADMIN';
|
||||
return user.role === 'SUPER_ADMIN'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以查看操作日志
|
||||
*/
|
||||
export function canViewActivityLogs(user: UserInfo): boolean {
|
||||
return ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(user.role);
|
||||
return isAdmin(user) || isDepartmentSupervisor(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以导出数据
|
||||
*/
|
||||
export function canExportData(user: UserInfo): boolean {
|
||||
return ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(user.role);
|
||||
return isAdmin(user) || isDepartmentSupervisor(user) || isSupervisor(user) || user.role === 'DESIGNER' || user.role === 'USER'
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@ -566,26 +430,23 @@ export async function isDepartmentWithinScope(
|
||||
managedDepartmentId: string,
|
||||
targetDepartmentId?: string
|
||||
): Promise<boolean> {
|
||||
if (!targetDepartmentId) return false;
|
||||
if (managedDepartmentId === targetDepartmentId) return true;
|
||||
if (!targetDepartmentId) return false
|
||||
if (managedDepartmentId === targetDepartmentId) return true
|
||||
|
||||
// 查询目标部门的父部门链
|
||||
const department = await departmentRepository.findParent(targetDepartmentId);
|
||||
const department = await departmentRepository.findParent(targetDepartmentId)
|
||||
|
||||
if (!department) return false;
|
||||
if (!department.parentId) return false;
|
||||
if (!department?.parentId) return false
|
||||
|
||||
// 递归检查父部门
|
||||
return isDepartmentWithinScope(managedDepartmentId, department.parentId);
|
||||
return isDepartmentWithinScope(managedDepartmentId, department.parentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的完整信息(包括管理关系)
|
||||
*/
|
||||
export async function getUserInfo(userId: string): Promise<UserInfo | null> {
|
||||
const user = await userRepository.findPermissionUserById(userId);
|
||||
const user = await userRepository.findPermissionUserById(userId)
|
||||
|
||||
if (!user) return null;
|
||||
if (!user) return null
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@ -597,5 +458,5 @@ export async function getUserInfo(userId: string): Promise<UserInfo | null> {
|
||||
teamId: user.teamId || undefined,
|
||||
managedDepartmentId: user.managedDepartment?.id,
|
||||
managedTeamId: user.managedTeam?.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
65
furniture-3d-system/src/lib/role-scope.ts
Normal file
65
furniture-3d-system/src/lib/role-scope.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { UserRole } from '@/server/core/types'
|
||||
|
||||
export type AllowedRole = UserRole | 'SUPERVISOR_DEPARTMENT' | 'SUPERVISOR_TEAM'
|
||||
|
||||
export interface ScopedRoleUser {
|
||||
role: UserRole
|
||||
departmentId?: string | null
|
||||
teamId?: string | null
|
||||
managedDepartmentId?: string | null
|
||||
managedTeamId?: string | null
|
||||
}
|
||||
|
||||
export function getManagedDepartmentId(user: ScopedRoleUser): string | undefined {
|
||||
return user.managedDepartmentId ?? user.departmentId ?? undefined
|
||||
}
|
||||
|
||||
export function getManagedTeamId(user: ScopedRoleUser): string | undefined {
|
||||
return user.managedTeamId ?? user.teamId ?? undefined
|
||||
}
|
||||
|
||||
export function isSuperAdmin(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'SUPER_ADMIN'
|
||||
}
|
||||
|
||||
export function isAdmin(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
||||
}
|
||||
|
||||
export function isSupervisor(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'SUPERVISOR'
|
||||
}
|
||||
|
||||
export function isDepartmentSupervisor(user: ScopedRoleUser): boolean {
|
||||
return isSupervisor(user) && Boolean(getManagedDepartmentId(user))
|
||||
}
|
||||
|
||||
export function isTeamSupervisor(user: ScopedRoleUser): boolean {
|
||||
return isSupervisor(user) && Boolean(getManagedTeamId(user))
|
||||
}
|
||||
|
||||
export function isDesigner(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'DESIGNER'
|
||||
}
|
||||
|
||||
export function isReadOnlyUser(user: ScopedRoleUser): boolean {
|
||||
return user.role === 'USER'
|
||||
}
|
||||
|
||||
export function canCreateContent(user: ScopedRoleUser): boolean {
|
||||
return !isReadOnlyUser(user)
|
||||
}
|
||||
|
||||
export function hasAllowedRole(user: ScopedRoleUser, allowedRoles: readonly AllowedRole[]): boolean {
|
||||
return allowedRoles.some((allowedRole) => {
|
||||
if (allowedRole === 'SUPERVISOR_DEPARTMENT') {
|
||||
return isDepartmentSupervisor(user)
|
||||
}
|
||||
|
||||
if (allowedRole === 'SUPERVISOR_TEAM') {
|
||||
return isTeamSupervisor(user)
|
||||
}
|
||||
|
||||
return user.role === allowedRole
|
||||
})
|
||||
}
|
||||
@ -9,6 +9,8 @@ import { nanoid } from 'nanoid'
|
||||
import type { ApiResponse, PaginationMeta, ResponseMeta, AuthUser, RequestContext, OptionalAuthRequestContext } from './types'
|
||||
import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors'
|
||||
import { HTTP_STATUS, ENV } from './constants'
|
||||
import type { AllowedRole } from '@/lib/role-scope'
|
||||
import { hasAllowedRole } from '@/lib/role-scope'
|
||||
|
||||
// ============ 响应构建器 ============
|
||||
|
||||
@ -142,7 +144,7 @@ interface HandleRequestOptions {
|
||||
/** 是否需要认证 */
|
||||
requireAuth?: boolean
|
||||
/** 允许的角色 */
|
||||
allowedRoles?: string[]
|
||||
allowedRoles?: AllowedRole[]
|
||||
}
|
||||
|
||||
// 函数重载:requireAuth: false 时使用 OptionalAuthRequestContext
|
||||
@ -184,7 +186,7 @@ export async function handleRequest<T>(
|
||||
}
|
||||
|
||||
// 角色检查
|
||||
if (options.allowedRoles && !options.allowedRoles.includes(user.role)) {
|
||||
if (options.allowedRoles && !hasAllowedRole(user, options.allowedRoles)) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'没有权限执行此操作',
|
||||
|
||||
@ -56,10 +56,9 @@ export interface ApiResponse<T = unknown> {
|
||||
export type UserRole =
|
||||
| 'SUPER_ADMIN'
|
||||
| 'ADMIN'
|
||||
| 'DEPARTMENT_MANAGER'
|
||||
| 'TEAM_LEADER'
|
||||
| 'SUPERVISOR'
|
||||
| 'DESIGNER'
|
||||
| 'VIEWER'
|
||||
| 'USER'
|
||||
|
||||
/**
|
||||
* 认证用户信息
|
||||
|
||||
@ -50,7 +50,7 @@ describe('assetCenterService review workflow', () => {
|
||||
id: 'user-leader',
|
||||
email: 'leader@example.com',
|
||||
name: '组长',
|
||||
role: 'TEAM_LEADER' as const,
|
||||
role: 'SUPERVISOR' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
|
||||
@ -275,7 +275,7 @@ describe('openPlatformDemoSeedService', () => {
|
||||
id: 'user-reviewer',
|
||||
email: 'reviewer@example.com',
|
||||
name: '开放平台审核员',
|
||||
role: 'TEAM_LEADER',
|
||||
role: 'SUPERVISOR',
|
||||
status: 'ACTIVE',
|
||||
} as never)
|
||||
mockedPrisma.productCategory.findUnique.mockResolvedValue(null as never)
|
||||
|
||||
@ -20,7 +20,7 @@ describe('product-permission.helper', () => {
|
||||
id: 'viewer-1',
|
||||
email: 'viewer@test.com',
|
||||
name: '部门同事',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-1',
|
||||
} as any,
|
||||
@ -41,7 +41,7 @@ describe('product-permission.helper', () => {
|
||||
id: 'viewer-2',
|
||||
email: 'viewer2@test.com',
|
||||
name: '组内同事',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
teamId: 'team-1',
|
||||
} as any,
|
||||
@ -61,7 +61,7 @@ describe('product-permission.helper', () => {
|
||||
id: 'manager-1',
|
||||
email: 'manager@test.com',
|
||||
name: '部门管理员',
|
||||
role: 'DEPARTMENT_MANAGER',
|
||||
role: 'SUPERVISOR',
|
||||
status: 'ACTIVE',
|
||||
managedDepartmentId: 'dept-1',
|
||||
} as any,
|
||||
@ -81,7 +81,7 @@ describe('product-permission.helper', () => {
|
||||
id: 'manager-1',
|
||||
email: 'manager@test.com',
|
||||
name: '部门管理员',
|
||||
role: 'DEPARTMENT_MANAGER',
|
||||
role: 'SUPERVISOR',
|
||||
status: 'ACTIVE',
|
||||
managedDepartmentId: 'dept-1',
|
||||
} as any,
|
||||
@ -100,7 +100,7 @@ describe('product-permission.helper', () => {
|
||||
id: 'viewer-1',
|
||||
email: 'viewer@test.com',
|
||||
name: '查看者',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
} as any,
|
||||
),
|
||||
|
||||
@ -35,7 +35,7 @@ describe('ProductWriteService collaborator permissions', () => {
|
||||
id: 'manager-1',
|
||||
email: 'manager@test.com',
|
||||
name: '部门管理员',
|
||||
role: 'DEPARTMENT_MANAGER',
|
||||
role: 'SUPERVISOR',
|
||||
status: 'ACTIVE',
|
||||
managedDepartmentId: 'dept-1',
|
||||
} as any,
|
||||
@ -79,7 +79,7 @@ describe('ProductWriteService collaborator permissions', () => {
|
||||
id: 'viewer-1',
|
||||
email: 'viewer@test.com',
|
||||
name: '查看者',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
} as any,
|
||||
requestId: 'req-test',
|
||||
|
||||
@ -42,7 +42,7 @@ describe('userHandoverService', () => {
|
||||
id: 'leader-1',
|
||||
email: 'leader@example.com',
|
||||
name: '组长',
|
||||
role: 'TEAM_LEADER' as const,
|
||||
role: 'SUPERVISOR' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
teamId: 'team-1',
|
||||
managedTeamId: 'team-1',
|
||||
@ -300,7 +300,7 @@ describe('userHandoverService', () => {
|
||||
await expect(
|
||||
userHandoverService.getHandoverChecklist('user-outside', {
|
||||
...operator,
|
||||
role: 'DEPARTMENT_MANAGER',
|
||||
role: 'SUPERVISOR',
|
||||
departmentId: 'dept-1',
|
||||
managedDepartmentId: 'dept-1',
|
||||
}),
|
||||
|
||||
@ -117,7 +117,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
id: 'user-unassigned',
|
||||
email: 'free@example.com',
|
||||
name: '未分配人员',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'PENDING',
|
||||
loginSource: 'LOCAL',
|
||||
activationRequired: true,
|
||||
@ -250,7 +250,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
id: 'manager-1',
|
||||
email: 'manager@example.com',
|
||||
name: '部门负责人',
|
||||
role: 'DEPARTMENT_MANAGER',
|
||||
role: 'SUPERVISOR',
|
||||
status: 'ACTIVE',
|
||||
departmentId: 'dept-a',
|
||||
}
|
||||
@ -283,7 +283,7 @@ describe('userManagementService.getOrganizationTree', () => {
|
||||
id: 'viewer-1',
|
||||
email: 'viewer@example.com',
|
||||
name: '查看者',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
)
|
||||
|
||||
@ -321,8 +321,8 @@ class DingtalkAuthService {
|
||||
email,
|
||||
name: loginName,
|
||||
password,
|
||||
role: 'DESIGNER',
|
||||
status: employeeDirectory ? 'ACTIVE' : 'PENDING',
|
||||
role: 'USER',
|
||||
status: 'PENDING',
|
||||
isFirstLogin: false,
|
||||
avatar: profile.avatar || null,
|
||||
phone: profile.mobile || null,
|
||||
@ -354,11 +354,6 @@ class DingtalkAuthService {
|
||||
? await this.resolveDepartmentIdByExternalId(primaryDepartmentExternalId)
|
||||
: null
|
||||
|
||||
const nextStatus =
|
||||
employeeDirectoryId && currentUser?.status === 'PENDING'
|
||||
? 'ACTIVE'
|
||||
: currentUser?.status ?? 'ACTIVE'
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
@ -370,7 +365,7 @@ class DingtalkAuthService {
|
||||
phone: profile.mobile || undefined,
|
||||
employeeDirectoryId: employeeDirectoryId ?? undefined,
|
||||
departmentId: departmentId ?? currentUser?.departmentId ?? undefined,
|
||||
status: nextStatus,
|
||||
status: currentUser?.status ?? 'PENDING',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Feedback } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { isAdmin, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
|
||||
import {
|
||||
feedbackRepository,
|
||||
type FeedbackFilter,
|
||||
@ -33,18 +34,25 @@ class FeedbackQueryService {
|
||||
|
||||
let result: { items: FeedbackWithRelations[]; total: number }
|
||||
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') {
|
||||
if (isAdmin(user)) {
|
||||
result = await feedbackRepository.findManyWithRelations({
|
||||
pagination: { page, limit: pageSize },
|
||||
filter,
|
||||
})
|
||||
} else if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
} else if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
|
||||
const products = await prisma.product.findMany({
|
||||
where: { departmentId: user.managedDepartmentId },
|
||||
select: { id: true },
|
||||
})
|
||||
const productIds = products.map((item) => item.id)
|
||||
result = await this.findFeedbacksForProducts(productIds, { page, pageSize }, filter)
|
||||
} else if (isTeamSupervisor(user) && user.managedTeamId) {
|
||||
const products = await prisma.product.findMany({
|
||||
where: { creator: { teamId: user.managedTeamId } },
|
||||
select: { id: true },
|
||||
})
|
||||
const productIds = products.map((item) => item.id)
|
||||
result = await this.findFeedbacksForProducts(productIds, { page, pageSize }, filter)
|
||||
} else {
|
||||
result = await this.findUserFeedbacks(user.id, { page, pageSize }, filter)
|
||||
}
|
||||
@ -84,10 +92,10 @@ class FeedbackQueryService {
|
||||
}
|
||||
|
||||
async checkHandlePermission(user: FeedbackUserContext, feedback: Feedback): Promise<boolean> {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true
|
||||
if (isAdmin(user)) return true
|
||||
if (feedback.assigneeId === user.id) return true
|
||||
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: feedback.productId },
|
||||
select: { departmentId: true },
|
||||
@ -97,7 +105,7 @@ class FeedbackQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
if (isTeamSupervisor(user) && user.managedTeamId) {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: feedback.productId },
|
||||
include: { creator: { select: { teamId: true } } },
|
||||
@ -200,11 +208,11 @@ class FeedbackQueryService {
|
||||
}
|
||||
|
||||
private async checkViewPermission(user: FeedbackUserContext, feedback: Feedback): Promise<boolean> {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true
|
||||
if (isAdmin(user)) return true
|
||||
if (feedback.submitterId === user.id) return true
|
||||
if (feedback.assigneeId === user.id) return true
|
||||
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: feedback.productId },
|
||||
select: { departmentId: true },
|
||||
@ -212,6 +220,16 @@ class FeedbackQueryService {
|
||||
if (product && product.departmentId === user.managedDepartmentId) return true
|
||||
}
|
||||
|
||||
if (isTeamSupervisor(user) && user.managedTeamId) {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: feedback.productId },
|
||||
include: { creator: { select: { teamId: true } } },
|
||||
})
|
||||
if (product?.creator.teamId === user.managedTeamId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ class FeedbackWorkflowService {
|
||||
where: {
|
||||
OR: [
|
||||
{ role: { in: ['SUPER_ADMIN', 'ADMIN'] } },
|
||||
{ role: 'DEPARTMENT_MANAGER' },
|
||||
{ role: 'SUPERVISOR' },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { FeedbackPriority, FeedbackStatus } from '@prisma/client'
|
||||
import type { UserRole } from '@/server/core/types'
|
||||
|
||||
export interface CreateFeedbackInput {
|
||||
productId: string
|
||||
@ -68,7 +69,7 @@ export interface FeedbackListOptions {
|
||||
export interface FeedbackUserContext {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
role: UserRole
|
||||
departmentId?: string | null
|
||||
teamId?: string | null
|
||||
managedDepartmentId?: string | null
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from '@/app/intake/non-integrated/nonIntegratedStorage'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { canCreateContent } from '@/lib/role-scope'
|
||||
import {
|
||||
intakeNonIntegratedProductRepository,
|
||||
type IntakeNonIntegratedProductRepositoryClass,
|
||||
@ -55,6 +56,10 @@ class IntakeNonIntegratedProductService {
|
||||
}
|
||||
|
||||
async save(productInput: unknown, user: AuthUser) {
|
||||
if (!canCreateContent(user)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '普通用户只有查看和下载权限,不能新增或修改非集成产品')
|
||||
}
|
||||
|
||||
const normalizedProduct =
|
||||
normalizeNonIntegratedProductRecord(
|
||||
productInput as Partial<NonIntegratedProductRecord>,
|
||||
@ -128,6 +133,10 @@ class IntakeNonIntegratedProductService {
|
||||
}
|
||||
|
||||
async remove(id: string, user: AuthUser) {
|
||||
if (!canCreateContent(user)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '普通用户只有查看和下载权限,不能删除非集成产品')
|
||||
}
|
||||
|
||||
const existingRecord = await this.repository.findActiveById(id)
|
||||
if (!existingRecord) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
|
||||
|
||||
@ -2,6 +2,7 @@ import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { canCreateContent } from '@/lib/role-scope'
|
||||
import {
|
||||
draftGroupRepository,
|
||||
preSkuRepository,
|
||||
@ -520,6 +521,10 @@ class IntakeService {
|
||||
input: FormalizeDraftGroupInput,
|
||||
user: AuthUser,
|
||||
): Promise<FormalizeDraftGroupResult> {
|
||||
if (!canCreateContent(user)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '普通用户只有查看和下载权限,不能执行正式入库')
|
||||
}
|
||||
|
||||
const draftGroup = await draftGroupRepository.findByIdWithRelations(input.draftGroupId)
|
||||
if (!draftGroup) {
|
||||
throw new AppError(ErrorCode.DRAFT_GROUP_NOT_FOUND, '草稿组不存在')
|
||||
|
||||
@ -997,7 +997,7 @@ class OpenPlatformDemoSeedService {
|
||||
const reviewerRecord = await ensureSeedUser({
|
||||
email: 'open-platform-reviewer@example.com',
|
||||
name: '开放平台审核员',
|
||||
role: 'TEAM_LEADER',
|
||||
role: 'SUPERVISOR',
|
||||
})
|
||||
|
||||
const planner = toAuthUser(plannerRecord)
|
||||
|
||||
@ -2,24 +2,21 @@ import { Prisma } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { PlanningWorkspaceWithRelations } from '../repositories'
|
||||
import { canCreateContent, getManagedDepartmentId, isAdmin as isAdminRole, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
|
||||
|
||||
const ADMIN_ROLES = new Set<AuthUser['role']>(['SUPER_ADMIN', 'ADMIN'])
|
||||
const DEPARTMENT_EDITOR_ROLES = new Set<AuthUser['role']>([
|
||||
'DEPARTMENT_MANAGER',
|
||||
'TEAM_LEADER',
|
||||
])
|
||||
|
||||
class PlanningScopeService {
|
||||
getScopedDepartmentId(user: AuthUser): string | undefined {
|
||||
return user.managedDepartmentId ?? user.departmentId
|
||||
return getManagedDepartmentId(user)
|
||||
}
|
||||
|
||||
canManagePlanning(user: AuthUser): boolean {
|
||||
return user.role !== 'VIEWER'
|
||||
return canCreateContent(user)
|
||||
}
|
||||
|
||||
isAdmin(user: AuthUser): boolean {
|
||||
return ADMIN_ROLES.has(user.role)
|
||||
return ADMIN_ROLES.has(user.role) || isAdminRole(user)
|
||||
}
|
||||
|
||||
assertCanWritePlanning(user: AuthUser, message = '当前账号只有查看权限,不能修改规划数据'): void {
|
||||
@ -50,7 +47,7 @@ class PlanningScopeService {
|
||||
if (
|
||||
userDepartmentId &&
|
||||
workspace.departmentId === userDepartmentId &&
|
||||
DEPARTMENT_EDITOR_ROLES.has(user.role)
|
||||
(isDepartmentSupervisor(user) || isTeamSupervisor(user))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import type { Prisma } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { reportRepository } from '@/server/repositories/report.repository'
|
||||
import { isAdmin, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
|
||||
|
||||
type DateRangeFilter = {
|
||||
startDate?: string
|
||||
@ -56,10 +57,10 @@ class ReportService {
|
||||
departmentId?: string,
|
||||
teamId?: string
|
||||
): Promise<string[] | undefined> {
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
|
||||
return reportRepository.findUserIdsByDepartment(user.managedDepartmentId)
|
||||
}
|
||||
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
|
||||
if (isTeamSupervisor(user) && user.managedTeamId) {
|
||||
return reportRepository.findUserIdsByTeam(user.managedTeamId)
|
||||
}
|
||||
if (departmentId) {
|
||||
@ -281,13 +282,13 @@ class ReportService {
|
||||
}
|
||||
|
||||
async getUserActivity(filter: UserActivityFilter, user: AuthUser) {
|
||||
if (!['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(user.role)) {
|
||||
if (!isAdmin(user) && !isDepartmentSupervisor(user)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '权限不足')
|
||||
}
|
||||
|
||||
const dateRange = this.buildDateRange(filter.startDate, filter.endDate)
|
||||
const userWhere: Prisma.UserWhereInput = {}
|
||||
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
|
||||
if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
|
||||
userWhere.departmentId = user.managedDepartmentId
|
||||
} else if (filter.departmentId) {
|
||||
userWhere.departmentId = filter.departmentId
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
|
||||
import { handoverRepository } from '../repositories/handover.repository'
|
||||
import { userService, type UserDTO } from './user.service'
|
||||
|
||||
@ -47,7 +48,7 @@ class UserHandoverService {
|
||||
return targetUser
|
||||
}
|
||||
|
||||
if (operator.role === 'DEPARTMENT_MANAGER') {
|
||||
if (isDepartmentSupervisor(operator)) {
|
||||
const managedDepartmentId = this.getManagedDepartmentId(operator)
|
||||
if (!managedDepartmentId) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号未绑定可管理的部门')
|
||||
@ -61,7 +62,7 @@ class UserHandoverService {
|
||||
return targetUser
|
||||
}
|
||||
|
||||
if (operator.role === 'TEAM_LEADER') {
|
||||
if (isTeamSupervisor(operator)) {
|
||||
const managedTeamId = this.getManagedTeamId(operator)
|
||||
if (!managedTeamId || targetUser.teamId !== managedTeamId) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权交接当前小组范围外的用户')
|
||||
|
||||
@ -1,4 +1,17 @@
|
||||
import { canActivateUser, canDeleteUser, canManageUser, type UserInfo } from '@/lib/permissions'
|
||||
import {
|
||||
canActivateUser,
|
||||
canCreateUser,
|
||||
canDeleteUser,
|
||||
canManageUser,
|
||||
type UserInfo,
|
||||
} from '@/lib/permissions'
|
||||
import {
|
||||
getManagedDepartmentId,
|
||||
getManagedTeamId,
|
||||
isAdmin,
|
||||
isDepartmentSupervisor,
|
||||
isTeamSupervisor,
|
||||
} from '@/lib/role-scope'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { Role, UserStatus } from '@prisma/client'
|
||||
@ -7,7 +20,7 @@ import { departmentService, type DepartmentDTO } from './department.service'
|
||||
import { teamService, type TeamDTO } from './team.service'
|
||||
import { userService } from './user.service'
|
||||
|
||||
const ALLOWED_LIST_ROLES: Role[] = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']
|
||||
const ALLOWED_LIST_ROLES: Role[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR']
|
||||
const SENSITIVE_FIELDS = ['email', 'phone', 'lastLoginAt'] as const
|
||||
|
||||
interface ListUsersInput {
|
||||
@ -130,11 +143,27 @@ class UserManagementService {
|
||||
}
|
||||
|
||||
private getManagedDepartmentId(currentUser: AuthUser): string | undefined {
|
||||
return currentUser.managedDepartmentId ?? currentUser.departmentId
|
||||
return getManagedDepartmentId(currentUser)
|
||||
}
|
||||
|
||||
private getManagedTeamId(currentUser: AuthUser): string | undefined {
|
||||
return currentUser.managedTeamId ?? currentUser.teamId
|
||||
return getManagedTeamId(currentUser)
|
||||
}
|
||||
|
||||
private canAssignRole(operator: AuthUser, targetRole: Role): boolean {
|
||||
if (operator.role === 'SUPER_ADMIN') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (operator.role === 'ADMIN') {
|
||||
return targetRole !== 'SUPER_ADMIN'
|
||||
}
|
||||
|
||||
if (isDepartmentSupervisor(operator) || isTeamSupervisor(operator)) {
|
||||
return targetRole === 'USER' || targetRole === 'DESIGNER' || targetRole === 'SUPERVISOR'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private createDepartmentNode(department: DepartmentDTO): OrganizationTreeDepartmentNode {
|
||||
@ -207,8 +236,9 @@ class UserManagementService {
|
||||
const limit = Math.min(100, Math.max(1, input.limit ?? 20))
|
||||
const canViewFull = this.canViewFullUserInfo(currentUser)
|
||||
|
||||
if (currentUser.role === 'TEAM_LEADER') {
|
||||
if (!currentUser.teamId) {
|
||||
if (isTeamSupervisor(currentUser)) {
|
||||
const managedTeamId = this.getManagedTeamId(currentUser)
|
||||
if (!managedTeamId) {
|
||||
return {
|
||||
users: [],
|
||||
page: 1,
|
||||
@ -225,7 +255,7 @@ class UserManagementService {
|
||||
search: input.search,
|
||||
role: input.role,
|
||||
status: input.status,
|
||||
teamId: currentUser.teamId,
|
||||
teamId: managedTeamId,
|
||||
})
|
||||
|
||||
return {
|
||||
@ -238,8 +268,9 @@ class UserManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUser.role === 'DEPARTMENT_MANAGER') {
|
||||
if (!currentUser.departmentId) {
|
||||
if (isDepartmentSupervisor(currentUser)) {
|
||||
const managedDepartmentId = this.getManagedDepartmentId(currentUser)
|
||||
if (!managedDepartmentId) {
|
||||
return {
|
||||
users: [],
|
||||
page: 1,
|
||||
@ -250,7 +281,7 @@ class UserManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
const departmentIds = await userService.getDepartmentIdsForManager(currentUser.departmentId)
|
||||
const departmentIds = await userService.getDepartmentIdsForManager(managedDepartmentId)
|
||||
if (departmentIds.length === 0) {
|
||||
return {
|
||||
users: [],
|
||||
@ -317,7 +348,7 @@ class UserManagementService {
|
||||
let scopedTeams = allTeams
|
||||
let scopedDepartmentIds: string[] | undefined
|
||||
|
||||
if (currentUser.role === 'DEPARTMENT_MANAGER') {
|
||||
if (isDepartmentSupervisor(currentUser)) {
|
||||
const managedDepartmentId = this.getManagedDepartmentId(currentUser)
|
||||
if (!managedDepartmentId) {
|
||||
return this.buildEmptyOrganizationTree()
|
||||
@ -334,7 +365,7 @@ class UserManagementService {
|
||||
}
|
||||
|
||||
let allUsers: Awaited<ReturnType<typeof userService.listAll>> = []
|
||||
if (currentUser.role === 'TEAM_LEADER') {
|
||||
if (isTeamSupervisor(currentUser)) {
|
||||
const managedTeamId = this.getManagedTeamId(currentUser)
|
||||
if (!managedTeamId) {
|
||||
return this.buildEmptyOrganizationTree()
|
||||
@ -484,29 +515,40 @@ class UserManagementService {
|
||||
},
|
||||
currentUser: AuthUser
|
||||
) {
|
||||
const targetUser: UserInfo = {
|
||||
id: '',
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
role: (input.role || 'DESIGNER') as Role,
|
||||
status: 'ACTIVE',
|
||||
departmentId: input.departmentId,
|
||||
teamId: input.teamId,
|
||||
if (!canCreateUser(currentUser)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限创建用户')
|
||||
}
|
||||
|
||||
if (!canManageUser(currentUser, targetUser)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限创建此用户')
|
||||
const targetRole = (input.role || 'USER') as Role
|
||||
|
||||
if (!this.canAssignRole(currentUser, targetRole)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限分配此角色')
|
||||
}
|
||||
|
||||
if (isDepartmentSupervisor(currentUser)) {
|
||||
const managedDepartmentId = this.getManagedDepartmentId(currentUser)
|
||||
if (!managedDepartmentId || input.departmentId !== managedDepartmentId) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '部门主管只能在本部门内创建账号')
|
||||
}
|
||||
}
|
||||
|
||||
if (isTeamSupervisor(currentUser)) {
|
||||
const managedTeamId = this.getManagedTeamId(currentUser)
|
||||
if (!managedTeamId || input.teamId !== managedTeamId) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '小组主管只能在本组内创建账号')
|
||||
}
|
||||
}
|
||||
|
||||
const createdUser = await userService.create({
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
password: input.password,
|
||||
role: input.role || 'DESIGNER',
|
||||
role: input.role || 'USER',
|
||||
departmentId: input.departmentId,
|
||||
teamId: input.teamId,
|
||||
phone: input.phone,
|
||||
isFirstLogin: false,
|
||||
status: 'PENDING',
|
||||
})
|
||||
|
||||
await logRepository.createLog({
|
||||
@ -618,8 +660,13 @@ class UserManagementService {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '只有待激活用户可以执行激活')
|
||||
}
|
||||
|
||||
const targetRole = input.role ?? targetUser.role
|
||||
if (!this.canAssignRole(currentUser, targetRole)) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限分配此角色')
|
||||
}
|
||||
|
||||
const updatedUser = await userService.update(userId, {
|
||||
role: input.role ?? targetUser.role,
|
||||
role: targetRole,
|
||||
departmentId: input.departmentId ?? targetUser.departmentId,
|
||||
teamId: input.teamId ?? targetUser.teamId,
|
||||
status: 'ACTIVE',
|
||||
|
||||
@ -243,12 +243,12 @@ class UserService {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
password: hashedPassword,
|
||||
role: input.role ?? 'DESIGNER',
|
||||
role: input.role ?? 'USER',
|
||||
departmentId: input.departmentId,
|
||||
teamId: input.teamId,
|
||||
phone: input.phone,
|
||||
isFirstLogin: input.isFirstLogin ?? false,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
status: input.status ?? 'PENDING',
|
||||
loginSource: input.loginSource ?? 'LOCAL',
|
||||
employeeDirectoryId: input.employeeDirectoryId,
|
||||
dingtalkUserId: input.dingtalkUserId,
|
||||
|
||||
@ -29,8 +29,8 @@ export const createUserSchema = z.object({
|
||||
password: passwordSchema,
|
||||
|
||||
role: z
|
||||
.enum(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER', 'DESIGNER', 'VIEWER'])
|
||||
.default('DESIGNER'),
|
||||
.enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER'])
|
||||
.default('USER'),
|
||||
|
||||
departmentId: z
|
||||
.string()
|
||||
@ -77,7 +77,7 @@ export const updateUserSchema = z.object({
|
||||
.or(z.literal('')),
|
||||
|
||||
role: z
|
||||
.enum(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER', 'DESIGNER', 'VIEWER'])
|
||||
.enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER'])
|
||||
.optional(),
|
||||
|
||||
departmentId: z
|
||||
@ -93,7 +93,7 @@ export const updateUserSchema = z.object({
|
||||
.nullable(),
|
||||
|
||||
status: z
|
||||
.enum(['ACTIVE', 'INACTIVE', 'SUSPENDED'])
|
||||
.enum(['ACTIVE', 'PENDING', 'INACTIVE', 'SUSPENDED'])
|
||||
.optional(),
|
||||
})
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ const mockUsers = {
|
||||
},
|
||||
viewer: {
|
||||
id: 'viewer-1',
|
||||
role: 'VIEWER',
|
||||
role: 'USER',
|
||||
email: 'viewer@example.com',
|
||||
name: 'Viewer',
|
||||
},
|
||||
@ -342,7 +342,7 @@ describe('权限绕过安全测试', () => {
|
||||
|
||||
// 在实际 API 中,应该检查用户角色
|
||||
const canCreate = (user: any): boolean => {
|
||||
const allowedRoles = ['DESIGNER', 'TEAM_LEADER', 'DEPARTMENT_MANAGER', 'ADMIN', 'SUPER_ADMIN'];
|
||||
const allowedRoles = ['DESIGNER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN'];
|
||||
return allowedRoles.includes(user.role);
|
||||
};
|
||||
|
||||
@ -413,7 +413,7 @@ describe('权限绕过安全测试', () => {
|
||||
});
|
||||
|
||||
it('POST /api/products 应该需要设计师或更高权限', () => {
|
||||
const allowedRoles = ['DESIGNER', 'TEAM_LEADER', 'DEPARTMENT_MANAGER', 'ADMIN', 'SUPER_ADMIN'];
|
||||
const allowedRoles = ['DESIGNER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN'];
|
||||
const viewer = mockUsers.viewer;
|
||||
const designer = mockUsers.designer;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user