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