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]` 极低透明度,增加“空间感”。 - **背景文字装饰**:绝对定位的 `text-[80px]` 背景大字(如模块 path),设置 `opacity-[0.03]` 极低透明度,增加“空间感”。
**4. 状态指标看板 (System Health Dashboard)** **4. 状态指标看板 (System Health Dashboard)**
- **应用场景**:`/admin` 首页。 - 应用场景:`/admin` 首页。
- **组成**:Icon 容器 + 上下两行文本(上行为微缩标签,下行为状态值)。 - 组成:Icon 容器 + 上下两行文本(上行为微缩标签,下行为状态值)。
- **配色**:动态使用 `emerald-500` (正常), `blue-500` (活动), `amber-500` (注意)。 - 配色:动态使用 `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. 改动文件清单 ### 11. 改动文件清单
| 文件 | 改动类型 | 备注 | | 文件 | 改动类型 | 备注 |
|------|----------|------| |------|----------|------|
| `src/app/admin/page.tsx` | 重构 | 系统总控制台首页视觉重写 | | `src/app/admin/page.tsx` | 重构 | 系统总控制台首页视觉重载 |
| `src/app/users/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),优化卡片视觉张力。 - `系统总控制台 (/admin)`:引入“中枢枢纽”布局,增加系统状态看板 (Mock),优化卡片视觉张力。
- `身份控制中心 (/users)`:引入“沉浸式页头”与“精密控制条”,整合搜索与筛选器。 - `身份控制中心 (/users)`:引入“沉浸式页头”与“精密控制条”,整合搜索与筛选器。
- **核心特性**:
- **Frosted Glass (磨砂玻璃)**:全局背景与卡片采用 70% 透明度 + 30px 模糊。
- **Visual Hierarchy (视觉层级)**:通过大面积阴影与精致边框区分功能模块。
- **Typography (排版)**:强化了 Manrope 标题与 Inter 技术标签的对比。
- **负责人**:Gemini UI Engineer - **负责人**:Gemini UI Engineer
- **完成日期**:2026-04-16 - **完成日期**: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) ## 进行中任务 (In Progress)
- assisted-assembly(辅助装配):真实样本联调与验收 - assisted-assembly(辅助装配):真实样本联调与验收
- 负责人:Codex - 负责人: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 { enum Role {
SUPER_ADMIN // 超级管理员 SUPER_ADMIN // 超级管理员
ADMIN // 管理员 ADMIN // 管理员
DEPARTMENT_MANAGER // 部门管理员 SUPERVISOR // 主管(按部门/小组管理锚点区分范围)
TEAM_LEADER // 组长
DESIGNER // 设计师 DESIGNER // 设计师
VIEWER // 查看者 USER // 普通用户
} }
// 用户状态枚举 // 用户状态枚举
@ -998,7 +997,7 @@ model User {
phone String? phone String?
// 角色与权限 // 角色与权限
role Role @default(DESIGNER) role Role @default(USER)
// 组织架构 // 组织架构
departmentId String? departmentId String?
@ -1012,7 +1011,7 @@ model User {
managedTeam Team? @relation("TeamLeader") managedTeam Team? @relation("TeamLeader")
// 状态 // 状态
status UserStatus @default(ACTIVE) status UserStatus @default(PENDING)
// 关联 // 关联
createdProducts Product[] @relation("ProductCreator") createdProducts Product[] @relation("ProductCreator")

View File

@ -365,7 +365,7 @@ async function main() {
email: 'design.manager@furniture3d.com', email: 'design.manager@furniture3d.com',
name: '张设计', name: '张设计',
password: hashedPassword, password: hashedPassword,
role: 'DEPARTMENT_MANAGER', role: 'SUPERVISOR',
departmentId: designDept.id, departmentId: designDept.id,
isFirstLogin: false, isFirstLogin: false,
status: 'ACTIVE', status: 'ACTIVE',
@ -383,7 +383,7 @@ async function main() {
email: 'modeling.leader@furniture3d.com', email: 'modeling.leader@furniture3d.com',
name: '李组长', name: '李组长',
password: hashedPassword, password: hashedPassword,
role: 'TEAM_LEADER', role: 'SUPERVISOR',
departmentId: design3DDept.id, departmentId: design3DDept.id,
teamId: modelingTeam.id, teamId: modelingTeam.id,
isFirstLogin: false, isFirstLogin: false,

View File

@ -5,6 +5,7 @@ const { spawn } = require('child_process')
const repoRoot = process.cwd() const repoRoot = process.cwd()
const nextBin = path.join(repoRoot, 'node_modules', 'next', 'dist', 'bin', 'next') const nextBin = path.join(repoRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
const tscBin = path.join(repoRoot, 'node_modules', 'typescript', 'bin', 'tsc') const tscBin = path.join(repoRoot, 'node_modules', 'typescript', 'bin', 'tsc')
const tsconfigPath = path.join(repoRoot, 'tsconfig.json')
function run(command, args) { function run(command, args) {
return new Promise((resolve, reject) => { 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() { async function ensureNextCacheLifeStub() {
const candidatePaths = [ const candidatePaths = [
path.join(repoRoot, '.next', 'types', 'cache-life.d.ts'), 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() { async function main() {
await run(process.execPath, [nextBin, 'typegen']) await run(process.execPath, [nextBin, 'typegen'])
await ensureNextCacheLifeStub() await ensureNextCacheLifeStub()
const tempConfigPath = await createTypeCheckTsconfig() await run(process.execPath, [tscBin, '-p', tsconfigPath, '--noEmit', '--incremental', 'false'])
await run(process.execPath, [tscBin, '-p', tempConfigPath, '--noEmit', '--incremental', 'false'])
} }
main().catch((error) => { main().catch((error) => {

View File

@ -2,11 +2,13 @@ import Link from 'next/link'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { import {
Users, Palette, SwatchBook, Grid, ArrowRight, Users, Palette, SwatchBook, Grid, ArrowLeft, ArrowRight,
ShieldCheck, Activity, Database, Zap, type LucideIcon ShieldCheck, Activity, Database, Zap, Clock,
Cpu, Terminal, ShieldAlert, BarChart3
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { verifyToken } from '@/lib/jwt' import { verifyToken } from '@/lib/jwt'
import Button from '@/components/ui/Button'
const adminModules = [ const adminModules = [
{ {
@ -17,7 +19,7 @@ const adminModules = [
icon: Users, icon: Users,
color: 'text-blue-600', color: 'text-blue-600',
bgColor: 'bg-blue-50', bgColor: 'bg-blue-50',
accentColor: 'group-hover:bg-blue-600', stats: '1,248 Users Active',
}, },
{ {
title: '主题视觉管理', title: '主题视觉管理',
@ -27,7 +29,7 @@ const adminModules = [
icon: Palette, icon: Palette,
color: 'text-indigo-600', color: 'text-indigo-600',
bgColor: 'bg-indigo-50', bgColor: 'bg-indigo-50',
accentColor: 'group-hover:bg-indigo-600', stats: '12 Active Themes',
}, },
{ {
title: '材质物理库', title: '材质物理库',
@ -37,7 +39,7 @@ const adminModules = [
icon: SwatchBook, icon: SwatchBook,
color: 'text-emerald-600', color: 'text-emerald-600',
bgColor: 'bg-emerald-50', bgColor: 'bg-emerald-50',
accentColor: 'group-hover:bg-emerald-600', stats: '450+ Materials',
}, },
{ {
title: '分类体系定义', title: '分类体系定义',
@ -47,130 +49,167 @@ const adminModules = [
icon: Grid, icon: Grid,
color: 'text-amber-600', color: 'text-amber-600',
bgColor: 'bg-amber-50', bgColor: 'bg-amber-50',
accentColor: 'group-hover:bg-amber-600', stats: '32 Categories',
}, },
] ]
export const metadata = { export const metadata = {
title: '管理总控制台 | Precision Atelier', title: '系统总控制台 | Precision Atelier',
description: '后台管理总入口',
} }
export default async function AdminPortalPage() { export default async function AdminPortalPage() {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value const token = cookieStore.get('auth-token')?.value
if (!token) { if (!token) redirect('/login?redirect=/admin')
redirect('/login?redirect=/admin')
}
const payload = verifyToken(token) const payload = verifyToken(token)
if (!payload) { if (!payload) redirect('/login?redirect=/admin')
redirect('/login?redirect=/admin')
}
const allowedRoles = ['SUPER_ADMIN', 'ADMIN'] const allowedRoles = ['SUPER_ADMIN', 'ADMIN']
if (!allowedRoles.includes(payload.role)) { if (!allowedRoles.includes(payload.role)) redirect('/?error=unauthorized')
redirect('/?error=unauthorized')
}
return ( 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="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 left-[-10%] top-[-10%] h-[50%] w-[50%] rounded-full bg-blue-500/5 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] h-[40%] w-[40%] rounded-full bg-indigo-400/10 blur-[120px]" /> <div className="absolute bottom-[-10%] right-[-10%] h-[50%] w-[50%] rounded-full bg-indigo-500/5 blur-[120px]" />
<div <div className="absolute inset-0 opacity-[0.02] mix-blend-overlay" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
className="absolute inset-0 opacity-[0.015] mix-blend-overlay"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '32px 32px',
}}
/>
</div> </div>
<main className="relative z-10 mx-auto max-w-6xl px-8 py-12 lg:py-24"> <main className="relative z-10 mx-auto max-w-[1400px] px-10 py-16">
{/* 顶部状态栏 Concept */} {/* Header Section */}
<div className="mb-12 flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-700"> <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-4"> <div className="flex items-center gap-8">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white shadow-apple-sm ring-1 ring-apple-separator/5"> <div className="flex items-start gap-4">
<ShieldCheck className="h-6 w-6 text-apple-blue" /> <Link href="/">
<Button
variant="plain"
size="small"
className="mt-1 h-10 min-w-0 rounded-xl border border-apple-separator/10 bg-white/50 px-3 shadow-apple-xs transition-all hover:bg-white hover:shadow-apple-sm active:scale-95"
>
<ArrowLeft className="h-5 w-5 text-apple-label-tertiary" />
</Button>
</Link>
<div className="relative flex h-24 w-24 items-center justify-center rounded-[2.5rem] bg-black shadow-apple-2xl ring-1 ring-white/20">
<ShieldCheck className="h-12 w-12 text-white" />
<div className="absolute -bottom-1 -right-1 h-8 w-8 rounded-full bg-emerald-500 ring-4 ring-white flex items-center justify-center">
<Zap size={14} className="text-white" />
</div> </div>
<div className="flex flex-col"> </div>
<span className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary"> </div>
System Infrastructure <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> </span>
<h1 className="text-apple-2xl font-apple-black tracking-tight uppercase"> <div className="h-[1px] w-12 bg-apple-separator/20" />
管理<span className="text-apple-blue">控制中心</span> </div>
<h1 className="text-4xl font-apple-black tracking-tighter uppercase">
系统<span className="text-apple-blue">总控制台</span>
</h1> </h1>
<p className="mt-2 text-sm font-apple-medium text-apple-label-tertiary">
集成式基础设施管理中心 · 实时监控系统运行状态与核心资产分发
</p>
</div> </div>
</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="hidden md:flex gap-4">
<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"> <QuickMetric icon={Cpu} label="CPU 负载" value="12.4%" />
{adminModules.map((module) => ( <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 <Link
key={module.href} key={module.href}
href={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="mb-10 flex items-start justify-between">
<div className={cn( <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)}>
"mb-8 flex h-16 w-16 items-center justify-center rounded-2xl shadow-apple-xs transition-all duration-500 group-hover:scale-110 group-hover:shadow-apple-md",
module.bgColor,
module.color
)}>
<module.icon className="h-8 w-8" /> <module.icon className="h-8 w-8" />
</div> </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"> <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> </div>
<div className="relative"> <div className="relative">
<span className={cn( <span className={cn("mb-2 block text-[10px] font-apple-black uppercase tracking-[0.3em]", module.color)}>
"mb-2 block text-[10px] font-apple-black uppercase tracking-[0.2em] transition-colors",
module.color
)}>
{module.subtitle} {module.subtitle}
</span> </span>
<h2 className="mb-4 text-apple-xl font-apple-black tracking-tight text-apple-label-primary"> <h2 className="mb-4 text-apple-xl font-apple-black tracking-tight text-apple-label-primary">
{module.title} {module.title}
</h2> </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} {module.description}
</p> </p>
</div> </div>
{/* 背景装饰性文字 */} {/* Footer Stats inside card */}
<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"> <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()} {module.href.split('/').pop()}
</span> </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> </Link>
))} ))}
</div> </div>
{/* 底部脚注 */}
<footer className="mt-24 flex flex-col items-center border-t border-apple-separator/10 pt-12 animate-in fade-in duration-1000 delay-500 fill-mode-both">
<div className="mb-6 flex items-center gap-8">
<FooterLink label="系统文档" />
<FooterLink label="权限策略" />
<FooterLink label="更新日志" />
</div> </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 Precision Atelier · Built for Next Generation Design
</p> </p>
</footer> </footer>
@ -179,38 +218,42 @@ export default async function AdminPortalPage() {
) )
} }
function StatusIndicator({ function QuickMetric({ icon: Icon, label, value, color }: { icon: any, label: string, value: string, color?: string }) {
icon: Icon,
label,
value,
color,
}: {
icon: LucideIcon
label: string
value: string
color: string
}) {
return ( return (
<div className="flex items-center gap-3"> <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-8 w-8 items-center justify-center rounded-lg bg-white shadow-apple-xs ring-1 ring-apple-separator/5"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-apple-xs">
<Icon className="h-4 w-4 text-apple-label-tertiary" /> <Icon className="h-5 w-5 text-apple-label-tertiary" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[9px] font-apple-bold uppercase tracking-widest text-apple-label-tertiary opacity-60"> <span className="text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary opacity-60">{label}</span>
{label} <span className={cn("text-sm font-apple-black uppercase tracking-tight", color || "text-apple-label-primary")}>{value}</span>
</span>
<span className={cn("text-[11px] font-apple-black uppercase", color)}>
{value}
</span>
</div> </div>
</div> </div>
) )
} }
function FooterLink({ label }: { label: string }) { function HealthBar({ label, progress, color = "bg-emerald-500" }: { label: string, progress: number, color?: string }) {
return ( return (
<button className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary hover:text-apple-blue transition-colors"> <div className="space-y-2">
{label} <div className="flex items-center justify-between text-[10px] font-apple-bold uppercase tracking-widest">
</button> <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 - 获取材质详情 * 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 - 获取所有材质(管理员视图,包含非活跃的) * 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 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) { export async function POST(request: NextRequest) {
return handleRequest(request, async ({ requestId }) => { return handleRequest(request, async ({ requestId }) => {

View File

@ -2,7 +2,7 @@
* 用户注册 API * 用户注册 API
* POST /api/auth/register * POST /api/auth/register
* *
* @security RISK-011 - 防止权限提升:注册只允许创建 DESIGNER 角色 * @security RISK-011 - 防止权限提升:注册只允许创建 USER 角色,且必须待后台激活
*/ */
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
@ -17,13 +17,13 @@ interface RegisterRequest {
email: string email: string
name: string name: string
password: string password: string
// role 参数被忽略,注册只允许创建 DESIGNER 角色 // role 参数被忽略,注册只允许创建 USER 角色
departmentId?: string departmentId?: string
teamId?: string teamId?: string
} }
// 注册允许的默认角色(防止权限提升) // 注册允许的默认角色(防止权限提升)
const REGISTER_DEFAULT_ROLE = 'DESIGNER' as const const REGISTER_DEFAULT_ROLE = 'USER' as const
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return handleRequest( return handleRequest(
@ -33,12 +33,13 @@ export async function POST(request: NextRequest) {
const { email, name, password, departmentId, teamId } = body const { email, name, password, departmentId, teamId } = body
// 调用服务层创建用户 // 调用服务层创建用户
// 强制使用 DESIGNER 角色,忽略用户传入的 role 参数,防止权限提升攻击 // 强制使用 USER 角色,忽略用户传入的 role 参数,防止权限提升攻击
const user = await userService.create({ const user = await userService.create({
email, email,
name, name,
password, password,
role: REGISTER_DEFAULT_ROLE, role: REGISTER_DEFAULT_ROLE,
status: 'PENDING',
departmentId, departmentId,
teamId, teamId,
isFirstLogin: false, isFirstLogin: false,
@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
}) })
return jsonSuccess( return jsonSuccess(
{ message: '注册成功', user }, { message: '注册成功,请等待管理员激活', user },
201, 201,
ctx.requestId ctx.requestId
) )

View File

@ -126,7 +126,7 @@ describe('GET/POST /api/categories', () => {
requestId: 'req-test', requestId: 'req-test',
user: { user: {
id: 'viewer-1', 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 { ErrorCode } from '@/server/core/errors'
import { commonComponentService } from '@/server/services' 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) { export async function GET(request: NextRequest) {
return handleRequest( return handleRequest(

View File

@ -3,7 +3,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { AppError } from '@/server/core/errors' import { AppError } from '@/server/core/errors'
import { commonComponentService } from '@/server/services' 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( export async function GET(
request: NextRequest, request: NextRequest,

View File

@ -4,7 +4,7 @@ import { ErrorCode } from '@/server/core/errors'
import { commonComponentService } from '@/server/services' import { commonComponentService } from '@/server/services'
const MAX_MODEL_FILE_SIZE = 50 * 1024 * 1024 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) { export async function GET(request: NextRequest) {
return handleRequest( return handleRequest(

View File

@ -33,8 +33,8 @@ export async function GET(
role: user.role, role: user.role,
departmentId: user.departmentId, departmentId: user.departmentId,
teamId: user.teamId, teamId: user.teamId,
managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined, managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined, managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
}) })
return jsonSuccess(result, 200, ctx.requestId) return jsonSuccess(result, 200, ctx.requestId)
@ -88,8 +88,8 @@ export async function PUT(
role: user.role, role: user.role,
departmentId: user.departmentId, departmentId: user.departmentId,
teamId: user.teamId, teamId: user.teamId,
managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined, managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined, managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
} }
) )
@ -122,8 +122,8 @@ export async function DELETE(
role: user.role, role: user.role,
departmentId: user.departmentId, departmentId: user.departmentId,
teamId: user.teamId, teamId: user.teamId,
managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined, managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined, managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
}) })
return jsonSuccess({ message: '反馈已删除' }, 200, ctx.requestId) return jsonSuccess({ message: '反馈已删除' }, 200, ctx.requestId)

View File

@ -46,8 +46,8 @@ export async function GET(request: NextRequest) {
departmentId: user.departmentId, departmentId: user.departmentId,
teamId: user.teamId, teamId: user.teamId,
// 对于部门管理员,使用 departmentId 作为 managedDepartmentId // 对于部门管理员,使用 departmentId 作为 managedDepartmentId
managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined, managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : undefined, managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : undefined,
} }
) )
@ -111,8 +111,8 @@ export async function POST(request: NextRequest) {
role: user.role, role: user.role,
departmentId: user.departmentId, departmentId: user.departmentId,
teamId: user.teamId, teamId: user.teamId,
managedDepartmentId: user.role === 'DEPARTMENT_MANAGER' ? user.departmentId : undefined, managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : undefined,
managedTeamId: user.role === 'TEAM_LEADER' ? user.teamId : 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 { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { AppError } from '@/server/core/errors' import { AppError } from '@/server/core/errors'
import { hardwareService } from '@/server/services' 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] * GET /api/hardware/[id]

View File

@ -11,7 +11,7 @@ import { z } from 'zod'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response' import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors' import { ErrorCode } from '@/server/core/errors'
import { hardwareService } from '@/server/services' 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({ const CreateMappingSchema = z.object({

View File

@ -11,7 +11,7 @@ import { hardwareService } from '@/server/services'
// 文件大小限制(50MB) // 文件大小限制(50MB)
const MAX_MODEL_FILE_SIZE = 50 * 1024 * 1024 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 * GET /api/hardware

View File

@ -15,7 +15,7 @@ import { z } from 'zod'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response' import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors' import { ErrorCode } from '@/server/core/errors'
import { hardwareService } from '@/server/services' 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 ==================== // ==================== 请求验证 Schema ====================

View File

@ -34,7 +34,7 @@ export async function GET(request: NextRequest) {
const pageSize = parseInt(searchParams.get('pageSize') || '20') const pageSize = parseInt(searchParams.get('pageSize') || '20')
// 部门管理员只能查看本部门的日志 // 部门管理员只能查看本部门的日志
const departmentId = user.role === 'DEPARTMENT_MANAGER' && user.departmentId const departmentId = user.role === 'SUPERVISOR' && user.departmentId
? user.departmentId ? user.departmentId
: undefined : undefined

View File

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

View File

@ -31,6 +31,6 @@ export async function GET(request: NextRequest) {
throw error 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 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 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 throw error
} }
}, },
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] } { allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }
) )
} }
@ -91,6 +91,6 @@ export async function DELETE(
throw error 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 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.anything(),
expect.any(Function), expect.any(Function),
expect.objectContaining({ expect.objectContaining({
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER'], allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'],
}), }),
) )
expect(response).toEqual( expect(response).toEqual(
@ -128,7 +128,7 @@ describe('GET/POST /api/users/[id]/handover', () => {
expect.anything(), expect.anything(),
expect.any(Function), expect.any(Function),
expect.objectContaining({ expect.objectContaining({
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER'], allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'],
}), }),
) )
expect(response).toEqual( expect(response).toEqual(

View File

@ -9,12 +9,13 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response' import { handleRequest, jsonSuccess } from '@/server/core/response'
import { userHandoverService } from '@/server/services/user-handover.service' 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', 'SUPER_ADMIN',
'ADMIN', 'ADMIN',
'DEPARTMENT_MANAGER', 'SUPERVISOR_DEPARTMENT',
'TEAM_LEADER', 'SUPERVISOR_TEAM',
] ]
/** /**

View File

@ -98,7 +98,7 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
mockUpdateUser.mockResolvedValue({ mockUpdateUser.mockResolvedValue({
id: 'user-1', id: 'user-1',
name: '新名字', name: '新名字',
role: 'TEAM_LEADER', role: 'SUPERVISOR',
departmentId: 'dept-2', departmentId: 'dept-2',
teamId: null, teamId: null,
status: 'ACTIVE', status: 'ACTIVE',
@ -107,7 +107,7 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
const response = await PUT( const response = await PUT(
createRequest({ createRequest({
name: '新名字', name: '新名字',
role: 'TEAM_LEADER', role: 'SUPERVISOR',
departmentId: 'dept-2', departmentId: 'dept-2',
teamId: null, teamId: null,
phone: null, phone: null,
@ -124,7 +124,7 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
email: undefined, email: undefined,
name: '新名字', name: '新名字',
password: undefined, password: undefined,
role: 'TEAM_LEADER', role: 'SUPERVISOR',
departmentId: 'dept-2', departmentId: 'dept-2',
teamId: null, teamId: null,
phone: null, phone: null,

View File

@ -8,8 +8,9 @@ import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response' import { handleRequest, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service' import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client' 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) { export async function GET(request: NextRequest) {
return handleRequest( return handleRequest(

View File

@ -10,8 +10,9 @@ import { handleRequest, jsonSuccess, jsonSuccessWithPagination, jsonError } from
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import { userManagementService } from '@/server/services/user-management.service' import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client' 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) { export async function GET(request: NextRequest) {
return handleRequest( 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 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([ const ASSET_REVIEW_ROLES = new Set([
'SUPER_ADMIN', 'SUPER_ADMIN',
'ADMIN', 'ADMIN',
'DEPARTMENT_MANAGER', 'SUPERVISOR',
'TEAM_LEADER', 'SUPERVISOR',
]) ])
function canReviewAssets(role: string): boolean { function canReviewAssets(role: string): boolean {
@ -2273,8 +2273,11 @@ export default function AssetCenterClient({
<> <>
<PlatformSplitLayout <PlatformSplitLayout
title="资产中心" title="资产中心"
backHref="/"
backLabel="管理中心"
metrics={metrics} metrics={metrics}
rightPanelClassName="w-[360px] max-w-[32vw]" rightPanelClassName="w-[420px] max-w-[38vw]"
leftContent={ leftContent={
<ModelPreviewPanel <ModelPreviewPanel
modelUrl={selectedVariant?.modelUrl || null} modelUrl={selectedVariant?.modelUrl || null}

View File

@ -823,7 +823,7 @@ describe('AssetCenterClient workflow', () => {
id: 'user-1', id: 'user-1',
name: 'Test User', name: 'Test User',
email: 'test@example.com', email: 'test@example.com',
role: 'TEAM_LEADER', role: 'SUPERVISOR',
avatar: null, avatar: null,
department: null, department: null,
}} }}

View File

@ -31,7 +31,21 @@ jest.mock('@/components/ui/Toast', () => ({
jest.mock('@/components/platform/PlatformSplitLayout', () => ({ jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true, __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', () => ({ jest.mock('@/lib/open-product/platform-api', () => ({
@ -102,8 +116,8 @@ function renderClient() {
updatedAt: new Date(), updatedAt: new Date(),
}] as SubstrateDTO[]} }] as SubstrateDTO[]}
initialFinishes={[] as MaterialDTO[]} initialFinishes={[] as MaterialDTO[]}
initialMaterialCategories={materialCategories} initialCategories={materialCategories}
initialSubstrateDictionaries={dictionaries} initialDictionaries={dictionaries}
metrics={[]} metrics={[]}
/>, />,
) )

View File

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

View File

@ -158,13 +158,14 @@ describe('AssistedAssemblyClient', () => {
expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument() expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument()
expect(screen.getByText('库存:2')).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(() => { await waitFor(() => {
expect(screen.getByText('库存:1')).toBeInTheDocument() expect(screen.getByText('库存:1')).toBeInTheDocument()
}) })
await actor.click(screen.getByRole('checkbox', { name: /桌腿A/ })) await actor.click(screen.getByRole('button', { name: /桌腿A/ }))
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('库存:2')).toBeInTheDocument() expect(screen.getByText('库存:2')).toBeInTheDocument()
@ -212,7 +213,7 @@ describe('AssistedAssemblyClient', () => {
await actor.click(screen.getByRole('button', { name: /准备可保存工作台/ })) await actor.click(screen.getByRole('button', { name: /准备可保存工作台/ }))
await actor.clear(screen.getByLabelText('草稿组名称')) await actor.clear(screen.getByLabelText('草稿组名称'))
await actor.type(screen.getByLabelText('草稿组名称'), '测试草稿组') await actor.type(screen.getByLabelText('草稿组名称'), '测试草稿组')
await actor.click(screen.getByRole('button', { name: /保存草稿组/ })) await actor.click(screen.getByRole('button', { name: /仅保存草稿/ }))
await waitFor(() => { await waitFor(() => {
expect(createDraftGroup).toHaveBeenCalledWith( expect(createDraftGroup).toHaveBeenCalledWith(
@ -230,6 +231,8 @@ describe('AssistedAssemblyClient', () => {
}) })
it('资产中心带料进入时,会保留来源资产族并保留草稿规格款作为默认选择', async () => { it('资产中心带料进入时,会保留来源资产族并保留草稿规格款作为默认选择', async () => {
const actor = userEvent.setup()
mockSearchParams = new URLSearchParams( mockSearchParams = new URLSearchParams(
'entryMode=asset-center&source=asset-center&desktopFamilyIds=family-board-draft', 'entryMode=asset-center&source=asset-center&desktopFamilyIds=family-board-draft',
) )
@ -268,14 +271,21 @@ describe('AssistedAssemblyClient', () => {
) )
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('checkbox', { name: /草稿桌面/ })).toBeChecked()
expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument() expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument()
expect(screen.getByText('库存:1')).toBeInTheDocument() expect(screen.getByText('库存:1')).toBeInTheDocument()
expect(screen.getByText('首个规格款:variant-board-draft')).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 () => { it('源 SKU 新增进入时,只预选来源装配涉及的资产族,不会回退成全量默认选中', async () => {
const actor = userEvent.setup()
mockSearchParams = new URLSearchParams( mockSearchParams = new URLSearchParams(
'entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&desktopFamilyIds=family-board-source', 'entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&desktopFamilyIds=family-board-source',
) )
@ -339,8 +349,13 @@ describe('AssistedAssemblyClient', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('库存:1')).toBeInTheDocument() expect(screen.getByText('库存:1')).toBeInTheDocument()
expect(screen.getByText('首个规格款:variant-board-source')).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 { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' 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 Button from '@/components/ui/Button'
import Input from '@/components/ui/Input' import Input from '@/components/ui/Input'
import { AssemblyWorkbenchV2 } from '@/components/platform/AssemblyWorkbenchV2' import { AssemblyWorkbenchV2 } from '@/components/platform/AssemblyWorkbenchV2'
@ -22,6 +33,7 @@ import {
} from '@/lib/open-product/manual-assembly-launch' } from '@/lib/open-product/manual-assembly-launch'
import type { OpenProductAssetFamily } from '@/lib/open-product/types' import type { OpenProductAssetFamily } from '@/lib/open-product/types'
import type { AssetFamilyDTO } from '@/server/services' import type { AssetFamilyDTO } from '@/server/services'
import { cn } from '@/lib/utils'
interface AssistedAssemblyClientProps { interface AssistedAssemblyClientProps {
families: AssetFamilyDTO[] families: AssetFamilyDTO[]
@ -36,8 +48,9 @@ interface AssistedAssemblyClientProps {
} | null } | null
} }
interface CompactFamilyListProps { interface FloatingDockProps {
title: string title: string
icon: typeof Layers3
families: AssetFamilyDTO[] families: AssetFamilyDTO[]
selectedIds: string[] selectedIds: string[]
availableVariantMap: Map<string, AssistedAssemblyVariant[]> availableVariantMap: Map<string, AssistedAssemblyVariant[]>
@ -51,56 +64,6 @@ function toggleSelection(list: string[], value: string) {
return [...list, value] 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']) { function getVariantStatusPriority(status: AssistedAssemblyVariant['status']) {
if (status === 'AVAILABLE') return 0 if (status === 'AVAILABLE') return 0
if (status === 'DRAFT') return 1 if (status === 'DRAFT') return 1
@ -202,6 +165,123 @@ function resolveWeakCategoryId(selectedFamilies: AssetFamilyDTO[]) {
return categoryIds.length === 1 ? categoryIds[0] : null 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({ export default function AssistedAssemblyClient({
families, families,
variants, variants,
@ -300,14 +380,17 @@ export default function AssistedAssemblyClient({
}), }),
[connectorFamilies, connectorIds, desktopFamilies, desktopIds, legFamilies, legIds], [connectorFamilies, connectorIds, desktopFamilies, desktopIds, legFamilies, legIds],
) )
const selectedFamilyCount = const selectedFamilyCount =
selectedFamilyMap.desktops.length + selectedFamilyMap.desktops.length +
selectedFamilyMap.legs.length + selectedFamilyMap.legs.length +
selectedFamilyMap.connectors.length selectedFamilyMap.connectors.length
const workspaceTitle = useMemo( const workspaceTitle = useMemo(
() => buildAssemblyDefaultName(selectedFamilyMap), () => buildAssemblyDefaultName(selectedFamilyMap),
[selectedFamilyMap], [selectedFamilyMap],
) )
const weakCategoryId = useMemo( const weakCategoryId = useMemo(
() => () =>
resolveWeakCategoryId([ resolveWeakCategoryId([
@ -317,25 +400,35 @@ export default function AssistedAssemblyClient({
]), ]),
[selectedFamilyMap], [selectedFamilyMap],
) )
const saveValidation = workspace ? deriveAssemblySaveValidation(workspace) : null const saveValidation = workspace ? deriveAssemblySaveValidation(workspace) : null
const canSave = const canSave =
Boolean(workspace) && Boolean(workspace) &&
Boolean(draftGroupName.trim()) && Boolean(draftGroupName.trim()) &&
selectedFamilyCount > 0 && selectedFamilyCount > 0 &&
saveValidation?.level !== 'BLOCKED' saveValidation?.level !== 'BLOCKED'
const workspaceMainNodes = workspace?.nodes.filter((node) => node.zone === 'main') ?? []
const workspaceConnectedNodeIdSet = useMemo( const workspaceStats = useMemo(() => {
() => if (!workspace) {
new Set( return { nodeCount: 0, relationCount: 0, pendingMainNodeCount: 0 }
(workspace?.relations ?? []) }
const connectedNodeIdSet = new Set(
workspace.relations
.filter((relation) => relation.status === 'CONNECTED') .filter((relation) => relation.status === 'CONNECTED')
.map((relation) => relation.sourceNodeId), .map((relation) => relation.sourceNodeId),
),
[workspace?.relations],
) )
const workspacePendingMainNodeCount = workspaceMainNodes.filter( const mainNodes = workspace.nodes.filter((node) => node.zone === 'main')
(node) => node.source !== 'desktop' && !workspaceConnectedNodeIdSet.has(node.nodeId),
).length 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 = const stageStatusLabel =
selectedFamilyCount === 0 ? '待选料' : canSave ? '可保存' : '装配中' selectedFamilyCount === 0 ? '待选料' : canSave ? '可保存' : '装配中'
@ -492,117 +585,28 @@ export default function AssistedAssemblyClient({
} }
} }
const inspectorContent = ( const workbenchWarnings = workspace
<div className="space-y-4"> ? [
<section className="space-y-1.5"> ...workspace.validation.warnings,
<div className="flex items-start justify-between gap-3"> ...(workspaceStats.pendingMainNodeCount > 0
<div className="min-w-0"> ? [`主装配区还有 ${workspaceStats.pendingMainNodeCount} 个节点待完成连接`]
<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>
)
return ( return (
<div className="relative h-screen w-screen overflow-hidden bg-[#0c0e10] font-apple-system text-white">
<PlatformSplitLayout <PlatformSplitLayout
title="辅助装配" title="辅助装配工作台"
backHref="/asset-center"
backLabel="资产中心"
metrics={metrics} metrics={metrics}
feedback={feedback?.tone === 'error' ? feedback : null} 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={ leftContent={
workspace ? ( workspace ? (
<div className="relative h-full w-full overflow-hidden rounded-[2.5rem] bg-[#16191d] shadow-2xl">
<AssemblyWorkbenchV2 <AssemblyWorkbenchV2
value={workspace} value={workspace}
workspaceKey={[ workspaceKey={[
@ -612,18 +616,46 @@ export default function AssistedAssemblyClient({
workspace.inventory.map((item) => item.inventoryId).join('|'), workspace.inventory.map((item) => item.inventoryId).join('|'),
].join(':')} ].join(':')}
candidateName={workspaceTitle} candidateName={workspaceTitle}
title="辅助装配" statusTag="辅助装配工作台"
statusTag="辅助拼装工作台" warnings={workbenchWarnings}
warnings={[
...workspace.validation.warnings,
...(workspacePendingMainNodeCount > 0
? [`主装配区还有 ${workspacePendingMainNodeCount} 个节点待完成连接`]
: []),
]}
availableVariants={variants} availableVariants={variants}
onChange={setWorkspace} 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="flex h-full items-center justify-center px-6 text-center">
<div className="rounded-full border border-white/65 bg-white/78 px-5 py-2.5 text-sm text-apple-label-secondary shadow-[0_12px_32px_rgba(55,70,78,0.1)] backdrop-blur-xl"> <div className="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> </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 [activeTab, setActiveTab] = useState<PendingTabKey>('pending')
const [spuKeyword, setSpuKeyword] = useState('') const [spuKeyword, setSpuKeyword] = useState('')
const [skuKeyword, setSkuKeyword] = useState('') const [skuKeyword, setSkuKeyword] = useState('')
@ -321,7 +327,7 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
<tr className="border-b border-apple-separator"> <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> <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">SPU</th>
<th className="px-3 py-2.5 font-apple-medium">SKU</th> <th className="px-3 py-2.5 font-apple-medium">SKU</th>
<th className="px-3 py-2.5 font-apple-medium">系列</th> <th className="px-3 py-2.5 font-apple-medium">系列</th>
@ -332,7 +338,10 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
<tbody> <tbody>
{filteredList.length === 0 ? ( {filteredList.length === 0 ? (
<tr> <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。 当前筛选条件下没有找到 SKU。
</td> </td>
</tr> </tr>
@ -347,11 +356,13 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
SKU 图 SKU 图
</div> </div>
</td> </td>
{canWrite ? (
<td className="px-3 py-3"> <td className="px-3 py-3">
<Button variant="plain" size="small"> <Button variant="plain" size="small">
上传 上传
</Button> </Button>
</td> </td>
) : null}
<td className="px-3 py-3 text-apple-label-primary">{sku.spuCode}</td> <td className="px-3 py-3 text-apple-label-primary">{sku.spuCode}</td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<p className="font-apple-medium text-apple-label-primary">{sku.code}</p> <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"> <td className="px-3 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small">查看</Button> <Button size="small">查看</Button>
{canWrite ? (
<Button variant="plain" size="small"> <Button variant="plain" size="small">
暂缓 暂缓
</Button> </Button>
) : null}
</div> </div>
</td> </td>
</tr> </tr>
@ -412,8 +425,10 @@ function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
function NonIntegratedPendingDetail({ function NonIntegratedPendingDetail({
initialProducts, initialProducts,
canWrite,
}: { }: {
initialProducts: NonIntegratedProductRecord[] initialProducts: NonIntegratedProductRecord[]
canWrite: boolean
}) { }) {
const [products, setProducts] = useState<NonIntegratedProductRecord[]>(() => const [products, setProducts] = useState<NonIntegratedProductRecord[]>(() =>
mergeNonIntegratedProducts(initialProducts, listNonIntegratedProducts()), mergeNonIntegratedProducts(initialProducts, listNonIntegratedProducts()),
@ -433,6 +448,11 @@ function NonIntegratedPendingDetail({
}) })
async function handleDelete(productId: string) { async function handleDelete(productId: string) {
if (!canWrite) {
window.alert('普通用户只有查看和下载权限,不能删除正式入库资料。')
return
}
if (!window.confirm('确认删除这条非集成产品测试数据吗?')) { if (!window.confirm('确认删除这条非集成产品测试数据吗?')) {
return 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="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="border-b border-apple-separator px-4 py-3">
<div className="flex flex-col gap-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"> <label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>SPU</span> <span>SPU</span>
<input <input
@ -535,6 +559,7 @@ function NonIntegratedPendingDetail({
查询 查询
</Button> </Button>
</div> </div>
{canWrite ? (
<div className="flex items-end justify-end"> <div className="flex items-end justify-end">
<Link href="/intake/non-integrated/new" className="w-full"> <Link href="/intake/non-integrated/new" className="w-full">
<Button size="small" className="h-9 w-full px-4 text-xs"> <Button size="small" className="h-9 w-full px-4 text-xs">
@ -542,6 +567,7 @@ function NonIntegratedPendingDetail({
</Button> </Button>
</Link> </Link>
</div> </div>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -565,7 +591,9 @@ function NonIntegratedPendingDetail({
<tr> <tr>
<td colSpan={7} className="px-4 py-10 text-center text-xs text-apple-label-secondary"> <td colSpan={7} className="px-4 py-10 text-center text-xs text-apple-label-secondary">
{products.length === 0 {products.length === 0
? canWrite
? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。' ? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。'
: '当前还没有非集成产品。'
: '当前查询条件下没有找到资料。'} : '当前查询条件下没有找到资料。'}
</td> </td>
</tr> </tr>
@ -605,8 +633,9 @@ function NonIntegratedPendingDetail({
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/intake/non-integrated/${encodeURIComponent(product.id)}`}> <Link href={`/intake/non-integrated/${encodeURIComponent(product.id)}`}>
<Button size="small">修改</Button> <Button size="small">{canWrite ? '修改' : '查看'}</Button>
</Link> </Link>
{canWrite ? (
<Button <Button
variant="plain" variant="plain"
size="small" size="small"
@ -614,6 +643,7 @@ function NonIntegratedPendingDetail({
> >
删除 删除
</Button> </Button>
) : null}
</div> </div>
</td> </td>
</tr> </tr>
@ -705,6 +735,7 @@ function FormalIntakeWorkbench({
export default function IntakeClient(props: IntakeClientProps) { export default function IntakeClient(props: IntakeClientProps) {
const [activeSection, setActiveSection] = useState<IntakeSection>('pending') const [activeSection, setActiveSection] = useState<IntakeSection>('pending')
const [pendingEntryTab, setPendingEntryTab] = useState<IntakeEntryTab>('nonIntegrated') const [pendingEntryTab, setPendingEntryTab] = useState<IntakeEntryTab>('nonIntegrated')
const canWriteIntake = props.user.role !== 'USER'
if (!isEditorMode(props)) { if (!isEditorMode(props)) {
const skuList = const skuList =
@ -725,9 +756,12 @@ export default function IntakeClient(props: IntakeClientProps) {
if (activeSection === 'pending') { if (activeSection === 'pending') {
leftContent = leftContent =
pendingEntryTab === 'integrated' ? ( pendingEntryTab === 'integrated' ? (
<PendingSkuDetail skuList={skuList} /> <PendingSkuDetail skuList={skuList} canWrite={canWriteIntake} />
) : ( ) : (
<NonIntegratedPendingDetail initialProducts={props.nonIntegratedProducts ?? []} /> <NonIntegratedPendingDetail
initialProducts={props.nonIntegratedProducts ?? []}
canWrite={canWriteIntake}
/>
) )
} else if (activeSection === 'bom') { } else if (activeSection === 'bom') {
leftContent = <BomDetail /> leftContent = <BomDetail />
@ -779,9 +813,9 @@ export default function IntakeClient(props: IntakeClientProps) {
if (activeSection === 'pending') { if (activeSection === 'pending') {
leftContent = leftContent =
pendingEntryTab === 'integrated' ? ( pendingEntryTab === 'integrated' ? (
<PendingSkuDetail skuList={skuList} /> <PendingSkuDetail skuList={skuList} canWrite={canWriteIntake} />
) : ( ) : (
<NonIntegratedPendingDetail initialProducts={[]} /> <NonIntegratedPendingDetail initialProducts={[]} canWrite={canWriteIntake} />
) )
} else if (activeSection === 'bom') { } else if (activeSection === 'bom') {
leftContent = <BomDetail /> leftContent = <BomDetail />

View File

@ -65,7 +65,7 @@ export default async function ProductDetailPage({ params }: ProductPageProps) {
redirect('/products') 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({ const product = await prisma.product.findUnique({

View File

@ -239,7 +239,7 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) {
redirect('/products') redirect('/products')
} }
const canViewSensitive = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'].includes(authUser.role) const canViewSensitive = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'].includes(authUser.role)
// 获取产品信息(包含完整 SKU 数据) // 获取产品信息(包含完整 SKU 数据)
const product = await prisma.product.findUnique({ const product = await prisma.product.findUnique({

View File

@ -73,7 +73,7 @@ export default function RegisterPage() {
return; return;
} }
toast.success('注册成功,正在返回登录页...'); toast.success('注册成功,请等待管理员激活');
router.push(`/login?registered=1&identifier=${encodeURIComponent(email)}`); router.push(`/login?registered=1&identifier=${encodeURIComponent(email)}`);
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
@ -109,7 +109,7 @@ export default function RegisterPage() {
</h1> </h1>
<p className="mt-4 xl:mt-[18px] font-normal text-[20px] xl:text-[24px] text-[#4b5563]"> <p className="mt-4 xl:mt-[18px] font-normal text-[20px] xl:text-[24px] text-[#4b5563]">
创建账号后即可进入系统开始试用 注册后先等待后台激活,再进入系统
</p> </p>
</div> </div>
@ -132,7 +132,7 @@ export default function RegisterPage() {
<div className="hidden lg:block"> <div className="hidden lg:block">
<LargeTitle className="text-(--color-label-primary)">注册</LargeTitle> <LargeTitle className="text-(--color-label-primary)">注册</LargeTitle>
<Body className="text-(--color-label-secondary) mt-2"> <Body className="text-(--color-label-secondary) mt-2">
创建一个普通设计账号,注册后可直接返回登录 所有新账号都会先进入待激活状态,管理员审核通过后才能登录
</Body> </Body>
</div> </div>
</div> </div>
@ -203,6 +203,12 @@ export default function RegisterPage() {
注册账号 注册账号
</Button> </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"> <div className="text-center">
<Footnote className="text-(--color-label-secondary)"> <Footnote className="text-(--color-label-secondary)">
已有账号? 已有账号?

View File

@ -2,14 +2,18 @@
/** /**
* 身份控制中心 (Identity Control Center) * 身份控制中心 (Identity Control Center)
* Precision Atelier - 管理基础设施重构版 * Precision Atelier - 三栏式管理工作台重构版
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState, useMemo } from 'react';
import { Search, ShieldCheck, Filter, Users, UserPlus, Fingerprint } from 'lucide-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 { AuthProvider, useAuth } from '@/hooks/useAuth';
import { RequireAuth } from '@/components/auth/RequireAuth'; 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 Button from '@/components/ui/Button';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { Alert } from '@/components/ui/Alert'; import { Alert } from '@/components/ui/Alert';
@ -17,19 +21,17 @@ import { CreateUserModal } from '@/components/users/CreateUserModal';
import { EditUserModal } from '@/components/users/EditUserModal'; import { EditUserModal } from '@/components/users/EditUserModal';
import { HandoverModal } from '@/components/users/HandoverModal'; import { HandoverModal } from '@/components/users/HandoverModal';
import { DepartmentAccessTree, type UserTreeItem } from '@/components/users/DepartmentAccessTree'; 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'; import { useDebounce } from '@/hooks/usePerformance';
type ActionError = string | null;
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
{ value: '', label: '所有权限角色' }, { value: '', label: '所有权限角色' },
{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'SUPER_ADMIN', label: '超级管理员' },
{ value: 'ADMIN', label: '系统管理员' }, { value: 'ADMIN', label: '管理员' },
{ value: 'DEPARTMENT_MANAGER', label: '部门主管' }, { value: 'SUPERVISOR', label: '主管' },
{ value: 'TEAM_LEADER', label: '项目组长' }, { value: 'DESIGNER', label: '设计师' },
{ value: 'DESIGNER', label: '核心设计师' }, { value: 'USER', label: '普通用户' },
{ value: 'VIEWER', label: '受限访客' },
]; ];
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
@ -44,9 +46,9 @@ export default function UsersPage() {
return ( return (
<AuthProvider> <AuthProvider>
<RequireAuth> <RequireAuth>
<AdminGuard> <PermissionGuard roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']}>
<UsersPageContent /> <UsersPageContent />
</AdminGuard> </PermissionGuard>
</RequireAuth> </RequireAuth>
</AuthProvider> </AuthProvider>
); );
@ -59,9 +61,9 @@ function UsersPageContent() {
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [refreshToken, setRefreshToken] = useState(0); const [refreshToken, setRefreshToken] = useState(0);
const [showCreateModal, setShowCreateModal] = useState(false); 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 [handoverUser, setHandoverUser] = useState<UserTreeItem | null>(null);
const [actionError, setActionError] = useState<ActionError>(null); const [actionError, setActionError] = useState<string | null>(null);
const debouncedSearchQuery = useDebounce(searchQuery, 300); const debouncedSearchQuery = useDebounce(searchQuery, 300);
@ -69,17 +71,9 @@ function UsersPageContent() {
setRefreshToken((current) => current + 1); setRefreshToken((current) => current + 1);
}, []); }, []);
const closeCreateModal = useCallback(() => { const closeCreateModal = useCallback(() => setShowCreateModal(false), []);
setShowCreateModal(false); const closeEditModal = useCallback(() => setSelectedUser(null), []);
}, []); const closeHandoverModal = useCallback(() => setHandoverUser(null), []);
const closeEditModal = useCallback(() => {
setEditingUser(null);
}, []);
const closeHandoverModal = useCallback(() => {
setHandoverUser(null);
}, []);
const handleCreateSuccess = useCallback(() => { const handleCreateSuccess = useCallback(() => {
closeCreateModal(); closeCreateModal();
@ -91,216 +85,228 @@ function UsersPageContent() {
triggerRefresh(); triggerRefresh();
}, [closeEditModal, triggerRefresh]); }, [closeEditModal, triggerRefresh]);
const handleHandoverSuccess = useCallback((_message: string) => { const handleHandoverSuccess = useCallback(() => {
closeHandoverModal(); closeHandoverModal();
triggerRefresh(); triggerRefresh();
}, [closeHandoverModal, triggerRefresh]); }, [closeHandoverModal, triggerRefresh]);
const handleDeleteUser = useCallback( const handleDeleteUser = useCallback(async (userId: string) => {
async (userId: string) => {
try { try {
setActionError(null); setActionError(null);
const confirmed = window.confirm('确定要移除此访问权限吗?此操作不可撤销。'); if (!window.confirm('确定要移除此访问权限吗?此操作不可撤销。')) return;
if (!confirmed) { const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', credentials: 'include' });
return; if (!response.ok) throw new Error('删除用户失败');
}
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(getApiErrorMessage(data.error, '删除用户失败'));
}
triggerRefresh(); triggerRefresh();
} catch (error) { } catch (error) {
setActionError(error instanceof Error ? error.message : '删除用户失败'); setActionError(error instanceof Error ? error.message : '删除失败');
} }
}, }, [triggerRefresh]);
[triggerRefresh]
);
const handleActivateUser = useCallback( const handleActivateUser = useCallback(async (userId: string) => {
async (userId: string) => {
try { try {
setActionError(null); setActionError(null);
const response = await fetch(`/api/users/${userId}/activate`, { const response = await fetch(`/api/users/${userId}/activate`, { method: 'POST', credentials: 'include' });
method: 'POST', if (!response.ok) throw new Error('激活用户失败');
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(getApiErrorMessage(data.error, '激活用户失败'));
}
triggerRefresh(); triggerRefresh();
} catch (error) { } catch (error) {
setActionError(error instanceof Error ? error.message : '激活用户失败'); setActionError(error instanceof Error ? error.message : '激活失败');
} }
}, }, [triggerRefresh]);
[triggerRefresh]
);
return ( return (
<div className="relative min-h-screen overflow-x-hidden bg-[#f4f7f9] pb-24 font-apple-system text-apple-label-primary"> <div className="relative min-h-screen overflow-hidden bg-[#f4f7f9] font-apple-system text-apple-label-primary">
{/* 背景光晕装饰 */} <PlatformSplitLayout
<div className="pointer-events-none fixed inset-0 z-0"> title="身份控制中心"
<div className="absolute right-[-5%] top-[-5%] h-[30%] w-[30%] rounded-full bg-blue-500/5 blur-[100px]" /> description="定义组织架构、配置原子化权限策略及账号生命周期管理。"
<div className="absolute left-[-5%] bottom-[-5%] h-[30%] w-[30%] rounded-full bg-indigo-500/5 blur-[100px]" /> backHref="/admin"
</div> backLabel="管理中心"
rightPanelClassName="w-[480px] max-w-[40vw]"
{/* 沉浸式页头 */} leftContent={
<header className="relative z-20 border-b border-apple-separator/5 bg-white/60 px-10 py-12 backdrop-blur-2xl"> <div className="flex h-full flex-col gap-8 py-4">
<div className="mx-auto flex max-w-[1400px] items-end justify-between"> <div className="space-y-1.5">
<div className="flex items-center gap-8"> <p className="px-3 text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary opacity-50">Quick Filters</p>
<div className="relative flex h-20 w-20 items-center justify-center rounded-[2rem] bg-black shadow-apple-xl ring-1 ring-white/20"> <div className="flex flex-col gap-1">
<Fingerprint className="h-10 w-10 text-white" /> {ROLE_OPTIONS.map(opt => (
<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"> <button
<ShieldCheck size={16} /> key={opt.value}
</div> onClick={() => setRoleFilter(opt.value)}
</div> className={cn(
<div> "flex items-center justify-between rounded-2xl px-4 py-3 transition-all",
<div className="mb-2 flex items-center gap-3"> roleFilter === opt.value ? "bg-white text-apple-blue shadow-apple-sm" : "text-apple-label-secondary hover:bg-white/50"
<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"
> >
<UserPlus className="mr-3 h-4 w-4 transition-transform group-hover:scale-110" /> <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> </button>
))}
</div> </div>
</div> </div>
</header>
{/* 核心交互区 */} <div className="mt-auto rounded-3xl bg-black/[0.03] p-6">
<main className="relative z-10 mx-auto max-w-[1400px] px-10 py-10"> <div className="flex items-center gap-3 mb-4">
{/* 精密控制条 */} <ShieldCheck className="h-5 w-5 text-apple-blue" />
<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"> <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"> <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 <input
type="text" type="text"
placeholder="通过姓名、UID 或 邮箱地址进行全局检索..." placeholder="搜索姓名、邮箱或 UID 检索用户..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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>
<div className="ml-3 flex items-center gap-3">
<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>
<Select <Select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
options={STATUS_OPTIONS} 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>
</div> </div>
{actionError && ( {actionError && <Alert type="error" variant="subtle" title="Security Alert" description={actionError} className="mb-8 rounded-3xl" />}
<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>
)}
{/* 资源树容器 */} {/* 资源树容器 */}
<div className="rounded-[2.5rem] border border-white/60 bg-white/30 p-2 shadow-apple-xl backdrop-blur-sm"> <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="overflow-hidden rounded-[2rem] bg-white/80 shadow-apple-xs ring-1 ring-apple-separator/5"> <div className="h-full overflow-y-auto rounded-[2rem] bg-white/80 shadow-apple-xs ring-1 ring-apple-separator/5">
<DepartmentAccessTree <DepartmentAccessTree
searchQuery={debouncedSearchQuery} searchQuery={debouncedSearchQuery}
roleFilter={roleFilter} roleFilter={roleFilter}
statusFilter={statusFilter} statusFilter={statusFilter}
refreshToken={refreshToken} refreshToken={refreshToken}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
onEdit={setEditingUser} onEdit={setSelectedUser}
onDelete={handleDeleteUser} onDelete={handleDeleteUser}
onActivate={handleActivateUser} onActivate={handleActivateUser}
onHandover={setHandoverUser} onHandover={setHandoverUser}
/> />
</div> </div>
</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>
{/* 底部装饰 */} {/* Details Inspector */}
<footer className="mt-12 flex justify-center opacity-20 transition-opacity hover:opacity-100"> <div className="flex-1 overflow-y-auto p-8">
<p className="text-[9px] font-apple-black uppercase tracking-[0.5em] text-apple-label-tertiary"> <div className="flex items-center justify-between mb-8">
Precision Atelier · Structural Security Infrastructure <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> </p>
</footer> </div>
</InspectorGroup>
</div>
</div>
{showCreateModal && ( <div className="p-8 border-t border-apple-separator/5 bg-white/20">
<CreateUserModal <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()}>
isOpen={showCreateModal} 刷新同步状态
onClose={closeCreateModal} </Button>
onSuccess={handleCreateSuccess} </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 && ( {showCreateModal && <CreateUserModal isOpen={showCreateModal} onClose={closeCreateModal} onSuccess={handleCreateSuccess} />}
<EditUserModal {handoverUser && <HandoverModal isOpen={!!handoverUser} user={handoverUser} onClose={closeHandoverModal} onSuccess={handleHandoverSuccess} />}
isOpen={!!editingUser}
user={editingUser}
onClose={closeEditModal}
onSuccess={handleEditSuccess}
/>
)}
{handoverUser && (
<HandoverModal
isOpen={!!handoverUser}
user={handoverUser}
onClose={closeHandoverModal}
onSuccess={handleHandoverSuccess}
/>
)}
</div> </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 [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([])
const [bindingSubmitting, setBindingSubmitting] = useState(false) const [bindingSubmitting, setBindingSubmitting] = useState(false)
const [bindingSearch, setBindingSearch] = useState('') const [bindingSearch, setBindingSearch] = useState('')
const canWriteWarehouse = user.role !== 'USER'
function notifyReadOnlyWarehouse(message = '普通用户只有查看和下载权限,不能修改仓库数据。') {
setFeedback({ tone: 'error', text: message })
}
async function handleCreateLocation() { async function handleCreateLocation() {
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setSubmitting(true) setSubmitting(true)
setFeedback(null) setFeedback(null)
try { try {
@ -363,6 +372,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleUpdateLocation() { async function handleUpdateLocation() {
if (!editingLocationId) return if (!editingLocationId) return
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setSubmitting(true) setSubmitting(true)
setFeedback(null) setFeedback(null)
try { try {
@ -384,6 +397,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
} }
async function handleToggleLocation(id: string, currentActive: boolean) { async function handleToggleLocation(id: string, currentActive: boolean) {
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setFeedback(null) setFeedback(null)
try { try {
const updated = await updateWarehouseLocation(id, { isActive: !currentActive }) const updated = await updateWarehouseLocation(id, { isActive: !currentActive })
@ -401,6 +418,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleDeleteLocation() { async function handleDeleteLocation() {
if (!deleteConfirmId) return if (!deleteConfirmId) return
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setFeedback(null) setFeedback(null)
try { try {
await deleteWarehouseLocation(deleteConfirmId) 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 }) { 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) setEditingLocationId(location.id)
setLocationForm({ setLocationForm({
code: location.code, code: location.code,
@ -434,6 +459,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleBindLocations() { async function handleBindLocations() {
if (!bindingVariantId || selectedLocationIds.length === 0) return if (!bindingVariantId || selectedLocationIds.length === 0) return
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setBindingSubmitting(true) setBindingSubmitting(true)
setFeedback(null) setFeedback(null)
try { try {
@ -467,6 +496,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
} }
async function handleUnbindLocation(variantId: string, locationId: string) { async function handleUnbindLocation(variantId: string, locationId: string) {
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setFeedback(null) setFeedback(null)
try { try {
await unbindVariantFromLocation(variantId, locationId) await unbindVariantFromLocation(variantId, locationId)
@ -504,6 +537,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleBindBrandMaterialLocation() { async function handleBindBrandMaterialLocation() {
if (!brandBindingTarget) return if (!brandBindingTarget) return
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setBrandBindingSubmitting(true) setBrandBindingSubmitting(true)
setFeedback(null) setFeedback(null)
try { try {
@ -533,6 +570,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
} }
async function handleUploadBrandFile(brand: string, materialType: string, file: File) { async function handleUploadBrandFile(brand: string, materialType: string, file: File) {
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setBrandFileUploading(true) setBrandFileUploading(true)
setFeedback(null) setFeedback(null)
try { try {
@ -556,6 +597,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
async function handleBindPreSkuLocations() { async function handleBindPreSkuLocations() {
if (!bindingPreSkuId || selectedPreSkuLocationIds.length === 0) return if (!bindingPreSkuId || selectedPreSkuLocationIds.length === 0) return
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setPreSkuBindingSubmitting(true) setPreSkuBindingSubmitting(true)
setFeedback(null) setFeedback(null)
try { try {
@ -591,6 +636,10 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
} }
async function handleUnbindPreSkuLocation(preSkuId: string, locationId: string) { async function handleUnbindPreSkuLocation(preSkuId: string, locationId: string) {
if (!canWriteWarehouse) {
notifyReadOnlyWarehouse()
return
}
setFeedback(null) setFeedback(null)
try { try {
await unbindPreSkuFromLocation(preSkuId, locationId) await unbindPreSkuFromLocation(preSkuId, locationId)
@ -673,6 +722,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
{/* 标题 + 新增 */} {/* 标题 + 新增 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-apple-semibold text-apple-label-primary">库位列表</h3> <h3 className="text-sm font-apple-semibold text-apple-label-primary">库位列表</h3>
{canWriteWarehouse ? (
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
@ -684,6 +734,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
新增库位 新增库位
</Button> </Button>
) : null}
</div> </div>
{/* 搜索 + 排序 */} {/* 搜索 + 排序 */}
@ -717,7 +768,9 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
{filteredLocations.length === 0 ? ( {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"> <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 {overview.locations.length === 0
? canWriteWarehouse
? '还没有库位。点击「新增库位」创建一个正式库位,后续入库即可挂接。' ? '还没有库位。点击「新增库位」创建一个正式库位,后续入库即可挂接。'
: '还没有库位。'
: '没有匹配的库位,试试调整搜索条件。'} : '没有匹配的库位,试试调整搜索条件。'}
</div> </div>
) : ( ) : (
@ -737,6 +790,8 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
<Badge type={location.isActive ? 'success' : 'warning'} variant="subtle"> <Badge type={location.isActive ? 'success' : 'warning'} variant="subtle">
{location.isActive ? '可用' : '停用'} {location.isActive ? '可用' : '停用'}
</Badge> </Badge>
{canWriteWarehouse ? (
<>
<Button variant="plain" size="small" onClick={() => { <Button variant="plain" size="small" onClick={() => {
setEditingLocationId(null) setEditingLocationId(null)
setLocationForm({ setLocationForm({
@ -761,6 +816,8 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
<Button variant="plain" size="small" onClick={() => setDeleteConfirmId(location.id)}> <Button variant="plain" size="small" onClick={() => setDeleteConfirmId(location.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</>
) : null}
</div> </div>
</div> </div>
))} ))}
@ -867,12 +924,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
) : ( ) : (
<Badge type="info" variant="subtle">待绑定</Badge> <Badge type="info" variant="subtle">待绑定</Badge>
)} )}
{canWriteWarehouse ? (
<Button variant="plain" size="small" onClick={() => { <Button variant="plain" size="small" onClick={() => {
setBindingVariantId(variant.id) setBindingVariantId(variant.id)
setSelectedLocationIds(variant.boundLocations.map((l) => l.id)) setSelectedLocationIds(variant.boundLocations.map((l) => l.id))
setBindingSearch('') setBindingSearch('')
setBindingModalOpen(true) setBindingModalOpen(true)
}}>绑定库位</Button> }}>绑定库位</Button>
) : null}
</div> </div>
</div> </div>
{variant.boundLocations.length > 0 && ( {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" 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} {loc.code}
{canWriteWarehouse ? (
<button <button
type="button" type="button"
className="text-apple-label-tertiary hover:text-apple-red" className="text-apple-label-tertiary hover:text-apple-red"
@ -890,6 +950,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
> >
× ×
</button> </button>
) : null}
</span> </span>
))} ))}
</div> </div>
@ -981,12 +1042,14 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
) : ( ) : (
<Badge type="info" variant="subtle">待绑定</Badge> <Badge type="info" variant="subtle">待绑定</Badge>
)} )}
{canWriteWarehouse ? (
<Button variant="plain" size="small" onClick={() => { <Button variant="plain" size="small" onClick={() => {
setBindingPreSkuId(ps.id) setBindingPreSkuId(ps.id)
setSelectedPreSkuLocationIds(ps.boundLocations.map((l) => l.id)) setSelectedPreSkuLocationIds(ps.boundLocations.map((l) => l.id))
setBindingSearch('') setBindingSearch('')
setPreSkuBindingModalOpen(true) setPreSkuBindingModalOpen(true)
}}>绑定库位</Button> }}>绑定库位</Button>
) : null}
</div> </div>
</div> </div>
{ps.boundLocations.length > 0 && ( {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" 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} {loc.code}
{canWriteWarehouse ? (
<button <button
type="button" type="button"
className="text-apple-label-tertiary hover:text-apple-red" className="text-apple-label-tertiary hover:text-apple-red"
@ -1004,6 +1068,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
> >
× ×
</button> </button>
) : null}
</span> </span>
))} ))}
</div> </div>
@ -1069,13 +1134,15 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
)} )}
</div> </div>
<div className="flex gap-2 shrink-0 ml-2"> <div className="flex gap-2 shrink-0 ml-2">
{canWriteWarehouse ? (
<Button variant="plain" size="small" onClick={() => { <Button variant="plain" size="small" onClick={() => {
setBrandBindingTarget({ brand: selectedBrand, materialType: opt.value }) setBrandBindingTarget({ brand: selectedBrand, materialType: opt.value })
setBrandBindingLocationId(binding?.warehouseLocation?.id ?? null) setBrandBindingLocationId(binding?.warehouseLocation?.id ?? null)
setBindingSearch('') setBindingSearch('')
setBrandBindingModalOpen(true) setBrandBindingModalOpen(true)
}}>绑定库位</Button> }}>绑定库位</Button>
{opt.value === 'CARTON_PRINT' && ( ) : null}
{canWriteWarehouse && opt.value === 'CARTON_PRINT' && (
<Button <Button
variant="plain" variant="plain"
size="small" size="small"
@ -1188,6 +1255,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
/> />
{/* 新增/编辑库位弹窗 */} {/* 新增/编辑库位弹窗 */}
{canWriteWarehouse ? (
<Modal <Modal
isOpen={locationModalOpen} isOpen={locationModalOpen}
onClose={() => { setLocationModalOpen(false); setEditingLocationId(null) }} onClose={() => { setLocationModalOpen(false); setEditingLocationId(null) }}
@ -1238,10 +1306,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
/> />
</div> </div>
</Modal> </Modal>
) : null}
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
{canWriteWarehouse ? (
<Modal <Modal
isOpen={deleteConfirmId !== null} isOpen={canWriteWarehouse && deleteConfirmId !== null}
onClose={() => setDeleteConfirmId(null)} onClose={() => setDeleteConfirmId(null)}
title="确认删除" title="确认删除"
size="small" size="small"
@ -1258,10 +1328,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
> >
<p className="text-sm text-apple-label-secondary">确定要删除这个库位吗?此操作不可恢复。</p> <p className="text-sm text-apple-label-secondary">确定要删除这个库位吗?此操作不可恢复。</p>
</Modal> </Modal>
) : null}
{/* 绑定库位弹窗 */} {/* 绑定库位弹窗 */}
{canWriteWarehouse ? (
<Modal <Modal
isOpen={bindingModalOpen} isOpen={canWriteWarehouse && bindingModalOpen}
onClose={() => { setBindingModalOpen(false); setBindingVariantId(null); setSelectedLocationIds([]); setBindingSearch('') }} onClose={() => { setBindingModalOpen(false); setBindingVariantId(null); setSelectedLocationIds([]); setBindingSearch('') }}
title="绑定库位" title="绑定库位"
size="medium" size="medium"
@ -1337,10 +1409,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
})} })}
</div> </div>
</Modal> </Modal>
) : null}
{/* 品牌物料绑定库位弹窗 */} {/* 品牌物料绑定库位弹窗 */}
{canWriteWarehouse ? (
<Modal <Modal
isOpen={brandBindingModalOpen} isOpen={canWriteWarehouse && brandBindingModalOpen}
onClose={() => { setBrandBindingModalOpen(false); setBrandBindingTarget(null); setBrandBindingLocationId(null) }} onClose={() => { setBrandBindingModalOpen(false); setBrandBindingTarget(null); setBrandBindingLocationId(null) }}
title="绑定库位" title="绑定库位"
size="medium" size="medium"
@ -1428,10 +1502,12 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
})} })}
</div> </div>
</Modal> </Modal>
) : null}
{/* 成品 SKU 绑定库位弹窗 */} {/* 成品 SKU 绑定库位弹窗 */}
{canWriteWarehouse ? (
<Modal <Modal
isOpen={preSkuBindingModalOpen} isOpen={canWriteWarehouse && preSkuBindingModalOpen}
onClose={() => { setPreSkuBindingModalOpen(false); setBindingPreSkuId(null); setSelectedPreSkuLocationIds([]) }} onClose={() => { setPreSkuBindingModalOpen(false); setBindingPreSkuId(null); setSelectedPreSkuLocationIds([]) }}
title="绑定库位" title="绑定库位"
size="medium" size="medium"
@ -1507,6 +1583,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
})} })}
</div> </div>
</Modal> </Modal>
) : null}
</> </>
) )
} }

View File

@ -6,15 +6,15 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Role } from '@prisma/client';
import { usePermissions } from '@/hooks/useAuth'; import { usePermissions } from '@/hooks/useAuth';
import type { AllowedRole } from '@/lib/role-scope';
/** /**
* 权限守卫属性 * 权限守卫属性
*/ */
interface PermissionGuardProps { interface PermissionGuardProps {
children: ReactNode; children: ReactNode;
roles?: Role[]; roles?: AllowedRole[];
fallback?: ReactNode; fallback?: ReactNode;
requireAll?: boolean; requireAll?: boolean;
} }
@ -80,7 +80,7 @@ export function DepartmentManagerGuard({
}) { }) {
return ( return (
<PermissionGuard <PermissionGuard
roles={['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']} roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT']}
fallback={fallback} fallback={fallback}
> >
{children} {children}
@ -100,7 +100,7 @@ export function TeamLeaderGuard({
}) { }) {
return ( return (
<PermissionGuard <PermissionGuard
roles={['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']} roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']}
fallback={fallback} fallback={fallback}
> >
{children} {children}
@ -120,7 +120,7 @@ export function CreatorGuard({
}) { }) {
return ( return (
<PermissionGuard <PermissionGuard
roles={['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER', 'DESIGNER']} roles={['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM', 'DESIGNER']}
fallback={fallback} fallback={fallback}
> >
{children} {children}

View File

@ -39,26 +39,20 @@ const ROLE_CONFIG: Record<
bgColor: 'bg-apple-orange/10', bgColor: 'bg-apple-orange/10',
borderColor: 'border-apple-orange/20', borderColor: 'border-apple-orange/20',
}, },
DEPARTMENT_MANAGER: { SUPERVISOR: {
label: '部门管理员', label: '主管',
color: 'text-apple-purple dark:text-apple-purple', color: 'text-apple-purple dark:text-apple-purple',
bgColor: 'bg-apple-purple/10', bgColor: 'bg-apple-purple/10',
borderColor: 'border-apple-purple/20', 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: { DESIGNER: {
label: '设计师', label: '设计师',
color: 'text-apple-green dark:text-apple-green', color: 'text-apple-green dark:text-apple-green',
bgColor: 'bg-apple-green/10', bgColor: 'bg-apple-green/10',
borderColor: 'border-apple-green/20', borderColor: 'border-apple-green/20',
}, },
VIEWER: { USER: {
label: '查看者', label: '普通用户',
color: 'text-apple-label-secondary dark:text-apple-label-tertiary', color: 'text-apple-label-secondary dark:text-apple-label-tertiary',
bgColor: 'bg-apple-bg-secondary/10', bgColor: 'bg-apple-bg-secondary/10',
borderColor: 'border-apple-separator/20', borderColor: 'border-apple-separator/20',

View File

@ -26,10 +26,9 @@ interface AuthMeResponse {
const roleLabelMap: Record<string, string> = { const roleLabelMap: Record<string, string> = {
SUPER_ADMIN: '超级管理员', SUPER_ADMIN: '超级管理员',
ADMIN: '管理员', ADMIN: '管理员',
DEPARTMENT_MANAGER: '负责人', SUPERVISOR: '主管',
TEAM_LEADER: '组长',
DESIGNER: '设计师', DESIGNER: '设计师',
VIEWER: '查看者', USER: '普通用户',
} }
export function SidebarUserCenter() { export function SidebarUserCenter() {
@ -39,7 +38,10 @@ export function SidebarUserCenter() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isLoggingOut, setIsLoggingOut] = 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(() => { useEffect(() => {
let active = true let active = true
@ -172,6 +174,9 @@ export function SidebarUserCenter() {
<LayoutDashboard size={14} className="opacity-40 group-hover:opacity-100" /> <LayoutDashboard size={14} className="opacity-40 group-hover:opacity-100" />
管理总控制台 管理总控制台
</Link> </Link>
</>
)}
{canManageUsers && (
<Link <Link
href="/users" href="/users"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
@ -180,7 +185,6 @@ export function SidebarUserCenter() {
<Users size={14} className="opacity-40 group-hover:opacity-100" /> <Users size={14} className="opacity-40 group-hover:opacity-100" />
用户权限管理 用户权限管理
</Link> </Link>
</>
)} )}
<button <button
type="button" type="button"

View File

@ -50,7 +50,7 @@ describe('AppSideNav', () => {
id: 'user-1', id: 'user-1',
name: '测试账号', name: '测试账号',
email: 'test@example.com', email: 'test@example.com',
role: 'TEAM_LEADER', role: 'SUPERVISOR',
avatar: null, avatar: null,
department: { id: 'dept-1', name: '研发一组' }, department: { id: 'dept-1', name: '研发一组' },
}, },
@ -86,7 +86,7 @@ describe('AppSideNav', () => {
expect(await screen.findByText('测试账号')).toBeInTheDocument() expect(await screen.findByText('测试账号')).toBeInTheDocument()
expect(screen.getByText('test@example.com')).toBeInTheDocument() expect(screen.getByText('test@example.com')).toBeInTheDocument()
expect(screen.getByText('组长')).toBeInTheDocument() expect(screen.getByText('主管')).toBeInTheDocument()
expect(screen.getByText('工作区')).toBeInTheDocument() expect(screen.getByText('工作区')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '退出登录' })) await actor.click(screen.getByRole('button', { name: '退出登录' }))

View File

@ -22,7 +22,7 @@ import {
} from '@/lib/open-product/assisted-assembly-variant' } from '@/lib/open-product/assisted-assembly-variant'
import { import {
deriveAssemblyActiveSnapCandidate, deriveAssemblyActiveSnapCandidate,
getAssemblyAutoSnapSupportStatus, deriveAssemblyAssistFeedback,
moveAssemblyNodes, moveAssemblyNodes,
removeAssemblyNodes, removeAssemblyNodes,
instantiateAssemblyNode, instantiateAssemblyNode,
@ -265,30 +265,13 @@ export function AssemblyWorkbenchV2({
const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId)) const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId))
const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null
const selectedNodeSnapSupport = useMemo( const selectedNodeAssistFeedback = useMemo(
() => (selectedSingleNode ? getAssemblyAutoSnapSupportStatus(selectedSingleNode) : null), () =>
[selectedSingleNode], 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( const activeSnapCandidate = useMemo(
() => () =>
selectedSingleNode selectedSingleNode
@ -835,12 +818,22 @@ export function AssemblyWorkbenchV2({
)} )}
> >
<div className="h-full max-h-full overflow-y-auto p-4"> <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"> <div className="mb-3 space-y-2">
{selectedNodeAutoSnapNotice ? ( {selectedNodeAssistFeedback ? (
<p className="rounded-2xl border border-amber-200/70 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700"> <div
{selectedNodeAutoSnapNotice} className={cn(
</p> '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} ) : null}
{warnings.map((warning) => ( {warnings.map((warning) => (
<p <p

View File

@ -1,7 +1,10 @@
'use client' 'use client'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import Button from '@/components/ui/Button'
interface PlatformSplitLayoutProps { interface PlatformSplitLayoutProps {
/** 页面标题(显示在内容区顶部) */ /** 页面标题(显示在内容区顶部) */
@ -13,6 +16,13 @@ interface PlatformSplitLayoutProps {
/** 标题栏额外操作区 */ /** 标题栏额外操作区 */
headerActions?: ReactNode headerActions?: ReactNode
/** 返回按钮目标路径,若不传则不显示返回按钮 */
backHref?: string
/** 返回按钮文案,默认为“返回” */
backLabel?: string
/** 点击返回的回调,若传了此项则优先执行此项而非跳转 backHref */
onBack?: () => void
/** @deprecated 已由全局 AppShell 导航替代,静默忽略 */ /** @deprecated 已由全局 AppShell 导航替代,静默忽略 */
eyebrow?: string eyebrow?: string
/** @deprecated 已由全局 AppShell 导航替代,静默忽略 */ /** @deprecated 已由全局 AppShell 导航替代,静默忽略 */
@ -20,6 +30,8 @@ interface PlatformSplitLayoutProps {
/** 左侧内容(3D 预览或统计面板) */ /** 左侧内容(3D 预览或统计面板) */
leftContent: ReactNode leftContent: ReactNode
/** 三栏布局时的中间主内容 */
mainContent?: ReactNode
/** 右侧面板内容 */ /** 右侧面板内容 */
rightContent?: ReactNode rightContent?: ReactNode
/** 右侧面板额外样式,可用于页面级宽度覆盖 */ /** 右侧面板额外样式,可用于页面级宽度覆盖 */
@ -36,39 +48,72 @@ export function PlatformSplitLayout({
description, description,
metrics, metrics,
headerActions, headerActions,
// 旧 props 静默忽略 backHref,
eyebrow: _eyebrow, backLabel = '返回',
headerTools: _headerTools, onBack,
leftContent, leftContent,
mainContent,
rightContent, rightContent,
rightPanelClassName, rightPanelClassName,
feedback, feedback,
className, className,
}: PlatformSplitLayoutProps) { }: PlatformSplitLayoutProps) {
const router = useRouter()
const handleBack = () => {
if (onBack) {
onBack()
return
}
if (backHref) {
router.push(backHref)
}
}
const showBackButton = !!backHref || !!onBack
return ( return (
<div className={cn('flex h-[calc(100vh-56px)] flex-col overflow-hidden', className)}> <div className={cn('flex h-[calc(100vh-56px)] flex-col overflow-hidden', className)}>
{title ? ( {title ? (
<header className="shrink-0 px-6 pt-5 pb-3"> <header className="shrink-0 px-8 pt-6 pb-4">
<div className="flex items-end justify-between"> <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="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 <h1
className="text-xl font-bold tracking-tight" className="text-xl font-apple-black tracking-tight uppercase"
style={{ style={{
fontFamily: 'Manrope, sans-serif',
color: 'var(--color-label-primary)', color: 'var(--color-label-primary)',
}} }}
> >
{title} {title}
</h1> </h1>
</div>
{description ? ( {description ? (
<p <p
className="text-sm" className="text-sm font-apple-medium"
style={{ color: 'var(--color-label-secondary)' }} style={{ color: 'var(--color-label-secondary)' }}
> >
{description} {description}
</p> </p>
) : null} ) : null}
</div> </div>
</div>
{headerActions ? ( {headerActions ? (
<div className="flex items-center gap-2">{headerActions}</div> <div className="flex items-center gap-2">{headerActions}</div>
) : null} ) : null}
@ -76,23 +121,23 @@ export function PlatformSplitLayout({
</header> </header>
) : null} ) : null}
{/* Metrics, Feedback, and Main remain the same... */}
{metrics && metrics.length > 0 ? ( {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"> <div className="flex items-center gap-4">
{metrics.map((m, i) => ( {metrics.map((m, i) => (
<div <div
key={i} 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={{ style={{
backgroundColor: 'var(--color-surface-container)',
color: 'var(--color-label-secondary)', color: 'var(--color-label-secondary)',
}} }}
> >
{m.icon} {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} {m.value}
</span> </span>
<span>{m.label}</span> <span className="opacity-60">{m.label}</span>
</div> </div>
))} ))}
</div> </div>
@ -100,9 +145,9 @@ export function PlatformSplitLayout({
) : null} ) : null}
{feedback ? ( {feedback ? (
<div className="shrink-0 px-6 pb-2"> <div className="shrink-0 px-8 pb-3">
<div <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={ style={
feedback.tone === 'success' feedback.tone === 'success'
? { ? {
@ -122,18 +167,33 @@ export function PlatformSplitLayout({
</div> </div>
) : null} ) : 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 <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={{ style={{
backgroundColor: 'var(--color-surface-container)', 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> <div className="min-w-0 flex-1 p-3">{leftContent}</div>
)}
{rightContent ? ( {rightContent ? (
<aside <aside
className={cn( 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, rightPanelClassName,
)} )}
style={{ style={{
@ -141,7 +201,7 @@ export function PlatformSplitLayout({
backgroundColor: 'var(--color-surface-container-lowest)', 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> </aside>
) : null} ) : null}
</section> </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 () => { it('选中缺少安装图样语义的节点时,会明确提示无法自动吸附', async () => {
const actor = userEvent.setup() const actor = userEvent.setup()
@ -626,6 +646,7 @@ describe('AssemblyWorkbenchV2', () => {
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' })) await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('缺少安装图样语义')).toBeInTheDocument()
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> = { const roleNames: Record<string, string> = {
SUPER_ADMIN: '超级管理员', SUPER_ADMIN: '超级管理员',
ADMIN: '管理员', ADMIN: '管理员',
DEPARTMENT_MANAGER: '部门管理员', SUPERVISOR: '主管',
TEAM_LEADER: '组长',
DESIGNER: '设计师', DESIGNER: '设计师',
VIEWER: '查看者', USER: '普通用户',
} }
return ( return (

View File

@ -31,7 +31,7 @@ export function CreateUserModal({ isOpen, onClose, onSuccess }: CreateUserModalP
name: '', name: '',
password: '', password: '',
confirmPassword: '', confirmPassword: '',
role: 'DESIGNER', role: 'USER',
phone: '', phone: '',
departmentId: '', departmentId: '',
teamId: '', teamId: '',
@ -144,11 +144,11 @@ export function CreateUserModal({ isOpen, onClose, onSuccess }: CreateUserModalP
<div className="space-y-6"> <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> <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={[ <Select label="系统角色" value={formData.role} onChange={(e) => setFormData({ ...formData, role: e.target.value })} options={[
{ value: 'USER', label: '普通用户(只看 / 下载)' },
{ value: 'DESIGNER', label: '设计师' }, { value: 'DESIGNER', label: '设计师' },
{ value: 'VIEWER', label: '查看者' }, { value: 'SUPERVISOR', label: '主管' },
{ value: 'TEAM_LEADER', label: '组长' },
{ value: 'DEPARTMENT_MANAGER', label: '部门管理员' },
{ value: 'ADMIN', label: '管理员' }, { value: 'ADMIN', label: '管理员' },
{ value: 'SUPER_ADMIN', label: '超级管理员' },
]} required className="bg-white/50" /> ]} required className="bg-white/50" />
<Select label="所属部门" value={formData.departmentId} onChange={(e) => setFormData({ ...formData, departmentId: e.target.value, teamId: '' })} options={[ <Select label="所属部门" value={formData.departmentId} onChange={(e) => setFormData({ ...formData, departmentId: e.target.value, teamId: '' })} options={[
{ value: '', label: '未分配部门' }, { 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)]" /> <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> <h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">安全信息</h4>
</div> </div>
<p className="text-[11px] text-apple-label-secondary">
新账号会先进入“待激活”状态,需要后台审核通过后才能正式登录。
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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.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" /> <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"> <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"> <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" /> <ShieldCheck className="h-3 w-3" />
组长 主管
</span> </span>
<span className="font-apple-bold text-apple-label-primary">{team.leader?.name || '未设置'}</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} {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="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"> <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" /> <ShieldCheck className="h-3 w-3" />
<span>部门管理员</span> <span>主管</span>
</div> </div>
<span className="font-apple-bold text-apple-label-primary"> <span className="font-apple-bold text-apple-label-primary">
{department.manager?.name || '未设置'} {department.manager?.name || '未设置'}

View File

@ -250,10 +250,9 @@ export function EditUserModal({ isOpen, user, onClose, onSuccess }: EditUserModa
value={formData.role} value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })} onChange={(e) => setFormData({ ...formData, role: e.target.value })}
options={[ options={[
{ value: 'USER', label: '普通用户(只看 / 下载)' },
{ value: 'DESIGNER', label: '设计师' }, { value: 'DESIGNER', label: '设计师' },
{ value: 'VIEWER', label: '查看者' }, { value: 'SUPERVISOR', label: '主管' },
{ value: 'TEAM_LEADER', label: '组长' },
{ value: 'DEPARTMENT_MANAGER', label: '部门管理员' },
{ value: 'ADMIN', label: '管理员' }, { value: 'ADMIN', label: '管理员' },
...(user.role === 'SUPER_ADMIN' ? [{ value: 'SUPER_ADMIN', label: '超级管理员' }] : []), ...(user.role === 'SUPER_ADMIN' ? [{ value: 'SUPER_ADMIN', label: '超级管理员' }] : []),
]} ]}

View File

@ -83,7 +83,7 @@ describe('DepartmentAccessTree', () => {
id: 'user-free', id: 'user-free',
name: '未分配成员', name: '未分配成员',
email: 'free@example.com', email: 'free@example.com',
role: 'VIEWER', role: 'USER',
status: 'PENDING', status: 'PENDING',
createdAt: '2026-01-01T00:00:00Z', createdAt: '2026-01-01T00:00:00Z',
}, },

View File

@ -13,6 +13,14 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Role, UserStatus } from '@prisma/client'; import { Role, UserStatus } from '@prisma/client';
import type { ApiResponse } from '@/lib/api'; 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; if (!user) return false;
return roles.includes(user.role); return hasAllowedRole(user, roles);
}; };
/** /**
* 检查是否为管理员 * 检查是否为管理员
*/ */
const isAdmin = (): boolean => { const isAdmin = (): boolean => {
return hasRole(['SUPER_ADMIN', 'ADMIN']); return Boolean(user && isAdminRole(user));
}; };
/** /**
* 检查是否为部门管理员或更高 * 检查是否为部门管理员或更高
*/ */
const isDepartmentManagerOrAbove = (): boolean => { const isDepartmentManagerOrAbove = (): boolean => {
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']); return Boolean(user && (isAdminRole(user) || isDepartmentSupervisor(user)));
}; };
/** /**
* 检查是否为组长或更高 * 检查是否为组长或更高
*/ */
const isTeamLeaderOrAbove = (): boolean => { const isTeamLeaderOrAbove = (): boolean => {
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER']); return Boolean(user && (isAdminRole(user) || isDepartmentSupervisor(user) || isTeamSupervisor(user)));
}; };
/** /**
* 检查是否可以管理用户 * 检查是否可以管理用户
*/ */
const canManageUsers = (): boolean => { 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 => { const canCreateProduct = (): boolean => {
if (!user) return false; if (!user) return false;
return user.role !== 'VIEWER'; return canCreateContent(user);
}; };
/** /**
@ -280,14 +288,14 @@ export function usePermissions() {
* 检查是否可以查看操作日志 * 检查是否可以查看操作日志
*/ */
const canViewActivityLogs = (): boolean => { const canViewActivityLogs = (): boolean => {
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']); return hasRole(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT']);
}; };
/** /**
* 检查是否可以导出数据 * 检查是否可以导出数据
*/ */
const canExportData = (): boolean => { const canExportData = (): boolean => {
return hasRole(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']); return Boolean(user);
}; };
return { return {

View File

@ -8,6 +8,8 @@ import { extractTokenFromHeader, TokenPayload } from './jwt';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { getUserInfo, UserInfo } from './permissions'; import { getUserInfo, UserInfo } from './permissions';
import { validateToken } from './token-validator'; 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 { export function hasRole(userInfo: UserInfo, allowedRoles: readonly AllowedRole[]): boolean {
return allowedRoles.includes(payload.role); return hasAllowedRole(userInfo, allowedRoles);
} }
/** /**
* 检查用户是否为管理员(超级管理员或管理员) * 检查用户是否为管理员(超级管理员或管理员)
*/ */
export function isAdmin(payload: TokenPayload): boolean { 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 { 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 { 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( export function withAuth(
handler: (request: NextRequest, payload: TokenPayload, userInfo: UserInfo) => Promise<NextResponse>, handler: (request: NextRequest, payload: TokenPayload, userInfo: UserInfo) => Promise<NextResponse>,
options?: { options?: {
allowedRoles?: Role[]; allowedRoles?: AllowedRole[];
} }
) { ) {
return async (request: NextRequest): Promise<NextResponse> => { 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('您没有权限访问此资源'); return forbiddenResponse('您没有权限访问此资源');
} }

View File

@ -1,6 +1,7 @@
import { import {
applyAutomaticAssemblySnapPreview, applyAutomaticAssemblySnapPreview,
attemptSnapNode, attemptSnapNode,
deriveAssemblyAssistFeedback,
buildManualAssemblyWorkspace, buildManualAssemblyWorkspace,
deriveAssemblyActiveSnapCandidate, deriveAssemblyActiveSnapCandidate,
deriveAssemblyAutoSnapCandidate, deriveAssemblyAutoSnapCandidate,
@ -273,135 +274,77 @@ describe('assembly-workspace', () => {
expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED') expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED')
}) })
it('拖到目标位置附近时,会把待装区节点自动吸附进主装配区', () => { it('拖到兼容孔位附近并松手后,会完成自动吸附', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, { const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1', inventoryId: 'desktop:family-desktop:1',
variant: { variant: desktopVariant,
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, { const withLeg = instantiateAssemblyNode(withDesktop, {
inventoryId: 'leg:family-leg:2', inventoryId: 'leg:family-leg:2',
variant: { variant: legVariant,
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'] },
},
}) })
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, { 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, { const snappedPreview = applyAutomaticAssemblySnapPreview(moved, withLeg.nodes[1]!.nodeId, {
fallbackZone: 'staging', fallbackZone: 'staging',
}) })
const feedback = deriveAssemblyAssistFeedback(snappedPreview, snappedPreview.nodes[1]!.nodeId)
expect(snappedPreview.nodes[1]?.zone).toBe('main') expect(snappedPreview.nodes[1]?.zone).toBe('main')
expect(snappedPreview.nodes[1]?.isSnapped).toBe(true) expect(snappedPreview.nodes[1]?.isSnapped).toBe(false)
expect(snappedPreview.nodes[1]?.position).toEqual(snappedPreview.nodes[1]?.targetPosition) 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('拖拽离开吸附范围时,会解除实时吸附并回到自由拖动', () => { it('拖拽离开吸附范围时,会解除实时吸附并回到自由拖动', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, { const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1', inventoryId: 'desktop:family-desktop:1',
variant: { variant: desktopVariant,
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, { const withLeg = instantiateAssemblyNode(withDesktop, {
inventoryId: 'leg:family-leg:2', inventoryId: 'leg:family-leg:2',
variant: { variant: legVariant,
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 snapped = applyAutomaticAssemblySnapPreview( const snapped = applyAutomaticAssemblySnapPreview(
updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, { updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
position: withLeg.nodes[1]!.targetPosition!, position: [0, 720, 0],
}), }),
withLeg.nodes[1]!.nodeId, withLeg.nodes[1]!.nodeId,
{ fallbackZone: 'staging' }, { fallbackZone: 'staging' },
@ -416,6 +359,36 @@ describe('assembly-workspace', () => {
expect(released.nodes[1]?.zone).toBe('staging') expect(released.nodes[1]?.zone).toBe('staging')
expect(released.nodes[1]?.isSnapped).toBe(false) 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('主装配区出现两个桌面板时会阻止保存', () => { it('主装配区出现两个桌面板时会阻止保存', () => {

View File

@ -108,6 +108,20 @@ export interface AssemblyValidationResult {
warnings: string[] 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 { export interface AssemblyAutoSnapCandidate {
sourceNodeId: string sourceNodeId: string
targetNodeId: string targetNodeId: string
@ -553,6 +567,14 @@ function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) {
return getAssemblyAutoSnapSupportStatus(node).canAutoSnap 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( function areHoleDiametersCompatible(
sourceHole: AssemblyBasisHoleRecord, sourceHole: AssemblyBasisHoleRecord,
targetHole: AssemblyBasisHoleRecord, targetHole: AssemblyBasisHoleRecord,
@ -698,6 +720,159 @@ export function deriveAssemblyActiveSnapCandidate(
return buildSnapBindingDisplayCandidate(snapshot, sourceNode) 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( export function deriveAssemblyAutoSnapCandidate(
snapshot: AssemblyWorkspaceSnapshot, snapshot: AssemblyWorkspaceSnapshot,
nodeId: string, nodeId: string,
@ -764,6 +939,10 @@ export function deriveAssemblyAutoSnapCandidate(
if (anchorNormalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) { if (anchorNormalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) {
return return
} }
const anchorDistanceMm = anchorSourceWorld.distanceTo(anchorTargetWorld)
if (anchorDistanceMm > getHoleSnapThreshold(anchorSourceHole, anchorTargetHole)) {
return
}
const translation = anchorTargetWorld.clone().sub(anchorSourceWorld) const translation = anchorTargetWorld.clone().sub(anchorSourceWorld)
const matchedPairs: AssemblyMatchedHolePair[] = [] const matchedPairs: AssemblyMatchedHolePair[] = []
@ -785,6 +964,12 @@ export function deriveAssemblyAutoSnapCandidate(
const translatedSourceWorld = getNodeWorldPoint(sourceNode, sourceHole.position).add(translation) const translatedSourceWorld = getNodeWorldPoint(sourceNode, sourceHole.position).add(translation)
const targetHoleWorld = getNodeWorldPoint(targetNode, targetHole.position) 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 sourceHoleNormal = getNodeWorldDirection(sourceNode, sourceHole.normal)
const targetHoleNormal = getNodeWorldDirection(targetNode, targetHole.normal) const targetHoleNormal = getNodeWorldDirection(targetNode, targetHole.normal)
const normalDot = sourceHoleNormal.dot(targetHoleNormal) const normalDot = sourceHoleNormal.dot(targetHoleNormal)
@ -793,10 +978,6 @@ export function deriveAssemblyAutoSnapCandidate(
} }
const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld) const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld)
if (distanceMm > HOLE_SNAP_DISTANCE_THRESHOLD_MM) {
return
}
const candidatePair: AssemblyMatchedHolePair = { const candidatePair: AssemblyMatchedHolePair = {
sourceHole, sourceHole,
targetHole, targetHole,
@ -804,7 +985,7 @@ export function deriveAssemblyAutoSnapCandidate(
targetHoleWorldPosition: tupleFromVector(targetHoleWorld), targetHoleWorldPosition: tupleFromVector(targetHoleWorld),
sourceHoleNormal: tupleFromVector(sourceHoleNormal), sourceHoleNormal: tupleFromVector(sourceHoleNormal),
targetHoleNormal: tupleFromVector(targetHoleNormal), targetHoleNormal: tupleFromVector(targetHoleNormal),
distanceMm, distanceMm: currentDistanceMm,
sourceFace: facePair.sourceFace, sourceFace: facePair.sourceFace,
targetFace: facePair.targetFace, targetFace: facePair.targetFace,
} }
@ -1142,21 +1323,6 @@ function matchesPrimaryAnchorRole(
return true 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[] { function rebuildRelations(nodes: AssemblyWorkspaceNode[]): AssemblyRelation[] {
const mainDesktop = nodes.find((node) => node.zone === 'main' && node.source === 'desktop') ?? null const mainDesktop = nodes.find((node) => node.zone === 'main' && node.source === 'desktop') ?? null
@ -1704,22 +1870,6 @@ export function updateAssemblyNodePosition(
return updateAssemblyNodeTransform(snapshot, nodeId, { position }) 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( export function snapNodesIntoMainAssembly(
snapshot: AssemblyWorkspaceSnapshot, snapshot: AssemblyWorkspaceSnapshot,
nodeIds: string[], nodeIds: string[],
@ -1762,7 +1912,10 @@ export function attemptSnapNode(
} }
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId) const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
if (candidate?.isValid) { if (!candidate?.isValid) {
return snapshot
}
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) => const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
node.nodeId === nodeId node.nodeId === nodeId
? { ? {
@ -1781,18 +1934,6 @@ export function attemptSnapNode(
return rebuildWorkspaceSnapshot(snapshot, nextNodes) 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( export function applyAutomaticAssemblySnapPreview(
snapshot: AssemblyWorkspaceSnapshot, snapshot: AssemblyWorkspaceSnapshot,
nodeId: string, nodeId: string,
@ -1826,19 +1967,6 @@ export function applyAutomaticAssemblySnapPreview(
return rebuildWorkspaceSnapshot(snapshot, nextNodes) 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) { if (targetNode.zone === fallbackZone && !targetNode.isSnapped) {
return snapshot return snapshot
} }

View File

@ -5,8 +5,7 @@ export function canUserReviewAsset(user: Pick<AuthUser, 'role'>): boolean {
return ( return (
user.role === 'SUPER_ADMIN' || user.role === 'SUPER_ADMIN' ||
user.role === 'ADMIN' || user.role === 'ADMIN' ||
user.role === 'DEPARTMENT_MANAGER' || user.role === 'SUPERVISOR'
user.role === 'TEAM_LEADER'
) )
} }

View File

@ -10,8 +10,8 @@ export interface SeedUserCandidate
const REVIEWER_ROLES = new Set<AuthUser['role']>([ const REVIEWER_ROLES = new Set<AuthUser['role']>([
'SUPER_ADMIN', 'SUPER_ADMIN',
'ADMIN', 'ADMIN',
'DEPARTMENT_MANAGER', 'SUPERVISOR',
'TEAM_LEADER', 'SUPERVISOR',
]) ])
function isActiveUser(user: SeedUserCandidate): boolean { function isActiveUser(user: SeedUserCandidate): boolean {

View File

@ -3,96 +3,118 @@
* 实现细粒度的权限控制逻辑 * 实现细粒度的权限控制逻辑
*/ */
import { Role, UserStatus } from '@prisma/client'; import { Role, UserStatus } from '@prisma/client'
import { departmentRepository } from '@/server/repositories/department.repository'; import { departmentRepository } from '@/server/repositories/department.repository'
import { userRepository } from '@/server/repositories/user.repository'; import { userRepository } from '@/server/repositories/user.repository'
import {
canCreateContent,
getManagedDepartmentId,
getManagedTeamId,
isAdmin,
isDepartmentSupervisor,
isReadOnlyUser,
isSupervisor,
isTeamSupervisor,
} from '@/lib/role-scope'
/** /**
* 用户信息接口(从 Token 或数据库获取) * 用户信息接口(从 Token 或数据库获取)
*/ */
export interface UserInfo { export interface UserInfo {
id: string; id: string
email: string; email: string
name: string; name: string
role: Role; role: Role
status: UserStatus; status: UserStatus
departmentId?: string; departmentId?: string
teamId?: string; teamId?: string
managedDepartmentId?: string; managedDepartmentId?: string
managedTeamId?: string; managedTeamId?: string
} }
/** /**
* 协作者信息 * 协作者信息
*/ */
export interface CollaboratorUser { export interface CollaboratorUser {
userId: string; userId: string
role: 'editor' | 'viewer'; role: 'editor' | 'viewer'
} }
export interface ProductCreatorContext { export interface ProductCreatorContext {
departmentId?: string; departmentId?: string
teamId?: string; teamId?: string
} }
/** /**
* 产品信息接口 * 产品信息接口
*/ */
export interface ProductInfo { export interface ProductInfo {
id: string; id: string
createdBy: string; createdBy: string
departmentId?: string; departmentId?: string
collaboratorUsers?: unknown; collaboratorUsers?: unknown
} }
/** /**
* SKU 信息接口 * SKU 信息接口
*/ */
export interface SKUInfo { export interface SKUInfo {
id: string; id: string
ownerId: string; ownerId: string
productId: string; productId: string
} }
/** /**
* 反馈信息接口 * 反馈信息接口
*/ */
export interface FeedbackInfo { export interface FeedbackInfo {
id: string; id: string
assignedTo?: string; assignedTo?: string
productId?: string; productId?: string
skuId?: string; skuId?: string
} }
/** /**
* 小组信息接口 * 小组信息接口
*/ */
export interface TeamInfo { export interface TeamInfo {
id: string; id: string
departmentId: string; departmentId: string
} }
export function normalizeCollaboratorUsers(raw: unknown): CollaboratorUser[] { export function normalizeCollaboratorUsers(raw: unknown): CollaboratorUser[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return []
return raw return raw
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') .filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item) => { .map((item) => {
const userId = typeof item.userId === 'string' ? item.userId.trim() : ''; const userId = typeof item.userId === 'string' ? item.userId.trim() : ''
if (!userId) return null; if (!userId) return null
return { return {
userId, userId,
role: item.role === 'viewer' ? 'viewer' : 'editor', 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[] { function getCollaboratorUsers(raw: unknown): CollaboratorUser[] {
return normalizeCollaboratorUsers(raw) 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 { export function canManageUser(operator: UserInfo, targetUser: UserInfo): boolean {
if (operator.id === targetUser.id) {
return true
}
// 1. 超级管理员可以管理所有用户 // 1. 超级管理员可以管理所有用户
if (operator.role === 'SUPER_ADMIN') return true; if (operator.role === 'SUPER_ADMIN') return true
// 2. 管理员可以管理除超级管理员外的所有用户 // 2. 管理员可以管理除超级管理员外的所有用户
if (operator.role === 'ADMIN') { if (operator.role === 'ADMIN') {
return targetUser.role !== 'SUPER_ADMIN'; return targetUser.role !== 'SUPER_ADMIN'
} }
// 3. 部门管理员可以管理本部门的用户 // 3. 部门主管可以管理本部门用户,但不能碰管理员链路
if (operator.role === 'DEPARTMENT_MANAGER' && operator.departmentId) { if (isDepartmentSupervisor(operator) && targetUser.role !== 'SUPER_ADMIN' && targetUser.role !== 'ADMIN') {
return targetUser.departmentId === operator.departmentId; 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 { 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 { 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
// 不能删除超级管理员 return canManageUser(operator, targetUser)
if (targetUser.role === 'SUPER_ADMIN') return false;
return canManageUser(operator, targetUser);
} }
/** /**
* 检查是否可以激活用户 * 检查是否可以激活用户
*/ */
export function canActivateUser(operator: UserInfo): boolean { 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 { export function canCreateProduct(user: UserInfo): boolean {
// 查看者不能创建产品 return canCreateContent(user)
return user.role !== 'VIEWER';
} }
/** /**
@ -162,122 +189,54 @@ export function canCreateProduct(user: UserInfo): boolean {
export function canEditProduct(user: UserInfo, product: ProductInfo): boolean { export function canEditProduct(user: UserInfo, product: ProductInfo): boolean {
const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers) const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers)
// 查看者不能编辑任何产品 if (isReadOnlyUser(user)) return false
if (user.role === 'VIEWER') return false; if (isAdmin(user)) return true
// 1. 超级管理员可编辑所有产品 if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
if (user.role === 'SUPER_ADMIN') return true; 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
if (product.createdBy === user.id) return true;
// 5. 协作者可编辑 if (collaboratorUsers.some((collaborator) => collaborator.userId === user.id && collaborator.role === 'editor')) {
if (collaboratorUsers.some((c) => c.userId === user.id && c.role === 'editor')) { return true
return true;
} }
// 6. 组长可编辑本组成员创建的产品 return false
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
// 需要查询创建者的 teamId,这里返回 false,在实际使用时需要查询数据库
return false;
}
return false;
} }
/** /**
* 检查是否可以删除产品 * 检查是否可以删除产品
*/ */
export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean { export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean {
// 查看者不能删除产品 if (isReadOnlyUser(user)) return false
if (user.role === 'VIEWER') return false; if (isAdmin(user)) return true
// 1. 超级管理员可删除所有产品 if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
if (user.role === 'SUPER_ADMIN') return true; 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. 创建者可以删除自己的产品 return product.createdBy === user.id
if (product.createdBy === user.id) return true;
// 5. 组长可删除本组成员创建的产品
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
// 需要查询创建者的 teamId
return false;
}
return false;
} }
/** /**
* 检查是否可以添加协作者 * 检查是否可以添加协作者
*/ */
export function canAddCollaborator(user: UserInfo, product: ProductInfo): boolean { export function canAddCollaborator(user: UserInfo, product: ProductInfo): boolean {
// 1. 超级管理员和管理员可以添加 if (isAdmin(user)) return true
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true;
// 2. 部门管理员可以为本部门的产品添加 if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) { return true
if (product.departmentId === user.managedDepartmentId) {
return true;
}
} }
// 3. 创建者可以添加 return product.createdBy === user.id
if (product.createdBy === user.id) return true;
// 4. 组长可以为本组产品添加
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
return false; // 需要查询
}
return false;
} }
/** /**
* 检查是否可以管理产品协作者(添加/移除) * 检查是否可以管理产品协作者(添加/移除)
*/ */
export function canManageCollaborators(user: UserInfo, product: ProductInfo): boolean { export function canManageCollaborators(user: UserInfo, product: ProductInfo): boolean {
// 1. 超级管理员可以管理所有产品的协作者 return canAddCollaborator(user, product)
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;
} }
// ============================================ // ============================================
@ -288,8 +247,7 @@ export function canManageCollaborators(user: UserInfo, product: ProductInfo): bo
* 检查是否可以创建 SKU * 检查是否可以创建 SKU
*/ */
export function canCreateSKU(user: UserInfo): boolean { export function canCreateSKU(user: UserInfo): boolean {
// 查看者不能创建 SKU return canCreateContent(user)
return user.role !== 'VIEWER';
} }
/** /**
@ -298,90 +256,49 @@ export function canCreateSKU(user: UserInfo): boolean {
export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo): boolean { export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo): boolean {
const collaboratorUsers = product ? getCollaboratorUsers(product.collaboratorUsers) : [] const collaboratorUsers = product ? getCollaboratorUsers(product.collaboratorUsers) : []
// 查看者不能编辑 SKU if (isReadOnlyUser(user)) return false
if (user.role === 'VIEWER') 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) { if (product) {
// 产品创建者可以编辑产品下的 SKU if (product.createdBy === user.id) return true
if (product.createdBy === user.id) return true;
// 产品编辑协作者可以编辑 SKU if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
if (collaboratorUsers.some((c) => c.userId === user.id && c.role === 'editor')) { return true
return true; }
if (collaboratorUsers.some((collaborator) => collaborator.userId === user.id && collaborator.role === 'editor')) {
return true
} }
} }
// 5. 部门管理员可编辑本部门的 SKU return false
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
// 需要查询 SKU 负责人的部门
return false;
}
// 6. 组长可编辑本组的 SKU
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
// 需要查询 SKU 负责人的小组
return false;
}
return false;
} }
/** /**
* 检查是否可以删除 SKU * 检查是否可以删除 SKU
*/ */
export function canDeleteSKU(user: UserInfo, _sku: SKUInfo): boolean { export function canDeleteSKU(user: UserInfo, sku: SKUInfo): boolean {
// 1. 超级管理员可删除所有 SKU if (isAdmin(user)) return true
if (user.role === 'SUPER_ADMIN') return true; if (isReadOnlyUser(user)) return false
return sku.ownerId === user.id
// 2. 管理员可删除所有 SKU
if (user.role === 'ADMIN') return true;
// 3. 部门管理员可删除本部门的 SKU
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
return false; // 需要查询
}
return false;
} }
/** /**
* 检查是否可以交接 SKU * 检查是否可以交接 SKU
*/ */
export function canTransferSKU( export function canTransferSKU(operator: UserInfo, sku: SKUInfo, owner?: UserInfo): boolean {
operator: UserInfo, if (isAdmin(operator)) return true
sku: SKUInfo,
owner?: UserInfo
): boolean {
// 1. 超级管理员可以交接任何 SKU
if (operator.role === 'SUPER_ADMIN') return true;
// 2. 管理员可以交接任何 SKU if (isDepartmentSupervisor(operator) && owner?.departmentId) {
if (operator.role === 'ADMIN') return true; return isWithinManagedDepartment(operator, owner.departmentId)
// 3. 部门管理员可以交接本部门的 SKU
if (operator.role === 'DEPARTMENT_MANAGER' && operator.departmentId) {
// 如果提供了 owner 信息,检查是否同部门
if (owner && owner.departmentId === operator.departmentId) {
return true;
}
} }
// 4. SKU 负责人可以交接自己负责的 SKU if (isTeamSupervisor(operator) && owner?.teamId) {
if (sku.ownerId === operator.id) { return isWithinManagedTeam(operator, owner.teamId)
return true;
} }
return false; return sku.ownerId === operator.id
} }
// ============================================ // ============================================
@ -392,36 +309,16 @@ export function canTransferSKU(
* 检查是否可以提交反馈 * 检查是否可以提交反馈
*/ */
export function canSubmitFeedback(_user: UserInfo): boolean { export function canSubmitFeedback(_user: UserInfo): boolean {
// 所有角色都可以提交反馈 return true
return true;
} }
/** /**
* 检查是否可以处理反馈 * 检查是否可以处理反馈
*/ */
export function canHandleFeedback(user: UserInfo, feedback: FeedbackInfo): boolean { export function canHandleFeedback(user: UserInfo, feedback: FeedbackInfo): boolean {
// 1. 超级管理员和管理员可处理所有反馈 if (isAdmin(user)) return true
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true; if (feedback.assignedTo === user.id) return true
return false
// 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;
} }
// ============================================ // ============================================
@ -432,33 +329,20 @@ export function canHandleFeedback(user: UserInfo, feedback: FeedbackInfo): boole
* 检查是否可以生成分享链接 * 检查是否可以生成分享链接
*/ */
export function canCreateShareLink(_user: UserInfo): boolean { export function canCreateShareLink(_user: UserInfo): boolean {
// 所有角色都可以生成分享链接 return true
return true;
} }
/** /**
* 检查是否可以管理分享链接 * 检查是否可以管理分享链接
*/ */
export function canManageShareLink(user: UserInfo, product: ProductInfo): boolean { export function canManageShareLink(user: UserInfo, product: ProductInfo): boolean {
// 1. 超级管理员和管理员可管理所有分享链接 if (isAdmin(user)) return true
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true;
// 2. 部门管理员可管理本部门的分享链接 if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) { return true
if (product.departmentId === user.managedDepartmentId) {
return true;
}
} }
// 3. 组长可管理本组的分享链接 return product.createdBy === user.id
if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
return false; // 需要查询
}
// 4. 产品创建者可管理
if (product.createdBy === user.id) return true;
return false;
} }
/** /**
@ -467,67 +351,47 @@ export function canManageShareLink(user: UserInfo, product: ProductInfo): boolea
export function canViewProduct( export function canViewProduct(
user: UserInfo, user: UserInfo,
product: ProductInfo & { visibility?: string }, product: ProductInfo & { visibility?: string },
creator?: ProductCreatorContext, creator?: ProductCreatorContext
): boolean { ): boolean {
const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers) const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers)
// 1. 超级管理员可以查看任何产品 if (isAdmin(user)) return true
if (user.role === 'SUPER_ADMIN') return true; if (product.visibility === 'PUBLIC') return true
if (product.createdBy === user.id) return true
// 2. 管理员可以查看任何产品 if (collaboratorUsers.some((collaborator) => collaborator.userId === user.id)) {
if (user.role === 'ADMIN') return true; 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;
} }
// 6. 部门可见产品,同部门用户可以查看 if (product.visibility === 'DEPARTMENT' && creator?.departmentId) {
if (product.visibility === 'DEPARTMENT' && creator) { return user.departmentId === creator.departmentId
if (user.departmentId === creator.departmentId) {
return true;
}
} }
// 7. 小组可见产品,同小组用户可以查看 if (product.visibility === 'TEAM' && creator?.teamId) {
if (product.visibility === 'TEAM' && creator) { return user.teamId === creator.teamId
if (user.teamId === creator.teamId) {
return true;
}
} }
return false; return false
} }
/** /**
* 检查是否可以管理部门 * 检查是否可以管理部门
*/ */
export function canManageDepartment(user: UserInfo): boolean { 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 { export function canManageTeam(user: UserInfo, team?: TeamInfo): boolean {
// 1. 超级管理员可以管理小组 if (isAdmin(user)) return true
if (user.role === 'SUPER_ADMIN') return true;
// 2. 管理员可以管理小组 if (isDepartmentSupervisor(user) && team?.departmentId) {
if (user.role === 'ADMIN') return true; return isWithinManagedDepartment(user, team.departmentId)
// 3. 部门管理员可以管理本部门的小组
if (user.role === 'DEPARTMENT_MANAGER' && user.departmentId && team) {
return team.departmentId === user.departmentId;
} }
return false; return false
} }
// ============================================ // ============================================
@ -538,21 +402,21 @@ export function canManageTeam(user: UserInfo, team?: TeamInfo): boolean {
* 检查是否可以访问系统设置 * 检查是否可以访问系统设置
*/ */
export function canAccessSystemSettings(user: UserInfo): boolean { export function canAccessSystemSettings(user: UserInfo): boolean {
return user.role === 'SUPER_ADMIN'; return user.role === 'SUPER_ADMIN'
} }
/** /**
* 检查是否可以查看操作日志 * 检查是否可以查看操作日志
*/ */
export function canViewActivityLogs(user: UserInfo): boolean { 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 { 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, managedDepartmentId: string,
targetDepartmentId?: string targetDepartmentId?: string
): Promise<boolean> { ): Promise<boolean> {
if (!targetDepartmentId) return false; if (!targetDepartmentId) return false
if (managedDepartmentId === targetDepartmentId) return true; 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> { 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 { return {
id: user.id, id: user.id,
@ -597,5 +458,5 @@ export async function getUserInfo(userId: string): Promise<UserInfo | null> {
teamId: user.teamId || undefined, teamId: user.teamId || undefined,
managedDepartmentId: user.managedDepartment?.id, managedDepartmentId: user.managedDepartment?.id,
managedTeamId: user.managedTeam?.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 type { ApiResponse, PaginationMeta, ResponseMeta, AuthUser, RequestContext, OptionalAuthRequestContext } from './types'
import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors' import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors'
import { HTTP_STATUS, ENV } from './constants' 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 requireAuth?: boolean
/** 允许的角色 */ /** 允许的角色 */
allowedRoles?: string[] allowedRoles?: AllowedRole[]
} }
// 函数重载:requireAuth: false 时使用 OptionalAuthRequestContext // 函数重载: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( return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED, ErrorCode.AUTH_PERMISSION_DENIED,
'没有权限执行此操作', '没有权限执行此操作',

View File

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

View File

@ -50,7 +50,7 @@ describe('assetCenterService review workflow', () => {
id: 'user-leader', id: 'user-leader',
email: 'leader@example.com', email: 'leader@example.com',
name: '组长', name: '组长',
role: 'TEAM_LEADER' as const, role: 'SUPERVISOR' as const,
status: 'ACTIVE' as const, status: 'ACTIVE' as const,
departmentId: 'dept-1', departmentId: 'dept-1',
teamId: 'team-1', teamId: 'team-1',

View File

@ -275,7 +275,7 @@ describe('openPlatformDemoSeedService', () => {
id: 'user-reviewer', id: 'user-reviewer',
email: 'reviewer@example.com', email: 'reviewer@example.com',
name: '开放平台审核员', name: '开放平台审核员',
role: 'TEAM_LEADER', role: 'SUPERVISOR',
status: 'ACTIVE', status: 'ACTIVE',
} as never) } as never)
mockedPrisma.productCategory.findUnique.mockResolvedValue(null as never) mockedPrisma.productCategory.findUnique.mockResolvedValue(null as never)

View File

@ -20,7 +20,7 @@ describe('product-permission.helper', () => {
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@test.com', email: 'viewer@test.com',
name: '部门同事', name: '部门同事',
role: 'VIEWER', role: 'USER',
status: 'ACTIVE', status: 'ACTIVE',
departmentId: 'dept-1', departmentId: 'dept-1',
} as any, } as any,
@ -41,7 +41,7 @@ describe('product-permission.helper', () => {
id: 'viewer-2', id: 'viewer-2',
email: 'viewer2@test.com', email: 'viewer2@test.com',
name: '组内同事', name: '组内同事',
role: 'VIEWER', role: 'USER',
status: 'ACTIVE', status: 'ACTIVE',
teamId: 'team-1', teamId: 'team-1',
} as any, } as any,
@ -61,7 +61,7 @@ describe('product-permission.helper', () => {
id: 'manager-1', id: 'manager-1',
email: 'manager@test.com', email: 'manager@test.com',
name: '部门管理员', name: '部门管理员',
role: 'DEPARTMENT_MANAGER', role: 'SUPERVISOR',
status: 'ACTIVE', status: 'ACTIVE',
managedDepartmentId: 'dept-1', managedDepartmentId: 'dept-1',
} as any, } as any,
@ -81,7 +81,7 @@ describe('product-permission.helper', () => {
id: 'manager-1', id: 'manager-1',
email: 'manager@test.com', email: 'manager@test.com',
name: '部门管理员', name: '部门管理员',
role: 'DEPARTMENT_MANAGER', role: 'SUPERVISOR',
status: 'ACTIVE', status: 'ACTIVE',
managedDepartmentId: 'dept-1', managedDepartmentId: 'dept-1',
} as any, } as any,
@ -100,7 +100,7 @@ describe('product-permission.helper', () => {
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@test.com', email: 'viewer@test.com',
name: '查看者', name: '查看者',
role: 'VIEWER', role: 'USER',
status: 'ACTIVE', status: 'ACTIVE',
} as any, } as any,
), ),

View File

@ -35,7 +35,7 @@ describe('ProductWriteService collaborator permissions', () => {
id: 'manager-1', id: 'manager-1',
email: 'manager@test.com', email: 'manager@test.com',
name: '部门管理员', name: '部门管理员',
role: 'DEPARTMENT_MANAGER', role: 'SUPERVISOR',
status: 'ACTIVE', status: 'ACTIVE',
managedDepartmentId: 'dept-1', managedDepartmentId: 'dept-1',
} as any, } as any,
@ -79,7 +79,7 @@ describe('ProductWriteService collaborator permissions', () => {
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@test.com', email: 'viewer@test.com',
name: '查看者', name: '查看者',
role: 'VIEWER', role: 'USER',
status: 'ACTIVE', status: 'ACTIVE',
} as any, } as any,
requestId: 'req-test', requestId: 'req-test',

View File

@ -42,7 +42,7 @@ describe('userHandoverService', () => {
id: 'leader-1', id: 'leader-1',
email: 'leader@example.com', email: 'leader@example.com',
name: '组长', name: '组长',
role: 'TEAM_LEADER' as const, role: 'SUPERVISOR' as const,
status: 'ACTIVE' as const, status: 'ACTIVE' as const,
teamId: 'team-1', teamId: 'team-1',
managedTeamId: 'team-1', managedTeamId: 'team-1',
@ -300,7 +300,7 @@ describe('userHandoverService', () => {
await expect( await expect(
userHandoverService.getHandoverChecklist('user-outside', { userHandoverService.getHandoverChecklist('user-outside', {
...operator, ...operator,
role: 'DEPARTMENT_MANAGER', role: 'SUPERVISOR',
departmentId: 'dept-1', departmentId: 'dept-1',
managedDepartmentId: 'dept-1', managedDepartmentId: 'dept-1',
}), }),

View File

@ -117,7 +117,7 @@ describe('userManagementService.getOrganizationTree', () => {
id: 'user-unassigned', id: 'user-unassigned',
email: 'free@example.com', email: 'free@example.com',
name: '未分配人员', name: '未分配人员',
role: 'VIEWER', role: 'USER',
status: 'PENDING', status: 'PENDING',
loginSource: 'LOCAL', loginSource: 'LOCAL',
activationRequired: true, activationRequired: true,
@ -250,7 +250,7 @@ describe('userManagementService.getOrganizationTree', () => {
id: 'manager-1', id: 'manager-1',
email: 'manager@example.com', email: 'manager@example.com',
name: '部门负责人', name: '部门负责人',
role: 'DEPARTMENT_MANAGER', role: 'SUPERVISOR',
status: 'ACTIVE', status: 'ACTIVE',
departmentId: 'dept-a', departmentId: 'dept-a',
} }
@ -283,7 +283,7 @@ describe('userManagementService.getOrganizationTree', () => {
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@example.com', email: 'viewer@example.com',
name: '查看者', name: '查看者',
role: 'VIEWER', role: 'USER',
status: 'ACTIVE', status: 'ACTIVE',
} }
) )

View File

@ -321,8 +321,8 @@ class DingtalkAuthService {
email, email,
name: loginName, name: loginName,
password, password,
role: 'DESIGNER', role: 'USER',
status: employeeDirectory ? 'ACTIVE' : 'PENDING', status: 'PENDING',
isFirstLogin: false, isFirstLogin: false,
avatar: profile.avatar || null, avatar: profile.avatar || null,
phone: profile.mobile || null, phone: profile.mobile || null,
@ -354,11 +354,6 @@ class DingtalkAuthService {
? await this.resolveDepartmentIdByExternalId(primaryDepartmentExternalId) ? await this.resolveDepartmentIdByExternalId(primaryDepartmentExternalId)
: null : null
const nextStatus =
employeeDirectoryId && currentUser?.status === 'PENDING'
? 'ACTIVE'
: currentUser?.status ?? 'ACTIVE'
return prisma.user.update({ return prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
@ -370,7 +365,7 @@ class DingtalkAuthService {
phone: profile.mobile || undefined, phone: profile.mobile || undefined,
employeeDirectoryId: employeeDirectoryId ?? undefined, employeeDirectoryId: employeeDirectoryId ?? undefined,
departmentId: departmentId ?? currentUser?.departmentId ?? undefined, departmentId: departmentId ?? currentUser?.departmentId ?? undefined,
status: nextStatus, status: currentUser?.status ?? 'PENDING',
}, },
}) })
} }

View File

@ -1,6 +1,7 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import type { Feedback } from '@prisma/client' import type { Feedback } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import { isAdmin, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
import { import {
feedbackRepository, feedbackRepository,
type FeedbackFilter, type FeedbackFilter,
@ -33,18 +34,25 @@ class FeedbackQueryService {
let result: { items: FeedbackWithRelations[]; total: number } let result: { items: FeedbackWithRelations[]; total: number }
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') { if (isAdmin(user)) {
result = await feedbackRepository.findManyWithRelations({ result = await feedbackRepository.findManyWithRelations({
pagination: { page, limit: pageSize }, pagination: { page, limit: pageSize },
filter, filter,
}) })
} else if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) { } else if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
const products = await prisma.product.findMany({ const products = await prisma.product.findMany({
where: { departmentId: user.managedDepartmentId }, where: { departmentId: user.managedDepartmentId },
select: { id: true }, select: { id: true },
}) })
const productIds = products.map((item) => item.id) const productIds = products.map((item) => item.id)
result = await this.findFeedbacksForProducts(productIds, { page, pageSize }, filter) 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 { } else {
result = await this.findUserFeedbacks(user.id, { page, pageSize }, filter) result = await this.findUserFeedbacks(user.id, { page, pageSize }, filter)
} }
@ -84,10 +92,10 @@ class FeedbackQueryService {
} }
async checkHandlePermission(user: FeedbackUserContext, feedback: Feedback): Promise<boolean> { 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 (feedback.assigneeId === user.id) return true
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) { if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
const product = await prisma.product.findUnique({ const product = await prisma.product.findUnique({
where: { id: feedback.productId }, where: { id: feedback.productId },
select: { departmentId: true }, 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({ const product = await prisma.product.findUnique({
where: { id: feedback.productId }, where: { id: feedback.productId },
include: { creator: { select: { teamId: true } } }, include: { creator: { select: { teamId: true } } },
@ -200,11 +208,11 @@ class FeedbackQueryService {
} }
private async checkViewPermission(user: FeedbackUserContext, feedback: Feedback): Promise<boolean> { 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.submitterId === user.id) return true
if (feedback.assigneeId === 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({ const product = await prisma.product.findUnique({
where: { id: feedback.productId }, where: { id: feedback.productId },
select: { departmentId: true }, select: { departmentId: true },
@ -212,6 +220,16 @@ class FeedbackQueryService {
if (product && product.departmentId === user.managedDepartmentId) return true 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 return false
} }
} }

View File

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

View File

@ -1,4 +1,5 @@
import type { FeedbackPriority, FeedbackStatus } from '@prisma/client' import type { FeedbackPriority, FeedbackStatus } from '@prisma/client'
import type { UserRole } from '@/server/core/types'
export interface CreateFeedbackInput { export interface CreateFeedbackInput {
productId: string productId: string
@ -68,7 +69,7 @@ export interface FeedbackListOptions {
export interface FeedbackUserContext { export interface FeedbackUserContext {
id: string id: string
name: string name: string
role: string role: UserRole
departmentId?: string | null departmentId?: string | null
teamId?: string | null teamId?: string | null
managedDepartmentId?: string | null managedDepartmentId?: string | null

View File

@ -7,6 +7,7 @@ import {
} from '@/app/intake/non-integrated/nonIntegratedStorage' } from '@/app/intake/non-integrated/nonIntegratedStorage'
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types' import type { AuthUser } from '@/server/core/types'
import { canCreateContent } from '@/lib/role-scope'
import { import {
intakeNonIntegratedProductRepository, intakeNonIntegratedProductRepository,
type IntakeNonIntegratedProductRepositoryClass, type IntakeNonIntegratedProductRepositoryClass,
@ -55,6 +56,10 @@ class IntakeNonIntegratedProductService {
} }
async save(productInput: unknown, user: AuthUser) { async save(productInput: unknown, user: AuthUser) {
if (!canCreateContent(user)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '普通用户只有查看和下载权限,不能新增或修改非集成产品')
}
const normalizedProduct = const normalizedProduct =
normalizeNonIntegratedProductRecord( normalizeNonIntegratedProductRecord(
productInput as Partial<NonIntegratedProductRecord>, productInput as Partial<NonIntegratedProductRecord>,
@ -128,6 +133,10 @@ class IntakeNonIntegratedProductService {
} }
async remove(id: string, user: AuthUser) { async remove(id: string, user: AuthUser) {
if (!canCreateContent(user)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '普通用户只有查看和下载权限,不能删除非集成产品')
}
const existingRecord = await this.repository.findActiveById(id) const existingRecord = await this.repository.findActiveById(id)
if (!existingRecord) { if (!existingRecord) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')

View File

@ -2,6 +2,7 @@ import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types' import type { AuthUser } from '@/server/core/types'
import { canCreateContent } from '@/lib/role-scope'
import { import {
draftGroupRepository, draftGroupRepository,
preSkuRepository, preSkuRepository,
@ -520,6 +521,10 @@ class IntakeService {
input: FormalizeDraftGroupInput, input: FormalizeDraftGroupInput,
user: AuthUser, user: AuthUser,
): Promise<FormalizeDraftGroupResult> { ): Promise<FormalizeDraftGroupResult> {
if (!canCreateContent(user)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '普通用户只有查看和下载权限,不能执行正式入库')
}
const draftGroup = await draftGroupRepository.findByIdWithRelations(input.draftGroupId) const draftGroup = await draftGroupRepository.findByIdWithRelations(input.draftGroupId)
if (!draftGroup) { if (!draftGroup) {
throw new AppError(ErrorCode.DRAFT_GROUP_NOT_FOUND, '草稿组不存在') throw new AppError(ErrorCode.DRAFT_GROUP_NOT_FOUND, '草稿组不存在')

View File

@ -997,7 +997,7 @@ class OpenPlatformDemoSeedService {
const reviewerRecord = await ensureSeedUser({ const reviewerRecord = await ensureSeedUser({
email: 'open-platform-reviewer@example.com', email: 'open-platform-reviewer@example.com',
name: '开放平台审核员', name: '开放平台审核员',
role: 'TEAM_LEADER', role: 'SUPERVISOR',
}) })
const planner = toAuthUser(plannerRecord) const planner = toAuthUser(plannerRecord)

View File

@ -2,24 +2,21 @@ import { Prisma } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types' import type { AuthUser } from '@/server/core/types'
import type { PlanningWorkspaceWithRelations } from '../repositories' 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 ADMIN_ROLES = new Set<AuthUser['role']>(['SUPER_ADMIN', 'ADMIN'])
const DEPARTMENT_EDITOR_ROLES = new Set<AuthUser['role']>([
'DEPARTMENT_MANAGER',
'TEAM_LEADER',
])
class PlanningScopeService { class PlanningScopeService {
getScopedDepartmentId(user: AuthUser): string | undefined { getScopedDepartmentId(user: AuthUser): string | undefined {
return user.managedDepartmentId ?? user.departmentId return getManagedDepartmentId(user)
} }
canManagePlanning(user: AuthUser): boolean { canManagePlanning(user: AuthUser): boolean {
return user.role !== 'VIEWER' return canCreateContent(user)
} }
isAdmin(user: AuthUser): boolean { isAdmin(user: AuthUser): boolean {
return ADMIN_ROLES.has(user.role) return ADMIN_ROLES.has(user.role) || isAdminRole(user)
} }
assertCanWritePlanning(user: AuthUser, message = '当前账号只有查看权限,不能修改规划数据'): void { assertCanWritePlanning(user: AuthUser, message = '当前账号只有查看权限,不能修改规划数据'): void {
@ -50,7 +47,7 @@ class PlanningScopeService {
if ( if (
userDepartmentId && userDepartmentId &&
workspace.departmentId === userDepartmentId && workspace.departmentId === userDepartmentId &&
DEPARTMENT_EDITOR_ROLES.has(user.role) (isDepartmentSupervisor(user) || isTeamSupervisor(user))
) { ) {
return true return true
} }

View File

@ -8,6 +8,7 @@ import type { Prisma } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types' import type { AuthUser } from '@/server/core/types'
import { reportRepository } from '@/server/repositories/report.repository' import { reportRepository } from '@/server/repositories/report.repository'
import { isAdmin, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
type DateRangeFilter = { type DateRangeFilter = {
startDate?: string startDate?: string
@ -56,10 +57,10 @@ class ReportService {
departmentId?: string, departmentId?: string,
teamId?: string teamId?: string
): Promise<string[] | undefined> { ): Promise<string[] | undefined> {
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) { if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
return reportRepository.findUserIdsByDepartment(user.managedDepartmentId) return reportRepository.findUserIdsByDepartment(user.managedDepartmentId)
} }
if (user.role === 'TEAM_LEADER' && user.managedTeamId) { if (isTeamSupervisor(user) && user.managedTeamId) {
return reportRepository.findUserIdsByTeam(user.managedTeamId) return reportRepository.findUserIdsByTeam(user.managedTeamId)
} }
if (departmentId) { if (departmentId) {
@ -281,13 +282,13 @@ class ReportService {
} }
async getUserActivity(filter: UserActivityFilter, user: AuthUser) { 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, '权限不足') throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '权限不足')
} }
const dateRange = this.buildDateRange(filter.startDate, filter.endDate) const dateRange = this.buildDateRange(filter.startDate, filter.endDate)
const userWhere: Prisma.UserWhereInput = {} const userWhere: Prisma.UserWhereInput = {}
if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) { if (isDepartmentSupervisor(user) && user.managedDepartmentId) {
userWhere.departmentId = user.managedDepartmentId userWhere.departmentId = user.managedDepartmentId
} else if (filter.departmentId) { } else if (filter.departmentId) {
userWhere.departmentId = filter.departmentId userWhere.departmentId = filter.departmentId

View File

@ -7,6 +7,7 @@
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types' import type { AuthUser } from '@/server/core/types'
import { isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
import { handoverRepository } from '../repositories/handover.repository' import { handoverRepository } from '../repositories/handover.repository'
import { userService, type UserDTO } from './user.service' import { userService, type UserDTO } from './user.service'
@ -47,7 +48,7 @@ class UserHandoverService {
return targetUser return targetUser
} }
if (operator.role === 'DEPARTMENT_MANAGER') { if (isDepartmentSupervisor(operator)) {
const managedDepartmentId = this.getManagedDepartmentId(operator) const managedDepartmentId = this.getManagedDepartmentId(operator)
if (!managedDepartmentId) { if (!managedDepartmentId) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号未绑定可管理的部门') throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号未绑定可管理的部门')
@ -61,7 +62,7 @@ class UserHandoverService {
return targetUser return targetUser
} }
if (operator.role === 'TEAM_LEADER') { if (isTeamSupervisor(operator)) {
const managedTeamId = this.getManagedTeamId(operator) const managedTeamId = this.getManagedTeamId(operator)
if (!managedTeamId || targetUser.teamId !== managedTeamId) { if (!managedTeamId || targetUser.teamId !== managedTeamId) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权交接当前小组范围外的用户') 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 { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types' import type { AuthUser } from '@/server/core/types'
import type { Role, UserStatus } from '@prisma/client' 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 { teamService, type TeamDTO } from './team.service'
import { userService } from './user.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 const SENSITIVE_FIELDS = ['email', 'phone', 'lastLoginAt'] as const
interface ListUsersInput { interface ListUsersInput {
@ -130,11 +143,27 @@ class UserManagementService {
} }
private getManagedDepartmentId(currentUser: AuthUser): string | undefined { private getManagedDepartmentId(currentUser: AuthUser): string | undefined {
return currentUser.managedDepartmentId ?? currentUser.departmentId return getManagedDepartmentId(currentUser)
} }
private getManagedTeamId(currentUser: AuthUser): string | undefined { 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 { private createDepartmentNode(department: DepartmentDTO): OrganizationTreeDepartmentNode {
@ -207,8 +236,9 @@ class UserManagementService {
const limit = Math.min(100, Math.max(1, input.limit ?? 20)) const limit = Math.min(100, Math.max(1, input.limit ?? 20))
const canViewFull = this.canViewFullUserInfo(currentUser) const canViewFull = this.canViewFullUserInfo(currentUser)
if (currentUser.role === 'TEAM_LEADER') { if (isTeamSupervisor(currentUser)) {
if (!currentUser.teamId) { const managedTeamId = this.getManagedTeamId(currentUser)
if (!managedTeamId) {
return { return {
users: [], users: [],
page: 1, page: 1,
@ -225,7 +255,7 @@ class UserManagementService {
search: input.search, search: input.search,
role: input.role, role: input.role,
status: input.status, status: input.status,
teamId: currentUser.teamId, teamId: managedTeamId,
}) })
return { return {
@ -238,8 +268,9 @@ class UserManagementService {
} }
} }
if (currentUser.role === 'DEPARTMENT_MANAGER') { if (isDepartmentSupervisor(currentUser)) {
if (!currentUser.departmentId) { const managedDepartmentId = this.getManagedDepartmentId(currentUser)
if (!managedDepartmentId) {
return { return {
users: [], users: [],
page: 1, 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) { if (departmentIds.length === 0) {
return { return {
users: [], users: [],
@ -317,7 +348,7 @@ class UserManagementService {
let scopedTeams = allTeams let scopedTeams = allTeams
let scopedDepartmentIds: string[] | undefined let scopedDepartmentIds: string[] | undefined
if (currentUser.role === 'DEPARTMENT_MANAGER') { if (isDepartmentSupervisor(currentUser)) {
const managedDepartmentId = this.getManagedDepartmentId(currentUser) const managedDepartmentId = this.getManagedDepartmentId(currentUser)
if (!managedDepartmentId) { if (!managedDepartmentId) {
return this.buildEmptyOrganizationTree() return this.buildEmptyOrganizationTree()
@ -334,7 +365,7 @@ class UserManagementService {
} }
let allUsers: Awaited<ReturnType<typeof userService.listAll>> = [] let allUsers: Awaited<ReturnType<typeof userService.listAll>> = []
if (currentUser.role === 'TEAM_LEADER') { if (isTeamSupervisor(currentUser)) {
const managedTeamId = this.getManagedTeamId(currentUser) const managedTeamId = this.getManagedTeamId(currentUser)
if (!managedTeamId) { if (!managedTeamId) {
return this.buildEmptyOrganizationTree() return this.buildEmptyOrganizationTree()
@ -484,29 +515,40 @@ class UserManagementService {
}, },
currentUser: AuthUser currentUser: AuthUser
) { ) {
const targetUser: UserInfo = { if (!canCreateUser(currentUser)) {
id: '', throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限创建用户')
email: input.email,
name: input.name,
role: (input.role || 'DESIGNER') as Role,
status: 'ACTIVE',
departmentId: input.departmentId,
teamId: input.teamId,
} }
if (!canManageUser(currentUser, targetUser)) { const targetRole = (input.role || 'USER') as Role
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限创建此用户')
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({ const createdUser = await userService.create({
email: input.email, email: input.email,
name: input.name, name: input.name,
password: input.password, password: input.password,
role: input.role || 'DESIGNER', role: input.role || 'USER',
departmentId: input.departmentId, departmentId: input.departmentId,
teamId: input.teamId, teamId: input.teamId,
phone: input.phone, phone: input.phone,
isFirstLogin: false, isFirstLogin: false,
status: 'PENDING',
}) })
await logRepository.createLog({ await logRepository.createLog({
@ -618,8 +660,13 @@ class UserManagementService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '只有待激活用户可以执行激活') 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, { const updatedUser = await userService.update(userId, {
role: input.role ?? targetUser.role, role: targetRole,
departmentId: input.departmentId ?? targetUser.departmentId, departmentId: input.departmentId ?? targetUser.departmentId,
teamId: input.teamId ?? targetUser.teamId, teamId: input.teamId ?? targetUser.teamId,
status: 'ACTIVE', status: 'ACTIVE',

View File

@ -243,12 +243,12 @@ class UserService {
email: input.email, email: input.email,
name: input.name, name: input.name,
password: hashedPassword, password: hashedPassword,
role: input.role ?? 'DESIGNER', role: input.role ?? 'USER',
departmentId: input.departmentId, departmentId: input.departmentId,
teamId: input.teamId, teamId: input.teamId,
phone: input.phone, phone: input.phone,
isFirstLogin: input.isFirstLogin ?? false, isFirstLogin: input.isFirstLogin ?? false,
status: input.status ?? 'ACTIVE', status: input.status ?? 'PENDING',
loginSource: input.loginSource ?? 'LOCAL', loginSource: input.loginSource ?? 'LOCAL',
employeeDirectoryId: input.employeeDirectoryId, employeeDirectoryId: input.employeeDirectoryId,
dingtalkUserId: input.dingtalkUserId, dingtalkUserId: input.dingtalkUserId,

View File

@ -29,8 +29,8 @@ export const createUserSchema = z.object({
password: passwordSchema, password: passwordSchema,
role: z role: z
.enum(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER', 'DESIGNER', 'VIEWER']) .enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER'])
.default('DESIGNER'), .default('USER'),
departmentId: z departmentId: z
.string() .string()
@ -77,7 +77,7 @@ export const updateUserSchema = z.object({
.or(z.literal('')), .or(z.literal('')),
role: z role: z
.enum(['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER', 'TEAM_LEADER', 'DESIGNER', 'VIEWER']) .enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER'])
.optional(), .optional(),
departmentId: z departmentId: z
@ -93,7 +93,7 @@ export const updateUserSchema = z.object({
.nullable(), .nullable(),
status: z status: z
.enum(['ACTIVE', 'INACTIVE', 'SUSPENDED']) .enum(['ACTIVE', 'PENDING', 'INACTIVE', 'SUSPENDED'])
.optional(), .optional(),
}) })

View File

@ -50,7 +50,7 @@ const mockUsers = {
}, },
viewer: { viewer: {
id: 'viewer-1', id: 'viewer-1',
role: 'VIEWER', role: 'USER',
email: 'viewer@example.com', email: 'viewer@example.com',
name: 'Viewer', name: 'Viewer',
}, },
@ -342,7 +342,7 @@ describe('权限绕过安全测试', () => {
// 在实际 API 中,应该检查用户角色 // 在实际 API 中,应该检查用户角色
const canCreate = (user: any): boolean => { 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); return allowedRoles.includes(user.role);
}; };
@ -413,7 +413,7 @@ describe('权限绕过安全测试', () => {
}); });
it('POST /api/products 应该需要设计师或更高权限', () => { 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 viewer = mockUsers.viewer;
const designer = mockUsers.designer; const designer = mockUsers.designer;