fix(home): open saved websites in a new tab

This commit is contained in:
WuYongJie 2026-10-19 16:41:25 +08:00
parent df253a470d
commit 414b86fd56
6 changed files with 62 additions and 22 deletions

View File

@ -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(<HomePortal user={user} quickLinks={[link]} />)
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(<HomePortal user={user} quickLinks={[link]} />)
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(<HomePortal user={user} />)
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(<HomePortal user={user} canAccessSupplyChain />)
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)

View File

@ -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<HomePortalEntry | null>(null)
const [entering, setEntering] = useState<HomePortalWorkspaceEntry | null>(null)
const [overview, setOverview] = useState(false)
const headerActions = useSyncExternalStore(subscribeHeader, getHeaderActions, getServerHeaderActions)
const entries = useMemo(() => homePortalEntries(canAccessSupplyChain, quickLinks), [canAccessSupplyChain, quickLinks])

View File

@ -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 () => {

View File

@ -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<HTMLDivElement>(null)
const cardRefs = useRef<(HTMLAnchorElement | HTMLButtonElement | null)[]>([])
@ -19,7 +19,7 @@ export function HomePortalFan({ entries, paused, onEnter }: {
const activate = (event: MouseEvent<HTMLAnchorElement>, 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 = <HomePortalCardContent entry={entry} />
return entry.enabled ? (
<a key={entry.id} ref={node => { 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}
</a>

View File

@ -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<HTMLDivElement>(null)
useEffect(() => { stageRef.current?.focus({ preventScroll: true }) }, [])
const activate = (event: MouseEvent<HTMLAnchorElement>, 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 ? (
<a key={entry.id} href={entry.href} className={className} style={style} data-portal-overview-card data-entry-id={entry.id}
aria-label={`进入${entry.title}`} onClick={event => 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)}>
<HomePortalCardContent entry={entry} />
</a>
) : (

View File

@ -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<HomePortalEntry>(link => ({
kind: 'website',
id: `quick-link-${link.id}`, href: link.url, title: link.title,
description: link.description || new URL(link.url).hostname, enabled: true,
})),