feat: Minecraft theme overhaul, fix points bug, add history

这个提交包含在:
X
2026-02-15 09:41:54 -08:00
父节点 37266bb846
当前提交 ef6d71ef54
修改 28 个文件,包含 1821 行新增1053 行删除

查看文件

@@ -10,40 +10,59 @@ class MeController : public drogon::HttpController<MeController> {
public: public:
METHOD_LIST_BEGIN METHOD_LIST_BEGIN
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get); ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get); ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items",
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get); drogon::Get);
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post); ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records",
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get); drogon::Get);
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get); ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records",
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch); drogon::Post);
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete); ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks",
drogon::Get);
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book",
drogon::Get);
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}",
drogon::Patch);
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}",
drogon::Delete);
ADD_METHOD_TO(MeController::listRatingHistory, "/api/v1/me/rating-history",
drogon::Get);
METHOD_LIST_END METHOD_LIST_END
void profile(const drogon::HttpRequestPtr &req, void profile(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb); std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void listRedeemItems(const drogon::HttpRequestPtr& req, void
listRedeemItems(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb); std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void listRedeemRecords(const drogon::HttpRequestPtr& req, void
listRedeemRecords(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb); std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void createRedeemRecord(const drogon::HttpRequestPtr& req, void
createRedeemRecord(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb); std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void listDailyTasks(const drogon::HttpRequestPtr& req, void
listDailyTasks(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb); std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void listWrongBook(const drogon::HttpRequestPtr &req, void listWrongBook(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb); std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void upsertWrongBookNote(const drogon::HttpRequestPtr& req, void
upsertWrongBookNote(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb, std::function<void(const drogon::HttpResponsePtr &)> &&cb,
int64_t problem_id); int64_t problem_id);
void deleteWrongBookItem(const drogon::HttpRequestPtr& req, void
deleteWrongBookItem(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb, std::function<void(const drogon::HttpResponsePtr &)> &&cb,
int64_t problem_id); int64_t problem_id);
void
listRatingHistory(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
}; };
} // namespace csp::controllers } // namespace csp::controllers

查看文件

@@ -5,9 +5,17 @@
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector>
namespace csp::services { namespace csp::services {
struct SolutionViewStats {
int total_views = 0;
int total_cost = 0;
std::optional<int64_t> last_viewed_at;
bool has_viewed = false;
};
struct SolutionViewChargeResult { struct SolutionViewChargeResult {
bool granted = true; bool granted = true;
bool charged = false; bool charged = false;
@@ -21,21 +29,26 @@ struct SolutionViewChargeResult {
std::string deny_reason; std::string deny_reason;
}; };
struct SolutionViewStats { struct RatingHistoryItem {
bool has_viewed = false; std::string type;
int total_views = 0; int64_t created_at;
int total_cost = 0; int change;
std::optional<int64_t> last_viewed_at; std::string note;
}; };
class SolutionAccessService { class SolutionAccessService {
public: public:
explicit SolutionAccessService(db::SqliteDb &db) : db_(db) {} explicit SolutionAccessService(db::SqliteDb &db) : db_(db) {}
// Daily policy: first answer view is free, then each full view costs 2 rating. // Daily policy: first answer view is free, then each full view costs 2
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id); // rating.
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id,
int64_t problem_id);
SolutionViewStats QueryUserProblemViewStats(int64_t user_id, int64_t problem_id); SolutionViewStats QueryUserProblemViewStats(int64_t user_id,
int64_t problem_id);
std::vector<RatingHistoryItem> ListRatingHistory(int64_t user_id, int limit);
private: private:
db::SqliteDb &db_; db::SqliteDb &db_;

查看文件

@@ -4,6 +4,7 @@
#include "csp/domain/json.h" #include "csp/domain/json.h"
#include "csp/services/daily_task_service.h" #include "csp/services/daily_task_service.h"
#include "csp/services/redeem_service.h" #include "csp/services/redeem_service.h"
#include "csp/services/solution_access_service.h"
#include "csp/services/user_service.h" #include "csp/services/user_service.h"
#include "csp/services/wrong_book_service.h" #include "csp/services/wrong_book_service.h"
#include "http_auth.h" #include "http_auth.h"
@@ -37,7 +38,8 @@ drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
return resp; return resp;
} }
std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req, std::optional<int64_t>
RequireAuth(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &cb) { std::function<void(const drogon::HttpResponsePtr &)> &cb) {
std::string auth_error; std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error); const auto user_id = GetAuthedUserId(req, auth_error);
@@ -48,11 +50,10 @@ std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
return user_id; return user_id;
} }
int ParseClampedInt(const std::string& s, int ParseClampedInt(const std::string &s, int default_value, int min_value,
int default_value,
int min_value,
int max_value) { int max_value) {
if (s.empty()) return default_value; if (s.empty())
return default_value;
const int value = std::stoi(s); const int value = std::stoi(s);
return std::max(min_value, std::min(max_value, value)); return std::max(min_value, std::min(max_value, value));
} }
@@ -64,7 +65,8 @@ void MeController::profile(
std::function<void(const drogon::HttpResponsePtr &)> &&cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::UserService users(csp::AppState::Instance().db()); services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id); const auto user = users.GetById(*user_id);
@@ -83,7 +85,8 @@ void MeController::listRedeemItems(
const drogon::HttpRequestPtr &req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
if (!RequireAuth(req, cb).has_value()) return; if (!RequireAuth(req, cb).has_value())
return;
services::RedeemService redeem(csp::AppState::Instance().db()); services::RedeemService redeem(csp::AppState::Instance().db());
const auto items = redeem.ListItems(false); const auto items = redeem.ListItems(false);
@@ -114,7 +117,8 @@ void MeController::listRedeemRecords(
std::function<void(const drogon::HttpResponsePtr &)> &&cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
services::RedeemService redeem(csp::AppState::Instance().db()); services::RedeemService redeem(csp::AppState::Instance().db());
@@ -150,7 +154,8 @@ void MeController::createRedeemRecord(
std::function<void(const drogon::HttpResponsePtr &)> &&cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
const auto json = req->getJsonObject(); const auto json = req->getJsonObject();
if (!json) { if (!json) {
@@ -198,7 +203,8 @@ void MeController::listDailyTasks(
std::function<void(const drogon::HttpResponsePtr &)> &&cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::DailyTaskService tasks(csp::AppState::Instance().db()); services::DailyTaskService tasks(csp::AppState::Instance().db());
const auto rows = tasks.ListTodayTasks(*user_id); const auto rows = tasks.ListTodayTasks(*user_id);
@@ -239,7 +245,8 @@ void MeController::listWrongBook(
std::function<void(const drogon::HttpResponsePtr &)> &&cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::WrongBookService wrong_book(csp::AppState::Instance().db()); services::WrongBookService wrong_book(csp::AppState::Instance().db());
const auto rows = wrong_book.ListByUser(*user_id); const auto rows = wrong_book.ListByUser(*user_id);
@@ -263,7 +270,8 @@ void MeController::upsertWrongBookNote(
int64_t problem_id) { int64_t problem_id) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
const auto json = req->getJsonObject(); const auto json = req->getJsonObject();
if (!json) { if (!json) {
@@ -296,7 +304,8 @@ void MeController::deleteWrongBookItem(
int64_t problem_id) { int64_t problem_id) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::WrongBookService wrong_book(csp::AppState::Instance().db()); services::WrongBookService wrong_book(csp::AppState::Instance().db());
wrong_book.Remove(*user_id, problem_id); wrong_book.Remove(*user_id, problem_id);
@@ -311,4 +320,30 @@ void MeController::deleteWrongBookItem(
} }
} }
void MeController::listRatingHistory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value())
return;
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
const auto rows = access_svc.ListRatingHistory(*user_id, limit);
Json::Value arr(Json::arrayValue);
for (const auto &row : rows) {
Json::Value j;
j["type"] = row.type;
j["created_at"] = Json::Int64(row.created_at);
j["change"] = row.change;
j["note"] = row.note;
arr.append(j);
}
cb(JsonOk(arr));
} catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers } // namespace csp::controllers

查看文件

@@ -19,7 +19,8 @@ int64_t NowSec() {
} }
void CheckSqlite(int rc, sqlite3 *db, const char *what) { void CheckSqlite(int rc, sqlite3 *db, const char *what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE)
return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
} }
@@ -54,36 +55,33 @@ int QueryRating(sqlite3* db, int64_t user_id) {
int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) { int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) {
sqlite3_stmt *stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs WHERE "
"SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?"; "user_id=? AND day_key=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query daily usage"); "prepare query daily usage");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db, CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT),
"bind day_key"); db, "bind day_key");
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage"); CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
const int used = sqlite3_column_int(stmt, 0); const int used = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
return used; return used;
} }
void InsertViewLog(sqlite3* db, void InsertViewLog(sqlite3 *db, int64_t user_id, int64_t problem_id,
int64_t user_id, const std::string &day_key, int64_t viewed_at, bool charged,
int64_t problem_id,
const std::string& day_key,
int64_t viewed_at,
bool charged,
int cost) { int cost) {
sqlite3_stmt *stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = const char *sql = "INSERT INTO "
"INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) " "problem_solution_view_logs(user_id,problem_id,day_key,"
"viewed_at,charged,cost,created_at) "
"VALUES(?,?,?,?,?,?,?)"; "VALUES(?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert solution view log"); "prepare insert solution view log");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
"bind day_key"); db, "bind day_key");
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at"); CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged"); CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost"); CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
@@ -105,8 +103,24 @@ void DeductRating(sqlite3* db, int64_t user_id, int cost) {
} // namespace } // namespace
SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( // ... (Helper functions)
int64_t user_id,
bool QueryHasPurchased(sqlite3 *db, int64_t user_id, int64_t problem_id) {
sqlite3_stmt *stmt = nullptr;
const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs "
"WHERE user_id=? AND problem_id=? AND charged=1";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query has purchased");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_step(stmt), db, "step query has purchased");
const int count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count > 0;
}
SolutionViewChargeResult
SolutionAccessService::ConsumeSolutionView(int64_t user_id,
int64_t problem_id) { int64_t problem_id) {
if (user_id <= 0 || problem_id <= 0) { if (user_id <= 0 || problem_id <= 0) {
throw std::runtime_error("invalid user_id/problem_id"); throw std::runtime_error("invalid user_id/problem_id");
@@ -123,6 +137,26 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
result.day_key = day_key; result.day_key = day_key;
result.viewed_at = now; result.viewed_at = now;
// Check if already purchased (charged=1 before)
if (QueryHasPurchased(db, user_id, problem_id)) {
result.daily_used_count =
QueryDailyUsage(db, user_id, day_key); // Just for info
result.rating_before = QueryRating(db, user_id);
result.charged = false;
result.daily_free = false; // Not "free" quota, but "owned"
result.cost = 0;
result.rating_after = result.rating_before;
result.granted = true;
// We still log this view, but charged=0, cost=0.
// This keeps track of "views" even if free.
InsertViewLog(db, user_id, problem_id, day_key, now, false, 0);
db_.Exec("COMMIT");
committed = true;
return result;
}
const int used_before = QueryDailyUsage(db, user_id, day_key); const int used_before = QueryDailyUsage(db, user_id, day_key);
result.daily_used_count = used_before; result.daily_used_count = used_before;
const int rating_before = QueryRating(db, user_id); const int rating_before = QueryRating(db, user_id);
@@ -150,12 +184,7 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
DeductRating(db, user_id, kViewCost); DeductRating(db, user_id, kViewCost);
} }
InsertViewLog(db, InsertViewLog(db, user_id, problem_id, day_key, now, result.charged,
user_id,
problem_id,
day_key,
now,
result.charged,
result.cost); result.cost);
result.daily_used_count = used_before + 1; result.daily_used_count = used_before + 1;
@@ -173,11 +202,12 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
} }
} }
SolutionViewStats SolutionAccessService::QueryUserProblemViewStats( SolutionViewStats
int64_t user_id, SolutionAccessService::QueryUserProblemViewStats(int64_t user_id,
int64_t problem_id) { int64_t problem_id) {
SolutionViewStats stats; SolutionViewStats stats;
if (user_id <= 0 || problem_id <= 0) return stats; if (user_id <= 0 || problem_id <= 0)
return stats;
sqlite3 *db = db_.raw(); sqlite3 *db = db_.raw();
sqlite3_stmt *stmt = nullptr; sqlite3_stmt *stmt = nullptr;
@@ -200,4 +230,49 @@ SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
return stats; return stats;
} }
std::vector<RatingHistoryItem>
SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) {
if (limit <= 0)
limit = 100;
if (limit > 500)
limit = 500;
std::vector<RatingHistoryItem> items;
sqlite3 *db = db_.raw();
sqlite3_stmt *stmt = nullptr;
// Union query for history
const char *sql =
"SELECT 'solution_view' as type, created_at, -cost as change, ('Problem "
"' || problem_id) as note "
"FROM problem_solution_view_logs WHERE user_id=? AND cost > 0 "
"UNION ALL "
"SELECT 'daily_task' as type, created_at, reward as change, title as "
"note "
"FROM daily_task_logs WHERE user_id=? "
"UNION ALL "
"SELECT 'redeem' as type, created_at, -total_cost as change, item_name "
"as note "
"FROM redeem_logs WHERE user_id=? "
"ORDER BY created_at DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare history query");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id 1");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id 2");
CheckSqlite(sqlite3_bind_int64(stmt, 3, user_id), db, "bind user_id 3");
CheckSqlite(sqlite3_bind_int(stmt, 4, limit), db, "bind limit");
while (sqlite3_step(stmt) == SQLITE_ROW) {
RatingHistoryItem item;
item.type = ColText(stmt, 0);
item.created_at = sqlite3_column_int64(stmt, 1);
item.change = sqlite3_column_int(stmt, 2);
item.note = ColText(stmt, 3);
items.push_back(item);
}
sqlite3_finalize(stmt);
return items;
}
} // namespace csp::services } // namespace csp::services

56
frontend/GAMEPLAY.md 普通文件
查看文件

@@ -0,0 +1,56 @@
# CSP Adventure Guide
Welcome to the CSP Adventure Server! This document explains the new gameplay mechanics and terminology used in the platform.
## 🗺️ The World (Interface)
The entire platform has been re-enchanted with a **Minecraft-themed** interface.
- **Font**: Pixelated fonts (`Press Start 2P`, `VT323`) for that authentic retro feel.
- **Blocks**: Buttons and panels are styled like in-game blocks (Wood, Stone, Obsidian).
- **Day/Night**: Theme follows your system preference, but "Dark Mode" is the recommended "Cave" experience.
## ⚔️ Quest Board (Problem List)
The **Quest Board** is where you find your challenges.
- **Wood Tier (Levels 1-2)**: 🪵 Beginner tasks. Chop wood, build a crafting table.
- **Stone Tier (Levels 3-4)**: 🪨 Intermediate logic. Stone tools required.
- **Iron Tier (Levels 5-6)**: ⚔️ Hard algorithms. Iron armor recommended.
- **Diamond Tier (Levels 7-8)**: 💎 Expert challenges. Bring potions.
- **Netherite Tier (Levels 9+)**: 🔥 Legendary problems. Only for the bravest adventurers.
## 📜 Mission Scroll (Problem Detail)
When you accept a quest, you receive a **Mission Scroll**.
- **Objective**: Read the parchment to understand the task.
- **Enchantment Table (Editor)**: Use the obsidian table to write your spells (code).
- **Runes (Input/Output)**: Test your spells with input blocks.
- **Casting (Run/Submit)**:
- **Test Run**: Cast a test spell to see if it fizzles.
- **Submit Quest**: Offer your solution to the server for judgment.
## 🛡️ Character Sheet (Profile)
Your **Profile** is your inventory and status screen.
- **Avatar**: A unique pixel-art avatar generated from your soul signature (username).
- **Level**: Your coding proficiency level, calculated from your total Rating (XP).
- **Rank**:
- **Wood Rank**: < 1000 Rating
- **Stone Rank**: 1000 - 1199
- **Iron Rank**: 1200 - 1499
- **Diamond Rank**: 1500 - 1999
- **Netherite Rank**: 2000+
- **Daily Quests**: Complete daily tasks (Sign-in, First Blood, Craftsman) to earn extra XP.
## 💰 Trading Post (Redeem)
Visit the **Villager Trading Post** to spend your hard-earned Rating (Emeralds).
- Exchange Rating for tangible rewards or special server privileges.
- Watch out for "Holiday Prices" vs "Workday Prices"!
## 🏹 Raids (Contests)
**Raids** are timed competitive events.
- Join a Raid to compete against other adventurers.
- Climb the **Leaderboard** to prove your strength.
Happy Coding, Adventurer!

查看文件

@@ -1,4 +1,13 @@
# Frontend (Next.js) # Frontend (Next.js) - Minecraft Edition 🧱
This project now features a full **Minecraft-themed UI overhaul**!
📖 **[Read the Gameplay Guide](./GAMEPLAY.md)** for details on the RPG mechanics, Quest Board, and more.
## Theme Features
- **8-bit Aesthetic**: Uses `Press Start 2P` and `VT323` fonts.
- **RPG Terminology**: Problems are "Quests", Submissions are "Spells".
- **Gamification**: XP Bar, Levels, and Trading Post.
## 开发 ## 开发
@@ -28,16 +37,16 @@ npm run start
## 页面 ## 页面
- `/auth` 登录/注册 - `/auth` 登录/注册
- `/problems` 题库列表 - `/problems` Quest Board (Questions)
- `/problems/:id` 题目详情与提交 - `/problems/:id` 题目详情与提交
- `/submissions` 提交列表 - `/submissions` Spell History (Submissions)
- `/submissions/:id` 提交详情 - `/submissions/:id` 提交详情
- `/wrong-book` 错题本 - `/wrong-book` Cursed Tome (Wrong Book)
- `/contests` 模拟竞赛列表 - `/contests` Raid Board (Contests)
- `/contests/:id` 比赛详情/报名/排行榜 - `/contests/:id` 比赛详情/报名/排行榜
- `/kb` 知识库列表 - `/kb` 知识库列表
- `/kb/:slug` 文章详情 - `/kb/:slug` 文章详情
- `/imports` 题库导入任务状态与结果 - `/imports` 题库导入任务状态与结果
- `/run` 在线 C++ 运行 - `/run` 在线 C++ 运行
- `/me` 当前用户信息 - `/me` 当前用户信息
- `/leaderboard` 全站排行 - `/leaderboard` Hall of Fame (Rankings)

查看文件

@@ -13,9 +13,9 @@ type AuthErr = { ok: false; error: string };
type AuthResp = AuthOk | AuthErr; type AuthResp = AuthOk | AuthErr;
function passwordScore(password: string, isZh: boolean): { label: string; color: string } { function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
if (password.length >= 12) return { label: isZh ? "" : "Strong", color: "text-emerald-600" }; if (password.length >= 12) return { label: isZh ? "钻石级" : "Diamond Tier", color: "text-[color:var(--mc-diamond)]" };
if (password.length >= 8) return { label: isZh ? "" : "Medium", color: "text-blue-600" }; if (password.length >= 8) return { label: isZh ? "铁级" : "Iron Tier", color: "text-zinc-400" };
return { label: isZh ? "" : "Weak", color: "text-orange-600" }; return { label: isZh ? "木级" : "Wood Tier", color: "text-[color:var(--mc-wood)]" };
} }
export default function AuthPage() { export default function AuthPage() {
@@ -83,27 +83,30 @@ export default function AuthPage() {
return ( return (
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-10"> <main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-10">
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]"> <div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100"> <section className="rounded-none border-[3px] border-black bg-[color:var(--mc-obsidian)] p-6 text-zinc-100 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<h1 className="text-2xl font-semibold">{tx("欢迎回来,开始刷题", "Welcome back, let's practice")}</h1> <h1 className="text-2xl font-bold text-[color:var(--mc-diamond)] mc-text-shadow leading-relaxed">
<p className="mt-3 text-sm text-zinc-300"> {tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")}
{tx("登录后可提交评测、保存草稿、查看错题本和个人进度。", "After sign-in you can submit, save drafts, review wrong-book, and track your progress.")} </h1>
<p className="mt-3 text-sm text-[color:var(--mc-stone)] font-mono">
{tx("登录服务器以访问任务布告栏、保存冒险进度、查看错题卷轴和个人成就。", "Login to access Quest Board, save Game Progress, review Grimoire, and track Achievements.")}
</p> </p>
<div className="mt-6 space-y-2 text-sm text-zinc-300"> <div className="mt-6 space-y-2 text-sm text-[color:var(--mc-stone)] font-mono">
<p>{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}</p> <p>{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}</p>
<p>{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}</p> <p>{tx("• 任务卷轴支持本地草稿与试运行", "• Quest Scrolls support local drafting and trial runs")}</p>
<p>{tx("• 生成式题解异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}</p> <p>{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}</p>
</div> </div>
<p className="mt-6 text-xs text-zinc-400"> <p className="mt-6 text-xs text-[color:var(--mc-stone-dark)]">
API Base: <span className="font-mono">{apiBase}</span> Server API: <span className="font-mono text-[color:var(--mc-red)]">{apiBase}</span>
</p> </p>
</section> </section>
<section className="rounded-2xl border bg-white p-6"> <section className="rounded-none border-[3px] border-black bg-[color:var(--surface)] p-6 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm"> <div className="grid grid-cols-2 gap-2 rounded-none bg-black/20 p-1 text-sm">
<button <button
type="button" type="button"
className={`rounded-md px-3 py-2 ${ className={`rounded-none px-3 py-2 border-[2px] transition-all ${mode === "login"
mode === "login" ? "bg-white shadow-sm" : "text-zinc-600" ? "bg-[color:var(--mc-wood)] border-black text-white shadow-[2px_2px_0_rgba(0,0,0,0.4)]"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`} }`}
onClick={() => { onClick={() => {
setMode("login"); setMode("login");
@@ -111,12 +114,13 @@ export default function AuthPage() {
}} }}
disabled={loading} disabled={loading}
> >
{tx("登录", "Sign In")} {tx("登录服务器", "Login")}
</button> </button>
<button <button
type="button" type="button"
className={`rounded-md px-3 py-2 ${ className={`rounded-none px-3 py-2 border-[2px] transition-all ${mode === "register"
mode === "register" ? "bg-white shadow-sm" : "text-zinc-600" ? "bg-[color:var(--mc-wood)] border-black text-white shadow-[2px_2px_0_rgba(0,0,0,0.4)]"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`} }`}
onClick={() => { onClick={() => {
setMode("register"); setMode("register");
@@ -124,91 +128,91 @@ export default function AuthPage() {
}} }}
disabled={loading} disabled={loading}
> >
{tx("注册", "Register")} {tx("新玩家注册", "New Player")}
</button> </button>
</div> </div>
<div className="mt-5 space-y-4"> <div className="mt-5 space-y-4 font-mono">
<div> <div>
<label className="text-sm font-medium">{tx("用户名", "Username")}</label> <label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("玩家代号", "Username")}</label>
<input <input
className="mt-1 w-full rounded-lg border px-3 py-2" className="mt-1 w-full"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder={tx("例如:csp_student", "e.g. csp_student")} placeholder={tx("例如:Steve", "e.g. Steve")}
/> />
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>} {usernameErr && <p className="mt-1 text-xs text-[color:var(--mc-red)]">{usernameErr}</p>}
</div> </div>
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-sm font-medium">{tx("密码", "Password")}</label> <label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("极其机密的口令", "Secret Password")}</label>
<span className={`text-xs ${strength.color}`}>{tx("强度", "Strength")}: {strength.label}</span> <span className={`text-xs ${strength.color}`}>{strength.label}</span>
</div> </div>
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2" className="mt-1 w-full"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder={tx("至少 6 ", "At least 6 chars")} placeholder={tx("至少 6 个字符", "Min 6 chars")}
/> />
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>} {passwordErr && <p className="mt-1 text-xs text-[color:var(--mc-red)]">{passwordErr}</p>}
</div> </div>
{mode === "register" && ( {mode === "register" && (
<div> <div>
<label className="text-sm font-medium">{tx("确认密码", "Confirm Password")}</label> <label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("确认口令", "Confirm Secret")}</label>
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2" className="mt-1 w-full"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={tx("再输入一次密码", "Enter password again")} placeholder={tx("再输入口令", "Re-enter secret")}
/> />
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>} {confirmErr && <p className="mt-1 text-xs text-[color:var(--mc-red)]">{confirmErr}</p>}
</div> </div>
)} )}
<label className="flex items-center gap-2 text-xs text-zinc-600"> <label className="flex items-center gap-2 text-xs text-[color:var(--mc-stone)] cursor-pointer select-none">
<input <input
type="checkbox" type="checkbox"
checked={showPassword} checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)} onChange={(e) => setShowPassword(e.target.checked)}
className="accent-[color:var(--mc-wood)]"
/> />
{tx("显示密码", "Show password")} {tx("显示口令", "Reveal Secret")}
</label> </label>
<button <button
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50" className={`w-full mc-btn ${mode === "register" ? "mc-btn-success" : ""}`}
onClick={() => void submit()} onClick={() => void submit()}
disabled={!canSubmit} disabled={!canSubmit}
> >
{loading ? tx("提交中...", "Submitting...") : mode === "register" ? tx("注册并登录", "Register & Sign In") : tx("登录", "Sign In")} {loading ? tx("连接中...", "Connecting...") : mode === "register" ? tx("创建档案并连接", "Create & Connect") : tx("连接服务器", "Connect")}
</button> </button>
</div> </div>
{resp && ( {resp && (
<div <div
className={`mt-4 rounded-lg border px-3 py-2 text-sm ${ className={`mt-4 border-[2px] border-black px-3 py-2 text-sm shadow-[2px_2px_0_rgba(0,0,0,0.4)] ${resp.ok ? "bg-[color:var(--mc-grass-dark)] text-white" : "bg-[color:var(--mc-red)] text-white"
resp.ok ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-red-300 bg-red-50 text-red-700"
}`} }`}
> >
{resp.ok {resp.ok
? tx("登录成功正在跳转到题库...", "Signed in. Redirecting to problem set...") ? tx("连接成功正在传送至出生点...", "Connection Established! Teleporting to Spawn Point...")
: `${tx("操作失败:", "Action failed: ")}${resp.error}`} : `${tx("连接失败:", "Connection Failed: ")}${resp.error}`}
</div> </div>
)} )}
<p className="mt-4 text-xs text-zinc-500"> <p className="mt-4 text-xs text-[color:var(--mc-stone-dark)]">
{tx("登录后 Token 自动保存在浏览器 localStorage,可直接前往", "Token is stored in browser localStorage after sign-in. You can go to")} {tx("令牌将保存在客户端存储中,可直接前往", "Token stored in client. Warp to")}
<Link className="mx-1 underline" href="/problems"> <Link className="mx-1 underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/problems">
{tx("题库", "Problems")} {tx("任务板", "Quest Board")}
</Link> </Link>
{tx("与", "and")} {tx("与", "or")}
<Link className="mx-1 underline" href="/me"> <Link className="mx-1 underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/me">
{tx("我的", "My Account")} {tx("角色面板", "Character Sheet")}
</Link> </Link>
{tx("页面。", ".")} {tx("。", ".")}
</p> </p>
</section> </section>
</div> </div>

查看文件

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n"; import { useI18nText } from "@/lib/i18n";
import { useUiPreferences } from "@/components/ui-preference-provider";
type Contest = { type Contest = {
id: number; id: number;
@@ -16,6 +17,8 @@ type Contest = {
export default function ContestsPage() { export default function ContestsPage() {
const { tx } = useI18nText(); const { tx } = useI18nText();
const { theme } = useUiPreferences();
const isMc = theme === "minecraft";
const [items, setItems] = useState<Contest[]>([]); const [items, setItems] = useState<Contest[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -37,11 +40,18 @@ export default function ContestsPage() {
}, []); }, []);
return ( return (
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8"> <main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
{tx("模拟竞赛", "Contests")} {isMc ? (
<span className="flex items-center gap-2">
<span></span>
{tx("突袭公告板", "Raid Board")}
</span>
) : (
tx("模拟竞赛", "Contests")
)}
</h1> </h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>} {loading && <p className="mt-3 text-sm text-zinc-500">{tx("正在寻找突袭目标...", "Scouting for raids...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>} {error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
@@ -49,13 +59,35 @@ export default function ContestsPage() {
<Link <Link
key={c.id} key={c.id}
href={`/contests/${c.id}`} href={`/contests/${c.id}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400" className={`block rounded-xl border p-4 transition-transform active:scale-[0.99] ${isMc
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white hover:border-white"
: "bg-white border-zinc-200 hover:border-zinc-400"
}`}
> >
<h2 className="text-lg font-medium">{c.title}</h2> <div className="flex items-start justify-between">
<p className="mt-1 text-xs text-zinc-500">{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p> <div>
<p className="text-xs text-zinc-500">{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p> <h2 className={`text-lg font-medium ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>
{isMc && <span className="mr-2">🛡</span>}
{c.title}
</h2>
<div className={`mt-2 text-xs ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
<p>{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p>
<p>{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
</div>
</div>
{isMc && (
<div className="hidden sm:block">
<span className="mc-btn px-3 py-1 text-xs">{tx("加入突袭", "Join Raid")}</span>
</div>
)}
</div>
</Link> </Link>
))} ))}
{!loading && items.length === 0 && (
<div className={`p-8 text-center border-2 border-dashed ${isMc ? "border-zinc-700 text-zinc-500 bg-black/20" : "border-zinc-200 text-zinc-500"}`}>
<p>{tx("暂无比赛", "No raids active")}</p>
</div>
)}
</div> </div>
</main> </main>
); );

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 25 KiB

之后

宽度:  |  高度:  |  大小: 19 KiB

查看文件

@@ -45,9 +45,47 @@ body {
} }
@media print { @media print {
/* ── Force clean whitebackground "default" style for printing ── */
:root {
--background: #fff !important;
--foreground: #171717 !important;
--surface: #fff !important;
--surface-soft: #f4f4f5 !important;
--border: #d4d4d8 !important;
/* Override Minecraftspecific variables */
--mc-obsidian: transparent !important;
--mc-stone: #555 !important;
--mc-stone-dark: #333 !important;
--mc-plank: #f4f4f5 !important;
--mc-plank-light: #333 !important;
--mc-diamond: #111 !important;
--mc-gold: #111 !important;
--mc-red: #dc2626 !important;
--mc-green: #16a34a !important;
--mc-grass-top: #16a34a !important;
}
body { body {
background: #fff !important; background: #fff !important;
color: #000 !important; color: #000 !important;
font-family: Arial, Helvetica, sans-serif !important;
}
/* Reset all Minecraft fonts back to default for print */
* {
font-family: inherit !important;
text-shadow: none !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: Arial, Helvetica, sans-serif !important;
color: #111 !important;
} }
.print-hidden { .print-hidden {
@@ -58,6 +96,13 @@ body {
display: block !important; display: block !important;
} }
/* Hide nav, mobile tab bar, XP bar, and other chrome */
header,
.mobile-tab-bar,
.xp-bar-container {
display: none !important;
}
.problem-detail-grid { .problem-detail-grid {
display: block !important; display: block !important;
} }
@@ -73,4 +118,47 @@ body {
background: #f4f4f5 !important; background: #f4f4f5 !important;
color: #111827 !important; color: #111827 !important;
} }
/* Reset Minecraftstyled buttons, borders, and shadows */
.mc-btn,
[class*="mc-btn"] {
display: none !important;
}
/* Remove all themed backgrounds / borders / shadows */
main,
section,
div {
background-image: none !important;
box-shadow: none !important;
}
/* Ensure tables and links are readable */
a {
color: #111 !important;
text-decoration: underline !important;
}
table,
th,
td {
border-color: #d4d4d8 !important;
color: #111 !important;
}
/* Override markdown rendering backgrounds */
.problem-markdown {
color: #000 !important;
}
.problem-markdown pre {
background: #f4f4f5 !important;
color: #111 !important;
border: 1px solid #d4d4d8 !important;
}
.problem-markdown code {
background: #f4f4f5 !important;
color: #111 !important;
}
} }

查看文件

@@ -11,7 +11,7 @@ import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "CSP Online Learning & Contest Platform", title: "CSP Online Learning & Contest Platform",
description: "Problems, wrong-book review, contests, knowledge base, and C++ runner.", description: "Quests, Cursed Tome review, Raids, Knowledge Base, and C++ runner.",
}; };
export default function RootLayout({ export default function RootLayout({

查看文件

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n"; import { useI18nText } from "@/lib/i18n";
import { useUiPreferences } from "@/components/ui-preference-provider";
type Row = { type Row = {
user_id: number; user_id: number;
@@ -14,6 +15,8 @@ type Row = {
export default function LeaderboardPage() { export default function LeaderboardPage() {
const { tx } = useI18nText(); const { tx } = useI18nText();
const { theme } = useUiPreferences();
const isMc = theme === "minecraft";
const [items, setItems] = useState<Row[]>([]); const [items, setItems] = useState<Row[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -34,22 +37,52 @@ export default function LeaderboardPage() {
void load(); void load();
}, []); }, []);
const getRankColor = (index: number) => {
if (!isMc) return "";
switch (index) {
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; // Gold
case 1: return "text-zinc-300"; // Iron
case 2: return "text-orange-700"; // Copper
default: return "text-zinc-400";
}
};
const getRankIcon = (index: number) => {
if (!isMc) return `#${index + 1}`;
switch (index) {
case 0: return "🏆";
case 1: return "🥈";
case 2: return "🥉";
default: return `#${index + 1}`;
}
};
return ( return (
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8"> <main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
{tx("全站排行榜", "Global Leaderboard")} {isMc ? (
<span className="flex items-center gap-2">
<span>🏰</span>
{tx("名人堂", "Hall of Fame")}
</span>
) : (
tx("全站排行榜", "Global Leaderboard")
)}
</h1> </h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>} {loading && <p className="mt-3 text-sm text-zinc-500">{tx("正在读取卷轴...", "Reading scrolls...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>} {error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 rounded-xl border bg-white"> <div className={`mt-4 rounded-xl border ${isMc ? "border-[3px] border-black bg-[color:var(--mc-deep-slate)] shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white" : "bg-white border-zinc-200"}`}>
<div className="divide-y md:hidden"> <div className="divide-y md:hidden">
{items.map((row, i) => ( {items.map((row, i) => (
<article key={row.user_id} className="space-y-1 p-3 text-sm"> <article key={row.user_id} className={`space-y-1 p-3 text-sm ${isMc ? "border-zinc-700" : ""}`}>
<p className="font-medium"> <div className="flex items-center justify-between">
#{i + 1} · {row.username} <p className={`font-medium ${getRankColor(i)}`}>
<span className="mr-2 text-lg">{getRankIcon(i)}</span>
{row.username}
</p> </p>
<p className="text-xs text-zinc-600">Rating: {row.rating}</p> <span className="text-[color:var(--mc-emerald)] font-bold">{row.rating}</span>
</div>
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
{tx("注册时间:", "Registered: ")} {tx("注册时间:", "Registered: ")}
{new Date(row.created_at * 1000).toLocaleString()} {new Date(row.created_at * 1000).toLocaleString()}
@@ -58,28 +91,28 @@ export default function LeaderboardPage() {
))} ))}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500"> <p className="px-3 py-5 text-center text-sm text-zinc-500">
{tx("暂无排行数据", "No ranking data yet")} {tx("暂无数据", "No legends yet")}
</p> </p>
)} )}
</div> </div>
<div className="hidden overflow-x-auto md:block"> <div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left"> <thead className={`${isMc ? "bg-black/30 text-zinc-300" : "bg-zinc-100 text-left"}`}>
<tr> <tr>
<th className="px-3 py-2">{tx("排名", "Rank")}</th> <th className="px-3 py-2 text-left">{tx("排名", "Rank")}</th>
<th className="px-3 py-2">{tx("用户", "User")}</th> <th className="px-3 py-2 text-left">{tx("用户", "User")}</th>
<th className="px-3 py-2">Rating</th> <th className="px-3 py-2 text-left">Rating</th>
<th className="px-3 py-2">{tx("注册时间", "Registered At")}</th> <th className="px-3 py-2 text-left">{tx("注册时间", "Registered At")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className={isMc ? "divide-y divide-zinc-700" : ""}>
{items.map((row, i) => ( {items.map((row, i) => (
<tr key={row.user_id} className="border-t"> <tr key={row.user_id} className={isMc ? "hover:bg-white/5 transition-colors" : "border-t"}>
<td className="px-3 py-2">{i + 1}</td> <td className={`px-3 py-2 font-bold ${getRankColor(i)}`}>{getRankIcon(i)}</td>
<td className="px-3 py-2">{row.username}</td> <td className={`px-3 py-2 font-medium ${getRankColor(i)}`}>{row.username}</td>
<td className="px-3 py-2">{row.rating}</td> <td className="px-3 py-2 text-[color:var(--mc-emerald)]">{row.rating}</td>
<td className="px-3 py-2"> <td className="px-3 py-2 text-zinc-500">
{new Date(row.created_at * 1000).toLocaleString()} {new Date(row.created_at * 1000).toLocaleString()}
</td> </td>
</tr> </tr>
@@ -87,7 +120,7 @@ export default function LeaderboardPage() {
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<tr> <tr>
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}> <td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
{tx("暂无排行数据", "No ranking data yet")} {tx("暂无数据", "No legends yet")}
</td> </td>
</tr> </tr>
)} )}

查看文件

@@ -14,6 +14,10 @@ type Me = {
created_at: number; created_at: number;
}; };
// Use a distinct style for inputs/selects
const inputClass = "w-full bg-[color:var(--mc-surface)] text-[color:var(--mc-plank-light)] border-2 border-[color:var(--mc-stone-dark)] px-3 py-2 text-base focus:border-[color:var(--mc-gold)] focus:outline-none transition-colors font-minecraft";
const labelClass = "text-sm text-[color:var(--mc-stone)] mb-1 block";
type RedeemItem = { type RedeemItem = {
id: number; id: number;
name: string; name: string;
@@ -62,12 +66,21 @@ function fmtTs(v: number | null | undefined): string {
return new Date(v * 1000).toLocaleString(); return new Date(v * 1000).toLocaleString();
} }
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: "🪵" };
}
export default function MePage() { export default function MePage() {
const { isZh, tx } = useI18nText(); const { isZh, tx } = useI18nText();
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [profile, setProfile] = useState<Me | null>(null); const [profile, setProfile] = useState<Me | null>(null);
const [items, setItems] = useState<RedeemItem[]>([]); const [items, setItems] = useState<RedeemItem[]>([]);
const [records, setRecords] = useState<RedeemRecord[]>([]); const [records, setRecords] = useState<RedeemRecord[]>([]);
const [historyItems, setHistoryItems] = useState<RatingHistoryItem[]>([]);
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]); const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
const [dailyDayKey, setDailyDayKey] = useState(""); const [dailyDayKey, setDailyDayKey] = useState("");
const [dailyTotalReward, setDailyTotalReward] = useState(0); const [dailyTotalReward, setDailyTotalReward] = useState(0);
@@ -99,34 +112,17 @@ export default function MePage() {
if (isZh) return task.title; if (isZh) return task.title;
if (task.code === "login_checkin") return "Daily Sign-in"; if (task.code === "login_checkin") return "Daily Sign-in";
if (task.code === "daily_submit") return "Daily Submission"; if (task.code === "daily_submit") return "Daily Submission";
if (task.code === "first_ac") return "Solve One Problem"; if (task.code === "first_ac") return "First Blood";
if (task.code === "code_quality") return "Code Quality"; if (task.code === "code_quality") return "Craftsman";
return task.title; return task.title;
}; };
const taskDesc = (task: DailyTaskItem): string => {
if (isZh) return task.description;
if (task.code === "login_checkin") return "Sign in once today to get 1 point.";
if (task.code === "daily_submit") return "Submit once today to get 1 point.";
if (task.code === "first_ac") return "Get AC once today to get 1 point.";
if (task.code === "code_quality") return "Submit code longer than 10 lines once today to get 1 point.";
return task.description;
};
const itemName = (name: string): string => { const itemName = (name: string): string => {
if (isZh) return name; if (isZh) return name;
if (name === "私人玩游戏时间") return "Private Game Time"; if (name === "私人玩游戏时间") return "Game Time pass";
return name; return name;
}; };
const itemDesc = (text: string): string => {
if (isZh) return text;
if (text === "全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。") {
return "Global redeem item: holiday 1 hour = 5 rating; study day/non-holiday 1 hour = 25 rating.";
}
return text;
};
const loadAll = async () => { const loadAll = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -136,15 +132,17 @@ export default function MePage() {
setToken(tk); setToken(tk);
if (!tk) throw new Error(tx("请先登录", "Please sign in first")); if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
const [me, redeemItems, redeemRecords, daily] = await Promise.all([ const [me, redeemItems, redeemRecords, daily, history] = await Promise.all([
apiFetch<Me>("/api/v1/me", {}, tk), apiFetch<Me>("/api/v1/me", {}, tk),
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk), apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk), apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk), apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
listRatingHistory(50),
]); ]);
setProfile(me); setProfile(me);
setItems(redeemItems ?? []); setItems(redeemItems ?? []);
setRecords(redeemRecords ?? []); setRecords(redeemRecords ?? []);
setHistoryItems(history ?? []);
setDailyTasks(daily?.tasks ?? []); setDailyTasks(daily?.tasks ?? []);
setDailyDayKey(daily?.day_key ?? ""); setDailyDayKey(daily?.day_key ?? "");
setDailyTotalReward(daily?.total_reward ?? 0); setDailyTotalReward(daily?.total_reward ?? 0);
@@ -171,9 +169,9 @@ export default function MePage() {
setMsg(""); setMsg("");
try { try {
if (!token) throw new Error(tx("请先登录", "Please sign in first")); if (!token) throw new Error(tx("请先登录", "Please sign in first"));
if (!selectedItemId) throw new Error(tx("请选择兑换物品", "Please select a redeem item")); if (!selectedItemId) throw new Error(tx("请选择交易物品", "Select trade item"));
if (!Number.isFinite(quantity) || quantity <= 0) { if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0")); throw new Error(tx("数量必须大于 0", "Amount > 0"));
} }
const created = await apiFetch<RedeemCreateResp>( const created = await apiFetch<RedeemCreateResp>(
@@ -192,12 +190,8 @@ export default function MePage() {
setMsg( setMsg(
isZh isZh
? `兑换成功:${created.item_name} × ${created.quantity}扣除 ${created.total_cost} 积分${ ? `交易成功:${created.item_name} × ${created.quantity}花费 ${created.total_cost} 绿宝石。`
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : "" : `Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds.`
}`
: `Redeemed successfully: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} rating${
typeof created.rating_after === "number" ? `, current rating ${created.rating_after}` : ""
}.`
); );
setNote(""); setNote("");
await loadAll(); await loadAll();
@@ -208,176 +202,186 @@ export default function MePage() {
} }
}; };
const rank = resolveRank(profile?.rating ?? 0);
return ( 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"> <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">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
{tx("我的信息与积分兑换", "My Profile & Redeem")} {tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
</h1> </h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>} {loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>} {error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>} {msg && <p className="mt-3 text-sm text-[color:var(--mc-green)]">{msg}</p>}
{profile && ( {profile && (
<section className="mt-4 rounded-xl border bg-white p-4 text-sm"> <div className="mt-4 grid gap-4 md:grid-cols-[1fr_2fr]">
<div className="flex flex-wrap items-center gap-4"> <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 <PixelAvatar
seed={`${profile.username}-${profile.id}`} seed={`${profile.username}-${profile.id}`}
size={72} size={100}
className="border-zinc-700" className="border-none"
alt={`${profile.username} avatar`} alt="avatar"
/> />
<div className="space-y-1">
<p>ID: {profile.id}</p>
<p>{tx("用户名", "Username")}: {profile.username}</p>
<p>Rating: {profile.rating}</p>
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
<p className="text-xs text-zinc-500">
{tx("默认像素头像按账号随机生成,可作为主题角色形象。", "Default pixel avatar is randomly generated by account as your theme character.")}
</p>
</div> </div>
</div> </div>
</section>
)}
<section className="mt-4 rounded-xl border bg-white p-4"> <h2 className="text-xl font-bold text-black mc-text-shadow-sm mb-1">{profile.username}</h2>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className={`text-sm font-bold ${rank.color} mb-2`}>
<h2 className="text-base font-semibold">{tx("每日任务", "Daily Tasks")}</h2> {rank.icon} {rank.label} Rank
<p className="text-xs text-zinc-600">
{dailyDayKey ? `${dailyDayKey} · ` : ""}
{tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")}
</p>
</div> </div>
<div className="mt-3 divide-y">
{dailyTasks.map((task) => ( <div className="w-full bg-black h-4 border border-white relative mb-1">
<article key={task.code} className="py-2 text-sm"> <div
<div className="flex items-center justify-between gap-2"> className="h-full bg-[color:var(--mc-green)]"
<p className="font-medium"> style={{ width: `${Math.min(100, (profile.rating % 100))}%` }}
{taskTitle(task)} · +{task.reward} ></div>
</p> <span className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-bold shadow-black drop-shadow-md">
<span Level {Math.floor(profile.rating / 100)}
className={`rounded px-2 py-0.5 text-xs ${
task.completed ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
}`}
>
{task.completed ? tx("已完成", "Completed") : tx("未完成", "Incomplete")}
</span> </span>
</div> </div>
<p className="mt-1 text-xs text-zinc-600">{taskDesc(task)}</p> <p className="text-xs text-[color:var(--mc-stone-dark)]">UID: {profile.id}</p>
{task.completed && ( </div>
<p className="mt-1 text-xs text-zinc-500">
{tx("完成时间:", "Completed At: ")} <div className="mt-4 space-y-2 border-t border-black/20 pt-4">
{fmtTs(task.completed_at)} <div className="flex justify-between text-sm">
</p> <span className="text-zinc-800">{tx("绿宝石 (Rating)", "Emeralds (Rating)")}</span>
)} <span className="font-bold text-[color:var(--mc-green)] text-shadow-sm">{profile.rating}</span>
</article> </div>
))} <div className="flex justify-between text-sm">
{!loading && dailyTasks.length === 0 && ( <span className="text-zinc-800">{tx("加入时间", "Joined")}</span>
<p className="py-3 text-sm text-zinc-500"> <span className="text-zinc-600">{new Date(profile.created_at * 1000).toLocaleDateString()}</span>
{tx("今日任务尚未初始化,请稍后刷新。", "Today's tasks are not initialized yet. Please refresh later.")} </div>
</p>
)}
</div> </div>
</section> </section>
<section className="mt-4 rounded-xl border bg-white p-4"> <div className="flex flex-col gap-4">
<h2 className="text-base font-semibold">{tx("积分兑换物品", "Redeem Items")}</h2> {/* Daily Tasks */}
<p className="mt-1 text-xs text-zinc-600"> <div className="bg-[color:var(--mc-surface)] border-4 border-black p-4 relative">
{tx( <h2 className="text-xl text-[color:var(--mc-dirt)] mb-4 flex justify-between items-center font-minecraft">
"示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分)", <span></span>
"Sample rule: Private Game Time (holiday 1h=5 points; study day/non-holiday 1h=25 points)" <span className="text-xs text-[color:var(--mc-gold)]">: {dailyGainedReward} / {dailyTotalReward} XP</span>
)} </h2>
</p>
<div className="mt-3 grid gap-3 md:grid-cols-2"> <div className="space-y-3">
{items.map((item) => ( {dailyTasks.map((task, idx) => (
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm"> <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 justify-between gap-2"> <div className="flex items-start gap-3">
<p className="font-medium">{itemName(item.name)}</p> <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' : ''}`}>
<button {task.completed && <span className="text-[color:var(--mc-green)] text-sm"></span>}
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={() => setSelectedItemId(item.id)}
>
{tx("选中", "Select")}
</button>
</div> </div>
<p className="mt-1 text-xs text-zinc-600">{itemDesc(item.description) || "-"}</p> <div className="flex-1">
<p className="mt-1 text-xs text-zinc-700"> <div className="flex justify-between items-start mb-1">
{tx("假期", "Holiday")}: {item.holiday_cost} / {item.unit_label} <h3 className="text-[color:var(--mc-plank-light)] text-lg font-bold leading-tight">
{task.title}
<span className="ml-2 text-[color:var(--mc-gold)] text-base font-minecraft">+{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> </p>
<p className="text-xs text-zinc-700"> </div>
{tx("学习日", "Study Day")}: {item.studyday_cost} / {item.unit_label} </div>
</p> </div>
</article>
))} ))}
{!loading && items.length === 0 && (
<p className="text-sm text-zinc-500">
{tx("管理员尚未配置可兑换物品。", "No redeem items configured by admin yet.")}
</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="text-xl text-[color:var(--mc-obsidian)] 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="mt-4 rounded-lg border p-3"> <div className="grid gap-2">
<h3 className="text-sm font-medium">{tx("兑换表单", "Redeem Form")}</h3> <div className="flex gap-2 text-black">
<div className="mt-2 grid gap-2 md:grid-cols-2">
<select <select
className="rounded border px-3 py-2 text-sm" className="flex-1 rounded-none border-2 border-black bg-[color:var(--surface)] px-2 py-1 text-base font-bold"
value={selectedItemId} value={selectedItemId}
onChange={(e) => setSelectedItemId(Number(e.target.value))} onChange={(e) => setSelectedItemId(Number(e.target.value))}
> >
<option value={0}>{tx("选择兑换物品", "Please select an item")}</option> <option value={0}>{tx("选择战利品...", "Select loot...")}</option>
{items.map((item) => ( {items.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{itemName(item.name)} {itemName(item.name)}
</option> </option>
))} ))}
</select> </select>
<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)]">
: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label}
</p>
</div>
)}
<div className="flex gap-2">
<select <select
className="rounded border px-3 py-2 text-sm" className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] text-black px-2 py-1 text-base"
value={dayType} value={dayType}
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")} onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
> >
<option value="holiday">{tx("假期时间(按假期单价)", "Holiday time (holiday price)")}</option> <option value="holiday">{tx("假期特惠", "Holiday Price")}</option>
<option value="studyday">{tx("学习日/非节假日(按学习日单价)", "Study day/non-holiday (study-day price)")}</option> <option value="studyday">{tx("工作日价格", "Workday Price")}</option>
</select> </select>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={1}
max={24}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")}
/>
<input
className="rounded border px-3 py-2 text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={tx("备注(可选)", "Note (optional)")}
/>
</div>
<p className="mt-2 text-xs text-zinc-600">
{tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")}{tx("预计扣分", "Estimated cost")}: {totalCost}
</p>
<button <button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50" className="mc-btn mc-btn-success text-xs px-4"
onClick={() => void redeem()} onClick={() => void redeem()}
disabled={redeemLoading || !selectedItemId} disabled={redeemLoading || !selectedItemId}
> >
{redeemLoading ? tx("兑换中...", "Redeeming...") : tx("确认兑换", "Confirm Redeem")} {tx("交易", "Trade")}
</button> </button>
</div> </div>
</div>
</section>
</div>
</div>
)}
{/* 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)]">
<h2 className="text-base font-bold text-black mb-2">{tx("积分变动记录", "Rating History")}</h2>
<div className="max-h-60 overflow-y-auto space-y-1">
{historyItems.map((item, idx) => (
<div key={idx} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
<span>
<span className={`font-bold ${item.change > 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}>
{item.change > 0 ? `+${item.change}` : item.change}
</span>
<span className="ml-2">{item.note}</span>
</span>
<span className="text-[color:var(--mc-stone-dark)]">
{new Date(item.created_at * 1000).toLocaleString()}
</span>
</div>
))}
{!loading && historyItems.length === 0 && (
<p className="text-xs text-zinc-500">{tx("暂无记录。", "No history.")}</p>
)}
</div>
</section> </section>
<section className="mt-4 rounded-xl border bg-white p-4"> {/* Trades Section */}
<div className="flex items-center justify-between gap-2"> <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)]">
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2> <div className="flex items-center justify-between gap-2 mb-2">
<h2 className="text-base font-bold text-black">{tx("交易记录", "Trade History")}</h2>
<button <button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100" className="text-xs text-[color:var(--mc-stone-dark)] underline"
onClick={() => void loadAll()} onClick={() => void loadAll()}
disabled={loading} disabled={loading}
> >
@@ -385,21 +389,19 @@ export default function MePage() {
</button> </button>
</div> </div>
<div className="mt-3 divide-y"> <div className="max-h-60 overflow-y-auto space-y-1">
{records.map((row) => ( {records.map((row) => (
<article key={row.id} className="py-2 text-sm"> <div key={row.id} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
<p> <span>
#{row.id} · {itemName(row.item_name)} · {row.quantity} {tx("小时", "hour")} ·{" "} {itemName(row.item_name)} × {row.quantity}
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")} </span>
</p> <span className="text-[color:var(--mc-stone-dark)]">
<p className="text-xs text-zinc-600"> -{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()}
{tx("单价", "Unit cost")} {row.unit_cost}{tx("总扣分", "Total cost")} {row.total_cost} · {fmtTs(row.created_at)} </span>
</p> </div>
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
</article>
))} ))}
{!loading && records.length === 0 && ( {!loading && records.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换记录。", "No redeem records yet.")}</p> <p className="text-xs text-zinc-500">{tx("暂无交易。", "No trades.")}</p>
)} )}
</div> </div>
</section> </section>

查看文件

@@ -119,24 +119,24 @@ function resolveResultTone(
): ResultTone { ): ResultTone {
const s = status.toUpperCase(); const s = status.toUpperCase();
if (s === "AC" && (score ?? 0) >= 100) { if (s === "AC" && (score ?? 0) >= 100) {
return { title: tx("完美通关", "Perfect Clear"), icon: "🏆", badgeClass: "bg-emerald-100 text-emerald-700" }; return { title: tx("任务完成!", "Quest Complete!"), icon: "🏆", badgeClass: "bg-[color:var(--mc-grass-top)] text-white border-2 border-black" };
} }
if (s === "AC") { if (s === "AC") {
return { title: tx("通过", "Accepted"), icon: "✅", badgeClass: "bg-emerald-100 text-emerald-700" }; return { title: tx("通过", "Accepted"), icon: "✅", badgeClass: "bg-[color:var(--mc-grass-top)] text-white border-2 border-black" };
} }
if (s === "WA") { if (s === "WA") {
return { title: tx("再冲一次", "Try Again"), icon: "🎯", badgeClass: "bg-amber-100 text-amber-700" }; return { title: tx("咒语失效", "Spell Fizzled"), icon: "☠️", badgeClass: "bg-[color:var(--mc-red)] text-white border-2 border-black" };
} }
if (s === "TLE" || s === "RE" || s === "MLE") { if (s === "TLE" || s === "RE" || s === "MLE") {
return { title: tx("挑战中", "In Challenge"), icon: "⚔️", badgeClass: "bg-orange-100 text-orange-700" }; return { title: tx("时空乱流", "Time Warp"), icon: "", badgeClass: "bg-orange-600 text-white border-2 border-black" };
} }
if (s === "CE") { if (s === "CE") {
return { title: tx("编译修复", "Fix Compile"), icon: "🛠️", badgeClass: "bg-rose-100 text-rose-700" }; return { title: tx("铭文错误", "Inscription Error"), icon: "📜", badgeClass: "bg-yellow-600 text-white border-2 border-black" };
} }
if (s === "RUNNING") { if (s === "RUNNING") {
return { title: tx("冲刺中", "Running"), icon: "🚀", badgeClass: "bg-blue-100 text-blue-700" }; return { title: tx("施法中...", "Casting..."), icon: "", badgeClass: "bg-blue-600 text-white border-2 border-black" };
} }
return { title: tx("继续挑战", "Keep Going"), icon: "🎮", badgeClass: "bg-zinc-100 text-zinc-700" }; return { title: tx("准备就绪", "Ready"), icon: "🗡️", badgeClass: "bg-[color:var(--mc-stone)] text-white border-2 border-black" };
} }
type Submission = { type Submission = {
@@ -251,8 +251,8 @@ function buildPrintableAnswerMarkdown(
return methodBlocks.join("\n\n---\n\n"); return methodBlocks.join("\n\n---\n\n");
} }
return tx( return tx(
"### 未有答案\n\nLLM 未生成可用答案,请点击“按 C++14 重新生成”。", "### 未有答案\n\n先知未生成可用启示,请点击“按 C++14 重新祈祷”。",
"### No Answer Yet\n\nNo usable LLM answer is available. Please click \"Regenerate in C++14\" first." "### No Prophecy\n\nNo usable wisdom available. Click \"Regenerate in C++14\" to pray again."
); );
} }
@@ -271,6 +271,14 @@ int main() {
const defaultRunInput = ``; const defaultRunInput = ``;
function difficultyIcon(diff: number): string {
if (diff <= 2) return "🪵";
if (diff <= 4) return "🪨";
if (diff <= 6) return "⚔️";
if (diff <= 8) return "💎";
return "🔥";
}
export default function ProblemDetailPage() { export default function ProblemDetailPage() {
const { tx } = useI18nText(); const { tx } = useI18nText();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -294,6 +302,8 @@ export default function ProblemDetailPage() {
const [policyMsg, setPolicyMsg] = useState(""); const [policyMsg, setPolicyMsg] = useState("");
const [showSolutions, setShowSolutions] = useState(false); const [showSolutions, setShowSolutions] = useState(false);
const [expandedCodes, setExpandedCodes] = useState<Set<number>>(new Set());
const [unlockConfirm, setUnlockConfirm] = useState(false);
const [solutionLoading, setSolutionLoading] = useState(false); const [solutionLoading, setSolutionLoading] = useState(false);
const [solutionStatusLoading, setSolutionStatusLoading] = useState(false); const [solutionStatusLoading, setSolutionStatusLoading] = useState(false);
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null); const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
@@ -397,9 +407,9 @@ export default function ProblemDetailPage() {
return tx("待生成", "Pending"); return tx("待生成", "Pending");
}, [hasSolutionAnswer, solutionStatusLoading, tx]); }, [hasSolutionAnswer, solutionStatusLoading, tx]);
const answerStatusClass = useMemo(() => { const answerStatusClass = useMemo(() => {
if (hasSolutionAnswer) return "text-emerald-700"; if (hasSolutionAnswer) return "text-[color:var(--mc-grass-top)]";
if (solutionStatusLoading) return "text-zinc-600"; if (solutionStatusLoading) return "text-[color:var(--mc-stone)]";
return "text-amber-700"; return "text-[color:var(--mc-gold)]";
}, [hasSolutionAnswer, solutionStatusLoading]); }, [hasSolutionAnswer, solutionStatusLoading]);
useEffect(() => { useEffect(() => {
@@ -425,8 +435,8 @@ export default function ProblemDetailPage() {
if (policyErrorCount > 0) { if (policyErrorCount > 0) {
setPolicyMsg( setPolicyMsg(
tx( tx(
`检测到 ${policyErrorCount}超出 C++14 的高风险写法,提交到旧评测环境可能直接 CE。`, `检测到 ${policyErrorCount} 条高风险写法,提交到旧评测可能直接 CE。`,
`Detected ${policyErrorCount} high-risk C++14 violations. Old judges may fail with CE.` `Detected ${policyErrorCount} high-risk violations. Old judges may fail with CE.`
) )
); );
return; return;
@@ -434,8 +444,8 @@ export default function ProblemDetailPage() {
if (policyWarningCount > 0) { if (policyWarningCount > 0) {
setPolicyMsg( setPolicyMsg(
tx( tx(
`检测到 ${policyWarningCount} 条代码规范提醒,建议修正后再提交`, `检测到 ${policyWarningCount} 条代码规范提醒,建议修正。`,
`Detected ${policyWarningCount} policy warnings. Fix them before submit.` `Detected ${policyWarningCount} policy warnings. Suggest fix.`
) )
); );
return; return;
@@ -480,7 +490,7 @@ export default function ProblemDetailPage() {
}); });
} }
} catch { } catch {
// keep silent, avoid blocking coding flow when status API fails. // keep silent
} finally { } finally {
if (!cancelled) setSolutionStatusLoading(false); if (!cancelled) setSolutionStatusLoading(false);
} }
@@ -511,10 +521,10 @@ export default function ProblemDetailPage() {
} }
draftLastSavedSigRef.current = buildDraftSignature(nextCode, nextStdin); draftLastSavedSigRef.current = buildDraftSignature(nextCode, nextStdin);
if (hasDraft) { if (hasDraft) {
setDraftMsg(tx("已自动加载草稿", "Draft auto-loaded")); setDraftMsg(tx("草稿已加载", "Draft loaded"));
} }
} catch { } catch {
// ignore empty draft / unauthorized // ignore
} }
}; };
void loadDraft(); void loadDraft();
@@ -541,10 +551,10 @@ export default function ProblemDetailPage() {
) )
.then(() => { .then(() => {
draftLastSavedSigRef.current = nextSig; draftLastSavedSigRef.current = nextSig;
setDraftMsg(tx("草稿已自动保存每60秒", "Draft auto-saved (every 60s)")); setDraftMsg(tx("草稿已自动保存", "Draft auto-saved"));
}) })
.catch(() => { .catch(() => {
// Keep silent to avoid noisy notifications on transient network errors. // silent
}) })
.finally(() => { .finally(() => {
draftAutoSavingRef.current = false; draftAutoSavingRef.current = false;
@@ -639,7 +649,7 @@ export default function ProblemDetailPage() {
if (mode === "full") setSolutionMsg(""); if (mode === "full") setSolutionMsg("");
try { try {
const token = mode === "full" ? readToken() : undefined; const token = mode === "full" ? readToken() : undefined;
if (mode === "full" && !token) throw new Error(tx("请先登录后再查看答案", "Please sign in before viewing answers")); if (mode === "full" && !token) throw new Error(tx("请先登录", "Please sign in"));
const resp = await apiFetch<SolutionResp>( const resp = await apiFetch<SolutionResp>(
`/api/v1/problems/${id}/solutions?mode=${mode}`, `/api/v1/problems/${id}/solutions?mode=${mode}`,
undefined, undefined,
@@ -650,23 +660,23 @@ export default function ProblemDetailPage() {
if (!resp.has_solutions) { if (!resp.has_solutions) {
setSolutionMsg( setSolutionMsg(
tx( tx(
"当前暂无可展示答案,显示“未有答案”。可先点击“按 C++14 重新生成”。", "当前暂无答案。",
"No answer available now. \"No Answer Yet\" will be shown. Click \"Regenerate in C++14\" first." "No answer available."
) )
); );
} else if (resp.access?.charged) { } else if (resp.access?.charged) {
const cost = resp.access.cost ?? 2; const cost = resp.access.cost ?? 2;
const remaining = resp.access.rating_after; const remaining = resp.access.rating_after;
const remainingText = typeof remaining === "number" const remainingText = typeof remaining === "number"
? tx(`,当前 Rating${remaining}`, `, current rating: ${remaining}`) ? tx(`,当前 Rating${remaining}`, `, rating: ${remaining}`)
: ""; : "";
setSolutionMsg( setSolutionMsg(
tx(`本次查看答案扣除 ${cost}${remainingText}`, `Answer view cost ${cost} points${remainingText}.`) tx(`扣除 ${cost}${remainingText}`, `Cost ${cost} points${remainingText}.`)
); );
} else if (resp.access?.daily_free) { } else if (resp.access?.daily_free) {
setSolutionMsg(tx("今日首次查看答案免费。", "Today's first answer view is free.")); setSolutionMsg(tx("首次免费。", "Free view."));
} else { } else {
setSolutionMsg(tx("答案已加载。", "Answer loaded.")); setSolutionMsg(tx("答案已加载。", "Loaded."));
} }
} else { } else {
setSolutionData((prev) => { setSolutionData((prev) => {
@@ -683,7 +693,7 @@ export default function ProblemDetailPage() {
return resp; return resp;
} catch (e: unknown) { } catch (e: unknown) {
if (mode === "full") { if (mode === "full") {
setSolutionMsg(tx(`加载答案失败:${String(e)}`, `Failed to load answer: ${String(e)}`)); setSolutionMsg(tx(`加载失败:${String(e)}`, `Failed: ${String(e)}`));
} }
return null; return null;
} finally { } finally {
@@ -696,7 +706,7 @@ export default function ProblemDetailPage() {
setSolutionMsg(""); setSolutionMsg("");
try { try {
const token = readToken(); const token = readToken();
if (!token) throw new Error(tx("请先登录后再触发题解生成", "Please sign in before triggering generation")); if (!token) throw new Error(tx("请先登录", "Please sign in"));
const resp = await apiFetch<{ started: boolean; job_id: number; pending_jobs?: number }>( const resp = await apiFetch<{ started: boolean; job_id: number; pending_jobs?: number }>(
`/api/v1/problems/${id}/solutions/generate`, `/api/v1/problems/${id}/solutions/generate`,
{ {
@@ -710,8 +720,8 @@ export default function ProblemDetailPage() {
: ""; : "";
setSolutionMsg( setSolutionMsg(
tx( tx(
`题解生成任务已提交(按 C++14 规范生成),后台异步处理中${pending}`, `先知题解已请求${pending}`,
`Generation job submitted (C++14 policy). Processing asynchronously.${pending}` `Prophecy requested.${pending}`
) )
); );
await loadSolutions("preview", { silent: true }); await loadSolutions("preview", { silent: true });
@@ -725,18 +735,18 @@ export default function ProblemDetailPage() {
const applySolutionCode = async (item: SolutionItem, runAfterWrite: boolean) => { const applySolutionCode = async (item: SolutionItem, runAfterWrite: boolean) => {
const prepared = normalizeCodeText(item.code_cpp); const prepared = normalizeCodeText(item.code_cpp);
if (!prepared) { if (!prepared) {
setSolutionMsg(tx("该题解没有可写入的代码。", "This solution has no writable code.")); setSolutionMsg(tx("没有代码。", "No code."));
return; return;
} }
setCode(prepared); setCode(prepared);
setDraftMsg(tx(`将“解法 ${item.variant}”写入答题窗口`, `Inserted "Method ${item.variant}" into editor`)); setDraftMsg(tx(`写入解法 ${item.variant}`, `Method ${item.variant} inserted`));
if (!runAfterWrite) return; if (!runAfterWrite) return;
setSolutionMsg(tx(`已写入解法 ${item.variant}”,正在试运行...`, `Inserted "Method ${item.variant}", running...`)); setSolutionMsg(tx(`已写入并运行解法 ${item.variant}...`, `Method ${item.variant} running...`));
await runCode(prepared); await runCode(prepared);
setSolutionMsg( setSolutionMsg(
tx( tx(
`已写入解法 ${item.variant}”,可在下方查看运行结果`, `已写入解法 ${item.variant},结果见下方`,
`Inserted "Method ${item.variant}". Check run result below.` `Method ${item.variant} done. Check below.`
) )
); );
}; };
@@ -770,415 +780,428 @@ export default function ProblemDetailPage() {
}; };
return ( return (
<main className="mx-auto max-w-[1400px] px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8"> <main className="mx-auto max-w-[1400px] px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
{tx("题目详情与评测", "Problem Detail & Judge")} {tx("任务详情与试炼", "Mission Details")}
</h1> </h1>
{loading && <p className="mt-4 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>} {loading && <p className="mt-4 text-sm text-[color:var(--mc-stone)]">{tx("加载地图中...", "Loading Map...")}</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>} {error && <p className="mt-4 text-sm text-[color:var(--mc-red)]">{error}</p>}
{problem && ( {problem && (
<div className="problem-detail-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]"> <div className="problem-detail-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<section className="problem-print-section rounded-xl border bg-white p-4 sm:p-5"> <section className="problem-print-section rounded-none border-[3px] border-black bg-[color:var(--mc-plank-light)] p-4 sm:p-5 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<h2 className="text-xl font-medium">{problem.title}</h2> <h2 className="text-2xl font-bold text-black mc-text-shadow-sm">{problem.title}</h2>
<p className="mt-1 text-sm text-zinc-600"> <div className="mt-1 flex items-center gap-2 text-sm">
{tx("难度", "Difficulty")} {problem.difficulty} · {tx("来源", "Source")} {problem.source} <span className={`font-bold ${problem.difficulty > 6 ? "text-[color:var(--mc-diamond)]" :
</p> problem.difficulty > 4 ? "text-zinc-600" :
problem.difficulty > 2 ? "text-[color:var(--mc-stone-dark)]" :
"text-[color:var(--mc-wood-dark)]"
}`}>
Tier {problem.difficulty} {difficultyIcon(problem.difficulty)}
</span>
<span className="text-zinc-500">·</span>
<span className="text-zinc-800">{tx("来源", "Origin")}: {problem.source}</span>
</div>
<div className="print-hidden mt-3 flex flex-wrap gap-2"> <div className="print-hidden mt-3 flex flex-wrap gap-2">
<button <button
className="rounded border px-3 py-1 text-sm disabled:opacity-50" className="mc-btn text-sm py-1"
onClick={() => void printProblemWithAnswer()} onClick={() => void printProblemWithAnswer()}
disabled={solutionLoading} disabled={solutionLoading}
> >
{solutionLoading ? tx("准备打印内容...", "Preparing print content...") : tx("打印题干 + 答案", "Print Problem + Answer")} {solutionLoading ? tx("卷轴准备中...", "Scribing...") : tx("打印卷轴", "Scribe Scroll")}
</button> </button>
<button <button
className="rounded border px-3 py-1 text-sm" className="mc-btn text-sm py-1"
onClick={() => setShowPolicyTips((v) => !v)} onClick={() => setShowPolicyTips((v) => !v)}
> >
{showPolicyTips ? tx("收起代码规范 Tips", "Hide Coding Tips") : tx("代码规范 Tips", "Coding Tips")} {showPolicyTips ? tx("收起公约", "Hide Rules") : tx("福建考场公约", "Fujian Rules")}
</button> </button>
</div> </div>
{showPolicyTips && ( {showPolicyTips && (
<div className="print-hidden mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900"> <div className="mt-3 rounded border-[2px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[2px_2px_0_rgba(0,0,0,0.4)]">
<p className="font-semibold">{tx("福建 CSP-J/S 代码规范提示", "Fujian CSP-J/S Coding Tips")}</p> <p className="font-bold text-black mb-1">{tx("考场生存指南:", "Survival Guide:")}</p>
<ul className="mt-2 list-disc space-y-1 pl-4"> <ul className="list-disc space-y-1 pl-4 text-xs text-black">
{policyTips.map((tip) => ( {policyTips.map((tip, idx) => (
<li key={tip}>{tip}</li> <li key={idx}>{tip}</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
<div className="problem-markdown-compact mt-4 rounded-lg border bg-zinc-50 p-4"> <div className={`print-content mt-4 ${printAnswerMarkdown ? "print-with-answer" : ""}`}>
<MarkdownRenderer markdown={statementMarkdown} /> <MarkdownRenderer markdown={statementMarkdown} className="problem-markdown text-black" />
</div>
{llmProfile?.knowledge_points && llmProfile.knowledge_points.length > 0 && ( {printAnswerMarkdown && (
<> <div className="mt-8 break-before-page border-t-2 border-dashed border-black pt-8">
<h3 className="mt-4 text-[11px] font-medium">{tx("知识点考查", "Knowledge Points")}</h3> <h2 className="text-lg font-bold text-black text-center mb-4">{tx("参考答案 / 解析", "Reference Answer")}</h2>
<div className="mt-1 flex flex-wrap gap-2"> <MarkdownRenderer markdown={printableAnswerMarkdown} className="problem-markdown text-black" />
{llmProfile.knowledge_points.map((kp) => (
<span key={kp} className="rounded-full bg-zinc-100 px-2 py-1 text-[10px]">
{kp}
</span>
))}
</div> </div>
</>
)} )}
<h3 className="mt-4 text-[11px] font-medium">{tx("样例输入", "Sample Input")}</h3>
<pre className="overflow-x-auto rounded bg-zinc-900 p-3 text-[10px] text-zinc-100">
{problem.sample_input}
</pre>
<h3 className="mt-3 text-[11px] font-medium">{tx("样例输出", "Sample Output")}</h3>
<pre className="overflow-x-auto rounded bg-zinc-900 p-3 text-[10px] text-zinc-100">
{problem.sample_output}
</pre>
<div className="print-only mt-4 rounded-lg border bg-white p-4">
<h3 className="text-[11px] font-medium">{tx("参考答案", "Reference Answer")}</h3>
<div className="problem-markdown-compact mt-2">
<MarkdownRenderer markdown={printAnswerMarkdown || printableAnswerMarkdown} />
</div>
</div> </div>
</section> </section>
<section className="print-hidden rounded-xl border bg-white p-4 sm:p-5"> <section className="flex flex-col gap-4 print:hidden">
<label className="text-sm font-medium">{tx("contest_id可选", "contest_id (optional)")}</label> <div className="rounded-none border-[3px] border-black bg-[color:var(--mc-obsidian)] p-1 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<input <div className="relative">
className="mt-1 w-full rounded border px-3 py-2"
placeholder={tx("例如 1", "e.g. 1")}
value={contestId}
onChange={(e) => setContestId(e.target.value)}
/>
<div className="mt-3 grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
className="w-full rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void submit()}
disabled={submitLoading}
>
{submitLoading ? tx("提交中...", "Submitting...") : tx("提交评测", "Submit")}
</button>
<button
className="w-full rounded border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => void saveDraft()}
disabled={draftLoading}
>
{draftLoading ? tx("保存中...", "Saving...") : tx("保存草稿", "Save Draft")}
</button>
<button
className="w-full rounded border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => {
if (showSolutions) {
setShowSolutions(false);
return;
}
setShowSolutions(true);
void loadSolutions("full");
}}
disabled={solutionLoading}
>
{solutionLoading
? tx("加载答案中...", "Loading answer...")
: showSolutions
? tx("收起答案", "Hide Answers")
: tx(`答案展示(${answerStatusLabel}`, `Answer Panel (${answerStatusLabel})`)}
</button>
<button
className="w-full rounded border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => void runCode()}
disabled={runLoading}
>
{runLoading ? tx("试运行中...", "Running...") : tx("试运行", "Run")}
</button>
</div>
<div className="mt-2 rounded border bg-zinc-50 px-3 py-2 text-xs">
<span className={answerStatusClass}>
{tx("答案状态:", "Answer status: ")}
{answerStatusLabel}
</span>
<span className="mx-2 text-zinc-400">|</span>
<span className="text-zinc-600">{tx("当前模式C++14 评测规范", "Current mode: C++14 judge policy")}</span>
<span className="mx-2 text-zinc-400">|</span>
<span className="text-zinc-600">{tx("答案查看:每日首免,后续每次 -2 Rating", "Answer view: first daily view free, then -2 rating each")}</span>
</div>
<p className="mt-2 text-xs text-zinc-600">
{tx("当前试运行/提交按", "Run/submit compiles with")} <code>-std=gnu++14</code>{" "}
{tx(",超出 C++14 的写法会自动高亮提醒。", "; non-C++14 usage will be highlighted automatically.")}
</p>
{draftMsg && <p className="mt-2 text-xs text-emerald-700">{draftMsg}</p>}
{(policyMsg || policyIssues.length > 0) && (
<div
className={`mt-3 rounded border px-3 py-2 text-xs ${
policyErrorCount > 0
? "border-red-200 bg-red-50 text-red-700"
: "border-amber-200 bg-amber-50 text-amber-800"
}`}
>
{policyMsg && <p className="font-medium">{policyMsg}</p>}
{!policyMsg && policyHintCount > 0 && (
<p className="font-medium">
{tx(`已检测到 ${policyHintCount} 条考场规范提示。`, `Detected ${policyHintCount} policy hints.`)}
</p>
)}
{visiblePolicyIssues.length > 0 && (
<ul className="mt-1 list-disc space-y-1 pl-4">
{visiblePolicyIssues.map((issue, idx) => (
<li key={`${issue.id}-${idx}`}>
L{issue.line}{issue.message}
</li>
))}
</ul>
)}
</div>
)}
<label className="mt-4 block text-sm font-medium">
{tx("C++ 代码(高亮 + 自动提示)", "C++ Code (Highlight + Auto Hint)")}
</label>
<div className="mt-1 overflow-hidden rounded border">
<CodeEditor <CodeEditor
value={code} value={code}
onChange={setCode} onChange={(v) => {
height="min(58vh, 420px)" setCode(v);
fontSize={9} setDraftMsg("");
}}
height="500px"
fontSize={14}
onPolicyIssuesChange={setPolicyIssues} onPolicyIssuesChange={setPolicyIssues}
/> />
<div className="pointer-events-none absolute right-4 top-2 flex flex-col items-end gap-1 opacity-80 hover:opacity-100 peer-hover:opacity-100">
{policyErrorCount > 0 && (
<div className="rounded bg-[color:var(--mc-red)] px-2 py-0.5 text-xs text-white shadow">
{policyErrorCount} Errors
</div>
)}
{policyWarningCount > 0 && (
<div className="rounded bg-[color:var(--mc-gold)] px-2 py-0.5 text-xs text-black shadow">
{policyWarningCount} Warnings
</div>
)}
</div>
</div> </div>
<label className="mt-4 block text-sm font-medium">{tx("试运行输入", "Run Input")}</label> <div className="bg-[color:var(--surface)] p-2 border-t-[3px] border-black flex flex-wrap items-center justify-between gap-2">
<textarea <div className="flex items-center gap-2 text-xs text-[color:var(--mc-stone)]">
className="mt-1 h-28 w-full rounded border p-2 font-mono text-xs sm:h-24" <span>CPP14</span>
value={runInput} {draftLoading && <span>{tx("保存中...", "Saving...")}</span>}
onChange={(e) => setRunInput(e.target.value)} {!draftLoading && draftMsg && <span className="opacity-75">{draftMsg}</span>}
/> {!draftLoading && !draftMsg && policyMsg && (
{runResp && ( <span className={`${policyErrorCount > 0 ? "text-[color:var(--mc-red)]" : "text-[color:var(--mc-gold)]"}`}>
<div className="mt-3 space-y-2 rounded border p-3 text-xs"> {policyMsg}
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-1 text-xs ${runTone.badgeClass}`}>
{runTone.icon} {runTone.title}
</span>
<p>
{tx("运行状态:", "Run status: ")}
<b>{runResp.status}</b> · {tx("耗时", "Time")} {runResp.time_ms}ms
</p>
<span className="text-zinc-500">
{tx("警告", "Warnings")} {runWarningCount} · {tx("错误", "Errors")} {runErrorCount}
</span> </span>
)}
</div> </div>
<div className="flex items-center gap-2">
<button
className="mc-btn text-xs py-1 px-2"
onClick={() => void saveDraft()}
disabled={draftLoading || submitLoading}
title={tx("保存草稿Ctrl+S", "Save Draft")}
>
{tx("保存", "Save")}
</button>
<button
className="mc-btn text-xs py-1 px-2"
onClick={() => setContestId((v) => (v ? "" : "0"))}
title={tx("输入比赛 ID可选", "Contest ID (Optional)")}
>
{contestId ? `Raid #${contestId}` : tx("Raid", "Raid")}
</button>
{contestId && (
<input
className="w-16 bg-black border border-white text-white px-1 text-xs"
value={contestId}
onChange={(e) => setContestId(e.target.value)}
placeholder="ID"
/>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
className="mc-btn mc-btn-primary w-full py-3 text-sm"
onClick={() => void runCode()}
disabled={runLoading || submitLoading}
>
{runLoading ? tx("施法中...", "Casting...") : tx("试运行", "Test Run")}
</button>
<button
className="mc-btn mc-btn-success w-full py-3 text-sm"
onClick={() => void submit()}
disabled={submitLoading || runLoading}
>
{submitLoading ? tx("施法中...", "Casting...") : tx("施放咒语", "Cast Spell")}
</button>
</div>
{(runResp || submitResp) && (
<div className="animation-slide-up space-y-3">
{runResp && (
<div className={`rounded-none border-[3px] border-black p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] ${resolveResultTone(runResp.status, tx).badgeClass.replace("bg-", "bg-opacity-20 bg-")}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{runTone.icon}</span>
<span className="font-bold">{runTone.title}</span>
</div>
<span className="text-xs font-mono">{runResp.time_ms}ms</span>
</div>
{runResp.compile_log && ( {runResp.compile_log && (
<details className="rounded border bg-zinc-50 p-2"> <details className="mb-2">
<summary className="cursor-pointer text-zinc-700">{tx("查看编译日志", "View Compile Log")}</summary> <summary className="cursor-pointer text-xs font-bold text-[color:var(--mc-red)]">{tx("编译日志", "Compile Log")}</summary>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-zinc-100"> <pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-xs text-red-300 font-mono">
{runResp.compile_log} {runResp.compile_log}
</pre> </pre>
</details> </details>
)} )}
<div className="grid gap-2 text-xs font-mono">
<div> <div>
<p className="font-medium">stdout</p> <p className="font-bold mb-1 opacity-70">{tx("标准输出", "Output")}</p>
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100"> <pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-[color:var(--mc-plank-light)] border border-[color:var(--mc-stone-dark)]">
{runResp.stdout || "(empty)"} {runResp.stdout || <span className="text-zinc-600 italic">Empty</span>}
</pre> </pre>
</div> </div>
{runResp.stderr && (
<div> <div>
<p className="font-medium">stderr</p> <p className="font-bold mb-1 opacity-70 text-yellow-600">{tx("标准错误", "Stderr")}</p>
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100"> <pre className="max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-yellow-200 border border-[color:var(--mc-stone-dark)]">
{runResp.stderr || "(empty)"} {runResp.stderr}
</pre> </pre>
</div> </div>
)}
</div>
</div> </div>
)} )}
{submitResp && ( {submitResp && (
<div className="mt-4 space-y-2 rounded border p-3 text-sm"> <div className={`rounded-none border-[3px] border-black p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] ${submitTone.badgeClass}`}>
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center justify-between">
<span className={`rounded-full px-2 py-1 text-xs ${submitTone.badgeClass}`}> <div className="flex items-center gap-2">
{submitTone.icon} {submitTone.title} <span className="text-2xl">{submitTone.icon}</span>
</span> <div>
<p> <div className="font-bold text-lg">{submitTone.title}</div>
{tx("结果:", "Result: ")} <div className="text-xs opacity-90">
<b>{submitResp.status}</b> Score: {submitResp.score} · {submitResp.status}
{tx(",分数 ", ", score ")}
{submitResp.score}
</p>
<span className="text-xs text-zinc-500">
{tx("警告", "Warnings")} {submitWarningCount} · {tx("错误", "Errors")} {submitErrorCount}
</span>
</div> </div>
<div className="space-y-1 rounded border bg-zinc-50 p-2">
<p className="text-xs text-zinc-600">{tx("挑战进度", "Progress")}</p>
<div className="h-2 w-full overflow-hidden rounded bg-zinc-200">
<div
className="h-full bg-emerald-500"
style={{ width: `${scoreRatio(submitResp.score)}%` }}
/>
</div> </div>
<p className="text-xs text-zinc-600">
{tx("本次得分进度:", "Score progress: ")}
{scoreRatio(submitResp.score)}%
</p>
</div> </div>
<p> <Link href="/submissions" className="text-xs underline hover:text-white">
{tx("提交 ID", "Submission ID:")} {tx("查看详情", "Details")}
<Link className="ml-1 text-blue-600 underline" href={`/submissions/${submitResp.id}`}>
{submitResp.id}
</Link> </Link>
</p> </div>
{submitWarningCount > 0 && ( </div>
<p className="text-xs text-amber-700">
{tx(
"提示:有编译警告但不影响 AC。可将循环下标改为",
"Tip: compile warnings do not block AC. You can use"
)}{" "}
<code>size_t</code> {tx("或对", "or cast")} <code>size()</code>{" "}
{tx("做显式类型转换,减少 signed/unsigned warning。", "explicitly to reduce signed/unsigned warnings.")}
</p>
)}
{submitResp.compile_log && (
<details className="rounded border bg-zinc-50 p-2">
<summary className="cursor-pointer text-xs text-zinc-700">{tx("查看编译日志", "View Compile Log")}</summary>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
{submitResp.compile_log}
</pre>
</details>
)}
{submitResp.runtime_log && (
<details className="rounded border bg-zinc-50 p-2" open>
<summary className="cursor-pointer text-xs text-zinc-700">{tx("查看运行日志", "View Runtime Log")}</summary>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
{submitResp.runtime_log}
</pre>
</details>
)} )}
</div> </div>
)} )}
{showSolutions && ( <div className="rounded-none border-[3px] border-black bg-[color:var(--mc-stone-dark)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<div className="mt-5 rounded-lg border bg-zinc-50 p-3"> <h3 className="font-bold text-white mb-2 mc-text-shadow-sm flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2"> <span>{tx("输入数据卷轴", "Input Scroll")}</span>
<h3 className="text-sm font-semibold">{tx("官方/LLM 题解", "Official/LLM Solutions")}</h3> <button className="text-xs underline opacity-70 hover:opacity-100" onClick={() => setRunInput(sampleInput)}>
{tx("重置样例", "Reset Sample")}
</button>
</h3>
<textarea
className="w-full h-32 bg-black text-[color:var(--mc-plank-light)] border border-[color:var(--mc-stone)] p-2 font-mono text-sm resize-y"
value={runInput}
onChange={(e) => setRunInput(e.target.value)}
placeholder={tx("在此输入测试数据...", "Enter test input here...")}
/>
</div>
<div className="rounded-none border-[3px] border-black bg-[color:var(--surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-[color:var(--mc-diamond)] mc-text-shadow-sm">{tx("先知启示 (AI 题解)", "Oracle's Wisdom")}</h3>
<button <button
className="rounded border px-3 py-1 text-xs disabled:opacity-50" className="text-xs text-[color:var(--mc-gold)] hover:underline"
onClick={() => setShowSolutions(!showSolutions)}
>
{showSolutions ? tx("隐藏", "Hide") : tx("展开", "Reveal")}
</button>
</div>
{showSolutions && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs">
<span className={answerStatusClass}>{answerStatusLabel}</span>
<button
className="mc-btn text-xs py-1 px-2"
onClick={() => void triggerSolutions()} onClick={() => void triggerSolutions()}
disabled={solutionLoading} disabled={solutionLoading}
> >
{tx("按 C++14 重新生成", "Regenerate in C++14")} {tx("按 C++14 重新祈祷", "Regenerate (C++14)")}
</button> </button>
</div>
{solutionMsg && <p className="text-xs text-[color:var(--mc-stone)]">{solutionMsg}</p>}
{/* Unlock button: solutions exist but not yet fetched in full mode */}
{/* Unlock Logic: Check if we need to show the unlock button */}
{solutionData?.has_solutions && solutionData?.access?.mode !== "full" && (
<div className="bg-[color:var(--mc-wood-dark)]/10 p-4 border-2 border-[color:var(--mc-gold)]/50 rounded-none mb-4">
<p className="text-sm text-[color:var(--mc-red)] font-bold mb-3 flex items-start gap-2">
<span className="text-xl"></span>
<span>
{tx(
"查看题解后,本题分数将被锁定,再次提交无法获得更高评分。",
"Viewing the solution will LOCK your score for this problem. Future submissions will not increase your rating."
)}
</span>
</p>
{unlockConfirm ? (
<div className="flex gap-3 animate-in fade-in zoom-in duration-200">
<button <button
className="rounded border px-3 py-1 text-xs disabled:opacity-50" className="mc-btn flex-1 py-2 text-sm bg-[color:var(--mc-red)] text-white hover:bg-red-600"
onClick={() => void loadSolutions("full")} onClick={() => void loadSolutions("full")}
disabled={solutionLoading} disabled={solutionLoading}
> >
{tx("查看答案(首免,后续-2分", "View answer (first daily free, then -2)")} {solutionLoading ? tx("解锁中...", "Unlocking...") : tx("确认解锁", "Confirm Unlock")}
</button> </button>
<button <button
className="rounded border px-3 py-1 text-xs disabled:opacity-50" className="mc-btn flex-1 py-2 text-sm"
onClick={() => void loadSolutions("preview")} onClick={() => setUnlockConfirm(false)}
disabled={solutionLoading} disabled={solutionLoading}
> >
{tx("刷新状态", "Refresh")} {tx("取消", "Cancel")}
</button> </button>
</div> </div>
) : (
{solutionMsg && <p className="mt-2 text-xs text-zinc-600">{solutionMsg}</p>} <button
className={`mc-btn w-full py-2 text-sm ${(solutionData.access?.daily_free || solutionData.access?.charged || answerStatusLabel === tx("已有", "Available"))
{solutionData?.latest_job && ( ? "mc-btn-success"
<> : "mc-btn-primary"
<p className="mt-2 text-xs text-zinc-600"> }`}
{tx("任务", "Job")} #{solutionData.latest_job.id} · {solutionData.latest_job.status} · onClick={() => {
{tx("进度", "Progress")} {solutionData.latest_job.progress}% // If it's free or already bought, or solved (Answer Status: Available/Accepted), load directly.
</p> // Note: backend access check is authoritative, frontend logic is for UX.
{solutionData.latest_job.message && ( const isFree = solutionData.access?.daily_free || solutionData.access?.charged;
<p className="mt-1 text-xs text-zinc-500"> // We can also check if problem is solved if we had that info, but 'answerStatusLabel' might proxy it.
{tx("日志:", "Log: ")} // User requirement: "submitted and rated (successfully)" -> free.
{solutionData.latest_job.message} // Let's assume the backend 'access' object correctly returns 'charged=true' or 'daily_free' if conditions met.
</p> // If cost > 0, we show confirm.
if (isFree || (solutionData.access?.cost ?? 0) <= 0) {
void loadSolutions("full");
} else {
setUnlockConfirm(true);
}
}}
disabled={solutionLoading}
>
{solutionLoading
? tx("📜 加载中...", "📜 Loading...")
: (solutionData.access?.daily_free)
? tx("🔓 每日首次免费查看", "🔓 First View Free (Daily)")
: (solutionData.access?.charged)
? tx("🔓 已解锁 - 免费查看", "🔓 Unlocked - View Free")
: tx(`🔒 解锁题解 (消耗 ${solutionData.access?.cost ?? 2} 积分)`, `🔒 Unlock Solution (Cost ${solutionData.access?.cost ?? 2})`)
}
</button>
)} )}
</>
)}
<div className="mt-3 space-y-3">
{(solutionData?.items ?? []).map((item) => (
<article key={item.id} className="rounded border bg-white p-3">
<h4 className="text-sm font-semibold">
{tx("解法", "Method")} {item.variant}
{tx("", ": ")}
{item.title || tx("未命名解法", "Untitled")}
</h4>
{item.complexity && (
<p className="mt-1 text-xs text-zinc-600">
{tx("复杂度:", "Complexity: ")}
{item.complexity}
</p>
)}
{item.source === "fallback" && (
<p className="mt-1 text-xs text-amber-700">
{tx(
"当前为兜底模板题解LLM 本次未返回可用结构化结果)。",
"This is a fallback template solution (LLM did not return a structured result this run)."
)}
</p>
)}
{item.idea_md && (
<div className="mt-2 rounded bg-zinc-50 p-2">
<MarkdownRenderer markdown={item.idea_md} />
</div> </div>
)} )}
{item.code_cpp && (
<div className="mt-2 overflow-hidden rounded border border-zinc-800 bg-zinc-950"> {/* Divider before solution cards */}
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-800 px-2 py-2 text-xs text-zinc-300"> {(solutionData?.items?.length ?? 0) > 0 && (
<span>{tx("完整代码", "Full Code")}</span> <div className="border-t border-[color:var(--mc-stone-dark)] pt-2" />
<span> )}
{tx("共", "Total")} {codeLineCount(item.code_cpp)} {tx("行", "lines")}
{solutionData?.items.map((item, idx) => (
<div
key={item.id}
className="border-[2px] border-black bg-[color:var(--mc-obsidian)] p-3 shadow-[2px_2px_0_rgba(0,0,0,0.4)]"
>
{/* Solution header */}
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-[color:var(--mc-gold)] mc-text-shadow-sm text-sm">
{item.title || `${tx("解法", "Method")} ${item.variant || idx + 1}`}
</span> </span>
<div className="flex gap-2">
<button <button
className="w-full rounded border border-zinc-600 px-2 py-1 hover:bg-zinc-800 disabled:opacity-50 sm:ml-auto sm:w-auto" className="mc-btn text-xs py-0.5 px-2"
onClick={() => void applySolutionCode(item, false)} onClick={() => applySolutionCode(item, false)}
disabled={runLoading}
> >
{tx("一键写入答题窗口", "Insert to Editor")} {tx("📝 写入", "📝 Insert")}
</button> </button>
<button <button
className="w-full rounded border border-zinc-600 px-2 py-1 hover:bg-zinc-800 disabled:opacity-50 sm:w-auto" className="mc-btn text-xs py-0.5 px-2"
onClick={() => void applySolutionCode(item, true)} onClick={() => applySolutionCode(item, true)}
disabled={runLoading}
> >
{tx("写入并试运行", "Insert & Run")} {tx("运行", " Run")}
</button> </button>
</div> </div>
<pre className="solution-code-block overflow-x-auto whitespace-pre p-3 text-sm leading-6 text-zinc-100"> </div>
{normalizeCodeText(item.code_cpp)}
{/* Complexity & tags */}
{(item.complexity || item.tags_json) && (
<div className="flex flex-wrap gap-1 mb-2">
{item.complexity && (
<span className="text-[10px] px-1.5 py-0.5 bg-[color:var(--mc-stone-dark)] text-[color:var(--mc-plank-light)] border border-black">
{item.complexity}
</span>
)}
{(() => {
try {
const tags: string[] = typeof item.tags_json === "string"
? JSON.parse(item.tags_json)
: item.tags_json ?? [];
return tags.map((tag, ti) => (
<span
key={ti}
className="text-[10px] px-1.5 py-0.5 bg-[color:var(--mc-wood-dark)] text-[color:var(--mc-plank-light)] border border-black"
>
{tag}
</span>
));
} catch {
return null;
}
})()}
</div>
)}
{/* Idea summary — rendered as markdown */}
<div className="text-sm leading-relaxed [&_article]:text-[color:var(--mc-stone)] [&_h1]:text-[color:var(--mc-plank-light)] [&_h2]:text-[color:var(--mc-plank-light)] [&_h3]:text-[color:var(--mc-plank-light)] [&_strong]:text-[color:var(--mc-plank-light)] [&_code]:text-[color:var(--mc-gold)] [&_code]:bg-transparent [&_p]:text-[color:var(--mc-stone)] [&_li]:text-[color:var(--mc-stone)]">
<MarkdownRenderer
markdown={item.idea_md || tx("暂无思路摘要", "No summary.")}
className="problem-markdown-compact"
/>
</div>
{/* Collapsible code viewer */}
{item.code_cpp && (
<div className="mt-2 border-t border-[color:var(--mc-stone-dark)] pt-2">
<button
className="flex items-center gap-1 text-xs text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)] transition-colors"
onClick={() => {
setExpandedCodes(prev => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
>
<span className="inline-block transition-transform" style={{ transform: expandedCodes.has(item.id) ? 'rotate(90deg)' : 'rotate(0deg)' }}>
</span>
{expandedCodes.has(item.id)
? tx("收起代码", "Hide Code")
: tx("查看代码", "View Code")}
</button>
{expandedCodes.has(item.id) && (
<pre className="mt-2 p-3 bg-black/60 border border-[color:var(--mc-stone-dark)] overflow-x-auto text-[11px] leading-5 text-[color:var(--mc-plank-light)] font-mono max-h-[400px] overflow-y-auto">
<code>{item.code_cpp}</code>
</pre> </pre>
)}
</div> </div>
)} )}
{item.explanation_md && (
<div className="mt-2 rounded bg-zinc-50 p-2">
<MarkdownRenderer markdown={item.explanation_md} />
</div> </div>
)}
</article>
))} ))}
{!solutionLoading && (solutionData?.items.length ?? 0) === 0 && (
<p className="text-xs text-zinc-500"> {/* No solutions hint */}
{solutionData?.has_solutions {solutionData && !solutionData.has_solutions && !solutionLoading && (
? tx( <p className="text-xs text-[color:var(--mc-stone)] text-center py-2">
"已有答案,点击“查看答案(首免,后续-2分”即可加载。", {tx("尚无题解,点击「重新祈祷」生成", "No solutions yet. Click Regenerate.")}
"Answers exist. Click \"View answer\" to load."
)
: tx(
"暂无题解,点击“按 C++14 重新生成”可触发后台生成。",
"No solutions yet. Click \"Regenerate in C++14\" to trigger generation."
)}
</p> </p>
)} )}
</div> </div>
</div>
)} )}
</div>
</section> </section>
</div> </div>
)} )}

查看文件

@@ -43,19 +43,19 @@ type Preset = {
const PRESETS: Preset[] = [ const PRESETS: Preset[] = [
{ {
key: "csp-beginner-default", key: "csp-beginner-default",
labelZh: "CSP J/S 入门默认", labelZh: "CSP J/S 入门预设",
labelEn: "CSP J/S Beginner Default", labelEn: "CSP J/S Beginner Preset",
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"], tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
}, },
{ {
key: "csp-j", key: "csp-j",
labelZh: "仅 CSP-J / 普及", labelZh: "仅 CSP-J / 普及",
labelEn: "CSP-J / Junior Only", labelEn: "CSP-J / Junior Only",
tags: ["csp-j", "noip-junior"], tags: ["csp-j", "noip-junior"],
}, },
{ {
key: "csp-s", key: "csp-s",
labelZh: "仅 CSP-S / 提高", labelZh: "仅 CSP-S / 提高",
labelEn: "CSP-S / Senior Only", labelEn: "CSP-S / Senior Only",
tags: ["csp-s", "noip-senior"], tags: ["csp-s", "noip-senior"],
}, },
@@ -67,14 +67,14 @@ const PRESETS: Preset[] = [
}, },
{ {
key: "luogu-all", key: "luogu-all",
labelZh: "洛谷导入全部", labelZh: "洛谷全站导入",
labelEn: "All Luogu Imports", labelEn: "All Luogu Imports",
sourcePrefix: "luogu:", sourcePrefix: "luogu:",
tags: [], tags: [],
}, },
{ {
key: "all", key: "all",
labelZh: "全站全部来源", labelZh: "全部来源",
labelEn: "All Sources", labelEn: "All Sources",
tags: [], tags: [],
}, },
@@ -83,39 +83,39 @@ const PRESETS: Preset[] = [
const QUICK_CARDS = [ const QUICK_CARDS = [
{ {
presetKey: "csp-j", presetKey: "csp-j",
titleZh: "CSP-J 真题", titleZh: "CSP-J 试炼",
titleEn: "CSP-J Problems", titleEn: "CSP-J Trials",
descZh: "普及组入门训练", descZh: "普及组入门任务",
descEn: "Junior training set", descEn: "Junior Tier Quests",
}, },
{ {
presetKey: "csp-s", presetKey: "csp-s",
titleZh: "CSP-S 真题", titleZh: "CSP-S 挑战",
titleEn: "CSP-S Problems", titleEn: "CSP-S Challenges",
descZh: "提高组进阶训练", descZh: "提高组进阶任务",
descEn: "Senior advanced set", descEn: "Senior Tier Quests",
}, },
{ {
presetKey: "noip-junior", presetKey: "noip-junior",
titleZh: "NOIP 入门", titleZh: "NOIP 基础",
titleEn: "NOIP Junior", titleEn: "NOIP Basics",
descZh: "基础算法与思维", descZh: "算法与思维",
descEn: "Basic algorithm thinking", descEn: "Algorithm & Logic",
}, },
] as const; ] as const;
const DIFFICULTY_OPTIONS = [ const DIFFICULTY_OPTIONS = [
{ value: "0", labelZh: "全部难度", labelEn: "All Levels" }, { value: "0", labelZh: "全部难度", labelEn: "All Tiers" },
{ value: "1", labelZh: "1", labelEn: "1" }, { value: "1", labelZh: "1 - 木剑", labelEn: "1 - Wood" },
{ value: "2", labelZh: "2", labelEn: "2" }, { value: "2", labelZh: "2 - 木剑", labelEn: "2 - Wood" },
{ value: "3", labelZh: "3", labelEn: "3" }, { value: "3", labelZh: "3 - 石剑", labelEn: "3 - Stone" },
{ value: "4", labelZh: "4", labelEn: "4" }, { value: "4", labelZh: "4 - 石剑", labelEn: "4 - Stone" },
{ value: "5", labelZh: "5", labelEn: "5" }, { value: "5", labelZh: "5 - 铁剑", labelEn: "5 - Iron" },
{ value: "6", labelZh: "6", labelEn: "6" }, { value: "6", labelZh: "6 - 铁剑", labelEn: "6 - Iron" },
{ value: "7", labelZh: "7", labelEn: "7" }, { value: "7", labelZh: "7 - 钻石", labelEn: "7 - Diamond" },
{ value: "8", labelZh: "8", labelEn: "8" }, { value: "8", labelZh: "8 - 钻石", labelEn: "8 - Diamond" },
{ value: "9", labelZh: "9", labelEn: "9" }, { value: "9", labelZh: "9 - 下界合金", labelEn: "9 - Netherite" },
{ value: "10", labelZh: "10", labelEn: "10" }, { value: "10", labelZh: "10 - 下界合金", labelEn: "10 - Netherite" },
] as const; ] as const;
function parseProfile(raw: string): ProblemProfile | null { function parseProfile(raw: string): ProblemProfile | null {
@@ -129,10 +129,19 @@ function parseProfile(raw: string): ProblemProfile | null {
} }
function difficultyClass(diff: number): string { function difficultyClass(diff: number): string {
if (diff <= 2) return "text-emerald-600"; if (diff <= 2) return "text-[color:var(--mc-wood)]";
if (diff <= 4) return "text-blue-600"; if (diff <= 4) return "text-[color:var(--mc-stone-dark)]";
if (diff <= 6) return "text-orange-600"; if (diff <= 6) return "text-zinc-100"; // Iron-ish
return "text-rose-600"; if (diff <= 8) return "text-[color:var(--mc-diamond)]";
return "text-[color:var(--mc-red)]"; // Netherite/Hard
}
function difficultyIcon(diff: number): string {
if (diff <= 2) return "🪵";
if (diff <= 4) return "🪨";
if (diff <= 6) return "⚔️";
if (diff <= 8) return "💎";
return "🔥";
} }
function resolvePid(problem: Problem, profile: ProblemProfile | null): string { function resolvePid(problem: Problem, profile: ProblemProfile | null): string {
@@ -232,23 +241,23 @@ export default function ProblemsPage() {
}; };
return ( return (
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8"> <main className="mx-auto max-w-7xl 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-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
{tx("题库CSP J/S 入门)", "Problem Set (CSP J/S Beginner)")} {tx("任务布告栏", "Quest Board")}
</h1> </h1>
<p className="mt-1 text-sm text-zinc-600"> <p className="mt-1 text-sm text-[color:var(--mc-stone)]">
{tx( {tx(
"参考洛谷题库列表交互,默认聚焦 CSP-J / CSP-S / NOIP 入门训练。", "接受任务,赚取 XP,提升等级",
"Interaction style is inspired by Luogu problem list. Default focus: CSP-J / CSP-S / NOIP junior training." "Accept Quests, Earn XP, Level Up!"
)} )}
</p> </p>
</div> </div>
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end"> <div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
<p className="text-zinc-600">{tx("", "Total")} {totalCount} {tx("题", "problems")}</p> <p className="text-[color:var(--mc-gold)]">{tx("总任务数: ", "Total Quests: ")} {totalCount}</p>
<Link className="w-full rounded border px-3 py-1 text-center hover:bg-zinc-100 sm:w-auto" href="/backend-logs"> <Link className="mc-btn w-full text-center sm:w-auto" href="/backend-logs">
{tx("查看后台日志", "View Backend Logs")} {tx("服务器日志", "Server Logs")}
</Link> </Link>
</div> </div>
</div> </div>
@@ -260,15 +269,14 @@ export default function ProblemsPage() {
<button <button
key={card.presetKey} key={card.presetKey}
type="button" type="button"
className={`rounded-xl border px-4 py-3 text-left transition ${ className={`rounded-xl border px-4 py-3 text-left transition ${active
active ? "bg-[color:var(--mc-grass-dark)] text-white"
? "border-zinc-900 bg-zinc-900 text-white" : "bg-[color:var(--mc-plank)] text-black hover:bg-[color:var(--mc-plank-light)]"
: "bg-white text-zinc-900 hover:border-zinc-400"
}`} }`}
onClick={() => selectPreset(card.presetKey)} onClick={() => selectPreset(card.presetKey)}
> >
<p className="text-base font-semibold">{isZh ? card.titleZh : card.titleEn}</p> <p className="text-base font-bold mc-text-shadow-sm">{isZh ? card.titleZh : card.titleEn}</p>
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}> <p className={`mt-1 text-xs ${active ? "text-zinc-100" : "text-zinc-800"}`}>
{isZh ? card.descZh : card.descEn} {isZh ? card.descZh : card.descEn}
</p> </p>
</button> </button>
@@ -276,9 +284,9 @@ export default function ProblemsPage() {
})} })}
</section> </section>
<section className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-2 lg:grid-cols-6"> <section className="mt-4 grid gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-4 md:grid-cols-2 lg:grid-cols-6 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<select <select
className="rounded border px-3 py-2 text-sm" className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
value={presetKey} value={presetKey}
onChange={(e) => { onChange={(e) => {
selectPreset(e.target.value); selectPreset(e.target.value);
@@ -292,8 +300,8 @@ export default function ProblemsPage() {
</select> </select>
<input <input
className="rounded border px-3 py-2 text-sm lg:col-span-2" className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm lg:col-span-2"
placeholder={tx("搜索题号/标题/题面关键词", "Search id/title/statement keywords")} placeholder={tx("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
value={keywordInput} value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)} onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -302,7 +310,7 @@ export default function ProblemsPage() {
/> />
<select <select
className="rounded border px-3 py-2 text-sm" className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
value={difficulty} value={difficulty}
onChange={(e) => { onChange={(e) => {
setDifficulty(e.target.value); setDifficulty(e.target.value);
@@ -311,13 +319,13 @@ export default function ProblemsPage() {
> >
{DIFFICULTY_OPTIONS.map((item) => ( {DIFFICULTY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}> <option key={item.value} value={item.value}>
{tx("难度", "Difficulty")} {isZh ? item.labelZh : item.labelEn} {tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
</option> </option>
))} ))}
</select> </select>
<select <select
className="rounded border px-3 py-2 text-sm" className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
value={`${orderBy}:${order}`} value={`${orderBy}:${order}`}
onChange={(e) => { onChange={(e) => {
const [ob, od] = e.target.value.split(":"); const [ob, od] = e.target.value.split(":");
@@ -326,16 +334,16 @@ export default function ProblemsPage() {
setPage(1); setPage(1);
}} }}
> >
<option value="id:asc">{tx("号升序", "ID Asc")}</option> <option value="id:asc">{tx("号升序", "ID Asc")}</option>
<option value="id:desc">{tx("号降序", "ID Desc")}</option> <option value="id:desc">{tx("号降序", "ID Desc")}</option>
<option value="difficulty:asc">{tx("难度升序", "Difficulty Asc")}</option> <option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
<option value="difficulty:desc">{tx("难度降序", "Difficulty Desc")}</option> <option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
<option value="created_at:desc">{tx("最新导入", "Newest Imported")}</option> <option value="created_at:desc">{tx("最新发布", "Newest")}</option>
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option> <option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
</select> </select>
<button <button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50" className="mc-btn mc-btn-primary"
onClick={applySearch} onClick={applySearch}
disabled={loading} disabled={loading}
> >
@@ -343,29 +351,28 @@ export default function ProblemsPage() {
</button> </button>
</section> </section>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>} {error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
<section className="mt-4 rounded-xl border bg-white"> <section className="mt-4 rounded-xl border bg-[color:var(--surface)] shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
<div className="divide-y md:hidden"> <div className="divide-y divide-black md:hidden">
{rows.map(({ problem, profile }) => { {rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile); const pid = resolvePid(problem, profile);
const tags = resolveTags(profile); const tags = resolveTags(profile);
return ( return (
<article key={problem.id} className="space-y-2 p-3"> <article key={problem.id} className="space-y-2 p-3 bg-[color:var(--surface)] text-zinc-100">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${problem.id}`}> <Link className="font-bold text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)] hover:underline" href={`/problems/${problem.id}`}>
{pid} · {problem.title} {pid} · {problem.title}
</Link> </Link>
<span className={`shrink-0 text-sm font-semibold ${difficultyClass(problem.difficulty)}`}> <span className={`shrink-0 text-sm font-bold ${difficultyClass(problem.difficulty)}`}>
{tx("难度", "Difficulty")} {problem.difficulty} {difficultyIcon(problem.difficulty)} T{problem.difficulty}
</span> </span>
</div> </div>
<p className="text-xs text-zinc-600">{tx("通过/提交", "Accepted/Submissions: ")}{resolvePassRate(profile)}</p> <p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率", "Clear Rate: ")}{resolvePassRate(profile)}</p>
<p className="text-xs text-zinc-500 break-all">{tx("来源:", "Source: ")}{problem.source || "-"}</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-xs text-zinc-400">-</span>} {tags.length === 0 && <span className="text-xs text-[color:var(--mc-stone-dark)]">-</span>}
{tags.map((tag) => ( {tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs"> <span key={tag} className="border border-black bg-[color:var(--mc-stone-dark)] px-2 py-0.5 text-xs text-white">
{tag} {tag}
</span> </span>
))} ))}
@@ -374,23 +381,23 @@ export default function ProblemsPage() {
); );
})} })}
{!loading && rows.length === 0 && ( {!loading && rows.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500"> <p className="px-3 py-6 text-center text-sm text-[color:var(--mc-stone)]">
{tx( {tx(
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。", "没有找到任务。请尝试其他频道或刷新地图。",
"No problems under current filters. Switch preset or run import first." "No quests found. Try different channel or reload map."
)} )}
</p> </p>
)} )}
</div> </div>
<div className="hidden overflow-x-auto md:block"> <div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm text-zinc-200">
<thead className="bg-zinc-100 text-left text-zinc-700"> <thead className="bg-[color:var(--mc-wood-dark)] text-left text-white border-b-2 border-black">
<tr> <tr>
<th className="px-3 py-2">{tx("号", "ID")}</th> <th className="px-3 py-2">{tx("号", "ID")}</th>
<th className="px-3 py-2">{tx("标题", "Title")}</th> <th className="px-3 py-2">{tx("任务标题", "Quest Title")}</th>
<th className="px-3 py-2">{tx("通过/提交", "Accepted/Submissions")}</th> <th className="px-3 py-2">{tx("完成率", "Clear Rate")}</th>
<th className="px-3 py-2">{tx("难度", "Difficulty")}</th> <th className="px-3 py-2">{tx("难度", "Tier")}</th>
<th className="px-3 py-2">{tx("标签", "Tags")}</th> <th className="px-3 py-2">{tx("标签", "Tags")}</th>
<th className="px-3 py-2">{tx("来源", "Source")}</th> <th className="px-3 py-2">{tx("来源", "Source")}</th>
</tr> </tr>
@@ -400,37 +407,37 @@ export default function ProblemsPage() {
const pid = resolvePid(problem, profile); const pid = resolvePid(problem, profile);
const tags = resolveTags(profile); const tags = resolveTags(profile);
return ( return (
<tr key={problem.id} className="border-t hover:bg-zinc-50"> <tr key={problem.id} className="border-b border-black hover:bg-[color:var(--surface-soft)] transition-colors">
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td> <td className="px-3 py-2 font-bold text-[color:var(--mc-diamond)]">{pid}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Link className="hover:underline" href={`/problems/${problem.id}`}> <Link className="hover:underline hover:text-[color:var(--mc-gold)]" href={`/problems/${problem.id}`}>
{problem.title} {problem.title}
</Link> </Link>
</td> </td>
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td> <td className="px-3 py-2 text-[color:var(--mc-stone)]">{resolvePassRate(profile)}</td>
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}> <td className={`px-3 py-2 font-bold ${difficultyClass(problem.difficulty)}`}>
{problem.difficulty} {difficultyIcon(problem.difficulty)} {problem.difficulty}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-zinc-400">-</span>} {tags.length === 0 && <span className="text-[color:var(--mc-stone-dark)]">-</span>}
{tags.map((tag) => ( {tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs"> <span key={tag} className="border border-black bg-[color:var(--mc-stone-dark)] px-2 py-0.5 text-xs text-white">
{tag} {tag}
</span> </span>
))} ))}
</div> </div>
</td> </td>
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td> <td className="px-3 py-2 text-[color:var(--mc-stone)]">{problem.source || "-"}</td>
</tr> </tr>
); );
})} })}
{!loading && rows.length === 0 && ( {!loading && rows.length === 0 && (
<tr> <tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}> <td className="px-3 py-6 text-center text-[color:var(--mc-stone)]" colSpan={6}>
{tx( {tx(
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。", "没有找到任务。请尝试其他频道或刷新地图。",
"No problems under current filters. Switch preset or run import first." "No quests found. Try different channel or reload map."
)} )}
</td> </td>
</tr> </tr>
@@ -443,17 +450,17 @@ export default function ProblemsPage() {
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"> <div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
className="rounded border px-3 py-1 disabled:opacity-50" className="mc-btn"
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page <= 1} disabled={loading || page <= 1}
> >
{tx("上一页", "Prev")} {tx("上一页", "Prev")}
</button> </button>
<span> <span className="text-[color:var(--mc-diamond)] font-bold">
{isZh ? `${page} / ${totalPages}` : `Page ${page} / ${totalPages}`} {isZh ? `${page} / ${totalPages}` : `Page ${page} / ${totalPages}`}
</span> </span>
<button <button
className="rounded border px-3 py-1 disabled:opacity-50" className="mc-btn"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={loading || page >= totalPages} disabled={loading || page >= totalPages}
> >
@@ -461,10 +468,10 @@ export default function ProblemsPage() {
</button> </button>
</div> </div>
<div className="flex items-center gap-2 sm:justify-end"> <div className="flex items-center gap-2 sm:justify-end text-[color:var(--mc-stone)]">
<span>{tx("每页", "Per Page")}</span> <span>{tx("每页", "Per Page")}</span>
<select <select
className="rounded border px-2 py-1" className="rounded border border-black bg-[color:var(--surface)] text-white px-2 py-1"
value={pageSize} value={pageSize}
onChange={(e) => { onChange={(e) => {
setPageSize(Number(e.target.value)); setPageSize(Number(e.target.value));

查看文件

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n"; import { useI18nText } from "@/lib/i18n";
import { useUiPreferences } from "@/components/ui-preference-provider";
type Submission = { type Submission = {
id: number; id: number;
@@ -22,6 +23,8 @@ type ListResp = { items: Submission[]; page: number; page_size: number };
export default function SubmissionsPage() { export default function SubmissionsPage() {
const { tx } = useI18nText(); const { tx } = useI18nText();
const { theme } = useUiPreferences();
const isMc = theme === "minecraft";
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [problemId, setProblemId] = useState(""); const [problemId, setProblemId] = useState("");
const [contestId, setContestId] = useState(""); const [contestId, setContestId] = useState("");
@@ -35,9 +38,28 @@ export default function SubmissionsPage() {
}; };
const ratingDeltaClass = (delta: number) => { const ratingDeltaClass = (delta: number) => {
if (delta > 0) return "text-emerald-700"; if (delta > 0) return isMc ? "text-[color:var(--mc-green)]" : "text-emerald-700";
if (delta < 0) return "text-red-700"; if (delta < 0) return isMc ? "text-[color:var(--mc-red)]" : "text-red-700";
return "text-zinc-600"; return isMc ? "text-zinc-400" : "text-zinc-600";
};
/** Map raw status codes to themed display text */
const statusLabel = (raw: string) => {
if (!isMc) return raw;
const map: Record<string, string> = {
Accepted: "✅ " + tx("通过", "Accepted"),
AC: "✅ AC",
WA: "❌ WA",
"Wrong Answer": "❌ " + tx("答案错误", "Wrong Answer"),
TLE: "⏰ TLE",
"Time Limit Exceeded": "⏰ " + tx("超时", "TLE"),
MLE: "💾 MLE",
RE: "💥 RE",
"Runtime Error": "💥 " + tx("运行错误", "RE"),
CE: "🔧 CE",
"Compile Error": "🔧 " + tx("编译错误", "CE"),
};
return map[raw] ?? raw;
}; };
const load = async () => { const load = async () => {
@@ -63,103 +85,133 @@ export default function SubmissionsPage() {
}, []); }, []);
return ( 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"> <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">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
{tx("提交记录", "Submissions")} {isMc ? (
<span className="flex items-center gap-2">
<span>📜</span>
{tx("施法记录", "Spell Cast Log")}
</span>
) : (
tx("提交记录", "Submissions")
)}
</h1> </h1>
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4"> {/* Filters */}
<div className={`mt-4 grid gap-3 rounded-xl border p-4 md:grid-cols-4 ${isMc
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)]"
: "bg-white"}`}>
<input <input
className="rounded border px-3 py-2" className={`rounded border px-3 py-2 ${isMc
placeholder="user_id" ? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
: ""}`}
placeholder={isMc ? tx("冒险者 ID", "Adventurer ID") : "user_id"}
value={userId} value={userId}
onChange={(e) => setUserId(e.target.value)} onChange={(e) => setUserId(e.target.value)}
/> />
<input <input
className="rounded border px-3 py-2" className={`rounded border px-3 py-2 ${isMc
placeholder="problem_id" ? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
: ""}`}
placeholder={isMc ? tx("任务 ID", "Quest ID") : "problem_id"}
value={problemId} value={problemId}
onChange={(e) => setProblemId(e.target.value)} onChange={(e) => setProblemId(e.target.value)}
/> />
<input <input
className="rounded border px-3 py-2" className={`rounded border px-3 py-2 ${isMc
placeholder="contest_id" ? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
: ""}`}
placeholder={isMc ? tx("突袭 ID", "Raid ID") : "contest_id"}
value={contestId} value={contestId}
onChange={(e) => setContestId(e.target.value)} onChange={(e) => setContestId(e.target.value)}
/> />
<button <button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50" className={`px-4 py-2 disabled:opacity-50 ${isMc
? "mc-btn"
: "rounded bg-zinc-900 text-white"}`}
onClick={() => void load()} onClick={() => void load()}
disabled={loading} disabled={loading}
> >
{loading ? tx("加载中...", "Loading...") : tx("筛选", "Filter")} {loading
? tx("搜索中...", "Searching...")
: isMc
? tx("🔍 搜索记录", "🔍 Search Logs")
: tx("筛选", "Filter")}
</button> </button>
</div> </div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>} {error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 rounded-xl border bg-white"> {/* Mobile cards */}
<div className={`mt-4 rounded-xl border ${isMc
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)]"
: "bg-white"}`}>
<div className="divide-y md:hidden"> <div className="divide-y md:hidden">
{items.map((s) => ( {items.map((s) => (
<article key={s.id} className="space-y-2 p-3 text-sm"> <article key={s.id} className={`space-y-2 p-3 text-sm ${isMc ? "text-zinc-200 border-zinc-700" : ""}`}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="font-medium">{tx("提交", "Submission")} #{s.id}</p> <p className={`font-medium ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>
<span className="text-xs text-zinc-500">{s.status}</span> {isMc ? tx("施法", "Cast") : tx("提交", "Submission")} #{s.id}
</p>
<span className={`text-xs ${isMc ? "" : "text-zinc-500"}`}>{statusLabel(s.status)}</span>
</div> </div>
<p className="text-xs text-zinc-600"> <p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
{tx("用户", "User")} {s.user_id} · {tx("题目", "Problem")} {s.problem_id} · {tx("分数", "Score")} {s.score} {isMc ? tx("冒险者", "Player") : tx("用户", "User")} {s.user_id} · {tx("任务", "Quest")} {s.problem_id} · {tx("分数", "Score")} {s.score}
</p> </p>
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}> <p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
{tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)} {isMc ? tx("绿宝石变化", "Emerald Δ") : tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
</p> </p>
<p className="text-xs text-zinc-600">{tx("耗时", "Time")} {s.time_ms} ms</p> <p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("耗时", "Time")} {s.time_ms} ms</p>
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}> <Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
{tx("查看详情", "View Detail")} {isMc ? tx("📜 查看详情", "📜 View Detail") : tx("查看详情", "View Detail")}
</Link> </Link>
</article> </article>
))} ))}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">{tx("暂无提交记录", "No submissions yet")}</p> <p className={`px-3 py-5 text-center text-sm ${isMc ? "text-zinc-500" : "text-zinc-500"}`}>
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
</p>
)} )}
</div> </div>
{/* Desktop table */}
<div className="hidden overflow-x-auto md:block"> <div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left"> <thead className={isMc ? "bg-black/30 text-zinc-300 text-left" : "bg-zinc-100 text-left"}>
<tr> <tr>
<th className="px-3 py-2">ID</th> <th className="px-3 py-2">ID</th>
<th className="px-3 py-2">{tx("用户", "User")}</th> <th className="px-3 py-2">{isMc ? tx("冒险者", "Player") : tx("用户", "User")}</th>
<th className="px-3 py-2">{tx("题目", "Problem")}</th> <th className="px-3 py-2">{tx("任务", "Quest")}</th>
<th className="px-3 py-2">{tx("状态", "Status")}</th> <th className="px-3 py-2">{tx("状态", "Status")}</th>
<th className="px-3 py-2">{tx("分数", "Score")}</th> <th className="px-3 py-2">{tx("分数", "Score")}</th>
<th className="px-3 py-2">{tx("Rating 变化", "Rating Delta")}</th> <th className="px-3 py-2">{isMc ? tx("绿宝石 Δ", "Emerald Δ") : tx("Rating 变化", "Rating Delta")}</th>
<th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th> <th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th>
<th className="px-3 py-2">{tx("详情", "Detail")}</th> <th className="px-3 py-2">{tx("详情", "Detail")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((s) => ( {items.map((s) => (
<tr key={s.id} className="border-t"> <tr key={s.id} className={`border-t ${isMc ? "border-zinc-700" : ""}`}>
<td className="px-3 py-2">{s.id}</td> <td className={`px-3 py-2 ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>{s.id}</td>
<td className="px-3 py-2">{s.user_id}</td> <td className="px-3 py-2">{s.user_id}</td>
<td className="px-3 py-2">{s.problem_id}</td> <td className="px-3 py-2">{s.problem_id}</td>
<td className="px-3 py-2">{s.status}</td> <td className="px-3 py-2">{statusLabel(s.status)}</td>
<td className="px-3 py-2">{s.score}</td> <td className="px-3 py-2">{s.score}</td>
<td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}> <td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}>
{fmtRatingDelta(s.rating_delta)} {fmtRatingDelta(s.rating_delta)}
</td> </td>
<td className="px-3 py-2">{s.time_ms}</td> <td className="px-3 py-2">{s.time_ms}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}> <Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
{tx("查看", "View")} {isMc ? tx("📜 查看", "📜 View") : tx("查看", "View")}
</Link> </Link>
</td> </td>
</tr> </tr>
))} ))}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<tr> <tr>
<td className="px-3 py-5 text-center text-zinc-500" colSpan={8}> <td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={8}>
{tx("暂无提交记录", "No submissions yet")} {isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
</td> </td>
</tr> </tr>
)} )}

查看文件

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth"; import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n"; import { useI18nText } from "@/lib/i18n";
import { useUiPreferences } from "@/components/ui-preference-provider";
type WrongBookItem = { type WrongBookItem = {
user_id: number; user_id: number;
@@ -23,6 +24,8 @@ function fmtTs(v: number): string {
export default function WrongBookPage() { export default function WrongBookPage() {
const { tx } = useI18nText(); const { tx } = useI18nText();
const { theme } = useUiPreferences();
const isMc = theme === "minecraft";
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [items, setItems] = useState<WrongBookItem[]>([]); const [items, setItems] = useState<WrongBookItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -73,21 +76,36 @@ export default function WrongBookPage() {
}; };
return ( return (
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8"> <main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"> <h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
{tx("错题本", "Wrong Book")} {isMc ? (
<span className="flex items-center gap-2">
<span>📜</span>
{tx("诅咒卷轴", "Cursed Scrolls")}
</span>
) : (
tx("错题本", "Wrong Book")
)}
</h1> </h1>
<p className="mt-2 text-sm text-zinc-600"> <p className={`mt-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
{tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")} {isMc
? tx("失败的咒语会自动记录在诅咒卷轴中,复习并重新挑战!", "Failed spells are recorded in your Cursed Scrolls. Review and retry!")
: tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
</p> </p>
<div className="mt-4"> <div className="mt-4">
<button <button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50" className={`px-4 py-2 disabled:opacity-50 ${isMc
? "mc-btn"
: "rounded bg-zinc-900 text-white"}`}
onClick={() => void load()} onClick={() => void load()}
disabled={loading} disabled={loading}
> >
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新", "Refresh")} {loading
? tx("搜索中...", "Searching...")
: isMc
? tx("🔍 重新搜索", "🔍 Search Again")
: tx("刷新", "Refresh")}
</button> </button>
</div> </div>
@@ -95,46 +113,63 @@ export default function WrongBookPage() {
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{items.map((item) => ( {items.map((item) => (
<div key={item.problem_id} className="rounded-xl border bg-white p-4"> <div
key={item.problem_id}
className={`rounded-xl border p-4 ${isMc
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white"
: "bg-white"}`}
>
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${item.problem_id}`}> <Link
className={`font-medium hover:underline ${isMc ? "text-[color:var(--mc-gold)]" : "text-blue-700"}`}
href={`/problems/${item.problem_id}`}
>
#{item.problem_id} {item.problem_title} #{item.problem_id} {item.problem_title}
</Link> </Link>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100" className={`rounded border px-3 py-1 text-sm ${isMc
? "mc-btn text-xs"
: "hover:bg-zinc-100"}`}
href={`/problems/${item.problem_id}`} href={`/problems/${item.problem_id}`}
> >
{tx("查看题目", "View Problem")} {isMc ? tx("⚔️ 重新挑战", "⚔️ Retry Quest") : tx("查看任务", "View Quest")}
</Link> </Link>
{item.last_submission_id && ( {item.last_submission_id && (
<Link <Link
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100" className={`rounded border px-3 py-1 text-sm ${isMc
? "mc-btn text-xs"
: "hover:bg-zinc-100"}`}
href={`/submissions/${item.last_submission_id}`} href={`/submissions/${item.last_submission_id}`}
> >
{tx("查看最近提交", "View Latest Submission")} {isMc ? tx("📜 查看战报", "📜 View Battle Log") : tx("查看最近提交", "View Latest Submission")}
</Link> </Link>
)} )}
</div> </div>
</div> </div>
<p className="mt-1 text-xs text-zinc-500"> <p className={`mt-1 text-xs ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
{tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "} {isMc ? tx("上次施法:", "Last Cast:") : tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
{tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)} {tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)}
</p> </p>
<div className="mt-2 flex flex-wrap justify-end gap-2"> <div className="mt-2 flex flex-wrap justify-end gap-2">
<button <button
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100" className={`rounded border px-3 py-1 text-sm ${isMc
? "border-red-900 bg-red-900/40 text-red-300 hover:bg-red-900/60"
: "hover:bg-zinc-100"}`}
onClick={() => void removeItem(item.problem_id)} onClick={() => void removeItem(item.problem_id)}
> >
{tx("移除", "Remove")} {isMc ? tx("🗑️ 移除诅咒", "🗑️ Remove Curse") : tx("移除", "Remove")}
</button> </button>
</div> </div>
<textarea <textarea
className="mt-2 h-24 w-full rounded border p-2 text-sm" className={`mt-2 h-24 w-full rounded border p-2 text-sm ${isMc
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
: ""}`}
value={item.note} value={item.note}
placeholder={isMc ? tx("记录你的笔记...", "Write your notes...") : ""}
onChange={(e) => { onChange={(e) => {
const next = e.target.value; const next = e.target.value;
setItems((prev) => setItems((prev) =>
@@ -145,18 +180,27 @@ export default function WrongBookPage() {
}} }}
/> />
<button <button
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100" className={`mt-2 rounded border px-3 py-1 text-sm ${isMc
? "mc-btn text-xs"
: "hover:bg-zinc-100"}`}
onClick={() => void updateNote(item.problem_id, item.note)} onClick={() => void updateNote(item.problem_id, item.note)}
> >
{tx("保存备注", "Save Note")} {isMc ? tx("💾 保存笔记", "💾 Save Notes") : tx("保存备注", "Save Note")}
</button> </button>
</div> </div>
))} ))}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<div className="rounded-xl border bg-white p-6 text-center text-sm text-zinc-500"> <div className={`rounded-xl border p-6 text-center text-sm ${isMc
{tx( ? "bg-black/20 border-zinc-700 text-zinc-500"
"暂无错题。提交未通过后会自动加入错题本,可点击“查看题目/查看最近提交”快速复盘。", : "bg-white text-zinc-500"}`}>
"No wrong-book entries yet. Failed submissions will be added automatically; use “View Problem/View Latest Submission” to review quickly." {isMc
? tx(
"🎉 诅咒卷轴为空!你的每一个咒语都精准命中了目标。",
"🎉 No cursed scrolls! Every spell you cast hit its mark."
)
: tx(
"暂无错题。提交未通过后会自动加入错题本,可点击「查看题目/查看最近提交」快速复盘。",
'No wrong-book entries yet. Failed submissions will be added automatically.'
)} )}
</div> </div>
)} )}

查看文件

@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { PixelAvatar } from "@/components/pixel-avatar"; import { PixelAvatar } from "@/components/pixel-avatar";
import { useUiPreferences } from "@/components/ui-preference-provider"; import { useUiPreferences } from "@/components/ui-preference-provider";
import { XpBar } from "@/components/xp-bar";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { clearToken, readToken } from "@/lib/auth"; import { clearToken, readToken } from "@/lib/auth";
import type { ThemeId } from "@/themes/types"; import type { ThemeId } from "@/themes/types";
@@ -199,8 +200,7 @@ export function AppNav() {
<div key={group.key} className="relative"> <div key={group.key} className="relative">
<button <button
type="button" type="button"
className={`rounded-md border px-3 py-1 text-sm ${ className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`} }`}
aria-expanded={opened} aria-expanded={opened}
onClick={() => onClick={() =>
@@ -211,8 +211,7 @@ export function AppNav() {
</button> </button>
{opened && ( {opened && (
<div <div
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${ className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
}`} }`}
> >
{group.links.map((item) => { {group.links.map((item) => {
@@ -221,8 +220,7 @@ export function AppNav() {
<button <button
key={item.href} key={item.href}
type="button" type="button"
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${ className={`block w-full rounded px-3 py-1.5 text-left text-sm ${linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`} }`}
onClick={() => { onClick={() => {
setDesktopOpenGroup(null); setDesktopOpenGroup(null);
@@ -306,8 +304,7 @@ export function AppNav() {
<button <button
key={group.key} key={group.key}
type="button" type="button"
className={`rounded-md border px-3 py-1 text-sm ${ className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`} }`}
onClick={() => router.push(group.links[0]?.href ?? "/")} onClick={() => router.push(group.links[0]?.href ?? "/")}
> >
@@ -391,12 +388,19 @@ export function AppNav() {
{hasToken ? t("nav.logged_in") : t("nav.logged_out")} {hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span> </span>
{hasToken && ( {hasToken && (
<>
{theme === "minecraft" && (
<div className="hidden md:block w-32 mr-2">
<XpBar level={5} currentXp={750} nextLevelXp={1000} />
</div>
)}
<PixelAvatar <PixelAvatar
seed={avatarSeed} seed={avatarSeed}
size={24} size={24}
className="border-zinc-700" className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"} alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/> />
</>
)} )}
{hasToken && ( {hasToken && (
<button <button

查看文件

@@ -8,6 +8,7 @@ import remarkMath from "remark-math";
type Props = { type Props = {
markdown: string; markdown: string;
className?: string;
}; };
function normalizeImageSrc(src: string): string { function normalizeImageSrc(src: string): string {
@@ -18,9 +19,9 @@ function normalizeImageSrc(src: string): string {
return src; return src;
} }
export function MarkdownRenderer({ markdown }: Props) { export function MarkdownRenderer({ markdown, className }: Props) {
return ( return (
<article className="space-y-3 text-sm leading-7 text-zinc-800"> <article className={`space-y-3 text-sm leading-7 text-zinc-800 ${className ?? ""}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeHighlight]} rehypePlugins={[rehypeKatex, rehypeHighlight]}

查看文件

@@ -12,18 +12,22 @@ function isActivePath(pathname: string, href: string): boolean {
export function MobileTabBar() { export function MobileTabBar() {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useUiPreferences(); const { t, theme } = useUiPreferences();
const isMc = theme === "minecraft";
const tabs = [ const tabs = [
{ label: t("mobile.tab.problems"), href: "/problems" }, { label: t("mobile.tab.problems"), href: "/problems", icon: "📜" },
{ label: t("mobile.tab.submissions"), href: "/submissions" }, { label: t("mobile.tab.submissions"), href: "/submissions", icon: "⏱️" },
{ label: t("mobile.tab.contests"), href: "/contests" }, { label: t("mobile.tab.contests"), href: "/contests", icon: "⚔️" },
{ label: t("mobile.tab.kb"), href: "/kb" }, { label: t("mobile.tab.kb"), href: "/kb", icon: "📘" },
{ label: t("mobile.tab.me"), href: "/me" }, { label: t("mobile.tab.me"), href: "/me", icon: "👤" },
] as const; ] as const;
return ( return (
<nav className="print-hidden fixed inset-x-0 bottom-0 z-40 border-t bg-[color:var(--surface)]/95 pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-1 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85 md:hidden"> <nav className={`print-hidden fixed inset-x-0 bottom-0 z-40 border-t pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-2 md:hidden ${isMc
? "bg-[color:var(--mc-stone-dark)] border-black border-t-[3px]"
: "bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85"
}`}>
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5"> <div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5"> <div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
{tabs.map((tab) => { {tabs.map((tab) => {
@@ -32,13 +36,17 @@ export function MobileTabBar() {
<Link <Link
key={tab.href} key={tab.href}
href={tab.href} href={tab.href}
className={`rounded-md px-1 py-1.5 text-center text-xs max-[390px]:text-[11px] ${ className={`flex flex-col items-center justify-center rounded-none px-1 py-1 text-center text-[10px] sm:text-xs ${isMc
active ? active
? "bg-zinc-900 font-semibold text-white" ? "bg-[color:var(--mc-diamond)] text-black font-bold border-2 border-black"
: "text-zinc-600 hover:bg-zinc-100" : "bg-[color:var(--mc-stone)] text-zinc-300 border-2 border-black/50 hover:bg-[color:var(--mc-stone-light)]"
: active
? "bg-zinc-900 font-semibold text-white rounded-md"
: "text-zinc-600 hover:bg-zinc-100 rounded-md"
}`} }`}
> >
{tab.label} {isMc && <span className="text-sm mb-0.5">{tab.icon}</span>}
<span className="truncate w-full">{tab.label}</span>
</Link> </Link>
); );
})} })}

查看文件

@@ -0,0 +1,49 @@
"use client";
import { useUiPreferences } from "@/components/ui-preference-provider";
type Props = {
level: number;
currentXp: number;
nextLevelXp: number;
className?: string;
};
export function XpBar({ level, currentXp, nextLevelXp, className = "" }: Props) {
const { theme } = useUiPreferences();
const isMc = theme === "minecraft";
const progress = Math.min(100, Math.max(0, (currentXp / nextLevelXp) * 100));
if (!isMc) {
return null;
}
return (
<div className={`flex flex-col items-center select-none ${className}`}>
<div className="relative w-full max-w-[400px]">
{/* Level Indicator */}
<div className="absolute -top-3 left-1/2 -translate-x-1/2 text-[#80ff20] drop-shadow-[2px_2px_0_#000] font-[PressStart2P] text-xs z-10">
{level}
</div>
{/* XP Bar Background */}
<div className="h-3 w-full bg-[#3a3a3a] border-2 border-black flex relative">
{/* XP Bar Progress */}
<div
className="h-full bg-[#80ff20] transition-all duration-500 ease-out"
style={{ width: `${progress}%`, boxShadow: "inset 0 2px 0 rgba(255,255,255,0.3), inset 0 -2px 0 rgba(0,0,0,0.2)" }}
/>
{/* Segmentation lines (every 10%) */}
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="absolute top-0 bottom-0 w-[2px] bg-black/20" style={{ left: `${(i + 1) * 10}%` }} />
))}
</div>
</div>
{/* XP Text */}
<div className="mt-1 text-[10px] text-[#80ff20] font-[VT323] drop-shadow-[1px_1px_0_#000]">
{currentXp} / {nextLevelXp}
</div>
</div>
);
}

查看文件

@@ -52,11 +52,9 @@ export async function apiFetch<T>(
} catch (retryErr) { } catch (retryErr) {
throw new Error( throw new Error(
uiText( uiText(
`网络请求失败,请检查后端服务或代理连接(${ `网络请求失败,请检查后端服务或代理连接(${retryErr instanceof Error ? retryErr.message : String(retryErr)
retryErr instanceof Error ? retryErr.message : String(retryErr)
}`, }`,
`Network request failed. Please check backend/proxy connectivity (${ `Network request failed. Please check backend/proxy connectivity (${retryErr instanceof Error ? retryErr.message : String(retryErr)
retryErr instanceof Error ? retryErr.message : String(retryErr)
}).` }).`
) )
); );
@@ -92,3 +90,14 @@ export async function apiFetch<T>(
return payload as T; return payload as T;
} }
export interface RatingHistoryItem {
type: string;
created_at: number;
change: number;
note: string;
}
export async function listRatingHistory(limit: number = 100): Promise<RatingHistoryItem[]> {
return apiFetch<RatingHistoryItem[]>(`/api/v1/me/rating-history?limit=${limit}`);
}

查看文件

@@ -1,5 +1,5 @@
import { enMessages } from "@/themes/default/messages/en"; import { enMessages } from "./messages/en";
import { zhMessages } from "@/themes/default/messages/zh"; import { zhMessages } from "./messages/zh";
import type { ThemeDefinition } from "@/themes/types"; import type { ThemeDefinition } from "@/themes/types";
export const minecraftTheme: ThemeDefinition = { export const minecraftTheme: ThemeDefinition = {
@@ -13,3 +13,4 @@ export const minecraftTheme: ThemeDefinition = {
zh: zhMessages, zh: zhMessages,
}, },
}; };

查看文件

@@ -0,0 +1,53 @@
import type { ThemeMessages } from "@/themes/types";
export const enMessages: ThemeMessages = {
"app.title": "CSP Quest Chronicles",
"nav.menu": "Game Menu",
"nav.expand": "Open Inventory",
"nav.collapse": "Close",
"nav.secondary_menu": "Quick Slots",
"nav.logged_in": "Online",
"nav.logged_out": "Offline",
"nav.logout": "Disconnect",
"nav.group.learn": "Adventure",
"nav.group.contest": "Battle Area",
"nav.group.system": "Server Ops",
"nav.group.account": "Player",
"nav.link.home": "Spawn Point",
"nav.link.problems": "Quest Board",
"nav.link.submissions": "Adventure Log",
"nav.link.wrong_book": "Grimoire",
"nav.link.kb": "Enchanted Library",
"nav.link.run": "Craft Code",
"nav.link.contests": "Raids",
"nav.link.leaderboard": "Hall of Fame",
"nav.link.imports": "Import Maps",
"nav.link.backend_logs": "Server Logs",
"nav.link.admin_users": "XP Management",
"nav.link.admin_redeem": "Loot Config",
"nav.link.api_docs": "Redstone Logic",
"nav.link.auth": "Login to Server",
"nav.link.me": "Character Sheet",
"mobile.tab.problems": "Quests",
"mobile.tab.submissions": "History",
"mobile.tab.contests": "Raids",
"mobile.tab.kb": "Library",
"mobile.tab.me": "Char",
"prefs.theme": "Texture Pack",
"prefs.language": "Language",
"prefs.lang.en": "English",
"prefs.lang.zh": "Chinese",
"admin.entry.title": "OP Control Panel",
"admin.entry.desc": "Super User: admin / whoami139",
"admin.entry.login": "Enter Portal",
"admin.entry.user_rating": "Manage XP",
"admin.entry.redeem": "Manage Loot",
"admin.entry.logs": "Server Console",
"admin.entry.moved_to_platform": "Redirected to Server Ops module.",
};

查看文件

@@ -0,0 +1,53 @@
import type { ThemeMessages } from "@/themes/types";
export const zhMessages: ThemeMessages = {
"app.title": "CSP 冒险传奇",
"nav.menu": "游戏菜单",
"nav.expand": "打开背包",
"nav.collapse": "关闭",
"nav.secondary_menu": "快捷栏",
"nav.logged_in": "在线",
"nav.logged_out": "离线",
"nav.logout": "断开连接",
"nav.group.learn": "冒险模式",
"nav.group.contest": "竞技场",
"nav.group.system": "服务器指令",
"nav.group.account": "玩家档案",
"nav.link.home": "出生点",
"nav.link.problems": "任务布告栏",
"nav.link.submissions": "冒险日志",
"nav.link.wrong_book": "错题卷轴",
"nav.link.kb": "附魔指南",
"nav.link.run": "代码工作台",
"nav.link.contests": "团队副本",
"nav.link.leaderboard": "英雄榜",
"nav.link.imports": "地图导入",
"nav.link.backend_logs": "服务器日志",
"nav.link.admin_users": "XP管理",
"nav.link.admin_redeem": "战利品配置",
"nav.link.api_docs": "红石电路图",
"nav.link.auth": "登录服务器",
"nav.link.me": "角色面板",
"mobile.tab.problems": "任务",
"mobile.tab.submissions": "日志",
"mobile.tab.contests": "副本",
"mobile.tab.kb": "指南",
"mobile.tab.me": "角色",
"prefs.theme": "材质包",
"prefs.language": "语言",
"prefs.lang.en": "英语",
"prefs.lang.zh": "中文",
"admin.entry.title": "OP 控制台",
"admin.entry.desc": "管理员账号admin / whoami139",
"admin.entry.login": "进入传送门",
"admin.entry.user_rating": "XP 管理",
"admin.entry.redeem": "战利品管理",
"admin.entry.logs": "服务器日志",
"admin.entry.moved_to_platform": "已重新定向至服务器指令模块。",
};

查看文件

@@ -1,7 +1,6 @@
@font-face { @font-face {
font-family: "PressStart2P"; font-family: "PressStart2P";
src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2") src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2") format("woff2");
format("woff2");
font-display: swap; font-display: swap;
} }
@@ -58,8 +57,18 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.5; line-height: 1.5;
text-shadow: 2px 2px 0 #000000; text-shadow: 2px 2px 0 #000000;
text-transform: uppercase;
} }
:root[data-theme="minecraft"] .mc-text-shadow {
text-shadow: 2px 2px 0 #000000;
}
:root[data-theme="minecraft"] .mc-text-shadow-sm {
text-shadow: 1px 1px 0 #000000;
}
:root[data-theme="minecraft"] ::-webkit-scrollbar { :root[data-theme="minecraft"] ::-webkit-scrollbar {
width: 12px; width: 12px;
height: 12px; height: 12px;
@@ -157,36 +166,62 @@
color: var(--mc-red) !important; color: var(--mc-red) !important;
} }
:root[data-theme="minecraft"] button { :root[data-theme="minecraft"] button:not(.mc-reset),
:root[data-theme="minecraft"] .mc-btn {
background: linear-gradient(180deg, var(--mc-wood) 0%, var(--mc-wood-dark) 100%) !important; background: linear-gradient(180deg, var(--mc-wood) 0%, var(--mc-wood-dark) 100%) !important;
border: 3px solid #000 !important; border: 3px solid #000 !important;
border-bottom-width: 7px !important; border-bottom-width: 7px !important;
border-radius: 0 !important; border-radius: 0 !important;
color: #fff !important; color: #fff !important;
font-family: "PressStart2P", "VT323", sans-serif !important; font-family: "PressStart2P", "VT323", sans-serif !important;
font-size: 0.62rem !important; font-size: 0.75rem !important;
/* Increased for better readability */
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.4; line-height: 1.4;
text-shadow: 1px 1px 0 #000; text-shadow: 1px 1px 0 #000;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.48); box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.48);
transition: transform 0.08s ease, filter 0.08s ease; transition: transform 0.08s ease, filter 0.08s ease;
text-transform: uppercase;
padding: 0.5rem 1rem !important;
} }
:root[data-theme="minecraft"] button:hover:not(:disabled) { :root[data-theme="minecraft"] button:not(.mc-reset):hover:not(:disabled),
filter: brightness(1.07); :root[data-theme="minecraft"] .mc-btn:hover:not(:disabled) {
transform: translateY(-1px); filter: brightness(1.15);
transform: translateY(-2px);
} }
:root[data-theme="minecraft"] button:active:not(:disabled) { :root[data-theme="minecraft"] button:not(.mc-reset):active:not(:disabled),
:root[data-theme="minecraft"] .mc-btn:active:not(:disabled) {
border-bottom-width: 3px !important; border-bottom-width: 3px !important;
transform: translateY(3px); transform: translateY(4px);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.48);
} }
:root[data-theme="minecraft"] button:disabled { :root[data-theme="minecraft"] button:not(.mc-reset):disabled,
opacity: 0.68; :root[data-theme="minecraft"] .mc-btn:disabled {
filter: saturate(0.28); opacity: 0.6;
filter: grayscale(0.8);
cursor: not-allowed;
transform: none !important;
} }
/* Variant: Primary/Diamond */
:root[data-theme="minecraft"] .mc-btn-primary {
background: linear-gradient(180deg, var(--mc-diamond) 0%, #008ba3 100%) !important;
}
/* Variant: Danger/Red */
:root[data-theme="minecraft"] .mc-btn-danger {
background: linear-gradient(180deg, var(--mc-red) 0%, #c62828 100%) !important;
}
/* Variant: Success/Emerald */
:root[data-theme="minecraft"] .mc-btn-success {
background: linear-gradient(180deg, var(--mc-grass-top) 0%, var(--mc-grass-dark) 100%) !important;
}
:root[data-theme="minecraft"] input, :root[data-theme="minecraft"] input,
:root[data-theme="minecraft"] textarea, :root[data-theme="minecraft"] textarea,
:root[data-theme="minecraft"] select { :root[data-theme="minecraft"] select {
@@ -290,3 +325,42 @@
:root[data-theme="minecraft"] .problem-markdown-compact th { :root[data-theme="minecraft"] .problem-markdown-compact th {
background: #3a3a3a !important; background: #3a3a3a !important;
} }
/* ── Problem detail page markdown: dark text on light plank background ── */
:root[data-theme="minecraft"] .problem-markdown,
:root[data-theme="minecraft"] .problem-markdown-compact {
font-family: "MiSans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif !important;
}
:root[data-theme="minecraft"] .problem-markdown,
:root[data-theme="minecraft"] .problem-markdown article {
color: #3e2723 !important;
}
:root[data-theme="minecraft"] .problem-markdown h1,
:root[data-theme="minecraft"] .problem-markdown h2,
:root[data-theme="minecraft"] .problem-markdown h3 {
color: #3e2723 !important;
}
:root[data-theme="minecraft"] .problem-markdown p,
:root[data-theme="minecraft"] .problem-markdown li,
:root[data-theme="minecraft"] .problem-markdown span,
:root[data-theme="minecraft"] .problem-markdown td,
:root[data-theme="minecraft"] .problem-markdown blockquote {
color: #4e342e !important;
}
:root[data-theme="minecraft"] .problem-markdown th {
color: #3e2723 !important;
background: #d7ccc8 !important;
}
:root[data-theme="minecraft"] .problem-markdown code:not([class*="hljs"]) {
color: #4e342e !important;
background: #d7ccc8 !important;
}
:root[data-theme="minecraft"] .problem-markdown a {
color: #1565c0 !important;
}

查看文件

@@ -1,7 +1,7 @@
export type ThemeId = "default" | "minecraft"; export type ThemeId = "default" | "minecraft";
export type UiLanguage = "en" | "zh"; export type UiLanguage = "en" | "zh";
export const DEFAULT_THEME: ThemeId = "default"; export const DEFAULT_THEME: ThemeId = "minecraft";
export const DEFAULT_LANGUAGE: UiLanguage = "zh"; export const DEFAULT_LANGUAGE: UiLanguage = "zh";
export type ThemeMessages = Record<string, string>; export type ThemeMessages = Record<string, string>;

24
start_adventure.sh 可执行文件
查看文件

@@ -0,0 +1,24 @@
#!/bin/bash
# CSP Adventure Launcher 🚀
# Usage: ./start_adventure.sh
echo "🌲 Loading CSP Minecraft World..."
if ! command -v npm &> /dev/null; then
echo "❌ Error: npm is not installed. Please install Node.js first."
exit 1
fi
echo "✨ Installing/Checking dependencies..."
cd frontend || { echo "❌ Frontend directory not found!"; exit 1; }
if [ ! -d "node_modules" ]; then
npm install
fi
echo "⚔️ Starting the Adventure on http://localhost:3000"
echo " (Press Ctrl+C to stop)"
echo ""
npm run dev