feat: Minecraft theme overhaul, fix points bug, add history
这个提交包含在:
@@ -7,43 +7,62 @@
|
||||
namespace csp::controllers {
|
||||
|
||||
class MeController : public drogon::HttpController<MeController> {
|
||||
public:
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post);
|
||||
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch);
|
||||
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete);
|
||||
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}",
|
||||
drogon::Patch);
|
||||
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}",
|
||||
drogon::Delete);
|
||||
ADD_METHOD_TO(MeController::listRatingHistory, "/api/v1/me/rating-history",
|
||||
drogon::Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void profile(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
void profile(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||
|
||||
void listRedeemItems(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
void
|
||||
listRedeemItems(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||
|
||||
void listRedeemRecords(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
void
|
||||
listRedeemRecords(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||
|
||||
void createRedeemRecord(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
void
|
||||
createRedeemRecord(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||
|
||||
void listDailyTasks(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
void
|
||||
listDailyTasks(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||
|
||||
void listWrongBook(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
void listWrongBook(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||
|
||||
void upsertWrongBookNote(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
int64_t problem_id);
|
||||
void
|
||||
upsertWrongBookNote(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id);
|
||||
|
||||
void deleteWrongBookItem(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
int64_t problem_id);
|
||||
void
|
||||
deleteWrongBookItem(const drogon::HttpRequestPtr &req,
|
||||
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 <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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 {
|
||||
bool granted = true;
|
||||
bool charged = false;
|
||||
@@ -21,24 +29,29 @@ struct SolutionViewChargeResult {
|
||||
std::string deny_reason;
|
||||
};
|
||||
|
||||
struct SolutionViewStats {
|
||||
bool has_viewed = false;
|
||||
int total_views = 0;
|
||||
int total_cost = 0;
|
||||
std::optional<int64_t> last_viewed_at;
|
||||
struct RatingHistoryItem {
|
||||
std::string type;
|
||||
int64_t created_at;
|
||||
int change;
|
||||
std::string note;
|
||||
};
|
||||
|
||||
class SolutionAccessService {
|
||||
public:
|
||||
explicit SolutionAccessService(db::SqliteDb& db) : db_(db) {}
|
||||
public:
|
||||
explicit SolutionAccessService(db::SqliteDb &db) : db_(db) {}
|
||||
|
||||
// Daily policy: first answer view is free, then each full view costs 2 rating.
|
||||
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id);
|
||||
// Daily policy: first answer view is free, then each full view costs 2
|
||||
// rating.
|
||||
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id,
|
||||
int64_t problem_id);
|
||||
|
||||
SolutionViewStats QueryUserProblemViewStats(int64_t user_id, int64_t problem_id);
|
||||
SolutionViewStats QueryUserProblemViewStats(int64_t user_id,
|
||||
int64_t problem_id);
|
||||
|
||||
private:
|
||||
db::SqliteDb& db_;
|
||||
std::vector<RatingHistoryItem> ListRatingHistory(int64_t user_id, int limit);
|
||||
|
||||
private:
|
||||
db::SqliteDb &db_;
|
||||
};
|
||||
|
||||
} // namespace csp::services
|
||||
} // namespace csp::services
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "csp/domain/json.h"
|
||||
#include "csp/services/daily_task_service.h"
|
||||
#include "csp/services/redeem_service.h"
|
||||
#include "csp/services/solution_access_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
#include "csp/services/wrong_book_service.h"
|
||||
#include "http_auth.h"
|
||||
@@ -19,7 +20,7 @@ namespace csp::controllers {
|
||||
namespace {
|
||||
|
||||
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
|
||||
const std::string& msg) {
|
||||
const std::string &msg) {
|
||||
Json::Value j;
|
||||
j["ok"] = false;
|
||||
j["error"] = msg;
|
||||
@@ -28,7 +29,7 @@ drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
|
||||
return resp;
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
|
||||
drogon::HttpResponsePtr JsonOk(const Json::Value &data) {
|
||||
Json::Value j;
|
||||
j["ok"] = true;
|
||||
j["data"] = data;
|
||||
@@ -37,8 +38,9 @@ drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
|
||||
std::optional<int64_t>
|
||||
RequireAuth(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &cb) {
|
||||
std::string auth_error;
|
||||
const auto user_id = GetAuthedUserId(req, auth_error);
|
||||
if (!user_id.has_value()) {
|
||||
@@ -48,23 +50,23 @@ std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
|
||||
return user_id;
|
||||
}
|
||||
|
||||
int ParseClampedInt(const std::string& s,
|
||||
int default_value,
|
||||
int min_value,
|
||||
int ParseClampedInt(const std::string &s, int default_value, int min_value,
|
||||
int max_value) {
|
||||
if (s.empty()) return default_value;
|
||||
if (s.empty())
|
||||
return default_value;
|
||||
const int value = std::stoi(s);
|
||||
return std::max(min_value, std::min(max_value, value));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace
|
||||
|
||||
void MeController::profile(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
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;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
services::UserService users(csp::AppState::Instance().db());
|
||||
const auto user = users.GetById(*user_id);
|
||||
@@ -74,22 +76,23 @@ void MeController::profile(
|
||||
}
|
||||
|
||||
cb(JsonOk(domain::ToPublicJson(*user)));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::listRedeemItems(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||
try {
|
||||
if (!RequireAuth(req, cb).has_value()) return;
|
||||
if (!RequireAuth(req, cb).has_value())
|
||||
return;
|
||||
|
||||
services::RedeemService redeem(csp::AppState::Instance().db());
|
||||
const auto items = redeem.ListItems(false);
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& item : items) {
|
||||
for (const auto &item : items) {
|
||||
Json::Value j;
|
||||
j["id"] = Json::Int64(item.id);
|
||||
j["name"] = item.name;
|
||||
@@ -104,24 +107,25 @@ void MeController::listRedeemItems(
|
||||
arr.append(j);
|
||||
}
|
||||
cb(JsonOk(arr));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::listRedeemRecords(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
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;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
|
||||
services::RedeemService redeem(csp::AppState::Instance().db());
|
||||
const auto rows = redeem.ListRecordsByUser(*user_id, limit);
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& row : rows) {
|
||||
for (const auto &row : rows) {
|
||||
Json::Value j;
|
||||
j["id"] = Json::Int64(row.id);
|
||||
j["user_id"] = Json::Int64(row.user_id);
|
||||
@@ -136,21 +140,22 @@ void MeController::listRedeemRecords(
|
||||
arr.append(j);
|
||||
}
|
||||
cb(JsonOk(arr));
|
||||
} catch (const std::invalid_argument&) {
|
||||
} catch (const std::invalid_argument &) {
|
||||
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
|
||||
} catch (const std::out_of_range&) {
|
||||
} catch (const std::out_of_range &) {
|
||||
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::createRedeemRecord(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
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;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
const auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
@@ -186,19 +191,20 @@ void MeController::createRedeemRecord(
|
||||
j["rating_after"] = user->rating;
|
||||
}
|
||||
cb(JsonOk(j));
|
||||
} catch (const std::runtime_error& e) {
|
||||
} catch (const std::runtime_error &e) {
|
||||
cb(JsonError(drogon::k400BadRequest, e.what()));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::listDailyTasks(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
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;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
services::DailyTaskService tasks(csp::AppState::Instance().db());
|
||||
const auto rows = tasks.ListTodayTasks(*user_id);
|
||||
@@ -206,7 +212,7 @@ void MeController::listDailyTasks(
|
||||
Json::Value arr(Json::arrayValue);
|
||||
int total_reward = 0;
|
||||
int gained_reward = 0;
|
||||
for (const auto& row : rows) {
|
||||
for (const auto &row : rows) {
|
||||
Json::Value j;
|
||||
j["code"] = row.code;
|
||||
j["title"] = row.title;
|
||||
@@ -229,41 +235,43 @@ void MeController::listDailyTasks(
|
||||
out["gained_reward"] = gained_reward;
|
||||
out["tasks"] = arr;
|
||||
cb(JsonOk(out));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::listWrongBook(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
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;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
const auto rows = wrong_book.ListByUser(*user_id);
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& row : rows) {
|
||||
for (const auto &row : rows) {
|
||||
Json::Value item = domain::ToJson(row.item);
|
||||
item["problem_title"] = row.problem_title;
|
||||
arr.append(item);
|
||||
}
|
||||
|
||||
cb(JsonOk(arr));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::upsertWrongBookNote(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
const auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
@@ -285,18 +293,19 @@ void MeController::upsertWrongBookNote(
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["note"] = note;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::deleteWrongBookItem(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
if (!user_id.has_value())
|
||||
return;
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
wrong_book.Remove(*user_id, problem_id);
|
||||
@@ -306,9 +315,35 @@ void MeController::deleteWrongBookItem(
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["deleted"] = true;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception& e) {
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace csp::controllers
|
||||
void MeController::listRatingHistory(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<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();
|
||||
}
|
||||
|
||||
void CheckSqlite(int rc, sqlite3* db, const char* what) {
|
||||
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
|
||||
void CheckSqlite(int rc, sqlite3 *db, const char *what) {
|
||||
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE)
|
||||
return;
|
||||
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
||||
}
|
||||
|
||||
std::string ColText(sqlite3_stmt* stmt, int col) {
|
||||
const unsigned char* txt = sqlite3_column_text(stmt, col);
|
||||
return txt ? reinterpret_cast<const char*>(txt) : std::string();
|
||||
std::string ColText(sqlite3_stmt *stmt, int col) {
|
||||
const unsigned char *txt = sqlite3_column_text(stmt, col);
|
||||
return txt ? reinterpret_cast<const char *>(txt) : std::string();
|
||||
}
|
||||
|
||||
std::string BuildDayKeyChina(int64_t ts_sec) {
|
||||
const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600);
|
||||
std::tm tm {};
|
||||
std::tm tm{};
|
||||
gmtime_r(&shifted, &tm);
|
||||
char buf[16] = {0};
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
int QueryRating(sqlite3* db, int64_t user_id) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "SELECT rating FROM users WHERE id=?";
|
||||
int QueryRating(sqlite3 *db, int64_t user_id) {
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
const char *sql = "SELECT rating FROM users WHERE id=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare query rating");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
@@ -52,38 +53,35 @@ int QueryRating(sqlite3* db, int64_t user_id) {
|
||||
return rating;
|
||||
}
|
||||
|
||||
int QueryDailyUsage(sqlite3* db, int64_t user_id, const std::string& day_key) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?";
|
||||
int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) {
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs WHERE "
|
||||
"user_id=? AND day_key=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare query daily usage");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind day_key");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||
db, "bind day_key");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
|
||||
const int used = sqlite3_column_int(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return used;
|
||||
}
|
||||
|
||||
void InsertViewLog(sqlite3* db,
|
||||
int64_t user_id,
|
||||
int64_t problem_id,
|
||||
const std::string& day_key,
|
||||
int64_t viewed_at,
|
||||
bool charged,
|
||||
void InsertViewLog(sqlite3 *db, int64_t user_id, int64_t problem_id,
|
||||
const std::string &day_key, int64_t viewed_at, bool charged,
|
||||
int cost) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?)";
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
const char *sql = "INSERT INTO "
|
||||
"problem_solution_view_logs(user_id,problem_id,day_key,"
|
||||
"viewed_at,charged,cost,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?)";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare insert solution view log");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind day_key");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||
db, "bind day_key");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
|
||||
@@ -92,9 +90,9 @@ void InsertViewLog(sqlite3* db,
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
void DeductRating(sqlite3* db, int64_t user_id, int cost) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "UPDATE users SET rating=rating-? WHERE id=?";
|
||||
void DeductRating(sqlite3 *db, int64_t user_id, int cost) {
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
const char *sql = "UPDATE users SET rating=rating-? WHERE id=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare deduct rating");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 1, cost), db, "bind cost");
|
||||
@@ -103,16 +101,32 @@ void DeductRating(sqlite3* db, int64_t user_id, int cost) {
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace
|
||||
|
||||
SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
||||
int64_t user_id,
|
||||
int64_t problem_id) {
|
||||
// ... (Helper functions)
|
||||
|
||||
bool QueryHasPurchased(sqlite3 *db, int64_t user_id, int64_t problem_id) {
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs "
|
||||
"WHERE user_id=? AND problem_id=? AND charged=1";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare query has purchased");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "step query has purchased");
|
||||
const int count = sqlite3_column_int(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
SolutionViewChargeResult
|
||||
SolutionAccessService::ConsumeSolutionView(int64_t user_id,
|
||||
int64_t problem_id) {
|
||||
if (user_id <= 0 || problem_id <= 0) {
|
||||
throw std::runtime_error("invalid user_id/problem_id");
|
||||
}
|
||||
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3 *db = db_.raw();
|
||||
const int64_t now = NowSec();
|
||||
const std::string day_key = BuildDayKeyChina(now);
|
||||
|
||||
@@ -123,6 +137,26 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
||||
result.day_key = day_key;
|
||||
result.viewed_at = now;
|
||||
|
||||
// Check if already purchased (charged=1 before)
|
||||
if (QueryHasPurchased(db, user_id, problem_id)) {
|
||||
result.daily_used_count =
|
||||
QueryDailyUsage(db, user_id, day_key); // Just for info
|
||||
result.rating_before = QueryRating(db, user_id);
|
||||
result.charged = false;
|
||||
result.daily_free = false; // Not "free" quota, but "owned"
|
||||
result.cost = 0;
|
||||
result.rating_after = result.rating_before;
|
||||
result.granted = true;
|
||||
|
||||
// We still log this view, but charged=0, cost=0.
|
||||
// This keeps track of "views" even if free.
|
||||
InsertViewLog(db, user_id, problem_id, day_key, now, false, 0);
|
||||
|
||||
db_.Exec("COMMIT");
|
||||
committed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
const int used_before = QueryDailyUsage(db, user_id, day_key);
|
||||
result.daily_used_count = used_before;
|
||||
const int rating_before = QueryRating(db, user_id);
|
||||
@@ -150,12 +184,7 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
||||
DeductRating(db, user_id, kViewCost);
|
||||
}
|
||||
|
||||
InsertViewLog(db,
|
||||
user_id,
|
||||
problem_id,
|
||||
day_key,
|
||||
now,
|
||||
result.charged,
|
||||
InsertViewLog(db, user_id, problem_id, day_key, now, result.charged,
|
||||
result.cost);
|
||||
result.daily_used_count = used_before + 1;
|
||||
|
||||
@@ -173,15 +202,16 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
||||
}
|
||||
}
|
||||
|
||||
SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
|
||||
int64_t user_id,
|
||||
int64_t problem_id) {
|
||||
SolutionViewStats
|
||||
SolutionAccessService::QueryUserProblemViewStats(int64_t user_id,
|
||||
int64_t problem_id) {
|
||||
SolutionViewStats stats;
|
||||
if (user_id <= 0 || problem_id <= 0) return stats;
|
||||
if (user_id <= 0 || problem_id <= 0)
|
||||
return stats;
|
||||
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
sqlite3 *db = db_.raw();
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
const char *sql =
|
||||
"SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) "
|
||||
"FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
@@ -200,4 +230,49 @@ SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
|
||||
return stats;
|
||||
}
|
||||
|
||||
} // namespace csp::services
|
||||
std::vector<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
普通文件
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` 登录/注册
|
||||
- `/problems` 题库列表
|
||||
- `/problems` Quest Board (Questions)
|
||||
- `/problems/:id` 题目详情与提交
|
||||
- `/submissions` 提交列表
|
||||
- `/submissions` Spell History (Submissions)
|
||||
- `/submissions/:id` 提交详情
|
||||
- `/wrong-book` 错题本
|
||||
- `/contests` 模拟竞赛列表
|
||||
- `/wrong-book` Cursed Tome (Wrong Book)
|
||||
- `/contests` Raid Board (Contests)
|
||||
- `/contests/:id` 比赛详情/报名/排行榜
|
||||
- `/kb` 知识库列表
|
||||
- `/kb/:slug` 文章详情
|
||||
- `/imports` 题库导入任务状态与结果
|
||||
- `/run` 在线 C++ 运行
|
||||
- `/me` 当前用户信息
|
||||
- `/leaderboard` 全站排行
|
||||
- `/leaderboard` Hall of Fame (Rankings)
|
||||
|
||||
@@ -13,9 +13,9 @@ type AuthErr = { ok: false; error: string };
|
||||
type AuthResp = AuthOk | AuthErr;
|
||||
|
||||
function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
|
||||
if (password.length >= 12) return { label: isZh ? "强" : "Strong", color: "text-emerald-600" };
|
||||
if (password.length >= 8) return { label: isZh ? "中" : "Medium", color: "text-blue-600" };
|
||||
return { label: isZh ? "弱" : "Weak", color: "text-orange-600" };
|
||||
if (password.length >= 12) return { label: isZh ? "钻石级" : "Diamond Tier", color: "text-[color:var(--mc-diamond)]" };
|
||||
if (password.length >= 8) return { label: isZh ? "铁级" : "Iron Tier", color: "text-zinc-400" };
|
||||
return { label: isZh ? "木级" : "Wood Tier", color: "text-[color:var(--mc-wood)]" };
|
||||
}
|
||||
|
||||
export default function AuthPage() {
|
||||
@@ -83,132 +83,136 @@ export default function AuthPage() {
|
||||
return (
|
||||
<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]">
|
||||
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
|
||||
<h1 className="text-2xl font-semibold">{tx("欢迎回来,开始刷题", "Welcome back, let's practice")}</h1>
|
||||
<p className="mt-3 text-sm text-zinc-300">
|
||||
{tx("登录后可提交评测、保存草稿、查看错题本和个人进度。", "After sign-in you can submit, save drafts, review wrong-book, and track your progress.")}
|
||||
<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-bold text-[color:var(--mc-diamond)] mc-text-shadow leading-relaxed">
|
||||
{tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")}
|
||||
</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>
|
||||
<div className="mt-6 space-y-2 text-sm text-zinc-300">
|
||||
<p>{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}</p>
|
||||
<p>{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}</p>
|
||||
<p>{tx("• 生成式题解会异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}</p>
|
||||
<div className="mt-6 space-y-2 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
<p>{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}</p>
|
||||
<p>{tx("• 任务卷轴支持本地草稿与试炼运行", "• Quest Scrolls support local drafting and trial runs")}</p>
|
||||
<p>{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}</p>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-zinc-400">
|
||||
API Base: <span className="font-mono">{apiBase}</span>
|
||||
<p className="mt-6 text-xs text-[color:var(--mc-stone-dark)]">
|
||||
Server API: <span className="font-mono text-[color:var(--mc-red)]">{apiBase}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border bg-white p-6">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm">
|
||||
<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-none bg-black/20 p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-2 ${
|
||||
mode === "login" ? "bg-white shadow-sm" : "text-zinc-600"
|
||||
}`}
|
||||
className={`rounded-none px-3 py-2 border-[2px] transition-all ${mode === "login"
|
||||
? "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={() => {
|
||||
setMode("login");
|
||||
setResp(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{tx("登录", "Sign In")}
|
||||
{tx("登录服务器", "Login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-2 ${
|
||||
mode === "register" ? "bg-white shadow-sm" : "text-zinc-600"
|
||||
}`}
|
||||
className={`rounded-none px-3 py-2 border-[2px] transition-all ${mode === "register"
|
||||
? "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={() => {
|
||||
setMode("register");
|
||||
setResp(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{tx("注册", "Register")}
|
||||
{tx("新玩家注册", "New Player")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="mt-5 space-y-4 font-mono">
|
||||
<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
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
className="mt-1 w-full"
|
||||
value={username}
|
||||
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 className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{tx("密码", "Password")}</label>
|
||||
<span className={`text-xs ${strength.color}`}>{tx("强度", "Strength")}: {strength.label}</span>
|
||||
<label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("极其机密的口令", "Secret Password")}</label>
|
||||
<span className={`text-xs ${strength.color}`}>{strength.label}</span>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
className="mt-1 w-full"
|
||||
value={password}
|
||||
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>
|
||||
|
||||
{mode === "register" && (
|
||||
<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
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
className="mt-1 w-full"
|
||||
value={confirmPassword}
|
||||
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>
|
||||
)}
|
||||
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={(e) => setShowPassword(e.target.checked)}
|
||||
className="accent-[color:var(--mc-wood)]"
|
||||
/>
|
||||
{tx("显示密码", "Show password")}
|
||||
{tx("显示口令", "Reveal Secret")}
|
||||
</label>
|
||||
|
||||
<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()}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{resp && (
|
||||
<div
|
||||
className={`mt-4 rounded-lg border px-3 py-2 text-sm ${
|
||||
resp.ok ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-red-300 bg-red-50 text-red-700"
|
||||
}`}
|
||||
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
|
||||
? tx("登录成功,正在跳转到题库...", "Signed in. Redirecting to problem set...")
|
||||
: `${tx("操作失败:", "Action failed: ")}${resp.error}`}
|
||||
? tx("连接成功!正在传送至出生点...", "Connection Established! Teleporting to Spawn Point...")
|
||||
: `${tx("连接失败:", "Connection Failed: ")}${resp.error}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-zinc-500">
|
||||
{tx("登录后 Token 自动保存在浏览器 localStorage,可直接前往", "Token is stored in browser localStorage after sign-in. You can go to")}
|
||||
<Link className="mx-1 underline" href="/problems">
|
||||
{tx("题库", "Problems")}
|
||||
<p className="mt-4 text-xs text-[color:var(--mc-stone-dark)]">
|
||||
{tx("令牌将保存在客户端存储中,可直接前往", "Token stored in client. Warp to")}
|
||||
<Link className="mx-1 underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/problems">
|
||||
{tx("任务板", "Quest Board")}
|
||||
</Link>
|
||||
{tx("与", "and")}
|
||||
<Link className="mx-1 underline" href="/me">
|
||||
{tx("我的", "My Account")}
|
||||
{tx("与", "or")}
|
||||
<Link className="mx-1 underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/me">
|
||||
{tx("角色面板", "Character Sheet")}
|
||||
</Link>
|
||||
{tx("页面。", ".")}
|
||||
{tx("。", ".")}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
@@ -16,6 +17,8 @@ type Contest = {
|
||||
|
||||
export default function ContestsPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const [items, setItems] = useState<Contest[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -37,11 +40,18 @@ export default function ContestsPage() {
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("模拟竞赛", "Contests")}
|
||||
<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-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||
{isMc ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>⚔️</span>
|
||||
{tx("突袭公告板", "Raid Board")}
|
||||
</span>
|
||||
) : (
|
||||
tx("模拟竞赛", "Contests")
|
||||
)}
|
||||
</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>}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
@@ -49,13 +59,35 @@ export default function ContestsPage() {
|
||||
<Link
|
||||
key={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>
|
||||
<p className="mt-1 text-xs text-zinc-500">{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p>
|
||||
<p className="text-xs text-zinc-500">{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<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>
|
||||
))}
|
||||
{!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>
|
||||
</main>
|
||||
);
|
||||
|
||||
二进制文件未显示。
|
之前 宽度: | 高度: | 大小: 25 KiB 之后 宽度: | 高度: | 大小: 19 KiB |
@@ -45,9 +45,47 @@ body {
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* ── Force clean white‑background "default" style for printing ── */
|
||||
|
||||
:root {
|
||||
--background: #fff !important;
|
||||
--foreground: #171717 !important;
|
||||
--surface: #fff !important;
|
||||
--surface-soft: #f4f4f5 !important;
|
||||
--border: #d4d4d8 !important;
|
||||
/* Override Minecraft‑specific variables */
|
||||
--mc-obsidian: transparent !important;
|
||||
--mc-stone: #555 !important;
|
||||
--mc-stone-dark: #333 !important;
|
||||
--mc-plank: #f4f4f5 !important;
|
||||
--mc-plank-light: #333 !important;
|
||||
--mc-diamond: #111 !important;
|
||||
--mc-gold: #111 !important;
|
||||
--mc-red: #dc2626 !important;
|
||||
--mc-green: #16a34a !important;
|
||||
--mc-grass-top: #16a34a !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
font-family: Arial, Helvetica, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Reset all Minecraft fonts back to default for print */
|
||||
* {
|
||||
font-family: inherit !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: Arial, Helvetica, sans-serif !important;
|
||||
color: #111 !important;
|
||||
}
|
||||
|
||||
.print-hidden {
|
||||
@@ -58,6 +96,13 @@ body {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Hide nav, mobile tab bar, XP bar, and other chrome */
|
||||
header,
|
||||
.mobile-tab-bar,
|
||||
.xp-bar-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.problem-detail-grid {
|
||||
display: block !important;
|
||||
}
|
||||
@@ -73,4 +118,47 @@ body {
|
||||
background: #f4f4f5 !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Reset Minecraft‑styled buttons, borders, and shadows */
|
||||
.mc-btn,
|
||||
[class*="mc-btn"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Remove all themed backgrounds / borders / shadows */
|
||||
main,
|
||||
section,
|
||||
div {
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure tables and links are readable */
|
||||
a {
|
||||
color: #111 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
border-color: #d4d4d8 !important;
|
||||
color: #111 !important;
|
||||
}
|
||||
|
||||
/* Override markdown rendering backgrounds */
|
||||
.problem-markdown {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.problem-markdown pre {
|
||||
background: #f4f4f5 !important;
|
||||
color: #111 !important;
|
||||
border: 1px solid #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.problem-markdown code {
|
||||
background: #f4f4f5 !important;
|
||||
color: #111 !important;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CSP Online Learning & Contest Platform",
|
||||
description: "Problems, wrong-book review, contests, knowledge base, and C++ runner.",
|
||||
description: "Quests, Cursed Tome review, Raids, Knowledge Base, and C++ runner.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
type Row = {
|
||||
user_id: number;
|
||||
@@ -14,6 +15,8 @@ type Row = {
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const [items, setItems] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -34,22 +37,52 @@ export default function LeaderboardPage() {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const getRankColor = (index: number) => {
|
||||
if (!isMc) return "";
|
||||
switch (index) {
|
||||
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; // Gold
|
||||
case 1: return "text-zinc-300"; // Iron
|
||||
case 2: return "text-orange-700"; // Copper
|
||||
default: return "text-zinc-400";
|
||||
}
|
||||
};
|
||||
|
||||
const getRankIcon = (index: number) => {
|
||||
if (!isMc) return `#${index + 1}`;
|
||||
switch (index) {
|
||||
case 0: return "🏆";
|
||||
case 1: return "🥈";
|
||||
case 2: return "🥉";
|
||||
default: return `#${index + 1}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("全站排行榜", "Global Leaderboard")}
|
||||
<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-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||
{isMc ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>🏰</span>
|
||||
{tx("名人堂", "Hall of Fame")}
|
||||
</span>
|
||||
) : (
|
||||
tx("全站排行榜", "Global Leaderboard")
|
||||
)}
|
||||
</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>}
|
||||
|
||||
<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">
|
||||
{items.map((row, i) => (
|
||||
<article key={row.user_id} className="space-y-1 p-3 text-sm">
|
||||
<p className="font-medium">
|
||||
#{i + 1} · {row.username}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">Rating: {row.rating}</p>
|
||||
<article key={row.user_id} className={`space-y-1 p-3 text-sm ${isMc ? "border-zinc-700" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={`font-medium ${getRankColor(i)}`}>
|
||||
<span className="mr-2 text-lg">{getRankIcon(i)}</span>
|
||||
{row.username}
|
||||
</p>
|
||||
<span className="text-[color:var(--mc-emerald)] font-bold">{row.rating}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{tx("注册时间:", "Registered: ")}
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
@@ -58,28 +91,28 @@ export default function LeaderboardPage() {
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="px-3 py-5 text-center text-sm text-zinc-500">
|
||||
{tx("暂无排行数据", "No ranking data yet")}
|
||||
{tx("暂无数据", "No legends yet")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<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>
|
||||
<th className="px-3 py-2">{tx("排名", "Rank")}</th>
|
||||
<th className="px-3 py-2">{tx("用户", "User")}</th>
|
||||
<th className="px-3 py-2">Rating</th>
|
||||
<th className="px-3 py-2">{tx("注册时间", "Registered At")}</th>
|
||||
<th className="px-3 py-2 text-left">{tx("排名", "Rank")}</th>
|
||||
<th className="px-3 py-2 text-left">{tx("用户", "User")}</th>
|
||||
<th className="px-3 py-2 text-left">Rating</th>
|
||||
<th className="px-3 py-2 text-left">{tx("注册时间", "Registered At")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className={isMc ? "divide-y divide-zinc-700" : ""}>
|
||||
{items.map((row, i) => (
|
||||
<tr key={row.user_id} className="border-t">
|
||||
<td className="px-3 py-2">{i + 1}</td>
|
||||
<td className="px-3 py-2">{row.username}</td>
|
||||
<td className="px-3 py-2">{row.rating}</td>
|
||||
<td className="px-3 py-2">
|
||||
<tr key={row.user_id} className={isMc ? "hover:bg-white/5 transition-colors" : "border-t"}>
|
||||
<td className={`px-3 py-2 font-bold ${getRankColor(i)}`}>{getRankIcon(i)}</td>
|
||||
<td className={`px-3 py-2 font-medium ${getRankColor(i)}`}>{row.username}</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-emerald)]">{row.rating}</td>
|
||||
<td className="px-3 py-2 text-zinc-500">
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,7 +120,7 @@ export default function LeaderboardPage() {
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
|
||||
{tx("暂无排行数据", "No ranking data yet")}
|
||||
{tx("暂无数据", "No legends yet")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,10 @@ type Me = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
// Use a distinct style for inputs/selects
|
||||
const inputClass = "w-full bg-[color:var(--mc-surface)] text-[color:var(--mc-plank-light)] border-2 border-[color:var(--mc-stone-dark)] px-3 py-2 text-base focus:border-[color:var(--mc-gold)] focus:outline-none transition-colors font-minecraft";
|
||||
const labelClass = "text-sm text-[color:var(--mc-stone)] mb-1 block";
|
||||
|
||||
type RedeemItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -62,12 +66,21 @@ function fmtTs(v: number | null | undefined): string {
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function resolveRank(rating: number): { label: string; color: string; icon: string } {
|
||||
if (rating >= 2000) return { label: "Netherite", color: "text-[color:var(--mc-red)]", icon: "🔥" };
|
||||
if (rating >= 1500) return { label: "Diamond", color: "text-[color:var(--mc-diamond)]", icon: "💎" };
|
||||
if (rating >= 1200) return { label: "Iron", color: "text-zinc-200", icon: "⚔️" };
|
||||
if (rating >= 1000) return { label: "Stone", color: "text-[color:var(--mc-stone)]", icon: "🪨" };
|
||||
return { label: "Wood", color: "text-[color:var(--mc-wood)]", icon: "🪵" };
|
||||
}
|
||||
|
||||
export default function MePage() {
|
||||
const { isZh, tx } = useI18nText();
|
||||
const [token, setToken] = useState("");
|
||||
const [profile, setProfile] = useState<Me | null>(null);
|
||||
const [items, setItems] = useState<RedeemItem[]>([]);
|
||||
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
||||
const [historyItems, setHistoryItems] = useState<RatingHistoryItem[]>([]);
|
||||
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
||||
const [dailyDayKey, setDailyDayKey] = useState("");
|
||||
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
||||
@@ -99,34 +112,17 @@ export default function MePage() {
|
||||
if (isZh) return task.title;
|
||||
if (task.code === "login_checkin") return "Daily Sign-in";
|
||||
if (task.code === "daily_submit") return "Daily Submission";
|
||||
if (task.code === "first_ac") return "Solve One Problem";
|
||||
if (task.code === "code_quality") return "Code Quality";
|
||||
if (task.code === "first_ac") return "First Blood";
|
||||
if (task.code === "code_quality") return "Craftsman";
|
||||
return task.title;
|
||||
};
|
||||
|
||||
const taskDesc = (task: DailyTaskItem): string => {
|
||||
if (isZh) return task.description;
|
||||
if (task.code === "login_checkin") return "Sign in once today to get 1 point.";
|
||||
if (task.code === "daily_submit") return "Submit once today to get 1 point.";
|
||||
if (task.code === "first_ac") return "Get AC once today to get 1 point.";
|
||||
if (task.code === "code_quality") return "Submit code longer than 10 lines once today to get 1 point.";
|
||||
return task.description;
|
||||
};
|
||||
|
||||
const itemName = (name: string): string => {
|
||||
if (isZh) return name;
|
||||
if (name === "私人玩游戏时间") return "Private Game Time";
|
||||
if (name === "私人玩游戏时间") return "Game Time pass";
|
||||
return name;
|
||||
};
|
||||
|
||||
const itemDesc = (text: string): string => {
|
||||
if (isZh) return text;
|
||||
if (text === "全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。") {
|
||||
return "Global redeem item: holiday 1 hour = 5 rating; study day/non-holiday 1 hour = 25 rating.";
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -136,15 +132,17 @@ export default function MePage() {
|
||||
setToken(tk);
|
||||
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
|
||||
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
|
||||
const [me, redeemItems, redeemRecords, daily, history] = await Promise.all([
|
||||
apiFetch<Me>("/api/v1/me", {}, tk),
|
||||
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
||||
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
||||
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
||||
listRatingHistory(50),
|
||||
]);
|
||||
setProfile(me);
|
||||
setItems(redeemItems ?? []);
|
||||
setRecords(redeemRecords ?? []);
|
||||
setHistoryItems(history ?? []);
|
||||
setDailyTasks(daily?.tasks ?? []);
|
||||
setDailyDayKey(daily?.day_key ?? "");
|
||||
setDailyTotalReward(daily?.total_reward ?? 0);
|
||||
@@ -171,9 +169,9 @@ export default function MePage() {
|
||||
setMsg("");
|
||||
try {
|
||||
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
if (!selectedItemId) throw new Error(tx("请选择兑换物品", "Please select a redeem item"));
|
||||
if (!selectedItemId) throw new Error(tx("请选择交易物品", "Select trade item"));
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||
throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0"));
|
||||
throw new Error(tx("数量必须大于 0", "Amount > 0"));
|
||||
}
|
||||
|
||||
const created = await apiFetch<RedeemCreateResp>(
|
||||
@@ -192,12 +190,8 @@ export default function MePage() {
|
||||
|
||||
setMsg(
|
||||
isZh
|
||||
? `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
|
||||
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
|
||||
}。`
|
||||
: `Redeemed successfully: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} rating${
|
||||
typeof created.rating_after === "number" ? `, current rating ${created.rating_after}` : ""
|
||||
}.`
|
||||
? `交易成功:${created.item_name} × ${created.quantity},花费 ${created.total_cost} 绿宝石。`
|
||||
: `Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds.`
|
||||
);
|
||||
setNote("");
|
||||
await loadAll();
|
||||
@@ -208,176 +202,186 @@ export default function MePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const rank = resolveRank(profile?.rating ?? 0);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("我的信息与积分兑换", "My Profile & Redeem")}
|
||||
<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-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
|
||||
</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</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-[color:var(--mc-red)]">{error}</p>}
|
||||
{msg && <p className="mt-3 text-sm text-[color:var(--mc-green)]">{msg}</p>}
|
||||
|
||||
{profile && (
|
||||
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<PixelAvatar
|
||||
seed={`${profile.username}-${profile.id}`}
|
||||
size={72}
|
||||
className="border-zinc-700"
|
||||
alt={`${profile.username} avatar`}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p>ID: {profile.id}</p>
|
||||
<p>{tx("用户名", "Username")}: {profile.username}</p>
|
||||
<p>Rating: {profile.rating}</p>
|
||||
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{tx("默认像素头像按账号随机生成,可作为主题角色形象。", "Default pixel avatar is randomly generated by account as your theme character.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_2fr]">
|
||||
<section className="rounded-none border-[3px] border-black bg-[color:var(--mc-plank)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 bg-black opacity-20 translate-x-1 translate-y-1 rounded-none"></div>
|
||||
<div className="border-[4px] border-white p-1 bg-[color:var(--mc-stone-dark)]">
|
||||
<PixelAvatar
|
||||
seed={`${profile.username}-${profile.id}`}
|
||||
size={100}
|
||||
className="border-none"
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold">{tx("每日任务", "Daily Tasks")}</h2>
|
||||
<p className="text-xs text-zinc-600">
|
||||
{dailyDayKey ? `${dailyDayKey} · ` : ""}
|
||||
{tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 divide-y">
|
||||
{dailyTasks.map((task) => (
|
||||
<article key={task.code} className="py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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")}
|
||||
<h2 className="text-xl font-bold text-black mc-text-shadow-sm mb-1">{profile.username}</h2>
|
||||
<div className={`text-sm font-bold ${rank.color} mb-2`}>
|
||||
{rank.icon} {rank.label} Rank
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-black h-4 border border-white relative mb-1">
|
||||
<div
|
||||
className="h-full bg-[color:var(--mc-green)]"
|
||||
style={{ width: `${Math.min(100, (profile.rating % 100))}%` }}
|
||||
></div>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-bold shadow-black drop-shadow-md">
|
||||
Level {Math.floor(profile.rating / 100)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">{taskDesc(task)}</p>
|
||||
{task.completed && (
|
||||
<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>
|
||||
<p className="text-xs text-[color:var(--mc-stone-dark)]">UID: {profile.id}</p>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-base font-semibold">{tx("积分兑换物品", "Redeem Items")}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx(
|
||||
"示例规则:私人玩游戏时间(假期 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 className="mt-4 space-y-2 border-t border-black/20 pt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-800">{tx("绿宝石 (Rating)", "Emeralds (Rating)")}</span>
|
||||
<span className="font-bold text-[color:var(--mc-green)] text-shadow-sm">{profile.rating}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">{itemDesc(item.description) || "-"}</p>
|
||||
<p className="mt-1 text-xs text-zinc-700">
|
||||
{tx("假期", "Holiday")}: {item.holiday_cost} / {item.unit_label}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-700">
|
||||
{tx("学习日", "Study Day")}: {item.studyday_cost} / {item.unit_label}
|
||||
</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="flex justify-between text-sm">
|
||||
<span className="text-zinc-800">{tx("加入时间", "Joined")}</span>
|
||||
<span className="text-zinc-600">{new Date(profile.created_at * 1000).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<h3 className="text-sm font-medium">{tx("兑换表单", "Redeem Form")}</h3>
|
||||
<div className="mt-2 grid gap-2 md:grid-cols-2">
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={selectedItemId}
|
||||
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>{tx("请选择兑换物品", "Please select an item")}</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{itemName(item.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Daily Tasks */}
|
||||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4 relative">
|
||||
<h2 className="text-xl text-[color:var(--mc-dirt)] mb-4 flex justify-between items-center font-minecraft">
|
||||
<span>每日悬赏任务</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP</span>
|
||||
</h2>
|
||||
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={dayType}
|
||||
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
||||
>
|
||||
<option value="holiday">{tx("假期时间(按假期单价)", "Holiday time (holiday price)")}</option>
|
||||
<option value="studyday">{tx("学习日/非节假日(按学习日单价)", "Study day/non-holiday (study-day price)")}</option>
|
||||
</select>
|
||||
<div className="space-y-3">
|
||||
{dailyTasks.map((task, idx) => (
|
||||
<div key={idx} className="bg-[color:var(--mc-surface-soft)] p-3 border-2 border-[color:var(--mc-stone-dark)] relative group hover:border-[color:var(--mc-stone)] transition-colors">
|
||||
<div className="flex items-start 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' : ''}`}>
|
||||
{task.completed && <span className="text-[color:var(--mc-green)] text-sm">✓</span>}
|
||||
</div>
|
||||
<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
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||
placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 text-black">
|
||||
<select
|
||||
className="flex-1 rounded-none border-2 border-black bg-[color:var(--surface)] px-2 py-1 text-base font-bold"
|
||||
value={selectedItemId}
|
||||
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
||||
>
|
||||
<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
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={tx("备注(可选)", "Note (optional)")}
|
||||
/>
|
||||
{selectedItem && (
|
||||
<div className="bg-[color:var(--mc-stone)]/20 p-3 border border-[color:var(--mc-stone)]/30 rounded-none text-base text-[color:var(--mc-obsidian)]">
|
||||
<p>{selectedItem.description}</p>
|
||||
<p className="mt-1 text-[color:var(--mc-wood-dark)]">
|
||||
单价: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
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>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-zinc-600">
|
||||
{tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")};{tx("预计扣分", "Estimated cost")}: {totalCost}
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
onClick={() => void redeem()}
|
||||
disabled={redeemLoading || !selectedItemId}
|
||||
>
|
||||
{redeemLoading ? tx("兑换中...", "Redeeming...") : tx("确认兑换", "Confirm Redeem")}
|
||||
</button>
|
||||
{/* Rating History Section */}
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<h2 className="text-base font-bold text-black mb-2">{tx("积分变动记录", "Rating History")}</h2>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{historyItems.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||
<span>
|
||||
<span className={`font-bold ${item.change > 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}>
|
||||
{item.change > 0 ? `+${item.change}` : item.change}
|
||||
</span>
|
||||
<span className="ml-2">{item.note}</span>
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
{new Date(item.created_at * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && historyItems.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">{tx("暂无记录。", "No history.")}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2>
|
||||
{/* Trades Section */}
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<h2 className="text-base font-bold text-black">{tx("交易记录", "Trade History")}</h2>
|
||||
<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()}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -385,21 +389,19 @@ export default function MePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 divide-y">
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{records.map((row) => (
|
||||
<article key={row.id} className="py-2 text-sm">
|
||||
<p>
|
||||
#{row.id} · {itemName(row.item_name)} · {row.quantity} {tx("小时", "hour")} ·{" "}
|
||||
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">
|
||||
{tx("单价", "Unit cost")} {row.unit_cost},{tx("总扣分", "Total cost")} {row.total_cost} · {fmtTs(row.created_at)}
|
||||
</p>
|
||||
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
|
||||
</article>
|
||||
<div key={row.id} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||
<span>
|
||||
{itemName(row.item_name)} × {row.quantity}
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
-{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!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>
|
||||
</section>
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
@@ -43,19 +43,19 @@ type Preset = {
|
||||
const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "csp-beginner-default",
|
||||
labelZh: "CSP J/S 入门默认",
|
||||
labelEn: "CSP J/S Beginner Default",
|
||||
labelZh: "CSP J/S 入门预设",
|
||||
labelEn: "CSP J/S Beginner Preset",
|
||||
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
|
||||
},
|
||||
{
|
||||
key: "csp-j",
|
||||
labelZh: "仅 CSP-J / 普及",
|
||||
labelZh: "仅 CSP-J / 普及组",
|
||||
labelEn: "CSP-J / Junior Only",
|
||||
tags: ["csp-j", "noip-junior"],
|
||||
},
|
||||
{
|
||||
key: "csp-s",
|
||||
labelZh: "仅 CSP-S / 提高",
|
||||
labelZh: "仅 CSP-S / 提高组",
|
||||
labelEn: "CSP-S / Senior Only",
|
||||
tags: ["csp-s", "noip-senior"],
|
||||
},
|
||||
@@ -67,14 +67,14 @@ const PRESETS: Preset[] = [
|
||||
},
|
||||
{
|
||||
key: "luogu-all",
|
||||
labelZh: "洛谷导入全部",
|
||||
labelZh: "洛谷全站导入",
|
||||
labelEn: "All Luogu Imports",
|
||||
sourcePrefix: "luogu:",
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
labelZh: "全站全部来源",
|
||||
labelZh: "全部来源",
|
||||
labelEn: "All Sources",
|
||||
tags: [],
|
||||
},
|
||||
@@ -83,39 +83,39 @@ const PRESETS: Preset[] = [
|
||||
const QUICK_CARDS = [
|
||||
{
|
||||
presetKey: "csp-j",
|
||||
titleZh: "CSP-J 真题",
|
||||
titleEn: "CSP-J Problems",
|
||||
descZh: "普及组入门训练",
|
||||
descEn: "Junior training set",
|
||||
titleZh: "CSP-J 试炼",
|
||||
titleEn: "CSP-J Trials",
|
||||
descZh: "普及组入门任务",
|
||||
descEn: "Junior Tier Quests",
|
||||
},
|
||||
{
|
||||
presetKey: "csp-s",
|
||||
titleZh: "CSP-S 真题",
|
||||
titleEn: "CSP-S Problems",
|
||||
descZh: "提高组进阶训练",
|
||||
descEn: "Senior advanced set",
|
||||
titleZh: "CSP-S 挑战",
|
||||
titleEn: "CSP-S Challenges",
|
||||
descZh: "提高组进阶任务",
|
||||
descEn: "Senior Tier Quests",
|
||||
},
|
||||
{
|
||||
presetKey: "noip-junior",
|
||||
titleZh: "NOIP 入门",
|
||||
titleEn: "NOIP Junior",
|
||||
descZh: "基础算法与思维",
|
||||
descEn: "Basic algorithm thinking",
|
||||
titleZh: "NOIP 基础",
|
||||
titleEn: "NOIP Basics",
|
||||
descZh: "算法与思维",
|
||||
descEn: "Algorithm & Logic",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "0", labelZh: "全部难度", labelEn: "All Levels" },
|
||||
{ value: "1", labelZh: "1", labelEn: "1" },
|
||||
{ value: "2", labelZh: "2", labelEn: "2" },
|
||||
{ value: "3", labelZh: "3", labelEn: "3" },
|
||||
{ value: "4", labelZh: "4", labelEn: "4" },
|
||||
{ value: "5", labelZh: "5", labelEn: "5" },
|
||||
{ value: "6", labelZh: "6", labelEn: "6" },
|
||||
{ value: "7", labelZh: "7", labelEn: "7" },
|
||||
{ value: "8", labelZh: "8", labelEn: "8" },
|
||||
{ value: "9", labelZh: "9", labelEn: "9" },
|
||||
{ value: "10", labelZh: "10", labelEn: "10" },
|
||||
{ value: "0", labelZh: "全部难度", labelEn: "All Tiers" },
|
||||
{ value: "1", labelZh: "1 - 木剑", labelEn: "1 - Wood" },
|
||||
{ value: "2", labelZh: "2 - 木剑", labelEn: "2 - Wood" },
|
||||
{ value: "3", labelZh: "3 - 石剑", labelEn: "3 - Stone" },
|
||||
{ value: "4", labelZh: "4 - 石剑", labelEn: "4 - Stone" },
|
||||
{ value: "5", labelZh: "5 - 铁剑", labelEn: "5 - Iron" },
|
||||
{ value: "6", labelZh: "6 - 铁剑", labelEn: "6 - Iron" },
|
||||
{ value: "7", labelZh: "7 - 钻石", labelEn: "7 - Diamond" },
|
||||
{ value: "8", labelZh: "8 - 钻石", labelEn: "8 - Diamond" },
|
||||
{ value: "9", labelZh: "9 - 下界合金", labelEn: "9 - Netherite" },
|
||||
{ value: "10", labelZh: "10 - 下界合金", labelEn: "10 - Netherite" },
|
||||
] as const;
|
||||
|
||||
function parseProfile(raw: string): ProblemProfile | null {
|
||||
@@ -129,10 +129,19 @@ function parseProfile(raw: string): ProblemProfile | null {
|
||||
}
|
||||
|
||||
function difficultyClass(diff: number): string {
|
||||
if (diff <= 2) return "text-emerald-600";
|
||||
if (diff <= 4) return "text-blue-600";
|
||||
if (diff <= 6) return "text-orange-600";
|
||||
return "text-rose-600";
|
||||
if (diff <= 2) return "text-[color:var(--mc-wood)]";
|
||||
if (diff <= 4) return "text-[color:var(--mc-stone-dark)]";
|
||||
if (diff <= 6) return "text-zinc-100"; // Iron-ish
|
||||
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 {
|
||||
@@ -232,23 +241,23 @@ export default function ProblemsPage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("题库(CSP J/S 入门)", "Problem Set (CSP J/S Beginner)")}
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("任务布告栏", "Quest Board")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone)]">
|
||||
{tx(
|
||||
"参考洛谷题库列表交互,默认聚焦 CSP-J / CSP-S / NOIP 入门训练。",
|
||||
"Interaction style is inspired by Luogu problem list. Default focus: CSP-J / CSP-S / NOIP junior training."
|
||||
"接受任务,赚取 XP,提升等级!",
|
||||
"Accept Quests, Earn XP, Level Up!"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<Link className="w-full rounded border px-3 py-1 text-center hover:bg-zinc-100 sm:w-auto" href="/backend-logs">
|
||||
{tx("查看后台日志", "View Backend Logs")}
|
||||
<p className="text-[color:var(--mc-gold)]">{tx("总任务数: ", "Total Quests: ")} {totalCount}</p>
|
||||
<Link className="mc-btn w-full text-center sm:w-auto" href="/backend-logs">
|
||||
{tx("服务器日志", "Server Logs")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,15 +269,14 @@ export default function ProblemsPage() {
|
||||
<button
|
||||
key={card.presetKey}
|
||||
type="button"
|
||||
className={`rounded-xl border px-4 py-3 text-left transition ${
|
||||
active
|
||||
? "border-zinc-900 bg-zinc-900 text-white"
|
||||
: "bg-white text-zinc-900 hover:border-zinc-400"
|
||||
}`}
|
||||
className={`rounded-xl border px-4 py-3 text-left transition ${active
|
||||
? "bg-[color:var(--mc-grass-dark)] text-white"
|
||||
: "bg-[color:var(--mc-plank)] text-black hover:bg-[color:var(--mc-plank-light)]"
|
||||
}`}
|
||||
onClick={() => selectPreset(card.presetKey)}
|
||||
>
|
||||
<p className="text-base font-semibold">{isZh ? card.titleZh : card.titleEn}</p>
|
||||
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
|
||||
<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-100" : "text-zinc-800"}`}>
|
||||
{isZh ? card.descZh : card.descEn}
|
||||
</p>
|
||||
</button>
|
||||
@@ -276,9 +284,9 @@ export default function ProblemsPage() {
|
||||
})}
|
||||
</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
|
||||
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}
|
||||
onChange={(e) => {
|
||||
selectPreset(e.target.value);
|
||||
@@ -292,8 +300,8 @@ export default function ProblemsPage() {
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm lg:col-span-2"
|
||||
placeholder={tx("搜索题号/标题/题面关键词", "Search id/title/statement keywords")}
|
||||
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("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -302,7 +310,7 @@ export default function ProblemsPage() {
|
||||
/>
|
||||
|
||||
<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}
|
||||
onChange={(e) => {
|
||||
setDifficulty(e.target.value);
|
||||
@@ -311,13 +319,13 @@ export default function ProblemsPage() {
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{tx("难度", "Difficulty")} {isZh ? item.labelZh : item.labelEn}
|
||||
{tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</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}`}
|
||||
onChange={(e) => {
|
||||
const [ob, od] = e.target.value.split(":");
|
||||
@@ -326,16 +334,16 @@ export default function ProblemsPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="id:asc">{tx("题号升序", "ID Asc")}</option>
|
||||
<option value="id:desc">{tx("题号降序", "ID Desc")}</option>
|
||||
<option value="difficulty:asc">{tx("难度升序", "Difficulty Asc")}</option>
|
||||
<option value="difficulty:desc">{tx("难度降序", "Difficulty Desc")}</option>
|
||||
<option value="created_at:desc">{tx("最新导入", "Newest Imported")}</option>
|
||||
<option value="id:asc">{tx("编号升序", "ID Asc")}</option>
|
||||
<option value="id:desc">{tx("编号降序", "ID Desc")}</option>
|
||||
<option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
|
||||
<option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
|
||||
<option value="created_at:desc">{tx("最新发布", "Newest")}</option>
|
||||
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||
</select>
|
||||
|
||||
<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}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -343,29 +351,28 @@ export default function ProblemsPage() {
|
||||
</button>
|
||||
</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">
|
||||
<div className="divide-y md:hidden">
|
||||
<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 divide-black md:hidden">
|
||||
{rows.map(({ problem, profile }) => {
|
||||
const pid = resolvePid(problem, profile);
|
||||
const tags = resolveTags(profile);
|
||||
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">
|
||||
<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}
|
||||
</Link>
|
||||
<span className={`shrink-0 text-sm font-semibold ${difficultyClass(problem.difficulty)}`}>
|
||||
{tx("难度", "Difficulty")} {problem.difficulty}
|
||||
<span className={`shrink-0 text-sm font-bold ${difficultyClass(problem.difficulty)}`}>
|
||||
{difficultyIcon(problem.difficulty)} T{problem.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">{tx("通过/提交:", "Accepted/Submissions: ")}{resolvePassRate(profile)}</p>
|
||||
<p className="text-xs text-zinc-500 break-all">{tx("来源:", "Source: ")}{problem.source || "-"}</p>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(profile)}</p>
|
||||
<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) => (
|
||||
<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}
|
||||
</span>
|
||||
))}
|
||||
@@ -374,23 +381,23 @@ export default function ProblemsPage() {
|
||||
);
|
||||
})}
|
||||
{!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(
|
||||
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
|
||||
"No problems under current filters. Switch preset or run import first."
|
||||
"没有找到任务。请尝试其他频道或刷新地图。",
|
||||
"No quests found. Try different channel or reload map."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-zinc-100 text-left text-zinc-700">
|
||||
<table className="min-w-full text-sm text-zinc-200">
|
||||
<thead className="bg-[color:var(--mc-wood-dark)] text-left text-white border-b-2 border-black">
|
||||
<tr>
|
||||
<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("通过/提交", "Accepted/Submissions")}</th>
|
||||
<th className="px-3 py-2">{tx("难度", "Difficulty")}</th>
|
||||
<th className="px-3 py-2">{tx("编号", "ID")}</th>
|
||||
<th className="px-3 py-2">{tx("任务标题", "Quest Title")}</th>
|
||||
<th className="px-3 py-2">{tx("完成率", "Clear Rate")}</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("来源", "Source")}</th>
|
||||
</tr>
|
||||
@@ -400,37 +407,37 @@ export default function ProblemsPage() {
|
||||
const pid = resolvePid(problem, profile);
|
||||
const tags = resolveTags(profile);
|
||||
return (
|
||||
<tr key={problem.id} className="border-t hover:bg-zinc-50">
|
||||
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
|
||||
<tr key={problem.id} className="border-b border-black hover:bg-[color:var(--surface-soft)] transition-colors">
|
||||
<td className="px-3 py-2 font-bold text-[color:var(--mc-diamond)]">{pid}</td>
|
||||
<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}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
|
||||
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
|
||||
{problem.difficulty}
|
||||
<td className="px-3 py-2 text-[color:var(--mc-stone)]">{resolvePassRate(profile)}</td>
|
||||
<td className={`px-3 py-2 font-bold ${difficultyClass(problem.difficulty)}`}>
|
||||
{difficultyIcon(problem.difficulty)} {problem.difficulty}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<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) => (
|
||||
<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}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{!loading && rows.length === 0 && (
|
||||
<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(
|
||||
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
|
||||
"No problems under current filters. Switch preset or run import first."
|
||||
"没有找到任务。请尝试其他频道或刷新地图。",
|
||||
"No quests found. Try different channel or reload map."
|
||||
)}
|
||||
</td>
|
||||
</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="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
className="mc-btn"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page <= 1}
|
||||
>
|
||||
{tx("上一页", "Prev")}
|
||||
</button>
|
||||
<span>
|
||||
<span className="text-[color:var(--mc-diamond)] font-bold">
|
||||
{isZh ? `第 ${page} / ${totalPages} 页` : `Page ${page} / ${totalPages}`}
|
||||
</span>
|
||||
<button
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
className="mc-btn"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={loading || page >= totalPages}
|
||||
>
|
||||
@@ -461,10 +468,10 @@ export default function ProblemsPage() {
|
||||
</button>
|
||||
</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>
|
||||
<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}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
type Submission = {
|
||||
id: number;
|
||||
@@ -22,6 +23,8 @@ type ListResp = { items: Submission[]; page: number; page_size: number };
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const [userId, setUserId] = useState("");
|
||||
const [problemId, setProblemId] = useState("");
|
||||
const [contestId, setContestId] = useState("");
|
||||
@@ -35,9 +38,28 @@ export default function SubmissionsPage() {
|
||||
};
|
||||
|
||||
const ratingDeltaClass = (delta: number) => {
|
||||
if (delta > 0) return "text-emerald-700";
|
||||
if (delta < 0) return "text-red-700";
|
||||
return "text-zinc-600";
|
||||
if (delta > 0) return isMc ? "text-[color:var(--mc-green)]" : "text-emerald-700";
|
||||
if (delta < 0) return isMc ? "text-[color:var(--mc-red)]" : "text-red-700";
|
||||
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 () => {
|
||||
@@ -63,103 +85,133 @@ export default function SubmissionsPage() {
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("提交记录", "Submissions")}
|
||||
<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-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||
{isMc ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>📜</span>
|
||||
{tx("施法记录", "Spell Cast Log")}
|
||||
</span>
|
||||
) : (
|
||||
tx("提交记录", "Submissions")
|
||||
)}
|
||||
</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
|
||||
className="rounded border px-3 py-2"
|
||||
placeholder="user_id"
|
||||
className={`rounded border px-3 py-2 ${isMc
|
||||
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||
: ""}`}
|
||||
placeholder={isMc ? tx("冒险者 ID", "Adventurer ID") : "user_id"}
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border px-3 py-2"
|
||||
placeholder="problem_id"
|
||||
className={`rounded border px-3 py-2 ${isMc
|
||||
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||
: ""}`}
|
||||
placeholder={isMc ? tx("任务 ID", "Quest ID") : "problem_id"}
|
||||
value={problemId}
|
||||
onChange={(e) => setProblemId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border px-3 py-2"
|
||||
placeholder="contest_id"
|
||||
className={`rounded border px-3 py-2 ${isMc
|
||||
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||
: ""}`}
|
||||
placeholder={isMc ? tx("突袭 ID", "Raid ID") : "contest_id"}
|
||||
value={contestId}
|
||||
onChange={(e) => setContestId(e.target.value)}
|
||||
/>
|
||||
<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()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? tx("加载中...", "Loading...") : tx("筛选", "Filter")}
|
||||
{loading
|
||||
? tx("搜索中...", "Searching...")
|
||||
: isMc
|
||||
? tx("🔍 搜索记录", "🔍 Search Logs")
|
||||
: tx("筛选", "Filter")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{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">
|
||||
<p className="font-medium">{tx("提交", "Submission")} #{s.id}</p>
|
||||
<span className="text-xs text-zinc-500">{s.status}</span>
|
||||
<p className={`font-medium ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>
|
||||
{isMc ? tx("施法", "Cast") : tx("提交", "Submission")} #{s.id}
|
||||
</p>
|
||||
<span className={`text-xs ${isMc ? "" : "text-zinc-500"}`}>{statusLabel(s.status)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">
|
||||
{tx("用户", "User")} {s.user_id} · {tx("题目", "Problem")} {s.problem_id} · {tx("分数", "Score")} {s.score}
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{isMc ? tx("冒险者", "Player") : tx("用户", "User")} {s.user_id} · {tx("任务", "Quest")} {s.problem_id} · {tx("分数", "Score")} {s.score}
|
||||
</p>
|
||||
<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 className="text-xs text-zinc-600">{tx("耗时", "Time")} {s.time_ms} ms</p>
|
||||
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
||||
{tx("查看详情", "View Detail")}
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("耗时", "Time")} {s.time_ms} ms</p>
|
||||
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||
{isMc ? tx("📜 查看详情", "📜 View Detail") : tx("查看详情", "View Detail")}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
{!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>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<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>
|
||||
<th className="px-3 py-2">ID</th>
|
||||
<th className="px-3 py-2">{tx("用户", "User")}</th>
|
||||
<th className="px-3 py-2">{tx("题目", "Problem")}</th>
|
||||
<th className="px-3 py-2">{isMc ? tx("冒险者", "Player") : tx("用户", "User")}</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("分数", "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("详情", "Detail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-t">
|
||||
<td className="px-3 py-2">{s.id}</td>
|
||||
<tr key={s.id} className={`border-t ${isMc ? "border-zinc-700" : ""}`}>
|
||||
<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.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 ${ratingDeltaClass(s.rating_delta)}`}>
|
||||
{fmtRatingDelta(s.rating_delta)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.time_ms}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
||||
{tx("查看", "View")}
|
||||
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||
{isMc ? tx("📜 查看", "📜 View") : tx("查看", "View")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={8}>
|
||||
{tx("暂无提交记录", "No submissions yet")}
|
||||
<td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={8}>
|
||||
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
type WrongBookItem = {
|
||||
user_id: number;
|
||||
@@ -23,6 +24,8 @@ function fmtTs(v: number): string {
|
||||
|
||||
export default function WrongBookPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const [token, setToken] = useState("");
|
||||
const [items, setItems] = useState<WrongBookItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -73,21 +76,36 @@ export default function WrongBookPage() {
|
||||
};
|
||||
|
||||
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">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("错题本", "Wrong Book")}
|
||||
<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-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||
{isMc ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>📜</span>
|
||||
{tx("诅咒卷轴", "Cursed Scrolls")}
|
||||
</span>
|
||||
) : (
|
||||
tx("错题本", "Wrong Book")
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
{tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
|
||||
<p className={`mt-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{isMc
|
||||
? tx("失败的咒语会自动记录在诅咒卷轴中,复习并重新挑战!", "Failed spells are recorded in your Cursed Scrolls. Review and retry!")
|
||||
: tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<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()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新", "Refresh")}
|
||||
{loading
|
||||
? tx("搜索中...", "Searching...")
|
||||
: isMc
|
||||
? tx("🔍 重新搜索", "🔍 Search Again")
|
||||
: tx("刷新", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -95,46 +113,63 @@ export default function WrongBookPage() {
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{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">
|
||||
<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}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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}`}
|
||||
>
|
||||
{tx("查看题目", "View Problem")}
|
||||
{isMc ? tx("⚔️ 重新挑战", "⚔️ Retry Quest") : tx("查看任务", "View Quest")}
|
||||
</Link>
|
||||
{item.last_submission_id && (
|
||||
<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}`}
|
||||
>
|
||||
{tx("查看最近提交", "View Latest Submission")}
|
||||
{isMc ? tx("📜 查看战报", "📜 View Battle Log") : tx("查看最近提交", "View Latest Submission")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
|
||||
<p className={`mt-1 text-xs ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
|
||||
{isMc ? tx("上次施法:", "Last Cast:") : tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
|
||||
{tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||
<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)}
|
||||
>
|
||||
{tx("移除", "Remove")}
|
||||
{isMc ? tx("🗑️ 移除诅咒", "🗑️ Remove Curse") : tx("移除", "Remove")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
placeholder={isMc ? tx("记录你的笔记...", "Write your notes...") : ""}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setItems((prev) =>
|
||||
@@ -145,19 +180,28 @@ export default function WrongBookPage() {
|
||||
}}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
{tx("保存备注", "Save Note")}
|
||||
{isMc ? tx("💾 保存笔记", "💾 Save Notes") : tx("保存备注", "Save Note")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="rounded-xl border bg-white p-6 text-center text-sm text-zinc-500">
|
||||
{tx(
|
||||
"暂无错题。提交未通过后会自动加入错题本,可点击“查看题目/查看最近提交”快速复盘。",
|
||||
"No wrong-book entries yet. Failed submissions will be added automatically; use “View Problem/View Latest Submission” to review quickly."
|
||||
)}
|
||||
<div className={`rounded-xl border p-6 text-center text-sm ${isMc
|
||||
? "bg-black/20 border-zinc-700 text-zinc-500"
|
||||
: "bg-white text-zinc-500"}`}>
|
||||
{isMc
|
||||
? tx(
|
||||
"🎉 诅咒卷轴为空!你的每一个咒语都精准命中了目标。",
|
||||
"🎉 No cursed scrolls! Every spell you cast hit its mark."
|
||||
)
|
||||
: tx(
|
||||
"暂无错题。提交未通过后会自动加入错题本,可点击「查看题目/查看最近提交」快速复盘。",
|
||||
'No wrong-book entries yet. Failed submissions will be added automatically.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PixelAvatar } from "@/components/pixel-avatar";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { XpBar } from "@/components/xp-bar";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { clearToken, readToken } from "@/lib/auth";
|
||||
import type { ThemeId } from "@/themes/types";
|
||||
@@ -199,9 +200,8 @@ export function AppNav() {
|
||||
<div key={group.key} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
aria-expanded={opened}
|
||||
onClick={() =>
|
||||
setDesktopOpenGroup((prev) => (prev === group.key ? null : group.key))
|
||||
@@ -211,9 +211,8 @@ export function AppNav() {
|
||||
</button>
|
||||
{opened && (
|
||||
<div
|
||||
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]"
|
||||
}`}
|
||||
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.links.map((item) => {
|
||||
const linkActive = isActivePath(pathname, item.href);
|
||||
@@ -221,9 +220,8 @@ export function AppNav() {
|
||||
<button
|
||||
key={item.href}
|
||||
type="button"
|
||||
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${
|
||||
linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDesktopOpenGroup(null);
|
||||
router.push(item.href);
|
||||
@@ -306,9 +304,8 @@ export function AppNav() {
|
||||
<button
|
||||
key={group.key}
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => router.push(group.links[0]?.href ?? "/")}
|
||||
>
|
||||
{group.label}
|
||||
@@ -391,12 +388,19 @@ export function AppNav() {
|
||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<PixelAvatar
|
||||
seed={avatarSeed}
|
||||
size={24}
|
||||
className="border-zinc-700"
|
||||
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
|
||||
/>
|
||||
<>
|
||||
{theme === "minecraft" && (
|
||||
<div className="hidden md:block w-32 mr-2">
|
||||
<XpBar level={5} currentXp={750} nextLevelXp={1000} />
|
||||
</div>
|
||||
)}
|
||||
<PixelAvatar
|
||||
seed={avatarSeed}
|
||||
size={24}
|
||||
className="border-zinc-700"
|
||||
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{hasToken && (
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import remarkMath from "remark-math";
|
||||
|
||||
type Props = {
|
||||
markdown: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function normalizeImageSrc(src: string): string {
|
||||
@@ -18,9 +19,9 @@ function normalizeImageSrc(src: string): string {
|
||||
return src;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ markdown }: Props) {
|
||||
export function MarkdownRenderer({ markdown, className }: Props) {
|
||||
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
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
||||
|
||||
@@ -12,18 +12,22 @@ function isActivePath(pathname: string, href: string): boolean {
|
||||
|
||||
export function MobileTabBar() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useUiPreferences();
|
||||
const { t, theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
|
||||
const tabs = [
|
||||
{ label: t("mobile.tab.problems"), href: "/problems" },
|
||||
{ label: t("mobile.tab.submissions"), href: "/submissions" },
|
||||
{ label: t("mobile.tab.contests"), href: "/contests" },
|
||||
{ label: t("mobile.tab.kb"), href: "/kb" },
|
||||
{ label: t("mobile.tab.me"), href: "/me" },
|
||||
{ label: t("mobile.tab.problems"), href: "/problems", icon: "📜" },
|
||||
{ label: t("mobile.tab.submissions"), href: "/submissions", icon: "⏱️" },
|
||||
{ label: t("mobile.tab.contests"), href: "/contests", icon: "⚔️" },
|
||||
{ label: t("mobile.tab.kb"), href: "/kb", icon: "📘" },
|
||||
{ label: t("mobile.tab.me"), href: "/me", icon: "👤" },
|
||||
] as const;
|
||||
|
||||
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="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
|
||||
{tabs.map((tab) => {
|
||||
@@ -32,13 +36,17 @@ export function MobileTabBar() {
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`rounded-md px-1 py-1.5 text-center text-xs max-[390px]:text-[11px] ${
|
||||
active
|
||||
? "bg-zinc-900 font-semibold text-white"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
className={`flex flex-col items-center justify-center rounded-none px-1 py-1 text-center text-[10px] sm:text-xs ${isMc
|
||||
? active
|
||||
? "bg-[color:var(--mc-diamond)] text-black font-bold border-2 border-black"
|
||||
: "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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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> =
|
||||
| { ok: true; data?: T; [k: string]: unknown }
|
||||
| { ok: false; error?: string; [k: string]: unknown };
|
||||
| { ok: true; data?: T;[k: string]: unknown }
|
||||
| { ok: false; error?: string;[k: string]: unknown };
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
@@ -52,11 +52,9 @@ export async function apiFetch<T>(
|
||||
} catch (retryErr) {
|
||||
throw new Error(
|
||||
uiText(
|
||||
`网络请求失败,请检查后端服务或代理连接(${
|
||||
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
`网络请求失败,请检查后端服务或代理连接(${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
})`,
|
||||
`Network request failed. Please check backend/proxy connectivity (${
|
||||
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
`Network request failed. Please check backend/proxy connectivity (${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
}).`
|
||||
)
|
||||
);
|
||||
@@ -92,3 +90,14 @@ export async function apiFetch<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 { zhMessages } from "@/themes/default/messages/zh";
|
||||
import { enMessages } from "./messages/en";
|
||||
import { zhMessages } from "./messages/zh";
|
||||
import type { ThemeDefinition } from "@/themes/types";
|
||||
|
||||
export const minecraftTheme: ThemeDefinition = {
|
||||
@@ -13,3 +13,4 @@ export const minecraftTheme: ThemeDefinition = {
|
||||
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-family: "PressStart2P";
|
||||
src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2")
|
||||
format("woff2");
|
||||
src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -58,8 +57,18 @@
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.5;
|
||||
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 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -157,36 +166,62 @@
|
||||
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;
|
||||
border: 3px solid #000 !important;
|
||||
border-bottom-width: 7px !important;
|
||||
border-radius: 0 !important;
|
||||
color: #fff !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;
|
||||
line-height: 1.4;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.48);
|
||||
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) {
|
||||
filter: brightness(1.07);
|
||||
transform: translateY(-1px);
|
||||
:root[data-theme="minecraft"] button:not(.mc-reset):hover:not(:disabled),
|
||||
:root[data-theme="minecraft"] .mc-btn:hover:not(:disabled) {
|
||||
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;
|
||||
transform: translateY(3px);
|
||||
transform: translateY(4px);
|
||||
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] button:disabled {
|
||||
opacity: 0.68;
|
||||
filter: saturate(0.28);
|
||||
:root[data-theme="minecraft"] button:not(.mc-reset):disabled,
|
||||
:root[data-theme="minecraft"] .mc-btn:disabled {
|
||||
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"] textarea,
|
||||
:root[data-theme="minecraft"] select {
|
||||
@@ -290,3 +325,42 @@
|
||||
:root[data-theme="minecraft"] .problem-markdown-compact th {
|
||||
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 UiLanguage = "en" | "zh";
|
||||
|
||||
export const DEFAULT_THEME: ThemeId = "default";
|
||||
export const DEFAULT_THEME: ThemeId = "minecraft";
|
||||
export const DEFAULT_LANGUAGE: UiLanguage = "zh";
|
||||
|
||||
export type ThemeMessages = Record<string, string>;
|
||||
|
||||
24
start_adventure.sh
可执行文件
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
|
||||
在新工单中引用
屏蔽一个用户