@@ -240,7 +241,7 @@ export function AppHeader({
wave.entry === 'brand' ? 'app-header-text-wave' : ''
}`}
>
-

+
@@ -430,10 +431,9 @@ export function AppHeader({
accountMenu.expanded ? 'scale-110' : 'scale-100'
}`}
>
-

@@ -550,7 +550,7 @@ function MarketingHeaderView({ session, wave, onWave }: MarketingHeaderViewProps
wave.entry === 'brand' ? 'app-header-text-wave' : ''
}`}
>
-

+
diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx
index 083ee886..b1a40bc4 100644
--- a/frontend/src/features/account-panel/index.tsx
+++ b/frontend/src/features/account-panel/index.tsx
@@ -1,3 +1,4 @@
+import { WindupMark } from '@/shared/ui/windup-mark'
import {
useEffect,
useId,
@@ -668,7 +669,7 @@ function AccountPanelDialog({
className="auth-screen-brand fixed z-[2] flex items-center gap-[0.7rem]"
aria-label="Windup"
>
-

+
Windup
diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx
index 3dc1d755..995d4109 100644
--- a/frontend/src/pages/account/index.test.tsx
+++ b/frontend/src/pages/account/index.test.tsx
@@ -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()
diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx
index e1a3a5ee..1a50baff 100644
--- a/frontend/src/pages/account/index.tsx
+++ b/frontend/src/pages/account/index.tsx
@@ -1,3 +1,4 @@
+import { WindupMark } from '@/shared/ui/windup-mark'
import { Gift, X } from '@phosphor-icons/react'
import {
useEffect,
@@ -734,10 +735,9 @@ export function AccountPage() {
-
diff --git a/frontend/src/pages/landing/index.tsx b/frontend/src/pages/landing/index.tsx
index 15e88e73..8d9cc095 100644
--- a/frontend/src/pages/landing/index.tsx
+++ b/frontend/src/pages/landing/index.tsx
@@ -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'
@@ -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"
>
-
+
Windup
diff --git a/frontend/src/shared/ui/asset-preview-card.test.tsx b/frontend/src/shared/ui/asset-preview-card.test.tsx
index 6bda0c94..08dfe7c8 100644
--- a/frontend/src/shared/ui/asset-preview-card.test.tsx
+++ b/frontend/src/shared/ui/asset-preview-card.test.tsx
@@ -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)
@@ -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(
+ ,
+ )
+ fireEvent.error(screen.getByRole('img', { name: '角色预览' }))
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.getByRole('status').textContent).toContain('无法加载')
+ rerender(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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()
+ })
+})
diff --git a/frontend/src/shared/ui/asset-preview-card.tsx b/frontend/src/shared/ui/asset-preview-card.tsx
index 77dedbd2..6d862300 100644
--- a/frontend/src/shared/ui/asset-preview-card.tsx
+++ b/frontend/src/shared/ui/asset-preview-card.tsx
@@ -73,14 +73,22 @@ export function AssetPreviewSurface({
className = '',
thumbnail = false,
}: AssetPreviewSurfaceProps) {
+ const [failedUrl, setFailedUrl] = useState(null)
+ const unavailable = !!previewUrl && failedUrl === previewUrl
+ const handleError: ImgHTMLAttributes['onError'] = (event) => {
+ // 缩略图失败仍要尝试原图;只有原图也失败才显示缺图状态。
+ if (event.currentTarget.getAttribute('src') === previewUrl) setFailedUrl(previewUrl)
+ }
+
return (
- {previewUrl ? (
+ {previewUrl && !unavailable ? (
thumbnail ? (
)
) : (
-
+
- 暂无造型预览
+ {unavailable ? '预览图片暂时无法加载' : '暂无造型预览'}
)}
diff --git a/frontend/src/shared/ui/windup-mark.tsx b/frontend/src/shared/ui/windup-mark.tsx
new file mode 100644
index 00000000..7bbaff42
--- /dev/null
+++ b/frontend/src/shared/ui/windup-mark.tsx
@@ -0,0 +1,44 @@
+import { useId, type SVGProps } from 'react'
+
+/** 内联标志跟随文字颜色;独立遮罩避免同页多个实例互相引用。 */
+export function WindupMark(props: SVGProps
) {
+ const maskId = useId()
+ return (
+
+ )
+}