diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts index 0b05caf9..d82d1e06 100644 --- a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts +++ b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie' +import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions' import { dingtalkAuthService, resolveDingtalkCallbackOrigin, @@ -30,7 +31,11 @@ export async function GET(request: NextRequest) { try { const redirectPath = await dingtalkAuthService.resolveRedirectPath(state) const result = await dingtalkAuthService.authenticate(code) - const finalPath = result.isFirstLogin ? '/first-login' : redirectPath + const finalPath = result.isFirstLogin + ? '/first-login' + : result.user + ? resolveWorkspaceRedirectForUser(redirectPath, result.user) + : redirectPath const appOrigin = resolveDingtalkCallbackOrigin(request.nextUrl.origin) const response = NextResponse.redirect(new URL(finalPath, appOrigin)) diff --git a/furniture-3d-system/src/app/api/auth/login/route.ts b/furniture-3d-system/src/app/api/auth/login/route.ts index ea64c673..2b08a2c6 100644 --- a/furniture-3d-system/src/app/api/auth/login/route.ts +++ b/furniture-3d-system/src/app/api/auth/login/route.ts @@ -9,6 +9,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie' import { authService, type LoginInput } from '@/server/services/auth.service' import { resolvePostLoginRedirect } from '@/server/services/auth-redirect.service' +import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions' const WINDOW_MS = 60 * 1000 const MAX_ATTEMPTS_PER_WINDOW = 20 @@ -105,9 +106,10 @@ export async function POST(request: NextRequest) { password: body.password, }) clearFailure(key) + const resolvedRedirectPath = await resolvePostLoginRedirect(body.redirectPath) const redirectPath = result.isFirstLogin ? '/first-login' - : await resolvePostLoginRedirect(body.redirectPath) + : resolveWorkspaceRedirectForUser(resolvedRedirectPath, result.user) const response = jsonSuccess( { diff --git a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx index e3800a80..2b6bdd55 100644 --- a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx @@ -13,6 +13,18 @@ export default function AccessoryLibraryPanel() { changedEventName={ACCESSORY_LIBRARY_CHANGED_EVENT} entityName="配件" queryNamePlaceholder="例如:六角螺丝 / 平角合" + extraFields={[ + { + key: 'color', + label: '颜色', + placeholder: '例如:黑色 / 银色 / 胡桃色', + }, + { + key: 'material', + label: '材质', + placeholder: '例如:铁 / 不锈钢 / 塑料', + }, + ]} /> ) } diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx new file mode 100644 index 00000000..a51a8db1 --- /dev/null +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AccessoryLibraryPanel from '@/app/intake/AccessoryLibraryPanel' +import PackagingMaterialLibraryPanel from '@/app/intake/PackagingMaterialLibraryPanel' +import { ACCESSORY_LIBRARY_STORAGE_KEY } from '@/lib/accessory-library' + +describe('alphabet library panels', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('adds color and material fields to the accessory library only', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: '新增当前字母' })) + + const dialog = await screen.findByRole('dialog', { name: '新增配件' }) + + expect(within(dialog).getByLabelText('颜色')).toBeInTheDocument() + expect(within(dialog).getByLabelText('材质')).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText(/配件名称/), { + target: { value: '六角螺丝' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '黑色' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: '铁' }, + }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('黑色')).toBeInTheDocument() + expect(screen.getByText('铁')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + name: '六角螺丝', + color: '黑色', + material: '铁', + }) + }) + + it('keeps the packaging material library on its original fields', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: '新增当前字母' })) + + const dialog = await screen.findByRole('dialog', { name: '新增包材' }) + + expect(within(dialog).getByLabelText(/包材名称/)).toBeInTheDocument() + expect(within(dialog).queryByLabelText('颜色')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('材质')).not.toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx index 81457ce2..9001a887 100644 --- a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx @@ -21,6 +21,8 @@ type AlphabetLibraryItem = { letter: AlphabetLibraryLetter code: string name: string + color?: string + material?: string price: string imageUrl: string imageName: string @@ -31,16 +33,27 @@ type AlphabetLibraryDraft = { letter: AlphabetLibraryLetter code: string name: string + color: string + material: string price: string imageUrl: string imageName: string } +type AlphabetLibraryExtraFieldKey = 'color' | 'material' + +type AlphabetLibraryExtraFieldDefinition = { + key: AlphabetLibraryExtraFieldKey + label: string + placeholder?: string +} + interface AlphabetLibraryPanelProps { storageKey: string entityName: string queryNamePlaceholder?: string changedEventName?: string + extraFields?: AlphabetLibraryExtraFieldDefinition[] } function createItemId() { @@ -75,6 +88,8 @@ function readLibraryItems(storageKey: string) { ALPHABET_LIBRARY_LETTERS.includes(item.letter as AlphabetLibraryLetter) && typeof item.code === 'string' && typeof item.name === 'string' && + (item.color === undefined || typeof item.color === 'string') && + (item.material === undefined || typeof item.material === 'string') && typeof item.price === 'string' && typeof item.imageUrl === 'string' && typeof item.imageName === 'string' && @@ -124,6 +139,8 @@ function createDraft(items: AlphabetLibraryItem[], letter: AlphabetLibraryLetter letter, code: buildNextLibraryCode(items, letter), name: '', + color: '', + material: '', price: '', imageUrl: '', imageName: '', @@ -135,6 +152,7 @@ export default function AlphabetLibraryPanel({ entityName, queryNamePlaceholder, changedEventName, + extraFields = [], }: AlphabetLibraryPanelProps) { const [items, setItems] = useState(() => readLibraryItems(storageKey)) const [selectedLetter, setSelectedLetter] = useState('A') @@ -211,6 +229,13 @@ export default function AlphabetLibraryPanel({ }, [groupedItems, hasActiveQuery, selectedLetter]) const currentLetterItems = groupedItems[activeLetter] + const libraryGridTemplateColumns = extraFields.length + ? `130px minmax(180px,1.2fr) repeat(${extraFields.length}, minmax(110px,0.75fr)) 150px 180px` + : '130px minmax(0,1.3fr) 150px 180px' + const libraryGridMinWidth = extraFields.length ? 840 : undefined + const libraryFieldSummary = ['编号', '名称', ...extraFields.map((field) => field.label), '图片', '价格'].join( + '、', + ) function applySearch() { setAppliedQueryCode(queryCode) @@ -230,6 +255,8 @@ export default function AlphabetLibraryPanel({ letter: item.letter, code: item.code, name: item.name, + color: item.color ?? '', + material: item.material ?? '', price: item.price, imageUrl: item.imageUrl, imageName: item.imageName, @@ -289,6 +316,8 @@ export default function AlphabetLibraryPanel({ const normalizedCode = draft.code.trim().toUpperCase() const normalizedName = draft.name.trim() + const normalizedColor = draft.color.trim() + const normalizedMaterial = draft.material.trim() const normalizedPrice = draft.price.trim() if (!normalizedCode) { @@ -316,6 +345,8 @@ export default function AlphabetLibraryPanel({ letter: draft.letter, code: normalizedCode, name: normalizedName, + color: normalizedColor, + material: normalizedMaterial, price: normalizedPrice, imageUrl: draft.imageUrl, imageName: draft.imageName, @@ -452,7 +483,7 @@ export default function AlphabetLibraryPanel({ {activeLetter} 组{entityName}

- 当前显示 {currentLetterItems.length} 条数据,字段固定为编号、名称、图片、价格。 + 当前显示 {currentLetterItems.length} 条数据,字段为{libraryFieldSummary}。

- ))} - - + {showDesignControlledProductData ? ( + + } + expanded={activeSection === "bom"} + onToggle={() => onToggle("bom")} + > +
+ {BOM_LIBRARY_OPTIONS.map((option) => ( + + ))} +
+
+ ) : null} - - } - expanded={activeSection === "patents"} - onToggle={onOpenProductPatents} - > - - - + + + + ) : null} {onOpenSpuRuleLibraryWorkspace ? ( + {isCurrentUserPlainReadOnlyViewer ? null : ( + + )} @@ -24560,7 +24685,9 @@ function renderFactoryQualityWorkspace() { 开品参考填写

- 开品前先记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。立项通过后再补产品资料、样品评审和完整基础信息。 + {isCurrentUserPlainReadOnlyViewer + ? "开品前记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。" + : "开品前先记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。立项通过后再补产品资料、样品评审和完整基础信息。"}

@@ -25109,6 +25236,8 @@ function renderFactoryQualityWorkspace() { hasContent: boolean; label: string; }) { + if (isCurrentUserPlainReadOnlyViewer) return null; + const isApproved = isWorkflowNodeConfirmed(category, hasContent); if (isApproved) { @@ -27550,7 +27679,10 @@ function renderFactoryQualityWorkspace() { active: activeSkuTab === option.key, })} > - {option.label} + {isCurrentUserPlainReadOnlyViewer && + option.key === "fileUpload" + ? "资料文件" + : option.label} ))} {canDownloadWorkspaceFiles ? ( @@ -29908,6 +30040,7 @@ function renderFactoryQualityWorkspace() { bodyClassName="flex min-h-0 flex-1 flex-col" compact={isProductPhotoWorkspace} headerActions={ + isCurrentUserPlainReadOnlyViewer ? null : isOpeningReferenceWorkspace || isPatentApplicationWorkspace || isProjectReviewWorkspace ? null : isPlanningLayoutWorkspace ? ( @@ -30828,14 +30961,16 @@ function renderFactoryQualityWorkspace() { ) : ( - 待上传 + {isReadOnly ? "暂无图片" : "待上传"} )}

{slotFile ? "已匹配" - : "点击上传"} + : isReadOnly + ? "暂无图片" + : "点击上传"}

); @@ -30861,9 +30996,9 @@ function renderFactoryQualityWorkspace() { 当前还没有{activeProductPhotoPanelTitle}

- 点击这里或上方“ - {currentProductPhotoGroupUploadLabel} - ”后,这里会显示图片缩略图列表。 + {isReadOnly + ? `当前还没有${activeProductPhotoPanelTitle}资料。` + : `点击这里或上方“${currentProductPhotoGroupUploadLabel}”后,这里会显示图片缩略图列表。`}

) : ( diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx index c4c107cd..13f7c980 100644 --- a/furniture-3d-system/src/components/layout/AppShell.tsx +++ b/furniture-3d-system/src/components/layout/AppShell.tsx @@ -1,9 +1,14 @@ 'use client' -import { Suspense, useEffect, type ReactNode } from 'react' -import { usePathname, useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useState, type ReactNode } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { AppTopNav } from './AppTopNav' import { AppSideNav } from './AppSideNav' +import { + FORMAL_PRODUCTS_HOME_PATH, + canAccessWorkspacePath, + type WorkspacePermissionUser, +} from '@/lib/workspace-route-permissions' // AppShell 只服务新平台主链;旧 /products 兼容链和工具页不进入这套全局壳子 export const APP_SHELL_ROOTS = [ @@ -32,19 +37,35 @@ export function shouldUseAppShell(pathname: string): boolean { interface AppShellProps { children: ReactNode - user?: { + user?: WorkspaceShellUser | null +} + +interface WorkspaceShellUser extends WorkspacePermissionUser { id: string name: string email: string role: string + businessRoles?: string[] avatar: string | null - department: { id: string; name: string } | null - } | null + department: { id: string; name: string; fullPath?: string | null } | null + departmentPaths?: string[] +} + +interface AuthMeResponse { + success?: boolean + data?: { + authenticated?: boolean + user?: WorkspaceShellUser + } } function AppShellContent({ children, user }: AppShellProps) { const pathname = usePathname() + const router = useRouter() const searchParams = useSearchParams() + const [fetchedUser, setFetchedUser] = useState( + user === undefined ? undefined : user, + ) const isOpeningDetailLinkView = /^\/intake\/non-integrated\/[^/]+$/.test(pathname) && searchParams.get('workspace') === 'opening-reference' @@ -77,6 +98,57 @@ function AppShellContent({ children, user }: AppShellProps) { const shouldLockMainViewport = isWorkbenchShellRoute + useEffect(() => { + if (user !== undefined) { + setFetchedUser(user) + return + } + + if (!shouldUseAppShell(pathname)) { + return + } + + let active = true + + void (async () => { + try { + const response = await fetch('/api/auth/me', { cache: 'no-store' }) + const payload = (await response.json().catch(() => null)) as AuthMeResponse | null + + if (!active) return + + if (response.ok && payload?.success && payload.data?.authenticated && payload.data.user) { + setFetchedUser(payload.data.user) + return + } + + setFetchedUser(null) + } catch { + if (active) { + setFetchedUser(null) + } + } + })() + + return () => { + active = false + } + }, [pathname, user]) + + useEffect(() => { + if (fetchedUser === undefined) { + return + } + + if (!shouldUseAppShell(pathname)) { + return + } + + if (!canAccessWorkspacePath(pathname, fetchedUser)) { + router.replace(FORMAL_PRODUCTS_HOME_PATH) + } + }, [fetchedUser, pathname, router]) + useEffect(() => { if (!shouldLockMainViewport || typeof document === 'undefined') { return @@ -122,7 +194,7 @@ function AppShellContent({ children, user }: AppShellProps) { return (
{!isWorkbenchShellRoute && } diff --git a/furniture-3d-system/src/components/layout/AppTopNav.tsx b/furniture-3d-system/src/components/layout/AppTopNav.tsx index f0673fb1..97d0ae19 100644 --- a/furniture-3d-system/src/components/layout/AppTopNav.tsx +++ b/furniture-3d-system/src/components/layout/AppTopNav.tsx @@ -15,6 +15,12 @@ import { } from 'lucide-react' import UserMenu from '@/components/ui/UserMenu' import { APP_BRAND_LOGO_SRC, APP_BRAND_NAME } from '@/lib/branding' +import { + FORMAL_PRODUCTS_HOME_PATH, + canAccessWorkspacePath, + getWorkspaceAccessLevel, + type WorkspacePermissionUser, +} from '@/lib/workspace-route-permissions' const WORKBENCH_NAV_ITEMS = [ { href: '/', icon: Home, label: '首页概览' }, @@ -32,14 +38,16 @@ function matchesWorkbenchHref(pathname: string, href: string) { } interface AppTopNavProps { - user?: { + user?: ({ id: string name: string email: string role: string + businessRoles?: string[] avatar: string | null - department: { id: string; name: string } | null - } | null + department: { id: string; name: string; fullPath?: string | null } | null + departmentPaths?: string[] + } & WorkspacePermissionUser) | null variant?: 'default' | 'workbench' } @@ -47,9 +55,16 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) { const pathname = usePathname() ?? '/' if (variant === 'workbench') { + const shouldRestrictNavigation = + user !== undefined && getWorkspaceAccessLevel(user) === 'formal-products-only' + const brandHref = shouldRestrictNavigation ? FORMAL_PRODUCTS_HOME_PATH : '/' + const visibleNavItems = shouldRestrictNavigation + ? WORKBENCH_NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user)) + : WORKBENCH_NAV_ITEMS + return (