feat(intake): add node permissions and accessory library controls
This commit is contained in:
parent
320670fa92
commit
78a036ca2d
@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS "IntakeNodePermissionOverride" (
|
||||
"id" TEXT NOT NULL,
|
||||
"targetKey" TEXT NOT NULL,
|
||||
"subjectKey" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdBy" TEXT,
|
||||
"updatedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "IntakeNodePermissionOverride_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_subjectKey_action_key"
|
||||
ON "IntakeNodePermissionOverride"("targetKey", "subjectKey", "action");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_idx"
|
||||
ON "IntakeNodePermissionOverride"("targetKey");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_subjectKey_idx"
|
||||
ON "IntakeNodePermissionOverride"("subjectKey");
|
||||
@ -1563,6 +1563,24 @@ model ThemeConfig {
|
||||
@@index([startDate, endDate])
|
||||
}
|
||||
|
||||
model IntakeNodePermissionOverride {
|
||||
id String @id @default(cuid())
|
||||
|
||||
targetKey String
|
||||
subjectKey String
|
||||
action String
|
||||
enabled Boolean @default(true)
|
||||
|
||||
createdBy String?
|
||||
updatedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([targetKey, subjectKey, action])
|
||||
@@index([targetKey])
|
||||
@@index([subjectKey])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 五金件库(标准五金件复用系统)
|
||||
// ============================================
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { AppError } from "@/server/core/errors";
|
||||
import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response";
|
||||
import { intakeNodePermissionSettingsService } from "@/server/services/intake-node-permission-settings.service";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const settings = await intakeNodePermissionSettingsService.getSettings();
|
||||
return jsonSuccess(settings, 200, ctx.requestId);
|
||||
},
|
||||
{ requireAuth: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const rawBody = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const settings =
|
||||
await intakeNodePermissionSettingsService.updateSettings(
|
||||
rawBody,
|
||||
ctx.user,
|
||||
);
|
||||
return jsonSuccess(settings, 200, ctx.requestId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(
|
||||
error.code,
|
||||
error.message,
|
||||
error.statusCode,
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,44 +9,141 @@ describe('alphabet library panels', () => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('adds color and material fields to the accessory library only', async () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses the structured self-tapping screw library for accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增当前字母' }))
|
||||
expect(screen.getByText('A 自攻螺丝')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增' })).not.toBeInTheDocument()
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增配件' })
|
||||
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
|
||||
|
||||
expect(within(dialog).getByLabelText('颜色')).toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('材质')).toBeInTheDocument()
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M4*16 十字 沉头 尖尾 自攻螺丝',
|
||||
)
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/配件名称/), {
|
||||
target: { value: '六角螺丝' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '黑色' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '铁' },
|
||||
fireEvent.change(within(dialog).getByLabelText('长度'), {
|
||||
target: { value: '20mm' },
|
||||
})
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M4*20 十字 沉头 尖尾 自攻螺丝',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('黑色')).toBeInTheDocument()
|
||||
expect(screen.getByText('铁')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('M4*20 十字 沉头 尖尾 自攻螺丝'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
name: '六角螺丝',
|
||||
color: '黑色',
|
||||
material: '铁',
|
||||
letter: 'A',
|
||||
code: 'A0',
|
||||
name: 'M4*20 十字 沉头 尖尾 自攻螺丝',
|
||||
headType: '沉头',
|
||||
drive: '十字',
|
||||
tail: '尖尾',
|
||||
material: '1022A碳钢',
|
||||
tooth: '木牙',
|
||||
diameter: 'M4',
|
||||
length: '20mm',
|
||||
color: '银色',
|
||||
})
|
||||
})
|
||||
|
||||
it('lets every accessory letter group be selected even before it is configured', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'B 0 条' }))
|
||||
|
||||
expect(screen.getByText('B 组配件')).toBeInTheDocument()
|
||||
expect(screen.getByText('B 组暂未配置配件分类')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' }))
|
||||
|
||||
expect(screen.getByText('A 组配件 · 自攻螺丝')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '新增 A 组' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens copy-new mode and blocks exact duplicate screw configs', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
|
||||
let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('M4*16 十字 沉头 尖尾 自攻螺丝'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '复制' }))
|
||||
dialog = await screen.findByRole('dialog', { name: '复制新建:A0' })
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(await within(dialog).findByText(/已存在相同配置/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('长度'), {
|
||||
target: { value: '25mm' },
|
||||
})
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('M4*25 十字 沉头 尖尾 自攻螺丝'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows delete only when accessory library deletion is allowed', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
const { rerender } = render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
|
||||
let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(<AccessoryLibraryPanel canDeleteItems />)
|
||||
const deleteButton = await screen.findByRole('button', { name: '删除' })
|
||||
|
||||
await actor.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('M4*16 十字 沉头 尖尾 自攻螺丝'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('确认删除配件'),
|
||||
)
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]'),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the packaging material library on its original fields', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
|
||||
@ -13,15 +13,22 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
Boxes,
|
||||
Calculator,
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
Copy,
|
||||
Database,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Home,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Layers3,
|
||||
Package,
|
||||
PackageOpen,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
@ -29,6 +36,7 @@ import {
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload,
|
||||
Warehouse,
|
||||
X as XIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@ -90,6 +98,20 @@ import {
|
||||
isIntakeNonIntegratedQualityGroupUser,
|
||||
type IntakeNonIntegratedPermissionUser,
|
||||
} from "@/lib/non-integrated-permissions";
|
||||
import {
|
||||
ALL_NODE_PERMISSION_TARGETS,
|
||||
CORE_NODE_PERMISSION_SUBJECTS,
|
||||
NODE_PERMISSION_STATE_LABEL,
|
||||
ORDINARY_NODE_PERMISSION_SUBJECTS,
|
||||
PRODUCT_DEVELOPMENT_PERMISSION_TARGETS,
|
||||
TOP_ENTRY_PERMISSION_TARGETS,
|
||||
buildIntakeNodePermissionOverrideKey,
|
||||
sanitizeIntakeNodePermissionOverrides,
|
||||
type IntakeNodePermissionOverrideMap,
|
||||
type IntakeNodePermissionState,
|
||||
type IntakeNodePermissionSubject,
|
||||
type IntakeNodePermissionTargetKey,
|
||||
} from "@/lib/intake-node-permission-settings";
|
||||
import {
|
||||
getNextProductPatentFeeDueDate,
|
||||
listProductPatentRecords,
|
||||
@ -193,6 +215,10 @@ function canManageSpuRuleLibrary(role: string): boolean {
|
||||
return role === "SUPER_ADMIN" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function canManageNodePermissionSettings(role: string): boolean {
|
||||
return role === "SUPER_ADMIN" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function canAccessDesignControlledProductData(
|
||||
user: IntakeProductPermissionUser,
|
||||
): boolean {
|
||||
@ -241,6 +267,7 @@ type IntakeSection =
|
||||
| "rawMaterialLibrary"
|
||||
| "referenceEstimator"
|
||||
| "patents"
|
||||
| "adminNodePermission"
|
||||
| null;
|
||||
type IntakeEntryTab = "integrated" | "nonIntegrated" | "stock";
|
||||
type ProductDevelopmentKind = NonNullable<
|
||||
@ -276,6 +303,7 @@ const FORMAL_SIDEBAR_GROUP_ICONS = {
|
||||
referenceEstimator: Calculator,
|
||||
patents: ShieldCheck,
|
||||
spuRuleLibrary: Database,
|
||||
nodePermission: KeyRound,
|
||||
} satisfies Record<string, LucideIcon>;
|
||||
|
||||
function FormalSidebarIcon({
|
||||
@ -287,6 +315,410 @@ function FormalSidebarIcon({
|
||||
}) {
|
||||
return <Icon aria-hidden="true" className={className} />;
|
||||
}
|
||||
|
||||
const TOP_ENTRY_PERMISSION_ICON_BY_KEY: Partial<
|
||||
Record<IntakeNodePermissionTargetKey, LucideIcon>
|
||||
> = {
|
||||
homeDashboard: Home,
|
||||
assetCenter: Boxes,
|
||||
assistedAssembly: Layers,
|
||||
draftGroups: PackageOpen,
|
||||
formalProducts: ArrowDownToLine,
|
||||
warehouseManage: Warehouse,
|
||||
warehouseQuality: ClipboardCheck,
|
||||
};
|
||||
|
||||
function getNodePermissionStateClass(state: IntakeNodePermissionState) {
|
||||
if (state === "edit") return "bg-apple-blue/10 text-apple-blue";
|
||||
if (state === "readonly") {
|
||||
return "bg-apple-bg-secondary text-apple-label-secondary";
|
||||
}
|
||||
return "bg-zinc-100 text-zinc-400";
|
||||
}
|
||||
|
||||
function getNodePermissionTargetButtonClass(isActive: boolean) {
|
||||
return `relative flex min-h-[34px] items-center rounded-apple-lg border px-3 py-2 text-left transition ${
|
||||
isActive
|
||||
? "border-zinc-950 bg-zinc-950 text-white hover:border-zinc-800 hover:bg-zinc-800"
|
||||
: "border-transparent bg-apple-bg-primary text-apple-label-primary hover:bg-apple-bg-secondary/40"
|
||||
}`;
|
||||
}
|
||||
|
||||
function getTopEntryPermissionButtonClass(isActive: boolean) {
|
||||
return `inline-flex h-9 shrink-0 items-center gap-2 rounded-[6px] border px-3 text-[12px] font-apple-semibold transition ${
|
||||
isActive
|
||||
? "border-transparent bg-black/[0.04] text-[#2d3335] shadow-sm"
|
||||
: "border-transparent text-[#5a6062]/75 hover:bg-black/[0.025] hover:text-[#2d3335]"
|
||||
}`;
|
||||
}
|
||||
|
||||
function NodePermissionToggle({
|
||||
checked,
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`inline-flex h-6 w-11 items-center rounded-full border px-0.5 transition ${
|
||||
checked
|
||||
? "justify-end border-zinc-950 bg-zinc-950"
|
||||
: "justify-start border-apple-separator bg-apple-bg-secondary"
|
||||
} ${disabled ? "cursor-not-allowed opacity-40" : ""}`}
|
||||
title={label}
|
||||
>
|
||||
<span className="h-5 w-5 rounded-full bg-white shadow-apple-xs" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminNodePermissionWorkspace() {
|
||||
const [activeTargetKey, setActiveTargetKey] = useState(
|
||||
TOP_ENTRY_PERMISSION_TARGETS[0]!.key,
|
||||
);
|
||||
const [overrides, setOverrides] =
|
||||
useState<IntakeNodePermissionOverrideMap>({});
|
||||
const [permissionSettingsStatus, setPermissionSettingsStatus] =
|
||||
useState("正在读取权限配置...");
|
||||
const [ordinaryExpanded, setOrdinaryExpanded] = useState(false);
|
||||
const activeTarget =
|
||||
ALL_NODE_PERMISSION_TARGETS.find((target) => target.key === activeTargetKey) ??
|
||||
ALL_NODE_PERMISSION_TARGETS[0]!;
|
||||
|
||||
function getOverrideKey(
|
||||
subjectKey: IntakeNodePermissionSubject["key"],
|
||||
action: "view" | "download" | "edit",
|
||||
) {
|
||||
return buildIntakeNodePermissionOverrideKey(
|
||||
activeTarget.key,
|
||||
subjectKey,
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
async function persistOverrides(nextOverrides: IntakeNodePermissionOverrideMap) {
|
||||
setPermissionSettingsStatus("正在保存...");
|
||||
try {
|
||||
const response = await fetch("/api/intake/node-permission-settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ overrides: nextOverrides }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
success?: boolean;
|
||||
data?: { overrides?: unknown };
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || "权限配置保存失败");
|
||||
}
|
||||
|
||||
setOverrides(sanitizeIntakeNodePermissionOverrides(payload.data?.overrides));
|
||||
setPermissionSettingsStatus("已保存,刷新后仍会生效");
|
||||
} catch (error) {
|
||||
setPermissionSettingsStatus(
|
||||
error instanceof Error ? error.message : "权限配置保存失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOverride(
|
||||
subjectKey: IntakeNodePermissionSubject["key"],
|
||||
action: "view" | "download" | "edit",
|
||||
) {
|
||||
const key = getOverrideKey(subjectKey, action);
|
||||
setOverrides((current) => {
|
||||
const next = { ...current };
|
||||
if (next[key]) {
|
||||
delete next[key];
|
||||
if (action === "view") {
|
||||
delete next[getOverrideKey(subjectKey, "download")];
|
||||
delete next[getOverrideKey(subjectKey, "edit")];
|
||||
}
|
||||
} else {
|
||||
next[key] = true;
|
||||
if (action === "download" || action === "edit") {
|
||||
next[getOverrideKey(subjectKey, "view")] = true;
|
||||
}
|
||||
}
|
||||
|
||||
void persistOverrides(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/intake/node-permission-settings", {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
success?: boolean;
|
||||
data?: { overrides?: unknown };
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || "权限配置读取失败");
|
||||
}
|
||||
|
||||
setOverrides(sanitizeIntakeNodePermissionOverrides(payload.data?.overrides));
|
||||
setPermissionSettingsStatus("已读取当前权限配置");
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setPermissionSettingsStatus(
|
||||
error instanceof Error ? error.message : "权限配置读取失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function renderSubjectRow(subject: IntakeNodePermissionSubject) {
|
||||
const state = activeTarget.defaults[subject.key] ?? "hidden";
|
||||
const visibleOverrideOn = Boolean(overrides[getOverrideKey(subject.key, "view")]);
|
||||
const downloadOverrideOn = Boolean(
|
||||
overrides[getOverrideKey(subject.key, "download")],
|
||||
);
|
||||
const editOverrideOn = Boolean(overrides[getOverrideKey(subject.key, "edit")]);
|
||||
const visibleOn =
|
||||
state !== "hidden" ||
|
||||
visibleOverrideOn;
|
||||
const defaultDownloadOn = Boolean(activeTarget.downloads?.[subject.key]);
|
||||
const downloadOn =
|
||||
defaultDownloadOn ||
|
||||
downloadOverrideOn;
|
||||
const editOn = state === "edit" || editOverrideOn;
|
||||
const effectiveState: IntakeNodePermissionState = editOn
|
||||
? "edit"
|
||||
: visibleOn
|
||||
? "readonly"
|
||||
: "hidden";
|
||||
const openedActions = [
|
||||
visibleOverrideOn ? "可见" : null,
|
||||
downloadOverrideOn ? "下载" : null,
|
||||
editOverrideOn ? "操作" : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<tr key={subject.key} className="border-b border-apple-separator">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-apple-semibold text-apple-label-primary">
|
||||
{subject.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-apple-label-tertiary">
|
||||
{subject.department}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-apple-md px-2 text-[11px] font-apple-semibold ${getNodePermissionStateClass(
|
||||
state,
|
||||
)}`}
|
||||
>
|
||||
默认:{NODE_PERMISSION_STATE_LABEL[state]}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-apple-md px-2 text-[11px] font-apple-semibold ${getNodePermissionStateClass(
|
||||
effectiveState,
|
||||
)}`}
|
||||
>
|
||||
生效:{NODE_PERMISSION_STATE_LABEL[effectiveState]}
|
||||
</span>
|
||||
{openedActions.length > 0 ? (
|
||||
<span className="inline-flex h-6 items-center rounded-apple-md bg-zinc-950 px-2 text-[11px] font-apple-semibold text-white">
|
||||
已开通:{openedActions.join("、")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<NodePermissionToggle
|
||||
checked={visibleOn}
|
||||
disabled={state !== "hidden"}
|
||||
label="开放可见"
|
||||
onChange={() => toggleOverride(subject.key, "view")}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<NodePermissionToggle
|
||||
checked={downloadOn}
|
||||
disabled={!visibleOn || defaultDownloadOn}
|
||||
label="开放下载"
|
||||
onChange={() => toggleOverride(subject.key, "download")}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<NodePermissionToggle
|
||||
checked={editOn}
|
||||
disabled={state === "edit" || !visibleOn}
|
||||
label="开放操作"
|
||||
onChange={() => toggleOverride(subject.key, "edit")}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-apple-label-secondary">
|
||||
{openedActions.length > 0
|
||||
? `管理员已额外开通:${openedActions.join("、")}。`
|
||||
: state === "edit"
|
||||
? "默认已有操作权,无需开放。"
|
||||
: state === "readonly"
|
||||
? "默认可看不可改,可开放下载或操作。"
|
||||
: "默认隐藏,可先打开可见,再开放下载或操作。"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_184px] overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/40">
|
||||
<section className="min-h-0 overflow-auto p-3">
|
||||
<div className="flex min-h-full flex-col gap-3 rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-3 shadow-apple-xs">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3">
|
||||
<div className="text-[11px] text-apple-label-secondary">
|
||||
{permissionSettingsStatus}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 overflow-x-auto rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/35 px-2 py-1.5">
|
||||
{TOP_ENTRY_PERMISSION_TARGETS.map((target) => {
|
||||
const isActive = target.key === activeTarget.key;
|
||||
const Icon = TOP_ENTRY_PERMISSION_ICON_BY_KEY[target.key] ?? Home;
|
||||
return (
|
||||
<button
|
||||
key={target.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTargetKey(target.key)}
|
||||
className={getTopEntryPermissionButtonClass(isActive)}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="h-[15px] w-[15px] shrink-0"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<span className="whitespace-nowrap">{target.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-apple-lg border border-apple-separator">
|
||||
<table className="w-full min-w-[920px] border-collapse text-xs">
|
||||
<thead className="bg-apple-bg-secondary text-apple-label-secondary">
|
||||
<tr>
|
||||
<th className="border-b border-apple-separator px-3 py-2 text-left font-apple-semibold">
|
||||
对象
|
||||
</th>
|
||||
<th className="border-b border-apple-separator px-3 py-2 text-left font-apple-semibold">
|
||||
权限状态
|
||||
</th>
|
||||
<th className="border-b border-apple-separator px-3 py-2 text-center font-apple-semibold">
|
||||
可见
|
||||
</th>
|
||||
<th className="border-b border-apple-separator px-3 py-2 text-center font-apple-semibold">
|
||||
下载
|
||||
</th>
|
||||
<th className="border-b border-apple-separator px-3 py-2 text-center font-apple-semibold">
|
||||
操作
|
||||
</th>
|
||||
<th className="border-b border-apple-separator px-3 py-2 text-left font-apple-semibold">
|
||||
说明
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-apple-separator bg-apple-bg-secondary/60">
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-3 py-2 text-[11px] font-apple-semibold text-apple-label-secondary"
|
||||
>
|
||||
核心对象
|
||||
</td>
|
||||
</tr>
|
||||
{CORE_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)}
|
||||
<tr className="border-b border-apple-separator bg-apple-bg-secondary/60">
|
||||
<td colSpan={6} className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrdinaryExpanded((current) => !current)}
|
||||
className="flex w-full items-center justify-between text-left text-[11px] font-apple-semibold text-apple-label-secondary"
|
||||
>
|
||||
<span>普通业务</span>
|
||||
<span>{ordinaryExpanded ? "收起" : "展开"}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{ordinaryExpanded
|
||||
? ORDINARY_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 w-full shrink-0 border-l border-apple-separator p-3">
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 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-3 py-2">
|
||||
<div className="text-[12px] font-apple-semibold text-apple-label-primary">
|
||||
权限范围
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-apple-label-secondary">
|
||||
点击查看权限
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2 pr-1.5">
|
||||
<div className="px-1 pt-1 text-[10px] font-apple-semibold text-apple-label-tertiary">
|
||||
产品开发明细节点
|
||||
</div>
|
||||
{PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.map((target) => {
|
||||
const isActive = target.key === activeTarget.key;
|
||||
return (
|
||||
<button
|
||||
key={target.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTargetKey(target.key)}
|
||||
className={getNodePermissionTargetButtonClass(isActive)}
|
||||
>
|
||||
{isActive ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-1.5 left-0 top-1.5 w-[3px] rounded-full bg-zinc-950"
|
||||
/>
|
||||
) : null}
|
||||
<p className="truncate text-[13px] font-apple-medium leading-5">
|
||||
{target.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type IntakeWorkspaceTab =
|
||||
| {
|
||||
id: string;
|
||||
@ -304,6 +736,11 @@ type IntakeWorkspaceTab =
|
||||
kind: "spuRuleLibrary";
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "adminNodePermission";
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "productPatentLedger";
|
||||
@ -368,6 +805,10 @@ type PersistedIntakeWorkspaceTab =
|
||||
id: string;
|
||||
kind: "spuRuleLibrary";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "adminNodePermission";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "productPatentLedger";
|
||||
@ -1234,6 +1675,9 @@ function restoreIntakeWorkspaceTab(
|
||||
if (tab.kind === "spuRuleLibrary") {
|
||||
return { id: tab.id, kind: tab.kind, label: "SPU规则库" };
|
||||
}
|
||||
if (tab.kind === "adminNodePermission") {
|
||||
return { id: tab.id, kind: tab.kind, label: "节点权限开放" };
|
||||
}
|
||||
if (tab.kind === "productPatentLedger") {
|
||||
return {
|
||||
id: tab.id,
|
||||
@ -1363,6 +1807,7 @@ function readInitialIntakeWorkspaceState(params: {
|
||||
function isDesignControlledWorkspaceTab(tab: IntakeWorkspaceTab) {
|
||||
if (tab.kind === "newNonIntegratedProduct") return true;
|
||||
if (tab.kind === "bomLibrary") return true;
|
||||
if (tab.kind === "adminNodePermission") return true;
|
||||
if (tab.kind === "productPatentDashboard") return true;
|
||||
return tab.kind === "productPatentLedger" && !tab.target;
|
||||
}
|
||||
@ -1407,7 +1852,13 @@ function persistIntakeWorkspaceState(params: {
|
||||
}
|
||||
|
||||
function restrictSectionForBusinessUser(section: IntakeSection): IntakeSection {
|
||||
if (section === "bom" || section === "patents") return "pending";
|
||||
if (
|
||||
section === "bom" ||
|
||||
section === "patents" ||
|
||||
section === "adminNodePermission"
|
||||
) {
|
||||
return "pending";
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
@ -1422,6 +1873,7 @@ function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
|
||||
return "pending";
|
||||
}
|
||||
if (tab.kind === "progressDashboard") return "progress";
|
||||
if (tab.kind === "adminNodePermission") return "adminNodePermission";
|
||||
if (tab.kind === "productPatentLedger") return "patents";
|
||||
if (tab.kind === "productPatentDashboard") return "patents";
|
||||
if (tab.kind === "bomLibrary") return "bom";
|
||||
@ -1444,6 +1896,7 @@ function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) {
|
||||
if (tab.kind === "pendingEntry") return `pending-entry:${tab.entryTab}`;
|
||||
if (tab.kind === "progressDashboard") return "product-progress:dashboard";
|
||||
if (tab.kind === "spuRuleLibrary") return "spu-rule-library";
|
||||
if (tab.kind === "adminNodePermission") return "node-permission";
|
||||
if (tab.kind === "productPatentDashboard") return "product-patent:dashboard";
|
||||
if (tab.kind === "productPatentLedger") {
|
||||
const target = tab.target;
|
||||
@ -5804,7 +6257,9 @@ function BomDetail({
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AccessoryLibraryPanel />
|
||||
<AccessoryLibraryPanel
|
||||
canDeleteItems={user.role === "SUPER_ADMIN" || user.role === "ADMIN"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -8810,7 +9265,9 @@ function FormalIntakeWorkbench({
|
||||
onOpenProductPatentLedger,
|
||||
onOpenProductPatentDashboard,
|
||||
onOpenSpuRuleLibraryWorkspace,
|
||||
onOpenAdminNodePermissionWorkspace,
|
||||
showDesignControlledProductData,
|
||||
showNodePermissionSettings,
|
||||
}: {
|
||||
activeSection: IntakeSection;
|
||||
onToggle: (section: Exclude<IntakeSection, null>) => void;
|
||||
@ -8826,7 +9283,9 @@ function FormalIntakeWorkbench({
|
||||
onOpenProductPatentLedger: () => void;
|
||||
onOpenProductPatentDashboard: () => void;
|
||||
onOpenSpuRuleLibraryWorkspace?: () => void;
|
||||
onOpenAdminNodePermissionWorkspace?: () => void;
|
||||
showDesignControlledProductData: boolean;
|
||||
showNodePermissionSettings: boolean;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [
|
||||
@ -8854,7 +9313,11 @@ function FormalIntakeWorkbench({
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "pending" || activeSection === "progress"}
|
||||
expanded={
|
||||
activeSection === "pending" ||
|
||||
activeSection === "progress" ||
|
||||
activeSection === "adminNodePermission"
|
||||
}
|
||||
onToggle={() => onToggle("pending")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
@ -8894,6 +9357,20 @@ function FormalIntakeWorkbench({
|
||||
>
|
||||
<span>{getPendingEntryTabLabel("stock")}</span>
|
||||
</button>
|
||||
{showNodePermissionSettings && onOpenAdminNodePermissionWorkspace ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAdminNodePermissionWorkspace}
|
||||
className={`flex w-full items-center gap-2 rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "adminNodePermission"
|
||||
? "border-zinc-950 bg-zinc-950 text-white"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<FormalSidebarIcon icon={FORMAL_SIDEBAR_GROUP_ICONS.nodePermission} />
|
||||
<span>节点权限开放</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
@ -9423,6 +9900,8 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
} else if (tab.kind === "spuRuleLibrary") {
|
||||
setActiveSection("pending");
|
||||
setPendingEntryTab("nonIntegrated");
|
||||
} else if (tab.kind === "adminNodePermission") {
|
||||
setActiveSection("adminNodePermission");
|
||||
} else if (tab.kind === "productPatentLedger") {
|
||||
setActiveSection("patents");
|
||||
} else if (tab.kind === "productPatentDashboard") {
|
||||
@ -9508,6 +9987,20 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
openWorkspaceTab(nextTab);
|
||||
}
|
||||
|
||||
function openAdminNodePermissionWorkspace() {
|
||||
if (!canManageNodePermissionSettings(props.user.role)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = createWorkspaceTabId("node-permission", "dashboard");
|
||||
const nextTab: IntakeWorkspaceTab = {
|
||||
id: tabId,
|
||||
kind: "adminNodePermission",
|
||||
label: "节点权限开放",
|
||||
};
|
||||
openWorkspaceTab(nextTab);
|
||||
}
|
||||
|
||||
function openProductWorkspace(
|
||||
product: NonIntegratedProductRecord,
|
||||
mode: WorkspaceProductMode,
|
||||
@ -9822,6 +10315,12 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
);
|
||||
} else if (activeWorkspaceTab?.kind === "spuRuleLibrary") {
|
||||
activeContent = <SpuRuleLibraryWorkspace />;
|
||||
} else if (activeWorkspaceTab?.kind === "adminNodePermission") {
|
||||
activeContent = canManageNodePermissionSettings(props.user.role) ? (
|
||||
<AdminNodePermissionWorkspace />
|
||||
) : (
|
||||
<EmptyDetail />
|
||||
);
|
||||
} else if (activeWorkspaceTab?.kind === "bomLibrary") {
|
||||
activeContent = (
|
||||
<BomDetail
|
||||
@ -10109,9 +10608,17 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
? openSpuRuleLibraryWorkspace
|
||||
: undefined
|
||||
}
|
||||
onOpenAdminNodePermissionWorkspace={
|
||||
canManageNodePermissionSettings(props.user.role)
|
||||
? openAdminNodePermissionWorkspace
|
||||
: undefined
|
||||
}
|
||||
showDesignControlledProductData={
|
||||
canUseDesignControlledProductData
|
||||
}
|
||||
showNodePermissionSettings={canManageNodePermissionSettings(
|
||||
props.user.role,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -10274,7 +10781,15 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
? openSpuRuleLibraryWorkspace
|
||||
: undefined
|
||||
}
|
||||
onOpenAdminNodePermissionWorkspace={
|
||||
canManageNodePermissionSettings(props.user.role)
|
||||
? openAdminNodePermissionWorkspace
|
||||
: undefined
|
||||
}
|
||||
showDesignControlledProductData={canUseDesignControlledProductData}
|
||||
showNodePermissionSettings={canManageNodePermissionSettings(
|
||||
props.user.role,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3525,8 +3525,8 @@ describe("NonIntegratedDetailClient", () => {
|
||||
createProductRecord({
|
||||
id: "DNZ0001-purchase-download-restricted",
|
||||
spu: "SZ0230",
|
||||
purchaser: "其他采购",
|
||||
purchaseOwner: "其他采购",
|
||||
purchaser: "采购同事",
|
||||
purchaseOwner: "采购同事",
|
||||
skuItems: [
|
||||
createSkuItem({
|
||||
id: "sku-purchase-download-restricted-1",
|
||||
@ -4686,7 +4686,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets purchasing users edit only purchasing document nodes", async () => {
|
||||
it("keeps purchasing users readonly on pricing-owned measurement nodes", async () => {
|
||||
const actor = userEvent.setup();
|
||||
const sampleMeasurementCategory = "样包测量";
|
||||
|
||||
@ -4735,16 +4735,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
const sampleLengthInput = screen.getByTestId(
|
||||
"sample-measurement-lengthCm-sku-purchasing-permission-1",
|
||||
);
|
||||
expect(sampleLengthInput).not.toHaveAttribute("readonly");
|
||||
|
||||
await actor.type(sampleLengthInput, "120");
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById(
|
||||
"SZ0230-purchasing-permission",
|
||||
);
|
||||
expect(savedProduct?.sampleMeasurementRows?.[0]?.lengthCm).toBe("120");
|
||||
});
|
||||
expect(sampleLengthInput).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("lets supply chain pricing and quality groups maintain their own nodes", async () => {
|
||||
@ -4885,7 +4876,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets planning users edit opening reference, planning layout, and product limit nodes", async () => {
|
||||
it("lets product planning users edit opening reference and product limit but not planning layout", async () => {
|
||||
const actor = userEvent.setup();
|
||||
const openingReferenceCategory = "开品参考";
|
||||
const planningLayoutCategory = "企划布局";
|
||||
@ -4939,12 +4930,9 @@ describe("NonIntegratedDetailClient", () => {
|
||||
await actor.click(
|
||||
screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("planning-layout-styles-draft-sku-planning-permission-1"),
|
||||
).not.toHaveAttribute("readonly");
|
||||
expect(
|
||||
screen.getByTestId(`document-workspace-confirm-${planningLayoutCategory}`),
|
||||
).not.toBeDisabled();
|
||||
).toBeDisabled();
|
||||
|
||||
await actor.click(
|
||||
screen.getByTestId(`document-workspace-trigger-${productLimitCategory}`),
|
||||
|
||||
@ -271,6 +271,13 @@ import {
|
||||
isIntakeNonIntegratedSupplyChainUser,
|
||||
type IntakeNonIntegratedPermissionUser,
|
||||
} from "@/lib/non-integrated-permissions";
|
||||
import {
|
||||
getIntakeNodePermissionTargetKeyByWorkspaceCategory,
|
||||
resolveIntakeNodePermissionAccess,
|
||||
resolveIntakeNodePermissionSubjectKeys,
|
||||
sanitizeIntakeNodePermissionOverrides,
|
||||
type IntakeNodePermissionOverrideMap,
|
||||
} from "@/lib/intake-node-permission-settings";
|
||||
|
||||
type BasicFieldType = "text" | "textarea" | "select";
|
||||
type SkuTabKey = "componentSkuInfo" | "skuInfo" | "fileUpload";
|
||||
@ -1042,76 +1049,6 @@ const PRODUCT_LIMIT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
|
||||
"产品限价";
|
||||
const PLANNING_LAYOUT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
|
||||
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY;
|
||||
const PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>([
|
||||
"开品参考",
|
||||
"设计卖点",
|
||||
PLANNING_LAYOUT_WORKSPACE_CATEGORY,
|
||||
PATENT_APPLICATION_WORKSPACE_CATEGORY,
|
||||
"产品实拍图",
|
||||
INSTALLATION_WORKSPACE_CATEGORY,
|
||||
PACKAGING_WORKSPACE_CATEGORY,
|
||||
PRODUCT_LIMIT_WORKSPACE_CATEGORY,
|
||||
"工厂质检",
|
||||
DETECTION_WORKSPACE_CATEGORY,
|
||||
]);
|
||||
|
||||
function isPurchaseHiddenDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
options?: { canViewFactoryQuality?: boolean },
|
||||
) {
|
||||
if (category === "工厂质检" && options?.canViewFactoryQuality) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
const PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>([
|
||||
PRODUCT_DATA_WORKSPACE_CATEGORY,
|
||||
PATENT_APPLICATION_WORKSPACE_CATEGORY,
|
||||
PACKAGING_WORKSPACE_CATEGORY,
|
||||
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
|
||||
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
|
||||
"工厂质检",
|
||||
DETECTION_WORKSPACE_CATEGORY,
|
||||
]);
|
||||
|
||||
const PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>(["开品参考", "设计卖点"]);
|
||||
|
||||
const PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>([
|
||||
PRODUCT_DATA_WORKSPACE_CATEGORY,
|
||||
REVIEW_WORKSPACE_CATEGORY,
|
||||
PATENT_APPLICATION_WORKSPACE_CATEGORY,
|
||||
PACKAGING_WORKSPACE_CATEGORY,
|
||||
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
|
||||
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
|
||||
"工厂质检",
|
||||
]);
|
||||
|
||||
function isPlanningHiddenDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
) {
|
||||
return PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
function isPlainViewerHiddenDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
) {
|
||||
return PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
function canPlanningDownloadDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
) {
|
||||
return PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
function getUniqueNonEmptyStrings(
|
||||
values: ReadonlyArray<string | null | undefined>,
|
||||
) {
|
||||
@ -10337,6 +10274,8 @@ export default function NonIntegratedDetailClient({
|
||||
useState<UploadProgressState | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<DownloadProgressState | null>(null);
|
||||
const [nodePermissionOverrides, setNodePermissionOverrides] =
|
||||
useState<IntakeNodePermissionOverrideMap>({});
|
||||
const [projectStatus, setProjectStatus] =
|
||||
useState<NonIntegratedProjectStatus>(initialProjectStatus);
|
||||
const [projectReview, setProjectReview] = useState<NonIntegratedProjectReview>(
|
||||
@ -11108,6 +11047,38 @@ export default function NonIntegratedDetailClient({
|
||||
currentUserRole,
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/intake/node-permission-settings", {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
success?: boolean;
|
||||
data?: { overrides?: unknown };
|
||||
} | null;
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.ok && payload?.success) {
|
||||
setNodePermissionOverrides(
|
||||
sanitizeIntakeNodePermissionOverrides(payload.data?.overrides),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setNodePermissionOverrides({});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const currentPermissionProduct = useMemo(
|
||||
() => ({
|
||||
createdBy: existingProduct?.createdBy,
|
||||
@ -11163,21 +11134,51 @@ export default function NonIntegratedDetailClient({
|
||||
!canCurrentUserEditAnyConfiguredWorkspace &&
|
||||
!isCurrentUserPurchaseDownloadRestricted &&
|
||||
!isCurrentUserPlanningContentRestricted;
|
||||
const isCurrentUserWorkspaceDownloadRestricted =
|
||||
isCurrentUserPurchaseDownloadRestricted ||
|
||||
isCurrentUserPlanningContentRestricted;
|
||||
const currentPermissionSubjectKeys = useMemo(
|
||||
() => resolveIntakeNodePermissionSubjectKeys(currentPermissionUser),
|
||||
[currentPermissionUser],
|
||||
);
|
||||
const getNodePermissionAccessForCategory = useCallback(
|
||||
(category: DocumentWorkspaceCategory | string) => {
|
||||
const targetKey =
|
||||
getIntakeNodePermissionTargetKeyByWorkspaceCategory(category);
|
||||
return resolveIntakeNodePermissionAccess(
|
||||
nodePermissionOverrides,
|
||||
targetKey,
|
||||
currentPermissionSubjectKeys,
|
||||
);
|
||||
},
|
||||
[currentPermissionSubjectKeys, nodePermissionOverrides],
|
||||
);
|
||||
const activeNodePermissionAccess = getNodePermissionAccessForCategory(
|
||||
activeDocumentWorkspaceCategory,
|
||||
);
|
||||
const isCurrentUserPurchaseOwner = isCurrentUserNonIntegratedPurchaseOwner(
|
||||
currentPermissionProduct,
|
||||
currentPermissionUser,
|
||||
);
|
||||
const isCurrentUserMerchandiser =
|
||||
currentPermissionSubjectKeys.includes("merchandiser");
|
||||
const canPurchaseDownloadActiveWorkspace =
|
||||
isCurrentUserSupplyChainPurchaseDepartment &&
|
||||
(activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY ||
|
||||
activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY);
|
||||
const canPlanningDownloadActiveWorkspace =
|
||||
isIntakeNonIntegratedBrandCenterPlanningUser(currentPermissionUser) &&
|
||||
canPlanningDownloadDocumentWorkspaceCategory(activeDocumentWorkspaceCategory);
|
||||
activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY
|
||||
? isCurrentUserPurchaseOwner || isCurrentUserMerchandiser
|
||||
: activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY
|
||||
? isCurrentUserSupplyChainPurchaseDepartment ||
|
||||
isCurrentUserSupplyChainPricingGroup ||
|
||||
isCurrentUserSupplyChainQualityGroup ||
|
||||
isCurrentUserMerchandiser
|
||||
: activeDocumentWorkspaceCategory === PRODUCT_FACTORY_WORKSPACE_CATEGORY
|
||||
? isCurrentUserPurchaseOwner
|
||||
: true;
|
||||
const canUseDefaultDownloadForActiveWorkspace =
|
||||
activeNodePermissionAccess.defaultDownload &&
|
||||
(!isCurrentUserPurchaseDownloadRestricted ||
|
||||
canPurchaseDownloadActiveWorkspace);
|
||||
const canDownloadWorkspaceFiles =
|
||||
!isCurrentUserPlainReadOnlyViewer &&
|
||||
(!isCurrentUserWorkspaceDownloadRestricted ||
|
||||
canPurchaseDownloadActiveWorkspace ||
|
||||
canPlanningDownloadActiveWorkspace);
|
||||
activeNodePermissionAccess.override.download ||
|
||||
!hasCurrentUserContext ||
|
||||
isCurrentUserFullDetailEditor ||
|
||||
canUseDefaultDownloadForActiveWorkspace;
|
||||
const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback(
|
||||
(category: DocumentWorkspaceCategory) => {
|
||||
if (linkEntryView) {
|
||||
@ -11191,29 +11192,14 @@ export default function NonIntegratedDetailClient({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
isCurrentUserPurchaseDownloadRestricted &&
|
||||
isPurchaseHiddenDocumentWorkspaceCategory(category, {
|
||||
canViewFactoryQuality: isCurrentUserSupplyChainQualityGroup,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
isCurrentUserPlanningContentRestricted &&
|
||||
isPlanningHiddenDocumentWorkspaceCategory(category)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
if (!hasCurrentUserContext || isCurrentUserFullDetailEditor) return true;
|
||||
return getNodePermissionAccessForCategory(category).view;
|
||||
},
|
||||
[
|
||||
isCurrentUserPlanningContentRestricted,
|
||||
getNodePermissionAccessForCategory,
|
||||
hasCurrentUserContext,
|
||||
isCurrentUserFullDetailEditor,
|
||||
isCurrentUserPlainReadOnlyViewer,
|
||||
isCurrentUserPurchaseDownloadRestricted,
|
||||
isCurrentUserSupplyChainQualityGroup,
|
||||
linkEntryView,
|
||||
],
|
||||
);
|
||||
@ -11246,6 +11232,8 @@ export default function NonIntegratedDetailClient({
|
||||
documentWorkspaceView === "detail"
|
||||
? NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY
|
||||
: activeDocumentWorkspaceCategory;
|
||||
const activeWorkspaceNodePermissionAccess =
|
||||
getNodePermissionAccessForCategory(activePermissionWorkspaceCategory);
|
||||
const canEditCurrentProduct =
|
||||
mode === "create"
|
||||
? !hasCurrentUserContext ||
|
||||
@ -11254,9 +11242,10 @@ export default function NonIntegratedDetailClient({
|
||||
canEditNonIntegratedProductIdentity(
|
||||
currentPermissionProduct,
|
||||
currentPermissionUser,
|
||||
);
|
||||
);
|
||||
const canEditActiveWorkspace =
|
||||
!hasCurrentUserContext ||
|
||||
activeWorkspaceNodePermissionAccess.override.edit ||
|
||||
(mode === "create"
|
||||
? canEditCurrentProduct
|
||||
: canEditNonIntegratedWorkspace(
|
||||
@ -13018,7 +13007,10 @@ export default function NonIntegratedDetailClient({
|
||||
const canDownloadProductFactoryContract =
|
||||
!hasCurrentUserContext ||
|
||||
isCurrentUserAdmin ||
|
||||
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser);
|
||||
activeWorkspaceNodePermissionAccess.override.download ||
|
||||
(getNodePermissionAccessForCategory(PRODUCT_FACTORY_WORKSPACE_CATEGORY)
|
||||
.defaultDownload &&
|
||||
isCurrentUserPurchaseOwner);
|
||||
const skuStyleKeys = useMemo(() => {
|
||||
const nextKeys = new Set<string>();
|
||||
|
||||
|
||||
@ -140,7 +140,6 @@ export function getNonIntegratedDocumentWorkspaceCategoryLabel(
|
||||
if (category === "评审资料") return "样品评审";
|
||||
if (category === "企划布局") return "空间布局";
|
||||
if (category === "工厂质检") return "首批质检";
|
||||
if (category === "检测报告") return "仓库抽检";
|
||||
return category;
|
||||
}
|
||||
|
||||
|
||||
@ -8,8 +8,18 @@ export type AccessoryLibraryItem = {
|
||||
letter: string
|
||||
code: string
|
||||
name: string
|
||||
headType?: string
|
||||
drive?: string
|
||||
tail?: string
|
||||
color?: string
|
||||
material?: string
|
||||
finish?: string
|
||||
tooth?: string
|
||||
diameter?: string
|
||||
length?: string
|
||||
usages?: string[]
|
||||
preDrill?: string
|
||||
environments?: string[]
|
||||
price: string
|
||||
imageUrl: string
|
||||
imageName: string
|
||||
@ -73,8 +83,24 @@ export function readAccessoryLibraryItems() {
|
||||
typeof item.letter === 'string' &&
|
||||
typeof item.code === 'string' &&
|
||||
typeof item.name === 'string' &&
|
||||
(item.headType === undefined || typeof item.headType === 'string') &&
|
||||
(item.drive === undefined || typeof item.drive === 'string') &&
|
||||
(item.tail === undefined || typeof item.tail === 'string') &&
|
||||
(item.color === undefined || typeof item.color === 'string') &&
|
||||
(item.material === undefined || typeof item.material === 'string') &&
|
||||
(item.finish === undefined || typeof item.finish === 'string') &&
|
||||
(item.tooth === undefined || typeof item.tooth === 'string') &&
|
||||
(item.diameter === undefined || typeof item.diameter === 'string') &&
|
||||
(item.length === undefined || typeof item.length === 'string') &&
|
||||
(item.usages === undefined ||
|
||||
(Array.isArray(item.usages) &&
|
||||
item.usages.every((value: unknown) => typeof value === 'string'))) &&
|
||||
(item.preDrill === undefined || typeof item.preDrill === 'string') &&
|
||||
(item.environments === undefined ||
|
||||
(Array.isArray(item.environments) &&
|
||||
item.environments.every(
|
||||
(value: unknown) => typeof value === 'string',
|
||||
))) &&
|
||||
typeof item.price === 'string' &&
|
||||
typeof item.imageUrl === 'string' &&
|
||||
typeof item.imageName === 'string' &&
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import {
|
||||
buildIntakeNodePermissionOverrideKey,
|
||||
resolveIntakeNodePermissionAccess,
|
||||
resolveIntakeNodePermissionSubjectKeys,
|
||||
} from './intake-node-permission-settings'
|
||||
|
||||
describe('intake node permission settings', () => {
|
||||
it('resolves DingTalk department paths into the configured permission subjects', () => {
|
||||
expect(
|
||||
resolveIntakeNodePermissionSubjectKeys({
|
||||
role: 'USER',
|
||||
businessRoles: [],
|
||||
departmentPaths: [
|
||||
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
|
||||
],
|
||||
}),
|
||||
).toEqual(['brandCenter', 'space'])
|
||||
|
||||
expect(
|
||||
resolveIntakeNodePermissionSubjectKeys({
|
||||
role: 'USER',
|
||||
businessRoles: [],
|
||||
departmentPaths: [
|
||||
'江苏简尚品牌管理有限公司 > 供应链中心 > 品牌 采购部 > 跟单组',
|
||||
],
|
||||
}),
|
||||
).toEqual(['merchandiser'])
|
||||
})
|
||||
|
||||
it('uses the same matrix for default view, download, and admin-opened overrides', () => {
|
||||
const spaceKeys = ['brandCenter', 'space'] as const
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess({}, 'planningLayout', spaceKeys),
|
||||
).toMatchObject({
|
||||
defaultState: 'edit',
|
||||
defaultView: true,
|
||||
view: true,
|
||||
edit: true,
|
||||
})
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess({}, 'homeDashboard', spaceKeys),
|
||||
).toMatchObject({
|
||||
defaultState: 'hidden',
|
||||
defaultView: false,
|
||||
view: false,
|
||||
edit: false,
|
||||
})
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess({}, 'productData', spaceKeys),
|
||||
).toMatchObject({
|
||||
defaultState: 'hidden',
|
||||
defaultView: false,
|
||||
view: false,
|
||||
download: false,
|
||||
})
|
||||
|
||||
const overrides = {
|
||||
[buildIntakeNodePermissionOverrideKey(
|
||||
'productData',
|
||||
'space',
|
||||
'view',
|
||||
)]: true,
|
||||
[buildIntakeNodePermissionOverrideKey(
|
||||
'productData',
|
||||
'space',
|
||||
'download',
|
||||
)]: true,
|
||||
}
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess(overrides, 'productData', spaceKeys),
|
||||
).toMatchObject({
|
||||
defaultState: 'hidden',
|
||||
view: true,
|
||||
download: true,
|
||||
edit: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
893
furniture-3d-system/src/lib/intake-node-permission-settings.ts
Normal file
893
furniture-3d-system/src/lib/intake-node-permission-settings.ts
Normal file
@ -0,0 +1,893 @@
|
||||
import {
|
||||
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
|
||||
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
|
||||
type NonIntegratedWorkflowNodeCategory,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import type {
|
||||
IntakeNonIntegratedPermissionUser,
|
||||
} from "@/lib/non-integrated-permissions";
|
||||
|
||||
export type IntakeNodePermissionState = "edit" | "readonly" | "hidden";
|
||||
export type IntakeNodePermissionAction = "view" | "download" | "edit";
|
||||
|
||||
export type IntakeNodePermissionSubjectKey =
|
||||
| "design"
|
||||
| "brandCenter"
|
||||
| "planning"
|
||||
| "space"
|
||||
| "purchase"
|
||||
| "pricing"
|
||||
| "quality"
|
||||
| "operation"
|
||||
| "warehouse"
|
||||
| "warehouseQuality"
|
||||
| "merchandiser"
|
||||
| "viewer";
|
||||
|
||||
export type IntakeNodePermissionTargetKey =
|
||||
| "homeDashboard"
|
||||
| "assetCenter"
|
||||
| "assistedAssembly"
|
||||
| "draftGroups"
|
||||
| "formalProducts"
|
||||
| "warehouseManage"
|
||||
| "warehouseQuality"
|
||||
| "openingReference"
|
||||
| "designSellingPoint"
|
||||
| "projectReview"
|
||||
| "productData"
|
||||
| "review"
|
||||
| "basicInfo"
|
||||
| "planningLayout"
|
||||
| "patent"
|
||||
| "photo"
|
||||
| "installation"
|
||||
| "packaging"
|
||||
| "factory"
|
||||
| "measurement"
|
||||
| "finishedSampleShipment"
|
||||
| "pricing"
|
||||
| "limit"
|
||||
| "quality"
|
||||
| "warehouseSampling";
|
||||
|
||||
export type IntakeNodePermissionSubject = {
|
||||
key: IntakeNodePermissionSubjectKey;
|
||||
name: string;
|
||||
department: string;
|
||||
};
|
||||
|
||||
export type IntakeNodePermissionTarget = {
|
||||
key: IntakeNodePermissionTargetKey;
|
||||
label: string;
|
||||
defaults: Record<IntakeNodePermissionSubjectKey, IntakeNodePermissionState>;
|
||||
downloads?: Partial<Record<IntakeNodePermissionSubjectKey, boolean>>;
|
||||
};
|
||||
|
||||
export type IntakeNodePermissionOverrideMap = Record<string, boolean>;
|
||||
|
||||
const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [
|
||||
"design",
|
||||
"brandCenter",
|
||||
"planning",
|
||||
"space",
|
||||
"purchase",
|
||||
"pricing",
|
||||
"quality",
|
||||
"operation",
|
||||
"warehouse",
|
||||
"warehouseQuality",
|
||||
"merchandiser",
|
||||
"viewer",
|
||||
];
|
||||
|
||||
const ALL_TARGET_KEYS: IntakeNodePermissionTargetKey[] = [
|
||||
"homeDashboard",
|
||||
"assetCenter",
|
||||
"assistedAssembly",
|
||||
"draftGroups",
|
||||
"formalProducts",
|
||||
"warehouseManage",
|
||||
"warehouseQuality",
|
||||
"openingReference",
|
||||
"designSellingPoint",
|
||||
"projectReview",
|
||||
"productData",
|
||||
"review",
|
||||
"basicInfo",
|
||||
"planningLayout",
|
||||
"patent",
|
||||
"photo",
|
||||
"installation",
|
||||
"packaging",
|
||||
"factory",
|
||||
"measurement",
|
||||
"finishedSampleShipment",
|
||||
"pricing",
|
||||
"limit",
|
||||
"quality",
|
||||
"warehouseSampling",
|
||||
];
|
||||
|
||||
const ALL_ACTIONS: IntakeNodePermissionAction[] = [
|
||||
"view",
|
||||
"download",
|
||||
"edit",
|
||||
];
|
||||
|
||||
const SUBJECT_KEY_SET = new Set<string>(ALL_SUBJECT_KEYS);
|
||||
const TARGET_KEY_SET = new Set<string>(ALL_TARGET_KEYS);
|
||||
const ACTION_SET = new Set<string>(ALL_ACTIONS);
|
||||
|
||||
const NODE_PERMISSION_HIDDEN: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
design: "hidden",
|
||||
brandCenter: "hidden",
|
||||
planning: "hidden",
|
||||
space: "hidden",
|
||||
purchase: "hidden",
|
||||
pricing: "hidden",
|
||||
quality: "hidden",
|
||||
operation: "hidden",
|
||||
warehouse: "hidden",
|
||||
warehouseQuality: "hidden",
|
||||
merchandiser: "hidden",
|
||||
viewer: "hidden",
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_READONLY: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
design: "readonly",
|
||||
brandCenter: "readonly",
|
||||
planning: "readonly",
|
||||
space: "readonly",
|
||||
purchase: "readonly",
|
||||
pricing: "readonly",
|
||||
quality: "readonly",
|
||||
operation: "readonly",
|
||||
warehouse: "readonly",
|
||||
warehouseQuality: "readonly",
|
||||
merchandiser: "readonly",
|
||||
viewer: "readonly",
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_DESIGN_ENTRY_ONLY: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "edit",
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
...NODE_PERMISSION_READONLY,
|
||||
design: "edit",
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_DESIGN_DOWNLOADS: Partial<
|
||||
Record<IntakeNodePermissionSubjectKey, boolean>
|
||||
> = {
|
||||
design: true,
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_PURCHASE_DOWNLOADS: Partial<
|
||||
Record<IntakeNodePermissionSubjectKey, boolean>
|
||||
> = {
|
||||
purchase: true,
|
||||
merchandiser: true,
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_BRAND_READONLY: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
brandCenter: "readonly",
|
||||
planning: "readonly",
|
||||
space: "readonly",
|
||||
operation: "readonly",
|
||||
viewer: "readonly",
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_PLAIN_VISIBLE: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
...NODE_PERMISSION_READONLY,
|
||||
design: "readonly",
|
||||
purchase: "readonly",
|
||||
pricing: "readonly",
|
||||
quality: "readonly",
|
||||
merchandiser: "readonly",
|
||||
};
|
||||
|
||||
const NODE_PERMISSION_SUPPLY_VISIBLE: Record<
|
||||
IntakeNodePermissionSubjectKey,
|
||||
IntakeNodePermissionState
|
||||
> = {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
purchase: "readonly",
|
||||
pricing: "readonly",
|
||||
quality: "readonly",
|
||||
merchandiser: "readonly",
|
||||
};
|
||||
|
||||
export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [
|
||||
{
|
||||
key: "design",
|
||||
name: "设计师",
|
||||
department: "产品研发中心-外观 / 结构",
|
||||
},
|
||||
{
|
||||
key: "brandCenter",
|
||||
name: "品牌中心",
|
||||
department: "品牌中心-事业部 / 策划 / 企划",
|
||||
},
|
||||
{
|
||||
key: "planning",
|
||||
name: "商品企划",
|
||||
department: "品牌中心-商品企划部",
|
||||
},
|
||||
{
|
||||
key: "space",
|
||||
name: "空间视觉",
|
||||
department: "品牌中心-空间视觉部",
|
||||
},
|
||||
{
|
||||
key: "purchase",
|
||||
name: "采购负责人",
|
||||
department: "供应链中心-品牌采购部",
|
||||
},
|
||||
{
|
||||
key: "pricing",
|
||||
name: "核价组",
|
||||
department: "供应链中心-核价组",
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
name: "质检组",
|
||||
department: "供应链中心-品牌采购部-质检组",
|
||||
},
|
||||
];
|
||||
|
||||
export const ORDINARY_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [
|
||||
{ key: "operation", name: "运营", department: "运营中心" },
|
||||
{ key: "warehouse", name: "仓库管理", department: "供应链中心-仓储" },
|
||||
{
|
||||
key: "warehouseQuality",
|
||||
name: "仓库质检",
|
||||
department: "供应链中心-仓库质检",
|
||||
},
|
||||
{
|
||||
key: "merchandiser",
|
||||
name: "跟单组",
|
||||
department: "供应链中心-品牌采购部-跟单组",
|
||||
},
|
||||
{
|
||||
key: "viewer",
|
||||
name: "其他业务用户",
|
||||
department: "除已配置部门外的普通账号",
|
||||
},
|
||||
];
|
||||
|
||||
export const TOP_ENTRY_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
|
||||
{
|
||||
key: "homeDashboard",
|
||||
label: "首页概览",
|
||||
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
|
||||
},
|
||||
{
|
||||
key: "assetCenter",
|
||||
label: "资产中心",
|
||||
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
|
||||
},
|
||||
{
|
||||
key: "assistedAssembly",
|
||||
label: "辅助装配",
|
||||
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
|
||||
},
|
||||
{
|
||||
key: "draftGroups",
|
||||
label: "草稿组",
|
||||
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
|
||||
},
|
||||
{
|
||||
key: "formalProducts",
|
||||
label: "正式产品",
|
||||
defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
|
||||
},
|
||||
{
|
||||
key: "warehouseManage",
|
||||
label: "仓库管理",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
warehouse: "readonly",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "warehouseQuality",
|
||||
label: "仓库质检",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
warehouseQuality: "readonly",
|
||||
quality: "readonly",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOP_ENTRY_PATH_PREFIXES: Partial<
|
||||
Record<IntakeNodePermissionTargetKey, string[]>
|
||||
> = {
|
||||
homeDashboard: ["/"],
|
||||
assetCenter: ["/asset-center"],
|
||||
assistedAssembly: ["/assisted-assembly"],
|
||||
draftGroups: ["/draft-groups"],
|
||||
formalProducts: ["/intake"],
|
||||
warehouseManage: ["/warehouse"],
|
||||
warehouseQuality: ["/warehouse-quality"],
|
||||
};
|
||||
|
||||
export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
|
||||
{
|
||||
key: "openingReference",
|
||||
label: "开品参考",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
design: "edit",
|
||||
planning: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "designSellingPoint",
|
||||
label: "设计卖点",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
design: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "projectReview",
|
||||
label: "立项评审",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
design: "edit",
|
||||
planning: "readonly",
|
||||
purchase: "readonly",
|
||||
merchandiser: "readonly",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "productData",
|
||||
label: "产品资料",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "edit",
|
||||
purchase: "readonly",
|
||||
pricing: "readonly",
|
||||
quality: "readonly",
|
||||
merchandiser: "readonly",
|
||||
},
|
||||
downloads: {
|
||||
...NODE_PERMISSION_DESIGN_DOWNLOADS,
|
||||
...NODE_PERMISSION_PURCHASE_DOWNLOADS,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "review",
|
||||
label: "样品评审",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "edit",
|
||||
brandCenter: "readonly",
|
||||
planning: "readonly",
|
||||
space: "readonly",
|
||||
purchase: "readonly",
|
||||
pricing: "readonly",
|
||||
quality: "readonly",
|
||||
merchandiser: "readonly",
|
||||
},
|
||||
downloads: {
|
||||
...NODE_PERMISSION_DESIGN_DOWNLOADS,
|
||||
...NODE_PERMISSION_PURCHASE_DOWNLOADS,
|
||||
pricing: true,
|
||||
quality: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "basicInfo",
|
||||
label: "基础信息",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_PLAIN_VISIBLE,
|
||||
design: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "planningLayout",
|
||||
label: "空间布局",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
space: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "patent",
|
||||
label: "专利申请",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "photo",
|
||||
label: "产品实拍",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
design: "edit",
|
||||
operation: "readonly",
|
||||
viewer: "readonly",
|
||||
},
|
||||
downloads: NODE_PERMISSION_DESIGN_DOWNLOADS,
|
||||
},
|
||||
{
|
||||
key: "installation",
|
||||
label: "安装方式",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
design: "edit",
|
||||
operation: "readonly",
|
||||
viewer: "readonly",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "packaging",
|
||||
label: "包装细节",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "edit",
|
||||
},
|
||||
downloads: NODE_PERMISSION_DESIGN_DOWNLOADS,
|
||||
},
|
||||
{
|
||||
key: "factory",
|
||||
label: "产品定厂",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_SUPPLY_VISIBLE,
|
||||
purchase: "edit",
|
||||
},
|
||||
downloads: {
|
||||
purchase: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "measurement",
|
||||
label: "样包测量",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_SUPPLY_VISIBLE,
|
||||
pricing: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "finishedSampleShipment",
|
||||
label: "成品寄样",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_SUPPLY_VISIBLE,
|
||||
purchase: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "pricing",
|
||||
label: "产品核价",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_SUPPLY_VISIBLE,
|
||||
pricing: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "limit",
|
||||
label: "产品限价",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_BRAND_READONLY,
|
||||
planning: "edit",
|
||||
operation: "readonly",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "首批质检",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
quality: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "warehouseSampling",
|
||||
label: "检测报告",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
warehouseQuality: "edit",
|
||||
quality: "readonly",
|
||||
operation: "readonly",
|
||||
viewer: "readonly",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_NODE_PERMISSION_TARGETS = [
|
||||
...TOP_ENTRY_PERMISSION_TARGETS,
|
||||
...PRODUCT_DEVELOPMENT_PERMISSION_TARGETS,
|
||||
] satisfies IntakeNodePermissionTarget[];
|
||||
|
||||
export const NODE_PERMISSION_STATE_LABEL: Record<
|
||||
IntakeNodePermissionState,
|
||||
string
|
||||
> = {
|
||||
edit: "可操作",
|
||||
readonly: "只读不可编辑",
|
||||
hidden: "隐藏",
|
||||
};
|
||||
|
||||
const DOCUMENT_CATEGORY_TARGET_KEY_MAP: Partial<
|
||||
Record<string, IntakeNodePermissionTargetKey>
|
||||
> = {
|
||||
开品参考: "openingReference",
|
||||
设计卖点: "designSellingPoint",
|
||||
立项评审: "projectReview",
|
||||
产品模型: "productData",
|
||||
产品资料: "productData",
|
||||
评审资料: "review",
|
||||
样品评审: "review",
|
||||
[NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: "basicInfo",
|
||||
[NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY]: "planningLayout",
|
||||
空间布局: "planningLayout",
|
||||
专利申请: "patent",
|
||||
产品实拍图: "photo",
|
||||
产品实拍: "photo",
|
||||
安装方式: "installation",
|
||||
包装细节: "packaging",
|
||||
产品定厂: "factory",
|
||||
样包测量: "measurement",
|
||||
成品寄样: "finishedSampleShipment",
|
||||
产品核价: "pricing",
|
||||
产品限价: "limit",
|
||||
工厂质检: "quality",
|
||||
首批质检: "quality",
|
||||
检测报告: "warehouseSampling",
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeDepartmentPath(value: unknown) {
|
||||
return normalizeText(value).replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
function getDepartmentPathValues(user: IntakeNonIntegratedPermissionUser) {
|
||||
return [
|
||||
user.department?.fullPath,
|
||||
user.department?.name,
|
||||
...(user.departmentPaths ?? []),
|
||||
]
|
||||
.map(normalizeDepartmentPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasDepartmentPath(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
predicate: (path: string) => boolean,
|
||||
) {
|
||||
return getDepartmentPathValues(user).some(predicate);
|
||||
}
|
||||
|
||||
function hasDepartmentScope(user: IntakeNonIntegratedPermissionUser) {
|
||||
return getDepartmentPathValues(user).length > 0;
|
||||
}
|
||||
|
||||
function hasBusinessRoleFallback(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
role: string,
|
||||
) {
|
||||
return !hasDepartmentScope(user) && Boolean(user.businessRoles?.includes(role));
|
||||
}
|
||||
|
||||
function isProductResearchDepartmentPath(path: string) {
|
||||
return /产品研发部/.test(path);
|
||||
}
|
||||
|
||||
function isBrandPlanningDepartmentPath(path: string) {
|
||||
return /品牌中心/.test(path) && /商品企划部/.test(path);
|
||||
}
|
||||
|
||||
function isSpaceVisualDepartmentPath(path: string) {
|
||||
return /品牌中心/.test(path) && /空间视觉部/.test(path);
|
||||
}
|
||||
|
||||
function isBrandCenterDepartmentPath(path: string) {
|
||||
return /品牌中心/.test(path);
|
||||
}
|
||||
|
||||
function isPricingGroupDepartmentPath(path: string) {
|
||||
return /供应链中心/.test(path) && /核价组/.test(path);
|
||||
}
|
||||
|
||||
function isQualityGroupDepartmentPath(path: string) {
|
||||
return /供应链中心/.test(path) && /质检组/.test(path);
|
||||
}
|
||||
|
||||
function isMerchandiserDepartmentPath(path: string) {
|
||||
return /供应链中心/.test(path) && /跟单组/.test(path);
|
||||
}
|
||||
|
||||
function isSupplyChainPurchaseDepartmentPath(path: string) {
|
||||
return (
|
||||
/供应链中心/.test(path) &&
|
||||
/采购部/.test(path) &&
|
||||
!/核价组|质检组|跟单组/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isOperationDepartmentPath(path: string) {
|
||||
return /运营/.test(path);
|
||||
}
|
||||
|
||||
function isWarehouseQualityDepartmentPath(path: string) {
|
||||
return /仓库质检|仓储质检|质检/.test(path) && /仓|仓储/.test(path);
|
||||
}
|
||||
|
||||
function isWarehouseDepartmentPath(path: string) {
|
||||
return /仓库|仓储/.test(path);
|
||||
}
|
||||
|
||||
function pushUniqueSubject(
|
||||
keys: IntakeNodePermissionSubjectKey[],
|
||||
key: IntakeNodePermissionSubjectKey,
|
||||
) {
|
||||
if (!keys.includes(key)) keys.push(key);
|
||||
}
|
||||
|
||||
export function getIntakeNodePermissionTargetByKey(
|
||||
key: IntakeNodePermissionTargetKey | string,
|
||||
) {
|
||||
return ALL_NODE_PERMISSION_TARGETS.find((target) => target.key === key) ?? null;
|
||||
}
|
||||
|
||||
export function getIntakeNodePermissionTargetKeyByWorkspaceCategory(
|
||||
category: NonIntegratedWorkflowNodeCategory | string | null | undefined,
|
||||
) {
|
||||
const normalizedCategory = normalizeText(category);
|
||||
if (!normalizedCategory) return null;
|
||||
return DOCUMENT_CATEGORY_TARGET_KEY_MAP[normalizedCategory] ?? null;
|
||||
}
|
||||
|
||||
export function buildIntakeNodePermissionOverrideKey(
|
||||
targetKey: IntakeNodePermissionTargetKey,
|
||||
subjectKey: IntakeNodePermissionSubjectKey,
|
||||
action: IntakeNodePermissionAction,
|
||||
) {
|
||||
return `${targetKey}:${subjectKey}:${action}`;
|
||||
}
|
||||
|
||||
export function parseIntakeNodePermissionOverrideKey(key: string) {
|
||||
const [targetKey, subjectKey, action, ...rest] = key.split(":");
|
||||
if (rest.length > 0) return null;
|
||||
if (!targetKey || !TARGET_KEY_SET.has(targetKey)) return null;
|
||||
if (!subjectKey || !SUBJECT_KEY_SET.has(subjectKey)) return null;
|
||||
if (!action || !ACTION_SET.has(action)) return null;
|
||||
|
||||
return {
|
||||
targetKey: targetKey as IntakeNodePermissionTargetKey,
|
||||
subjectKey: subjectKey as IntakeNodePermissionSubjectKey,
|
||||
action: action as IntakeNodePermissionAction,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeIntakeNodePermissionOverrides(
|
||||
value: unknown,
|
||||
): IntakeNodePermissionOverrideMap {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextOverrides: IntakeNodePermissionOverrideMap = {};
|
||||
Object.entries(value as Record<string, unknown>).forEach(([key, enabled]) => {
|
||||
if (enabled !== true) return;
|
||||
if (!parseIntakeNodePermissionOverrideKey(key)) return;
|
||||
nextOverrides[key] = true;
|
||||
});
|
||||
|
||||
return nextOverrides;
|
||||
}
|
||||
|
||||
export function resolveIntakeNodePermissionSubjectKeys(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
) {
|
||||
const keys: IntakeNodePermissionSubjectKey[] = [];
|
||||
|
||||
if (hasDepartmentPath(user, isProductResearchDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "design");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isBrandCenterDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "brandCenter");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isBrandPlanningDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "planning");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isSpaceVisualDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "space");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isPricingGroupDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "pricing");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isQualityGroupDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "quality");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isMerchandiserDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "merchandiser");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isSupplyChainPurchaseDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "purchase");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isWarehouseQualityDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "warehouseQuality");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isWarehouseDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "warehouse");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isOperationDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "operation");
|
||||
}
|
||||
|
||||
if (hasBusinessRoleFallback(user, "DESIGN")) {
|
||||
pushUniqueSubject(keys, "design");
|
||||
}
|
||||
|
||||
if (hasBusinessRoleFallback(user, "PLANNING")) {
|
||||
pushUniqueSubject(keys, "planning");
|
||||
}
|
||||
|
||||
if (hasBusinessRoleFallback(user, "PURCHASING")) {
|
||||
pushUniqueSubject(keys, "purchase");
|
||||
}
|
||||
|
||||
if (hasBusinessRoleFallback(user, "WAREHOUSE")) {
|
||||
pushUniqueSubject(keys, "warehouse");
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
keys.push("viewer");
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getIntakeNodePermissionOverrideState(
|
||||
overrides: IntakeNodePermissionOverrideMap,
|
||||
targetKey: IntakeNodePermissionTargetKey | string | null | undefined,
|
||||
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
|
||||
) {
|
||||
if (!targetKey || !TARGET_KEY_SET.has(targetKey)) {
|
||||
return { view: false, download: false, edit: false };
|
||||
}
|
||||
|
||||
const resolvedTargetKey = targetKey as IntakeNodePermissionTargetKey;
|
||||
const hasAction = (action: IntakeNodePermissionAction) =>
|
||||
subjectKeys.some((subjectKey) =>
|
||||
Boolean(
|
||||
overrides[
|
||||
buildIntakeNodePermissionOverrideKey(
|
||||
resolvedTargetKey,
|
||||
subjectKey,
|
||||
action,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const edit = hasAction("edit");
|
||||
const download = hasAction("download");
|
||||
const view = edit || download || hasAction("view");
|
||||
|
||||
return { view, download, edit };
|
||||
}
|
||||
|
||||
export function resolveIntakeNodePermissionAccess(
|
||||
overrides: IntakeNodePermissionOverrideMap,
|
||||
targetKey: IntakeNodePermissionTargetKey | string | null | undefined,
|
||||
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
|
||||
) {
|
||||
const target =
|
||||
targetKey && TARGET_KEY_SET.has(targetKey)
|
||||
? getIntakeNodePermissionTargetByKey(targetKey)
|
||||
: null;
|
||||
const overrideState = getIntakeNodePermissionOverrideState(
|
||||
overrides,
|
||||
targetKey,
|
||||
subjectKeys,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return {
|
||||
defaultState: "hidden" as IntakeNodePermissionState,
|
||||
defaultView: false,
|
||||
defaultDownload: false,
|
||||
view: overrideState.view,
|
||||
download: overrideState.download,
|
||||
edit: overrideState.edit,
|
||||
override: overrideState,
|
||||
};
|
||||
}
|
||||
|
||||
const hasDefaultEdit = subjectKeys.some(
|
||||
(subjectKey) => target.defaults[subjectKey] === "edit",
|
||||
);
|
||||
const hasDefaultReadonly = subjectKeys.some(
|
||||
(subjectKey) => target.defaults[subjectKey] === "readonly",
|
||||
);
|
||||
const defaultState: IntakeNodePermissionState = hasDefaultEdit
|
||||
? "edit"
|
||||
: hasDefaultReadonly
|
||||
? "readonly"
|
||||
: "hidden";
|
||||
const defaultView = defaultState !== "hidden";
|
||||
const defaultDownload = subjectKeys.some((subjectKey) =>
|
||||
Boolean(target.downloads?.[subjectKey]),
|
||||
);
|
||||
|
||||
return {
|
||||
defaultState,
|
||||
defaultView,
|
||||
defaultDownload,
|
||||
view: defaultView || overrideState.view,
|
||||
download: defaultDownload || overrideState.download,
|
||||
edit: defaultState === "edit" || overrideState.edit,
|
||||
override: overrideState,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIntakeTopEntryAllowedPathPrefixes(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
overrides: IntakeNodePermissionOverrideMap,
|
||||
) {
|
||||
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user);
|
||||
const prefixes = new Set<string>();
|
||||
|
||||
TOP_ENTRY_PERMISSION_TARGETS.forEach((target) => {
|
||||
const access = resolveIntakeNodePermissionAccess(
|
||||
overrides,
|
||||
target.key,
|
||||
subjectKeys,
|
||||
);
|
||||
if (!access.view && !access.download && !access.edit) {
|
||||
return;
|
||||
}
|
||||
|
||||
TOP_ENTRY_PATH_PREFIXES[target.key]?.forEach((prefix) => {
|
||||
prefixes.add(prefix);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(prefixes);
|
||||
}
|
||||
@ -41,6 +41,7 @@ export interface TokenPayload {
|
||||
departmentId?: string;
|
||||
teamId?: string;
|
||||
workspaceAccess?: WorkspaceAccessLevel;
|
||||
workspaceAllowedPathPrefixes?: string[];
|
||||
exp?: number; // JWT 过期时间(Unix 时间戳)
|
||||
iat?: number; // JWT 签发时间(Unix 时间戳)
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ describe('non-integrated intake permissions', () => {
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '开品参考')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '产品限价')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, otherPlanner, '开品参考')).toBe(false)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, brandCampaignUser, '成品寄样')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, brandCampaignUser, '成品寄样')).toBe(false)
|
||||
expect(canEditAnyNonIntegratedWorkspace(baseProduct, brandCampaignUser)).toBe(false)
|
||||
})
|
||||
|
||||
@ -93,7 +93,7 @@ describe('non-integrated intake permissions', () => {
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, user, '产品限价')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows planning business users to maintain planning layout even when they are not assigned as space owner', () => {
|
||||
it('does not let unrelated brand planning users maintain planning layout', () => {
|
||||
const user = {
|
||||
id: 'planning-id',
|
||||
name: '希文',
|
||||
@ -102,8 +102,8 @@ describe('non-integrated intake permissions', () => {
|
||||
departmentPaths: ['江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部'],
|
||||
}
|
||||
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, user, '企划布局')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, user, '企划布局')).toBe(false)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(false)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, user, '样包测量')).toBe(false)
|
||||
})
|
||||
|
||||
@ -139,6 +139,9 @@ describe('non-integrated intake permissions', () => {
|
||||
}
|
||||
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '产品定厂')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '样包测量')).toBe(false)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '产品核价')).toBe(false)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '成品寄样')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, otherPurchaser, '产品定厂')).toBe(false)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, pricingUser, '样包测量')).toBe(true)
|
||||
expect(canEditNonIntegratedWorkspace(baseProduct, pricingUser, '产品核价')).toBe(true)
|
||||
|
||||
@ -83,14 +83,10 @@ const SPACE_OWNER_WORKSPACE_CATEGORIES = new Set<string>([
|
||||
|
||||
const PURCHASE_OWNER_WORKSPACE_CATEGORIES = new Set<string>([
|
||||
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
|
||||
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
|
||||
PRODUCT_PRICING_WORKSPACE_CATEGORY,
|
||||
])
|
||||
|
||||
const OPEN_LINK_EDIT_WORKSPACE_CATEGORIES = new Set<string>([
|
||||
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
|
||||
])
|
||||
const OPEN_LINK_EDIT_WORKSPACE_CATEGORIES = new Set<string>()
|
||||
|
||||
const PRICING_GROUP_WORKSPACE_CATEGORIES = new Set<string>([
|
||||
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
|
||||
@ -354,15 +350,6 @@ export function isCurrentUserNonIntegratedSpaceOwner(
|
||||
)
|
||||
}
|
||||
|
||||
export function isIntakeNonIntegratedPlanningLayoutEditor(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
) {
|
||||
return (
|
||||
hasDepartmentPath(user, isSpaceVisualDepartmentPath) ||
|
||||
hasBusinessRoleFallback(user, 'PLANNING')
|
||||
)
|
||||
}
|
||||
|
||||
export function isCurrentUserNonIntegratedPurchaseOwner(
|
||||
product: IntakeNonIntegratedPermissionProduct,
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
@ -403,8 +390,7 @@ export function canEditNonIntegratedWorkspace(
|
||||
|
||||
if (
|
||||
SPACE_OWNER_WORKSPACE_CATEGORIES.has(normalizedCategory) &&
|
||||
(isCurrentUserNonIntegratedSpaceOwner(product, user) ||
|
||||
isIntakeNonIntegratedPlanningLayoutEditor(user))
|
||||
isCurrentUserNonIntegratedSpaceOwner(product, user)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -4,38 +4,45 @@ import {
|
||||
hasFullWorkspaceAccess,
|
||||
} from './workspace-route-permissions'
|
||||
|
||||
describe('workspace-route-permissions', () => {
|
||||
it('管理员拥有完整工作台权限', () => {
|
||||
describe('workspace route permissions', () => {
|
||||
it('keeps admins as full workspace users', () => {
|
||||
expect(hasFullWorkspaceAccess({ role: 'ADMIN', businessRoles: [] })).toBe(true)
|
||||
expect(getWorkspaceAccessLevel({ role: 'SUPER_ADMIN', businessRoles: [] })).toBe('full')
|
||||
})
|
||||
|
||||
it('产品研发设计拥有完整工作台权限', () => {
|
||||
const user = {
|
||||
it('keeps space visual users in formal products only even if their business role is design', () => {
|
||||
const spaceVisualUser = {
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentPaths: ['江苏简尚品牌管理有限公司 > 产品研发部 > 基本盘 > 桌类组'],
|
||||
departmentPaths: [
|
||||
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
|
||||
],
|
||||
}
|
||||
|
||||
expect(hasFullWorkspaceAccess(user)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/asset-center', user)).toBe(true)
|
||||
expect(hasFullWorkspaceAccess(spaceVisualUser)).toBe(false)
|
||||
expect(getWorkspaceAccessLevel(spaceVisualUser)).toBe('formal-products-only')
|
||||
expect(canAccessWorkspacePath('/', spaceVisualUser)).toBe(false)
|
||||
expect(canAccessWorkspacePath('/asset-center', spaceVisualUser)).toBe(false)
|
||||
expect(canAccessWorkspacePath('/assisted-assembly', spaceVisualUser)).toBe(false)
|
||||
expect(canAccessWorkspacePath('/intake', spaceVisualUser)).toBe(true)
|
||||
})
|
||||
|
||||
it('空间视觉设计只保留正式产品工作区入口', () => {
|
||||
const user = {
|
||||
it('keeps product research designers as full workspace users', () => {
|
||||
const productDesigner = {
|
||||
role: 'USER',
|
||||
businessRoles: ['DESIGN'],
|
||||
departmentPaths: ['江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部'],
|
||||
departmentPaths: [
|
||||
'江苏简尚品牌管理有限公司 > 产品研发部 > 外观设计组',
|
||||
],
|
||||
}
|
||||
|
||||
expect(hasFullWorkspaceAccess(user)).toBe(false)
|
||||
expect(getWorkspaceAccessLevel(user)).toBe('formal-products-only')
|
||||
expect(canAccessWorkspacePath('/intake', user)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/asset-center', user)).toBe(false)
|
||||
expect(canAccessWorkspacePath('/assisted-assembly', user)).toBe(false)
|
||||
expect(hasFullWorkspaceAccess(productDesigner)).toBe(true)
|
||||
expect(getWorkspaceAccessLevel(productDesigner)).toBe('full')
|
||||
expect(canAccessWorkspacePath('/', productDesigner)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/asset-center', productDesigner)).toBe(true)
|
||||
})
|
||||
|
||||
it('供应链和普通用户不能进入正式产品以外的工作台', () => {
|
||||
it('keeps supply chain users outside non-formal workspaces unless explicitly opened', () => {
|
||||
const purchasingUser = {
|
||||
role: 'USER',
|
||||
businessRoles: ['PURCHASING'],
|
||||
@ -46,4 +53,16 @@ describe('workspace-route-permissions', () => {
|
||||
expect(canAccessWorkspacePath('/intake/non-integrated/product-1', purchasingUser)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/warehouse', purchasingUser)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows admin-opened workspace prefixes for restricted users', () => {
|
||||
const purchasingUser = {
|
||||
role: 'USER',
|
||||
businessRoles: ['PURCHASING'],
|
||||
departmentPaths: ['江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部'],
|
||||
workspaceAllowedPathPrefixes: ['/warehouse'],
|
||||
}
|
||||
|
||||
expect(canAccessWorkspacePath('/warehouse', purchasingUser)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/warehouse/receiving', purchasingUser)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,6 +9,7 @@ export interface WorkspacePermissionUser {
|
||||
businessRoles?: readonly string[] | null
|
||||
department?: { name?: string | null; fullPath?: string | null } | null
|
||||
departmentPaths?: readonly string[] | null
|
||||
workspaceAllowedPathPrefixes?: readonly string[] | null
|
||||
}
|
||||
|
||||
const RESTRICTED_ALLOWED_PATH_PREFIXES = [
|
||||
@ -34,19 +35,37 @@ export function isRestrictedWorkspacePathAllowed(pathname: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function isPathAllowedByPrefixes(
|
||||
pathname: string,
|
||||
prefixes: readonly string[] | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
prefixes?.some((prefix) =>
|
||||
prefix === '/'
|
||||
? pathname === '/'
|
||||
: pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function canAccessWorkspacePath(
|
||||
pathname: string,
|
||||
user: WorkspacePermissionUser | null | undefined,
|
||||
) {
|
||||
if (getWorkspaceAccessLevel(user) === 'full') return true
|
||||
if (isPathAllowedByPrefixes(pathname, user?.workspaceAllowedPathPrefixes)) {
|
||||
return true
|
||||
}
|
||||
return isRestrictedWorkspacePathAllowed(pathname)
|
||||
}
|
||||
|
||||
export function canAccessWorkspacePathByAccessLevel(
|
||||
pathname: string,
|
||||
accessLevel: WorkspaceAccessLevel | null | undefined,
|
||||
allowedPathPrefixes?: readonly string[] | null,
|
||||
) {
|
||||
if (!accessLevel || accessLevel === 'full') return true
|
||||
if (isPathAllowedByPrefixes(pathname, allowedPathPrefixes)) return true
|
||||
return isRestrictedWorkspacePathAllowed(pathname)
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ export function isPublicPath(pathname: string): boolean {
|
||||
interface WorkspaceTokenPayload {
|
||||
exp?: number
|
||||
workspaceAccess?: WorkspaceAccessLevel
|
||||
workspaceAllowedPathPrefixes?: string[]
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
@ -69,7 +70,13 @@ export async function proxy(request: NextRequest) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (!canAccessWorkspacePathByAccessLevel(pathname, payload.workspaceAccess)) {
|
||||
if (
|
||||
!canAccessWorkspacePathByAccessLevel(
|
||||
pathname,
|
||||
payload.workspaceAccess,
|
||||
payload.workspaceAllowedPathPrefixes,
|
||||
)
|
||||
) {
|
||||
return NextResponse.redirect(new URL(FORMAL_PRODUCTS_HOME_PATH, request.url))
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,8 @@ import {
|
||||
getWorkspaceAccessLevel,
|
||||
type WorkspaceAccessLevel,
|
||||
} from '@/lib/workspace-route-permissions'
|
||||
import { resolveIntakeTopEntryAllowedPathPrefixes } from '@/lib/intake-node-permission-settings'
|
||||
import { intakeNodePermissionSettingsService } from './intake-node-permission-settings.service'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
@ -51,6 +53,8 @@ export interface FirstLoginUpdateResult {
|
||||
departmentId: string | null
|
||||
teamId: string | null
|
||||
isFirstLogin: false
|
||||
workspaceAccess?: WorkspaceAccessLevel
|
||||
workspaceAllowedPathPrefixes?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,6 +88,8 @@ export interface AuthUserDTO {
|
||||
managedTeam: { id: string; name: string } | null
|
||||
avatar: string | null
|
||||
lastLoginAt: Date | null
|
||||
workspaceAccess?: WorkspaceAccessLevel
|
||||
workspaceAllowedPathPrefixes?: string[]
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
@ -96,6 +102,7 @@ export interface TokenPayload {
|
||||
departmentId?: string
|
||||
teamId?: string
|
||||
workspaceAccess?: WorkspaceAccessLevel
|
||||
workspaceAllowedPathPrefixes?: string[]
|
||||
}
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
@ -107,6 +114,29 @@ function isEmail(identifier: string): boolean {
|
||||
return EMAIL_REGEX.test(identifier)
|
||||
}
|
||||
|
||||
type WorkspaceTokenPermissionUser = {
|
||||
role?: string | null
|
||||
businessRoles?: readonly string[] | null
|
||||
department?: { name?: string | null; fullPath?: string | null } | null
|
||||
departmentPaths?: readonly string[] | null
|
||||
}
|
||||
|
||||
async function buildWorkspaceTokenPermissions(user: WorkspaceTokenPermissionUser) {
|
||||
const workspaceAccess = getWorkspaceAccessLevel(user)
|
||||
if (workspaceAccess === 'full') {
|
||||
return { workspaceAccess }
|
||||
}
|
||||
|
||||
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
|
||||
return {
|
||||
workspaceAccess,
|
||||
workspaceAllowedPathPrefixes: resolveIntakeTopEntryAllowedPathPrefixes(
|
||||
user,
|
||||
overrides,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 服务类 ============
|
||||
|
||||
class AuthService {
|
||||
@ -234,6 +264,11 @@ class AuthService {
|
||||
})
|
||||
const departmentPaths = await getUserDepartmentPathsById(updatedUser.id)
|
||||
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
role: updatedUser.role,
|
||||
businessRoles: updatedUser.businessRoles,
|
||||
departmentPaths,
|
||||
})
|
||||
const token = generateToken({
|
||||
userId: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
@ -243,11 +278,7 @@ class AuthService {
|
||||
isFirstLogin: false,
|
||||
departmentId: updatedUser.departmentId || undefined,
|
||||
teamId: updatedUser.teamId || undefined,
|
||||
workspaceAccess: getWorkspaceAccessLevel({
|
||||
role: updatedUser.role,
|
||||
businessRoles: updatedUser.businessRoles,
|
||||
departmentPaths,
|
||||
}),
|
||||
...workspacePermissions,
|
||||
})
|
||||
|
||||
return {
|
||||
@ -262,6 +293,7 @@ class AuthService {
|
||||
departmentId: updatedUser.departmentId,
|
||||
teamId: updatedUser.teamId,
|
||||
isFirstLogin: false,
|
||||
...workspacePermissions,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -312,6 +344,12 @@ class AuthService {
|
||||
}
|
||||
|
||||
const departmentPaths = await getUserDepartmentPathsById(user.id)
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
department: user.department,
|
||||
departmentPaths,
|
||||
})
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
@ -333,6 +371,7 @@ class AuthService {
|
||||
managedTeam: user.managedTeam,
|
||||
avatar: user.avatar,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
...workspacePermissions,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -368,6 +407,11 @@ class AuthService {
|
||||
}
|
||||
|
||||
const departmentPaths = await getUserDepartmentPathsById(user.id)
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
departmentPaths,
|
||||
})
|
||||
|
||||
return generateToken({
|
||||
userId: user.id,
|
||||
@ -378,11 +422,7 @@ class AuthService {
|
||||
isFirstLogin: user.isFirstLogin,
|
||||
departmentId: user.departmentId || undefined,
|
||||
teamId: user.teamId || undefined,
|
||||
workspaceAccess: getWorkspaceAccessLevel({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
departmentPaths,
|
||||
}),
|
||||
...workspacePermissions,
|
||||
})
|
||||
}
|
||||
|
||||
@ -419,6 +459,12 @@ class AuthService {
|
||||
|
||||
private async toLoginResult(user: AuthUserWithRelations): Promise<LoginResult> {
|
||||
const departmentPaths = await getUserDepartmentPathsById(user.id)
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
department: user.department,
|
||||
departmentPaths,
|
||||
})
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
@ -428,12 +474,7 @@ class AuthService {
|
||||
isFirstLogin: user.isFirstLogin,
|
||||
departmentId: user.departmentId || undefined,
|
||||
teamId: user.teamId || undefined,
|
||||
workspaceAccess: getWorkspaceAccessLevel({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
department: user.department,
|
||||
departmentPaths,
|
||||
}),
|
||||
...workspacePermissions,
|
||||
})
|
||||
|
||||
return {
|
||||
@ -456,6 +497,7 @@ class AuthService {
|
||||
managedTeam: user.managedTeam,
|
||||
avatar: user.avatar,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
...workspacePermissions,
|
||||
},
|
||||
isFirstLogin: user.isFirstLogin,
|
||||
}
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
parseIntakeNodePermissionOverrideKey,
|
||||
sanitizeIntakeNodePermissionOverrides,
|
||||
type IntakeNodePermissionOverrideMap,
|
||||
} from "@/lib/intake-node-permission-settings";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
import type { AuthUser } from "@/server/core/types";
|
||||
|
||||
type IntakeNodePermissionOverrideRow = {
|
||||
targetKey: string;
|
||||
subjectKey: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
const ADMIN_ROLES = new Set(["ADMIN", "SUPER_ADMIN"]);
|
||||
const INTAKE_NODE_PERMISSION_SETTINGS_LOCK_KEY = 2026052311;
|
||||
|
||||
function assertCanManageSettings(user: AuthUser) {
|
||||
if (ADMIN_ROLES.has(user.role)) return;
|
||||
throw new AppError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
"只有管理员可以维护正式产品节点权限",
|
||||
);
|
||||
}
|
||||
|
||||
function buildOverrideKey(row: IntakeNodePermissionOverrideRow) {
|
||||
return `${row.targetKey}:${row.subjectKey}:${row.action}`;
|
||||
}
|
||||
|
||||
class IntakeNodePermissionSettingsService {
|
||||
private ensureTablePromise: Promise<void> | null = null;
|
||||
|
||||
private async ensureTable() {
|
||||
if (!this.ensureTablePromise) {
|
||||
this.ensureTablePromise = (async () => {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "IntakeNodePermissionOverride" (
|
||||
"id" TEXT NOT NULL,
|
||||
"targetKey" TEXT NOT NULL,
|
||||
"subjectKey" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdBy" TEXT,
|
||||
"updatedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "IntakeNodePermissionOverride_pkey" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_subjectKey_action_key"
|
||||
ON "IntakeNodePermissionOverride"("targetKey", "subjectKey", "action")
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_idx"
|
||||
ON "IntakeNodePermissionOverride"("targetKey")
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_subjectKey_idx"
|
||||
ON "IntakeNodePermissionOverride"("subjectKey")
|
||||
`);
|
||||
})();
|
||||
}
|
||||
|
||||
await this.ensureTablePromise;
|
||||
}
|
||||
|
||||
async getSettings() {
|
||||
await this.ensureTable();
|
||||
|
||||
const rows = await prisma.$queryRaw<IntakeNodePermissionOverrideRow[]>`
|
||||
SELECT "targetKey", "subjectKey", "action"
|
||||
FROM "IntakeNodePermissionOverride"
|
||||
WHERE "enabled" = true
|
||||
ORDER BY "targetKey" ASC, "subjectKey" ASC, "action" ASC
|
||||
`;
|
||||
const overrides: IntakeNodePermissionOverrideMap = {};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const key = buildOverrideKey(row);
|
||||
if (parseIntakeNodePermissionOverrideKey(key)) {
|
||||
overrides[key] = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { overrides };
|
||||
}
|
||||
|
||||
async updateSettings(input: unknown, user: AuthUser) {
|
||||
assertCanManageSettings(user);
|
||||
await this.ensureTable();
|
||||
|
||||
const inputRecord =
|
||||
input && typeof input === "object" && !Array.isArray(input)
|
||||
? (input as { overrides?: unknown })
|
||||
: {};
|
||||
const overrides = sanitizeIntakeNodePermissionOverrides(
|
||||
inputRecord.overrides,
|
||||
);
|
||||
const entries = Array.from(
|
||||
new Map(
|
||||
Object.keys(overrides)
|
||||
.map(parseIntakeNodePermissionOverrideKey)
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is NonNullable<
|
||||
ReturnType<typeof parseIntakeNodePermissionOverrideKey>
|
||||
> => Boolean(entry),
|
||||
)
|
||||
.map((entry) => [
|
||||
`${entry.targetKey}:${entry.subjectKey}:${entry.action}`,
|
||||
entry,
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
SELECT pg_advisory_xact_lock(${INTAKE_NODE_PERMISSION_SETTINGS_LOCK_KEY})
|
||||
`;
|
||||
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "IntakeNodePermissionOverride"
|
||||
`;
|
||||
|
||||
for (const entry of entries) {
|
||||
const id = randomUUID();
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "IntakeNodePermissionOverride" (
|
||||
"id",
|
||||
"targetKey",
|
||||
"subjectKey",
|
||||
"action",
|
||||
"enabled",
|
||||
"createdBy",
|
||||
"updatedBy",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
${id},
|
||||
${entry.targetKey},
|
||||
${entry.subjectKey},
|
||||
${entry.action},
|
||||
true,
|
||||
${user.id},
|
||||
${user.id},
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT ("targetKey", "subjectKey", "action")
|
||||
DO UPDATE SET
|
||||
"enabled" = true,
|
||||
"updatedBy" = ${user.id},
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
return { overrides };
|
||||
}
|
||||
}
|
||||
|
||||
export const intakeNodePermissionSettingsService =
|
||||
new IntakeNodePermissionSettingsService();
|
||||
@ -590,7 +590,7 @@ function getChangedWorkspaceCategories(
|
||||
return categories
|
||||
}
|
||||
|
||||
function assertCanWriteProductChanges(
|
||||
async function assertCanWriteProductChanges(
|
||||
record: IntakeNonIntegratedRecord,
|
||||
user: AuthUser,
|
||||
nextProduct: SharedNonIntegratedProductRecord,
|
||||
@ -603,20 +603,20 @@ function assertCanWriteProductChanges(
|
||||
})
|
||||
|
||||
if (!existingProduct) {
|
||||
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
|
||||
return
|
||||
}
|
||||
|
||||
const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProduct)
|
||||
|
||||
if (changedCategories.size === 0) {
|
||||
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
|
||||
return
|
||||
}
|
||||
|
||||
changedCategories.forEach((category) => {
|
||||
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category)
|
||||
})
|
||||
for (const category of changedCategories) {
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePresentationPreview(
|
||||
@ -1541,7 +1541,7 @@ class IntakeNonIntegratedProductService {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
|
||||
}
|
||||
|
||||
intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, category)
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, category)
|
||||
}
|
||||
|
||||
async save(productInput: unknown, user: AuthUser) {
|
||||
@ -1608,7 +1608,7 @@ class IntakeNonIntegratedProductService {
|
||||
|
||||
try {
|
||||
if (existingRecord) {
|
||||
assertCanWriteProductChanges(existingRecord, user, persistedProduct)
|
||||
await assertCanWriteProductChanges(existingRecord, user, persistedProduct)
|
||||
|
||||
await this.repository.update(existingRecord.id, {
|
||||
spu: normalizedSpu,
|
||||
@ -1703,7 +1703,7 @@ class IntakeNonIntegratedProductService {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
|
||||
}
|
||||
|
||||
intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, input.category)
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, input.category)
|
||||
|
||||
const payload = buildHydratedProductRecord(existingRecord.payload, {
|
||||
createdAt: existingRecord.createdAt,
|
||||
@ -1824,7 +1824,7 @@ class IntakeNonIntegratedProductService {
|
||||
user: AuthUser,
|
||||
) {
|
||||
const { record, product, file } = await this.resolveUploadFileForPreview(productId, input, user)
|
||||
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, file.category)
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, file.category)
|
||||
|
||||
const nextUploadFiles = product.uploadFiles.map((file) =>
|
||||
(input.fileId ? file.id === input.fileId : file.dataUrl === input.fileUrl)
|
||||
|
||||
@ -8,6 +8,12 @@ import {
|
||||
canEditNonIntegratedWorkspace,
|
||||
type IntakeNonIntegratedPermissionProduct,
|
||||
} from '@/lib/non-integrated-permissions'
|
||||
import {
|
||||
getIntakeNodePermissionOverrideState,
|
||||
getIntakeNodePermissionTargetKeyByWorkspaceCategory,
|
||||
resolveIntakeNodePermissionSubjectKeys,
|
||||
} from '@/lib/intake-node-permission-settings'
|
||||
import { intakeNodePermissionSettingsService } from './intake-node-permission-settings.service'
|
||||
|
||||
type IntakeNonIntegratedPermissionTarget = Pick<
|
||||
IntakeNonIntegratedProduct,
|
||||
@ -156,12 +162,37 @@ class IntakeNonIntegratedScopeService {
|
||||
)
|
||||
}
|
||||
|
||||
assertCanWriteRecord(
|
||||
async canWriteWorkspaceRecordWithSettings(
|
||||
record: IntakeNonIntegratedPermissionTarget,
|
||||
user: AuthUser,
|
||||
category?: string | null,
|
||||
): void {
|
||||
): Promise<boolean> {
|
||||
if (this.canWriteWorkspaceRecord(record, user, category)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const targetKey = getIntakeNodePermissionTargetKeyByWorkspaceCategory(category)
|
||||
if (!targetKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
|
||||
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user)
|
||||
const overrideState = getIntakeNodePermissionOverrideState(
|
||||
overrides,
|
||||
targetKey,
|
||||
subjectKeys,
|
||||
)
|
||||
|
||||
return overrideState.edit
|
||||
}
|
||||
|
||||
async assertCanWriteRecord(
|
||||
record: IntakeNonIntegratedPermissionTarget,
|
||||
user: AuthUser,
|
||||
category?: string | null,
|
||||
): Promise<void> {
|
||||
if (await this.canWriteWorkspaceRecordWithSettings(record, user, category)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user