feat: add streak reward and 100-achievement milestone system
这个提交包含在:
@@ -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`}>
|
||||
|
||||
在新工单中引用
屏蔽一个用户