feat: add streak reward and 100-achievement milestone system

这个提交包含在:
cryptocommuniums-afk
2026-02-23 20:29:14 +08:00
父节点 43cbd38bac
当前提交 0b53113a4b
修改 14 个文件,包含 1334 行新增57 行删除

查看文件

@@ -106,6 +106,45 @@ type ExperienceHistoryItem = {
created_at: number;
};
type AchievementItem = {
key: string;
icon: string;
title: string;
description: string;
honor: number;
progress: number;
target: number;
completed: boolean;
};
type AchievementMilestone = {
milestone_no: number;
completed_count_snapshot: number;
rating_bonus: number;
created_at: number;
};
type AchievementSummary = {
total_count: number;
completed_count: number;
honor_total: number;
milestone_step: number;
milestone_bonus_rating: number;
milestones_awarded: number;
bonus_milestones_added_now: number;
bonus_rating_added_now: number;
rating_bonus_awarded_total: number;
next_milestone_completed_required: number;
next_milestone_remaining: number;
rating_after_apply: number;
};
type AchievementSnapshot = {
summary: AchievementSummary;
items: AchievementItem[];
milestone_logs: AchievementMilestone[];
};
type DailyTaskItem = {
code: string;
title: string;
@@ -193,6 +232,13 @@ export default function MePage() {
const [experienceHistory, setExperienceHistory] = useState<ExperienceHistoryItem[]>([]);
const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false);
const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false);
const [achievementPanelOpen, setAchievementPanelOpen] = useState(false);
const [achievementLoading, setAchievementLoading] = useState(false);
const [achievementLoaded, setAchievementLoaded] = useState(false);
const [achievementSummary, setAchievementSummary] = useState<AchievementSummary | null>(null);
const [achievementItems, setAchievementItems] = useState<AchievementItem[]>([]);
const [achievementMilestones, setAchievementMilestones] = useState<AchievementMilestone[]>([]);
const [achievementError, setAchievementError] = useState("");
const [selectedItemId, setSelectedItemId] = useState<number>(0);
const [quantity, setQuantity] = useState(1);
@@ -246,6 +292,7 @@ export default function MePage() {
daily_submit: ["每日提交 📝", "Daily Submission 📝"],
first_ac: ["首次通过 ⭐", "First AC ⭐"],
code_quality: ["代码质量 🛠️", "Code Quality 🛠️"],
learning_streak_3d: ["连学奖励 🔥", "Streak Bonus 🔥"],
};
if (type === "daily_task") {
if (taskLabels[note]) {
@@ -460,6 +507,13 @@ export default function MePage() {
setExperience(null);
setExperienceHistory([]);
setExperienceHistoryOpen(false);
setAchievementPanelOpen(false);
setAchievementLoading(false);
setAchievementLoaded(false);
setAchievementSummary(null);
setAchievementItems([]);
setAchievementMilestones([]);
setAchievementError("");
showToast(
"success",
tx("已断开连接并退出登录。", "Disconnected and signed out.")
@@ -529,40 +583,43 @@ export default function MePage() {
}),
[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]);
const achievementCompletedCount = achievementSummary?.completed_count ?? 0;
const achievementTotalCount = achievementSummary?.total_count ?? 100;
const achievementHonorTotal = achievementSummary?.honor_total ?? 0;
const toggleAchievementPanel = useCallback(async () => {
const next = !achievementPanelOpen;
setAchievementPanelOpen(next);
if (!next || achievementLoaded || achievementLoading) return;
if (!token) return;
setAchievementError("");
setAchievementLoading(true);
try {
const data = await apiFetch<AchievementSnapshot>("/api/v1/me/achievements", {}, token);
setAchievementSummary(data.summary ?? null);
setAchievementItems(Array.isArray(data.items) ? data.items : []);
setAchievementMilestones(Array.isArray(data.milestone_logs) ? data.milestone_logs : []);
setAchievementLoaded(true);
if (Number.isFinite(data.summary?.rating_after_apply)) {
setProfile((prev) =>
prev ? { ...prev, rating: Number(data.summary.rating_after_apply) } : prev
);
}
if ((data.summary?.bonus_rating_added_now ?? 0) > 0) {
showToast(
"success",
tx(
`成就里程碑达成,额外获得 +${data.summary.bonus_rating_added_now} Rating`,
`Achievement milestone reached: +${data.summary.bonus_rating_added_now} rating bonus`
)
);
}
} catch (e: unknown) {
setAchievementError(String(e));
} finally {
setAchievementLoading(false);
}
}, [achievementLoaded, achievementLoading, achievementPanelOpen, showToast, token, 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">
@@ -777,28 +834,109 @@ export default function MePage() {
</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 className="flex flex-wrap items-center justify-between gap-2">
<h3 className={`${sectionTitleClass} font-minecraft`}>
{tx("物品鉴定(成就系统)", "Item Appraisal (Achievements)")}
</h3>
<button
className="mc-btn text-xs px-3 py-1"
onClick={() => void toggleAchievementPanel()}
disabled={achievementLoading}
>
{achievementPanelOpen ? tx("收起", "Collapse") : tx("展开", "Expand")}
</button>
</div>
<p className="mt-2 text-xs text-[color:var(--mc-stone-dark)]">
{tx("默认隐藏,点击展开查看。", "Hidden by default, click to expand.")} ·{" "}
{tx("已完成", "Completed")}: {achievementCompletedCount}/{achievementTotalCount} ·{" "}
{tx("荣誉分", "Honor")}: {achievementHonorTotal}
</p>
{achievementPanelOpen && (
<div className="mt-3 space-y-2">
{achievementLoading && (
<p className="text-xs text-[color:var(--mc-stone-dark)]">
{tx("加载成就中...", "Loading achievements...")}
</p>
)}
{!achievementLoading && achievementError && (
<p className="text-xs text-red-700">{achievementError}</p>
)}
{!achievementLoading && !achievementError && achievementSummary && (
<div className="rounded border border-zinc-300 bg-white p-3 text-xs text-zinc-700">
<p>
{tx("规则", "Rule")}: {tx("每完成", "Every")}{" "}
{achievementSummary.milestone_step}{" "}
{tx("个成就奖励", "achievements reward")}{" "}
<span className="font-bold text-[color:var(--mc-gold)]">
+{achievementSummary.milestone_bonus_rating} Rating
</span>
</p>
<p className="mt-1">
{tx("已获里程碑奖励", "Milestones awarded")}:{" "}
{achievementSummary.milestones_awarded} · {tx("累计奖励", "Total bonus")}{" "}
+{achievementSummary.rating_bonus_awarded_total} Rating
</p>
{achievementSummary.next_milestone_completed_required > 0 ? (
<p className="mt-1">
{tx("下个里程碑", "Next milestone")}:{" "}
{achievementSummary.next_milestone_completed_required} ·{" "}
{tx("还差", "Remaining")} {achievementSummary.next_milestone_remaining}
</p>
) : (
<p className="mt-1 text-emerald-700">
{tx("已完成全部成就!", "All achievements completed!")}
</p>
)}
{achievementMilestones.length > 0 && (
<div className="mt-2 rounded border border-zinc-200 bg-zinc-50 p-2 text-[11px] text-zinc-600">
<p className="font-semibold text-zinc-700">
{tx("最近里程碑奖励", "Recent milestone bonuses")}
</p>
{achievementMilestones.slice(0, 3).map((row) => (
<p key={row.milestone_no} className="mt-1">
#{row.milestone_no} · +{row.rating_bonus} Rating ·{" "}
{tx("完成", "Completed")} {row.completed_count_snapshot} · {fmtTs(row.created_at)}
</p>
))}
</div>
)}
</div>
)}
{!achievementLoading && !achievementError && (
<div className="max-h-80 overflow-y-auto rounded border border-zinc-300 bg-white p-2">
<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.completed
? "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.title}
{item.completed ? <span className="text-[color:var(--mc-gold)]"></span> : null}
</p>
<p className="mt-1 text-xs">{item.description}</p>
<p className="mt-1 text-[11px]">
{tx("进度", "Progress")}: {item.progress}/{item.target} ·{" "}
{tx("荣誉", "Honor")} +{item.honor}
</p>
</div>
))}
{!achievementLoading && achievementItems.length === 0 && (
<p className="text-xs text-zinc-500">
{tx("暂无成就数据。", "No achievements yet.")}
</p>
)}
</div>
</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`}>