码桶
发现社区成员的开源项目
OnboardingTour.jsx8.3 KB
import React, { useState, useEffect, useCallback } from 'react';
import { X, ChevronRight } from 'lucide-react';
const ONBOARDING_KEY = 'ONBOARDING_DONE';
const OnboardingTour = ({ t }) => {
const [active, setActive] = useState(false);
const [step, setStep] = useState(0);
const [targetRect, setTargetRect] = useState(null);
const steps = [
{ target: null, title: t('onboardingWelcomeTitle'), desc: t('onboardingWelcomeDesc') },
{ target: '.zone-selector-area, [aria-label="' + t('switchZone') + '"]', title: t('onboardingZoneTitle'), desc: t('onboardingZoneDesc') },
{ target: '.table-container, .data-table', title: t('onboardingDnsTitle'), desc: t('onboardingDnsDesc') },
{ target: '[title="' + t('templates') + '"], .btn-text', title: t('onboardingTemplatesTitle'), desc: t('onboardingTemplatesDesc'), fallbackMatch: 'templates' },
{ target: '.search-container, [placeholder="' + t('searchPlaceholder') + '"]', title: t('onboardingSearchTitle'), desc: t('onboardingSearchDesc') },
{ target: null, title: t('onboardingDoneTitle'), desc: t('onboardingDoneDesc') },
];
useEffect(() => {
if (!localStorage.getItem(ONBOARDING_KEY)) {
const timer = setTimeout(() => setActive(true), 1000);
return () => clearTimeout(timer);
}
}, []);
const updateTargetRect = useCallback(() => {
const currentStep = steps[step];
if (!currentStep || !currentStep.target) {
setTargetRect(null);
return;
}
const selectors = currentStep.target.split(',').map(s => s.trim());
let el = null;
for (const sel of selectors) {
try { el = document.querySelector(sel); } catch (e) { /* ignore */ }
if (el) break;
}
if (el) {
const rect = el.getBoundingClientRect();
setTargetRect({ top: rect.top, left: rect.left, width: rect.width, height: rect.height });
} else {
setTargetRect(null);
}
}, [step]);
useEffect(() => {
if (!active) return;
updateTargetRect();
const interval = setInterval(updateTargetRect, 500);
return () => clearInterval(interval);
}, [active, step, updateTargetRect]);
const finish = () => {
setActive(false);
localStorage.setItem(ONBOARDING_KEY, 'true');
};
const next = () => {
if (step >= steps.length - 1) {
finish();
} else {
setStep(step + 1);
}
};
const skip = () => {
finish();
};
if (!active) return null;
const currentStep = steps[step];
const isFirst = step === 0;
const isLast = step === steps.length - 1;
// Calculate tooltip position
let tooltipStyle = {
position: 'fixed',
zIndex: 10002,
background: 'var(--card-bg, #fff)',
border: '1px solid var(--border)',
borderRadius: '12px',
padding: '1.25rem',
maxWidth: '320px',
width: '90vw',
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
};
if (targetRect && targetRect.width > 0) {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const centerX = targetRect.left + targetRect.width / 2;
const below = targetRect.top + targetRect.height + 12;
const above = targetRect.top - 12;
if (below + 180 < viewportHeight) {
tooltipStyle.top = below + 'px';
} else {
tooltipStyle.bottom = (viewportHeight - above) + 'px';
}
tooltipStyle.left = Math.max(16, Math.min(centerX - 160, viewportWidth - 336)) + 'px';
} else {
tooltipStyle.top = '50%';
tooltipStyle.left = '50%';
tooltipStyle.transform = 'translate(-50%, -50%)';
}
return (
<>
{/* Overlay */}
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.4)',
zIndex: 10000,
}}
onClick={skip}
/>
{/* Highlight box with pulsing border */}
{targetRect && targetRect.width > 0 && (
<div
style={{
position: 'fixed',
top: targetRect.top - 4,
left: targetRect.left - 4,
width: targetRect.width + 8,
height: targetRect.height + 8,
border: '2px solid var(--primary)',
borderRadius: '8px',
zIndex: 10001,
pointerEvents: 'none',
boxShadow: '0 0 0 4000px rgba(0, 0, 0, 0.4)',
animation: 'onboarding-pulse 1.5s ease-in-out infinite',
background: 'transparent',
}}
/>
)}
{/* Tooltip */}
<div style={tooltipStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 700, color: 'var(--text)' }}>
{currentStep.title}
</h4>
<button
onClick={skip}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
padding: '2px', display: 'flex', flexShrink: 0,
color: 'var(--text-muted)'
}}
aria-label="Close tour"
>
<X size={16} />
</button>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', lineHeight: 1.5, margin: '0 0 1rem 0' }}>
{currentStep.desc}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>
{step + 1} / {steps.length}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{!isLast && (
<button
onClick={skip}
style={{
border: 'none', background: 'transparent',
cursor: 'pointer', fontSize: '0.8rem',
color: 'var(--text-muted)', padding: '6px 12px',
borderRadius: '6px',
}}
>
{t('onboardingSkip')}
</button>
)}
<button
onClick={next}
style={{
border: 'none',
background: 'var(--primary)',
color: '#fff',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
padding: '6px 16px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
{isLast ? t('onboardingFinish') : t('onboardingNext')}
{!isLast && <ChevronRight size={14} />}
</button>
</div>
</div>
</div>
<style>{`
@keyframes onboarding-pulse {
0%, 100% { border-color: var(--primary); box-shadow: 0 0 0 4000px rgba(0,0,0,0.4), 0 0 0 0 rgba(243,128,32,0.4); }
50% { border-color: var(--primary); box-shadow: 0 0 0 4000px rgba(0,0,0,0.4), 0 0 0 8px rgba(243,128,32,0.15); }
}
`}</style>
</>
);
};
export default OnboardingTour;