From ef6d71ef541fd13e8ea788902d00d5fd4f3553ef Mon Sep 17 00:00:00 2001 From: X Date: Sun, 15 Feb 2026 09:41:54 -0800 Subject: [PATCH] feat: Minecraft theme overhaul, fix points bug, add history --- .../include/csp/controllers/me_controller.h | 73 +- .../csp/services/solution_access_service.h | 39 +- backend/src/controllers/me_controller.cc | 133 ++- .../src/services/solution_access_service.cc | 173 +++- frontend/GAMEPLAY.md | 56 ++ frontend/README.md | 21 +- frontend/src/app/auth/page.tsx | 112 +-- frontend/src/app/contests/page.tsx | 48 +- frontend/src/app/favicon.ico | Bin 25931 -> 19126 bytes frontend/src/app/globals.css | 90 +- frontend/src/app/layout.tsx | 2 +- frontend/src/app/leaderboard/page.tsx | 79 +- frontend/src/app/me/page.tsx | 386 ++++---- frontend/src/app/problems/[id]/page.tsx | 853 +++++++++--------- frontend/src/app/problems/page.tsx | 205 +++-- frontend/src/app/submissions/page.tsx | 126 ++- frontend/src/app/wrong-book/page.tsx | 94 +- frontend/src/components/app-nav.tsx | 40 +- frontend/src/components/markdown-renderer.tsx | 5 +- frontend/src/components/mobile-tab-bar.tsx | 34 +- frontend/src/components/xp-bar.tsx | 49 + frontend/src/lib/api.ts | 21 +- frontend/src/themes/minecraft/index.ts | 5 +- frontend/src/themes/minecraft/messages/en.ts | 53 ++ frontend/src/themes/minecraft/messages/zh.ts | 53 ++ frontend/src/themes/minecraft/theme.css | 98 +- frontend/src/themes/types.ts | 2 +- start_adventure.sh | 24 + 28 files changed, 1821 insertions(+), 1053 deletions(-) create mode 100644 frontend/GAMEPLAY.md create mode 100644 frontend/src/components/xp-bar.tsx create mode 100644 frontend/src/themes/minecraft/messages/en.ts create mode 100644 frontend/src/themes/minecraft/messages/zh.ts create mode 100755 start_adventure.sh diff --git a/backend/include/csp/controllers/me_controller.h b/backend/include/csp/controllers/me_controller.h index b0feb41..f04aba9 100644 --- a/backend/include/csp/controllers/me_controller.h +++ b/backend/include/csp/controllers/me_controller.h @@ -7,43 +7,62 @@ namespace csp::controllers { class MeController : public drogon::HttpController { - public: +public: METHOD_LIST_BEGIN 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::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get); - ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post); - 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::listRedeemItems, "/api/v1/me/redeem/items", + drogon::Get); + ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", + drogon::Get); + ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", + drogon::Post); + 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 - void profile(const drogon::HttpRequestPtr& req, - std::function&& cb); + void profile(const drogon::HttpRequestPtr &req, + std::function &&cb); - void listRedeemItems(const drogon::HttpRequestPtr& req, - std::function&& cb); + void + listRedeemItems(const drogon::HttpRequestPtr &req, + std::function &&cb); - void listRedeemRecords(const drogon::HttpRequestPtr& req, - std::function&& cb); + void + listRedeemRecords(const drogon::HttpRequestPtr &req, + std::function &&cb); - void createRedeemRecord(const drogon::HttpRequestPtr& req, - std::function&& cb); + void + createRedeemRecord(const drogon::HttpRequestPtr &req, + std::function &&cb); - void listDailyTasks(const drogon::HttpRequestPtr& req, - std::function&& cb); + void + listDailyTasks(const drogon::HttpRequestPtr &req, + std::function &&cb); - void listWrongBook(const drogon::HttpRequestPtr& req, - std::function&& cb); + void listWrongBook(const drogon::HttpRequestPtr &req, + std::function &&cb); - void upsertWrongBookNote(const drogon::HttpRequestPtr& req, - std::function&& cb, - int64_t problem_id); + void + upsertWrongBookNote(const drogon::HttpRequestPtr &req, + std::function &&cb, + int64_t problem_id); - void deleteWrongBookItem(const drogon::HttpRequestPtr& req, - std::function&& cb, - int64_t problem_id); + void + deleteWrongBookItem(const drogon::HttpRequestPtr &req, + std::function &&cb, + int64_t problem_id); + + void + listRatingHistory(const drogon::HttpRequestPtr &req, + std::function &&cb); }; -} // namespace csp::controllers +} // namespace csp::controllers diff --git a/backend/include/csp/services/solution_access_service.h b/backend/include/csp/services/solution_access_service.h index fd1ff61..5e8c84e 100644 --- a/backend/include/csp/services/solution_access_service.h +++ b/backend/include/csp/services/solution_access_service.h @@ -5,9 +5,17 @@ #include #include #include +#include namespace csp::services { +struct SolutionViewStats { + int total_views = 0; + int total_cost = 0; + std::optional last_viewed_at; + bool has_viewed = false; +}; + struct SolutionViewChargeResult { bool granted = true; bool charged = false; @@ -21,24 +29,29 @@ struct SolutionViewChargeResult { std::string deny_reason; }; -struct SolutionViewStats { - bool has_viewed = false; - int total_views = 0; - int total_cost = 0; - std::optional last_viewed_at; +struct RatingHistoryItem { + std::string type; + int64_t created_at; + int change; + std::string note; }; class SolutionAccessService { - public: - explicit SolutionAccessService(db::SqliteDb& db) : db_(db) {} +public: + explicit SolutionAccessService(db::SqliteDb &db) : db_(db) {} - // Daily policy: first answer view is free, then each full view costs 2 rating. - SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id); + // Daily policy: first answer view is free, then each full view costs 2 + // 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); - private: - db::SqliteDb& db_; + std::vector ListRatingHistory(int64_t user_id, int limit); + +private: + db::SqliteDb &db_; }; -} // namespace csp::services +} // namespace csp::services diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc index 97b42e0..b5958a8 100644 --- a/backend/src/controllers/me_controller.cc +++ b/backend/src/controllers/me_controller.cc @@ -4,6 +4,7 @@ #include "csp/domain/json.h" #include "csp/services/daily_task_service.h" #include "csp/services/redeem_service.h" +#include "csp/services/solution_access_service.h" #include "csp/services/user_service.h" #include "csp/services/wrong_book_service.h" #include "http_auth.h" @@ -19,7 +20,7 @@ namespace csp::controllers { namespace { drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, - const std::string& msg) { + const std::string &msg) { Json::Value j; j["ok"] = false; j["error"] = msg; @@ -28,7 +29,7 @@ drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, return resp; } -drogon::HttpResponsePtr JsonOk(const Json::Value& data) { +drogon::HttpResponsePtr JsonOk(const Json::Value &data) { Json::Value j; j["ok"] = true; j["data"] = data; @@ -37,8 +38,9 @@ drogon::HttpResponsePtr JsonOk(const Json::Value& data) { return resp; } -std::optional RequireAuth(const drogon::HttpRequestPtr& req, - std::function& cb) { +std::optional +RequireAuth(const drogon::HttpRequestPtr &req, + std::function &cb) { std::string auth_error; const auto user_id = GetAuthedUserId(req, auth_error); if (!user_id.has_value()) { @@ -48,23 +50,23 @@ std::optional RequireAuth(const drogon::HttpRequestPtr& req, return user_id; } -int ParseClampedInt(const std::string& s, - int default_value, - int min_value, +int ParseClampedInt(const std::string &s, int default_value, int min_value, int max_value) { - if (s.empty()) return default_value; + if (s.empty()) + return default_value; const int value = std::stoi(s); return std::max(min_value, std::min(max_value, value)); } -} // namespace +} // namespace void MeController::profile( - const drogon::HttpRequestPtr& req, - std::function&& cb) { + const drogon::HttpRequestPtr &req, + std::function &&cb) { try { 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()); const auto user = users.GetById(*user_id); @@ -74,22 +76,23 @@ void MeController::profile( } cb(JsonOk(domain::ToPublicJson(*user))); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::listRedeemItems( - const drogon::HttpRequestPtr& req, - std::function&& cb) { + const drogon::HttpRequestPtr &req, + std::function &&cb) { try { - if (!RequireAuth(req, cb).has_value()) return; + if (!RequireAuth(req, cb).has_value()) + return; services::RedeemService redeem(csp::AppState::Instance().db()); const auto items = redeem.ListItems(false); Json::Value arr(Json::arrayValue); - for (const auto& item : items) { + for (const auto &item : items) { Json::Value j; j["id"] = Json::Int64(item.id); j["name"] = item.name; @@ -104,24 +107,25 @@ void MeController::listRedeemItems( arr.append(j); } cb(JsonOk(arr)); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::listRedeemRecords( - const drogon::HttpRequestPtr& req, - std::function&& cb) { + const drogon::HttpRequestPtr &req, + std::function &&cb) { try { 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); services::RedeemService redeem(csp::AppState::Instance().db()); const auto rows = redeem.ListRecordsByUser(*user_id, limit); Json::Value arr(Json::arrayValue); - for (const auto& row : rows) { + for (const auto &row : rows) { Json::Value j; j["id"] = Json::Int64(row.id); j["user_id"] = Json::Int64(row.user_id); @@ -136,21 +140,22 @@ void MeController::listRedeemRecords( arr.append(j); } cb(JsonOk(arr)); - } catch (const std::invalid_argument&) { + } catch (const std::invalid_argument &) { cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); - } catch (const std::out_of_range&) { + } catch (const std::out_of_range &) { cb(JsonError(drogon::k400BadRequest, "query parameter out of range")); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::createRedeemRecord( - const drogon::HttpRequestPtr& req, - std::function&& cb) { + const drogon::HttpRequestPtr &req, + std::function &&cb) { try { const auto user_id = RequireAuth(req, cb); - if (!user_id.has_value()) return; + if (!user_id.has_value()) + return; const auto json = req->getJsonObject(); if (!json) { @@ -186,19 +191,20 @@ void MeController::createRedeemRecord( j["rating_after"] = user->rating; } cb(JsonOk(j)); - } catch (const std::runtime_error& e) { + } catch (const std::runtime_error &e) { cb(JsonError(drogon::k400BadRequest, e.what())); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::listDailyTasks( - const drogon::HttpRequestPtr& req, - std::function&& cb) { + const drogon::HttpRequestPtr &req, + std::function &&cb) { try { 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()); const auto rows = tasks.ListTodayTasks(*user_id); @@ -206,7 +212,7 @@ void MeController::listDailyTasks( Json::Value arr(Json::arrayValue); int total_reward = 0; int gained_reward = 0; - for (const auto& row : rows) { + for (const auto &row : rows) { Json::Value j; j["code"] = row.code; j["title"] = row.title; @@ -229,41 +235,43 @@ void MeController::listDailyTasks( out["gained_reward"] = gained_reward; out["tasks"] = arr; cb(JsonOk(out)); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::listWrongBook( - const drogon::HttpRequestPtr& req, - std::function&& cb) { + const drogon::HttpRequestPtr &req, + std::function &&cb) { try { 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()); const auto rows = wrong_book.ListByUser(*user_id); Json::Value arr(Json::arrayValue); - for (const auto& row : rows) { + for (const auto &row : rows) { Json::Value item = domain::ToJson(row.item); item["problem_title"] = row.problem_title; arr.append(item); } cb(JsonOk(arr)); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::upsertWrongBookNote( - const drogon::HttpRequestPtr& req, - std::function&& cb, + const drogon::HttpRequestPtr &req, + std::function &&cb, int64_t problem_id) { try { const auto user_id = RequireAuth(req, cb); - if (!user_id.has_value()) return; + if (!user_id.has_value()) + return; const auto json = req->getJsonObject(); if (!json) { @@ -285,18 +293,19 @@ void MeController::upsertWrongBookNote( data["problem_id"] = Json::Int64(problem_id); data["note"] = note; cb(JsonOk(data)); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } void MeController::deleteWrongBookItem( - const drogon::HttpRequestPtr& req, - std::function&& cb, + const drogon::HttpRequestPtr &req, + std::function &&cb, int64_t problem_id) { try { 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()); wrong_book.Remove(*user_id, problem_id); @@ -306,9 +315,35 @@ void MeController::deleteWrongBookItem( data["problem_id"] = Json::Int64(problem_id); data["deleted"] = true; cb(JsonOk(data)); - } catch (const std::exception& e) { + } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } } -} // namespace csp::controllers +void MeController::listRatingHistory( + const drogon::HttpRequestPtr &req, + std::function &&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 diff --git a/backend/src/services/solution_access_service.cc b/backend/src/services/solution_access_service.cc index 1562161..85643a8 100644 --- a/backend/src/services/solution_access_service.cc +++ b/backend/src/services/solution_access_service.cc @@ -18,28 +18,29 @@ int64_t NowSec() { return duration_cast(system_clock::now().time_since_epoch()).count(); } -void CheckSqlite(int rc, sqlite3* db, const char* what) { - if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; +void CheckSqlite(int rc, sqlite3 *db, const char *what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) + return; throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); } -std::string ColText(sqlite3_stmt* stmt, int col) { - const unsigned char* txt = sqlite3_column_text(stmt, col); - return txt ? reinterpret_cast(txt) : std::string(); +std::string ColText(sqlite3_stmt *stmt, int col) { + const unsigned char *txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); } std::string BuildDayKeyChina(int64_t ts_sec) { const std::time_t shifted = static_cast(ts_sec + 8 * 3600); - std::tm tm {}; + std::tm tm{}; gmtime_r(&shifted, &tm); char buf[16] = {0}; std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm); return std::string(buf); } -int QueryRating(sqlite3* db, int64_t user_id) { - sqlite3_stmt* stmt = nullptr; - const char* sql = "SELECT rating FROM users WHERE id=?"; +int QueryRating(sqlite3 *db, int64_t user_id) { + sqlite3_stmt *stmt = nullptr; + const char *sql = "SELECT rating FROM users WHERE id=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare query rating"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); @@ -52,38 +53,35 @@ int QueryRating(sqlite3* db, int64_t user_id) { return rating; } -int QueryDailyUsage(sqlite3* db, int64_t user_id, const std::string& day_key) { - sqlite3_stmt* stmt = nullptr; - const char* sql = - "SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?"; +int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) { + sqlite3_stmt *stmt = nullptr; + const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs WHERE " + "user_id=? AND day_key=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare query daily usage"); 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, - "bind day_key"); + CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind day_key"); CheckSqlite(sqlite3_step(stmt), db, "step query daily usage"); const int used = sqlite3_column_int(stmt, 0); sqlite3_finalize(stmt); return used; } -void InsertViewLog(sqlite3* db, - int64_t user_id, - int64_t problem_id, - const std::string& day_key, - int64_t viewed_at, - bool charged, +void InsertViewLog(sqlite3 *db, int64_t user_id, int64_t problem_id, + const std::string &day_key, int64_t viewed_at, bool charged, int cost) { - sqlite3_stmt* stmt = nullptr; - const char* sql = - "INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) " - "VALUES(?,?,?,?,?,?,?)"; + sqlite3_stmt *stmt = nullptr; + const char *sql = "INSERT INTO " + "problem_solution_view_logs(user_id,problem_id,day_key," + "viewed_at,charged,cost,created_at) " + "VALUES(?,?,?,?,?,?,?)"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare insert solution view log"); 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_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, - "bind day_key"); + CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind day_key"); 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, 6, cost), db, "bind cost"); @@ -92,9 +90,9 @@ void InsertViewLog(sqlite3* db, sqlite3_finalize(stmt); } -void DeductRating(sqlite3* db, int64_t user_id, int cost) { - sqlite3_stmt* stmt = nullptr; - const char* sql = "UPDATE users SET rating=rating-? WHERE id=?"; +void DeductRating(sqlite3 *db, int64_t user_id, int cost) { + sqlite3_stmt *stmt = nullptr; + const char *sql = "UPDATE users SET rating=rating-? WHERE id=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare deduct rating"); CheckSqlite(sqlite3_bind_int(stmt, 1, cost), db, "bind cost"); @@ -103,16 +101,32 @@ void DeductRating(sqlite3* db, int64_t user_id, int cost) { sqlite3_finalize(stmt); } -} // namespace +} // namespace -SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( - int64_t user_id, - int64_t problem_id) { +// ... (Helper functions) + +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) { if (user_id <= 0 || problem_id <= 0) { throw std::runtime_error("invalid user_id/problem_id"); } - sqlite3* db = db_.raw(); + sqlite3 *db = db_.raw(); const int64_t now = NowSec(); const std::string day_key = BuildDayKeyChina(now); @@ -123,6 +137,26 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( result.day_key = day_key; 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); result.daily_used_count = used_before; const int rating_before = QueryRating(db, user_id); @@ -150,12 +184,7 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( DeductRating(db, user_id, kViewCost); } - InsertViewLog(db, - user_id, - problem_id, - day_key, - now, - result.charged, + InsertViewLog(db, user_id, problem_id, day_key, now, result.charged, result.cost); result.daily_used_count = used_before + 1; @@ -173,15 +202,16 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( } } -SolutionViewStats SolutionAccessService::QueryUserProblemViewStats( - int64_t user_id, - int64_t problem_id) { +SolutionViewStats +SolutionAccessService::QueryUserProblemViewStats(int64_t user_id, + int64_t problem_id) { 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_stmt* stmt = nullptr; - const char* sql = + sqlite3 *db = db_.raw(); + sqlite3_stmt *stmt = nullptr; + const char *sql = "SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) " "FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, @@ -200,4 +230,49 @@ SolutionViewStats SolutionAccessService::QueryUserProblemViewStats( return stats; } -} // namespace csp::services +std::vector +SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) { + if (limit <= 0) + limit = 100; + if (limit > 500) + limit = 500; + + std::vector 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 diff --git a/frontend/GAMEPLAY.md b/frontend/GAMEPLAY.md new file mode 100644 index 0000000..3960235 --- /dev/null +++ b/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! diff --git a/frontend/README.md b/frontend/README.md index ab3af47..989eb02 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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` 登录/注册 -- `/problems` 题库列表 +- `/problems` Quest Board (Questions) - `/problems/:id` 题目详情与提交 -- `/submissions` 提交列表 +- `/submissions` Spell History (Submissions) - `/submissions/:id` 提交详情 -- `/wrong-book` 错题本 -- `/contests` 模拟竞赛列表 +- `/wrong-book` Cursed Tome (Wrong Book) +- `/contests` Raid Board (Contests) - `/contests/:id` 比赛详情/报名/排行榜 - `/kb` 知识库列表 - `/kb/:slug` 文章详情 - `/imports` 题库导入任务状态与结果 - `/run` 在线 C++ 运行 - `/me` 当前用户信息 -- `/leaderboard` 全站排行 +- `/leaderboard` Hall of Fame (Rankings) diff --git a/frontend/src/app/auth/page.tsx b/frontend/src/app/auth/page.tsx index e9680c6..dff5bda 100644 --- a/frontend/src/app/auth/page.tsx +++ b/frontend/src/app/auth/page.tsx @@ -13,9 +13,9 @@ type AuthErr = { ok: false; error: string }; type AuthResp = AuthOk | AuthErr; 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 >= 8) return { label: isZh ? "中" : "Medium", color: "text-blue-600" }; - return { label: isZh ? "弱" : "Weak", color: "text-orange-600" }; + if (password.length >= 12) return { label: isZh ? "钻石级" : "Diamond Tier", color: "text-[color:var(--mc-diamond)]" }; + if (password.length >= 8) return { label: isZh ? "铁级" : "Iron Tier", color: "text-zinc-400" }; + return { label: isZh ? "木级" : "Wood Tier", color: "text-[color:var(--mc-wood)]" }; } export default function AuthPage() { @@ -83,132 +83,136 @@ export default function AuthPage() { return (
-
-

{tx("欢迎回来,开始刷题", "Welcome back, let's practice")}

-

- {tx("登录后可提交评测、保存草稿、查看错题本和个人进度。", "After sign-in you can submit, save drafts, review wrong-book, and track your progress.")} +

+

+ {tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")} +

+

+ {tx("登录服务器以访问任务布告栏、保存冒险进度、查看错题卷轴和个人成就。", "Login to access Quest Board, save Game Progress, review Grimoire, and track Achievements.")}

-
-

{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}

-

{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}

-

{tx("• 生成式题解会异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}

+
+

{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}

+

{tx("• 任务卷轴支持本地草稿与试炼运行", "• Quest Scrolls support local drafting and trial runs")}

+

{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}

-

- API Base: {apiBase} +

+ Server API: {apiBase}

-
-
+
+
-
+
- + setUsername(e.target.value)} - placeholder={tx("例如:csp_student", "e.g. csp_student")} + placeholder={tx("例如:Steve", "e.g. Steve")} /> - {usernameErr &&

{usernameErr}

} + {usernameErr &&

{usernameErr}

}
- - {tx("强度", "Strength")}: {strength.label} + + {strength.label}
setPassword(e.target.value)} - placeholder={tx("至少 6 位", "At least 6 chars")} + placeholder={tx("至少 6 个字符", "Min 6 chars")} /> - {passwordErr &&

{passwordErr}

} + {passwordErr &&

{passwordErr}

}
{mode === "register" && (
- + setConfirmPassword(e.target.value)} - placeholder={tx("再输入一次密码", "Enter password again")} + placeholder={tx("再次输入口令", "Re-enter secret")} /> - {confirmErr &&

{confirmErr}

} + {confirmErr &&

{confirmErr}

}
)} -
{resp && (
{resp.ok - ? tx("登录成功,正在跳转到题库...", "Signed in. Redirecting to problem set...") - : `${tx("操作失败:", "Action failed: ")}${resp.error}`} + ? tx("连接成功!正在传送至出生点...", "Connection Established! Teleporting to Spawn Point...") + : `${tx("连接失败:", "Connection Failed: ")}${resp.error}`}
)} -

- {tx("登录后 Token 自动保存在浏览器 localStorage,可直接前往", "Token is stored in browser localStorage after sign-in. You can go to")} - - {tx("题库", "Problems")} +

+ {tx("令牌将保存在客户端存储中,可直接前往", "Token stored in client. Warp to")} + + {tx("任务板", "Quest Board")} - {tx("与", "and")} - - {tx("我的", "My Account")} + {tx("与", "or")} + + {tx("角色面板", "Character Sheet")} - {tx("页面。", ".")} + {tx("。", ".")}

diff --git a/frontend/src/app/contests/page.tsx b/frontend/src/app/contests/page.tsx index c89e8fb..cd00901 100644 --- a/frontend/src/app/contests/page.tsx +++ b/frontend/src/app/contests/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { apiFetch } from "@/lib/api"; import { useI18nText } from "@/lib/i18n"; +import { useUiPreferences } from "@/components/ui-preference-provider"; type Contest = { id: number; @@ -16,6 +17,8 @@ type Contest = { export default function ContestsPage() { const { tx } = useI18nText(); + const { theme } = useUiPreferences(); + const isMc = theme === "minecraft"; const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -37,11 +40,18 @@ export default function ContestsPage() { }, []); return ( -
-

- {tx("模拟竞赛", "Contests")} +
+

+ {isMc ? ( + + ⚔️ + {tx("突袭公告板", "Raid Board")} + + ) : ( + tx("模拟竞赛", "Contests") + )}

- {loading &&

{tx("加载中...", "Loading...")}

} + {loading &&

{tx("正在寻找突袭目标...", "Scouting for raids...")}

} {error &&

{error}

}
@@ -49,13 +59,35 @@ export default function ContestsPage() { -

{c.title}

-

{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}

-

{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}

+
+
+

+ {isMc && 🛡️} + {c.title} +

+
+

{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}

+

{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}

+
+
+ {isMc && ( +
+ {tx("加入突袭", "Join Raid")} +
+ )} +
))} + {!loading && items.length === 0 && ( +
+

{tx("暂无比赛", "No raids active")}

+
+ )}
); diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..290868980e1840e8ed3c766ef317d0879af05ed6 100644 GIT binary patch literal 19126 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 VcRM77unu?pnF#oA@5AuF{U3o1V4(m2 literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 538dfbc..56dabee 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -45,9 +45,47 @@ body { } @media print { + /* ── Force clean white‑background "default" style for printing ── */ + + :root { + --background: #fff !important; + --foreground: #171717 !important; + --surface: #fff !important; + --surface-soft: #f4f4f5 !important; + --border: #d4d4d8 !important; + /* Override Minecraft‑specific 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 { background: #fff !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 { @@ -58,6 +96,13 @@ body { 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 { display: block !important; } @@ -73,4 +118,47 @@ body { background: #f4f4f5 !important; color: #111827 !important; } -} + + /* Reset Minecraft‑styled 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; + } +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1a0a69b..3c1b0db 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -11,7 +11,7 @@ import "./globals.css"; export const metadata: Metadata = { 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({ diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx index c498f09..6ff1565 100644 --- a/frontend/src/app/leaderboard/page.tsx +++ b/frontend/src/app/leaderboard/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { apiFetch } from "@/lib/api"; import { useI18nText } from "@/lib/i18n"; +import { useUiPreferences } from "@/components/ui-preference-provider"; type Row = { user_id: number; @@ -14,6 +15,8 @@ type Row = { export default function LeaderboardPage() { const { tx } = useI18nText(); + const { theme } = useUiPreferences(); + const isMc = theme === "minecraft"; const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -34,22 +37,52 @@ export default function LeaderboardPage() { 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 ( -
-

- {tx("全站排行榜", "Global Leaderboard")} +
+

+ {isMc ? ( + + 🏰 + {tx("名人堂", "Hall of Fame")} + + ) : ( + tx("全站排行榜", "Global Leaderboard") + )}

- {loading &&

{tx("加载中...", "Loading...")}

} + {loading &&

{tx("正在读取卷轴...", "Reading scrolls...")}

} {error &&

{error}

} -
+
{items.map((row, i) => ( -
-

- #{i + 1} · {row.username} -

-

Rating: {row.rating}

+
+
+

+ {getRankIcon(i)} + {row.username} +

+ {row.rating} +

{tx("注册时间:", "Registered: ")} {new Date(row.created_at * 1000).toLocaleString()} @@ -58,28 +91,28 @@ export default function LeaderboardPage() { ))} {!loading && items.length === 0 && (

- {tx("暂无排行数据", "No ranking data yet")} + {tx("暂无数据", "No legends yet")}

)}
- + - - - - + + + + - + {items.map((row, i) => ( - - - - - + + + + @@ -87,7 +120,7 @@ export default function LeaderboardPage() { {!loading && items.length === 0 && ( )} diff --git a/frontend/src/app/me/page.tsx b/frontend/src/app/me/page.tsx index 213c98d..315a4d6 100644 --- a/frontend/src/app/me/page.tsx +++ b/frontend/src/app/me/page.tsx @@ -14,6 +14,10 @@ type Me = { 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 = { id: number; name: string; @@ -62,12 +66,21 @@ function fmtTs(v: number | null | undefined): string { 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() { const { isZh, tx } = useI18nText(); const [token, setToken] = useState(""); const [profile, setProfile] = useState(null); const [items, setItems] = useState([]); const [records, setRecords] = useState([]); + const [historyItems, setHistoryItems] = useState([]); const [dailyTasks, setDailyTasks] = useState([]); const [dailyDayKey, setDailyDayKey] = useState(""); const [dailyTotalReward, setDailyTotalReward] = useState(0); @@ -99,34 +112,17 @@ export default function MePage() { if (isZh) return task.title; if (task.code === "login_checkin") return "Daily Sign-in"; if (task.code === "daily_submit") return "Daily Submission"; - if (task.code === "first_ac") return "Solve One Problem"; - if (task.code === "code_quality") return "Code Quality"; + if (task.code === "first_ac") return "First Blood"; + if (task.code === "code_quality") return "Craftsman"; 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 => { if (isZh) return name; - if (name === "私人玩游戏时间") return "Private Game Time"; + if (name === "私人玩游戏时间") return "Game Time pass"; 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 () => { setLoading(true); setError(""); @@ -136,15 +132,17 @@ export default function MePage() { setToken(tk); 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("/api/v1/me", {}, tk), apiFetch("/api/v1/me/redeem/items", {}, tk), apiFetch("/api/v1/me/redeem/records?limit=200", {}, tk), apiFetch("/api/v1/me/daily-tasks", {}, tk), + listRatingHistory(50), ]); setProfile(me); setItems(redeemItems ?? []); setRecords(redeemRecords ?? []); + setHistoryItems(history ?? []); setDailyTasks(daily?.tasks ?? []); setDailyDayKey(daily?.day_key ?? ""); setDailyTotalReward(daily?.total_reward ?? 0); @@ -171,9 +169,9 @@ export default function MePage() { setMsg(""); try { 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) { - throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0")); + throw new Error(tx("数量必须大于 0", "Amount > 0")); } const created = await apiFetch( @@ -192,12 +190,8 @@ export default function MePage() { setMsg( isZh - ? `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${ - typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : "" - }。` - : `Redeemed successfully: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} rating${ - typeof created.rating_after === "number" ? `, current rating ${created.rating_after}` : "" - }.` + ? `交易成功:${created.item_name} × ${created.quantity},花费 ${created.total_cost} 绿宝石。` + : `Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds.` ); setNote(""); await loadAll(); @@ -208,176 +202,186 @@ export default function MePage() { } }; + const rank = resolveRank(profile?.rating ?? 0); + return ( -
-

- {tx("我的信息与积分兑换", "My Profile & Redeem")} +
+

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

- {loading &&

{tx("加载中...", "Loading...")}

} - {error &&

{error}

} - {msg &&

{msg}

} + {loading &&

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

} + {error &&

{error}

} + {msg &&

{msg}

} {profile && ( -
-
- -
-

ID: {profile.id}

-

{tx("用户名", "Username")}: {profile.username}

-

Rating: {profile.rating}

-

{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}

-

- {tx("默认像素头像按账号随机生成,可作为主题角色形象。", "Default pixel avatar is randomly generated by account as your theme character.")} -

-
-
-
- )} +
+
+
+
+
+
+ +
+
-
-
-

{tx("每日任务", "Daily Tasks")}

-

- {dailyDayKey ? `${dailyDayKey} · ` : ""} - {tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")} -

-
-
- {dailyTasks.map((task) => ( -
-
-

- {taskTitle(task)} · +{task.reward} -

- - {task.completed ? tx("已完成", "Completed") : tx("未完成", "Incomplete")} +

{profile.username}

+
+ {rank.icon} {rank.label} Rank +
+ +
+
+ + Level {Math.floor(profile.rating / 100)}
-

{taskDesc(task)}

- {task.completed && ( -

- {tx("完成时间:", "Completed At: ")} - {fmtTs(task.completed_at)} -

- )} -
- ))} - {!loading && dailyTasks.length === 0 && ( -

- {tx("今日任务尚未初始化,请稍后刷新。", "Today's tasks are not initialized yet. Please refresh later.")} -

- )} -
-
+

UID: {profile.id}

+
-
-

{tx("积分兑换物品", "Redeem Items")}

-

- {tx( - "示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分)", - "Sample rule: Private Game Time (holiday 1h=5 points; study day/non-holiday 1h=25 points)" - )} -

- -
- {items.map((item) => ( -
-
-

{itemName(item.name)}

- +
+
+ {tx("绿宝石 (Rating)", "Emeralds (Rating)")} + {profile.rating}
-

{itemDesc(item.description) || "-"}

-

- {tx("假期", "Holiday")}: {item.holiday_cost} / {item.unit_label} -

-

- {tx("学习日", "Study Day")}: {item.studyday_cost} / {item.unit_label} -

-
- ))} - {!loading && items.length === 0 && ( -

- {tx("管理员尚未配置可兑换物品。", "No redeem items configured by admin yet.")} -

- )} -
+
+ {tx("加入时间", "Joined")} + {new Date(profile.created_at * 1000).toLocaleDateString()} +
+
+ -
-

{tx("兑换表单", "Redeem Form")}

-
- +
+ {/* Daily Tasks */} +
+

+ 每日悬赏任务 + 进度: {dailyGainedReward} / {dailyTotalReward} XP +

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

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

+
+

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

+
+
+
+ ))} +
+
+
+

+ 💎 + 村民交易站 + 消耗: RATING +

- setQuantity(Math.max(1, Number(e.target.value) || 1))} - placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")} - /> +
+
+ + setQuantity(Math.max(1, Number(e.target.value) || 1))} + /> +
- setNote(e.target.value)} - placeholder={tx("备注(可选)", "Note (optional)")} - /> + {selectedItem && ( +
+

{selectedItem.description}

+

+ 单价: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label} +

+
+ )} + +
+ + +
+
+
+
+ )} -

- {tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")};{tx("预计扣分", "Estimated cost")}: {totalCost} -

- - + {/* Rating History Section */} +
+

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

+
+ {historyItems.map((item, idx) => ( +
+ + 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}> + {item.change > 0 ? `+${item.change}` : item.change} + + {item.note} + + + {new Date(item.created_at * 1000).toLocaleString()} + +
+ ))} + {!loading && historyItems.length === 0 && ( +

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

+ )}
-
-
-

{tx("兑换记录", "Redeem Records")}

+ {/* Trades Section */} +
+
+

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

-
+
{records.map((row) => ( -
-

- #{row.id} · {itemName(row.item_name)} · {row.quantity} {tx("小时", "hour")} ·{" "} - {row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")} -

-

- {tx("单价", "Unit cost")} {row.unit_cost},{tx("总扣分", "Total cost")} {row.total_cost} · {fmtTs(row.created_at)} -

- {row.note &&

{tx("备注:", "Note: ")}{row.note}

} -
+
+ + {itemName(row.item_name)} × {row.quantity} + + + -{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()} + +
))} {!loading && records.length === 0 && ( -

{tx("暂无兑换记录。", "No redeem records yet.")}

+

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

)}
diff --git a/frontend/src/app/problems/[id]/page.tsx b/frontend/src/app/problems/[id]/page.tsx index bf3f428..ed53950 100644 --- a/frontend/src/app/problems/[id]/page.tsx +++ b/frontend/src/app/problems/[id]/page.tsx @@ -119,24 +119,24 @@ function resolveResultTone( ): ResultTone { const s = status.toUpperCase(); 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") { - 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") { - 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") { - 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") { - 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") { - 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 = { @@ -251,8 +251,8 @@ function buildPrintableAnswerMarkdown( return methodBlocks.join("\n\n---\n\n"); } return tx( - "### 未有答案\n\nLLM 未生成可用答案,请先点击“按 C++14 重新生成”。", - "### No Answer Yet\n\nNo usable LLM answer is available. Please click \"Regenerate in C++14\" first." + "### 未有答案\n\n先知未生成可用启示,请点击“按 C++14 重新祈祷”。", + "### No Prophecy\n\nNo usable wisdom available. Click \"Regenerate in C++14\" to pray again." ); } @@ -271,6 +271,14 @@ int main() { 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() { const { tx } = useI18nText(); const params = useParams<{ id: string }>(); @@ -294,6 +302,8 @@ export default function ProblemDetailPage() { const [policyMsg, setPolicyMsg] = useState(""); const [showSolutions, setShowSolutions] = useState(false); + const [expandedCodes, setExpandedCodes] = useState>(new Set()); + const [unlockConfirm, setUnlockConfirm] = useState(false); const [solutionLoading, setSolutionLoading] = useState(false); const [solutionStatusLoading, setSolutionStatusLoading] = useState(false); const [solutionData, setSolutionData] = useState(null); @@ -397,9 +407,9 @@ export default function ProblemDetailPage() { return tx("待生成", "Pending"); }, [hasSolutionAnswer, solutionStatusLoading, tx]); const answerStatusClass = useMemo(() => { - if (hasSolutionAnswer) return "text-emerald-700"; - if (solutionStatusLoading) return "text-zinc-600"; - return "text-amber-700"; + if (hasSolutionAnswer) return "text-[color:var(--mc-grass-top)]"; + if (solutionStatusLoading) return "text-[color:var(--mc-stone)]"; + return "text-[color:var(--mc-gold)]"; }, [hasSolutionAnswer, solutionStatusLoading]); useEffect(() => { @@ -425,8 +435,8 @@ export default function ProblemDetailPage() { if (policyErrorCount > 0) { setPolicyMsg( tx( - `检测到 ${policyErrorCount} 条超出 C++14 的高风险写法,提交到旧评测环境可能直接 CE。`, - `Detected ${policyErrorCount} high-risk C++14 violations. Old judges may fail with CE.` + `检测到 ${policyErrorCount} 条高风险写法,提交到旧评测机可能直接 CE。`, + `Detected ${policyErrorCount} high-risk violations. Old judges may fail with CE.` ) ); return; @@ -434,8 +444,8 @@ export default function ProblemDetailPage() { if (policyWarningCount > 0) { setPolicyMsg( tx( - `检测到 ${policyWarningCount} 条代码规范提醒,建议修正后再提交。`, - `Detected ${policyWarningCount} policy warnings. Fix them before submit.` + `检测到 ${policyWarningCount} 条代码规范提醒,建议修正。`, + `Detected ${policyWarningCount} policy warnings. Suggest fix.` ) ); return; @@ -480,7 +490,7 @@ export default function ProblemDetailPage() { }); } } catch { - // keep silent, avoid blocking coding flow when status API fails. + // keep silent } finally { if (!cancelled) setSolutionStatusLoading(false); } @@ -511,10 +521,10 @@ export default function ProblemDetailPage() { } draftLastSavedSigRef.current = buildDraftSignature(nextCode, nextStdin); if (hasDraft) { - setDraftMsg(tx("已自动加载草稿", "Draft auto-loaded")); + setDraftMsg(tx("草稿已加载", "Draft loaded")); } } catch { - // ignore empty draft / unauthorized + // ignore } }; void loadDraft(); @@ -541,10 +551,10 @@ export default function ProblemDetailPage() { ) .then(() => { draftLastSavedSigRef.current = nextSig; - setDraftMsg(tx("草稿已自动保存(每60秒)", "Draft auto-saved (every 60s)")); + setDraftMsg(tx("草稿已自动保存", "Draft auto-saved")); }) .catch(() => { - // Keep silent to avoid noisy notifications on transient network errors. + // silent }) .finally(() => { draftAutoSavingRef.current = false; @@ -639,7 +649,7 @@ export default function ProblemDetailPage() { if (mode === "full") setSolutionMsg(""); try { 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( `/api/v1/problems/${id}/solutions?mode=${mode}`, undefined, @@ -650,23 +660,23 @@ export default function ProblemDetailPage() { if (!resp.has_solutions) { setSolutionMsg( 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) { const cost = resp.access.cost ?? 2; const remaining = resp.access.rating_after; const remainingText = typeof remaining === "number" - ? tx(`,当前 Rating:${remaining}`, `, current rating: ${remaining}`) + ? tx(`,当前 Rating:${remaining}`, `, rating: ${remaining}`) : ""; setSolutionMsg( - tx(`本次查看答案扣除 ${cost} 分${remainingText}。`, `Answer view cost ${cost} points${remainingText}.`) + tx(`扣除 ${cost} 分${remainingText}。`, `Cost ${cost} points${remainingText}.`) ); } else if (resp.access?.daily_free) { - setSolutionMsg(tx("今日首次查看答案免费。", "Today's first answer view is free.")); + setSolutionMsg(tx("首次免费。", "Free view.")); } else { - setSolutionMsg(tx("答案已加载。", "Answer loaded.")); + setSolutionMsg(tx("答案已加载。", "Loaded.")); } } else { setSolutionData((prev) => { @@ -683,7 +693,7 @@ export default function ProblemDetailPage() { return resp; } catch (e: unknown) { if (mode === "full") { - setSolutionMsg(tx(`加载答案失败:${String(e)}`, `Failed to load answer: ${String(e)}`)); + setSolutionMsg(tx(`加载失败:${String(e)}`, `Failed: ${String(e)}`)); } return null; } finally { @@ -696,7 +706,7 @@ export default function ProblemDetailPage() { setSolutionMsg(""); try { 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 }>( `/api/v1/problems/${id}/solutions/generate`, { @@ -710,8 +720,8 @@ export default function ProblemDetailPage() { : ""; setSolutionMsg( tx( - `题解生成任务已提交(按 C++14 规范生成),后台异步处理中。${pending}`, - `Generation job submitted (C++14 policy). Processing asynchronously.${pending}` + `先知题解已请求。${pending}`, + `Prophecy requested.${pending}` ) ); await loadSolutions("preview", { silent: true }); @@ -725,18 +735,18 @@ export default function ProblemDetailPage() { const applySolutionCode = async (item: SolutionItem, runAfterWrite: boolean) => { const prepared = normalizeCodeText(item.code_cpp); if (!prepared) { - setSolutionMsg(tx("该题解没有可写入的代码。", "This solution has no writable code.")); + setSolutionMsg(tx("没有代码。", "No code.")); return; } setCode(prepared); - setDraftMsg(tx(`已将“解法 ${item.variant}”写入答题窗口`, `Inserted "Method ${item.variant}" into editor`)); + setDraftMsg(tx(`已写入解法 ${item.variant}`, `Method ${item.variant} inserted`)); if (!runAfterWrite) return; - setSolutionMsg(tx(`已写入“解法 ${item.variant}”,正在试运行...`, `Inserted "Method ${item.variant}", running...`)); + setSolutionMsg(tx(`已写入并运行解法 ${item.variant}...`, `Method ${item.variant} running...`)); await runCode(prepared); setSolutionMsg( tx( - `已写入“解法 ${item.variant}”,可在下方查看运行结果。`, - `Inserted "Method ${item.variant}". Check run result below.` + `已写入解法 ${item.variant},结果见下方。`, + `Method ${item.variant} done. Check below.` ) ); }; @@ -770,415 +780,428 @@ export default function ProblemDetailPage() { }; return ( -
-

- {tx("题目详情与评测", "Problem Detail & Judge")} +
+

+ {tx("任务详情与试炼", "Mission Details")}

- {loading &&

{tx("加载中...", "Loading...")}

} - {error &&

{error}

} + {loading &&

{tx("加载地图中...", "Loading Map...")}

} + {error &&

{error}

} {problem && (
-
-

{problem.title}

-

- {tx("难度", "Difficulty")} {problem.difficulty} · {tx("来源", "Source")} {problem.source} -

+
+

{problem.title}

+
+ 6 ? "text-[color:var(--mc-diamond)]" : + 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)} + + · + {tx("来源", "Origin")}: {problem.source} +
+
+ {showPolicyTips && ( -
-

{tx("福建 CSP-J/S 代码规范提示", "Fujian CSP-J/S Coding Tips")}

-
    - {policyTips.map((tip) => ( -
  • {tip}
  • +
    +

    {tx("考场生存指南:", "Survival Guide:")}

    +
      + {policyTips.map((tip, idx) => ( +
    • {tip}
    • ))}
    )} -
    - -
    +
    + - {llmProfile?.knowledge_points && llmProfile.knowledge_points.length > 0 && ( - <> -

    {tx("知识点考查", "Knowledge Points")}

    -
    - {llmProfile.knowledge_points.map((kp) => ( - - {kp} - - ))} + {printAnswerMarkdown && ( +
    +

    {tx("参考答案 / 解析", "Reference Answer")}

    +
    - - )} - -

    {tx("样例输入", "Sample Input")}

    -
    -              {problem.sample_input}
    -            
    - -

    {tx("样例输出", "Sample Output")}

    -
    -              {problem.sample_output}
    -            
    - -
    -

    {tx("参考答案", "Reference Answer")}

    -
    - -
    + )}
-
- - setContestId(e.target.value)} - /> - -
- - - - -
-
- - {tx("答案状态:", "Answer status: ")} - {answerStatusLabel} - - | - {tx("当前模式:C++14 评测规范", "Current mode: C++14 judge policy")} - | - {tx("答案查看:每日首免,后续每次 -2 Rating", "Answer view: first daily view free, then -2 rating each")} -
-

- {tx("当前试运行/提交按", "Run/submit compiles with")} -std=gnu++14{" "} - {tx(",超出 C++14 的写法会自动高亮提醒。", "; non-C++14 usage will be highlighted automatically.")} -

- - {draftMsg &&

{draftMsg}

} - {(policyMsg || policyIssues.length > 0) && ( -
0 - ? "border-red-200 bg-red-50 text-red-700" - : "border-amber-200 bg-amber-50 text-amber-800" - }`} - > - {policyMsg &&

{policyMsg}

} - {!policyMsg && policyHintCount > 0 && ( -

- {tx(`已检测到 ${policyHintCount} 条考场规范提示。`, `Detected ${policyHintCount} policy hints.`)} -

- )} - {visiblePolicyIssues.length > 0 && ( -
    - {visiblePolicyIssues.map((issue, idx) => ( -
  • - L{issue.line}:{issue.message} -
  • - ))} -
- )} -
- )} - - -
- -
- - -

{tx("排名", "Rank")}{tx("用户", "User")}Rating{tx("注册时间", "Registered At")}{tx("排名", "Rank")}{tx("用户", "User")}Rating{tx("注册时间", "Registered At")}
{i + 1}{row.username}{row.rating} +
{getRankIcon(i)}{row.username}{row.rating} {new Date(row.created_at * 1000).toLocaleString()}
- {tx("暂无排行数据", "No ranking data yet")} + {tx("暂无数据", "No legends yet")}