v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨
工单 #8 编年史上限+分页: - engine.ts: slice(-50)→slice(-200) - ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示 工单 #5 限时挑战 (subagent 10-a): - beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge - BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态 - gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action 工单 #3 星潮类型深化 (subagent 10-a): - starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia) - TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult - decode.ts: generatePuzzle 加 targetLenBonus 参数 - achievements: ach_tides_all 阈值 6→9 工单 #4/P2 云排行榜 (subagent 10-b): - mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条) - API: GET/POST /api/leaderboard + /stats + CORS + 防刷 - beacon.ts: fetchCloudLeaderboard/submitCloudScore - BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮 工单 #9 手写叙事 (subagent 10-c): - chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段) - buildLore 优先手写节点, fallback 模板, 11个变量替换 P3 socket 多人星潮 (subagent 10-c): - mini-services/star-tide-service/ (端口3031, socket.io) - 每10-15min广播global-tide, 60s持续, 6种类型权重 - useGlobalTide hook + triggerGlobalTide action - StarTideIndicator 加 🌐 全球星潮标记 P2 UI打磨: - page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文 QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
This commit is contained in:
@@ -0,0 +1,754 @@
|
||||
1→"use client";
|
||||
2→// 回响星核 / Echo Nexus — 深空信标面板
|
||||
3→// v0.5:每日挑战 + 本地排行榜
|
||||
4→// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
5→import { useState, useEffect, useCallback } from "react";
|
||||
6→import { useGameStore } from "@/store/gameStore";
|
||||
7→import { useToast } from "@/hooks/use-toast";
|
||||
8→import { sfx } from "@/hooks/useAudio";
|
||||
9→import {
|
||||
10→ generateDailyChallenge,
|
||||
11→ generateWeeklyChallenge,
|
||||
12→ loadDailyProgress,
|
||||
13→ loadWeeklyProgress,
|
||||
14→ loadLeaderboard,
|
||||
15→ loadChainState,
|
||||
16→ claimBeaconReward,
|
||||
17→ BEACON_DIFFICULTY,
|
||||
18→ BEACON_TYPE_META,
|
||||
19→ BEACON_CHAIN_MILESTONES,
|
||||
20→ BEACON_CHAIN_REWARDS,
|
||||
21→ getWeekKey,
|
||||
22→ getTodayKey,
|
||||
23→ getNextMilestone,
|
||||
24→ getChainProgress,
|
||||
25→ msUntilNextDay,
|
||||
26→ msUntilNextWeek,
|
||||
27→ formatCountdown,
|
||||
28→ type BeaconDailyChallenge,
|
||||
29→ type BeaconDailyProgress,
|
||||
30→ type BeaconWeeklyChallenge,
|
||||
31→ type BeaconWeeklyProgress,
|
||||
32→ type BeaconScoreEntry,
|
||||
33→ type BeaconChallengeType,
|
||||
34→ type BeaconDifficulty,
|
||||
35→ type BeaconChainState,
|
||||
36→} from "@/lib/game/beacon";
|
||||
37→import { formatNum } from "@/lib/game/config";
|
||||
38→import { Button } from "@/components/ui/button";
|
||||
39→import { Progress } from "@/components/ui/progress";
|
||||
40→import {
|
||||
41→ Radio,
|
||||
42→ Clock,
|
||||
43→ Trophy,
|
||||
44→ Sparkles,
|
||||
45→ Award,
|
||||
46→ Crown,
|
||||
47→ Medal,
|
||||
48→ Link2,
|
||||
49→ Flame,
|
||||
50→ Zap,
|
||||
51→} from "lucide-react";
|
||||
52→
|
||||
53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
54→
|
||||
55→function rankBadge(rank: number) {
|
||||
56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
|
||||
57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
|
||||
58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
|
||||
59→ return null;
|
||||
60→}
|
||||
61→
|
||||
62→export function BeaconPanel() {
|
||||
63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
66→ const { toast } = useToast();
|
||||
67→
|
||||
68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
70→ const [weeklyChallenge, setWeeklyChallenge] =
|
||||
71→ useState<BeaconWeeklyChallenge | null>(null);
|
||||
72→ const [weeklyProgress, setWeeklyProgress] =
|
||||
73→ useState<BeaconWeeklyProgress | null>(null);
|
||||
74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
78→ const [now, setNow] = useState(Date.now());
|
||||
79→
|
||||
80→ // 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
81→ useEffect(() => {
|
||||
82→ setChallenge(generateDailyChallenge());
|
||||
83→ setProgress(loadDailyProgress());
|
||||
84→ setWeeklyChallenge(generateWeeklyChallenge());
|
||||
85→ setWeeklyProgress(loadWeeklyProgress());
|
||||
86→ setChainState(loadChainState());
|
||||
87→ setLeaderboard(loadLeaderboard());
|
||||
88→ const id = setInterval(() => {
|
||||
89→ setNow(Date.now());
|
||||
90→ setProgress(loadDailyProgress());
|
||||
91→ setWeeklyProgress(loadWeeklyProgress());
|
||||
92→ setChainState(loadChainState());
|
||||
93→ setChallenge((c) => c ?? generateDailyChallenge());
|
||||
94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
95→ }, 1000);
|
||||
96→ return () => clearInterval(id);
|
||||
97→ }, []);
|
||||
98→
|
||||
99→ useEffect(() => {
|
||||
100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
102→ }, [now]);
|
||||
103→
|
||||
104→ const handleClaimDaily = useCallback(() => {
|
||||
105→ if (!challenge || !progress) return;
|
||||
106→ if (progress.completedAt === null || progress.claimed) return;
|
||||
107→ const res = claimBeaconReward(challenge, progress);
|
||||
108→ setLeaderboard(res.leaderboard);
|
||||
109→ setProgress(loadDailyProgress());
|
||||
110→ // 发放奖励到游戏状态
|
||||
111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
|
||||
112→ sfx("achievement");
|
||||
113→ toast({
|
||||
114→ title: "✦ 每日信标奖励已领取",
|
||||
115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
116→ 1
|
||||
117→ )} 接触 · 得分 ${res.score}`,
|
||||
118→ });
|
||||
119→ }, [challenge, progress, toast, grantBeaconReward]);
|
||||
120→
|
||||
121→ const handleClaimWeekly = useCallback(() => {
|
||||
122→ if (!weeklyChallenge || !weeklyProgress) return;
|
||||
123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
|
||||
124→ const res = claimWeeklyBeacon();
|
||||
125→ setLeaderboard(loadLeaderboard());
|
||||
126→ setWeeklyProgress(loadWeeklyProgress());
|
||||
127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
128→ sfx("achievement");
|
||||
129→ toast({
|
||||
130→ title: "✦ 周挑战奖励已领取",
|
||||
131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
132→ 1
|
||||
133→ )} 接触 · 得分 ${res.score}`,
|
||||
134→ });
|
||||
135→ }
|
||||
136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
137→
|
||||
138→ const handleClaimChain = useCallback(
|
||||
139→ (milestone: number) => {
|
||||
140→ const reward = BEACON_CHAIN_REWARDS.find(
|
||||
141→ (r) => r.milestone === milestone
|
||||
142→ );
|
||||
143→ const res = claimChainReward(milestone);
|
||||
144→ setChainState(loadChainState());
|
||||
145→ if (res.ok) {
|
||||
146→ sfx("achievement");
|
||||
147→ toast({
|
||||
148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
|
||||
149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
150→ 1
|
||||
151→ )} 接触`,
|
||||
152→ });
|
||||
153→ }
|
||||
154→ },
|
||||
155→ [toast, claimChainReward]
|
||||
156→ );
|
||||
157→
|
||||
158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
159→ return (
|
||||
160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
161→ 正在校准深空信标…
|
||||
162→ </div>
|
||||
163→ );
|
||||
164→ }
|
||||
165→
|
||||
166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
|
||||
167→ const typeMeta = BEACON_TYPE_META[challenge.type];
|
||||
168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
|
||||
169→ const isCompleted = progress.completedAt !== null;
|
||||
170→ const isClaimed = progress.claimed;
|
||||
171→ const canClaim = isCompleted && !isClaimed;
|
||||
172→
|
||||
173→ // 周挑战派生量
|
||||
174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
|
||||
177→ const wCompleted = weeklyProgress.completedAt !== null;
|
||||
178→ const wClaimed = weeklyProgress.claimed;
|
||||
179→ const wCanClaim = wCompleted && !wClaimed;
|
||||
180→
|
||||
181→ // 信标链派生量
|
||||
182→ const chainProgress = getChainProgress(chainState.currentStreak);
|
||||
183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
|
||||
184→ const todayKey = getTodayKey();
|
||||
185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
|
||||
186→
|
||||
187→ return (
|
||||
188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
189→ <style jsx global>{`
|
||||
190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
193→ @keyframes beacon-pulse-ring {
|
||||
194→ 0% { transform: scale(0.8); opacity: 0.8; }
|
||||
195→ 100% { transform: scale(2.2); opacity: 0; }
|
||||
196→ }
|
||||
197→ @keyframes beacon-glow {
|
||||
198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
|
||||
199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
|
||||
200→ }
|
||||
201→ @keyframes weekly-glow {
|
||||
202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
|
||||
203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
|
||||
204→ }
|
||||
205→ @keyframes chain-milestone-pulse {
|
||||
206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
|
||||
207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
|
||||
208→ }
|
||||
209→ .chain-milestone-reachable {
|
||||
210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
|
||||
211→ }
|
||||
212→ @keyframes chain-streak-flux {
|
||||
213→ 0%, 100% { background-position: 0% 50%; }
|
||||
214→ 50% { background-position: 100% 50%; }
|
||||
215→ }
|
||||
216→ .chain-streak-text {
|
||||
217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
|
||||
218→ background-size: 200% 100%;
|
||||
219→ -webkit-background-clip: text;
|
||||
220→ background-clip: text;
|
||||
221→ -webkit-text-fill-color: transparent;
|
||||
222→ animation: chain-streak-flux 4s ease-in-out infinite;
|
||||
223→ }
|
||||
224→ `}</style>
|
||||
225→
|
||||
226→ {/* 头部:信标 + 倒计时 */}
|
||||
227→ <div className="flex items-center justify-between">
|
||||
228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
229→ <Radio className="h-4 w-4 text-fuchsia-400" />
|
||||
230→ 深空信标
|
||||
231→ </h3>
|
||||
232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
233→ <Clock className="h-3 w-3" />
|
||||
234→ 次日重置
|
||||
235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
|
||||
236→ </div>
|
||||
237→ </div>
|
||||
238→
|
||||
239→ {/* 每日挑战卡片 */}
|
||||
240→ <div
|
||||
241→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
242→ style={{
|
||||
243→ borderColor: `${diffMeta.color}55`,
|
||||
244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
|
||||
245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
|
||||
246→ }}
|
||||
247→ >
|
||||
248→ {/* 背景装饰:脉冲环 */}
|
||||
249→ {!isCompleted && (
|
||||
250→ <>
|
||||
251→ <div
|
||||
252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
253→ style={{
|
||||
254→ border: `1.5px solid ${diffMeta.color}`,
|
||||
255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
|
||||
256→ }}
|
||||
257→ />
|
||||
258→ <div
|
||||
259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
260→ style={{
|
||||
261→ border: `1.5px solid ${diffMeta.color}`,
|
||||
262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
|
||||
263→ }}
|
||||
264→ />
|
||||
265→ </>
|
||||
266→ )}
|
||||
267→
|
||||
268→ <div className="relative">
|
||||
269→ {/* 难度 + 类型 标签 */}
|
||||
270→ <div className="flex items-center gap-1.5 mb-2">
|
||||
271→ <span
|
||||
272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
|
||||
274→ >
|
||||
275→ <span>{diffMeta.icon}</span>
|
||||
276→ {diffMeta.label}
|
||||
277→ </span>
|
||||
278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
279→ {typeMeta.icon} {typeMeta.label}
|
||||
280→ </span>
|
||||
281→ {isCompleted && (
|
||||
282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
284→ </span>
|
||||
285→ )}
|
||||
286→ </div>
|
||||
287→
|
||||
288→ {/* 挑战标题 */}
|
||||
289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
|
||||
290→ {challenge.title}
|
||||
291→ </h4>
|
||||
292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
|
||||
293→ {challenge.desc}
|
||||
294→ </p>
|
||||
295→
|
||||
296→ {/* 进度条 */}
|
||||
297→ <div className="mb-2">
|
||||
298→ <div className="flex items-center justify-between mb-1">
|
||||
299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
|
||||
301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
|
||||
302→ </span>
|
||||
303→ </div>
|
||||
304→ <Progress
|
||||
305→ value={pct}
|
||||
306→ className="h-2 bg-black/40"
|
||||
307→ style={{
|
||||
308→ ["--progress-color" as string]: diffMeta.color,
|
||||
309→ }}
|
||||
310→ />
|
||||
311→ </div>
|
||||
312→
|
||||
313→ {/* 奖励 + 领取按钮 */}
|
||||
314→ <div className="flex items-center justify-between gap-2">
|
||||
315→ <div className="flex items-center gap-2 text-[10px]">
|
||||
316→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
317→ {challenge.rewardInsight > 0 && (
|
||||
318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
|
||||
319→ )}
|
||||
320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
|
||||
321→ </div>
|
||||
322→ <Button
|
||||
323→ onClick={handleClaimDaily}
|
||||
324→ disabled={!canClaim}
|
||||
325→ size="sm"
|
||||
326→ className="h-7 px-3 text-[11px] border-0"
|
||||
327→ style={{
|
||||
328→ background: canClaim
|
||||
329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
|
||||
330→ : `${diffMeta.color}1a`,
|
||||
331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
|
||||
332→ }}
|
||||
333→ >
|
||||
334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
|
||||
335→ </Button>
|
||||
336→ </div>
|
||||
337→
|
||||
338→ {/* 完成时长 */}
|
||||
339→ {isCompleted && progress.durationSec > 0 && (
|
||||
340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
|
||||
342→ </div>
|
||||
343→ )}
|
||||
344→ </div>
|
||||
345→ </div>
|
||||
346→
|
||||
347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
350→ <div
|
||||
351→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
352→ style={{
|
||||
353→ borderColor: "rgba(232,121,249,0.4)",
|
||||
354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
|
||||
355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
|
||||
356→ }}
|
||||
357→ >
|
||||
358→ {/* 头部:标题 + weekKey + 倒计时 */}
|
||||
359→ <div className="flex items-center justify-between mb-2">
|
||||
360→ <div className="flex items-center gap-1.5">
|
||||
361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
|
||||
362→ <span className="text-xs font-semibold text-fuchsia-200">
|
||||
363→ 周挑战 · WEEKLY
|
||||
364→ </span>
|
||||
365→ </div>
|
||||
366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
367→ <span className="font-mono text-fuchsia-300/80">
|
||||
368→ {weeklyChallenge.weekKey}
|
||||
369→ </span>
|
||||
370→ <Clock className="h-2.5 w-2.5" />
|
||||
371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
|
||||
372→ </div>
|
||||
373→ </div>
|
||||
374→
|
||||
375→ {/* 难度 + 类型 标签 */}
|
||||
376→ <div className="flex items-center gap-1.5 mb-2">
|
||||
377→ <span
|
||||
378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
|
||||
380→ >
|
||||
381→ <span>{wDiffMeta.icon}</span>
|
||||
382→ {wDiffMeta.label}
|
||||
383→ </span>
|
||||
384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
385→ {wTypeMeta.icon} {wTypeMeta.label}
|
||||
386→ </span>
|
||||
387→ {wCompleted && (
|
||||
388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
390→ </span>
|
||||
391→ )}
|
||||
392→ </div>
|
||||
393→
|
||||
394→ {/* 标题 */}
|
||||
395→ <h4
|
||||
396→ className="text-sm font-semibold mb-1"
|
||||
397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
|
||||
398→ >
|
||||
399→ {weeklyChallenge.title}
|
||||
400→ </h4>
|
||||
401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
402→ {weeklyChallenge.desc}
|
||||
403→ </p>
|
||||
404→
|
||||
405→ {/* 进度条 */}
|
||||
406→ <div className="mb-2">
|
||||
407→ <div className="flex items-center justify-between mb-1">
|
||||
408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
|
||||
410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
|
||||
411→ </span>
|
||||
412→ </div>
|
||||
413→ <Progress
|
||||
414→ value={wPct}
|
||||
415→ className="h-2 bg-black/40"
|
||||
416→ style={{
|
||||
417→ ["--progress-color" as string]: wDiffMeta.color,
|
||||
418→ }}
|
||||
419→ />
|
||||
420→ </div>
|
||||
421→
|
||||
422→ {/* 奖励 + 领取按钮 */}
|
||||
423→ <div className="flex items-center justify-between gap-2">
|
||||
424→ <div className="flex items-center gap-1.5 text-[10px]">
|
||||
425→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
426→ {weeklyChallenge.rewardInsight > 0 && (
|
||||
427→ <span className="text-amber-300 font-mono">
|
||||
428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
|
||||
429→ </span>
|
||||
430→ )}
|
||||
431→ <span className="text-fuchsia-300 font-mono">
|
||||
432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
|
||||
433→ </span>
|
||||
434→ </div>
|
||||
435→ <Button
|
||||
436→ onClick={handleClaimWeekly}
|
||||
437→ disabled={!wCanClaim}
|
||||
438→ size="sm"
|
||||
439→ className="h-7 px-3 text-[11px] border-0"
|
||||
440→ style={{
|
||||
441→ background: wCanClaim
|
||||
442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
443→ : "rgba(232,121,249,0.10)",
|
||||
444→ color: wCanClaim
|
||||
445→ ? "#022c22"
|
||||
446→ : "rgba(232,121,249,0.55)",
|
||||
447→ }}
|
||||
448→ >
|
||||
449→ {wClaimed
|
||||
450→ ? "已领取"
|
||||
451→ : wCanClaim
|
||||
452→ ? "领取奖励"
|
||||
453→ : wCompleted
|
||||
454→ ? "已领取"
|
||||
455→ : "进行中…"}
|
||||
456→ </Button>
|
||||
457→ </div>
|
||||
458→
|
||||
459→ {wCompleted && weeklyProgress.durationSec > 0 && (
|
||||
460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
|
||||
462→ </div>
|
||||
463→ )}
|
||||
464→ </div>
|
||||
465→
|
||||
466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
|
||||
467→ <div
|
||||
468→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
469→ style={{
|
||||
470→ borderColor: "rgba(251,191,36,0.35)",
|
||||
471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
|
||||
472→ }}
|
||||
473→ >
|
||||
474→ {/* 头部:标题 + 当前连续天数 */}
|
||||
475→ <div className="flex items-center justify-between mb-2">
|
||||
476→ <div className="flex items-center gap-1.5">
|
||||
477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
|
||||
478→ <span className="text-xs font-semibold text-amber-200">
|
||||
479→ 信标链 · CHAIN
|
||||
480→ </span>
|
||||
481→ </div>
|
||||
482→ <div className="flex items-baseline gap-1">
|
||||
483→ <Flame className="h-3 w-3 text-rose-400" />
|
||||
484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
|
||||
485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
|
||||
486→ {chainState.currentStreak}
|
||||
487→ </span>
|
||||
488→ <span className="text-[10px] text-muted-foreground/60">天</span>
|
||||
489→ </div>
|
||||
490→ </div>
|
||||
491→
|
||||
492→ {/* 今日完成状态 */}
|
||||
493→ <div className="mb-2 flex items-center justify-between">
|
||||
494→ <span className="text-[10px] text-muted-foreground/70">
|
||||
495→ {completedToday
|
||||
496→ ? "今日已贡献"
|
||||
497→ : "今日尚未完成日挑战"}
|
||||
498→ </span>
|
||||
499→ <span
|
||||
500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
501→ completedToday
|
||||
502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
|
||||
503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
|
||||
504→ }`}
|
||||
505→ >
|
||||
506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
|
||||
507→ </span>
|
||||
508→ </div>
|
||||
509→
|
||||
510→ {/* 4 个里程碑节点横向排列 */}
|
||||
511→ <div className="flex items-center justify-between mb-2 relative">
|
||||
512→ {/* 节点之间的连线(背景灰) */}
|
||||
513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
|
||||
514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
|
||||
515→ {(() => {
|
||||
516→ // 节点圆心水平位置(百分比)
|
||||
517→ const MILESTONE_POS: Record<number, number> = {
|
||||
518→ 0: 12.5,
|
||||
519→ 3: 12.5,
|
||||
520→ 7: 37.5,
|
||||
521→ 14: 62.5,
|
||||
522→ 30: 87.5,
|
||||
523→ };
|
||||
524→ const prevPos =
|
||||
525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
|
||||
526→ const nextPos =
|
||||
527→ chainProgress.next !== null
|
||||
528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
|
||||
529→ : 87.5;
|
||||
530→ const pct = chainProgress.progressPct / 100;
|
||||
531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
|
||||
532→ const widthPct = Math.max(0, activeEndPos - 12.5);
|
||||
533→ if (widthPct <= 0) return null;
|
||||
534→ return (
|
||||
535→ <div
|
||||
536→ className="absolute top-6 h-[2px]"
|
||||
537→ style={{
|
||||
538→ left: "12.5%",
|
||||
539→ width: `${widthPct}%`,
|
||||
540→ background:
|
||||
541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
|
||||
542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
|
||||
543→ }}
|
||||
544→ />
|
||||
545→ );
|
||||
546→ })()}
|
||||
547→
|
||||
548→ {BEACON_CHAIN_MILESTONES.map((m) => {
|
||||
549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
|
||||
550→ const claimed = chainState.milestonesClaimed.includes(m);
|
||||
551→ const reachable =
|
||||
552→ chainState.currentStreak >= m && !claimed;
|
||||
553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
|
||||
554→ // 节点配色
|
||||
555→ let nodeBg = "rgba(255,255,255,0.05)";
|
||||
556→ let nodeBorder = "rgba(255,255,255,0.15)";
|
||||
557→ let textColor = "rgba(255,255,255,0.4)";
|
||||
558→ if (claimed) {
|
||||
559→ nodeBg = "rgba(52,211,153,0.25)";
|
||||
560→ nodeBorder = "#34d399";
|
||||
561→ textColor = "#34d399";
|
||||
562→ } else if (reachable) {
|
||||
563→ nodeBg = "rgba(251,113,133,0.20)";
|
||||
564→ nodeBorder = "#fb7185";
|
||||
565→ textColor = "#fb7185";
|
||||
566→ } else if (inProgress) {
|
||||
567→ nodeBg = "rgba(251,191,36,0.20)";
|
||||
568→ nodeBorder = "#fbbf24";
|
||||
569→ textColor = "#fbbf24";
|
||||
570→ }
|
||||
571→
|
||||
572→ return (
|
||||
573→ <div
|
||||
574→ key={m}
|
||||
575→ className="flex flex-col items-center gap-1 z-10 flex-1"
|
||||
576→ >
|
||||
577→ <div
|
||||
578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
|
||||
579→ reachable ? "chain-milestone-reachable" : ""
|
||||
580→ }`}
|
||||
581→ style={{
|
||||
582→ background: nodeBg,
|
||||
583→ borderColor: nodeBorder,
|
||||
584→ color: textColor,
|
||||
585→ }}
|
||||
586→ >
|
||||
587→ {claimed ? (
|
||||
588→ <Sparkles className="h-4 w-4" />
|
||||
589→ ) : (
|
||||
590→ m
|
||||
591→ )}
|
||||
592→ </div>
|
||||
593→ <span
|
||||
594→ className="text-[9px] text-center leading-tight"
|
||||
595→ style={{ color: textColor }}
|
||||
596→ >
|
||||
597→ {reward?.label}
|
||||
598→ </span>
|
||||
599→ {claimed ? (
|
||||
600→ <span className="text-[9px] text-emerald-400/80 font-mono">
|
||||
601→ 已领
|
||||
602→ </span>
|
||||
603→ ) : reachable ? (
|
||||
604→ <Button
|
||||
605→ onClick={() => handleClaimChain(m)}
|
||||
606→ size="sm"
|
||||
607→ className="h-5 px-2 text-[9px] py-0 border-0"
|
||||
608→ style={{
|
||||
609→ background:
|
||||
610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||
611→ color: "#1c0608",
|
||||
612→ }}
|
||||
613→ >
|
||||
614→ 领取
|
||||
615→ </Button>
|
||||
616→ ) : (
|
||||
617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
618→ +{reward?.rewardInsight}洞
|
||||
619→ </span>
|
||||
620→ )}
|
||||
621→ </div>
|
||||
622→ );
|
||||
623→ })}
|
||||
624→ </div>
|
||||
625→
|
||||
626→ {/* 进度条 */}
|
||||
627→ <div className="mb-2">
|
||||
628→ <div className="flex items-center justify-between mb-1">
|
||||
629→ <span className="text-[10px] text-muted-foreground/70">
|
||||
630→ {chainProgress.next === null
|
||||
631→ ? "已通关全部里程碑"
|
||||
632→ : `下一目标:${chainProgress.next} 天`}
|
||||
633→ </span>
|
||||
634→ <span className="text-[10px] font-mono text-amber-300">
|
||||
635→ {chainProgress.current}
|
||||
636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
|
||||
637→ </span>
|
||||
638→ </div>
|
||||
639→ <Progress
|
||||
640→ value={chainProgress.progressPct}
|
||||
641→ className="h-1.5 bg-black/40"
|
||||
642→ style={{
|
||||
643→ ["--progress-color" as string]: "#fbbf24",
|
||||
644→ }}
|
||||
645→ />
|
||||
646→ </div>
|
||||
647→
|
||||
648→ {/* 底部统计 */}
|
||||
649→ <div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
650→ <div className="flex items-center gap-2">
|
||||
651→ <span className="text-muted-foreground/60">最长</span>
|
||||
652→ <span className="font-mono text-amber-300">
|
||||
653→ {chainState.longestStreak}天
|
||||
654→ </span>
|
||||
655→ </div>
|
||||
656→ <div className="flex items-center gap-2">
|
||||
657→ <span className="text-muted-foreground/60">累计</span>
|
||||
658→ <span className="font-mono text-rose-300">
|
||||
659→ {chainState.totalCompletions}次
|
||||
660→ </span>
|
||||
661→ </div>
|
||||
662→ <div className="flex items-center gap-1">
|
||||
663→ <span className="text-muted-foreground/60">续命</span>
|
||||
664→ {chainState.graceUsed >= 1 ? (
|
||||
665→ <span className="font-mono text-rose-400/80">已用</span>
|
||||
666→ ) : (
|
||||
667→ <span className="font-mono text-emerald-400/80">可用</span>
|
||||
668→ )}
|
||||
669→ </div>
|
||||
670→ </div>
|
||||
671→ </div>
|
||||
672→ </div>
|
||||
673→
|
||||
674→ {/* 本地排行榜 */}
|
||||
675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
676→ <div className="flex items-center gap-1.5 mb-2">
|
||||
677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
678→ <span className="text-xs font-semibold">深空排行榜</span>
|
||||
679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
680→ </div>
|
||||
681→
|
||||
682→ {leaderboard.length === 0 ? (
|
||||
683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
685→ 尚无记录。完成今日或本周信标即可登榜。
|
||||
686→ </div>
|
||||
687→ ) : (
|
||||
688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
689→ {leaderboard.map((entry, i) => {
|
||||
690→ const rb = rankBadge(i);
|
||||
691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
|
||||
694→ return (
|
||||
695→ <div
|
||||
696→ key={`${entry.timestamp}-${i}`}
|
||||
697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
698→ entry.isWeekly
|
||||
699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
700→ : isMine
|
||||
701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
702→ : "hover:bg-white/5"
|
||||
703→ }`}
|
||||
704→ >
|
||||
705→ {/* 排名 */}
|
||||
706→ <span className="w-6 flex items-center justify-center">
|
||||
707→ {rb ? (
|
||||
708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
709→ ) : (
|
||||
710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
711→ )}
|
||||
712→ </span>
|
||||
713→ {/* 类型 + 难度 + 周挑战标记 */}
|
||||
714→ <span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
717→ {entry.isWeekly && (
|
||||
718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
719→ WEEK
|
||||
720→ </span>
|
||||
721→ )}
|
||||
722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
723→ </span>
|
||||
724→ {/* 用时 */}
|
||||
725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
727→ </span>
|
||||
728→ {/* 分数 */}
|
||||
729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
730→ {formatNum(entry.score)}
|
||||
731→ </span>
|
||||
732→ </div>
|
||||
733→ );
|
||||
734→ })}
|
||||
735→ </div>
|
||||
736→ )}
|
||||
737→ </div>
|
||||
738→
|
||||
739→ {/* 难度图例 */}
|
||||
740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
||||
741→ {DIFFICULTY_ORDER.map((d) => {
|
||||
742→ const m = BEACON_DIFFICULTY[d];
|
||||
743→ return (
|
||||
744→ <span key={d} className="flex items-center gap-1">
|
||||
745→ <span style={{ color: m.color }}>{m.icon}</span>
|
||||
746→ {m.label} ×{m.mult}
|
||||
747→ </span>
|
||||
748→ );
|
||||
749→ })}
|
||||
750→ </div>
|
||||
751→ </div>
|
||||
752→ );
|
||||
753→}
|
||||
754→
|
||||
Reference in New Issue
Block a user