feat: 首次使用引导页增强

This commit is contained in:
shy3130
2026-06-19 20:11:03 +08:00
parent d89b9068a7
commit ef6801ef8e
2 changed files with 425 additions and 9 deletions
+392 -8
View File
@@ -1,14 +1,398 @@
import { Sparkles } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Eye,
EyeOff,
Loader2,
Save,
Check,
CheckCircle2,
AlertCircle,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
Sparkles,
LineChart,
ScanSearch,
Flame,
Zap,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys'
import { CAP_LABELS } from '@/lib/capability-labels'
import { Logo } from '@/components/Logo'
// ===== 引导页:4 步向导 =====
// 1. 欢迎 2. 输入 Key(可跳过) 3. 能力探测结果 4. 完成 → 写标记 → 进面板
const STEPS = ['欢迎', '配置 Key', '能力探测', '完成'] as const
const HIGHLIGHTS = [
{ icon: LineChart, title: '看板与自选', desc: '实时行情、MA/MACD 指标、自定义自选列表' },
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中' },
{ icon: Flame, title: '连板梯队', desc: '涨停板梯队、概念行业热度、市场情绪一览' },
{ icon: Zap, title: '回测验证', desc: '策略历史回测、因子分析,用数据说话' },
]
export function Onboarding() {
const navigate = useNavigate()
const qc = useQueryClient()
const [step, setStep] = useState(0)
// 完成向导 —— 写后端标记,使守卫放行
const complete = useMutation({
mutationFn: api.completeOnboarding,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.settings })
navigate('/', { replace: true })
},
onError: () => {
// 标记失败不应阻塞用户进入面板,仍放行
navigate('/', { replace: true })
},
})
const finish = () => complete.mutate()
return (
<div className="min-h-screen bg-base">
<EmptyState
icon={Sparkles}
title="欢迎使用 TF-Stocks-Panel"
hint="首次运行向导将在 Phase 1 接入(填 TickFlow Key → 能力探测 → 试用面板)。当前请直接进入面板。"
/>
<div className="min-h-screen bg-base flex flex-col">
{/* 顶栏:logo + 进度指示 */}
<header className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2 text-foreground">
<Logo size={22} />
<span className="text-sm font-medium">TF-Stocks-Panel</span>
</div>
<div className="flex items-center gap-1.5">
{STEPS.map((label, i) => (
<div key={label} className="flex items-center gap-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-300 ${
i === step ? 'w-6 bg-accent' : i < step ? 'w-1.5 bg-accent' : 'w-1.5 bg-border'
}`}
/>
</div>
))}
</div>
<div className="w-[88px] text-right">
<span className="text-xs text-muted">
{step + 1} / {STEPS.length}
</span>
</div>
</header>
{/* 步骤内容 */}
<main className="flex-1 flex items-center justify-center px-6 py-10">
<div className="w-full max-w-lg">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
>
{step === 0 && <WelcomeStep onNext={() => setStep(1)} onSkip={finish} />}
{step === 1 && (
<KeyStep onNext={() => setStep(2)} onSkip={() => setStep(2)} onBack={() => setStep(0)} />
)}
{step === 2 && <ResultStep onNext={finish} onBack={() => setStep(1)} />}
</motion.div>
</AnimatePresence>
</div>
</main>
</div>
)
}
// ===== Step 0: 欢迎 =====
function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
return (
<div className="text-center">
<div className="mx-auto w-fit rounded-2xl bg-accent/10 p-4">
<Sparkles className="h-8 w-8 text-accent" />
</div>
<h1 className="mt-6 text-2xl font-bold text-foreground">使 TF-Stocks-Panel</h1>
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
A
,使
</p>
<div className="mt-8 grid grid-cols-2 gap-3 text-left">
{HIGHLIGHTS.map((h) => (
<div key={h.title} className="rounded-card border border-border bg-surface p-4">
<h.icon className="h-5 w-5 text-accent" />
<div className="mt-2 text-sm font-medium text-foreground">{h.title}</div>
<div className="mt-1 text-xs text-muted leading-relaxed">{h.desc}</div>
</div>
))}
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<button
onClick={onNext}
className="inline-flex items-center gap-2 px-5 h-10 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 transition-colors"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
onClick={onSkip}
className="px-4 h-10 rounded-xl text-sm text-secondary hover:text-foreground transition-colors"
>
</button>
</div>
</div>
)
}
// ===== Step 1: 输入 TickFlow Key =====
function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () => void; onBack: () => void }) {
const qc = useQueryClient()
const settings = useSettings()
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [copiedCode, setCopiedCode] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: () => {
setSaved(true)
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
// 保存成功后自动进入下一步看探测结果
setTimeout(() => onNext(), 600)
},
})
const alreadyHasKey = settings.data?.has_tickflow_key
return (
<div>
<h2 className="text-xl font-bold text-foreground"> TickFlow API Key</h2>
<p className="mt-2 text-sm text-secondary leading-relaxed">
Key 使 Key <span className="font-medium text-foreground">Free</span> ;
</p>
{/* 注册引导 */}
<div className="mt-5 rounded-card border border-border bg-surface p-4 text-xs text-secondary leading-relaxed">
Key?{' '}
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
,{' '}
<span className="font-mono font-semibold text-accent inline-flex items-baseline gap-1">
V3KDKGXPEA
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText('V3KDKGXPEA').then(() => {
setCopiedCode(true)
setTimeout(() => setCopiedCode(false), 1500)
})
}}
className="text-muted hover:text-accent transition-colors self-center"
aria-label="复制邀请码"
tabIndex={-1}
>
{copiedCode ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</span>
,
</div>
{/* Key 已配置提示 */}
{alreadyHasKey && !save.isPending && (
<div className="mt-4 flex items-start gap-2 rounded-btn border border-bear/30 bg-bear/10 px-3 py-2.5 text-xs text-bear">
<CheckCircle2 className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
Key(<span className="font-mono">{settings.data?.tickflow_api_key_masked}</span>)
, Key
</span>
</div>
)}
{/* 输入 */}
<form
onSubmit={(e) => {
e.preventDefault()
if (keyInput.trim()) save.mutate()
}}
className="mt-4 space-y-2"
>
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={alreadyHasKey ? '粘贴新 Key 替换当前' : '粘贴 TickFlow API Key'}
value={keyInput}
onChange={(e) => {
setKeyInput(e.target.value)
if (saved) setSaved(false)
}}
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors"
/>
<button
type="button"
onClick={() => setRevealing((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors"
tabIndex={-1}
aria-label={revealing ? '隐藏' : '显示'}
>
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* 保存中提示 */}
{save.isPending && (
<div className="flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span> Key ,</span>
</div>
)}
{save.isError && (
<div className="text-xs text-danger">:{String((save.error as any).message)}</div>
)}
</form>
{/* 底部操作 */}
<div className="mt-6 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<button
onClick={onSkip}
disabled={save.isPending}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors disabled:opacity-50"
>
{alreadyHasKey ? '下一步' : '暂不配置'}
</button>
<button
onClick={() => keyInput.trim() && save.mutate()}
disabled={save.isPending || !keyInput.trim()}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 disabled:opacity-40 transition-all"
>
{save.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : saved ? (
<Check className="h-4 w-4" />
) : (
<Save className="h-4 w-4" />
)}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存并检测'}
</button>
</div>
</div>
</div>
)
}
// ===== Step 2: 能力探测结果 =====
function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void }) {
const settings = useSettings()
const caps = useCapabilities()
const hasKey = settings.data?.has_tickflow_key
const capList = caps.data ? Object.entries(caps.data.capabilities) : []
return (
<div>
<h2 className="text-xl font-bold text-foreground"></h2>
{hasKey ? (
<>
<p className="mt-2 text-sm text-secondary leading-relaxed">
Key ,
<span className="text-foreground"> </span>
Key
</p>
<div className="mt-5 rounded-card border border-border bg-surface p-5">
<div className="flex items-baseline justify-between">
<span className="text-[10px] uppercase tracking-widest text-muted"></span>
<span className="font-mono text-2xl font-bold tracking-tight text-foreground">
{caps.data?.label ?? settings.data?.tier_label ?? '—'}
</span>
</div>
{caps.isLoading ? (
<div className="mt-4 flex items-center gap-2 text-xs text-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : capList.length > 0 ? (
<div className="mt-4 grid grid-cols-1 gap-1.5">
{capList.slice(0, 8).map(([cap]) => {
const meta = CAP_LABELS[cap]
return (
<div key={cap} className="flex items-center gap-2 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 text-bear shrink-0" />
<span className="text-foreground">{meta?.name ?? cap}</span>
</div>
)
})}
{capList.length > 8 && (
<div className="text-[11px] text-muted pl-5"> {capList.length} </div>
)}
</div>
) : (
<div className="mt-4 text-xs text-muted"></div>
)}
</div>
</>
) : (
<div className="mt-5 rounded-card border border-border bg-surface p-6 text-center">
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
<Zap className="h-6 w-6 text-warning" />
</div>
<div className="mt-3 text-sm font-medium text-foreground"> Free </div>
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
,
<span className="text-foreground"> </span>
Key
</p>
</div>
)}
{/* 底部操作 */}
<div className="mt-6 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
onClick={onNext}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 transition-colors"
>
<ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
)
}
+33 -1
View File
@@ -18,12 +18,44 @@ import { Branding } from './pages/Branding'
import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices'
import { MinuteDataProbe } from './pages/MinuteDataProbe'
import { useSettings } from './lib/useSharedQueries'
import { Logo } from './components/Logo'
// 首次使用守卫 —— 未完成向导则重定向到 /onboarding
// 只挂在根路由上;/onboarding 本身不被守卫,避免循环重定向。
// settings 由 Layout 预取,守卫判定不产生额外请求。
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const settings = useSettings()
// 加载中:显示极简全屏占位,避免闪屏
if (settings.isLoading) {
return (
<div className="min-h-screen bg-base grid place-items-center">
<div className="flex flex-col items-center gap-3 text-muted">
<Logo size={28} className="text-foreground" />
<div className="text-xs"></div>
</div>
</div>
)
}
// 查询出错或字段缺失时不拦截 —— 宁可放行,也不把用户卡在空白页
if (settings.data && settings.data.onboarding_completed === false) {
return <Navigate to="/onboarding" replace />
}
return <>{children}</>
}
export const router = createBrowserRouter([
{ path: '/onboarding', element: <Onboarding /> },
{
path: '/',
element: <Layout />,
element: (
<OnboardingGuard>
<Layout />
</OnboardingGuard>
),
children: [
{ index: true, element: <Dashboard /> },
{ path: 'overview', element: <Navigate to="/" replace /> },