"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowRightLeft, Calendar, CheckCircle2, CircleMinus, History, IdCard, RefreshCw, ShoppingBag, LogOut, TrendingUp, TrendingDown, Zap, } from "lucide-react"; import { HintTip } from "@/components/hint-tip"; import { PixelAvatar } from "@/components/pixel-avatar"; import { SourceCrystalIcon } from "@/components/source-crystal-icon"; import { apiFetch, listRatingHistory, type RatingHistoryItem } from "@/lib/api"; import { clearToken, readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; import { dayKeyInShanghai, dayKeySerial, formatUnixDate, formatUnixDateTime, serialToDayKey } from "@/lib/time"; type Me = { id: number; username: string; rating: number; created_at: number; }; type RedeemItem = { id: number; name: string; description: string; unit_label: string; holiday_cost: number; studyday_cost: number; is_active: boolean; }; type RedeemRecord = { id: number; user_id: number; item_id: number; item_name: string; quantity: number; day_type: string; unit_cost: number; total_cost: number; note: string; created_at: number; }; type RedeemCreateResp = RedeemRecord & { rating_after?: number; }; type RedeemDayTypeInfo = { day_type: "holiday" | "studyday"; is_holiday: boolean; reason: string; source: string; date_ymd: string; checked_at: number; }; type SourceCrystalSummary = { user_id: number; balance: number; monthly_interest_rate: number; last_interest_at: number; updated_at: number; }; type SourceCrystalRecord = { id: number; user_id: number; tx_type: "deposit" | "withdraw" | "interest" | string; amount: number; balance_after: number; note: string; created_at: number; }; type ExperienceSummary = { user_id: number; experience: number; level: number; current_level_base: number; next_level_experience: number; updated_at: number; }; type ExperienceHistoryItem = { id: number; user_id: number; xp_delta: number; rating_before: number; rating_after: number; source: string; note: string; created_at: number; }; type DailyTaskItem = { code: string; title: string; description: string; reward: number; completed: boolean; completed_at?: number | null; }; type DailyTaskPayload = { day_key: string; total_reward: number; gained_reward: number; tasks: DailyTaskItem[]; }; function fmtTs(v: number | null | undefined): string { return formatUnixDateTime(v); } function fmtCrystal(v: number | null | undefined): string { if (typeof v !== "number" || !Number.isFinite(v)) return "0.00"; return v.toFixed(2); } function resolveRank(rating: number): { label: string; color: string; icon: string } { if (rating >= 2000) return { label: "Netherite", color: "text-[color:var(--mc-red)]", icon: "🔥" }; if (rating >= 1500) return { label: "Diamond", color: "text-[color:var(--mc-diamond)]", icon: "💎" }; if (rating >= 1200) return { label: "Iron", color: "text-zinc-200", icon: "⚔️" }; if (rating >= 1000) return { label: "Stone", color: "text-[color:var(--mc-stone)]", icon: "🪨" }; return { label: "Wood", color: "text-[color:var(--mc-wood)]", icon: "🪵" }; } function calcLearningStreak(items: RatingHistoryItem[]): number { const daySet = new Set( items .filter((item) => item.type === "daily_task" && item.change > 0) .map((item) => dayKeyInShanghai(item.created_at)) .filter((key) => key.length > 0) ); if (daySet.size === 0) return 0; const nowSec = Math.floor(Date.now() / 1000); const todaySerial = dayKeySerial(dayKeyInShanghai(nowSec)); if (todaySerial == null) return 0; let cursor = todaySerial; const todayKey = serialToDayKey(cursor); if (!daySet.has(todayKey)) { const yesterday = cursor - 1; const yesterdayKey = serialToDayKey(yesterday); if (!daySet.has(yesterdayKey)) return 0; cursor = yesterday; } let streak = 0; while (true) { const key = serialToDayKey(cursor); if (!daySet.has(key)) break; streak += 1; cursor -= 1; } return streak; } export default function MePage() { const { isZh, tx } = useI18nText(); const router = useRouter(); const [token, setToken] = useState(""); const [profile, setProfile] = useState(null); const [items, setItems] = useState([]); const [records, setRecords] = useState([]); const [historyItems, setHistoryItems] = useState([]); const [ratingHistoryTypeFilter, setRatingHistoryTypeFilter] = useState("all"); const [tradeTypeFilter, setTradeTypeFilter] = useState("all"); const [dailyTasks, setDailyTasks] = useState([]); const [dailyDayKey, setDailyDayKey] = useState(""); const [dailyTotalReward, setDailyTotalReward] = useState(0); const [dailyGainedReward, setDailyGainedReward] = useState(0); const [learningStreak, setLearningStreak] = useState(0); const [redeemDayType, setRedeemDayType] = useState(null); const [sourceCrystal, setSourceCrystal] = useState(null); const [sourceCrystalRecords, setSourceCrystalRecords] = useState([]); const [experience, setExperience] = useState(null); const [experienceHistory, setExperienceHistory] = useState([]); const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false); const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false); const [selectedItemId, setSelectedItemId] = useState(0); const [quantity, setQuantity] = useState(1); const [note, setNote] = useState(""); const [crystalAmount, setCrystalAmount] = useState("10"); const [crystalNote, setCrystalNote] = useState(""); const [loading, setLoading] = useState(false); const [redeemLoading, setRedeemLoading] = useState(false); const [crystalLoading, setCrystalLoading] = useState(false); // Toast notification system const [toast, setToast] = useState<{ type: "success" | "error"; text: string } | null>(null); const [toastVisible, setToastVisible] = useState(false); const toastTimer = useRef>(undefined); const lastCompletedTaskCountRef = useRef(-1); const showToast = useCallback((type: "success" | "error", text: string) => { if (toastTimer.current) clearTimeout(toastTimer.current); // Clean up common error prefixes let cleaned = text.replace(/^Error:\s*/i, ""); if (type === "error") { cleaned = cleaned .replace(/rating not enough/i, isZh ? "💎 绿宝石不足,继续冒险积攒吧!" : "💎 Not enough Emeralds! Keep adventuring!") .replace(/please sign in/i, isZh ? "🔒 请先登录" : "🔒 Please sign in first") .replace(/select trade item/i, isZh ? "📦 请选择交易物品" : "📦 Select a trade item"); } setToast({ type, text: cleaned }); setToastVisible(true); toastTimer.current = setTimeout(() => { setToastVisible(false); setTimeout(() => setToast(null), 300); }, type === "error" ? 5000 : 4000); }, [isZh]); const selectedItem = useMemo( () => items.find((item) => item.id === selectedItemId) ?? null, [items, selectedItemId] ); const itemName = (name: string): string => { if (isZh) return name; if (name === "私人玩游戏时间") return "Game Time pass"; return name; }; const formatRatingNote = (note: string, type: string): React.ReactNode => { // Daily task codes const taskLabels: Record = { login_checkin: ["每日签到 🎯", "Daily Sign-in 🎯"], daily_submit: ["每日提交 📝", "Daily Submission 📝"], first_ac: ["首次通过 ⭐", "First AC ⭐"], code_quality: ["代码质量 🛠️", "Code Quality 🛠️"], }; if (type === "daily_task") { if (taskLabels[note]) { return isZh ? taskLabels[note][0] : taskLabels[note][1]; } // Note score: "note_score_1234" const ns = note.match(/^note_score_(\d+)$/); if (ns) { return ( {isZh ? `📜 探索笔记鉴定 P${ns[1]}` : `📜 Note Appraisal P${ns[1]}`} ); } } // Solution view: "Problem 1234:Title" const m = note.match(/^Problem (\d+):(.*)$/); if (m) { const pid = m[1]; const title = m[2].trim(); return ( {isZh ? `查看题解 P${pid}` : `View Solution P${pid}`} {title ? ` · ${title}` : ""} ); } // Redeem items keep original text return note; }; const loadAll = async () => { setLoading(true); try { const tk = readToken(); setToken(tk); if (!tk) throw new Error(tx("请先登录", "Please sign in first")); const [me, redeemItems, redeemRecords, daily, history, dayTypeInfo, crystalSummary, crystalRows, expSummary] = await Promise.all([ apiFetch("/api/v1/me", {}, tk), apiFetch("/api/v1/me/redeem/items", {}, tk), apiFetch("/api/v1/me/redeem/records?limit=200", {}, tk), apiFetch("/api/v1/me/daily-tasks", {}, tk), listRatingHistory(50, tk), apiFetch("/api/v1/me/redeem/day-type", {}, tk), apiFetch("/api/v1/me/source-crystal", {}, tk), apiFetch("/api/v1/me/source-crystal/records?limit=200", {}, tk), apiFetch("/api/v1/me/experience", {}, tk), ]); setProfile(me); setItems(redeemItems ?? []); setRecords(redeemRecords ?? []); setHistoryItems(history ?? []); setLearningStreak(calcLearningStreak(history ?? [])); setDailyTasks(daily?.tasks ?? []); setDailyDayKey(daily?.day_key ?? ""); setDailyTotalReward(daily?.total_reward ?? 0); setDailyGainedReward(daily?.gained_reward ?? 0); setRedeemDayType(dayTypeInfo ?? null); setSourceCrystal(crystalSummary ?? null); setSourceCrystalRecords(crystalRows ?? []); setExperience(expSummary ?? null); if ((redeemItems ?? []).length > 0) { setSelectedItemId((prev) => prev || redeemItems[0].id); } } catch (e: unknown) { showToast("error", String(e)); } finally { setLoading(false); } }; const toggleExperienceHistory = async () => { const nextOpen = !experienceHistoryOpen; setExperienceHistoryOpen(nextOpen); if (!nextOpen || experienceHistory.length > 0) return; try { const tk = token || readToken(); if (!tk) throw new Error(tx("请先登录", "Please sign in first")); setExperienceHistoryLoading(true); const rows = await apiFetch( "/api/v1/me/experience/history?limit=200", {}, tk ); setExperienceHistory(rows ?? []); } catch (e: unknown) { showToast("error", String(e)); } finally { setExperienceHistoryLoading(false); } }; useEffect(() => { void loadAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { setLearningStreak(calcLearningStreak(historyItems)); }, [historyItems]); useEffect(() => { const completedCount = dailyTasks.filter((task) => task.completed).length; if (lastCompletedTaskCountRef.current < 0) { lastCompletedTaskCountRef.current = completedCount; return; } if (completedCount > lastCompletedTaskCountRef.current) { const gained = Math.max(0, dailyGainedReward); showToast( "success", isZh ? `🎉 每日任务推进!当前奖励 ${gained}/${dailyTotalReward} XP` : `🎉 Daily quest progressed! Reward ${gained}/${dailyTotalReward} XP` ); } lastCompletedTaskCountRef.current = completedCount; }, [dailyGainedReward, dailyTasks, dailyTotalReward, isZh, showToast]); const redeem = async () => { setRedeemLoading(true); try { if (!token) throw new Error(tx("请先登录", "Please sign in first")); if (!selectedItemId) throw new Error(tx("请选择交易物品", "Select trade item")); if (!Number.isFinite(quantity) || quantity <= 0) { throw new Error(tx("数量必须大于 0", "Amount > 0")); } const created = await apiFetch( "/api/v1/me/redeem/records", { method: "POST", body: JSON.stringify({ item_id: selectedItemId, quantity, note, }), }, token ); showToast("success", isZh ? `✅ 交易成功:${created.item_name} × ${created.quantity},花费 ${created.total_cost} 绿宝石` : `✅ Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds` ); setNote(""); await loadAll(); } catch (e: unknown) { showToast("error", String(e)); } finally { setRedeemLoading(false); } }; const withdrawSourceCrystal = async () => { setCrystalLoading(true); try { if (!token) throw new Error(tx("请先登录", "Please sign in first")); const amount = Number(crystalAmount); if (!Number.isFinite(amount) || amount <= 0) { throw new Error(tx("源晶数量必须大于 0", "Source crystal amount must be greater than 0")); } const payload = { amount, note: crystalNote }; await apiFetch( "/api/v1/me/source-crystal/withdraw", { method: "POST", body: JSON.stringify(payload), }, token ); showToast( "success", tx(`已支出 ${fmtCrystal(amount)} 源晶`, `Spent ${fmtCrystal(amount)} Source Crystals`) ); setCrystalNote(""); await loadAll(); } catch (e: unknown) { showToast("error", String(e)); } finally { setCrystalLoading(false); } }; const logout = useCallback(() => { const confirmed = window.confirm( tx( "确认断开连接并退出当前账号?", "Disconnect and sign out from current account?" ) ); if (!confirmed) return; clearToken(); setToken(""); setProfile(null); setItems([]); setRecords([]); setHistoryItems([]); setDailyTasks([]); setDailyDayKey(""); setDailyTotalReward(0); setDailyGainedReward(0); setRedeemDayType(null); setSourceCrystal(null); setSourceCrystalRecords([]); setExperience(null); setExperienceHistory([]); setExperienceHistoryOpen(false); showToast( "success", tx("已断开连接并退出登录。", "Disconnected and signed out.") ); router.replace("/auth"); }, [router, showToast, tx]); const rank = resolveRank(profile?.rating ?? 0); const expValue = experience?.experience ?? 0; const expLevel = experience?.level ?? 1; const expCurrentBase = experience?.current_level_base ?? 0; const expNext = experience?.next_level_experience ?? 100; const expProgress = Math.max( 0, Math.min(1, (expValue - expCurrentBase) / Math.max(1, expNext - expCurrentBase)) ); const expToNext = Math.max(0, expNext - expValue); const currentRedeemDayType = redeemDayType?.day_type === "holiday" ? "holiday" : "studyday"; const sourceCrystalBalance = sourceCrystal?.balance ?? 0; const sourceCrystalMonthlyRate = sourceCrystal?.monthly_interest_rate ?? 0; const sourceCrystalEstimatedMonthlyInterest = Math.max(0, sourceCrystalBalance * sourceCrystalMonthlyRate); const sectionTitleClass = "text-lg font-extrabold text-[color:var(--mc-gold)] drop-shadow-sm tracking-wide"; const sectionIconClass = "text-[color:var(--mc-diamond)]"; const crystalTxLabel = (txType: string): string => { if (txType === "deposit") return tx("存入", "Deposit"); if (txType === "withdraw") return tx("取出", "Withdraw"); if (txType === "interest") return tx("月息", "Interest"); return txType; }; const ratingTypeLabel = (type: string): string => { if (type === "daily_task") return tx("每日任务", "Daily Task"); if (type === "redeem") return tx("兑换消费", "Redeem"); if (type === "solution_view") return tx("题解查看", "Solution View"); if (type === "kb_skill") return tx("知识库奖励", "KB Reward"); return type; }; const tradeTypeLabel = (type: string): string => { if (type === "studyday") return tx("学习日", "Study Day"); if (type === "holiday") return tx("假期", "Holiday"); if (type === "unknown") return tx("未标注", "Unknown"); return type; }; const ratingHistoryTypes = useMemo( () => Array.from(new Set(historyItems.map((item) => item.type).filter((v) => v.length > 0))), [historyItems] ); const filteredHistoryItems = useMemo( () => historyItems.filter( (item) => ratingHistoryTypeFilter === "all" || item.type === ratingHistoryTypeFilter ), [historyItems, ratingHistoryTypeFilter] ); const tradeTypes = useMemo( () => Array.from( new Set(records.map((row) => (row.day_type && row.day_type.length > 0 ? row.day_type : "unknown"))) ), [records] ); const filteredTradeRecords = useMemo( () => records.filter((row) => { const rowType = row.day_type && row.day_type.length > 0 ? row.day_type : "unknown"; return tradeTypeFilter === "all" || rowType === tradeTypeFilter; }), [records, tradeTypeFilter] ); const achievementItems = useMemo(() => { const hasFirstAc = historyItems.some((item) => item.type === "daily_task" && item.note === "first_ac"); const hasSubmit = historyItems.some((item) => item.type === "daily_task" && item.note === "daily_submit"); return [ { key: "workbench", icon: "🧰", label: tx("工作台", "Workbench"), unlock: hasSubmit || hasFirstAc, hint: tx("完成一次提交", "Complete one submission"), }, { key: "torch", icon: "🕯️", label: tx("火把", "Torch"), unlock: learningStreak >= 3, hint: tx("连续学习 3 天", "Study 3 days in a row"), }, { key: "compass", icon: "🧭", label: tx("指南针", "Compass"), unlock: historyItems.length >= 10, hint: tx("累计 10 次成长记录", "Collect 10 progress logs"), }, { key: "iron-pickaxe", icon: "⛏️", label: tx("铁镐", "Iron Pickaxe"), unlock: hasFirstAc, hint: tx("首次通过题目", "Get first AC"), }, ]; }, [historyItems, learningStreak, tx]); return (

{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}

{tx( "这里汇总每日任务、成长记录与交易记录。建议优先完成每日任务,再按需求在交易站兑换物品。", "This page combines daily tasks, growth history, and trade records. Complete daily tasks first, then redeem items in the trading post when needed." )}
{loading &&

{tx("读取存档中...", "Loading Save...")}

} {/* Toast notification */} {toast && (
{toast.text}
)} {profile && (

{profile.username}

{rank.icon} {rank.label} Rank
Level {Math.floor(profile.rating / 100)}
{/* Stats grid */}
Rating
{profile.rating}
{tx("等级", "Level")}
{Math.floor(profile.rating / 100)}
🎯 {tx("下一等级", "Next Lv")}
{100 - (profile.rating % 100)} XP
🔥 {tx("连学", "Streak")}
{learningStreak} {tx("天", "days")}
{tx("经验值", "Experience")}
{expValue}
{tx("经验等级", "XP Level")}
Lv.{expLevel}

{tx("下一级经验", "XP to next")}: {expToNext}

UID {profile.id}
{tx("加入时间", "Joined")} {formatUnixDate(profile.created_at)}

{tx("源晶账户", "Source Crystal Account")}

{fmtCrystal(sourceCrystalBalance)} {tx("源晶", "SC")}

{tx("月利率", "Monthly Interest")}: {(sourceCrystalMonthlyRate * 100).toFixed(2)}% ·{" "} {tx("预计月息", "Est. monthly interest")}: +{fmtCrystal(sourceCrystalEstimatedMonthlyInterest)}

{tx("上次计息", "Last interest update")}: {fmtTs(sourceCrystal?.last_interest_at)}

{tx( "仅管理员可在管理页为你存入源晶;你可在此自行支出并填写备注。", "Only admin can deposit Source Crystals for you; you can spend here with notes." )}

{/* Daily Tasks */}

每日悬赏任务 进度: {dailyGainedReward} / {dailyTotalReward} XP · 🔥 {learningStreak}d · {dailyDayKey || "--"}

{dailyTasks.map((task, idx) => (
{task.completed && }

{task.title} +{task.reward} XP

{task.description} {task.completed && ({fmtTs(task.completed_at)})}

))}

{tx("物品图鉴", "Item Collection")}

{achievementItems.map((item) => (

{item.icon} {item.label} {item.unlock ? : null}

{item.hint}

))}

💎 村民交易站 消耗: RATING

setQuantity(Math.max(1, Number(e.target.value) || 1))} />
{selectedItem && (

{selectedItem.description}

{tx("单价", "Unit cost")}:{" "} {currentRedeemDayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost}{" "} Rating / {selectedItem.unit_label}

{currentRedeemDayType === "holiday" ? tx("今日判定:假期", "Today: Holiday") : tx("今日判定:学习日", "Today: Study Day")} {redeemDayType?.reason ? ` · ${redeemDayType.reason}` : ""}

)}
{currentRedeemDayType === "holiday" ? tx("自动使用假期价格", "Auto using holiday price") : tx("自动使用学习日价格", "Auto using study-day price")}
)}

{tx("源晶支出与流水", "Source Crystal Spend & History")}

setCrystalAmount(e.target.value)} placeholder={tx("数量", "Amount")} /> setCrystalNote(e.target.value)} placeholder={tx("备注(可选)", "Note (optional)")} />
{sourceCrystalRecords.map((row) => (
= 0 ? "text-emerald-700" : "text-red-600"}`}> {row.amount >= 0 ? "+" : ""} {fmtCrystal(row.amount)} {tx("源晶", "SC")} {crystalTxLabel(row.tx_type)} {row.note ? · {row.note} : null} {tx("余额", "Bal")}: {fmtCrystal(row.balance_after)} · {fmtTs(row.created_at)}
))} {!loading && sourceCrystalRecords.length === 0 && (

{tx("暂无源晶流水。", "No source crystal records yet.")}

)}

{tx("经验值系统", "Experience")}

{tx( "规则:1 经验值 = 1 Rating 增量;消费 Rating 不会减少经验值。", "Rule: 1 XP = 1 rating gain; spending rating never decreases XP." )}

{tx("当前经验", "Current XP")}: {expValue}

{tx("当前等级", "Level")}: Lv.{expLevel}

{expValue} / {expNext} {tx("(下一级)", "(next level)")}

{experienceHistoryOpen && (
{experienceHistoryLoading && (

{tx("加载经验历史中...", "Loading XP history...")}

)} {!experienceHistoryLoading && experienceHistory.map((row) => (
+{row.xp_delta} XP {tx("Rating", "Rating")} {row.rating_before} → {row.rating_after} {row.note ? · {row.note} : null} {fmtTs(row.created_at)}
))} {!experienceHistoryLoading && experienceHistory.length === 0 && (

{tx("暂无经验历史。", "No XP history yet.")}

)}
)}
{/* Rating History Section */}

{tx("积分变动记录", "Rating History")}

{filteredHistoryItems.map((item, idx) => (
0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}> {item.change > 0 ? : } {item.change > 0 ? `+${item.change}` : item.change} {formatRatingNote(item.note, item.type)} {ratingTypeLabel(item.type)} {formatUnixDateTime(item.created_at)}
))} {!loading && filteredHistoryItems.length === 0 && (

{tx("暂无记录。", "No history.")}

)}
{/* Trades Section */}

{tx("交易记录", "Trade History")}

{filteredTradeRecords.map((row) => (
{itemName(row.item_name)} × {row.quantity} {tradeTypeLabel(row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")} -{row.total_cost} Gems · {formatUnixDate(row.created_at)}
))} {!loading && filteredTradeRecords.length === 0 && (

{tx("暂无交易。", "No trades.")}

)}
); }