1100 行
45 KiB
TypeScript
1100 行
45 KiB
TypeScript
"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<Me | null>(null);
|
||
const [items, setItems] = useState<RedeemItem[]>([]);
|
||
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
||
const [historyItems, setHistoryItems] = useState<RatingHistoryItem[]>([]);
|
||
const [ratingHistoryTypeFilter, setRatingHistoryTypeFilter] = useState("all");
|
||
const [tradeTypeFilter, setTradeTypeFilter] = useState("all");
|
||
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
||
const [dailyDayKey, setDailyDayKey] = useState("");
|
||
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
||
const [dailyGainedReward, setDailyGainedReward] = useState(0);
|
||
const [learningStreak, setLearningStreak] = useState(0);
|
||
const [redeemDayType, setRedeemDayType] = useState<RedeemDayTypeInfo | null>(null);
|
||
const [sourceCrystal, setSourceCrystal] = useState<SourceCrystalSummary | null>(null);
|
||
const [sourceCrystalRecords, setSourceCrystalRecords] = useState<SourceCrystalRecord[]>([]);
|
||
const [experience, setExperience] = useState<ExperienceSummary | null>(null);
|
||
const [experienceHistory, setExperienceHistory] = useState<ExperienceHistoryItem[]>([]);
|
||
const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false);
|
||
const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false);
|
||
|
||
const [selectedItemId, setSelectedItemId] = useState<number>(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<ReturnType<typeof setTimeout>>(undefined);
|
||
const lastCompletedTaskCountRef = useRef<number>(-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<string, [string, string]> = {
|
||
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 (
|
||
<a href={`/problems/${ns[1]}`} className="hover:underline text-[color:var(--mc-diamond)]">
|
||
{isZh ? `📜 探索笔记鉴定 P${ns[1]}` : `📜 Note Appraisal P${ns[1]}`}
|
||
</a>
|
||
);
|
||
}
|
||
}
|
||
// Solution view: "Problem 1234:Title"
|
||
const m = note.match(/^Problem (\d+):(.*)$/);
|
||
if (m) {
|
||
const pid = m[1];
|
||
const title = m[2].trim();
|
||
return (
|
||
<a href={`/problems/${pid}`} className="hover:underline text-[color:var(--mc-diamond)]">
|
||
{isZh ? `查看题解 P${pid}` : `View Solution P${pid}`}
|
||
{title ? ` · ${title}` : ""}
|
||
</a>
|
||
);
|
||
}
|
||
// 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<Me>("/api/v1/me", {}, tk),
|
||
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
||
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
||
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
||
listRatingHistory(50, tk),
|
||
apiFetch<RedeemDayTypeInfo>("/api/v1/me/redeem/day-type", {}, tk),
|
||
apiFetch<SourceCrystalSummary>("/api/v1/me/source-crystal", {}, tk),
|
||
apiFetch<SourceCrystalRecord[]>("/api/v1/me/source-crystal/records?limit=200", {}, tk),
|
||
apiFetch<ExperienceSummary>("/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<ExperienceHistoryItem[]>(
|
||
"/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<RedeemCreateResp>(
|
||
"/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 (
|
||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
|
||
</h1>
|
||
<HintTip title={tx("页面说明", "Page Guide")}>
|
||
{tx(
|
||
"这里汇总每日任务、成长记录与交易记录。建议优先完成每日任务,再按需求在交易站兑换物品。",
|
||
"This page combines daily tasks, growth history, and trade records. Complete daily tasks first, then redeem items in the trading post when needed."
|
||
)}
|
||
</HintTip>
|
||
<button
|
||
type="button"
|
||
className="mc-btn mc-btn-danger ml-auto min-h-[40px] px-3 py-1 text-xs"
|
||
onClick={logout}
|
||
>
|
||
<span className="inline-flex items-center gap-1">
|
||
<LogOut size={14} />
|
||
{tx("断开连接", "Disconnect")}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
{loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>}
|
||
|
||
{/* Toast notification */}
|
||
{toast && (
|
||
<div
|
||
className={`fixed top-6 left-1/2 -translate-x-1/2 z-50 max-w-md w-[90vw] px-5 py-3 rounded-none border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)] font-mono text-sm transition-all duration-300 ${
|
||
toastVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-4"
|
||
} ${
|
||
toast.type === "error"
|
||
? "bg-red-900/95 text-red-100 border-red-700"
|
||
: "bg-emerald-900/95 text-emerald-100 border-emerald-700"
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{toast.text}</span>
|
||
<button
|
||
onClick={() => { setToastVisible(false); setTimeout(() => setToast(null), 300); }}
|
||
className="shrink-0 text-lg leading-none opacity-70 hover:opacity-100"
|
||
>×</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{profile && (
|
||
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_2fr]">
|
||
<section className="rounded-none border-[3px] border-black bg-[color:var(--mc-plank)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||
<div className="flex flex-col items-center text-center">
|
||
<div className="relative mb-4">
|
||
<div className="absolute inset-0 bg-black opacity-20 translate-x-1 translate-y-1 rounded-none"></div>
|
||
<div className="border-[4px] border-white p-1 bg-[color:var(--mc-stone-dark)]">
|
||
<PixelAvatar
|
||
seed={`${profile.username}-${profile.id}`}
|
||
size={100}
|
||
className="border-none"
|
||
alt="avatar"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 className="text-xl font-bold text-black mc-text-shadow-sm mb-1">{profile.username}</h2>
|
||
<div className={`text-sm font-bold ${rank.color} mb-2`}>
|
||
{rank.icon} {rank.label} Rank
|
||
</div>
|
||
|
||
<div className="w-full bg-black h-4 border border-white relative mb-2">
|
||
<div
|
||
className="h-full bg-[color:var(--mc-green)]"
|
||
style={{ width: `${Math.min(100, (profile.rating % 100))}%` }}
|
||
></div>
|
||
<span className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-bold shadow-black drop-shadow-md">
|
||
Level {Math.floor(profile.rating / 100)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Stats grid */}
|
||
<div className="w-full grid grid-cols-2 gap-x-3 gap-y-1 text-sm mt-1 mb-2">
|
||
<div className="flex items-center gap-1 text-zinc-800">
|
||
<Zap size={13} className="text-[color:var(--mc-gold)]" />
|
||
<span>Rating</span>
|
||
</div>
|
||
<span className="text-right font-bold text-[color:var(--mc-gold)]">{profile.rating}</span>
|
||
|
||
<div className="flex items-center gap-1 text-zinc-800">
|
||
<TrendingUp size={13} className="text-[color:var(--mc-green)]" />
|
||
<span>{tx("等级", "Level")}</span>
|
||
</div>
|
||
<span className="text-right font-bold text-[color:var(--mc-green)]">{Math.floor(profile.rating / 100)}</span>
|
||
|
||
<div className="flex items-center gap-1 text-zinc-800">
|
||
<span className="text-xs">🎯</span>
|
||
<span>{tx("下一等级", "Next Lv")}</span>
|
||
</div>
|
||
<span className="text-right text-zinc-600">{100 - (profile.rating % 100)} XP</span>
|
||
|
||
<div className="flex items-center gap-1 text-zinc-800">
|
||
<span className="text-xs">🔥</span>
|
||
<span>{tx("连学", "Streak")}</span>
|
||
</div>
|
||
<span className="text-right font-bold text-[color:var(--mc-red)]">
|
||
{learningStreak} {tx("天", "days")}
|
||
</span>
|
||
|
||
<div className="flex items-center gap-1 text-zinc-800">
|
||
<Zap size={13} className="text-[color:var(--mc-diamond)]" />
|
||
<span>{tx("经验值", "Experience")}</span>
|
||
</div>
|
||
<span className="text-right font-bold text-[color:var(--mc-diamond)]">{expValue}</span>
|
||
|
||
<div className="flex items-center gap-1 text-zinc-800">
|
||
<TrendingUp size={13} className="text-[color:var(--mc-diamond)]" />
|
||
<span>{tx("经验等级", "XP Level")}</span>
|
||
</div>
|
||
<span className="text-right font-bold text-[color:var(--mc-diamond)]">Lv.{expLevel}</span>
|
||
</div>
|
||
|
||
<div className="w-full mb-2">
|
||
<div className="h-2 overflow-hidden rounded bg-black/20">
|
||
<div
|
||
className="h-full bg-[color:var(--mc-diamond)]"
|
||
style={{ width: `${(expProgress * 100).toFixed(2)}%` }}
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-right text-[11px] text-zinc-600">
|
||
{tx("下一级经验", "XP to next")}: {expToNext}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="w-full border-t border-zinc-300 my-1"></div>
|
||
|
||
<div className="w-full flex flex-col gap-1 text-sm">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-zinc-800 flex items-center gap-1">
|
||
<IdCard size={14} className="text-zinc-500" />
|
||
UID
|
||
</span>
|
||
<span className="text-zinc-600 font-mono">{profile.id}</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-zinc-800 flex items-center gap-1">
|
||
<Calendar size={14} className="text-zinc-500" />
|
||
{tx("加入时间", "Joined")}
|
||
</span>
|
||
<span className="text-zinc-600 font-mono">{formatUnixDate(profile.created_at)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-full border-t border-zinc-300 mt-3 pt-3 text-left">
|
||
<div className="rounded border border-zinc-300 bg-white p-3">
|
||
<p className="flex items-center gap-1 text-sm font-bold text-[color:var(--mc-gold)]">
|
||
<SourceCrystalIcon size={14} className={sectionIconClass} />
|
||
{tx("源晶账户", "Source Crystal Account")}
|
||
</p>
|
||
<p className="mt-1 text-lg font-bold text-[color:var(--mc-diamond)]">
|
||
{fmtCrystal(sourceCrystalBalance)} {tx("源晶", "SC")}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{tx("月利率", "Monthly Interest")}: {(sourceCrystalMonthlyRate * 100).toFixed(2)}% ·{" "}
|
||
{tx("预计月息", "Est. monthly interest")}: +{fmtCrystal(sourceCrystalEstimatedMonthlyInterest)}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{tx("上次计息", "Last interest update")}: {fmtTs(sourceCrystal?.last_interest_at)}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{tx(
|
||
"仅管理员可在管理页为你存入源晶;你可在此自行支出并填写备注。",
|
||
"Only admin can deposit Source Crystals for you; you can spend here with notes."
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
{/* Daily Tasks */}
|
||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4 relative">
|
||
<h2 className={`${sectionTitleClass} mb-4 flex justify-between items-center font-minecraft`}>
|
||
<span>每日悬赏任务</span>
|
||
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP · 🔥 {learningStreak}d · {dailyDayKey || "--"}</span>
|
||
</h2>
|
||
|
||
<div className="space-y-3">
|
||
{dailyTasks.map((task, idx) => (
|
||
<div key={idx} className="bg-[color:var(--mc-surface-soft)] p-3 border-2 border-[color:var(--mc-stone-dark)] relative group hover:border-[color:var(--mc-stone)] transition-colors">
|
||
<div className="flex items-start gap-3">
|
||
<div className={`w-5 h-5 border-2 border-[color:var(--mc-stone-dark)] flex items-center justify-center bg-black/30 mt-0.5 ${task.completed ? 'bg-[color:var(--mc-green)]/20' : ''}`}>
|
||
{task.completed && <CheckCircle2 size={16} className="text-[color:var(--mc-green)]" />}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex justify-between items-start mb-1">
|
||
<h3 className="text-[color:var(--mc-plank-light)] text-lg font-bold leading-tight flex items-center gap-2">
|
||
{task.title}
|
||
<span className="ml-2 text-[color:var(--mc-gold)] text-base font-minecraft flex items-center gap-1">
|
||
<Zap size={14} />
|
||
+{task.reward} XP
|
||
</span>
|
||
</h3>
|
||
</div>
|
||
<p className="text-[color:var(--mc-stone)] text-base leading-snug">
|
||
{task.description}
|
||
{task.completed && <span className="ml-2 text-[color:var(--mc-stone-dark)] italic text-sm">({fmtTs(task.completed_at)})</span>}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4">
|
||
<h3 className={`${sectionTitleClass} mb-3 font-minecraft`}>
|
||
{tx("物品图鉴", "Item Collection")}
|
||
</h3>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
{achievementItems.map((item) => (
|
||
<div
|
||
key={item.key}
|
||
className={`border-2 border-[color:var(--mc-stone-dark)] p-2 text-sm ${
|
||
item.unlock
|
||
? "bg-[color:var(--mc-grass-top)]/20 text-[color:var(--mc-plank-light)]"
|
||
: "bg-black/20 text-[color:var(--mc-stone)]"
|
||
}`}
|
||
>
|
||
<p className="font-bold flex items-center gap-2">
|
||
<span>{item.icon}</span>
|
||
{item.label}
|
||
{item.unlock ? <span className="text-[color:var(--mc-gold)]">✓</span> : null}
|
||
</p>
|
||
<p className="mt-1 text-xs">{item.hint}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<section className="flex-1 rounded-none border-[3px] border-black bg-[color:var(--mc-stone)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white">
|
||
<h2 className={`${sectionTitleClass} mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft`}>
|
||
<span className="text-2xl">💎</span>
|
||
<span>村民交易站</span>
|
||
<span className="ml-auto text-sm text-[color:var(--mc-stone-dark)]">消耗: RATING</span>
|
||
</h2>
|
||
|
||
<div className="grid gap-2">
|
||
<div className="flex gap-2 text-black">
|
||
<div className="relative flex-1">
|
||
<ShoppingBag className="absolute left-2 top-2 text-black/50 pointer-events-none" size={16} />
|
||
<select
|
||
className="w-full rounded-none border-2 border-black bg-[color:var(--surface)] px-2 py-1 pl-8 text-base font-bold appearance-none"
|
||
value={selectedItemId}
|
||
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
||
>
|
||
<option value={0}>{tx("选择战利品...", "Select loot...")}</option>
|
||
{items.map((item) => (
|
||
<option key={item.id} value={item.id}>
|
||
{itemName(item.name)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<input
|
||
className="w-20 rounded-none border-2 border-black bg-[color:var(--surface)] px-2 py-1 text-base font-bold text-center"
|
||
type="number"
|
||
min={1}
|
||
max={64}
|
||
value={quantity}
|
||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||
/>
|
||
</div>
|
||
|
||
{selectedItem && (
|
||
<div className="bg-[color:var(--mc-stone)]/20 p-3 border border-[color:var(--mc-stone)]/30 rounded-none text-base text-[color:var(--mc-obsidian)]">
|
||
<p>{selectedItem.description}</p>
|
||
<p className="mt-1 text-[color:var(--mc-wood-dark)]">
|
||
{tx("单价", "Unit cost")}:{" "}
|
||
{currentRedeemDayType === "holiday"
|
||
? selectedItem.holiday_cost
|
||
: selectedItem.studyday_cost}{" "}
|
||
Rating / {selectedItem.unit_label}
|
||
</p>
|
||
<p className="mt-1 text-sm text-[color:var(--mc-stone-dark)]">
|
||
{currentRedeemDayType === "holiday"
|
||
? tx("今日判定:假期", "Today: Holiday")
|
||
: tx("今日判定:学习日", "Today: Study Day")}
|
||
{redeemDayType?.reason ? ` · ${redeemDayType.reason}` : ""}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<div className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] px-2 py-2 text-sm text-black">
|
||
{currentRedeemDayType === "holiday"
|
||
? tx("自动使用假期价格", "Auto using holiday price")
|
||
: tx("自动使用学习日价格", "Auto using study-day price")}
|
||
</div>
|
||
<button
|
||
className="mc-btn mc-btn-success text-xs px-4 flex items-center gap-2"
|
||
onClick={() => void redeem()}
|
||
disabled={redeemLoading || !selectedItemId}
|
||
>
|
||
<ArrowRightLeft size={14} />
|
||
{tx("交易", "Trade")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||
<SourceCrystalIcon size={20} className={sectionIconClass} />
|
||
{tx("源晶支出与流水", "Source Crystal Spend & History")}
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="mt-3 grid gap-2 md:grid-cols-[160px_1fr_auto]">
|
||
<input
|
||
className="rounded border px-3 py-2 text-sm"
|
||
type="number"
|
||
min={0.01}
|
||
step={0.01}
|
||
value={crystalAmount}
|
||
onChange={(e) => setCrystalAmount(e.target.value)}
|
||
placeholder={tx("数量", "Amount")}
|
||
/>
|
||
<input
|
||
className="rounded border px-3 py-2 text-sm"
|
||
value={crystalNote}
|
||
onChange={(e) => setCrystalNote(e.target.value)}
|
||
placeholder={tx("备注(可选)", "Note (optional)")}
|
||
/>
|
||
<button
|
||
className="mc-btn mc-btn-danger text-xs flex items-center gap-1"
|
||
onClick={() => void withdrawSourceCrystal()}
|
||
disabled={crystalLoading}
|
||
>
|
||
<CircleMinus size={14} />
|
||
{tx("支出", "Spend")}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto rounded border border-zinc-200 bg-white p-2 text-xs">
|
||
{sourceCrystalRecords.map((row) => (
|
||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||
<span>
|
||
<span className={`font-bold ${row.amount >= 0 ? "text-emerald-700" : "text-red-600"}`}>
|
||
{row.amount >= 0 ? "+" : ""}
|
||
{fmtCrystal(row.amount)} {tx("源晶", "SC")}
|
||
</span>
|
||
<span className="ml-2 text-zinc-700">{crystalTxLabel(row.tx_type)}</span>
|
||
{row.note ? <span className="ml-2 text-zinc-500">· {row.note}</span> : null}
|
||
</span>
|
||
<span className="text-zinc-500">
|
||
{tx("余额", "Bal")}: {fmtCrystal(row.balance_after)} · {fmtTs(row.created_at)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{!loading && sourceCrystalRecords.length === 0 && (
|
||
<p className="text-zinc-500">{tx("暂无源晶流水。", "No source crystal records yet.")}</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||
<Zap size={18} className={sectionIconClass} />
|
||
{tx("经验值系统", "Experience")}
|
||
</h2>
|
||
<button
|
||
className="mc-btn text-xs px-3 py-1"
|
||
onClick={() => void toggleExperienceHistory()}
|
||
disabled={experienceHistoryLoading}
|
||
>
|
||
{experienceHistoryOpen
|
||
? tx("收起经验历史", "Hide XP History")
|
||
: tx("查看经验历史", "View XP History")}
|
||
</button>
|
||
</div>
|
||
<p className="mt-1 text-xs text-zinc-600">
|
||
{tx(
|
||
"规则:1 经验值 = 1 Rating 增量;消费 Rating 不会减少经验值。",
|
||
"Rule: 1 XP = 1 rating gain; spending rating never decreases XP."
|
||
)}
|
||
</p>
|
||
<div className="mt-3 rounded border border-zinc-300 bg-white p-3">
|
||
<p className="text-sm font-bold text-[color:var(--mc-diamond)]">
|
||
{tx("当前经验", "Current XP")}: {expValue}
|
||
</p>
|
||
<p className="mt-1 text-xs text-zinc-600">
|
||
{tx("当前等级", "Level")}: Lv.{expLevel}
|
||
</p>
|
||
<div className="mt-2 h-2 overflow-hidden rounded bg-zinc-200">
|
||
<div
|
||
className="h-full bg-[color:var(--mc-diamond)]"
|
||
style={{ width: `${(expProgress * 100).toFixed(2)}%` }}
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{expValue} / {expNext} {tx("(下一级)", "(next level)")}
|
||
</p>
|
||
</div>
|
||
|
||
{experienceHistoryOpen && (
|
||
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto rounded border border-zinc-200 bg-white p-2 text-xs">
|
||
{experienceHistoryLoading && (
|
||
<p className="text-zinc-500">{tx("加载经验历史中...", "Loading XP history...")}</p>
|
||
)}
|
||
{!experienceHistoryLoading && experienceHistory.map((row) => (
|
||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||
<span>
|
||
<span className="font-bold text-emerald-700">+{row.xp_delta} XP</span>
|
||
<span className="ml-2 text-zinc-700">
|
||
{tx("Rating", "Rating")} {row.rating_before} → {row.rating_after}
|
||
</span>
|
||
{row.note ? <span className="ml-2 text-zinc-500">· {row.note}</span> : null}
|
||
</span>
|
||
<span className="text-zinc-500">{fmtTs(row.created_at)}</span>
|
||
</div>
|
||
))}
|
||
{!experienceHistoryLoading && experienceHistory.length === 0 && (
|
||
<p className="text-zinc-500">{tx("暂无经验历史。", "No XP history yet.")}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Rating History Section */}
|
||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||
<History size={18} className={sectionIconClass} />
|
||
{tx("积分变动记录", "Rating History")}
|
||
</h2>
|
||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||
<span>{tx("类型", "Type")}</span>
|
||
<select
|
||
className="rounded border px-2 py-1 text-xs text-zinc-700"
|
||
value={ratingHistoryTypeFilter}
|
||
onChange={(e) => setRatingHistoryTypeFilter(e.target.value)}
|
||
>
|
||
<option value="all">{tx("全部", "All")}</option>
|
||
{ratingHistoryTypes.map((type) => (
|
||
<option key={type} value={type}>
|
||
{ratingTypeLabel(type)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="max-h-56 overflow-y-auto space-y-1 rounded border border-zinc-200 bg-white p-2 text-xs">
|
||
{filteredHistoryItems.map((item, idx) => (
|
||
<div key={idx} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||
<span>
|
||
<span className={`font-bold flex items-center gap-1 ${item.change > 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}>
|
||
{item.change > 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||
{item.change > 0 ? `+${item.change}` : item.change}
|
||
</span>
|
||
<span className="ml-2 text-zinc-700">{formatRatingNote(item.note, item.type)}</span>
|
||
<span className="ml-2 rounded border border-zinc-200 px-1 text-[11px] text-zinc-500">
|
||
{ratingTypeLabel(item.type)}
|
||
</span>
|
||
</span>
|
||
<span className="text-zinc-500">
|
||
{formatUnixDateTime(item.created_at)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{!loading && filteredHistoryItems.length === 0 && (
|
||
<p className="text-xs text-zinc-500">{tx("暂无记录。", "No history.")}</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Trades Section */}
|
||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||
<ArrowRightLeft size={18} className={sectionIconClass} />
|
||
{tx("交易记录", "Trade History")}
|
||
</h2>
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||
<span>{tx("类型", "Type")}</span>
|
||
<select
|
||
className="rounded border px-2 py-1 text-xs text-zinc-700"
|
||
value={tradeTypeFilter}
|
||
onChange={(e) => setTradeTypeFilter(e.target.value)}
|
||
>
|
||
<option value="all">{tx("全部", "All")}</option>
|
||
{tradeTypes.map((type) => (
|
||
<option key={type} value={type}>
|
||
{tradeTypeLabel(type)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="mc-btn text-xs px-3 py-1 flex items-center gap-1"
|
||
onClick={() => void loadAll()}
|
||
disabled={loading}
|
||
>
|
||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||
{tx("刷新", "Refresh")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-h-56 overflow-y-auto space-y-1 rounded border border-zinc-200 bg-white p-2 text-xs">
|
||
{filteredTradeRecords.map((row) => (
|
||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||
<span className="text-zinc-700">
|
||
{itemName(row.item_name)} × {row.quantity}
|
||
<span className="ml-2 rounded border border-zinc-200 px-1 text-[11px] text-zinc-500">
|
||
{tradeTypeLabel(row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")}
|
||
</span>
|
||
</span>
|
||
<span className="text-zinc-500">
|
||
-{row.total_cost} Gems · {formatUnixDate(row.created_at)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{!loading && filteredTradeRecords.length === 0 && (
|
||
<p className="text-xs text-zinc-500">{tx("暂无交易。", "No trades.")}</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|