fix(onboarding): 修复引导页二次进入的问题,优化看板监控中心

引导页竞态修复 (走完向导后又被弹回第一页):
- Onboarding.tsx: complete mutation onSuccess 用 setQueryData 同步写入
  onboarding_completed 缓存, 避免跳转时守卫读到旧值 false 误重定向
- router.tsx: 守卫条件由 isLoading 升级为 isLoading || isFetching,
  settings 后台重取期间显示加载态, 兜底根治同类竞态

引导页内容丰富 + 样式美化:
- 补全 Step 3 完成页 (原 STEPS 有 4 项但只渲染 3 步), 4 步闭环
- 欢迎页特性卡片扩展到 6 个 (新增实时监控/本地优先)
- 背景加品牌紫+主色蓝双光晕渐变与网格底纹
- 进度条改胶囊式, 卡片毛玻璃 hover 上浮, 完成页 check 光晕脉冲

看板监控中心优化 (Dashboard.tsx MonitorWidget):
- 修复涨跌幅双重加号 (++10.01%) — fmtPct 已含正号, 删除手动拼接
- 新增命中信号 chips 展示 (cnSignal 转中文, 与监控中心一致)
- 代码/名称改为可点击, 弹出 StockPreviewDialog 日K预览
- 代码后加创业板/科创板/北交所板块标识 (boardTag)

附带: Layout.tsx 指数卡片 0.0% 显示白色 (原被 >=0 归入红色)
This commit is contained in:
shy3130
2026-06-22 13:13:00 +08:00
parent 56e9dc66a2
commit 7d41908e68
4 changed files with 284 additions and 87 deletions
+3 -1
View File
@@ -81,7 +81,9 @@ function fmtIndexPct(v: number | null | undefined) {
function indexPctClass(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
return Number(v) >= 0 ? 'text-bull' : 'text-bear'
const n = Number(v)
if (n === 0) return 'text-foreground'
return n > 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
+82 -40
View File
@@ -9,7 +9,10 @@ import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
function n(v: number | null | undefined) {
return typeof v === 'number' && Number.isFinite(v) ? v : null
@@ -89,6 +92,7 @@ const _SEVERITY_BAR: Record<string, string> = {
}
function MonitorWidget() {
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const alerts = useQuery({
queryKey: ['alerts', ''],
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
@@ -104,48 +108,86 @@ function MonitorWidget() {
}
return (
<div className="mt-1 space-y-1.5">
{events.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 */}
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] font-medium text-foreground/80 shrink-0">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
{ev.name && <span className="text-[10px] text-secondary truncate flex-1">{ev.name}</span>}
{ev.price != null && (
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
{pct >= 0 ? '+' : ''}{fmtPct(pct)}
<>
<div className="mt-1 space-y-1.5">
{events.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
<div className="flex items-center gap-1.5">
<button
onClick={() => ev.symbol && setPreviewEv(ev)}
title={ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
className="inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 cursor-pointer"
>
<span className="font-mono text-[10px] font-medium text-foreground/80 hover:text-accent">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return board && (
<span className={`inline-flex items-center justify-center h-3 w-3 rounded text-[7px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)
})()}
{ev.name && <span className="text-[10px] text-secondary truncate max-w-[5rem] hover:text-foreground">{ev.name}</span>}
</button>
<span className="flex-1" />
{ev.price != null && (
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(pct)}
</span>
)}
</div>
{/* 第二行: 分类标签 + 触发消息 + 时间 */}
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
{_SOURCE_LABEL[ev.source] ?? ev.source}
</span>
{ev.message && (
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
)}
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
{/* 第三行: 命中信号 (买入/卖出触发器) */}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{ev.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</div>
{/* 第二行: 分类标签 + 触发消息 + 时间 */}
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
{_SOURCE_LABEL[ev.source] ?? ev.source}
</span>
{ev.message && (
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
)}
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
</motion.div>
)
})}
</div>
</motion.div>
)
})}
</div>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</>
)
}
+196 -44
View File
@@ -19,6 +19,9 @@ import {
ScanSearch,
Flame,
Zap,
Radar,
ShieldCheck,
BellRing,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
@@ -27,15 +30,19 @@ import { CAP_LABELS } from '@/lib/capability-labels'
import { Logo } from '@/components/Logo'
// ===== 引导页:4 步向导 =====
// 1. 欢迎 2. 输入 Key(可跳过) 3. 能力探测结果 4. 完成 → 写标记 → 进面板
// 0. 欢迎 1. 输入 Key(可跳过) 2. 能力探测结果 3. 完成 → 写标记 → 进面板
const STEPS = ['欢迎', '配置 Key', '能力探测', '完成'] as const
const BRAND = '#8B5CF6'
const HIGHLIGHTS = [
{ icon: LineChart, title: '看板与自选', desc: '实时行情、MA/MACD 指标、自定义自选列表' },
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中' },
{ icon: Flame, title: '连板梯队', desc: '涨停板梯队、概念行业热度、市场情绪一览' },
{ icon: Zap, title: '回测验证', desc: '策略历史回测、因子分析,用数据说话' },
{ icon: LineChart, title: '看板与自选', desc: '实时行情、MA/MACD 指标、自定义自选列表', tint: 'text-accent' },
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中', tint: 'text-bull' },
{ icon: Flame, title: '连板梯队', desc: '涨停板梯队、概念行业热度、市场情绪一览', tint: 'text-warning' },
{ icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,触发即推送告警', tint: 'text-bear' },
{ icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据说话', tint: 'text-accent' },
{ icon: BellRing, title: '本地优先', desc: '数据本地存储,隐私可控,断网仍可查阅', tint: 'text-bull' },
]
export function Onboarding() {
@@ -47,7 +54,12 @@ export function Onboarding() {
// 完成向导 —— 写后端标记,使守卫放行
const complete = useMutation({
mutationFn: api.completeOnboarding,
onSuccess: () => {
onSuccess: (data) => {
// 用接口返回值同步更新缓存,确保跳转时守卫立即看到 onboarding_completed: true
// (避免 invalidate 后台重取未返回时, 守卫用旧缓存 false 误重定向回引导页)
qc.setQueryData(QK.settings, (old: any) =>
old ? { ...old, onboarding_completed: data.onboarding_completed } : old,
)
qc.invalidateQueries({ queryKey: QK.settings })
navigate('/', { replace: true })
},
@@ -60,34 +72,68 @@ export function Onboarding() {
const finish = () => complete.mutate()
return (
<div className="min-h-screen bg-base flex flex-col">
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
{/* 背景光晕 —— 品牌 + 主色渐变 */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
{/* 极淡网格底纹 */}
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
</div>
{/* 顶栏: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">TickFlow Stock Panel</span>
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<Logo
size={24}
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">TickFlow Stock 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'
}`}
{i > 0 && <div className="h-px w-3 bg-border" />}
<motion.div
animate={{
width: i === step ? 64 : 24,
backgroundColor: i === step
? 'hsl(var(--accent))'
: i < step
? 'hsl(var(--accent) / 0.6)'
: 'hsl(var(--border))',
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="h-1.5 rounded-full"
/>
</div>
))}
</div>
<div className="w-[88px] text-right">
<span className="text-xs text-muted">
<span className="text-xs text-muted tabular">
{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">
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
<div className="w-full max-w-xl">
<AnimatePresence mode="wait">
<motion.div
key={step}
@@ -100,7 +146,8 @@ export function Onboarding() {
{step === 1 && (
<KeyStep onNext={() => setStep(2)} onSkip={() => setStep(2)} onBack={() => setStep(0)} />
)}
{step === 2 && <ResultStep onNext={finish} onBack={() => setStep(1)} />}
{step === 2 && <ResultStep onNext={() => setStep(3)} onBack={() => setStep(1)} />}
{step === 3 && <FinishStep onNext={finish} onBack={() => setStep(2)} pending={complete.isPending} />}
</motion.div>
</AnimatePresence>
</div>
@@ -114,36 +161,54 @@ export function Onboarding() {
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">使 TickFlow Stock Panel</h1>
{/* 品牌 badge */}
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit rounded-2xl p-4 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<Sparkles className="h-8 w-8" style={{ color: BRAND }} />
</motion.div>
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
使 TickFlow Stock Panel
</h1>
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
A
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" />
{/* 6 个特性卡片 */}
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-3 text-left">
{HIGHLIGHTS.map((h, i) => (
<motion.div
key={h.title}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05 * i + 0.1 }}
whileHover={{ y: -2 }}
className="group rounded-card border border-border bg-surface/80 backdrop-blur-sm p-3.5 transition-colors hover:border-accent/30"
>
<h.icon className={`h-5 w-5 ${h.tint} transition-transform group-hover:scale-110`} />
<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>
</motion.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"
className="inline-flex items-center gap-2 px-6 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 transition-all"
>
<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"
className="px-4 h-11 rounded-xl text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
@@ -178,20 +243,25 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
return (
<div>
<h2 className="text-xl font-bold text-foreground"> TickFlow API Key</h2>
<p className="mt-2 text-sm text-secondary leading-relaxed">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-accent/10 p-2">
<ShieldCheck className="h-4 w-4 text-accent" />
</div>
<h2 className="text-xl font-bold text-foreground"> TickFlow API Key</h2>
</div>
<p className="mt-2.5 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">
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm 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"
className="text-accent hover:underline inline-flex items-baseline gap-0.5 font-medium"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
@@ -245,12 +315,12 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
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"
className="w-full px-3 py-2.5 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
<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"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors"
tabIndex={-1}
aria-label={revealing ? '隐藏' : '显示'}
>
@@ -319,17 +389,22 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
return (
<div>
<h2 className="text-xl font-bold text-foreground"></h2>
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-accent/10 p-2">
<ScanSearch className="h-4 w-4 text-accent" />
</div>
<h2 className="text-xl font-bold text-foreground"></h2>
</div>
{hasKey ? (
<>
<p className="mt-2 text-sm text-secondary leading-relaxed">
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key ,
<span className="text-foreground"> </span>
<span className="text-foreground font-medium"> </span>
Key
</p>
<div className="mt-5 rounded-card border border-border bg-surface p-5">
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm 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">
@@ -363,14 +438,14 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
</div>
</>
) : (
<div className="mt-5 rounded-card border border-border bg-surface p-6 text-center">
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm 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>
<span className="text-foreground font-medium"> </span>
Key
</p>
</div>
@@ -389,10 +464,87 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
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>
)
}
// ===== Step 3: 完成 =====
function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: () => void; pending: boolean }) {
const tips = [
{ icon: ScanSearch, text: '在「选股」页用内置策略一键扫描全市场' },
{ icon: BellRing, text: '在「监控」页设置条件或策略告警,盘中实时推送' },
{ icon: ShieldCheck, text: '在「回测」页用历史数据验证策略表现' },
]
return (
<div className="text-center">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit"
>
<div
className="relative rounded-2xl p-5 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<CheckCircle2 className="h-12 w-12 text-bear" />
{/* 光晕脉冲 */}
<motion.div
animate={{ scale: [1, 1.4], opacity: [0.4, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'easeOut' }}
className="absolute inset-5 rounded-full bg-bear/30"
/>
</div>
</motion.div>
<h1 className="mt-6 text-2xl font-bold text-foreground">!</h1>
<p className="mt-2.5 text-sm text-secondary leading-relaxed max-w-md mx-auto">
,
<span className="text-foreground font-medium"> </span>
</p>
{/* 快速上手提示 */}
<div className="mt-6 space-y-2 text-left">
{tips.map((t, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 * i + 0.2 }}
className="flex items-center gap-3 rounded-card border border-border bg-surface/80 backdrop-blur-sm px-3.5 py-2.5"
>
<div className="rounded-lg bg-accent/10 p-1.5 shrink-0">
<t.icon className="h-3.5 w-3.5 text-accent" />
</div>
<span className="text-xs text-secondary">{t.text}</span>
</motion.div>
))}
</div>
{/* 底部操作 */}
<div className="mt-8 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-10 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
onClick={onNext}
disabled={pending}
className="inline-flex items-center gap-2 px-6 h-10 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{pending ? '正在进入…' : '进入面板'}
</button>
</div>
</div>
)
}
+3 -2
View File
@@ -27,8 +27,9 @@ import { Logo } from './components/Logo'
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const settings = useSettings()
// 加载中:显示极简全屏占位,避免闪屏
if (settings.isLoading) {
// 加载中 或 正在后台重新请求 → 显示占位,避免用旧缓存误判
// (根治 invalidate 触发重取未返回时, 守卫用旧 onboarding_completed=false 误重定向的竞态)
if (settings.isLoading || settings.isFetching) {
return (
<div className="min-h-screen bg-base grid place-items-center">
<div className="flex flex-col items-center gap-3 text-muted">