Files
shanghai-residences/src/components/hint/HintHost.tsx
T
zhongyidianandClaude Opus 4.7 49ecabbf3a fix: 修复开发/测试包模拟进度被易班同步覆盖的问题
进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖;
现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度,
确保测试包的"到达某故居/全流程完成"调试入口生效。

同时纳入近期未提交的页面、组件、资源与环境变量改动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:19:36 +08:00

144 lines
3.7 KiB
TypeScript

import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getBackgroundImage } from '@/lib/utils'
export type HintPositionOffset = {
/** 相对窗口中心点的水平偏移(px) */
x?: number
/** 相对窗口中心点的垂直偏移(px) */
y?: number
}
export interface ShowHintOptions {
/** 自动消失时间(ms)。默认 2000 */
durationMs?: number
/** 是否常驻(常驻时不会自动消失) */
persistent?: boolean
/** 相对中心点微调位置 */
offset?: HintPositionOffset
/** 自定义层级(默认 9999) */
zIndex?: number
}
type HintState = {
visible: boolean
text: string
options: Required<Pick<ShowHintOptions, 'durationMs' | 'persistent' | 'offset' | 'zIndex'>>
}
const DEFAULT_OPTIONS: HintState['options'] = {
durationMs: 2000,
persistent: false,
offset: { x: 0, y: 0 },
zIndex: 9999,
}
let current: HintState = {
visible: false,
text: '',
options: DEFAULT_OPTIONS,
}
let hideTimer: number | null = null
const listeners = new Set<(s: HintState) => void>()
function emit(next: HintState) {
current = next
listeners.forEach((l) => l(current))
}
function clearHideTimer() {
if (hideTimer) {
window.clearTimeout(hideTimer)
hideTimer = null
}
}
export function showHint(text: string, options?: ShowHintOptions) {
if (typeof window === 'undefined') return
clearHideTimer()
const mergedOptions: HintState['options'] = {
...DEFAULT_OPTIONS,
...(options ?? {}),
offset: {
...DEFAULT_OPTIONS.offset,
...(options?.offset ?? {}),
},
}
emit({
visible: true,
text,
options: mergedOptions,
})
if (!mergedOptions.persistent) {
hideTimer = window.setTimeout(() => {
hideHint()
}, mergedOptions.durationMs)
}
}
export function hideHint() {
if (typeof window === 'undefined') return
clearHideTimer()
emit({ ...current, visible: false })
}
export function HintHost() {
const [state, setState] = useState<HintState>(current)
useEffect(() => {
listeners.add(setState)
return () => {
listeners.delete(setState)
}
}, [])
const offsetX = state.options.offset.x ?? 0
const offsetY = state.options.offset.y ?? 0
return (
<div className="fixed inset-0 pointer-events-none" style={{ zIndex: state.options.zIndex }}>
<AnimatePresence>
{state.visible && state.text && (
<motion.div
key="hint"
className="absolute left-1/2"
style={{
top: `calc(50% + ${offsetY}px)`,
x: `calc(-50% + ${offsetX}px)`,
zIndex: state.options.zIndex,
}}
initial={{ opacity: 0, y: 8, filter: 'blur(2px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, y: 8, filter: 'blur(2px)' }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<div
className={cn(
'inline-flex items-center justify-center',
'px-30 py-2',
// 'bg-linear-to-r from-transparent from-1% via-[#C0945A] via-50% to-transparent to-99% text-white',
'whitespace-nowrap text-white',
'text-3xl font-semibold tracking-wide',
'select-none'
)}
style={{
backgroundImage: getBackgroundImage('components/提示条'),
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
{state.text}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}