Skip to content
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/windup-mark.svg" />
<link rel="icon" type="image/svg+xml" href="/windup-mark.svg?v=2" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,9 @@ describe('AppHeader', () => {

const accountMenu = await screen.findByRole('button', { name: '打开账号菜单' })
expect(accountMenu.textContent).toContain('reader@example.com')
expect(within(accountMenu).getByTestId('default-account-avatar').getAttribute('src')).toBe(
'/windup-mark.svg',
)
expect(
within(accountMenu).getByTestId('default-account-avatar').getAttribute('aria-hidden'),
).toBe('true')
})

it('账号菜单在点击外部或按 Escape 时关闭', async () => {
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WindupMark } from '@/shared/ui/windup-mark'
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'

Expand Down Expand Up @@ -227,7 +228,7 @@ export function AppHeader({
<header
data-layout="unified"
data-surface="frosted-bar"
className="fixed inset-x-0 top-0 z-50 border-b border-app-ink/10 bg-transparent text-app-ink shadow-app-header backdrop-blur-xl"
className="fixed inset-x-0 top-0 z-50 border-b border-app-ink/10 bg-transparent text-app-ink backdrop-blur-xl"
>
<div className="relative mx-auto grid min-h-14 w-full max-w-[90rem] grid-cols-[auto_1fr_auto] items-center gap-4 px-4 sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-1.5">
Expand All @@ -240,7 +241,7 @@ export function AppHeader({
wave.entry === 'brand' ? 'app-header-text-wave' : ''
}`}
>
<img src="/windup-mark.svg" alt="" className="h-7 w-7" />
<WindupMark className="h-7 w-7" />
<strong className="hidden font-serif text-[1.0625rem] leading-none sm:inline">
<WaveText playId={wave.entry === 'brand' ? wave.playId : 0} text="Windup" />
</strong>
Expand Down Expand Up @@ -430,10 +431,9 @@ export function AppHeader({
accountMenu.expanded ? 'scale-110' : 'scale-100'
}`}
>
<img
<WindupMark
data-testid="default-account-avatar"
src="/windup-mark.svg"
alt=""

aria-hidden="true"
className="h-5 w-5 object-contain"
/>
Expand Down Expand Up @@ -550,7 +550,7 @@ function MarketingHeaderView({ session, wave, onWave }: MarketingHeaderViewProps
wave.entry === 'brand' ? 'app-header-text-wave' : ''
}`}
>
<img src="/windup-mark.svg" alt="" className="h-7 w-7" />
<WindupMark className="h-7 w-7" />
<strong className="font-serif text-lg leading-none">
<WaveText playId={wave.entry === 'brand' ? wave.playId : 0} text="Windup" />
</strong>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/features/account-panel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WindupMark } from '@/shared/ui/windup-mark'
import {
useEffect,
useId,
Expand Down Expand Up @@ -668,7 +669,7 @@ function AccountPanelDialog({
className="auth-screen-brand fixed z-[2] flex items-center gap-[0.7rem]"
aria-label="Windup"
>
<img src="/windup-mark.svg" alt="" className="size-[1.65rem]" />
<WindupMark className="size-[1.65rem]" />
<strong className="font-serif text-2xl leading-none tracking-[-0.025em]">Windup</strong>
</div>

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/account/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ describe('AccountPage', () => {
)
expect(screen.getByRole('heading', { name: '个人资料' })).toBeTruthy()
expect(screen.getByLabelText('昵称').getAttribute('placeholder')).toBe('输入昵称')
expect(screen.getByTestId('account-profile-default-avatar').getAttribute('src')).toBe(
'/windup-mark.svg',
expect(screen.getByTestId('account-profile-default-avatar').getAttribute('aria-hidden')).toBe(
'true',
)
expect(screen.queryByLabelText('邮箱验证码')).toBeNull()

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/account/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WindupMark } from '@/shared/ui/windup-mark'
import { Gift, X } from '@phosphor-icons/react'
import {
useEffect,
Expand Down Expand Up @@ -734,10 +735,9 @@ export function AccountPage() {

<div className="mt-5 flex items-center gap-4 border-b border-app-line pb-5">
<span className="grid size-14 shrink-0 place-items-center rounded-full bg-app-accent-soft">
<img
<WindupMark
data-testid="account-profile-default-avatar"
src="/windup-mark.svg"
alt=""

aria-hidden="true"
className="size-9 object-contain"
/>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/landing/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WindupMark } from '@/shared/ui/windup-mark'
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { Link } from 'react-router'
Expand Down Expand Up @@ -749,7 +750,7 @@ export function LandingPage() {
aria-label="返回 Windup 宣传页"
className="inline-flex items-center gap-3 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-app-accent"
>
<img src="/windup-mark.svg" alt="" className="h-7 w-7" />
<WindupMark className="h-7 w-7" />
<strong className="font-serif text-xl tracking-[-0.02em] text-ink">Windup</strong>
</Link>
<p className="mt-5 text-body leading-7 text-ink-muted">
Expand Down
39 changes: 38 additions & 1 deletion frontend/src/shared/ui/asset-preview-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router'

import { AssetPreviewCard, AssetThumbnailImage } from './asset-preview-card'
import { AssetPreviewCard, AssetPreviewSurface, AssetThumbnailImage } from './asset-preview-card'

afterEach(cleanup)

Expand Down Expand Up @@ -203,3 +203,40 @@ describe('AssetThumbnailImage', () => {
)
})
})

describe('unavailable preview images', () => {
it('replaces a failed source with a status and retries a different URL', () => {
const { rerender } = render(
<AssetPreviewSurface previewUrl="https://media.windup.test/old.png" previewAlt="角色预览" />,
)
fireEvent.error(screen.getByRole('img', { name: '角色预览' }))
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByRole('status').textContent).toContain('无法加载')
rerender(
<AssetPreviewSurface
previewUrl="https://storage.windup.test/new.png"
previewAlt="角色预览"
/>,
)
expect(screen.getByRole('img').getAttribute('src')).toBe('https://storage.windup.test/new.png')
expect(screen.queryByRole('status')).toBeNull()
})

it('tries the original after thumbnail failure before showing unavailable', () => {
render(
<AssetPreviewSurface
previewUrl="https://media.windup.test/media/outfit-preview/old.source.png"
previewAlt="角色预览"
thumbnail
/>,
)
fireEvent.error(screen.getByRole('img'))
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('img').getAttribute('src')).toBe(
'https://media.windup.test/media/outfit-preview/old.source.png',
)
fireEvent.error(screen.getByRole('img'))
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByRole('status')).toBeTruthy()
})
})
18 changes: 15 additions & 3 deletions frontend/src/shared/ui/asset-preview-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,22 @@ export function AssetPreviewSurface({
className = '',
thumbnail = false,
}: AssetPreviewSurfaceProps) {
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const unavailable = !!previewUrl && failedUrl === previewUrl
const handleError: ImgHTMLAttributes<HTMLImageElement>['onError'] = (event) => {
// 缩略图失败仍要尝试原图;只有原图也失败才显示缺图状态。
if (event.currentTarget.getAttribute('src') === previewUrl) setFailedUrl(previewUrl)
}

return (
<div
className={`relative overflow-hidden rounded-[1.25rem] border border-app-line bg-app-surface-muted ${className}`}
>
{previewUrl ? (
{previewUrl && !unavailable ? (
thumbnail ? (
<AssetThumbnailImage
src={previewUrl}
onError={handleError}
alt={previewAlt}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
Expand All @@ -90,6 +98,7 @@ export function AssetPreviewSurface({
) : (
<img
src={previewUrl}
onError={handleError}
alt={previewAlt}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
Expand All @@ -98,7 +107,10 @@ export function AssetPreviewSurface({
/>
)
) : (
<div className="relative h-full overflow-hidden bg-app-surface-muted">
<div
role={unavailable ? 'status' : undefined}
className="relative h-full overflow-hidden bg-app-surface-muted"
>
<div
aria-hidden="true"
className="absolute inset-0 opacity-55"
Expand All @@ -110,7 +122,7 @@ export function AssetPreviewSurface({
}}
/>
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-app-surface/85 px-2 py-1 font-mono text-[10px] tracking-[0.06em] whitespace-nowrap text-app-faint backdrop-blur-sm">
暂无造型预览
{unavailable ? '预览图片暂时无法加载' : '暂无造型预览'}
</span>
</div>
)}
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/shared/ui/windup-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useId, type SVGProps } from 'react'

/** 内联标志跟随文字颜色;独立遮罩避免同页多个实例互相引用。 */
export function WindupMark(props: SVGProps<SVGSVGElement>) {
const maskId = useId()
return (
<svg viewBox="0 0 256 256" aria-hidden="true" {...props}>
<defs>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="256" height="256">
<rect width="256" height="256" fill="#000" />
<path
fill="#fff"
d="M18 181 57 147c6-8 11-17 15-28 11-30 37-48 69-49 11-22 32-35 56-32 25 3 44 23 47 48l12 8-15 13c-4 27-18 48-42 64-24 17-53 25-84 20-20-3-38-11-52-24L18 194l27-31-27 18Z"
/>
<path
d="M91 122c20-25 51-34 86-23 1 27-15 53-47 75-17-10-31-28-39-52Z"
fill="none"
stroke="#000"
strokeWidth="7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m116 146 32-28M130 160l33-30"
fill="none"
stroke="#000"
strokeWidth="4"
strokeLinecap="round"
/>
<circle cx="111" cy="127" r="8" fill="#000" />
<circle cx="207" cy="76" r="5" fill="#000" />
<path
d="m53 160-21 22M67 168l-25 20"
fill="none"
stroke="#000"
strokeWidth="5"
strokeLinecap="round"
/>
</mask>
</defs>
<rect width="256" height="256" fill="currentColor" mask={`url(#${maskId})`} />
</svg>
)
}
Loading