diff --git a/furniture-3d-system/src/app/home/components/HomePortal.test.tsx b/furniture-3d-system/src/app/home/components/HomePortal.test.tsx index 9daa8ca8..88a12d7d 100644 --- a/furniture-3d-system/src/app/home/components/HomePortal.test.tsx +++ b/furniture-3d-system/src/app/home/components/HomePortal.test.tsx @@ -58,9 +58,45 @@ describe('HomePortal', () => { expect(screen.getByRole('link', { name: '进入由壹点供应链中心' })).toHaveAttribute('aria-current', 'true') }) + it.each(['https://example.com/design', 'http://localhost/design?tab=1#read'])( + 'selects a saved URL first, then opens %s natively in a new tab without a workspace transition', url => { + const link: HomeQuickLinkDto = { id: 'saved', title: '设计资料', description: '资料库', url, enabled: true, sortOrder: 0, iconKey: 'link' } + render() + expect(fireEvent.click(screen.getByRole('link', { name: '切换到设计资料' }))).toBe(false) + act(() => jest.advanceTimersByTime(1500)) + const centered = screen.getByRole('link', { name: '打开设计资料' }) + expect(centered).toHaveAttribute('aria-current', 'true') + expect(centered).toHaveAttribute('href', url) + expect(centered).toHaveAttribute('target', '_blank') + expect(centered).toHaveAttribute('rel', 'noopener noreferrer') + expect(fireEvent.click(centered)).toBe(true) + act(() => jest.advanceTimersByTime(1100)) + expect(screen.queryByText('正在进入工作台')).not.toBeInTheDocument() + expect(routerMock.push).not.toHaveBeenCalled() + }, + ) + + it.each(['https://example.com/design', 'http://localhost/design?tab=1#read'])( + 'opens the saved URL %s directly from overview without a workspace transition', url => { + const link: HomeQuickLinkDto = { id: 'saved', title: '设计资料', description: '资料库', url, enabled: true, sortOrder: 0, iconKey: 'link' } + render() + fireEvent.doubleClick(screen.getByRole('region', { name: '工作台扇形轮播' })) + const card = screen.getByRole('link', { name: '打开设计资料' }) + expect(card).toHaveAttribute('href', url) + expect(card).toHaveAttribute('target', '_blank') + expect(card).toHaveAttribute('rel', 'noopener noreferrer') + expect(fireEvent.click(card)).toBe(true) + act(() => jest.advanceTimersByTime(1100)) + expect(screen.queryByText('正在进入工作台')).not.toBeInTheDocument() + expect(routerMock.push).not.toHaveBeenCalled() + expect(screen.getByRole('region', { name: '全部工作台与网址' })).toBeVisible() + }, + ) + it('plays the selected workspace transition before navigating and ignores repeated clicks', () => { render() const card = screen.getByRole('link', { name: '进入由壹点产品开发' }) + expect(card).not.toHaveAttribute('target') fireEvent.click(card) fireEvent.click(card) expect(screen.getByRole('status')).toHaveTextContent('由壹点产品开发') @@ -117,7 +153,9 @@ describe('HomePortal', () => { it('enters directly from overview using the selected title and the existing transition delay', () => { render() fireEvent.doubleClick(screen.getByRole('region', { name: '工作台扇形轮播' })) - fireEvent.click(screen.getByRole('link', { name: '进入由壹点供应链中心' })) + const card = screen.getByRole('link', { name: '进入由壹点供应链中心' }) + expect(card).not.toHaveAttribute('target') + fireEvent.click(card) expect(screen.getByRole('status')).toHaveTextContent('由壹点供应链中心') act(() => jest.advanceTimersByTime(1100)) expect(routerMock.push).toHaveBeenCalledTimes(1) diff --git a/furniture-3d-system/src/app/home/components/HomePortal.tsx b/furniture-3d-system/src/app/home/components/HomePortal.tsx index 690ade57..60aef849 100644 --- a/furniture-3d-system/src/app/home/components/HomePortal.tsx +++ b/furniture-3d-system/src/app/home/components/HomePortal.tsx @@ -6,7 +6,7 @@ import { HomeQuickLinksSection } from './HomeQuickLinksSection' import { HomePortalFan } from './HomePortalFan' import { HomePortalOverview } from './HomePortalOverview' import { HomePortalEntryTransition } from './HomePortalEntryTransition' -import { homePortalEntries, type HomePortalEntry } from '../lib/home-portal-fan' +import { homePortalEntries, type HomePortalWorkspaceEntry } from '../lib/home-portal-fan' import type { HomeQuickLinkDto } from '@/server/services/home-quick-links.service' import styles from './home-portal.module.css' @@ -28,7 +28,7 @@ export function HomePortal({ isQuickLinkAdmin?: boolean }) { const [managerOpen, setManagerOpen] = useState(false) - const [entering, setEntering] = useState(null) + const [entering, setEntering] = useState(null) const [overview, setOverview] = useState(false) const headerActions = useSyncExternalStore(subscribeHeader, getHeaderActions, getServerHeaderActions) const entries = useMemo(() => homePortalEntries(canAccessSupplyChain, quickLinks), [canAccessSupplyChain, quickLinks]) diff --git a/furniture-3d-system/src/app/home/components/HomePortalEntryTransition.tsx b/furniture-3d-system/src/app/home/components/HomePortalEntryTransition.tsx index da446292..08e928f7 100644 --- a/furniture-3d-system/src/app/home/components/HomePortalEntryTransition.tsx +++ b/furniture-3d-system/src/app/home/components/HomePortalEntryTransition.tsx @@ -4,11 +4,11 @@ import Image from 'next/image' import { useRouter } from 'next/navigation' import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' -import type { HomePortalEntry } from '../lib/home-portal-fan' +import type { HomePortalWorkspaceEntry } from '../lib/home-portal-fan' import styles from './home-portal.module.css' export function HomePortalEntryTransition({ entry, onRestore }: { - entry: HomePortalEntry + entry: HomePortalWorkspaceEntry onRestore: () => void }) { const router = useRouter() @@ -17,11 +17,7 @@ export function HomePortalEntryTransition({ entry, onRestore }: { useEffect(() => { overlayRef.current?.focus({ preventScroll: true }) const duration = matchMedia('(prefers-reduced-motion: reduce)').matches ? 160 : 1100 - const timer = window.setTimeout(() => { - const destination = new URL(entry.href, location.href) - if (destination.origin === location.origin) router.push(destination.pathname + destination.search + destination.hash) - else location.assign(destination.href) - }, duration) + const timer = window.setTimeout(() => router.push(entry.href), duration) const restore = (event: PageTransitionEvent) => { if (event.persisted) onRestore() } window.addEventListener('pageshow', restore) return () => { diff --git a/furniture-3d-system/src/app/home/components/HomePortalFan.tsx b/furniture-3d-system/src/app/home/components/HomePortalFan.tsx index b92a327a..7629c9d9 100644 --- a/furniture-3d-system/src/app/home/components/HomePortalFan.tsx +++ b/furniture-3d-system/src/app/home/components/HomePortalFan.tsx @@ -4,13 +4,13 @@ import { useRef, type MouseEvent } from 'react' import { Mouse } from 'lucide-react' import { HomePortalCardContent } from './HomePortalCardContent' import { useHomePortalFan } from '../hooks/useHomePortalFan' -import { fanCardPlacement, type HomePortalEntry } from '../lib/home-portal-fan' +import { fanCardPlacement, type HomePortalEntry, type HomePortalWorkspaceEntry } from '../lib/home-portal-fan' import styles from './home-portal.module.css' export function HomePortalFan({ entries, paused, onEnter }: { entries: HomePortalEntry[] paused: boolean - onEnter: (entry: HomePortalEntry) => void + onEnter: (entry: HomePortalWorkspaceEntry) => void }) { const stageRef = useRef(null) const cardRefs = useRef<(HTMLAnchorElement | HTMLButtonElement | null)[]>([]) @@ -19,7 +19,7 @@ export function HomePortalFan({ entries, paused, onEnter }: { const activate = (event: MouseEvent, entry: HomePortalEntry, index: number) => { if (paused || suppressClick()) { event.preventDefault(); return } if (!isCentered(index)) { event.preventDefault(); select(index); return } - if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return + if (entry.kind === 'website' || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return event.preventDefault() onEnter(entry) } @@ -35,9 +35,10 @@ export function HomePortalFan({ entries, paused, onEnter }: { const content = return entry.enabled ? ( { cardRefs.current[index] = node }} href={entry.href} + target={entry.kind === 'website' ? '_blank' : undefined} rel={entry.kind === 'website' ? 'noopener noreferrer' : undefined} className={cardClassName} style={placement.style} data-portal-card data-entry-id={entry.id} aria-current={active ? 'true' : undefined} - aria-label={`${active ? '进入' : '切换到'}${entry.title}`} + aria-label={`${active ? (entry.kind === 'website' ? '打开' : '进入') : '切换到'}${entry.title}`} onClick={event => activate(event, entry, index)}> {content} diff --git a/furniture-3d-system/src/app/home/components/HomePortalOverview.tsx b/furniture-3d-system/src/app/home/components/HomePortalOverview.tsx index a2773b3d..2dcab8e6 100644 --- a/furniture-3d-system/src/app/home/components/HomePortalOverview.tsx +++ b/furniture-3d-system/src/app/home/components/HomePortalOverview.tsx @@ -3,20 +3,20 @@ import { useEffect, useRef, type MouseEvent } from 'react' import { Mouse } from 'lucide-react' import { HomePortalCardContent } from './HomePortalCardContent' -import type { HomePortalEntry } from '../lib/home-portal-fan' +import type { HomePortalEntry, HomePortalWorkspaceEntry } from '../lib/home-portal-fan' import styles from './home-portal.module.css' export function HomePortalOverview({ entries, paused, onEnter }: { entries: HomePortalEntry[] paused: boolean - onEnter: (entry: HomePortalEntry) => void + onEnter: (entry: HomePortalWorkspaceEntry) => void }) { const stageRef = useRef(null) useEffect(() => { stageRef.current?.focus({ preventScroll: true }) }, []) const activate = (event: MouseEvent, entry: HomePortalEntry) => { if (paused) { event.preventDefault(); return } - if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return + if (entry.kind === 'website' || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return event.preventDefault() onEnter(entry) } @@ -30,7 +30,8 @@ export function HomePortalOverview({ entries, paused, onEnter }: { const style = { animationDelay: `${Math.min(index * 24, 240)}ms` } return entry.enabled ? ( activate(event, entry)}> + target={entry.kind === 'website' ? '_blank' : undefined} rel={entry.kind === 'website' ? 'noopener noreferrer' : undefined} + aria-label={`${entry.kind === 'website' ? '打开' : '进入'}${entry.title}`} onClick={event => activate(event, entry)}> ) : ( diff --git a/furniture-3d-system/src/app/home/lib/home-portal-fan.ts b/furniture-3d-system/src/app/home/lib/home-portal-fan.ts index cb4f8ee5..0aa0a9da 100644 --- a/furniture-3d-system/src/app/home/lib/home-portal-fan.ts +++ b/furniture-3d-system/src/app/home/lib/home-portal-fan.ts @@ -1,7 +1,7 @@ import type { CSSProperties } from 'react' import type { HomeQuickLinkDto } from '@/server/services/home-quick-links.service' -export type HomePortalEntry = { +type HomePortalEntryDetails = { id: string href: string title: string @@ -9,13 +9,17 @@ export type HomePortalEntry = { enabled: boolean } +export type HomePortalWorkspaceEntry = HomePortalEntryDetails & { kind: 'workspace' } +export type HomePortalEntry = HomePortalWorkspaceEntry | (HomePortalEntryDetails & { kind: 'website' }) + export const wrapFanPosition = (value: number, count: number) => ((value % count) + count) % count export function homePortalEntries(canAccessSupplyChain: boolean, quickLinks: HomeQuickLinkDto[]): HomePortalEntry[] { return [ - { id: 'product-development', href: '/intake', title: '由壹点产品开发', description: '产品开发工作台', enabled: true }, - { id: 'supply-chain', href: '/supply-chain/dashboard', title: '由壹点供应链中心', description: '供应链工作台', enabled: canAccessSupplyChain }, - ...quickLinks.filter(link => link.enabled).map(link => ({ + { kind: 'workspace', id: 'product-development', href: '/intake', title: '由壹点产品开发', description: '产品开发工作台', enabled: true }, + { kind: 'workspace', id: 'supply-chain', href: '/supply-chain/dashboard', title: '由壹点供应链中心', description: '供应链工作台', enabled: canAccessSupplyChain }, + ...quickLinks.filter(link => link.enabled).map(link => ({ + kind: 'website', id: `quick-link-${link.id}`, href: link.url, title: link.title, description: link.description || new URL(link.url).hostname, enabled: true, })),