feat: consolidate workspace release updates

This commit is contained in:
WuYongJie 2026-04-17 12:50:06 +08:00
parent 87bb0e126d
commit 91584f21c6
96 changed files with 3414 additions and 4095 deletions

View File

@ -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 导航标准 |

View File

@ -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(类型检查)`。
- 待完成:
- 用真实样本在浏览器里完成最终点验,确认拖拽、预吸附、松手吸附与右侧提示在真实模型下都一致。

View File

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

View File

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

View File

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

View File

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

View File

@ -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" />
<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 className="flex flex-col">
<span className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary">
System Infrastructure
</div>
</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>
<h1 className="text-apple-2xl font-apple-black tracking-tight uppercase">
管理<span className="text-apple-blue">控制中心</span>
<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 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) => (
<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-[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]"
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="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
)}>
<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 transition-transform duration-500 group-hover:translate-x-0.5" />
<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.2em] transition-colors",
module.color
)}>
<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="max-w-[85%] text-apple-sm font-apple-medium leading-relaxed text-apple-label-tertiary transition-colors group-hover:text-apple-label-secondary">
<p className="text-apple-xs font-apple-medium leading-relaxed text-apple-label-tertiary">
{module.description}
</p>
</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">
{/* 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>
{/* 装饰线 */}
<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>
{/* 底部脚注 */}
<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">
{/* 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>
<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 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>
)
}

View File

@ -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 - 获取材质详情

View File

@ -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 - 获取所有材质(管理员视图,包含非活跃的)

View File

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

View File

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

View File

@ -126,7 +126,7 @@ describe('GET/POST /api/categories', () => {
requestId: 'req-test',
user: {
id: 'viewer-1',
role: 'VIEWER',
role: 'USER',
},
}),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ describe('GET /api/products/[id]/packaging', () => {
id: 'user-1',
email: 'viewer@test.com',
name: '查看协作者',
role: 'VIEWER',
role: 'USER',
status: 'ACTIVE',
},
}),

View File

@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}

View File

@ -30,6 +30,6 @@ export async function GET(request: NextRequest) {
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}

View File

@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}

View File

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

View File

@ -49,6 +49,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] }
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
)
}

View File

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

View File

@ -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',
]
/**

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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={[]}
/>,
)

View File

@ -17,8 +17,8 @@ export default async function MaterialLibraryPage() {
<MaterialLibraryClient
initialSubstrates={substrates}
initialFinishes={finishes}
initialMaterialCategories={materialCategories}
initialSubstrateDictionaries={substrateDictionaries}
initialCategories={materialCategories}
initialDictionaries={substrateDictionaries}
metrics={[
{
label: '板材基材',

View File

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

View File

@ -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 ?? [])
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),
),
[workspace?.relations],
)
const workspacePendingMainNodeCount = workspaceMainNodes.filter(
(node) => node.source !== 'desktop' && !workspaceConnectedNodeIdSet.has(node.nodeId),
).length
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,117 +585,28 @@ 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 (
<div className="relative h-screen w-screen overflow-hidden bg-[#0c0e10] font-apple-system text-white">
<PlatformSplitLayout
title="辅助装配"
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={[
@ -612,18 +616,46 @@ export default function AssistedAssemblyClient({
workspace.inventory.map((item) => item.inventoryId).join('|'),
].join(':')}
candidateName={workspaceTitle}
title="辅助装配"
statusTag="辅助拼装工作台"
warnings={[
...workspace.validation.warnings,
...(workspacePendingMainNodeCount > 0
? [`主装配区还有 ${workspacePendingMainNodeCount} 个节点待完成连接`]
: []),
]}
statusTag="辅助装配工作台"
warnings={workbenchWarnings}
availableVariants={variants}
onChange={setWorkspace}
rightDrawerContent={inspectorContent}
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">
@ -632,6 +664,148 @@ export default function AssistedAssemblyClient({
</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>
)
}

View File

@ -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>
{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>
{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,6 +559,7 @@ function NonIntegratedPendingDetail({
查询
</Button>
</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">
@ -542,6 +567,7 @@ function NonIntegratedPendingDetail({
</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,8 +633,9 @@ 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>
{canWrite ? (
<Button
variant="plain"
size="small"
@ -614,6 +643,7 @@ function NonIntegratedPendingDetail({
>
删除
</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 />

View File

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

View File

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

View File

@ -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)">
已有账号?

View File

@ -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) => {
const handleDeleteUser = useCallback(async (userId: string) => {
try {
setActionError(null);
const confirmed = window.confirm('确定要移除此访问权限吗?此操作不可撤销。');
if (!confirmed) {
return;
}
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, '删除用户失败'));
}
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 : '删除用户失败');
setActionError(error instanceof Error ? error.message : '删除失败');
}
},
[triggerRefresh]
);
}, [triggerRefresh]);
const handleActivateUser = useCallback(
async (userId: string) => {
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, '激活用户失败'));
}
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 : '激活用户失败');
setActionError(error instanceof Error ? error.message : '激活失败');
}
},
[triggerRefresh]
);
}, [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} />
</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>
<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"
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"
)}
>
<UserPlus className="mr-3 h-4 w-4 transition-transform group-hover:scale-110" />
开设新账号
</Button>
<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>
</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="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>
</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 transition-colors group-focus-within:text-apple-blue" />
<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 或 邮箱地址进行全局检索..."
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"
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="flex gap-3">
<div className="relative flex items-center">
<div className="absolute left-4 z-10 text-apple-label-tertiary">
<Users size={14} />
</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} />
</div>
<div className="ml-3 flex items-center gap-3">
<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"
className="h-12 min-w-[140px] rounded-2xl border-none bg-white/80 shadow-apple-xs"
/>
</div>
<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>
{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>
)}
{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">
<div className="overflow-hidden rounded-[2rem] bg-white/80 shadow-apple-xs ring-1 ring-apple-separator/5">
<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={setEditingUser}
onEdit={setSelectedUser}
onDelete={handleDeleteUser}
onActivate={handleActivateUser}
onHandover={setHandoverUser}
/>
</div>
</div>
</main>
</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>
{/* 底部装饰 */}
<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
{/* 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="space-y-8">
<InspectorGroup title="基础通讯 Base Contact">
<InspectorItem icon={Mail} label="工作邮箱" value={selectedUser.email} />
<InspectorItem icon={Phone} label="联系电话" value="未配置" />
</InspectorGroup>
<InspectorGroup title="组织架构 Architecture">
<InspectorItem icon={Briefcase} label="所属部门" value="研发中心 / 3D 实验室" />
<InspectorItem icon={Calendar} label="入职日期" value="2025-04-12" />
</InspectorGroup>
<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>
</footer>
</div>
</InspectorGroup>
</div>
</div>
{showCreateModal && (
<CreateUserModal
isOpen={showCreateModal}
onClose={closeCreateModal}
onSuccess={handleCreateSuccess}
<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>
)
}
/>
)}
{editingUser && (
<EditUserModal
isOpen={!!editingUser}
user={editingUser}
onClose={closeEditModal}
onSuccess={handleEditSuccess}
/>
)}
{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>
)
}

View File

@ -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,6 +722,7 @@ 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>
{canWriteWarehouse ? (
<Button
size="small"
onClick={() => {
@ -684,6 +734,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
<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,6 +790,8 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
<Badge type={location.isActive ? 'success' : 'warning'} variant="subtle">
{location.isActive ? '可用' : '停用'}
</Badge>
{canWriteWarehouse ? (
<>
<Button variant="plain" size="small" onClick={() => {
setEditingLocationId(null)
setLocationForm({
@ -761,6 +816,8 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
<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>
)}
{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,6 +942,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
className="inline-flex items-center gap-1 rounded-apple-md bg-apple-bg-tertiary px-1.5 py-0.5 text-xs text-apple-label-secondary"
>
{loc.code}
{canWriteWarehouse ? (
<button
type="button"
className="text-apple-label-tertiary hover:text-apple-red"
@ -890,6 +950,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
>
×
</button>
) : null}
</span>
))}
</div>
@ -981,12 +1042,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
) : (
<Badge type="info" variant="subtle">待绑定</Badge>
)}
{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,6 +1060,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
className="inline-flex items-center gap-1 rounded-apple-md bg-apple-bg-tertiary px-1.5 py-0.5 text-xs text-apple-label-secondary"
>
{loc.code}
{canWriteWarehouse ? (
<button
type="button"
className="text-apple-label-tertiary hover:text-apple-red"
@ -1004,6 +1068,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
>
×
</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">
{canWriteWarehouse ? (
<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' && (
) : null}
{canWriteWarehouse && opt.value === 'CARTON_PRINT' && (
<Button
variant="plain"
size="small"
@ -1188,6 +1255,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
/>
{/* 新增/编辑库位弹窗 */}
{canWriteWarehouse ? (
<Modal
isOpen={locationModalOpen}
onClose={() => { setLocationModalOpen(false); setEditingLocationId(null) }}
@ -1238,10 +1306,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
/>
</div>
</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}
</>
)
}

View File

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

View File

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

View File

@ -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,6 +174,9 @@ export function SidebarUserCenter() {
<LayoutDashboard size={14} className="opacity-40 group-hover:opacity-100" />
管理总控制台
</Link>
</>
)}
{canManageUsers && (
<Link
href="/users"
onClick={() => setIsOpen(false)}
@ -180,7 +185,6 @@ export function SidebarUserCenter() {
<Users size={14} className="opacity-40 group-hover:opacity-100" />
用户权限管理
</Link>
</>
)}
<button
type="button"

View File

@ -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: '退出登录' }))

View File

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

View File

@ -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,39 +48,72 @@ 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">
<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"
>
<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-bold tracking-tight"
className="text-xl font-apple-black tracking-tight uppercase"
style={{
fontFamily: 'Manrope, sans-serif',
color: 'var(--color-label-primary)',
}}
>
{title}
</h1>
</div>
{description ? (
<p
className="text-sm"
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>
) : null}
@ -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)',
}}
>
{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>

View File

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

View File

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

View File

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

View File

@ -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 || '未设置'}

View File

@ -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: '超级管理员' }] : []),
]}

View File

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

View File

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

View File

@ -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('您没有权限访问此资源');
}

View File

@ -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('主装配区出现两个桌面板时会阻止保存', () => {

View File

@ -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,7 +1912,10 @@ export function attemptSnapNode(
}
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
if (candidate?.isValid) {
if (!candidate?.isValid) {
return snapshot
}
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
node.nodeId === nodeId
? {
@ -1779,18 +1932,6 @@ export function attemptSnapNode(
)
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
}
if (!currentNode || !isNodeCloseEnoughToSnap(currentNode)) {
return snapshot
}
const readyForSnap =
currentNode.zone === 'main'
? snapshot
: moveAssemblyNodes(snapshot, [nodeId], 'main')
return snapNodesIntoMainAssembly(readyForSnap, [nodeId])
}
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
}

View File

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

View File

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

View File

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

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

View File

@ -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,
'没有权限执行此操作',

View File

@ -56,10 +56,9 @@ export interface ApiResponse<T = unknown> {
export type UserRole =
| 'SUPER_ADMIN'
| 'ADMIN'
| 'DEPARTMENT_MANAGER'
| 'TEAM_LEADER'
| 'SUPERVISOR'
| 'DESIGNER'
| 'VIEWER'
| 'USER'
/**
* 认证用户信息

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,7 +46,7 @@ class FeedbackWorkflowService {
where: {
OR: [
{ role: { in: ['SUPER_ADMIN', 'ADMIN'] } },
{ role: 'DEPARTMENT_MANAGER' },
{ role: 'SUPERVISOR' },
],
status: 'ACTIVE',
},

View File

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

View File

@ -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, '非集成产品不存在')

View File

@ -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, '草稿组不存在')

View File

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

View File

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

View File

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

View File

@ -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, '无权交接当前小组范围外的用户')

View File

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

View File

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

View File

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

View File

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