|
|
@ -1,6 +1,7 @@ |
|
|
import { useEffect, useRef, useState, useCallback } from 'react'; |
|
|
import { useEffect, useRef, useState, useCallback } from 'react'; |
|
|
import { useLocation } from 'react-router-dom'; |
|
|
import { useLocation } from 'react-router-dom'; |
|
|
import { getLocaleFromPathname, type Locale } from '@/i18n/utils'; |
|
|
import { getLocaleFromPathname, type Locale } from '@/i18n/utils'; |
|
|
|
|
|
import dogSvgRaw from '@/assets/dog.svg?raw'; |
|
|
|
|
|
|
|
|
type Mood = 'idle' | 'wave' | 'think' | 'cheer' | 'squeeze'; |
|
|
type Mood = 'idle' | 'wave' | 'think' | 'cheer' | 'squeeze'; |
|
|
|
|
|
|
|
|
@ -165,6 +166,35 @@ export default function Mascot() { |
|
|
const hideTimerRef = useRef<number | null>(null); |
|
|
const hideTimerRef = useRef<number | null>(null); |
|
|
const idleTimerRef = useRef<number | null>(null); |
|
|
const idleTimerRef = useRef<number | null>(null); |
|
|
const scrollCooldownRef = useRef<number | null>(null); |
|
|
const scrollCooldownRef = useRef<number | null>(null); |
|
|
|
|
|
const mascotBtnRef = useRef<HTMLButtonElement>(null); |
|
|
|
|
|
|
|
|
|
|
|
// 视线跟随:小狗眼睛随鼠标方向轻微转动(外层 g 位移,与眨眼动画分层不冲突)。
|
|
|
|
|
|
// 位移上限 7 个 SVG 用户单位(缩到 90px 后约 2px 屏幕位移),克制不夸张。
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
const btn = mascotBtnRef.current; |
|
|
|
|
|
if (!btn) return; |
|
|
|
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; |
|
|
|
|
|
if (window.matchMedia('(hover: none)').matches) return; // 触屏无悬停语义
|
|
|
|
|
|
let raf = 0; |
|
|
|
|
|
const onMove = (e: PointerEvent) => { |
|
|
|
|
|
const r = btn.getBoundingClientRect(); |
|
|
|
|
|
const dx = e.clientX - (r.left + r.width / 2); |
|
|
|
|
|
const dy = e.clientY - (r.top + r.height / 2); |
|
|
|
|
|
const dist = Math.hypot(dx, dy) || 1; |
|
|
|
|
|
const k = Math.min(dist / 240, 1) * 7; |
|
|
|
|
|
if (raf) return; |
|
|
|
|
|
raf = requestAnimationFrame(() => { |
|
|
|
|
|
raf = 0; |
|
|
|
|
|
btn.style.setProperty('--eye-x', `${((dx / dist) * k).toFixed(2)}`); |
|
|
|
|
|
btn.style.setProperty('--eye-y', `${((dy / dist) * k).toFixed(2)}`); |
|
|
|
|
|
}); |
|
|
|
|
|
}; |
|
|
|
|
|
window.addEventListener('pointermove', onMove, { passive: true }); |
|
|
|
|
|
return () => { |
|
|
|
|
|
window.removeEventListener('pointermove', onMove); |
|
|
|
|
|
if (raf) cancelAnimationFrame(raf); |
|
|
|
|
|
}; |
|
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
const clearAllTimers = () => { |
|
|
const clearAllTimers = () => { |
|
|
[showTimerRef, hideTimerRef, idleTimerRef, scrollCooldownRef].forEach((r) => { |
|
|
[showTimerRef, hideTimerRef, idleTimerRef, scrollCooldownRef].forEach((r) => { |
|
|
@ -350,6 +380,7 @@ export default function Mascot() { |
|
|
|
|
|
|
|
|
<button |
|
|
<button |
|
|
type="button" |
|
|
type="button" |
|
|
|
|
|
ref={mascotBtnRef} |
|
|
className="mascot-float" |
|
|
className="mascot-float" |
|
|
data-mood={displayMood} |
|
|
data-mood={displayMood} |
|
|
aria-label={ |
|
|
aria-label={ |
|
|
@ -380,81 +411,12 @@ export default function Mascot() { |
|
|
}); |
|
|
}); |
|
|
}} |
|
|
}} |
|
|
> |
|
|
> |
|
|
<svg className="mascot-svg" viewBox="0 0 120 120" width="84" height="84" aria-hidden="true"> |
|
|
{/* 小狗吉祥物:内嵌 SVG(338×314),CSS 可选中耳朵/眼睛/腮红做灵动动效 */} |
|
|
{/* 车身 */} |
|
|
<span |
|
|
<rect className="mascot-body" x="20" y="34" width="80" height="54" rx="16" stroke="#1A1A1A" strokeWidth="3" /> |
|
|
className="mascot-svg mascot-dog" |
|
|
{/* 顶灯 */} |
|
|
aria-hidden="true" |
|
|
<rect className="mascot-accent" x="54" y="28" width="12" height="6" rx="3" /> |
|
|
dangerouslySetInnerHTML={{ __html: dogSvgRaw }} |
|
|
{/* 车头灯(暖琥珀黄,像车灯发光) */} |
|
|
|
|
|
<circle className="mascot-light" cx="26" cy="66" r="3" /> |
|
|
|
|
|
{/* 风挡(车窗,圆角更柔)——浅色玻璃 + 细描边。 |
|
|
|
|
|
原为 #1A1A1A 实心黑窗,横在眼睛上会被看成"戴黑眼罩",故改浅玻璃。 */} |
|
|
|
|
|
<rect |
|
|
|
|
|
className="mascot-glass" |
|
|
|
|
|
x="34" |
|
|
|
|
|
y="45" |
|
|
|
|
|
width="52" |
|
|
|
|
|
height="22" |
|
|
|
|
|
rx="11" |
|
|
|
|
|
stroke="#1A1A1A" |
|
|
|
|
|
strokeWidth="1.6" |
|
|
|
|
|
/> |
|
|
/> |
|
|
{/* 眼珠(深色,直接落在浅玻璃上;不再需要眼白托底) */} |
|
|
|
|
|
<circle cx="47.5" cy="56.5" r="4.8" fill="#1A1A1A" /> |
|
|
|
|
|
<circle cx="73.5" cy="56.5" r="4.8" fill="#1A1A1A" /> |
|
|
|
|
|
{/* 高光(左上小白点,让眼睛有神) */} |
|
|
|
|
|
<circle cx="45.8" cy="54.6" r="1.7" fill="#FFFFFF" /> |
|
|
|
|
|
<circle cx="71.8" cy="54.6" r="1.7" fill="#FFFFFF" /> |
|
|
|
|
|
{/* 微笑 */} |
|
|
|
|
|
<path d="M42 70 Q60 80 78 70" fill="none" stroke="#1A1A1A" strokeWidth="3" strokeLinecap="round" /> |
|
|
|
|
|
{/* 车轮 */} |
|
|
|
|
|
<circle cx="40" cy="90" r="9" fill="#1A1A1A" /> |
|
|
|
|
|
<circle cx="80" cy="90" r="9" fill="#1A1A1A" /> |
|
|
|
|
|
<circle cx="40" cy="90" r="3.2" fill="var(--mascot-accent, #C8C6E0)" /> |
|
|
|
|
|
<circle cx="80" cy="90" r="3.2" fill="var(--mascot-accent, #C8C6E0)" /> |
|
|
|
|
|
|
|
|
|
|
|
{/* 左手:始终自然垂在车身左侧(不参与挥手动画) */} |
|
|
|
|
|
<g className="mascot-arm-left"> |
|
|
|
|
|
<line x1="22" y1="72" x2="12" y2="84" stroke="#1A1A1A" strokeWidth="4" strokeLinecap="round" /> |
|
|
|
|
|
<circle className="mascot-body" cx="10" cy="86" r="5" stroke="#1A1A1A" strokeWidth="2.5" /> |
|
|
|
|
|
</g> |
|
|
|
|
|
|
|
|
|
|
|
{/* 右手:默认垂下,wave/squeeze 时举起挥手 */} |
|
|
|
|
|
{(displayMood === 'wave' || displayMood === 'squeeze') ? ( |
|
|
|
|
|
<g className="mascot-arm"> |
|
|
|
|
|
<line x1="98" y1="58" x2="112" y2="38" stroke="#1A1A1A" strokeWidth="4" strokeLinecap="round" /> |
|
|
|
|
|
<circle className="mascot-body" cx="113" cy="36" r="6" stroke="#1A1A1A" strokeWidth="2.5" /> |
|
|
|
|
|
</g> |
|
|
|
|
|
) : ( |
|
|
|
|
|
<g className="mascot-arm-right"> |
|
|
|
|
|
<line x1="98" y1="72" x2="108" y2="84" stroke="#1A1A1A" strokeWidth="4" strokeLinecap="round" /> |
|
|
|
|
|
<circle className="mascot-body" cx="110" cy="86" r="5" stroke="#1A1A1A" strokeWidth="2.5" /> |
|
|
|
|
|
</g> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{/* 思考:气泡点 */} |
|
|
|
|
|
{(displayMood === 'think' || displayMood === 'squeeze') && ( |
|
|
|
|
|
<g className="mascot-dots"> |
|
|
|
|
|
<circle className="mascot-accent" cx="58" cy="20" r="4" /> |
|
|
|
|
|
<circle className="mascot-accent" cx="68" cy="14" r="3" /> |
|
|
|
|
|
<circle className="mascot-accent" cx="77" cy="9" r="2.5" /> |
|
|
|
|
|
</g> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{/* 欢呼:小星星(暖琥珀黄,光感闪耀) */} |
|
|
|
|
|
{(displayMood === 'cheer' || displayMood === 'squeeze') && ( |
|
|
|
|
|
<g className="mascot-sparks"> |
|
|
|
|
|
<path |
|
|
|
|
|
className="mascot-star" |
|
|
|
|
|
d="M28 13 L29.75 18.25 L35 20 L29.75 21.75 L28 27 L26.25 21.75 L21 20 L26.25 18.25 Z" |
|
|
|
|
|
/> |
|
|
|
|
|
<path |
|
|
|
|
|
className="mascot-star" |
|
|
|
|
|
d="M94 10 L95.5 14.5 L100 16 L95.5 17.5 L94 22 L92.5 17.5 L88 16 L92.5 14.5 Z" |
|
|
|
|
|
/> |
|
|
|
|
|
</g> |
|
|
|
|
|
)} |
|
|
|
|
|
</svg> |
|
|
|
|
|
</button> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
); |
|
|
|