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 718d6fe..2908689 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ 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")}