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

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

查看文件

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

查看文件

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

查看文件

@@ -4,6 +4,7 @@
#include "csp/domain/json.h" #include "csp/domain/json.h"
#include "csp/services/daily_task_service.h" #include "csp/services/daily_task_service.h"
#include "csp/services/redeem_service.h" #include "csp/services/redeem_service.h"
#include "csp/services/solution_access_service.h"
#include "csp/services/user_service.h" #include "csp/services/user_service.h"
#include "csp/services/wrong_book_service.h" #include "csp/services/wrong_book_service.h"
#include "http_auth.h" #include "http_auth.h"
@@ -19,7 +20,7 @@ namespace csp::controllers {
namespace { namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) { const std::string &msg) {
Json::Value j; Json::Value j;
j["ok"] = false; j["ok"] = false;
j["error"] = msg; j["error"] = msg;
@@ -28,7 +29,7 @@ drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
return resp; return resp;
} }
drogon::HttpResponsePtr JsonOk(const Json::Value& data) { drogon::HttpResponsePtr JsonOk(const Json::Value &data) {
Json::Value j; Json::Value j;
j["ok"] = true; j["ok"] = true;
j["data"] = data; j["data"] = data;
@@ -37,8 +38,9 @@ drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
return resp; return resp;
} }
std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req, std::optional<int64_t>
std::function<void(const drogon::HttpResponsePtr&)>& cb) { RequireAuth(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &cb) {
std::string auth_error; std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error); const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) { if (!user_id.has_value()) {
@@ -48,23 +50,23 @@ std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
return user_id; return user_id;
} }
int ParseClampedInt(const std::string& s, int ParseClampedInt(const std::string &s, int default_value, int min_value,
int default_value,
int min_value,
int max_value) { int max_value) {
if (s.empty()) return default_value; if (s.empty())
return default_value;
const int value = std::stoi(s); const int value = std::stoi(s);
return std::max(min_value, std::min(max_value, value)); return std::max(min_value, std::min(max_value, value));
} }
} // namespace } // namespace
void MeController::profile( void MeController::profile(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::UserService users(csp::AppState::Instance().db()); services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id); const auto user = users.GetById(*user_id);
@@ -74,22 +76,23 @@ void MeController::profile(
} }
cb(JsonOk(domain::ToPublicJson(*user))); cb(JsonOk(domain::ToPublicJson(*user)));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::listRedeemItems( void MeController::listRedeemItems(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
if (!RequireAuth(req, cb).has_value()) return; if (!RequireAuth(req, cb).has_value())
return;
services::RedeemService redeem(csp::AppState::Instance().db()); services::RedeemService redeem(csp::AppState::Instance().db());
const auto items = redeem.ListItems(false); const auto items = redeem.ListItems(false);
Json::Value arr(Json::arrayValue); Json::Value arr(Json::arrayValue);
for (const auto& item : items) { for (const auto &item : items) {
Json::Value j; Json::Value j;
j["id"] = Json::Int64(item.id); j["id"] = Json::Int64(item.id);
j["name"] = item.name; j["name"] = item.name;
@@ -104,24 +107,25 @@ void MeController::listRedeemItems(
arr.append(j); arr.append(j);
} }
cb(JsonOk(arr)); cb(JsonOk(arr));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::listRedeemRecords( void MeController::listRedeemRecords(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
services::RedeemService redeem(csp::AppState::Instance().db()); services::RedeemService redeem(csp::AppState::Instance().db());
const auto rows = redeem.ListRecordsByUser(*user_id, limit); const auto rows = redeem.ListRecordsByUser(*user_id, limit);
Json::Value arr(Json::arrayValue); Json::Value arr(Json::arrayValue);
for (const auto& row : rows) { for (const auto &row : rows) {
Json::Value j; Json::Value j;
j["id"] = Json::Int64(row.id); j["id"] = Json::Int64(row.id);
j["user_id"] = Json::Int64(row.user_id); j["user_id"] = Json::Int64(row.user_id);
@@ -136,21 +140,22 @@ void MeController::listRedeemRecords(
arr.append(j); arr.append(j);
} }
cb(JsonOk(arr)); cb(JsonOk(arr));
} catch (const std::invalid_argument&) { } catch (const std::invalid_argument &) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); 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")); 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())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::createRedeemRecord( void MeController::createRedeemRecord(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
const auto json = req->getJsonObject(); const auto json = req->getJsonObject();
if (!json) { if (!json) {
@@ -186,19 +191,20 @@ void MeController::createRedeemRecord(
j["rating_after"] = user->rating; j["rating_after"] = user->rating;
} }
cb(JsonOk(j)); cb(JsonOk(j));
} catch (const std::runtime_error& e) { } catch (const std::runtime_error &e) {
cb(JsonError(drogon::k400BadRequest, e.what())); cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::listDailyTasks( void MeController::listDailyTasks(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::DailyTaskService tasks(csp::AppState::Instance().db()); services::DailyTaskService tasks(csp::AppState::Instance().db());
const auto rows = tasks.ListTodayTasks(*user_id); const auto rows = tasks.ListTodayTasks(*user_id);
@@ -206,7 +212,7 @@ void MeController::listDailyTasks(
Json::Value arr(Json::arrayValue); Json::Value arr(Json::arrayValue);
int total_reward = 0; int total_reward = 0;
int gained_reward = 0; int gained_reward = 0;
for (const auto& row : rows) { for (const auto &row : rows) {
Json::Value j; Json::Value j;
j["code"] = row.code; j["code"] = row.code;
j["title"] = row.title; j["title"] = row.title;
@@ -229,41 +235,43 @@ void MeController::listDailyTasks(
out["gained_reward"] = gained_reward; out["gained_reward"] = gained_reward;
out["tasks"] = arr; out["tasks"] = arr;
cb(JsonOk(out)); cb(JsonOk(out));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::listWrongBook( void MeController::listWrongBook(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) { std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::WrongBookService wrong_book(csp::AppState::Instance().db()); services::WrongBookService wrong_book(csp::AppState::Instance().db());
const auto rows = wrong_book.ListByUser(*user_id); const auto rows = wrong_book.ListByUser(*user_id);
Json::Value arr(Json::arrayValue); Json::Value arr(Json::arrayValue);
for (const auto& row : rows) { for (const auto &row : rows) {
Json::Value item = domain::ToJson(row.item); Json::Value item = domain::ToJson(row.item);
item["problem_title"] = row.problem_title; item["problem_title"] = row.problem_title;
arr.append(item); arr.append(item);
} }
cb(JsonOk(arr)); cb(JsonOk(arr));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::upsertWrongBookNote( void MeController::upsertWrongBookNote(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb, std::function<void(const drogon::HttpResponsePtr &)> &&cb,
int64_t problem_id) { int64_t problem_id) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
const auto json = req->getJsonObject(); const auto json = req->getJsonObject();
if (!json) { if (!json) {
@@ -285,18 +293,19 @@ void MeController::upsertWrongBookNote(
data["problem_id"] = Json::Int64(problem_id); data["problem_id"] = Json::Int64(problem_id);
data["note"] = note; data["note"] = note;
cb(JsonOk(data)); cb(JsonOk(data));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
void MeController::deleteWrongBookItem( void MeController::deleteWrongBookItem(
const drogon::HttpRequestPtr& req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb, std::function<void(const drogon::HttpResponsePtr &)> &&cb,
int64_t problem_id) { int64_t problem_id) {
try { try {
const auto user_id = RequireAuth(req, cb); const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return; if (!user_id.has_value())
return;
services::WrongBookService wrong_book(csp::AppState::Instance().db()); services::WrongBookService wrong_book(csp::AppState::Instance().db());
wrong_book.Remove(*user_id, problem_id); wrong_book.Remove(*user_id, problem_id);
@@ -306,9 +315,35 @@ void MeController::deleteWrongBookItem(
data["problem_id"] = Json::Int64(problem_id); data["problem_id"] = Json::Int64(problem_id);
data["deleted"] = true; data["deleted"] = true;
cb(JsonOk(data)); cb(JsonOk(data));
} catch (const std::exception& e) { } catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what())); cb(JsonError(drogon::k500InternalServerError, e.what()));
} }
} }
} // namespace csp::controllers void MeController::listRatingHistory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value())
return;
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
const auto rows = access_svc.ListRatingHistory(*user_id, limit);
Json::Value arr(Json::arrayValue);
for (const auto &row : rows) {
Json::Value j;
j["type"] = row.type;
j["created_at"] = Json::Int64(row.created_at);
j["change"] = row.change;
j["note"] = row.note;
arr.append(j);
}
cb(JsonOk(arr));
} catch (const std::exception &e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -18,28 +18,29 @@ int64_t NowSec() {
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count(); return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
} }
void CheckSqlite(int rc, sqlite3* db, const char* what) { void CheckSqlite(int rc, sqlite3 *db, const char *what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE)
return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
} }
std::string ColText(sqlite3_stmt* stmt, int col) { std::string ColText(sqlite3_stmt *stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col); const unsigned char *txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string(); return txt ? reinterpret_cast<const char *>(txt) : std::string();
} }
std::string BuildDayKeyChina(int64_t ts_sec) { std::string BuildDayKeyChina(int64_t ts_sec) {
const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600); const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600);
std::tm tm {}; std::tm tm{};
gmtime_r(&shifted, &tm); gmtime_r(&shifted, &tm);
char buf[16] = {0}; char buf[16] = {0};
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm); std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm);
return std::string(buf); return std::string(buf);
} }
int QueryRating(sqlite3* db, int64_t user_id) { int QueryRating(sqlite3 *db, int64_t user_id) {
sqlite3_stmt* stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = "SELECT rating FROM users WHERE id=?"; const char *sql = "SELECT rating FROM users WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query rating"); "prepare query rating");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); 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; return rating;
} }
int QueryDailyUsage(sqlite3* db, int64_t user_id, const std::string& day_key) { int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) {
sqlite3_stmt* stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs WHERE "
"SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?"; "user_id=? AND day_key=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query daily usage"); "prepare query daily usage");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db, CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT),
"bind day_key"); db, "bind day_key");
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage"); CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
const int used = sqlite3_column_int(stmt, 0); const int used = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
return used; return used;
} }
void InsertViewLog(sqlite3* db, void InsertViewLog(sqlite3 *db, int64_t user_id, int64_t problem_id,
int64_t user_id, const std::string &day_key, int64_t viewed_at, bool charged,
int64_t problem_id,
const std::string& day_key,
int64_t viewed_at,
bool charged,
int cost) { int cost) {
sqlite3_stmt* stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = const char *sql = "INSERT INTO "
"INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) " "problem_solution_view_logs(user_id,problem_id,day_key,"
"VALUES(?,?,?,?,?,?,?)"; "viewed_at,charged,cost,created_at) "
"VALUES(?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert solution view log"); "prepare insert solution view log");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
"bind day_key"); db, "bind day_key");
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at"); CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged"); CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost"); CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
@@ -92,9 +90,9 @@ void InsertViewLog(sqlite3* db,
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
} }
void DeductRating(sqlite3* db, int64_t user_id, int cost) { void DeductRating(sqlite3 *db, int64_t user_id, int cost) {
sqlite3_stmt* stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = "UPDATE users SET rating=rating-? WHERE id=?"; const char *sql = "UPDATE users SET rating=rating-? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare deduct rating"); "prepare deduct rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, cost), db, "bind cost"); 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); sqlite3_finalize(stmt);
} }
} // namespace } // namespace
SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( // ... (Helper functions)
int64_t user_id,
int64_t problem_id) { bool QueryHasPurchased(sqlite3 *db, int64_t user_id, int64_t problem_id) {
sqlite3_stmt *stmt = nullptr;
const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs "
"WHERE user_id=? AND problem_id=? AND charged=1";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query has purchased");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_step(stmt), db, "step query has purchased");
const int count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count > 0;
}
SolutionViewChargeResult
SolutionAccessService::ConsumeSolutionView(int64_t user_id,
int64_t problem_id) {
if (user_id <= 0 || problem_id <= 0) { if (user_id <= 0 || problem_id <= 0) {
throw std::runtime_error("invalid user_id/problem_id"); throw std::runtime_error("invalid user_id/problem_id");
} }
sqlite3* db = db_.raw(); sqlite3 *db = db_.raw();
const int64_t now = NowSec(); const int64_t now = NowSec();
const std::string day_key = BuildDayKeyChina(now); const std::string day_key = BuildDayKeyChina(now);
@@ -123,6 +137,26 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
result.day_key = day_key; result.day_key = day_key;
result.viewed_at = now; result.viewed_at = now;
// Check if already purchased (charged=1 before)
if (QueryHasPurchased(db, user_id, problem_id)) {
result.daily_used_count =
QueryDailyUsage(db, user_id, day_key); // Just for info
result.rating_before = QueryRating(db, user_id);
result.charged = false;
result.daily_free = false; // Not "free" quota, but "owned"
result.cost = 0;
result.rating_after = result.rating_before;
result.granted = true;
// We still log this view, but charged=0, cost=0.
// This keeps track of "views" even if free.
InsertViewLog(db, user_id, problem_id, day_key, now, false, 0);
db_.Exec("COMMIT");
committed = true;
return result;
}
const int used_before = QueryDailyUsage(db, user_id, day_key); const int used_before = QueryDailyUsage(db, user_id, day_key);
result.daily_used_count = used_before; result.daily_used_count = used_before;
const int rating_before = QueryRating(db, user_id); const int rating_before = QueryRating(db, user_id);
@@ -150,12 +184,7 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
DeductRating(db, user_id, kViewCost); DeductRating(db, user_id, kViewCost);
} }
InsertViewLog(db, InsertViewLog(db, user_id, problem_id, day_key, now, result.charged,
user_id,
problem_id,
day_key,
now,
result.charged,
result.cost); result.cost);
result.daily_used_count = used_before + 1; result.daily_used_count = used_before + 1;
@@ -173,15 +202,16 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
} }
} }
SolutionViewStats SolutionAccessService::QueryUserProblemViewStats( SolutionViewStats
int64_t user_id, SolutionAccessService::QueryUserProblemViewStats(int64_t user_id,
int64_t problem_id) { int64_t problem_id) {
SolutionViewStats stats; SolutionViewStats stats;
if (user_id <= 0 || problem_id <= 0) return stats; if (user_id <= 0 || problem_id <= 0)
return stats;
sqlite3* db = db_.raw(); sqlite3 *db = db_.raw();
sqlite3_stmt* stmt = nullptr; sqlite3_stmt *stmt = nullptr;
const char* sql = const char *sql =
"SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) " "SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) "
"FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?"; "FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
@@ -200,4 +230,49 @@ SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
return stats; return stats;
} }
} // namespace csp::services std::vector<RatingHistoryItem>
SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) {
if (limit <= 0)
limit = 100;
if (limit > 500)
limit = 500;
std::vector<RatingHistoryItem> items;
sqlite3 *db = db_.raw();
sqlite3_stmt *stmt = nullptr;
// Union query for history
const char *sql =
"SELECT 'solution_view' as type, created_at, -cost as change, ('Problem "
"' || problem_id) as note "
"FROM problem_solution_view_logs WHERE user_id=? AND cost > 0 "
"UNION ALL "
"SELECT 'daily_task' as type, created_at, reward as change, title as "
"note "
"FROM daily_task_logs WHERE user_id=? "
"UNION ALL "
"SELECT 'redeem' as type, created_at, -total_cost as change, item_name "
"as note "
"FROM redeem_logs WHERE user_id=? "
"ORDER BY created_at DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare history query");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id 1");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id 2");
CheckSqlite(sqlite3_bind_int64(stmt, 3, user_id), db, "bind user_id 3");
CheckSqlite(sqlite3_bind_int(stmt, 4, limit), db, "bind limit");
while (sqlite3_step(stmt) == SQLITE_ROW) {
RatingHistoryItem item;
item.type = ColText(stmt, 0);
item.created_at = sqlite3_column_int64(stmt, 1);
item.change = sqlite3_column_int(stmt, 2);
item.note = ColText(stmt, 3);
items.push_back(item);
}
sqlite3_finalize(stmt);
return items;
}
} // namespace csp::services

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

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

查看文件

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

查看文件

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

查看文件

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

二进制文件未显示。

之前

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

之后

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

文件差异内容过多而无法显示 加载差异

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

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

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