进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
3.7 KiB
TypeScript
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>
|
|
)
|
|
}
|